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
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>.
Before proceeding, let's understand how to access and manage buffer data. This foundation applies throughout trueform.
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
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();
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();
Transfer ownership of the underlying memory:
// Release faces buffer
tube.faces_buffer().data_buffer().release();
// Release points buffer
tube.points_buffer().data_buffer().release();
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();
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.
The result is an offset_block_buffer—a container for variable-length blocks. Each block is a boundary loop containing vertex indices.
for (auto path : boundaries) {
for (auto vertex_id : path) {
// Process each vertex in the boundary loop
}
}
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)
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)));
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.
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.
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.
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.
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();
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.
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.
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");
}
| Buffer Type | Face Storage | Use Case |
|---|---|---|
polygons_buffer<..., 3> | blocked_buffer<int, 3> | Triangles |
polygons_buffer<..., 4> | blocked_buffer<int, 4> | Quads |
polygons_buffer<..., dynamic_size> | offset_block_buffer<int, int> | Mixed n-gons |
Key patterns:
ensure_positive_orientation for consistent outward normalstriangulated for triangle-only outputmake_boolean to combine closed meshes, not concatenate + clean