Fast and Robust Closest Point on a Mesh in C++
Find the closest point on a triangle mesh, the face it lies on, and the distance, for one point or a hundred thousand, in C++. Header-only, C++17, spatial tree built in 24.9 ms on 1M polygons.
A closest-point query takes a point in space and returns the nearest point on a mesh surface, the face it lies on, and the distance. It is the base of projection, snapping, distance checks, and scan-to-CAD comparison. trueform is header-only C++17. You build a spatial tree once, tag it onto the mesh, and query. This page covers single queries, batch queries over many points, radius-limited search, k-nearest faces, posed meshes, and the closest pair between two meshes.

Install
Header-only. It needs C++17 and oneTBB. Add it to your project via CMake FetchContent:
FetchContent_Declare(
trueform
GIT_REPOSITORY https://github.com/polydera/trueform
)
FetchContent_MakeAvailable(trueform)
target_link_libraries(my_app PRIVATE tf::trueform)
Also available via conan, nuget, and pip. See the installation guide for all options.
Loading the mesh
#include <trueform/trueform.hpp>
auto mesh_buffer = tf::read_stl("part.stl");
auto mesh = mesh_buffer.polygons();
tf::read_stl returns a tf::polygons_buffer that owns the data. .polygons() is a view on it. tf::read_obj works the same way. If your faces and points already live in your own arrays, wrap them with tf::make_polygons(tf::make_faces<3>(faces), tf::make_points<3>(points)). No copy is made. See fast STL and OBJ reading.
Building the spatial tree
Queries run against a tf::aabb_tree. Build it once and tag it onto the mesh:
tf::aabb_tree<int, float, 3> tree(mesh, tf::config_tree(4, 4));
auto form = mesh | tf::tag(tree);
(4, 4) is four children per node and up to four faces per leaf. The docs recommend it for all workloads. form is the mesh with its tree attached. Every query below takes it.
One point
tf::neighbor_search finds the nearest face to a query point:
auto query = tf::make_point(0.f, 0.f, 2.f);
auto nearest = tf::neighbor_search(form, query);
auto [face_id, metric_point] = nearest;
auto [dist2, closest] = metric_point;
float distance = std::sqrt(dist2);

face_id is the index of the nearest face. dist2 is the squared distance. closest is the point on that face. For a unit sphere and a query at (0, 0, 2), dist2 is 1 and closest is the north pole.
If you only need the distance, ask for it directly:
float d = tf::distance(form, query);
float d2 = tf::distance2(form, query);
Many points at once
A query is a const read of the tree, so queries run in parallel. tf::parallel_for_each over a tf::zip of inputs and outputs is the usual form:
std::vector<tf::point<float, 3>> queries(100000);
for (auto &q : queries)
q = tf::make_point(tf::random(-1.5f, 1.5f), tf::random(-1.5f, 1.5f),
tf::random(-1.5f, 1.5f));
std::vector<int> face_ids(queries.size());
std::vector<tf::point<float, 3>> closest_pts(queries.size());
tf::parallel_for_each(tf::zip(queries, face_ids, closest_pts), [&](auto row) {
auto &&[q, id, pt] = row;
auto [fid, mp] = tf::neighbor_search(form, q);
id = fid;
pt = mp.point;
});

The tree is built once. The loop only pays for the queries.
Search within a radius
Pass a radius to ignore anything farther away. The result converts to bool and is false when nothing is in range:
auto within = tf::neighbor_search(form, query, 0.5f);
if (within) {
auto [id, mp] = within;
}

The radius is a distance, not a squared distance. The query above is 1 away from the sphere, so within is false. A tight radius also prunes the search. Use it when you know the scale you care about, as in snapping or contact checks.
k nearest faces
For the k nearest faces, give tf::make_nearest_neighbors a buffer to fill. The results come back sorted by distance:
std::array<tf::nearest_neighbor<int, float, 3>, 4> buffer;
auto knn = tf::make_nearest_neighbors(buffer.begin(), 4);
tf::neighbor_search(form, query, knn);
for (auto [id, mp] : knn) {
// mp.metric is the squared distance, mp.point the closest point
}

