Modules | C++

CSG

N-form boolean expressions over implicit arrangements.

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.

  • Buildtf::make_csg_graph returns a tf::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. expr is a tf::csg::expr, a runtime tree over operand ids built from tf::csg::merge, tf::csg::intersection, tf::csg::difference, tf::csg::complement, plus tf::csg::any_of / tf::csg::all_of for 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).
csg_basic.cpp
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:

BuilderMeaning
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:

OperatorEquivalent builder
a | bmerge(a, b)
a & bintersection(a, b)
a - bdifference(a, b)
~acomplement(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:

reademits a piece of the named surfaces iffwinding
tf::csg::selection(tags, e)exactly one of its two sides satisfies e — the piece bounds the regionoutward from the region
tf::csg::inside(tags, e)both of its sides satisfy e — the piece lies inside the regionthe form's stored winding, never flipped
tf::csg::selection(tags)always — the embedded read: the surfaces cut by everything, no classificationstored

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):

readresult
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 flagWhat CSG accepts when set
tf::intersect_mode::primitivesFull 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::sosSymbolic-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_contoursResolves 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::withinAdditionally 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 scalarResolved Int
floattf::exact::int32
doubletf::exact::int64
any othertf::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:

fieldmeaning
face_tag_labels / face_labelsoutput face → (input form, input face) — the return_source_ids pair
point_tag_labels / point_labelsoutput 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:

csg_many.cpp
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

boolean.cpp
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:

OperationDescription
tf::boolean_op::mergeUnion: A ∪ B
tf::boolean_op::intersectionIntersection: A ∩ B
tf::boolean_op::left_differenceDifference: A \ B
tf::boolean_op::right_differenceDifference: 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:

boolean_configuration.cpp
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:

csg_precomputed.cpp
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:

csg_transformed.cpp
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.

csg_one_form.cpp
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:

csg_within.cpp
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.

csg_domains.cpp
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:

csg_domains_select.cpp
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:

FlagEffect
tf::domain_config::exclude_outer_shellDrop the unbounded universe (the cell whose inclusion bitvector is all-zero).
tf::domain_config::ignore_open_fragmentsFuse 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.

outer_shell.cpp
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.

outer_shell_concat.cpp
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:

outer_shell_int.cpp
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:

csg_curves.cpp
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

ArrangementCSG
Operands1, 2, or NN
Ops per call1unbounded (one graph, many make_csg_mesh)
Outputmaterialized mesh + face labelsmesh, 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.