Modules | TS

CSG

N-form boolean expressions over one arrangement, built once.

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

  • Buildtf.csgGraph([...]) computes the arrangement and its domain classification. This is where the heavy work happens; tf.async.csgGraph runs 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 expression
    • graph.domains() — every kept volumetric domain as its own watertight mesh
    • graph.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:

BuilderMeaning
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.