CSG
The CSG module builds an arrangement of N forms once and evaluates any boolean expression over them — no need to chain pairwise booleans.
Include the module with:
#include <trueform/csg.hpp>
Overview
A CSG computation is two stages: build the arrangement once, then extract from it as many times as you like.
- Build —
tf::make_csg_graphreturns atf::csg_graph, the implicit arrangement of N forms. This is where the heavy work (intersection, classification, the inclusion lattice) happens. - Extract — cheap queries over that one graph. There are three:
tf::make_csg_mesh(graph, expr)— the mesh for a boolean expression.expris atf::csg::expr, a runtime tree over operand ids built fromtf::csg::merge,tf::csg::intersection,tf::csg::difference,tf::csg::complement, plustf::csg::any_of/tf::csg::all_offor ranges. Called with no expression, it returns the full arrangement.tf::make_csg_domains(graph)— every domain the forms carve space into, each as its own watertight mesh (Domain Decomposition).tf::make_intersection_curves(graph)— the intersection-curve network where surfaces cross (Intersection Curves).
std::vector forms{ mesh0.polygons(), mesh1.polygons(), mesh2.polygons() };
auto graph = tf::make_csg_graph(tf::make_range(forms));
auto expr = tf::csg::difference(0, tf::csg::merge(1, 2)); // a \ (b ∪ c)
auto out = tf::make_csg_mesh(graph, expr);
Operand ids are positions in the forms range. Integers are auto-promoted to leaves, so tf::csg::merge(0, 1, 2) reads exactly like the algebra.
The graph is built once and reused: the same graph feeds any number of make_csg_mesh expressions, make_csg_domains, and make_intersection_curves calls without recomputing the arrangement.
Building Expressions
Every boolean is a tf::csg::expr — a tree whose leaves are operand ids. There are two interchangeable ways to build one, both producing the same expr. The function builders are variadic:
| Builder | Meaning |
|---|---|
tf::csg::merge(a, b, …) | union — inside any child |
tf::csg::intersection(a, b, …) | inside every child |
tf::csg::difference(a, b, …) | inside a, outside all of b… |
tf::csg::complement(a) | outside a |
tf::csg::any_of(range) | union over a range of operands (variadic merge) |
tf::csg::all_of(range) | intersection over a range of operands |
For the binary and unary cases the operators read more like algebra. tf::csg::op(i) is a leaf factory (op(i) == expr{i}), needed to lift the first integer into the expression type:
| Operator | Equivalent builder |
|---|---|
a | b | merge(a, b) |
a & b | intersection(a, b) |
a - b | difference(a, b) |
~a | complement(a) |
using namespace tf::csg;
auto e1 = difference(0, merge(1, 2)); // function form
auto e2 = op(0) - (op(1) | op(2)); // operator form — same expr
Once one side is an expr, integers on the other side auto-promote, so op(0) - 1 and op(0) | 1 are fine; only the leading leaf needs op. There is no XOR/symmetric-difference operator — spell it (a - b) | (b - a). Everything below uses whichever form reads best; they are the same algebra, and both make_csg_mesh and domain selection accept it.
Selections
An expression names a region; a selection says what to emit of which surfaces against it. tf::csg::selection restricts a boolean to the faces the named forms contributed, and tf::csg::inside reads the named forms' surface that lies inside the region rather than around it:
| read | emits a piece of the named surfaces iff | winding |
|---|---|---|
tf::csg::selection(tags, e) | exactly one of its two sides satisfies e — the piece bounds the region | outward from the region |
tf::csg::inside(tags, e) | both of its sides satisfy e — the piece lies inside the region | the form's stored winding, never flipped |
tf::csg::selection(tags) | always — the embedded read: the surfaces cut by everything, no classification | stored |
tags is one operand id, a braced list, or a range; an empty list is every form. A plain expr is the boundary read over every form.
using namespace tf::csg;
auto graph = tf::make_csg_graph(tf::make_range(forms), sheets); // A=0, B=1, horizon=2
auto e = (op(0) | op(1)) - op(2);
auto solid = tf::make_csg_mesh(graph, e); // the region's boundary
auto partA = tf::make_csg_mesh(graph, selection(0, e)); // A's walls of it
auto inA = tf::make_csg_mesh(graph, inside(2, op(0))); // the horizon inside A
auto embed = tf::make_csg_mesh(graph, selection({0})); // A cut by all, no boolean
An inside piece bounds nothing of the region, so it has no outward side; it keeps the winding its form was given, which for a sheet is the orientation op(i) is defined by. Where e does not name the selected form, inside(i, e) is exactly selection(i, op(i) & e) — same faces, same winding — and selection(i, ~op(i) & e) is those faces flipped. Where it does, each side of a piece is read with that side's own bit, so inside(i, op(i)) is empty and inside(i, op(i) | e) is inside(i, e). A volume's shell inside another operand is an inside read too — inside(1, op(0)) is B's surface within A; a sheet is the operand for which the boundary read cannot say it without naming the sheet's own side. tf::make_csg_domains takes a boundary selection only: a surface inside a domain is on no cell's boundary. All make_csg_mesh overloads — tf::return_source_ids, tf::return_index_map — accept either kind.
Sheets
An operand listed in sheets is an oriented separator: it bounds no volume, it cuts and is cut like any other operand, and op(i) is the half-space behind its normal. Every boolean expression composes sheets and volumes alike. A region bounded by a volume and a sheet is closed and capped; a region bounded only by sheets is unbounded and comes back open along their rims. Two crossing sheets carve four wedges, each a domain with its own inclusion row.
With A a plane at z = 0 (normal +z, tag 0) and B a unit box (tag 1):
| read | result |
|---|---|
make_csg_mesh(graph, op(1) - op(0)) / op(1) & op(0) | the box's upper / lower half, closed and capped by the sheet |
make_csg_mesh(graph, op(0) - op(1)) | the boundary of the unbounded region below the sheet and outside the box: the sheet's annulus plus the box's lower walls, open along the sheet's rim |
selection({0}, ~op(0) & ~op(1)) | the sheet outside the box (the annulus), wound away from that region |
inside({0}, op(1)) | the sheet inside the box (the cap), the sheet's own winding |
inside({0}, ~op(1)) | the annulus, the sheet's own winding |
selection({0}, op(1)) | nothing: both sides of the cap are inside the box, so no piece of the sheet bounds op(1) |
inside({1}, op(0)) | the box's walls below the sheet, outward |
inside({0}, op(0)) | nothing: a sheet's own bit differs across each of its pieces |
A sheet piece that separates nothing — a flap wholly inside a volume, its rim touching no other operand — is inside whatever contains it: inside({flap}, op(volume)) emits it once; the boundary reads see it from either side, and its half-space in an expression is empty. make_csg_domains drops every domain whose inclusion row is all-false — the unbounded outside among them; pass tf::domain_config::none to also keep the open regions a sheet's dangling parts bound, where they bound any (a floating flap's two sides are then two domains; a fin whose sides flood to one region yields nothing). An open operand is a volume unless declared a sheet, and a volume's open fragments are fused during classification, unconditionally — no flag governs them. make_outer_shell requires a volume to elect the universe by; on a graph of sheets alone it is not defined.
Configuration & Supported Input
tf::make_csg_graph takes an optional tf::arrangement_config — the intersection settings plus the cut-surface triangulation, implicitly constructible from an tf::intersect_config alone, so every intersect-only spelling below stays valid. The intersect mode flags decide what kinds of input are accepted and how degeneracies are resolved. What works in CSG is exactly what works under that mode:
| Mode flag | What CSG accepts when set |
|---|---|
tf::intersect_mode::primitives | Full 5-type intersection classification — handles shared edges, shared vertices, coplanar faces (aligned vs opposing boundary), and non-manifold edges shared by 3+ faces. |
tf::intersect_mode::sos | Symbolic-perturbation path: all intersections are edge-face, no degenerate cases. Faster, but cannot represent coplanar overlap or shared-vertex contact. |
tf::intersect_mode::resolve_crossing_contours | Resolves crossings between intersection curves from different form pairs that meet on the same face — required when more than two forms can pairwise intersect along the same face. |
tf::intersect_mode::within | Additionally self-arranges each operand: intersections within a form are found and cut alongside the pairwise ones. Required whenever an operand can self-overlap — e.g. two meshes concatenated into one operand. See Self-Arrangements. |
The default for tf::make_csg_graph is primitives | resolve_crossing_contours, matching make_boolean. Override only when you have a reason to:
tf::intersect_config cfg{tf::intersect_mode::primitives
| tf::intersect_mode::resolve_crossing_contours};
auto graph = tf::make_csg_graph(tf::make_range(forms), cfg);
Full flag semantics, the tolerance field, and the mode-vs-config equivalence are documented in Intersect: Intersection Configuration.
Regardless of mode, each form should be PWN (piecewise winding number) — locally consistent orientation. The per-domain inclusion lattice that backs every boolean expression only carries meaning when each form draws a clean inside/outside boundary.
Coordinate Precision
The arrangement uses exact integer arithmetic internally. The lattice resolution is selected by the Int template parameter on make_csg_graph and resolves automatically from the input coordinate type:
| Input scalar | Resolved Int |
|---|---|
float | tf::exact::int32 |
double | tf::exact::int64 |
| any other | tf::exact::int32 (fallback) |
// Auto-resolved
auto graph = tf::make_csg_graph(tf::make_range(forms));
// Explicit override
auto graph = tf::make_csg_graph<tf::exact::int64>(tf::make_range(forms));
See Intersect: Exact Arithmetic for the full precision chain.
Output Coordinate Type
The output mesh's scalar type is controlled by the OutputCoordinateType template parameter on tf::make_csg_mesh. It defaults to the input forms' real type; any floating-point type may be supplied. A graph read takes that slot alone — the graph's type has already fixed its Int and the graph holds the converter — while an entry that builds an arrangement from forms takes Int first and OutputCoordinateType second. See the template-slot law.
// Output matches input
auto out = tf::make_csg_mesh(graph, expr);
// Explicit override (independent of the graph's Int)
auto out = tf::make_csg_mesh<double>(graph, expr);
Internally the pipeline carries intersection points at full precision regardless of OutputCoordinateType; the parameter only governs the coordinate type at output emission. The Int chosen at graph-build time is unchanged.
The Full Arrangement
Call tf::make_csg_mesh(graph) with no expression to get the full arrangement surface — every input face, cut at intersections, each surface emitted once. It is the graph analogue of tf::make_mesh_arrangements, and the surface counterpart of tf::make_csg_domains(graph) returning every domain.
auto graph = tf::make_csg_graph(tf::make_range(forms));
auto surface = tf::make_csg_mesh(graph); // no expression -> full arrangement
This reuses the intersection graph and face cuts the graph already holds — only the mesh is materialised, the intersection pipeline is not re-run — so it is far cheaper than calling tf::make_mesh_arrangements on the forms again. Add tf::return_source_ids for the (mesh, tag_labels, face_labels) provenance form.
Face Provenance
Pass tf::return_source_ids to also recover, for every output face, which input form it came from and which original face within that form. The call then returns the mesh plus two per-output-face buffers — the same (tag_labels, face_labels) pair tf::make_mesh_arrangements returns:
auto [out, tag_labels, face_labels] =
tf::make_csg_mesh(graph, expr, tf::return_source_ids);
// For output face f:
// tag_labels[f] -> which input form it came from (0 .. n_forms-1)
// face_labels[f] -> the original face id within that form
Both buffers run parallel to out.faces(). Uncut faces carry their own original id; a triangulated cut face carries the id of the face it was cut from. This traces each surviving boundary triangle back to the operand and face that produced it — for transferring per-face attributes (materials, UVs, tags) onto the boolean result, or coloring the output by source. The plain tf::make_csg_mesh(graph, expr) is unchanged; the labels are opt-in.
Full Index Map
Pass tf::return_index_map instead for a single tf::mesh_arrangement_index_map that folds in the face provenance and adds the point axis — the same map tf::make_mesh_arrangements returns. Works on both the full-arrangement and the boolean overloads:
auto [out, imap] = tf::make_csg_mesh(graph, tf::return_index_map); // full arrangement
auto [res, imap] = tf::make_csg_mesh(graph, expr, tf::return_index_map); // boolean result
The map relates output back to the inputs in both directions:
| field | meaning |
|---|---|
face_tag_labels / face_labels | output face → (input form, input face) — the return_source_ids pair |
point_tag_labels / point_labels | output point → (input form, input point) |
point_f[tag][input point] | forward: input point → output point |
Created intersection points (and, for a boolean, input points no surviving face kept) have no input origin, so each inverse axis carries an end sentinel one past its own range — the tag ends at n_tags, the point id at n_output_points — the same idiom the arrangement index map uses. Use the forward map to push input point data onto the result, or the inverse to pull result attributes back to the operands.
Cut-Surface Triangulation
The triangulation of the cut faces is the second half of the
tf::arrangement_config. A
tf::triangulation_type converts to the config implicitly, so both
spellings work:
auto g0 = tf::make_csg_graph(forms, tf::triangulation_type::refined_cdt);
auto g1 = tf::make_csg_graph(
forms, {config, tf::triangulation_type::refined_cdt});
tf::triangulation_type::cdt(default) — plain constrained Delaunay per cut loop.tf::triangulation_type::refined_cdt— quality refinement of the cut surface (Ruppert circumcenter insertion). Boundary splits are negotiated globally through shared dyadic split records, so loop boundaries shared between faces — and across coplanar stacks — stay watertight by construction. Refined builds carry more created points;graph.created_points()is the unified table every extraction indexes.
Many Operations, One Graph
The arrangement is the cost. Once graph is built, every additional boolean expression evaluates without re-running the geometric pipeline:
auto graph = tf::make_csg_graph(tf::make_range(forms));
auto m_union = tf::make_csg_mesh(graph, tf::csg::merge(0, 1, 2));
auto m_inter = tf::make_csg_mesh(graph, tf::csg::intersection(0, 1, 2));
auto m_diff = tf::make_csg_mesh(graph,
tf::csg::difference(0,
tf::csg::any_of({1, 2})));
Boolean Operations
Boolean operations select specific regions from a mesh arrangement to produce a single result mesh. tf::make_boolean is the two-operand shortcut: it builds the arrangement and evaluates the operation in one call, without the caller ever holding a graph.
Basic Boolean: make_boolean
auto [result, labels, face_labels] = tf::make_boolean(
mesh1.polygons(), mesh2.polygons(), tf::boolean_op::merge);
// With curves
auto [result, labels, face_labels, curves] = tf::make_boolean(
mesh1.polygons(), mesh2.polygons(), tf::boolean_op::merge, tf::return_curves);
Available operations:
| Operation | Description |
|---|---|
tf::boolean_op::merge | Union: A ∪ B |
tf::boolean_op::intersection | Intersection: A ∩ B |
tf::boolean_op::left_difference | Difference: A \ B |
tf::boolean_op::right_difference | Difference: B \ A |
The labels buffer contains one integer per face, indicating which input mesh (0 or 1) the face originated from. The face_labels buffer maps each output face to its original face index in the source mesh.
Booleans use primitives mode internally with no contour crossing resolution — with two meshes every contour is of the one class (A,B), and same-class crossings are the within flag's territory.
Boolean Configuration
A boolean is the two-operand case of the CSG arrangement, so it takes the same tf::arrangement_config every other arrangement surface does:
auto [result, labels, face_labels] = tf::make_boolean(
mesh1.polygons(), mesh2.polygons(), tf::boolean_op::merge,
tf::triangulation_type::refined_cdt); // implicitly an arrangement_config
That carries the intersection mode, the input placement tolerance, and the triangulation type, so a boolean can ask for quality-refined cut surfaces. The default is the bare primitives mode described above: the crossing-resolution flag is arity-derived, and at two operands it is off.
With Precomputed Structures
tf::make_csg_graph auto-tags any form that's missing tf::face_membership, tf::manifold_edge_link, or tf::tree. For repeated work — for example, instancing the same canonical mesh many times — pre-tag once and share the structures:
tf::face_membership<int> fm;
fm.build(bunny.polygons());
tf::manifold_edge_link<int, 3> mel;
mel.build(bunny.polygons().faces(), fm);
tf::aabb_tree<int, float, 3> tree(bunny.polygons(), tf::config_tree(4, 4));
auto bunny_tagged = bunny.polygons() | tf::tag(fm) | tf::tag(mel) | tf::tag(tree);
std::vector forms{ sphere_tagged, bunny_tagged, bunny_tagged, bunny_tagged };
auto graph = tf::make_csg_graph(tf::make_range(forms));
All copies of bunny_tagged share the same FM / MEL / tree — the arrangement build re-uses them across instances.
With Transformations
Combine tagged structures with tagged transformations to instance the same canonical mesh at different positions without copying geometry:
auto T1 = tf::make_transformation_from_translation(tf::make_vector(1.0f, 0, 0));
auto T2 = tf::make_transformation_from_translation(tf::make_vector(0, 1.0f, 0));
std::vector forms{
sphere_tagged | tf::tag(tf::make_frame(tf::transformation<float, 3>{})),
bunny_tagged | tf::tag(tf::make_frame(T1)),
bunny_tagged | tf::tag(tf::make_frame(T2)),
};
auto graph = tf::make_csg_graph(tf::make_range(forms));
auto out = tf::make_csg_mesh(graph,
tf::csg::difference(0, tf::csg::merge(1, 2)));
One tagged canonical mesh + N frames + one graph is the cheapest way to fold many instances of the same shape into a single boolean.
Self-Arrangements
Everything above treats each operand as one clean solid. Two additions lift that restriction.
One form: the graph is the self arrangement
tf::make_csg_graph accepts a single form directly — no range, no second operand. The graph then is the form's self arrangement: every self-intersection is found and cut, and the structural reads apply — tf::make_outer_shell and tf::make_csg_domains work as usual. Boolean expressions need two operands, so make_csg_mesh does not apply here.
auto graph = tf::make_csg_graph(soup.polygons()); // self-arrangement
auto [cells, ids] = tf::make_csg_domains(graph); // every enclosed region
auto shell = tf::make_outer_shell(graph); // the outermost surface
This is the CSG-path answer to raw input — a scanned or concatenated soup whose components overlap — and it carves the same cells tf::make_polygon_arrangements followed by domain labels would, from one build.
N forms with self-overlap: the within flag
For multiple operands, self-intersections are skipped by default — a clean solid has none, and the pairwise sweep is cheaper without the self pass. When an operand can self-overlap — two meshes concatenated into one operand, an instanced part whose copies touch — add tf::intersect_mode::within to the config:
auto bc = tf::concatenated(b.polygons(), c.polygons()); // one self-overlapping operand
std::vector forms{ bc.polygons(), a.polygons() };
tf::intersect_config cfg{tf::intersect_mode::primitives
| tf::intersect_mode::resolve_crossing_contours
| tf::intersect_mode::within};
auto graph = tf::make_csg_graph(tf::make_range(forms), cfg);
auto [cells, ids] = tf::make_csg_domains(graph);
The self pass runs with the same parallelism as the pairwise sweep, and make_csg_domains classifies the result structurally, so regions covered twice by one operand — the overlap pockets — come out as their own cells: the extraction matches what the same meshes would produce as separate operands, cell for cell. Setting the flag on operands that don't self-overlap changes nothing but the (empty) self pass.
Note the asymmetry: make_csg_domains is self-overlap-aware; make_csg_mesh is not. A boolean expression reads per-operand inside/outside bits, and a self-overlapping operand doesn't have a single such boundary — keep expression queries on solid, non-self-overlapping operands.
Domain Decomposition
tf::make_csg_mesh returns one surface for a boolean expression. tf::make_csg_domains returns the arrangement's cells — every watertight region the N forms carve space into — each as its own polygons_buffer. This is the structure simulation wants: volumetric domains to assign materials and boundary conditions to, not a single merged boundary.
auto graph = tf::make_csg_graph(tf::make_range(forms));
auto [cells, ids] = tf::make_csg_domains(graph); // every cell, one mesh each
An expression restricts the extraction; the same selection can be made by hand from one full extraction via the inclusion matrix — 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.
The result is { cells, ids }: cells[k] is the closed, oriented boundary of domain k, and ids[k] is its coarse domain id. The arrangement is built once — extracting domains costs no new geometric work.
Selecting domains
Filter the cells with the same expression algebra as make_csg_mesh. tf::csg::op(i) selects the domains that lie inside form i:
auto [core, ids] = tf::make_csg_domains(graph, tf::csg::op(0)); // cells inside form 0
Because each operand draws an inside/outside boundary, any expression keeps the cells whose inclusion matches it: tf::csg::op(0) & tf::csg::op(1) keeps cells inside both forms, ~tf::csg::op(0) keeps cells outside form 0, and so on.
Cell provenance
Pass tf::return_source_ids to any make_csg_domains overload to also recover, for every cell face, which input form and original face it came from. The call returns two extra tf::offset_block_buffers whose blocks run parallel to cells:
auto [cells, ids, tag_blocks, face_blocks] =
tf::make_csg_domains(graph, tf::return_source_ids);
// For face j of cell k:
// tag_blocks[k][j] -> which input form it came from
// face_blocks[k][j] -> the original face id within that form
tag_blocks[k] and face_blocks[k] line up with cells[k].faces(). Uncut faces carry their own original id; a triangulated cut face carries the id of the face it was cut from. This is the per-cell analogue of the (tag_labels, face_labels) pair from tf::make_csg_mesh — use it to carry per-face attributes onto the extracted domains, or to color each cell by which operand's surface bounds it. All four overloads accept the tag; the plain { cells, ids } return is unchanged.
Cell index map
Pass tf::return_index_map instead for a single tf::csg_domains_index_map that folds in the face provenance and adds the point axis — the per-cell analogue of the arrangement's tf::mesh_arrangement_index_map. Because each cell re-deduplicates its own points, every axis is a tf::offset_block_buffer whose blocks run parallel to cells:
auto [cells, ids, imap] = tf::make_csg_domains(graph, tf::return_index_map);
// For cell k:
// imap.face_tag_blocks[k][j] / imap.face_blocks[k][j] -> face j -> (form, input face)
// imap.point_tag_blocks[k][p] / imap.point_blocks[k][p] -> point p -> (form, input point)
There is no forward map — a shared boundary point or face belongs to several cells, so "input element → its cell element" is not single-valued; the map is inverse-only. Created intersection points have no input origin and carry the end sentinel per axis (tag → imap.n_tags, point id → imap.n_output_points). Cell points are emitted in cut order (not [originals | created]), so detect a created point by the tag sentinel, point_tag_blocks[k][p] == imap.n_tags — that check is always reliable, whereas the point-id sentinel can collide with a surviving input-point id under a filter. All four overloads accept the tag.
Cell Classification
auto [cells, ids, imap] = tf::make_csg_domains(graph, tf::return_index_map);
// imap.inclusion[k][i]: true iff cell k lies inside form i
for (std::size_t k = 0; k < cells.size(); ++k)
if (imap.inclusion[k][0] && !imap.inclusion[k][1])
use(cells[k]); // inside form 0, outside form 1
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". The matrix is a tf::blocked_buffer<bool, tf::dynamic_size> with block_size() == n_tags, one row per cell.
auto [cells, ids, imap] = tf::make_csg_domains(
graph, tf::domain_config::ignore_open_fragments, tf::return_index_map);
for (std::size_t k = 0; k < cells.size(); ++k) {
bool outer = true;
for (std::size_t i = 0; i < std::size_t(imap.n_tags); ++i)
outer = outer && !imap.inclusion[k][i];
// outer: cell k is part of the outer shell
}
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 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 the domains read. With the default (the flag set), only the sealed portion of the sheet cuts; the outside stays whole, and with the outer shell not excluded it returns as one closed inverted cell. With it cleared, a dangling part whose two sides reach distinct regions separates them: 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. A fin whose free rim ends inside a volume, both sides flooding to one region, separates nothing under any flag; the boolean reads always see the sheet's virtual partition, wherever it separates. Sheet bits are half-space indicators for bounded and unbounded regions alike: behind the normal is "inside", everywhere.
Configuration
A tf::domain_config coarsens the partition before extraction:
| Flag | Effect |
|---|---|
tf::domain_config::exclude_outer_shell | Drop the unbounded universe (the cell whose inclusion bitvector is all-zero). |
tf::domain_config::ignore_open_fragments | Fuse the two sides of every open (boundary-carrying) component into one domain, so open sheets don't split space. |
Both are on by default. Compose with |, or pass tf::domain_config::none to keep every raw cell, the outside included:
auto [cells, ids] =
tf::make_csg_domains(graph, tf::domain_config::none); // raw cells, universe kept
With exclude_outer_shell off, the outside is also recoverable as an expression — ~tf::csg::op(0) & ~tf::csg::op(1).
Outer Shell
tf::make_outer_shell repairs a self-intersecting mesh into a clean shell: the boundary of the union of everything it encloses.
auto shell = tf::make_outer_shell(mesh.polygons());
The mesh is read through its own tf::csg_graph — the self arrangement plus the classification tier — and only the faces bounding the unbounded outside are kept, oriented outward. Internal structure — overlap membranes between interpenetrating parts, faces buried inside the solid, enclosed cavities — has the same domain on both sides and so never reaches the boundary. The result is free of self-intersections and suitable as a boolean or csg-graph operand.
auto merged = tf::concatenated(a.polygons(), b.polygons()); // self-intersecting solid
auto shell = tf::make_outer_shell(merged.polygons()); // clean union boundary
The extraction is structural — no winding bits, no boolean expression, no configuration. The unbounded universe is the most-negative-volume domain, and the shell is the boundary between it and everything else. This is the immersion-safe read: a self-overlapping form's double-covered pockets stay enclosed, where a winding-parity bit would drop them. Open fragments (fins, damage, a knife's dangling rim) are self-merged by the arrangement, so they bound no volume and cannot survive into the shell.
Because the shell is read off the graph rather than materialised and re-arranged, an uncut vertex reaches the output with its input coordinate untouched — only created points come from the exact lattice.
A graph overload reuses a build you already hold — the natural extraction for a one-form graph (see Self-Arrangements), valid for any N:
auto graph = tf::make_csg_graph(soup.polygons());
auto shell = tf::make_outer_shell(graph);
The mesh overload takes an optional tf::intersect_config (default primitives | resolve_contours) for the self-arrangement. It builds its own arrangement, so it takes the lattice Int first and OutputCoordinateType second. An integral output keeps the shell on the exact lattice the arrangement ran on — nothing is deconverted — and the converter that produced it comes back with the mesh:
auto [shell, converter] =
tf::make_outer_shell<tf::none_t, std::int32_t>(float_mesh.polygons());
auto p = converter.deconvert(shell.polygons().points()[0]); // back to float
Intersection Curves
tf::make_intersection_curves returns the seam network of the arrangement — the polylines where surfaces of different forms cross — as a tf::curves_buffer:
auto graph = tf::make_csg_graph(tf::make_range(forms));
auto curves = tf::make_intersection_curves(graph);
Edges are connected into maximal polylines. A coincident (coplanar) overlap contributes its contact border — the polyline where the coincidence ends — while the overlap's interior stays silent. Like the domains, this reads straight off the graph — no separate arrangement build.
Relationship to Arrangement
| Arrangement | CSG | |
|---|---|---|
| Operands | 1, 2, or N | N |
| Ops per call | 1 | unbounded (one graph, many make_csg_mesh) |
| Output | materialized mesh + face labels | mesh, per-domain cells, or intersection curves |
Anything binary fits cleanly in tf::make_boolean. N-ary or repeated boolean over the same forms is what the CSG module is for.
