Modules | PY

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.

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 expression
    • graph.domains() — every kept volumetric domain as its own watertight mesh
    • graph.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:

OperatorMeaning
a | bunion — inside any
a & bintersection — inside every
a - bdifference — inside a, outside b
~acomplement — 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,                        # one or more 3D meshes, same face kind and dtypes
    sheets=[2],                    # optional: operands declared as open sheets
    mode="primitives",             # the contact classifier ("primitives" or "sos")
    tolerance=0.0,                 # input placement 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) and real dtype (float32/float64), and be all triangle or all dynamic — a mixed set is refused; convert the triangle operands to dynamic faces (or tf.triangulated() the dynamic ones) first. Fixed n-gon meshes are refused too: call tf.triangulated() on them. On a dynamic graph every mesh read returns its faces as an OffsetBlockedArray. A single mesh is its own self arrangement: its self-intersections are the whole cut, and domains() classifies its overlap pockets. 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

The native intersection config remains {mode, tolerance}; the Python within=True option composes primitives | within before crossing nanobind.

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 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):

readresult
graph.mesh(tf.op(1) - tf.op(0)) / tf.op(1) & tf.op(0)the box's upper / lower half, closed and capped by the sheet
graph.mesh(tf.op(0) - 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) & ~tf.op(1), 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), 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, and its half-space in an expression is empty (a fin that separates nothing satisfies no tf.op). graph.domains() drops every domain whose inclusion row is all-false — the unbounded outside among them; pass exclude_outer_shell=False, ignore_open_fragments=False to also keep the open regions a sheet's dangling parts bound, where they bound any. An open operand is a volume unless declared a sheet, and a volume's open fragments are fused during classification, unconditionally — no flag governs them.

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.

Selections

An expression names a region; a selection 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:

reademits a piece of the named surfaces iffwinding
graph.mesh(e, selection=[i, ...])exactly one of its two sides satisfies e — the piece bounds the regionoutward from the region
graph.mesh(e, inside=[i, ...])both of its sides satisfy e — the piece lies inside the regionthe operand's stored winding, never flipped
graph.mesh(selection=[i, ...])always — the embedded read: the surfaces cut by everything, no classificationstored

The two keywords are exclusive, and inside= requires an expression. Both accept any integer sequence; an empty list is every operand.

graph = tf.CsgGraph([a, b, horizon], sheets=[2])
e = (tf.op(0) | tf.op(1)) - tf.op(2)

solid, pts = graph.mesh(e)                       # the region's boundary
part_a, _ = graph.mesh(e, selection=[0])         # A's walls of it
in_a, _ = graph.mesh(tf.op(0), inside=[2])       # the horizon inside A
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) & 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 return_source_ids=True and return_index_map=True accept either kind.

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
imap.uncut_faces        # (n_tags, 2): [begin, end) faces kept whole per mesh

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 every domain no operand claims — the unbounded outside among them. ignore_open_fragments=True fuses the open regions a sheet's dangling parts bound back into their surroundings; it governs sheets only — a volume's open fragments are always fused, before any read. 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 domains(). 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, a dangling part whose two sides reach distinct regions separates them: 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. A fin whose free rim ends inside a volume, both sides flooding to one region, separates nothing under any flag. The flag belongs to domains() alone: expression reads through graph.mesh() always see the sheet's virtual partition, wherever it separates. 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 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 CsgGraph operand.

faces, points = tf.concatenated([a, b])   # self-intersecting solid
merged = tf.Mesh(faces, points)
shell = tf.outer_shell(merged)       # clean union boundary

The extraction is structural — no winding bits, no expression, no options. Open fragments (fins, damage) are fused by the classification, so they bound no volume and cannot survive into the shell. An uncut vertex reaches the output with its input coordinate untouched.

On a built graph the same read is a query: graph.outer_shell() returns the boundary between the unbounded universe and everything the operands enclose, oriented outward — reusing the arrangement the graph already holds.

graph = tf.CsgGraph([a, b, c])
faces, points = graph.outer_shell()   # the boundary of everything enclosed

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.

Boolean Operations

Boolean operations select regions from a mesh arrangement to produce a single result mesh. tf.boolean_union, tf.boolean_intersection and tf.boolean_difference are the two-operand shortcut: each builds the arrangement and evaluates the operation in one call, without the caller ever holding a graph.

# Union: A ∪ B
(faces, points), labels, face_labels = tf.boolean_union(mesh0, mesh1)

# Intersection: A ∩ B
(faces, points), labels, face_labels = tf.boolean_intersection(mesh0, mesh1)

# Difference: A - B
(faces, points), labels, face_labels = tf.boolean_difference(mesh0, mesh1)

labels is which input mesh (0 or 1) each output face came from; face_labels is the original face index within that mesh.

Each call builds a two-operand CsgGraph and answers the operation as an expression against it. A mixed pair is normalized to one representation first — triangle faces re-expressed as dynamic blocks, a narrower index dtype widened to int64 — so the result faces are an OffsetBlockedArray when either input is dynamic, and carry the common index dtype. Transformations set on the operands carry through the normalization.

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:

(faces, points), labels, face_labels, (paths, curve_pts) = tf.boolean_union(
    mesh0, mesh1, return_curves=True)

With transformations:

import numpy as np
mesh1.transformation = np.eye(4, dtype=np.float32)
mesh1.transformation[:3, 3] = [5, 0, 0]

(faces, points), labels, face_labels = tf.boolean_union(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.