Spatial
The Spatial module provides distance, intersection, closest-point, neighbor search, and ray casting queries. All queries work uniformly across primitives and forms, and all support batch broadcasting. The spatial tree is built automatically on first query — no configuration needed.
Every query also has an async variant via tf.async for off-main-thread execution.
mesh.buildTree() explicitly, or await tf.async.buildTree(mesh) to build off the main thread. Subsequent calls are no-ops if the tree is already up to date.Transformations on Forms
Mesh and PointCloud can carry a 4x4 transformation matrix. Queries use the transformation on the fly — the underlying data and spatial tree stay unchanged.
import * as tf from "@polydera/trueform";
const mesh = tf.mesh(faces, points);
// Set from a primitive transformation
mesh.transformation = tf.makeTranslation(5, 0, 0);
// Set from raw data
mesh.transformation = new Float32Array([
1, 0, 0, 1,
0, 1, 0, 2,
0, 0, 1, 3,
0, 0, 0, 1,
]);
// Read back
const t = mesh.transformation; // NDArrayFloat32 [4,4] or null
// Clear
mesh.transformation = null;
| Property | Type | Description |
|---|---|---|
transformation (get) | NDArrayFloat32 | null | Current 4x4 transformation matrix |
transformation (set) | NDArrayFloat32 | Float32Array | null | Set, replace, or clear (null) the transformation |
[0,3], [1,3], [2,3]). Three.js and WebGL use column-major — convert with .T:// Three.js → trueform
mesh.transformation = tf.ndarray(threeMatrix4.elements, [4, 4]).T;
// trueform → Three.js
const m4 = new THREE.Matrix4().fromArray(mesh.transformation.T.data);
Shallow Copies
shallowCopy() creates a new handle that shares the underlying buffers (points, faces) but has its own cache slots and a cleared transformation. This lets you pose the same geometry independently without duplicating the data.
const mesh = tf.mesh(faces, points);
// Create copies with different poses
const viewA = mesh.shallowCopy();
viewA.transformation = tf.makeTranslation(5, 0, 0);
const viewB = mesh.shallowCopy();
viewB.transformation = tf.makeTranslation(-5, 0, 0);
const d = tf.distance(viewA, viewB);
mesh.buildTree()) on the original and then pass it directly — copies are only needed when you want independent transformations.Queries
All query functions accept any combination of primitives and forms.
Queries broadcast. A single primitive paired with a batch of N primitives produces N results — the single operand is reused for each element in the batch. Two batches of the same size are paired element-wise. A form paired with a batch queries each element against the spatial tree independently.
Dtype dispatch
Operations follow a fixed dtype policy:
| Pair | Behavior | Result dtype |
|---|---|---|
| Form + Form | Must match. Mixing float32 and float64 throws dtype mismatch. | Form's dtype |
| Form + Primitive | Form's dtype wins. Primitive is automatically upcast if needed. | Form's dtype |
| Primitive + Primitive | Upcast to the wider of the two (float64 wins over float32). | Wider dtype |
const m32 = tf.sphereMesh(1, 8, 8);
const m64 = tf.sphereMesh(1, 8, 8, { dtype: "float64" });
tf.distance2(m32, m32); // OK → float32
tf.distance2(m64, m64); // OK → float64
tf.distance2(m32, m64); // throws: dtype mismatch
tf.distance2(m64, tf.point(0, 0, 0)); // OK — point upcast to float64
Distance
tf.distance(a, b) and tf.distance2(a, b) compute the distance (or squared distance) between any two geometric objects. distance is signed for point-plane queries (positive on the normal side).
// Primitive x Primitive
const d = tf.distance(tf.point(0, 0, 0), tf.segment(tf.point(1, 0, 0), tf.point(1, 1, 0)));
// Form x Primitive
const d2 = tf.distance2(mesh, tf.point(0, 0, 0));
// Form x Form
const d3 = tf.distance(meshA, meshB);
// Batch — returns NDArrayFloat32
const pts = tf.point(new Float32Array([0,0,0, 1,1,1, 2,2,2]), 3);
const distances = tf.distance(pts, mesh); // shape [3]
| A | B | Returns |
|---|---|---|
| primitive | primitive | number |
| batch(N) | primitive or form | NDArrayFloat32 [N] |
| batch(N) | batch(N) | NDArrayFloat32 [N] |
| form | primitive | number |
| form | form | number |
Closest Point
tf.closestPoint(a, b) returns the closest point on b to a. tf.closestPointPair(a, b) returns the closest points on both sides. These work on primitives only — for forms, use neighborSearch which returns element IDs alongside closest points.
// Single
const r = tf.closestPoint(tf.point(0, 2, 0), segment);
console.log(r.point); // NDArrayFloat32 [3] — closest point on segment
console.log(r.distance2); // number — squared distance
const rp = tf.closestPointPair(segmentA, segmentB);
console.log(rp.point0); // closest point on A
console.log(rp.point1); // closest point on B
console.log(rp.distance2); // squared distance
// Batch
const pts = tf.point(new Float32Array([0,1,0, 0,2,0, 0,3,0]), 3);
const rb = tf.closestPointPair(pts, segment);
console.log(rb.points0); // NDArrayFloat32 [3, 3]
console.log(rb.points1); // NDArrayFloat32 [3, 3]
console.log(rb.distances); // NDArrayFloat32 [3]
| A | B | closestPoint | closestPointPair |
|---|---|---|---|
| primitive | primitive | { point, distance2 } | { point0, point1, distance2 } |
| batch(N) | primitive | { points[N,3], distances[N] } | { points0[N,3], points1[N,3], distances[N] } |
Intersection
tf.intersects(a, b) tests whether two geometric objects intersect.
// Primitive x Primitive
const hit = tf.intersects(aabb, segment); // boolean
// Form x Primitive
const hit2 = tf.intersects(mesh, polygon); // boolean
// Form x Form
const collide = tf.intersects(meshA, meshB); // boolean
// Batch — returns NDArrayBool
const segs = tf.segment(batchData, 3); // 3 segments
const hits = tf.intersects(mesh, segs); // NDArrayBool [3]
| A | B | Returns |
|---|---|---|
| primitive | primitive | boolean |
| batch(N) | primitive or form | NDArrayBool [N] |
| batch(N) | batch(N) | NDArrayBool [N] |
| form | primitive | boolean |
| form | form | boolean |
Neighbor Search
tf.neighborSearch(form, query) finds the nearest element in a form to a query primitive. This is the form equivalent of closestPoint — it returns an element ID alongside the closest point.
// Single query
const r = tf.neighborSearch(mesh, tf.point(0.5, 0.5, 1));
console.log(r.elementId); // face index
console.log(r.point); // closest point on that face
console.log(r.distance2); // squared distance
// With search radius — returns null fields if nothing within radius
const r2 = tf.neighborSearch(mesh, point, { radius: 1.0 });
// Batch query
const pts = tf.point(new Float32Array([...]), 1000);
const rb = tf.neighborSearch(mesh, pts);
console.log(rb.elementIds); // NDArrayInt32 [1000]
console.log(rb.points); // NDArrayFloat32 [1000, 3]
console.log(rb.distances); // NDArrayFloat32 [1000]
// Form x Form — closest pair between two forms
const rp = tf.neighborSearch(meshA, meshB);
console.log(rp.elementId0, rp.elementId1); // element IDs on each form
console.log(rp.point0, rp.point1); // closest points
console.log(rp.distance2); // squared distance
| Query | Returns |
|---|---|
| single | { elementId, point, distance2 } |
| batch(N) | { elementIds[N], points[N,3], distances[N] } |
| form x form | { elementId0, elementId1, point0, point1, distance2 } |
kNN
Pass { k } for k-nearest neighbor queries:
// Single query — k nearest faces
const r = tf.neighborSearch(mesh, point, { k: 5 });
console.log(r.elementIds); // NDArrayInt32 [5] (or fewer)
console.log(r.points); // NDArrayFloat32 [5, 3]
console.log(r.distances); // NDArrayFloat32 [5]
// With radius limit — may find fewer than k
const r2 = tf.neighborSearch(mesh, point, { k: 10, radius: 2.0 });
// Batch kNN
const pts = tf.point(new Float32Array([0,0,0, 0,0,5, 0,0,10]), 3);
const rb = tf.neighborSearch(mesh, pts, { k: 2 });
console.log(rb.elementIds.shape); // [3, 2]
console.log(rb.distances.shape); // [3, 2]
console.log(rb.counts); // NDArrayInt32 [3] — actual count per query
// Unused slots in elementIds are filled with -1
Ray Casting
tf.rayCast(ray, target, opts?) casts a ray against a primitive or form. The result includes t — the ray parameter where hitPoint = origin + t * direction. Use { minT, maxT } to constrain the valid range — pass a scalar for uniform bounds or an NDArrayFloat32 [N] for per-ray bounds.
// Ray x Primitive
const ray = tf.ray(tf.point(0.5, 0.5, -1), tf.vector(0, 0, 1));
const r = tf.rayCast(ray, triangle);
if (r.hit) console.log(`Hit at t=${r.t}`);
// Ray x Form — also returns the element ID
const r2 = tf.rayCast(ray, mesh);
if (r2.hit) console.log(`Face ${r2.elementId} at t=${r2.t}`);
// With bounds
const r3 = tf.rayCast(ray, mesh, { minT: 0, maxT: 100 });
// Batch rays
const rays = tf.ray(batchData, 5); // 5 rays
const rb = tf.rayCast(rays, mesh);
console.log(rb.hits); // NDArrayBool [5]
console.log(rb.ts); // NDArrayFloat32 [5] — NaN for misses
console.log(rb.elementIds); // NDArrayInt32 [5] — -1 for misses
// Per-ray bounds — each ray gets its own min/max
const minTs = tf.ndarray(new Float32Array([0, 0, 0.5, 1, 0]));
const maxTs = tf.ndarray(new Float32Array([10, 5, 10, 50, 100]));
const rb2 = tf.rayCast(rays, mesh, { minT: minTs, maxT: maxTs });
| Ray | Target | Returns |
|---|---|---|
| single | primitive | { hit, t, elementId } |
| single | form | { hit, t, elementId } |
| batch(N) | primitive | { hits[N], ts[N] } |
| batch(N) | form | { hits[N], ts[N], elementIds[N] } |
Async Queries
All functions are available as tf.async.distance2(), tf.async.neighborSearch(), tf.async.rayCast(), etc. Signatures are identical — they return Promise<T> instead of T.
const d = await tf.async.distance(mesh, point);
const r = await tf.async.neighborSearch(mesh, points, { k: 5 });
const cast = await tf.async.rayCast(ray, mesh);
