CSG
The CSG module builds an arrangement of N meshes once and evaluates any boolean expression over them — no chaining of pairwise booleans, no recomputation between queries.
Overview
- Build —
tf.csgGraph([...])computes the arrangement and its domain classification. This is where the heavy work happens;tf.async.csgGraphruns it off the main thread. - Query — every call after that is cheap and reuses the same build:
graph.mesh(expr)— the boolean result mesh for any expressiongraph.domains()— every kept volumetric domain as its own watertight meshgraph.intersectionCurves()— the seam polylines where surfaces cross
const graph = tf.csgGraph([meshA, meshB, meshC]);
const diff = graph.mesh(tf.op(0).sub(tf.op(1))); // boolean difference
const { meshes, ids } = graph.domains(); // volumetric decomposition
graph.delete(); // explicit lifetime, like Mesh
A sequence of operations costs one arrangement, not one per operation.
Building Expressions
Expressions are built from tf.op(i) leaves — i is the operand's index in the mesh array — combined with builder methods:
| Builder | Meaning |
|---|---|
a.or(b) | union — inside any |
a.and(b) | intersection — inside every |
a.sub(b) | difference — inside a, outside b |
a.not() | complement — outside a |
Plain integers auto-promote as arguments: tf.op(0).sub(1) works.
const carved = tf.op(0).sub(tf.op(1).or(2));
const shared = tf.op(0).and(1).and(2);
const outside = tf.op(0).or(1).not();
Building the Graph
const graph = tf.csgGraph(meshes, {
sheets: [2], // optional: operands declared as open sheets
mode: "primitives", // the contact classifier ("primitives" or "sos")
tolerance: 0, // input placement band (0 = exact)
within: false, // also self-arrange each operand (see below)
triangulation: "cdt", // cut-surface triangulation (see below)
});
// or off the main thread:
const graph = await tf.async.csgGraph(meshes, { triangulation: "refinedCdt" });
All meshes must share the dtype (float32/float64). Structures already built on the meshes (tree, topology) are reused, not rebuilt.
Everything passed at construction is remembered on the graph:
graph.forms // the input Mesh array, as passed
graph.sheets // sheet indices, as passed
graph.config // surface echo: { mode, tolerance, within, triangulation }
// native intersection config: { mode, tolerance }, with within in the mode bit
graph.createdPoints // NDArray [K, 3] of created points, input dtype
Cut-Surface Triangulation
triangulation selects how cut faces are triangulated:
"cdt"(default) — plain constrained Delaunay per cut loop."refinedCdt"— quality refinement of the cut surface (Ruppert circumcenter insertion). Boundary splits are negotiated globally, so shared loop boundaries stay watertight by construction; refined outputs carry more created points.
Self-Overlapping Operands
within: true also intersects each operand with itself. Use it when an operand can self-overlap — meshes concatenated into one operand, an instanced part whose copies touch. graph.domains() then classifies the overlap pockets structurally, so the extraction matches what the same meshes would produce as separate operands, cell for cell. Boolean expressions still require solid, non-self-overlapping operands.
Sheets
An operand listed in sheets is an oriented separator: it bounds no volume, it cuts and is cut like any other operand, and tf.op(i) is the half-space behind its normal. Every boolean expression composes sheets and volumes alike. A region bounded by a volume and a sheet is closed and capped; a region bounded only by sheets is unbounded and comes back open along their rims. Two crossing sheets carve four wedges, each a domain with its own inclusion row.
With A a plane at z = 0 (normal +z, operand 0, declared a sheet) and B a unit box (operand 1):
| read | result |
|---|---|
graph.mesh(tf.op(1).sub(tf.op(0))) / tf.op(1).and(tf.op(0)) | the box's upper / lower half, closed and capped by the sheet |
graph.mesh(tf.op(0).sub(tf.op(1))) | the boundary of the unbounded region below the sheet and outside the box: the sheet's annulus plus the box's lower walls, open along the sheet's rim |
graph.mesh(tf.op(0).not().and(tf.op(1).not()), { selection: [0] }) | the sheet outside the box (the annulus), wound away from that region |
graph.mesh(tf.op(1), { inside: [0] }) | the sheet inside the box (the cap), the sheet's own winding |
graph.mesh(tf.op(1).not(), { inside: [0] }) | the annulus, the sheet's own winding |
graph.mesh(tf.op(1), { selection: [0] }) | nothing: both sides of the cap are inside the box, so no piece of the sheet bounds tf.op(1) |
graph.mesh(tf.op(0), { inside: [1] }) | the box's walls below the sheet, outward |
graph.mesh(tf.op(0), { inside: [0] }) | nothing: a sheet's own bit differs across each of its pieces |
A sheet piece that separates nothing — a flap wholly inside a volume, its rim touching no other operand — is inside whatever contains it: graph.mesh(tf.op(volume), { inside: [flap] }) emits it once; the boundary reads see it from either side. graph.domains() drops unbounded regions by default; pass { excludeOuterShell: false, ignoreOpenFragments: false } to keep every wedge when every sheet separates. An open operand is a volume unless declared a sheet, and a volume's open fragments are fused away.
Boolean Meshes
const result = graph.mesh(tf.op(0).sub(tf.op(1))); // Mesh
With no expression, graph.mesh() returns the full arrangement mesh — every input face, cut at intersections, each surface emitted once.
Selections
An expression names a region; CsgMeshOptions says what to emit of which surfaces against it. selection restricts the boolean to the faces the named operands contributed, and inside reads the named operands' surface that lies inside the region rather than around it:
| read | emits a piece of the named surfaces iff | winding |
|---|---|---|
graph.mesh(e, { selection: [i, ...] }) | exactly one of its two sides satisfies e — the piece bounds the region | outward from the region |
graph.mesh(e, { inside: [i, ...] }) | both of its sides satisfy e — the piece lies inside the region | the operand's stored winding, never flipped |
graph.mesh({ selection: [i, ...] }) | always — the embedded read: the surfaces cut by everything, no classification | stored |
The two keys are exclusive, and inside requires an expression. An empty list is every operand.
const graph = tf.csgGraph([a, b, horizon], { sheets: [2] });
const e = tf.op(0).or(tf.op(1)).sub(tf.op(2));
const solid = graph.mesh(e); // the region's boundary
const partA = graph.mesh(e, { selection: [0] }); // A's walls of it
const inA = graph.mesh(tf.op(0), { inside: [2] }); // the horizon inside A
const embedded = graph.mesh({ selection: [0] }); // A cut by all, no boolean
An inside piece bounds nothing of the region, so it has no outward side; it keeps the winding its operand was given, which for a sheet is the orientation tf.op(i) is defined by. Where e does not name the selected operand, { inside: [i] } is exactly { selection: [i] } on tf.op(i).and(e) — same faces, same winding. Where it does, each side of a piece is read with that side's own bit, so graph.mesh(tf.op(i), { inside: [i] }) is empty. A volume's shell inside another operand is an inside read too. graph.domains() takes selection only: a surface inside a domain is on no cell's boundary. Both returnSourceIds and returnIndexMap accept either kind, and tf.async.csgMesh mirrors the whole option set.
Face Provenance
const { mesh, tagLabels, faceLabels } =
graph.mesh(tf.op(0).or(1), { returnSourceIds: true });
// tagLabels[f] -> which input mesh face f came from
// faceLabels[f] -> the original face id within it
Index Maps
const im = graph.mesh(tf.op(0).sub(1), { returnIndexMap: true });
im.pointTagLabels; // output point -> input mesh (created -> nTags)
im.pointLabels; // output point -> input point id
im.faceTagLabels; // output face -> input mesh
im.faceLabels; // output face -> original face id
im.pointFOffsets; // forward map blocks: offsets[tag] slices pointFData
im.pointFData;
im.uncutFaces; // (nTags, 2): [begin, end) faces kept whole per mesh
returnSourceIds and returnIndexMap are exclusive; the index map already carries the face labels. The index-map form requires an expression.
Domain Decomposition
const { meshes, ids } = graph.domains(); // one watertight Mesh per domain
The expression is optional and the options can take its place — no null placeholder needed:
graph.domains(); // every kept domain
graph.domains(tf.op(0).and(1)); // inside the selection
graph.domains({ returnSourceIds: true }); // options only
graph.domains(tf.op(0), { returnIndexMap: true }); // both
The same selection can be made by hand from one full extraction — see Cell Classification below; ids are stable across queries on one graph, so the two routes agree cell for cell.
Options: excludeOuterShell drops the unbounded outside domain; ignoreOpenFragments fuses open fragments (fins, damage) instead of letting them partition volumes. Both default on.
returnSourceIds adds per-cell face-provenance blocks (tagOffsets/tagData, faceOffsets/faceData) parallel to meshes; returnIndexMap adds per-cell face and point maps plus the sentinels (nTags, nOutputPoints, nOriginalPoints) and the inclusion matrix.
Cell Classification
const im = graph.domains({ returnIndexMap: true });
const inc = im.inclusion; // [nCells, nTags] boolean NDArray, row-major
const d = inc.data;
const onlyA = im.meshes.filter((_, k) =>
d[k * im.nTags + 0] && !d[k * im.nTags + 1]); // inside A, outside B
inclusion classifies every cell against every operand, so one extraction
answers every selection — a mask picks the same cells the equivalent
expression query would return. A sheet operand's column means "behind the
sheet's normal".
const all = graph.domains({ excludeOuterShell: false, returnIndexMap: true });
const outer = all.meshes.filter((_, k) => // the outer-shell cells
!all.inclusion.data.slice(k * all.nTags, (k + 1) * all.nTags).some(Boolean));
The outer shell is the space inside no operand — the unbounded outside,
plus any void enclosed by nothing. Its cells are exactly the all-false rows,
and there can be several: disjoint operand clusters each bound their own
patch of the outside. excludeOuterShell: true (the default) drops
precisely these rows.
Sheets and Open Fragments
For a sheet, ignoreOpenFragments governs whether its dangling part — fragments that seal nothing, like a knife's rim poking past the solids — partitions space. With the default (true), only the sealed portion of the sheet cuts; the outside stays whole, and with excludeOuterShell: false it returns as one closed inverted cell. With false, the dangling part separates too: the outside splits into a front and a behind half, each an open inverted mesh — and the behind half carries the sheet bit, so excludeOuterShell (which drops all-false rows) keeps it. Sheet bits are half-space indicators for bounded and unbounded regions alike: behind the normal is "inside", everywhere.
Outer Shell
tf.outerShell repairs a self-intersecting mesh into a clean shell: the boundary of the union of everything it encloses.
const shell = tf.outerShell(mesh);
// or off the main thread:
const shell = await tf.async.outerShell(mesh);
The mesh is read through its own CSG graph — the self arrangement plus the classification tier — and only the faces bounding the unbounded outside are kept, oriented outward. Internal structure — overlap membranes between interpenetrating parts, faces buried inside the solid, enclosed cavities — has the same domain on both sides and so never reaches the boundary. The result is free of self-intersections and suitable as a boolean or csg-graph operand.
const merged = tf.concatenateMeshes([a, b]); // self-intersecting solid
const shell = tf.outerShell(merged); // clean union boundary
The extraction is structural — no winding bits, no expression, no options. Open fragments (fins, damage) are self-merged by the arrangement, so they bound no volume and cannot survive into the shell. An uncut vertex reaches the output with its input coordinate untouched.
Intersection Curves
The seam network of the arrangement — the polylines where surfaces of different operands cross; a coincident (coplanar) overlap contributes its contact border while the overlap's interior stays silent:
const curves = graph.intersectionCurves(); // Curves, input dtype
curves.delete();
Async
Every query has an async twin under tf.async, running on the worker pool:
const graph = await tf.async.csgGraph(meshes);
const diff = await tf.async.csgMesh(graph, tf.op(0).sub(1));
const doms = await tf.async.csgDomains(graph, { returnSourceIds: true });
const seams = await tf.async.csgIntersectionCurves(graph);
Memory
CsgGraph follows the same lifetime rules as Mesh: call .delete() when done (or use using / [Symbol.dispose]); a FinalizationRegistry backstop releases leaked handles. Meshes returned by queries are independent and need their own .delete().
Many Operations, One Graph
const graph = await tf.async.csgGraph([a, b, c]);
const union = graph.mesh(tf.op(0).or(1).or(2));
const carved = graph.mesh(tf.op(0).sub(1).sub(2));
const { meshes, ids } = graph.domains();
graph.delete();
Three results, one arrangement build. This is the pattern for interactive workflows: build on load (async), query per user action.
Boolean Operations
Boolean operations select regions from a mesh arrangement to produce a single result mesh. tf.booleanUnion, tf.booleanIntersection and tf.booleanDifference are the two-operand shortcut: each builds the arrangement and evaluates the operation in one call, without the caller ever holding a graph.
const { mesh, labels, faceLabels } = tf.booleanUnion(mesh0, mesh1);
const { mesh, labels, faceLabels } = tf.booleanIntersection(mesh0, mesh1);
const { mesh, labels, faceLabels } = tf.booleanDifference(mesh0, mesh1);
labels is which input mesh (0 or 1) each output face came from; faceLabels is the original face index within that mesh.
Booleans use primitives internally — with two meshes every contour is of the one class (A,B), and same-class crossings are within's territory.
With curves:
const { mesh, labels, faceLabels, curves } = tf.booleanUnion(mesh0, mesh1, {
returnCurves: true,
});
With transformations:
mesh1.transformation = tf.makeTranslation([5, 0, 0]);
const { mesh, labels, faceLabels } = tf.booleanUnion(mesh0, mesh1);
Input meshes must be closed and PWN (piecewise winding number) — locally consistent orientation — so the intersection curves split them into separate inside/outside regions. To repair a self-intersecting operand first, see Outer Shell.
Each has an async twin, with the same overloads:
const { mesh, labels, faceLabels } = await tf.async.booleanUnion(mesh0, mesh1);
const { mesh, labels, faceLabels, curves } = await tf.async.booleanDifference(mesh0, mesh1, {
returnCurves: true,
});
