Examples | PY

Arrangements and Volumes

Building an arrangement from intersecting meshes, extracting closed bounded volumes, and selecting them by signed side of a knife.

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

Inputscube0closed · tag 0cube1closed · tag 1knifeopen plane · tag 2tf.mesh_arrangementsArrangementarr — merged mesh, every face split at every intersectiontag_labels[f] · face_labels[f]tf.cleaned + tag_labels[kept_faces]Cleaned + reindexed tagsarr — merged vertices, dropped degenerate & duplicate facestag_labels aligned to surviving faces via kept_facestf.domain_labelsDomain labelslabels_2d[f, 0] — domain containing f with reversed windinglabels_2d[f, 1] — domain containing f with forward windingn_domains — count of bounded interior regions flags: ignore_open_fragments=True, exclude_outer_shell=Truetf.split_into_domainsVolumesvolumes[i] — closed · manifold · outward-oriented submeshcomp_labels[i] — domain id of volumes[i] side-0 emissions reverse the winding · side-1 emissions keep it np.unique(labels_2d[knife, 0/1]) Signed side of the knifeabove_ids — domains receiving the knife with reversed windingbelow_ids — domains receiving the knife with forward winding

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 face f came from (0, 1, or 2 here).
  • 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.

This reindex pattern applies to any per-face attribute you want to carry through a cleaning step — colours, scalar fields, user IDs. The (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:

FlagEffect
ignore_open_fragmentsPark 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_shellFold 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 contains f with reversed winding (the side f's stored normal points INTO).
  • labels_2d[f, 1] — the domain that contains f with 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.unique handles 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

StepAPIWhat you get
Build arrangementtf.mesh_arrangementsOne merged mesh + tag_labels + face_labels
Cleantf.cleaned(..., return_index_map=True)Cleaned mesh + face/point index maps
Reindex attributesattr[kept_faces]Per-face attributes aligned to cleaned mesh
Label domainstf.domain_labels(mesh, flags)Per-face two-slot domain ids
Extract volumestf.split_into_domainsClosed, manifold, outward-oriented submeshes
Side selectionlabels_2d[knife, 0/1] + np.uniqueDomain ids on each signed side
Verifytf.is_closed / tf.is_manifoldStructural sanity per output
See Topology for domain_labels, is_closed, is_manifold; Reindex for split_into_domains; Cut for mesh_arrangements.