Booleans and Domains from One Build
One scene — a sphere straddling a knife plane, plus two floaters that touch nothing — queried three ways from one tf.CsgGraph build: the sides as boolean meshes, the volumes individually via expression-selected domains, and the same selection by hand from the inclusion matrix.

Source: arrangements.py
What the pipeline does
The scene
import numpy as np
import trueform as tf
def sphere(center, radius):
sf, sp = tf.make_sphere_mesh(radius, 32, 32)
return tf.Mesh(sf, np.asarray(sp) + np.asarray(center, sp.dtype))
plane_faces, plane_points = tf.make_plane_mesh(4.0, 4.0)
meshes = [sphere((0, 0, 0), 1.0), # op 0: straddles the knife
sphere((0, 0, 2), 0.5), # op 1: floats above, touches nothing
sphere((0, 0, -2), 0.5), # op 2: floats below, touches nothing
tf.Mesh(plane_faces, plane_points)] # op 3: the knife
Note the floaters never touch the knife — their side will come from winding alone; no cut geometry is needed to classify them.
One build
graph = tf.CsgGraph(meshes, sheets=[3])
solids = tf.op(0) | tf.op(1) | tf.op(2)
Declaring the plane a sheet makes op(3) an oriented separator: its operand bit means "behind the sheet's normal" (−Z here), so the knife cuts volumes through the same boolean algebra without enclosing one. Every query below reuses this build.
Path 1: boolean meshes
When you just need the two sides as meshes, one expression each:
above_mesh = graph.mesh(solids - tf.op(3))
below_mesh = graph.mesh(solids & tf.op(3))
=== Boolean meshes ===
solids - knife: vol=2.5949 closed=True
solids & knife: vol=2.5949 closed=True
Each side is a single closed mesh containing two disjoint pieces — the straddler's half capped by the knife, and the floater, whole.
Path 2: domains by expression
The same volumes, individually — one watertight mesh per cell:
above_cells, above_ids = graph.domains(solids - tf.op(3))
below_cells, below_ids = graph.domains(solids & tf.op(3))
=== Domains by expression ===
above: 2 cells, vols [2.076, 0.519]
below: 2 cells, vols [2.076, 0.519]
The two cells are exactly the pieces of the path-1 mesh (their volumes sum to it), now separately addressable.
Path 3: domains by hand
Extract everything once, then any selection is a mask over the inclusion matrix — the knife's column is 3, and behind its +Z normal means below:
cells, ids, imap = graph.domains(return_index_map=True)
below = imap.inclusion[:, 3]
above = ~below
=== Domains by hand ===
4 cells; above 2, below 2
The masks select the same cells the path-2 expressions return — ids are stable across queries on one graph, so the example asserts it:
assert sorted(np.asarray(ids)[above].tolist()) == sorted(np.asarray(above_ids).tolist())
assert sorted(np.asarray(ids)[below].tolist()) == sorted(np.asarray(below_ids).tolist())
Writing and verifying
def write_side(mask, prefix):
k = 0
for (faces, points), keep in zip(cells, mask):
if not keep:
continue
m = tf.Mesh(faces, points)
tf.write_stl(m, f"{prefix}_{k}.stl")
k += 1
write_side(above, "above")
write_side(below, "below")
wrote above_0.stl (faces=1088, closed=True, manifold=True)
wrote above_1.stl (faces=1984, closed=True, manifold=True)
wrote below_0.stl (faces=1152, closed=True, manifold=True)
wrote below_1.stl (faces=1984, closed=True, manifold=True)
Summary
| Path | API | What you get |
|---|---|---|
| Build | tf.CsgGraph(meshes, sheets=[3]) | Arrangement + domain classification, once |
| 1 — boolean mesh | graph.mesh(solids - tf.op(3)) | One closed mesh per side |
| 2 — domains by expression | graph.domains(expr) | The same volumes, individually |
| 3 — domains by hand | graph.domains(return_index_map=True) + imap.inclusion mask | Same cells, stable ids |
