Geometry
The Geometry module provides tools for computing geometric properties of meshes — normals, curvature, area, volume — along with mesh generation, triangulation, smoothing, and point cloud alignment.
Every function also has an async variant via tf.async for off-main-thread execution.
Mesh Generation
Generate common primitive meshes for testing or as building blocks.
import * as tf from "@polydera/trueform";
// UV sphere
const sphere = tf.sphereMesh(1.0, 20, 20); // radius, stacks, segments
// Cylinder along z-axis
const cyl = tf.cylinderMesh(1.0, 2.0, 20); // radius, height, segments
// Axis-aligned box
const box = tf.boxMesh(2, 1, 3);
// Subdivided box for deformation/simulation
const boxSub = tf.boxMesh(2, 1, 3, 4, 2, 6); // + widthTicks, heightTicks, depthTicks
// Flat plane in XY
const plane = tf.planeMesh(10, 5);
// Subdivided plane
const planeSub = tf.planeMesh(10, 5, 20, 10); // + widthTicks, heightTicks
// Double-precision mesh — pass { dtype: "float64" } as a trailing option
const sphere64 = tf.sphereMesh(1.0, 20, 20, { dtype: "float64" });
All generators accept a trailing { dtype: "float32" | "float64" } option (default: "float32"). For tubeMesh, the dtype is inferred from the input curves.
| Function | Parameters | Description |
|---|---|---|
sphereMesh | radius, stacks, segments, [opts] | UV sphere |
cylinderMesh | radius, height, segments, [opts] | Cylinder along z-axis |
boxMesh | width, height, depth, [ticks], [opts] | Axis-aligned box |
planeMesh | width, height, [ticks], [opts] | Flat plane in XY |
tubeMesh | curves, radius, [radialSegments] | Tube mesh from curves (dtype inherits from curves) |
Tube Mesh
Create tube meshes from curves using parallel transport frames. Works with intersection curves, boundary paths, isocontours, or any curve data.
// From intersection curves
const curves = tf.intersectionCurves(mesh0, mesh1);
const tubes = tf.tubeMesh(curves, 0.01, 12); // curves, radius, radialSegments
// From boundary paths
const boundary = tf.boundaryPaths(mesh);
const tubes = tf.tubeMesh(boundary, 0.005);
Closed curves (first point ~ last point) are detected automatically. The output is a manifold triangle mesh with shared vertex topology.
Edge Analysis
Sharp Edges
Extract edges where the dihedral angle between adjacent faces exceeds a threshold (in degrees):
const edges = tf.sharpEdges(mesh, 30); // 30°, returns NDArrayInt32 [N, 2]
Boundary and non-manifold edges are excluded — see Topology for boundaryEdges and nonManifoldEdges.
For the angle itself at every edge two faces share, rather than the edges past a threshold, see Dihedral Angles.
Measurements
Area
Total surface area of a mesh.
const box = tf.boxMesh(2, 3, 4);
const a = tf.area(box); // 52.0
Volume
Volume of a closed 3D mesh. signedVolume is positive when face normals point outward.
const box = tf.boxMesh(2, 3, 4);
tf.volume(box); // 24.0
tf.signedVolume(box); // 24.0
Edge Length
tf.meanEdgeLength(mesh);
tf.minEdgeLength(mesh);
tf.maxEdgeLength(mesh);
| Function | Returns | Description |
|---|---|---|
area | number | Total surface area |
volume | number | Absolute volume (3D closed mesh) |
signedVolume | number | Signed volume (positive = outward normals) |
meanEdgeLength | number | Mean edge length across all faces |
minEdgeLength | number | Minimum edge length |
maxEdgeLength | number | Maximum edge length |
mesh.transformation. When a mesh has a transformation, measurements are computed in the transformed coordinate space.Quality Measures
Face Quality
Measure every face of a mesh — one array per measure, each of shape [F] in the mesh's dtype.
const { quality, minAngle, maxAngle, aspectRatio } = tf.faceQuality(mesh);
| Field | Type | Description |
|---|---|---|
quality | NDArrayFloat32 | NDArrayFloat64 [F] | The triangle measure: 1 for equilateral, approaching 0 for a sliver; a face that is not a triangle reads -1 |
minAngle | NDArrayFloat32 | NDArrayFloat64 [F] | Smallest corner angle, in radians |
maxAngle | NDArrayFloat32 | NDArrayFloat64 [F] | Largest corner angle, in radians |
aspectRatio | NDArrayFloat32 | NDArrayFloat64 [F] | Longest side over the shortest, infinite where a side has no length |
The corner angles and the aspect ratio are stated for a face of any arity. An angle is unsigned and lies in [0, pi], so a reflex corner of a non-convex face reads its explement.
Dihedral Angles
Measure every edge two faces of a mesh share.
const { edges, angles } = tf.dihedralAngles(mesh);
// edges : NDArrayInt32 [N, 2] — vertex index pairs, one per undirected edge
// angles : NDArrayFloat32 | NDArrayFloat64 [N] — radians, aligned with edges
One entry per undirected edge, reached through the manifold edge link's representative. The angle is between the face normals, so a flat surface reads 0. A boundary or non-manifold edge joins no pair of faces and turns through no angle, so it is not stated. The manifoldEdgeLink and normals the call reads are cached on the mesh.
Normals
Face and vertex normals are lazily computed on first access and cached on the mesh.
const mesh = tf.mesh(faces, points);
// Face normals — one unit normal per face
const n = mesh.normals; // NDArrayFloat32 [F, 3]
// Vertex normals — area-weighted average of adjacent face normals
const pn = mesh.pointNormals; // NDArrayFloat32 [V, 3]
| Property | Returns | Description |
|---|---|---|
mesh.normals | NDArrayFloat32 [F, 3] | Unit face normals |
mesh.pointNormals | NDArrayFloat32 [V, 3] | Unit vertex normals |
To compute off the main thread:
const normals = await tf.async.computeNormals(mesh);
const pointNormals = await tf.async.computePointNormals(mesh);
Curvature Analysis
Surface curvature is computed by fitting a quadric to the local k-ring neighborhood at each vertex.
Principal Curvatures
// Curvature values only
const { k0, k1 } = tf.principalCurvatures(mesh);
// k0: NDArrayFloat32 [V] — maximum principal curvature
// k1: NDArrayFloat32 [V] — minimum principal curvature
// With principal directions
const { k0, k1, d0, d1 } = tf.principalDirections(mesh);
// d0: NDArrayFloat32 [V, 3] — direction of max curvature
// d1: NDArrayFloat32 [V, 3] — direction of min curvature
// Custom k-ring size (default k=2)
const curv = tf.principalCurvatures(mesh, 3);
// Derived measures
const gaussian = k0.mul(k1); // Gaussian curvature
const mean = k0.add(k1).mul(0.5); // Mean curvature
Shape Index
Maps principal curvatures to -1, 1, characterizing local surface type.
const si = tf.shapeIndex(mesh); // NDArrayFloat32 [V]
const si3 = tf.shapeIndex(mesh, 3); // custom k-ring
| Index Range | Surface Type |
|---|---|
| [-1, -5/8) | Concave ellipsoid (cup) |
| [-5/8, -3/8) | Concave cylinder (trough) |
| [-3/8, 3/8) | Hyperboloid (saddle) |
| [3/8, 5/8) | Convex cylinder (ridge) |
| 5/8, 1 | Convex ellipsoid (cap) |
Orientation
For closed 3D meshes, ensure faces are oriented so that normals point outward (positive signed volume).
// Orient mesh to have outward-facing normals
const oriented = tf.positivelyOriented(mesh);
// Skip consistency step if mesh is already consistently oriented
const oriented2 = tf.positivelyOriented(mesh, true);
This function:
- Orients faces consistently (unless
isConsistentistrue) - Computes the signed volume
- Reverses all face windings if the signed volume is negative
Triangulation
Convert polygon meshes to triangle meshes using constrained Delaunay triangulation.
// Triangulate a polygon
const poly = tf.polygon(new Float32Array([
0,0,0, 1,0,0, 1,1,0, 0.5,1.5,0, 0,1,0
]));
const triMesh = tf.triangulate(poly);
// Triangulate a quad mesh
const quadMesh = { faces: quadFaces, points: pts };
const triMesh2 = tf.triangulate(quadMesh);
// Triangulate a dynamic (variable n-gon) mesh
const dynMesh = { faces: offsetBlockedFaces, points: pts };
const triMesh3 = tf.triangulate(dynMesh);
// Pass a Mesh directly — returned as-is if already triangulated
const same = tf.triangulate(existingTriMesh);
Smoothing
Laplacian Smoothing
Iteratively moves vertices toward their neighbors' centroid.
// 100 iterations, lambda=0.5 (default)
const smoothed = tf.laplacianSmoothed(mesh, 100);
// Custom lambda
const smoothed2 = tf.laplacianSmoothed(mesh, 100, 0.3);
| Parameter | Type | Default | Description |
|---|---|---|---|
m | Mesh | Input mesh | |
iterations | number | Number of smoothing passes | |
lambda | number | 0.5 | Movement factor in 0, 1 |
Taubin Smoothing
Volume-preserving smoothing by alternating shrink and inflate passes.
// 50 iterations
const smoothed = tf.taubinSmoothed(mesh, 50);
// Custom parameters
const smoothed2 = tf.taubinSmoothed(mesh, 50, 0.5, 0.1);
| Parameter | Type | Default | Description |
|---|---|---|---|
m | Mesh | Input mesh | |
iterations | number | Number of passes (each = shrink + inflate) | |
lambda | number | 0.5 | Shrink factor in (0, 1] |
kpb | number | 0.1 | Pass-band frequency in (0, 1) |
Point Cloud Alignment
Compute rigid transformations to align point sets. All functions return a 4x4 delta transformation matrix (source world → target world).
| Scenario | Function | Notes |
|---|---|---|
| Known correspondences | fitRigidAlignment | Optimal closed-form solution |
| No correspondences, need initialization | fitObbAlignment | Coarse alignment via OBBs |
| No correspondences, need refinement | fitIcpAlignment | Full ICP loop |
// Typical pipeline: OBB → ICP
const T_coarse = tf.fitObbAlignment(source, target);
source.transformation = T_coarse;
const T_fine = tf.fitIcpAlignment(source, target, { maxIterations: 50 });
// Compose for total transformation
// T_fine is a delta from source's current world position
Rigid Alignment
Kabsch/Procrustes algorithm for optimal rotation + translation. Requires 1:1 correspondence (source and target must have the same number of points).
const T = tf.fitRigidAlignment(source, target); // NDArrayFloat32 [4, 4]
Coarse Alignment
Align using oriented bounding boxes (OBBs) for initialization.
const T = tf.fitObbAlignment(source, target);
// Custom sample size for orientation disambiguation
const T2 = tf.fitObbAlignment(source, target, { sampleSize: 200 });
sampleSize points.Iterative Refinement
Refine an initial alignment using Iterative Closest Point (ICP).
source.transformation = T_init;
const T = tf.fitIcpAlignment(source, target, {
maxIterations: 50,
nSamples: 1000,
});
| Option | Type | Default | Description |
|---|---|---|---|
maxIterations | number | 100 | Maximum iterations |
nSamples | number | 1000 | Subsample size per iteration (0 = all) |
k | number | 1 | Nearest neighbors (k=1 is classic ICP) |
sigma | number | -1 | Gaussian width (-1 = adaptive) |
outlierProportion | number | 0 | Outlier rejection ratio (0-1) |
minRelativeImprovement | number | 1e-6 | Convergence threshold |
emaAlpha | number | 0.3 | EMA smoothing factor |
Chamfer Error
One-way Chamfer distance — mean nearest-neighbor distance from source to target.
const error = tf.chamferError(source, target);
// With outlier rejection
const error2 = tf.chamferError(source, target, { outlierProportion: 0.05 });
// Symmetric chamfer distance
const sym = (tf.chamferError(a, b) + tf.chamferError(b, a)) / 2;
Async
All functions are available as tf.async.area(), tf.async.principalCurvatures(), tf.async.fitIcpAlignment(), etc. Signatures are identical — they return Promise<T> instead of T.
const a = await tf.async.area(mesh);
const { k0, k1 } = await tf.async.principalCurvatures(mesh);
const T = await tf.async.fitIcpAlignment(source, target, { maxIterations: 50 });
const tubes = await tf.async.tubeMesh(curves, 0.01);
