Arrangements and Volumes
This walkthrough takes a small CSG-flavoured scene — two overlapping cubes and a bisecting plane — and pushes it through trueform's arrangement pipeline. The result is a set of closed, manifold, outward-oriented volumes, each labelled by which signed side of the plane it sits on.
Source: arrangements.py
What the pipeline does
tf.mesh_arrangements splits every face at every intersection and merges everything into one mesh, tagging each face with its source operand. tf.domain_labels then partitions space into bounded volumetric regions ("domains"). tf.split_into_domains finally emits one watertight outward-oriented submesh per domain. We add a small post-step that reads the slot convention on knife faces to label which volumes are above vs below the knife.
Building the scene
Two unit cubes offset along the x-axis so they overlap in the middle, and a 4×4 plane on z=0:
import numpy as np
import trueform as tf
cube_faces, cube_points = tf.make_box_mesh(2.0, 2.0, 2.0)
plane_faces, plane_points = tf.make_plane_mesh(4.0, 4.0)
cube0 = tf.Mesh(cube_faces.copy(), cube_points + np.array([-0.5, 0, 0], dtype=cube_points.dtype))
cube1 = tf.Mesh(cube_faces.copy(), cube_points + np.array([+0.5, 0, 0], dtype=cube_points.dtype))
knife = tf.Mesh(plane_faces, plane_points) # tag 2
The two cubes alone produce three bounded regions (cube0-only, intersection, cube1-only). The plane bisects all three, so we expect six bounded domains.
Building the arrangement
(arr_faces, arr_points), tag_labels, face_labels = tf.mesh_arrangements(
[cube0, cube1, knife])
The return is three pieces:
(arr_faces, arr_points)— the merged triangle mesh with all intersections resolved.tag_labels[f]— which input operand facefcame from (0,1, or2here).face_labels[f]— which face within that operand it came from.
For our scene this produces 176 faces, 48 points before cleaning.
Cleaning and reindexing tags
tf.cleaned does three things in one pass: it merges coincident vertices within a tolerance, drops topologically degenerate triangles (faces that collapse to a point or edge after merging), and removes duplicate triangles. With return_index_map=True it also returns index maps describing exactly what happened to each face and point, so per-face attributes like tag_labels can be reindexed through them to stay aligned with the cleaned mesh:
(arr_faces, arr_points), (face_f, kept_faces), _ = tf.cleaned(
(arr_faces, arr_points), 1e-6, return_index_map=True)
tag_labels = tag_labels[kept_faces]
arr = tf.Mesh(arr_faces, arr_points)
kept_faces is a numpy array of the surviving old face ids in new order, so tag_labels[kept_faces] produces a new array of the right length aligned to the cleaned mesh. In our scene cleaning takes us from 176 → 114 faces, 48 → 40 points.
(f, kept_ids) index map returned by index-destructive operations is the canonical handle.Computing domain labels
labels_2d, n_domains, _ = tf.domain_labels(
arr, ignore_open_fragments=True, exclude_outer_shell=True)
Two flags shape the output:
| Flag | Effect |
|---|---|
ignore_open_fragments | Park face-sides bounding open fragments (in our scene: the plane's outer ring that pokes outside the cube union) at the sentinel label. |
exclude_outer_shell | Fold the unbounded universe domain into the same sentinel. |
What survives is the bounded interior. For our scene n_domains == 6. Each face f carries two domain ids:
labels_2d[f, 0]— the domain that containsfwith reversed winding (the sidef's stored normal points INTO).labels_2d[f, 1]— the domain that containsfwith forward winding.
That two-slot encoding is what makes the signed-side selection in the next step trivial.
Extracting the volumes
volumes, comp_labels = tf.split_into_domains(arr, (labels_2d, n_domains))
volumes[i] is a (faces, points) tuple — a standalone watertight, manifold, outward-oriented submesh for the domain comp_labels[i]. split_into_domains reverses side-0 emissions and keeps side-1 emissions, so each cap face ends up pointing outward of its volume by construction.
For our scene: 6 volumes.
Selecting volumes by signed side of the knife
The knife (tag 2) has stored normal +Z. By the slot convention above, labels_2d[f, 0] for an interior knife face is the domain on the +Z side, and labels_2d[f, 1] is on the −Z side. We mask to knife faces, drop sentinel hits, and dedupe with np.unique:
knife_mask = tag_labels == 2
above = labels_2d[knife_mask, 0]
below = labels_2d[knife_mask, 1]
inside = (above < n_domains) & (below < n_domains)
above_ids = np.unique(above[inside])
below_ids = np.unique(below[inside])
A few things worth pulling out:
- Boolean masking (
tag_labels == 2) picks out the knife faces in one vectorised step. - Sentinel skip (
< n_domains) drops the knife faces that landed in the outer ring — those don't bound any real domain. np.uniquehandles dedup. The knife can hit several closed regions (here: cube0-only, intersection, cube1-only), so each side ends up with multiple domain ids.
For our scene: 3 above, 3 below. The cube0-only, intersection, and cube1-only volumes each contribute one upper and one lower half.
Mapping domain ids to volume indices
comp_labels[i] tells us which domain volumes[i] represents. A scatter inverts that in one line:
domain_to_idx = np.full(n_domains, -1, dtype=np.int64)
domain_to_idx[comp_labels] = np.arange(len(comp_labels))
Writing volumes and verifying
def write_side(ids, prefix):
for k, d in enumerate(ids):
vf, vp = volumes[int(domain_to_idx[d])]
m = tf.Mesh(vf, vp)
fname = f"{prefix}_{k}.stl"
tf.write_stl(m, fname)
print(f" wrote {fname} "
f"(faces={len(vf)}, "
f"closed={tf.is_closed(m)}, "
f"manifold={tf.is_manifold(m)})")
write_side(above_ids, "above")
write_side(below_ids, "below")
tf.is_closed checks that every edge is shared by exactly two faces (no boundary), and tf.is_manifold checks that no edge is shared by three or more. Every output volume should print closed=True, manifold=True — the structural guarantee split_into_domains makes is verified at the boundary.
Running the example writes six STL files and prints:
=== Arrangement ===
Faces: 176
Points: 48
=== Cleaned ===
Faces: 114
Points: 40
=== Domain labels ===
Bounded domains: 6
Volumes extracted: 6
=== Signed side of the knife ===
Above (+normal): 3 volumes
Below (-normal): 3 volumes
wrote above_0.stl (faces=22, closed=True, manifold=True)
wrote above_1.stl (faces=24, closed=True, manifold=True)
wrote above_2.stl (faces=22, closed=True, manifold=True)
wrote below_0.stl (faces=24, closed=True, manifold=True)
wrote below_1.stl (faces=22, closed=True, manifold=True)
wrote below_2.stl (faces=22, closed=True, manifold=True)
The 24-face volumes are the intersection halves — they pick up parts of both cubes' inner walls, so their boundary surface is slightly larger than the cube-only halves.
Summary
| Step | API | What you get |
|---|---|---|
| Build arrangement | tf.mesh_arrangements | One merged mesh + tag_labels + face_labels |
| Clean | tf.cleaned(..., return_index_map=True) | Cleaned mesh + face/point index maps |
| Reindex attributes | attr[kept_faces] | Per-face attributes aligned to cleaned mesh |
| Label domains | tf.domain_labels(mesh, flags) | Per-face two-slot domain ids |
| Extract volumes | tf.split_into_domains | Closed, manifold, outward-oriented submeshes |
| Side selection | labels_2d[knife, 0/1] + np.unique | Domain ids on each signed side |
| Verify | tf.is_closed / tf.is_manifold | Structural sanity per output |
