Fast and Robust Closest Point on a Mesh in JavaScript
Find the closest point on a triangle mesh, the face it lies on, and the distance, for one point or a hundred thousand, in JavaScript and TypeScript. WebAssembly-powered, runs in Node.js and the browser.
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 does it in JavaScript and TypeScript with one npm install. It is WebAssembly-powered and runs in Node.js and the browser with the same API. Typed arrays in, typed arrays out. This page covers single queries, batch queries over many points, radius-limited search, k-nearest faces, and posed meshes.

Install
npm install @polydera/trueform
The package ships TypeScript types. The WebAssembly binary loads on first use. In the browser, multithreading needs SharedArrayBuffer headers; see the installation guide.
Loading the mesh
import * as tf from "@polydera/trueform";
import { readFileSync } from "node:fs";
const mesh = tf.readStl(readFileSync("part.stl"));
In the browser, fetch the bytes instead:
const response = await fetch("part.stl");
const mesh = tf.readStl(new Uint8Array(await response.arrayBuffer()));
tf.readObj works the same way. If you already have arrays, build the mesh with tf.mesh(faces, points). Points are float32 by default. See fast STL and OBJ reading for the details.
One point
tf.neighborSearch finds the nearest face of the mesh to a query point:
const r = tf.neighborSearch(mesh, tf.point(0, 0, 2));
r.elementId; // index of the nearest face
r.point; // NDArrayFloat32 [3], the point on that face
r.distance2; // squared distance
const distance = Math.sqrt(r.distance2);

Many points at once
Pack the queries into one flat Float32Array and wrap it with tf.point(data, 3). The 3 is the number of coordinates per point, not the number of points. One call runs all queries in parallel against the mesh's spatial tree.
const n = 100_000;
const data = new Float32Array(3 * n);
for (let i = 0; i < data.length; i++) data[i] = Math.random() * 3 - 1.5;
const queries = tf.point(data, 3);
const { elementIds, points, distances } = tf.neighborSearch(mesh, queries);
// elementIds: NDArrayInt32 [100000]
// points: NDArrayFloat32 [100000, 3]
// distances: NDArrayFloat32 [100000], squared distances

In a batch, distances holds squared distances, the same as distance2 for a single query.
The spatial tree is built on the first query and cached on the mesh. To pay that cost at a time you choose, build it first:
mesh.buildTree();
// or off the main thread
await tf.async.buildTree(mesh);
If you only need distances, tf.distance takes the same batch and returns plain (not squared) distances:
const d = tf.distance(mesh, queries); // NDArrayFloat32 [100000]
Search within a radius
Pass radius to ignore anything farther away. A miss comes back with elementId set to -1:
const near = tf.neighborSearch(mesh, tf.point(0, 0, 2), { radius: 0.5 });
if (near.elementId >= 0) {
// a face lies within 0.5
}

In a batch, misses are -1 in elementIds. Test the ids, not the distances:
const nb = tf.neighborSearch(mesh, queries, { radius: 0.1 });
const inRange = nb.elementIds.gte(0); // NDArrayBool [100000]
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
Pass k to get the k nearest faces per query:
const knn = tf.neighborSearch(mesh, queries, { k: 4 });
// knn.elementIds: [100000, 4]
// knn.points: [100000, 4, 3]
// knn.distances: [100000, 4]
// knn.counts: [100000], valid neighbors per query

With radius set, counts[i] can be less than k. Unused id slots hold -1.
Posed meshes
Set a 4×4 transformation on the mesh and queries run in world space. The tree is not rebuilt. A shallow copy shares the data and carries its own pose:
const moved = mesh.shallowCopy();
moved.transformation = tf.makeTranslation(5, 0, 0);
const r = tf.neighborSearch(moved, tf.point(5, 0, 2));
// r.point is in world space
Matrices are row-major. Three.js uses column-major, so transpose with .T when you pass a Matrix4 in.
Working with results
The face ids index straight into per-face data. Face normals are one gather:
const normals = mesh.normals.take(elementIds); // [100000, 3]
const offsets = tf.ndarray(data, [n, 3]).sub(points); // surface to query
const dist = tf.sqrt(distances); // [100000]

Project points onto the surface by replacing them with points. Report scan deviation as dist. Every array exposes its typed array as .data, ready for a Three.js BufferAttribute.
The distance here is unsigned. For the closest pair between two meshes, pass a second mesh as the query:
const other = mesh.shallowCopy();
other.transformation = tf.makeTranslation(3, 0, 0);
const { elementId0, elementId1, point0, point1, distance2 } =
tf.neighborSearch(mesh, other);
For single primitives such as a triangle or a segment, tf.closestPointPair returns the closest point on each side:
const tri = tf.triangle(tf.point(0, 0, 0), tf.point(1, 0, 0), tf.point(0, 1, 0));
const { point0, point1, distance2 } = tf.closestPointPair(tf.point(0.2, 0.2, 1), tri);
// point1 is on the triangle
Every query has an async twin under tf.async that runs off the main thread and returns a Promise:
const res = await tf.async.neighborSearch(mesh, queries);
Related tutorials
- Fast and Robust Mesh Collision Detection in JavaScript — do two meshes touch, how far apart, and where.
- Fast and Accurate Geodesic Distances in JavaScript — distance along the surface from one seed or many.
- Fast Connected Components in JavaScript — split a mesh into parts and find its boundary loops.
- Fast STL and OBJ Reading in JavaScript — load and write meshes, the first step of every pipeline.
Performance
We measured one batch of 100,000 query points against one mesh, on 100 Thingi10K meshes (100,368 to 938,940 faces) and the dragon at five sizes. The points fill the mesh's bounding box grown by 20 percent, so some lie far from the surface. Each query returns the distance to the closest point.
Apple M4 Max 16 threads Node.js 25 · WebAssembly
The Thingi10K rows are geometric means over the meshes in each size range. Build + query starts from the vertex and index arrays and includes building the mesh and its tree. Query only starts with the tree built. Times differ between meshes of the same size because the points fall nearer to or farther from the surface. trueform ran on its WebAssembly thread pool of 16 workers. No peer library in JavaScript was timed.
Best of the timed runs after warm-ups, measured 2026-11-01 with trueform 0.10.8; harness and raw results: benchmarks/tutorials/javascript-closest-point.
Full benchmarks · Documentation
@article{polydera:fast-closest-point-on-a-mesh-in-javascript,
title={Fast and Robust Closest Point on a Mesh in JavaScript},
author={Sajovic, {\v{Z}}iga, Polydera},
year={2026},
url={https://polydera.com/tutorials/fast-closest-point-on-a-mesh-in-javascript},
organization={Polydera}
}