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)
within=False, # also self-arrange each operand (see below)
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.
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. 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 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.
Outer Shell
tf.outer_shell repairs a self-intersecting mesh into a clean shell: the boundary of the union of everything it encloses.
shell = tf.outer_shell(mesh)
The mesh is split at its self-intersection curves, the volumetric domains are labeled, 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 — is removed. The result is free of self-intersections and suitable as a boolean or CsgGraph operand.
faces, points = tf.concatenated([a, b]) # self-intersecting solid
merged = tf.Mesh(faces, points)
shell = tf.outer_shell(merged) # clean union boundary
Options: ignore_open_fragments=True masks open fragments (surface pieces carrying boundary edges) out of the region formation instead of letting them partition. Default False.
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:
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.
Outer Shell
tf.outer_shell(mesh) repairs a self-intersecting solid to its outer shell — the boundary of the union of everything it encloses:
shell = tf.outer_shell(mesh) # Mesh in, Mesh out
The mesh is split at its self-intersection curves, its volumetric domains are labeled, 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 — is removed. The result is free of self-intersections and suitable as a boolean or CsgGraph operand.
ignore_open_fragments=True masks open fragments (fins, damage) out of the region formation, so they do not partition the enclosed volume:
shell = tf.outer_shell(mesh, ignore_open_fragments=True)
