Examples | C++

Geometry Walkthrough

Building and processing geometry with trueform.

This walkthrough covers the fundamentals: creating geometry, working with buffers, understanding topology, assembling watertight meshes, and combining them with boolean operations.

Source: mesh_assembly.cpp

Creating Geometry

Generate an open tube—a cylinder without caps:

tf::polygons_buffer<int, float, 3, 4> tube = examples::make_tube(1.0f, 2.0f, 16);
// Points: 64, Faces: 16

The polygons_buffer<Index, Real, Dims, N> holds face connectivity and vertex positions. With N=4, faces are stored in a tf::blocked_buffer<int, 4>.

Working with the Buffer

Before proceeding, let's understand how to access and manage buffer data. This foundation applies throughout trueform.

Accessing Views

Get lightweight views into the buffer data:

auto faces = tube.faces();       // face indices only
auto points = tube.points();     // vertex positions only
auto polygons = tube.polygons(); // combined view

Iterating Elements

Access individual faces and vertices:

// Face indices
auto [id0, id1, id2, id3] = tube.faces().front();

// Vertex positions directly
auto [pt0, pt1, pt2, pt3] = tube.polygons().front();

// Or get indices from a polygon
auto [i0, i1, i2, i3] = tube.polygons().front().indices();

Raw Data Access

Get pointers to underlying memory for interop with other libraries:

int* faces_data = tube.faces_buffer().data_buffer().data();
std::size_t faces_size = tube.faces_buffer().data_buffer().size();

float* points_data = tube.points_buffer().data_buffer().data();
std::size_t points_size = tube.points_buffer().data_buffer().size();

Releasing Ownership

Transfer ownership of the underlying memory:

// Release faces buffer
tube.faces_buffer().data_buffer().release();

// Release points buffer
tube.points_buffer().data_buffer().release();

Creating Views Manually

Here's how to construct the same views manually from raw pointers (useful when receiving data from other libraries):

// Create faces view from raw pointer
auto faces_view = tf::make_faces<4>(tf::make_range(faces_data, faces_size));

// Create points view from raw pointer
auto points_view = tf::make_points<3>(tf::make_range(points_data, points_size));

// Combine into polygons view
auto polygons_view = tf::make_polygons(faces_view, points_view);

// Use like any other polygons range
auto [pt0, pt1, pt2, pt3] = polygons_view.front();

Topology Requires Connectivity

Check for boundary edges:

tf::offset_block_buffer<int, int> boundaries = tf::make_boundary_paths(tube.polygons());
// Boundary loops: 16

16 loops for 16 faces—each quad is isolated. Without shared vertices, there is no topological connectivity. Every edge appears as a boundary.

Working with Offset Block Buffers

The result is an offset_block_buffer—a container for variable-length blocks. Each block is a boundary loop containing vertex indices.

Iterating Blocks

for (auto path : boundaries) {
    for (auto vertex_id : path) {
        // Process each vertex in the boundary loop
    }
}

Accessing Underlying Data

The buffer stores two arrays: offsets define block boundaries, data holds the packed values.

auto& offsets = boundaries.offsets_buffer();  // Block start positions
auto& data = boundaries.data_buffer();        // Packed vertex indices

// offsets: [0, 4, 8, 12, ...]  (each boundary has 4 vertices)
// data: [0,1,2,3, 4,5,6,7, ...]  (vertex indices)

Creating Views Manually

Here's how to construct the same view manually from raw pointers:

int* offsets_ptr = boundaries.offsets_buffer().data();
int* data_ptr = boundaries.data_buffer().data();
std::size_t n_blocks = boundaries.size();
std::size_t data_size = boundaries.data_buffer().size();

// Reconstruct view
auto paths_view = tf::make_paths(
    tf::make_offset_block_range(
        tf::make_range(offsets_ptr, n_blocks + 1),
        tf::make_range(data_ptr, data_size)));

Cleaning Creates Connectivity

Merge coincident vertices:

tf::polygons_buffer<int, float, 3, 4> tube_cleaned =
    tf::cleaned(tube.polygons(), tf::epsilon<float>);
// Points: 32, Faces: 16, Boundary loops: 2

Now edges are shared. The 2 boundaries are the top and bottom rings of the open tube.

Assembling a Watertight Cylinder

Add disk caps and combine:

tf::polygons_buffer<int, float, 3, 3> disk_bottom = examples::make_disk(1.0f, 0.0f, 16);
tf::polygons_buffer<int, float, 3, 3> disk_top = examples::make_disk(1.0f, 2.0f, 16);

tf::polygons_buffer<int, float, 3, tf::dynamic_size> combined =
    tf::concatenated(tube.polygons(), disk_bottom.polygons(), disk_top.polygons());
// Points: 160, Faces: 48, Boundary loops: 48

With tf::dynamic_size, faces are stored in an offset_block_buffer instead of a blocked buffer—the same structure we saw for boundary paths. This allows mixing triangles, quads, and n-gons. Point indices are automatically offset during concatenation.

Clean to merge all coincident vertices:

tf::polygons_buffer<int, float, 3, tf::dynamic_size> cylinder =
    tf::cleaned(combined.polygons(), tf::epsilon<float>);
// Points: 34, Faces: 48, Boundary loops: 0

Zero boundaries—watertight mesh. The tube edges merge with disk perimeters, sealing the caps.

Orientation and Triangulation

Ensure outward-facing normals, then triangulate:

