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::make_csg_graph 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.cpp
What the pipeline does
The scene
auto straddle = tf::make_sphere_mesh<Index>(Real(1), 32, 32); // op 0
auto above_s = tf::make_sphere_mesh<Index>(Real(0.5), 32, 32); // op 1: floats above
auto below_s = tf::make_sphere_mesh<Index>(Real(0.5), 32, 32); // op 2: floats below
auto plane = tf::make_plane_mesh<Index>(Real(4), Real(4)); // op 3: the knife
auto p0 = straddle.polygons() | tf::tag(fid);
auto p1 = above_s.polygons() | tf::tag(fa); // frame: +2 in z
auto p2 = below_s.polygons() | tf::tag(fb); // frame: -2 in z
auto p_knife = plane.polygons() | tf::tag(fid);
std::vector<decltype(p0)> forms{p0, p1, p2, p_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
std::array<int, 1> sheets{3};
auto graph =
tf::make_csg_graph(tf::make_range(forms), tf::make_range(sheets));
auto solids = tf::csg::merge(tf::csg::merge(0, 1), 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:
auto above_mesh = tf::make_csg_mesh(graph, tf::csg::difference(solids, 3));
auto below_mesh = tf::make_csg_mesh(graph, tf::csg::intersection(solids, 3));
=== Boolean meshes ===
solids - knife: vol=2.59494 closed=1
solids & knife: vol=2.59494 closed=1
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:
auto [above_cells, above_ids] =
tf::make_csg_domains(graph, tf::csg::difference(solids, 3));
auto [below_cells, below_ids] =
tf::make_csg_domains(graph, tf::csg::intersection(solids, 3));
=== Domains by expression ===
above: 2 cells
below: 2 cells
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:
auto [cells, ids, imap] =
tf::make_csg_domains(graph, tf::return_index_map);
tf::buffer<char> below;
below.allocate(cells.size());
for (std::size_t k = 0; k < cells.size(); ++k)
below[k] = imap.inclusion[k][3] ? char(1) : char(0);
=== 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.
Writing and verifying
auto write_side = [&, &cs = cells](bool want_below, const char *prefix) {
std::size_t k = 0;
for (std::size_t i = 0; i < cs.size(); ++i) {
if (bool(below[i]) != want_below)
continue;
tf::write_stl(cs[i].polygons(),
std::string(prefix) + "_" + std::to_string(k++) + ".stl");
}
};
write_side(false, "above");
write_side(true, "below");
wrote above_0.stl (faces=1088, closed=1, manifold=1)
wrote above_1.stl (faces=1984, closed=1, manifold=1)
wrote below_0.stl (faces=1152, closed=1, manifold=1)
wrote below_1.stl (faces=1984, closed=1, manifold=1)
Summary
| Path | API | What you get |
|---|---|---|
| Build | tf::make_csg_graph(forms, sheets) | Arrangement + domain classification, once |
| 1 — boolean mesh | tf::make_csg_mesh(graph, e) | One closed mesh per side |
| 2 — domains by expression | tf::make_csg_domains(graph, e) | The same volumes, individually |
| 3 — domains by hand | tf::make_csg_domains(graph, tf::return_index_map) + imap.inclusion | Same cells, stable ids |
