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", // intersection mode ("sos" or "primitives")
tolerance: 0, // predicate tolerance band (0 = exact)
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 // { mode, tolerance, resolveCrossings, triangulation }
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.
Sheets
An operand listed in sheets is treated as an oriented open surface that cuts volumes through the same boolean algebra without enclosing one — a terrain horizon splitting a block, for example.
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.
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;
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.
Intersection Curves
The seam network of the arrangement — the polylines where surfaces of different operands cross (coincident walls excluded):
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.
