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

  • Buildtf.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,                        # 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.