tf::ensure_positive_orientation(cylinder.polygons());
auto triangulated_cylinder = tf::triangulated(cylinder.polygons());
// Points: 34, Faces: 64, Boundary loops: 0, Non-manifold edges: 0

Triangulation increases face count (quads become 2 triangles each) but preserves topology—still watertight.

Building a Cone

Create a cone by modifying a disk. First clean it—make_disk creates isolated triangles, each with its own center vertex:

auto cone_cap = tf::cleaned(disk_top.polygons(), tf::epsilon<float>);
auto cone_sheet = cone_cap;  // Copy for the sloped surface

Use neighbor search to find the center vertex:

tf::aabb_tree<int, float, 3> sheet_tree(cone_sheet.points(), tf::config_tree(4, 4));
auto [center_id, _] = tf::neighbor_search(
    cone_sheet.points() | tf::tag(sheet_tree),
    tf::centroid(cone_sheet.points()));

cone_sheet.points()[center_id][2] += 1.0f;  // Move up to form apex

Combine flat cap and cone sheet:

tf::polygons_buffer<int, float, 3, 3> cone = tf::cleaned(
    tf::concatenated(cone_cap.polygons(), cone_sheet.polygons()).polygons(),
    tf::epsilon<float>);
tf::ensure_positive_orientation(cone.polygons());
// Points: 18, Faces: 32, Boundary loops: 0, Non-manifold edges: 0

Perimeter vertices merge while centers stay separate (flat center at z=2, apex at z=3). Result: watertight cone.

Combining Closed Meshes: Wrong vs Right

Wrong approach—concatenate and clean:

auto merged = tf::cleaned(
    tf::concatenated(triangulated_cylinder.polygons(), cone.polygons()).polygons(),
    tf::epsilon<float>);
// Points: 50, Faces: 96, Boundary loops: 0, Non-manifold edges: 16

Zero boundaries but 16 non-manifold edges. The cylinder's top cap and cone's bottom cap share the same ring—after cleaning, each edge on that ring belongs to 4 faces. Concatenation cannot remove interior faces.

Right approach—boolean union:

auto [pencil, _] = tf::make_boolean(
    triangulated_cylinder.polygons(), cone.polygons(), tf::boolean_op::merge);
// Points: 49, Faces: 80, Boundary loops: 0, Non-manifold edges: 0

Boolean union removes interior faces where meshes overlap, producing a single watertight manifold. Fewer faces, clean topology.

Volume is preserved—union equals sum of parts:

tf::signed_volume(pencil.polygons()) ==
    tf::signed_volume(cylinder.polygons()) + tf::signed_volume(cone.polygons())

Result is triangulated (polygons_buffer<int, float, 3, 3>)—access vertices directly:

auto [pt0, pt1, pt2] = pencil.polygons().front();

Boolean Intersection: Steinmetz Solid

Create a Steinmetz solid (bicylinder) by intersecting with a rotated view. Tag with a frame to apply transformation lazily—no data is copied:

auto horizontal_cylinder =
    triangulated_cylinder.polygons() |
    tf::tag(
        tf::make_rotation(tf::deg(90.f), tf::axis<0>,
                          tf::centroid(triangulated_cylinder.polygons())));

auto [bicylinder, _] = tf::make_boolean(
    pencil.polygons(), horizontal_cylinder, tf::boolean_op::intersection);
// Points: 97, Faces: 192, Boundary loops: 0, Non-manifold edges: 0

The intersection is the region inside both shapes—the classic Steinmetz solid with its distinctive curved edges.

Scalar Fields and Isobands

Compute a distance field from the centroid:

auto center = tf::centroid(bicylinder.points());
tf::buffer<float> scalars;
scalars.allocate(bicylinder.points().size());
tf::parallel_transform(bicylinder.points(), scalars, tf::distance_f(center));

Extract alternating isobands to create disconnected shells:

float step = (max_d - min_d) / 5.0f;
std::array<float, 4> cut_values = {min_d + step, min_d + 2*step,
                                   min_d + 3*step, min_d + 4*step};
std::array<int, 3> selected_bands = {0, 2, 4};  // first, middle, last

auto [slices, band_labels] = tf::make_isobands(
    bicylinder.polygons(), scalars,
    tf::make_range(cut_values), tf::make_range(selected_bands));
// Points: 172, Faces: 244, Boundary loops: 8

Four cut values create five bands (0–4). Selecting bands 0, 2, 4 creates shells with gaps between them.

Connected Components

The bicylinder's symmetry causes some bands to split into separate components—selecting 3 bands yields 5 connected regions:

auto [component_labels, n_components] =
    tf::make_manifold_edge_connected_component_labels(slices.polygons());
// Connected components: 5

Split into separate meshes:

auto [components, comp_ids] =
    tf::split_into_components(slices.polygons(), component_labels);
// Component 0: 40 faces, 1 boundary loop
// Component 1: 40 faces, 1 boundary loop
// Component 2: 36 faces, 2 boundary loops
// Component 3: 92 faces, 2 boundary loops
// Component 4: 36 faces, 2 boundary loops

Write each component to a separate file:

for (const auto &[i, component] : tf::enumerate(components)) {
    tf::write_stl(component.polygons(),
                  "component_" + std::to_string(comp_ids[i]) + ".stl");
}

Summary

Key patterns:

  • Topological operations require shared vertices—clean mesh soup first
  • ensure_positive_orientation for consistent outward normals
  • triangulated for triangle-only output
  • Use make_boolean to combine closed meshes, not concatenate + clean
See Topology, Geometry, and Cut for details.