Topology
The Topology module provides tools for analyzing connectivity and structure in meshes — adjacency structures, boundary detection, manifold analysis, connected components, and neighborhood queries.
Every function also has an async variant via tf.async for off-main-thread execution.
Adjacency Structures
Meshes lazily compute and cache adjacency structures on first access. These structures power the topology queries below.
import * as tf from "@polydera/trueform";
const mesh = tf.mesh(faces, points);
// Face membership — vertex → faces containing it
const fm = mesh.faceMembership; // OffsetBlockedBuffer
// Manifold edge link — face edge → adjacent face
const mel = mesh.manifoldEdgeLink; // NDArrayInt32 [F, 3]
// Face link — face → adjacent faces (sharing an edge)
const fl = mesh.faceLink; // OffsetBlockedBuffer
// Vertex link — vertex → connected vertices
const vl = mesh.vertexLink; // OffsetBlockedBuffer
| Property | Type | Description |
|---|---|---|
faceMembership | OffsetBlockedBuffer | Vertex → faces containing it |
manifoldEdgeLink | NDArrayInt32 [F, N] | Face edge → adjacent face index |
faceLink | OffsetBlockedBuffer | Face → adjacent faces |
vertexLink | OffsetBlockedBuffer | Vertex → connected vertices |
To precompute off the main thread:
const fm = await tf.async.computeFaceMembership(mesh);
const mel = await tf.async.computeManifoldEdgeLink(mesh);
const fl = await tf.async.computeFaceLink(mesh);
const vl = await tf.async.computeVertexLink(mesh);
Manifold Edge Link Sentinel Values
| Value | Meaning |
|---|---|
>= 0 | Adjacent face index |
-1 | Boundary edge (no adjacent face) |
-2 | Non-manifold edge (3+ faces) |
-3 | Non-manifold representative |
Mesh Analysis
Closed / Open
Check if a mesh is closed (watertight) or open (has boundary edges).
tf.isClosed(mesh); // true if every edge shared by exactly 2 faces
tf.isOpen(mesh); // true if at least one boundary edge
A closed mesh is watertight — suitable for volume calculations and boolean operations.
tf.boundaryEdges(mesh) to get the actual boundary edges when isOpen returns true.Manifold / Non-Manifold
Check if a mesh is manifold (no edge shared by more than 2 faces).
tf.isManifold(mesh); // true if every edge shared by at most 2 faces
tf.isNonManifold(mesh); // true if any edge shared by 3+ faces
Non-manifold edges typically occur at T-junctions where three or more faces meet at a single edge.
tf.nonManifoldEdges(mesh) to get the actual non-manifold edges when isNonManifold returns true.Euler Characteristic
const chi = tf.eulerCharacteristic(mesh); // V - E + F
Boundary Detection
Boundary Edges
Extract boundary edges — edges belonging to only one face.
const edges = tf.boundaryEdges(mesh); // NDArrayInt32 [N, 2]
Boundary Paths
Organize boundary edges into connected loops of vertex indices.
const paths = tf.boundaryPaths(mesh); // OffsetBlockedBuffer
Non-Manifold Edges
Find edges shared by more than two faces.
const edges = tf.nonManifoldEdges(mesh); // NDArrayInt32 [N, 2]
Neighborhoods
k-Ring
Compute k-ring neighborhoods for all vertices using breadth-first traversal. The 1-ring is the immediate neighbors, the 2-ring includes neighbors of neighbors, etc.
// 2-ring neighborhoods for all vertices
const rings = tf.kRings(mesh, 2); // OffsetBlockedBuffer
// Include the seed vertex in each neighborhood
const ringsInc = tf.kRings(mesh, 2, true);
Radius-Based
Find all vertices reachable via mesh edges within a Euclidean distance.
// All vertices within radius 0.5
const neighs = tf.neighborhoods(mesh, 0.5); // OffsetBlockedBuffer
// Include seed vertex
const neighsInc = tf.neighborhoods(mesh, 0.5, true);
Connected Components
From Mesh
connectedComponents computes connected components of a mesh using a specified connectivity type.
const { labels, nComponents } = tf.connectedComponents(mesh, "edge");
console.log(`${nComponents} components`);
// labels: NDArrayInt32 [F] — component ID per face
| Type | Connectivity | Description |
|---|---|---|
"edge" | faceLink | Faces connected by any shared edge |
"manifoldEdge" | manifoldEdgeLink | Faces connected by manifold edges only |
"vertex" | vertexLink | Vertices connected by shared edges |
From Connectivity
labelConnectedComponents works on raw connectivity data — either an OffsetBlockedBuffer (variable-width) or an NDArrayInt32 (fixed-width, -1 for missing neighbors).
// Variable-width connectivity (e.g., faceLink)
const { labels, nComponents } = tf.labelConnectedComponents(mesh.faceLink);
// Fixed-width connectivity (e.g., manifoldEdgeLink)
const result = tf.labelConnectedComponents(mesh.manifoldEdgeLink);
Face Orientation
Make all faces in each connected region have consistent winding order.
const oriented = tf.consistentlyOriented(mesh);
The function uses flood-fill through manifold edges to propagate orientation. Non-manifold edges act as barriers between regions. The final orientation preserves the majority area within each region.
Reverse the winding order of all faces (flips normals):
const flipped = tf.reverseWinding(mesh);
tf.positivelyOriented(mesh) from the Geometry module, which calls consistentlyOriented internally and then flips faces if needed.Volumetric Domains
A non-manifold polygon mesh bounds multiple 3D regions ("domains"). tf.domainLabels returns one label per face per side, partitioning space into volumetric components.
const dl = tf.domainLabels(mesh, {
ignoreOpenFragments: true,
excludeOuterShell: true,
});
// dl.labels : NDArrayInt32, shape (nFaces, 2)
// labels[f, 0] : domain containing face f with REVERSED winding
// (the side f's stored normal points INTO)
// labels[f, 1] : domain containing face f with FORWARD winding
// (the side f's stored normal points AWAY FROM)
// dl.nDomains : number (count of bounded domains)
// dl.outerShellLabel : number (== nDomains when excludeOuterShell is set)
Options (all bool kwargs):
| Option | Effect |
|---|---|
ignoreOpenFragments | Drop face-sides bounding open fragments (faces in MEL components carrying boundary edges) by parking them at the sentinel label nDomains. |
excludeOuterShell | Fold the unbounded universe domain into the same sentinel, so only bounded interior domains receive ids. Works on single closed manifolds (lone box) too. |
When the outer shell has been excluded, outerShellLabel equals nDomains (the sentinel); otherwise no face-side carries this label.
Pass the returned DomainLabelsResult directly to tf.splitIntoDomains (see Reindex module) to extract a watertight, outward-oriented submesh per domain.
Async: await tf.async.domainLabels(mesh, opts).
Path Finding
Connect edges into continuous vertex paths between branch points and endpoints.
const paths = tf.connectEdgesToPaths(edges); // OffsetBlockedBuffer
Edges are organized into maximal paths between endpoints and junctions (vertices with degree ≠ 2). Works on any edge collection — useful for organizing boundary segments, cut curves, and graph decomposition.
Constrained Delaunay Triangulation
tf.cdt triangulates a 2D point set and recovers user-supplied constraint edges as edges of the output. Constraint edges are allowed to intersect each other — vertex–vertex, vertex–edge, and edge–edge crossings are resolved by an arrangement pre-pass that splits crossings into sub-edges and adds the intersection points to the output.
import * as tf from "trueform";
// 256-vertex regular polygon outline + interior Steiner points
const nBoundary = 256;
const boundary = new Float32Array(nBoundary * 2);
for (let i = 0; i < nBoundary; i++) {
const t = (2 * Math.PI * i) / nBoundary;
boundary[2 * i] = Math.cos(t);
boundary[2 * i + 1] = Math.sin(t);
}
const points = tf.ndarray(boundary, [nBoundary, 2]);
// Closing edges of the polygon outline
const eFlat = new Int32Array(nBoundary * 2);
for (let i = 0; i < nBoundary; i++) {
eFlat[2 * i] = i;
eFlat[2 * i + 1] = (i + 1) % nBoundary;
}
const edges = tf.ndarray(eFlat, [nBoundary, 2]);
const { faces, points: outPoints } = tf.cdt(points, { edges });
// faces: NDArrayInt32 [K, 3] — triangles inside the outline
// outPoints: NDArrayFloat32 [M, 2] — vertex coordinates
Without edges, the result is the unconstrained convex-hull Delaunay triangulation of the input. With edges, only triangles inside the constrained outline are returned (parity flips at each boundary-constrained edge during a flood-fill from the convex hull).
Boundary vs. non-boundary constraints
By default every constraint is treated as a region boundary. To preserve a constraint as an edge of the output without having it split the interior in two — e.g., a feature line, an internal diagonal — pass an edgeMask:
// Rectangle (4 outline edges) + 2 diagonals through the centre
const edges = tf.ndarray(new Int32Array([
0, 1, 1, 2, 2, 3, 3, 0, // rectangle outline
0, 2, 1, 3, // diagonals (cross at the centre)
]), [6, 2]);
// All four outline edges are region boundaries; both diagonals are not.
const edgeMask = tf.ndarray(new Int8Array([1, 1, 1, 1, 0, 0]), [6]);
const { faces, points: outPoints } = tf.cdt(points, { edges, edgeMask });
// All four quarter-triangles kept — the rectangle is solid, the
// diagonals remain as preserved edges but don't divide the interior.
Tracking input points through the triangulation
Pass returnIndexMap: true to additionally get the input-point-to-output-point map as an IndexMap. For tf.cdt the map carries one extra subtlety:
f[i] === f.length(the sentinel) marks input points that fell on triangles dropped by the parity filter (i.e. landed outside the constrained outline).keptIds[k] === f.lengthmarks output slots that are synthetic intersection vertices created during constraint arrangement (no original input maps onto them).
const result = tf.cdt(points, { edges, returnIndexMap: true });
const { faces, points: outPoints, indexMap } = result;
// indexMap.f — NDArrayInt32, size = input count
// indexMap.keptIds — NDArrayInt32, size = output count
Internally the binding always works in float32 for points and int32 for face/edge indices. The exact-arithmetic integer width is auto-resolved on the C++ side from the input coordinate type.
Async
All functions are available as tf.async.isClosed(), tf.async.boundaryEdges(), tf.async.connectedComponents(), tf.async.cdt(), etc. Signatures are identical — they return Promise<T> instead of T.
const closed = await tf.async.isClosed(mesh);
const edges = await tf.async.boundaryEdges(mesh);
const { labels, nComponents } = await tf.async.connectedComponents(mesh, "edge");
const { faces, points: outPoints } = await tf.async.cdt(points, { edges });
