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.
import trueform as tf
Overview
- Build —
tf.CsgGraph([...])computes the arrangement and its domain classification. This is where the heavy work happens. - 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.intersection_curves()— the seam polylines where surfaces cross
graph = tf.CsgGraph([mesh_a, mesh_b, mesh_c])
faces, points = graph.mesh(tf.op(0) - tf.op(1)) # boolean difference
cells, ids = graph.domains() # volumetric decomposition
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 list — combined with Python operators:
| Operator | Meaning |
|---|---|
a | b | union — inside any |
a & b | intersection — inside every |
a - b | difference — inside a, outside b |
~a | complement — outside a |
Once one side is an expression, plain integers auto-promote: tf.op(0) - 1 works; only the leading leaf needs tf.op.
carved = tf.op(0) - (tf.op(1) | tf.op(2))
shared = tf.op(0) & tf.op(1) & tf.op(2)
outside = ~(tf.op(0) | 1 | 2)
Building the Graph
graph = tf.CsgGraph(
meshes, # two or more 3D triangle meshes, same dtypes
sheets=[2], # optional: operands declared as open sheets
mode="primitives", # intersection mode ("sos" or "primitives")
tolerance=0.0, # predicate tolerance band (0 = exact)
triangulation="cdt", # cut-surface triangulation (see below)
)
All meshes must share index dtype (int32/int64), real dtype (float32/float64), and be triangle meshes — call tf.triangulated() on n-gon meshes first. Structures already built on the meshes (tree, face membership, manifold edge link) are reused, not rebuilt.
Everything passed at construction is remembered on the graph:
graph.forms # the input meshes, as passed
graph.sheets # sheet indices, as passed
graph.mode # "primitives"
graph.triangulation # "cdt"
graph.created_points # points the arrangement created, (K, 3), input dtype
Cut-Surface Triangulation
triangulation selects how cut faces are triangulated:
"cdt"(default) — plain constrained Delaunay per cut loop."refined_cdt"— 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. Its bit is anchored per region to the side behind its normal.
Boolean Meshes
faces, points = graph.mesh(tf.op(0) - tf.op(1))
With no expression, graph.mesh() returns the full arrangement mesh — every input face, cut at intersections, each surface emitted once.
Face Provenance
Pass return_source_ids=True to also recover, for every output face, which input mesh it came from and which original face within it:
(faces, points), tag_labels, face_labels = graph.mesh(
tf.op(0) | tf.op(1), return_source_ids=True)
Index Maps
Pass return_index_map=True for a MeshIndexMap that folds in the face provenance and adds the point axis — inverse maps for every output point and face, plus a forward map from each input point to its output index:
(faces, points), imap = graph.mesh(tf.op(0) - 1, return_index_map=True)
imap.point_tag_labels # output point -> input mesh (created -> n_tags)
imap.point_labels # output point -> input point id
imap.face_tag_labels # output face -> input mesh
imap.face_labels # output face -> original face id
imap.point_f # forward: point_f[tag][input id] -> output id
return_source_ids and return_index_map are exclusive; the index map already carries the face labels. The index-map form requires an expression.
Domain Decomposition
graph.domains() returns every kept volumetric domain as its own watertight mesh, with ids[k] the coarse domain id of cell k:
cells, ids = graph.domains()
for (faces, points), domain_id in zip(cells, ids):
...
With an expression, only domains inside that selection are returned:
inter_cells, _ = graph.domains(tf.op(0) & tf.op(1))
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. The Arrangements and Volumes example runs this pattern end to end.
Options: exclude_outer_shell=True drops the unbounded outside domain; ignore_open_fragments=True fuses open fragments (fins, damage) instead of letting them partition volumes. Both default on.
return_source_ids=True adds two OffsetBlockedArrays of per-cell face provenance, parallel to cells; return_index_map=True adds a DomainsIndexMap with per-cell face and point maps:
cells, ids, tag_blocks, face_blocks = graph.domains(return_source_ids=True)
cells, ids, imap = graph.domains(return_index_map=True)
imap.point_tag_blocks[k] # cell k's per-point input meshes
Cell Classification
cells, ids, imap = graph.domains(return_index_map=True)
imap.inclusion # (n_cells, n_ops) bool: cell k inside form i
only_a = imap.inclusion[:, 0] & ~imap.inclusion[:, 1] # inside A, outside B
core = imap.inclusion.all(axis=1) # inside every operand
a_cells = [c for c, keep in zip(cells, only_a) if keep]
imap.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".
cells, ids, imap = graph.domains(exclude_outer_shell=False,
return_index_map=True)
outer = ~imap.inclusion.any(axis=1) # the outer-shell cells
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. exclude_outer_shell=True (the default) drops
precisely these rows.
Sheets and Open Fragments
For a sheet, ignore_open_fragments 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 exclude_outer_shell=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 exclude_outer_shell (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):
paths, curve_points = graph.intersection_curves()
for path in paths: # OffsetBlockedArray of point indices
polyline = curve_points[path]
Many Operations, One Graph
graph = tf.CsgGraph([a, b, c])
union = graph.mesh(tf.op(0) | 1 | 2)
carved = graph.mesh(tf.op(0) - 1 - 2)
core = graph.mesh(tf.op(0) & 1 & 2)
cells, ids = graph.domains()
Four results, one arrangement build. This is the pattern for interactive workflows: build on load, query per user action.