tf::make_nearest_neighbors(buffer.begin(), 4, radius) adds a radius. It may then find fewer than k.
Posed meshes
Tag a transformation onto the form and queries run in world space. The tree is not rebuilt and no vertex is copied:
auto T = tf::make_transformation_from_translation(
tf::make_vector(5.f, 0.f, 0.f));
auto moved = form | tf::tag(T);
auto [mid, mmp] = tf::neighbor_search(moved, tf::make_point(5.f, 0.f, 2.f));
// mmp.point is in world space: (5, 0, 1)
Closest pair between two meshes
Pass a second form instead of a point. The result holds a face id on each mesh and a point on each:
auto box_buffer = tf::make_box_mesh(1.f, 1.f, 1.f);
auto box = box_buffer.polygons();
tf::aabb_tree<int, float, 3> box_tree(box, tf::config_tree(4, 4));
auto box_form = box | tf::tag(box_tree)
| tf::tag(tf::make_transformation_from_translation(
tf::make_vector(3.f, 0.f, 0.f)));
auto pair = tf::neighbor_search(form, box_form);
auto [ids, mpp] = pair;
auto [id_a, id_b] = ids;
auto [pd2, pt_a, pt_b] = mpp;
// pd2 == 2.25: sphere at x = 1, box face at x = 2.5
The collision detection page covers this case in depth. For proximity between two meshes, the docs suggest an obb_tree or obbrss_tree for tighter culling. The call is the same.
Working with results
The face ids index straight into per-face data. Face normals are one lookup:
auto normals = tf::compute_normals(mesh);
auto n = normals[face_ids[0]];

Project points onto the surface by replacing them with closest_pts. Report scan deviation as the square root of the metric.
The distance here is unsigned. For a sign, negative inside, tf::signed_distance takes the same form with a face_membership and a manifold_edge_link tagged on. See the spatial docs.
Related tutorials
- Fast and Robust Mesh Collision Detection in C++ — do two meshes touch, how far apart, and where.
- Fast and Accurate Geodesic Distances in C++ — distance along the surface from one seed or many.
- Fast Connected Components in C++ — split a mesh into parts and find its boundary loops.
- Fast STL and OBJ Reading in C++ — load and write meshes, the first step of every pipeline.
Performance
Every query on this page runs on the spatial tree. Building it is the up-front cost. The benchmark builds the tree on a 1,025,766-polygon mesh in C++.
Apple M4 Max 16 threads
For the queries, we ran 100,000 closest-point queries against each of 100 Thingi10K meshes (100,368 to 938,940 faces) and the Stanford dragon at five sizes. The query points fall in each mesh's bounding box, grown by 20 %. Each query writes its face id, closest point and squared distance. The tree is built before the query clock starts; its build time is in its own table below.
Apple M4 Max 16 threads Apple clang 17 · C++17 -O3
Building the tree, from your arrays to a tree ready for queries:
These builds use this page's tf::config_tree(4, 4) on a view of your arrays. The build benchmark above first copies the arrays into a trueform buffer and uses (4, 12). libigl's batch query runs on all threads by itself. CGAL and MeshLib answer one query per call, so their batches ran under tbb::parallel_for. In a plain loop, CGAL took 2.2 s per batch (geometric mean, 63 meshes from 100K to 250K faces). All four libraries agree on every distance to within 0.2 % of the largest one.
Measured 2026-10-31 with trueform 0.10.8; harness and raw results: benchmarks/tutorials/cpp-closest-point.
For the closest pair between two meshes, at 1,025,766 polygons, the docs benchmark shows under 1 ms against FCL's 370 ms.

Full benchmarks · Documentation
@article{polydera:fast-closest-point-on-a-mesh-in-cpp,
title={Fast and Robust Closest Point on a Mesh in C++},
author={Sajovic, {\v{Z}}iga, Polydera},
year={2026},
url={https://polydera.com/tutorials/fast-closest-point-on-a-mesh-in-cpp},
organization={Polydera}
}