Geometry
The Geometry module provides tools for computing geometric properties of meshes, including normals, curvatures, triangulation, and point cloud alignment.
Include the module with:
#include <trueform/geometry.hpp>
Mesh Generation
Generate common primitive meshes for testing, or as building blocks.
Sphere
Create a UV sphere mesh:
// Index defaults to int, RealType deduced from radius
auto sphere = tf::make_sphere_mesh(1.0f, 20, 20); // radius, stacks, segments
// Returns tf::polygons_buffer<int, float, 3, 3>
// Specify index type for interop with other libraries
auto sphere = tf::make_sphere_mesh<uint32_t>(1.0f, 20, 20);
Cylinder
Create a cylinder mesh centered at origin along the z-axis:
// Index defaults to int, RealType deduced from radius/height
auto cylinder = tf::make_cylinder_mesh(1.0f, 2.0f, 20); // radius, height, segments
// Returns tf::polygons_buffer<int, float, 3, 3>
// Specify index type for interop with other libraries
auto cylinder = tf::make_cylinder_mesh<uint32_t>(1.0f, 2.0f, 20);
Box
Create an axis-aligned box mesh centered at origin:
// Index defaults to int, RealType deduced from dimensions
auto box = tf::make_box_mesh(2.0f, 1.0f, 3.0f); // width, height, depth
// Returns tf::polygons_buffer<int, float, 3, 3> (8 vertices, 12 triangles)
// Subdivided box for deformation/simulation (vertices shared at edges/corners)
auto box = tf::make_box_mesh(2.0f, 1.0f, 3.0f, 4, 2, 6); // + width_ticks, height_ticks, depth_ticks
// Specify index type for interop with other libraries
auto box = tf::make_box_mesh<uint32_t>(2.0f, 1.0f, 3.0f);
auto box = tf::make_box_mesh<uint32_t>(2.0f, 1.0f, 3.0f, 4, 2, 6);
Plane
Create a flat rectangular plane mesh in the XY plane, centered at origin:
// Index defaults to int, RealType deduced from dimensions
auto plane = tf::make_plane_mesh(10.0f, 5.0f); // width, height
// Returns tf::polygons_buffer<int, float, 3, 3> (4 vertices, 2 triangles)
// Subdivided plane for deformation/simulation
auto plane = tf::make_plane_mesh(10.0f, 5.0f, 20, 10); // + width_ticks, height_ticks
// Specify index type for interop with other libraries
auto plane = tf::make_plane_mesh<uint32_t>(10.0f, 5.0f);
auto plane = tf::make_plane_mesh<uint32_t>(10.0f, 5.0f, 20, 10);
Tube Mesh
Create tube meshes from curves using parallel transport frames. Works with intersection curves, boundary paths, isocontours, or any curve data.
Single curve:
auto tube = tf::make_tube_mesh(curve, 0.01f, 8); // curve, radius, radial_segments
// Returns tf::polygons_buffer<int, float, 3, 3>
Multiple curves:
// From intersection curves
auto curves_buf = tf::make_intersection_curves(form0, form1);
auto tubes = tf::make_tube_mesh(curves_buf.curves(), 0.01f, 12);
// From boundary paths
auto boundary = tf::make_boundary_paths(polygons);
auto boundary_curves = tf::make_curves(boundary, polygons.points());
auto tubes = tf::make_tube_mesh(boundary_curves, 0.005f, 8);
Closed curves (first point ~ last point) are detected automatically. The output is a manifold triangle mesh with shared vertex topology.
Edge Analysis
Sharp Edges
Extract edges where the dihedral angle between adjacent faces exceeds a threshold:
// Edges sharper than 30 degrees — blocked_buffer<Index, 2>
auto edges = tf::make_sharp_edges(polygons, tf::deg(30.f));
// With pre-computed manifold edge link and normals
auto tagged = polygons | tf::tag(mel) | tf::tag_normals(normals.unit_vectors());
auto edges = tf::make_sharp_edges(tagged, tf::deg(30.f));
// Visualize as segments
auto segments = tf::make_segments(edges, polygons.points());
Boundary and non-manifold edges are excluded — see Topology for make_boundary_edges and make_non_manifold_edges. If manifold edge link or normals are not tagged, they are computed internally.
For the angle itself at every edge two faces share, rather than the edges past a threshold, see Dihedral Angles.
Normal Computation
Polygon Normals
Compute face normals for polygon meshes:
// Compute polygon normals for all polygons
auto polygon_normals = tf::compute_normals(polygons);
// Result is a unit_vectors_buffer with one normal per face
for (auto normal : polygon_normals.unit_vectors()) {
// Each normal is a tf::unit_vector<T, 3>
auto [nx, ny, nz] = normal;
}
// Access underlying flat memory for export
float* normal_data = polygon_normals.data_buffer().data();
Point Normals
Compute vertex normals by averaging adjacent face normals. This requires the polygons to have both face_membership and normals tagged:
// First compute polygon normals
auto polygon_normals = tf::compute_normals(polygons);
// Build face membership for adjacency queries
auto fm = tf::make_face_membership(polygons);
// Tag the polygons with topology and normals
auto tagged_polygons = polygons
| tf::tag(fm)
| tf::tag_normals(polygon_normals.unit_vectors());
// Compute point normals (averages adjacent face normals)
auto point_normals = tf::compute_point_normals(tagged_polygons);
// Result is a unit_vectors_buffer with one normal per vertex
for (auto normal : point_normals.unit_vectors()) {
auto [nx, ny, nz] = normal;
}
Curvature Analysis
Compute surface curvature properties at each vertex by fitting a quadric to the local k-ring neighborhood.
Principal Curvatures
Compute the two principal curvatures (k0, k1) at each vertex:
auto [k0, k1] = tf::make_principal_curvatures(polygons);
for (auto [k0_i, k1_i] : tf::zip(k0, k1)) {
float gaussian = k0_i * k1_i; // Gaussian curvature
float mean = (k0_i + k1_i) / 2; // Mean curvature
}
To also compute principal directions:
auto [k0, k1, d0, d1] = tf::make_principal_directions(polygons);
for (auto [k0_i, k1_i, d0_i, d1_i] : tf::zip(k0, k1, d0, d1)) {
// d0_i is the direction of maximum curvature k0_i
// d1_i is the direction of minimum curvature k1_i
}
The k-ring size defaults to 2. For smoother estimates, increase k:
auto [k0, k1] = tf::make_principal_curvatures(polygons, /*k=*/3);
auto [k0, k1, d0, d1] = tf::make_principal_directions(polygons, /*k=*/3);
With pre-allocated buffers:
For repeated computations or custom memory management, use compute_principal_curvatures:
tf::buffer<float> k0, k1;
k0.allocate(polygons.points().size());
k1.allocate(polygons.points().size());
tf::compute_principal_curvatures(polygons, k0, k1);
// With directions
tf::unit_vectors_buffer<float, 3> d0, d1;
d0.allocate(polygons.points().size());
d1.allocate(polygons.points().size());
tf::compute_principal_curvatures(polygons, k0, k1, d0, d1);
The functions use the policy system. You can tag precomputed structures:
auto tagged = tf::make_polygons(faces, points | tf::tag_normals(normals))
| tf::tag(vertex_link);
auto [k0, k1] = tf::make_principal_curvatures(tagged);
Shape Index
Shape index maps principal curvatures to -1, 1 characterizing local surface type:
| Index Range | Surface Type |
|---|---|
| [-1, -5/8) | Concave ellipsoid (cup) |
| [-5/8, -3/8) | Concave cylinder (trough) |
| [-3/8, 3/8) | Hyperboloid (saddle) |
| [3/8, 5/8) | Convex cylinder (ridge) |
| 5/8, 1 | Convex ellipsoid (cap) |
auto shape_index = tf::make_shape_index(polygons);
The k-ring size defaults to 2. For smoother estimates, increase k:
auto shape_index = tf::make_shape_index(polygons, /*k=*/3);
With pre-allocated buffer:
tf::buffer<float> shape_index;
shape_index.allocate(polygons.points().size());
tf::compute_shape_index(polygons, shape_index);
The functions use the policy system. You can tag precomputed structures:
auto tagged = tf::make_polygons(faces, points | tf::tag_normals(normals))
| tf::tag(vertex_link);
auto shape_index = tf::make_shape_index(tagged);
Quality Measures
Face Quality
tf::compute_face_quality measures every face, one flat buffer per measure, each indexed by face:
auto measured = tf::compute_face_quality(polygons);
for (auto [q, a] : tf::zip(measured.quality, measured.aspect_ratio)) {
// q: the triangle measure, a: longest side over shortest
}
Or destructure the four buffers directly:
auto [quality, min_angle, max_angle, aspect_ratio] =
tf::compute_face_quality(polygons);
| Measure | Type | Description |
|---|---|---|
quality | tf::buffer<T> | The triangle measure: 1 for equilateral, approaching 0 for a sliver; a face that is not a triangle has none and reads -1 |
min_angle | tf::buffer<tf::rad<T>> | Smallest corner angle |
max_angle | tf::buffer<tf::rad<T>> | Largest corner angle |
aspect_ratio | tf::buffer<T> | Longest side over the shortest, infinite where a side has no length |
The corner angles and the aspect ratio are stated for a face of any arity. An angle is unsigned and lies in [0, pi], so a reflex corner of a non-convex face reads its explement. The input must be 3D.
Dihedral Angles
tf::compute_dihedral_angles measures every edge two faces share — edges blocked in vertex pairs beside an aligned angles buffer, face-major:
auto measured = tf::compute_dihedral_angles(polygons);
// With pre-computed manifold edge link and normals
auto tagged = polygons | tf::tag(mel) | tf::tag_normals(normals.unit_vectors());
auto measured_tagged = tf::compute_dihedral_angles(tagged);
for (std::size_t e = 0; e < measured.angles.size(); ++e) {
auto edge = measured.edges[e]; // [v0, v1]
auto angle = measured.angles[e]; // tf::rad<T> between the face normals
}
One entry per undirected edge, reached through the manifold edge link's representative. The angle is between the face normals, so a flat surface reads 0. A boundary or non-manifold edge joins no pair of faces and turns through no angle, so it is not stated. Manifold edge link and normals are taken from the form when tagged and built for the call when not.
Orientation
Ensure Positive Orientation
For closed meshes, ensure faces are oriented so that normals point outward (positive signed volume):
// First orients faces consistently, then flips all if signed volume is negative
bool oriented = tf::ensure_positive_orientation(polygons);
// Skip consistency step if mesh is already consistently oriented
oriented = tf::ensure_positive_orientation(polygons, true);
This function:
- Calls
tf::orient_faces_consistently(unlessis_consistent = true) - Computes the signed volume of the mesh
- Reverses all face windings if the signed volume is negative
It returns false and does not take the volume flip when any component is non-orientable, since such a surface has no outward side. The orientable components are still made consistent.
Triangulation
tf::triangulated is the one triangulation entry. It takes an indexed polygon
mesh, a single tf::polygon, or a polygon soup, and always returns an owning
triangle mesh — the corners with the point table they index.
Single Polygon
Triangulate a single polygon:
// From single polygon - returns tf::polygons_buffer<int, RealT, Dims, 3>
auto triangle_mesh = tf::triangulated(polygon);
for (auto triangle : triangle_mesh.polygons()) {
auto [pt0, pt1, pt2] = triangle;
}
Polygon Mesh
Triangulate a polygon mesh (quads, n-gons, dynamic):
// From polygon range - returns tf::polygons_buffer<Index, RealT, Dims, 3>
auto triangle_mesh = tf::triangulated(polygons);
for (auto triangle : triangle_mesh.polygons()) {
auto [pt0, pt1, pt2] = triangle;
}
// A corner is a position in the returned point table, so the width it is
// written in is the caller's choice: ask for int32_t when an int64_t mesh
// does not need the range. It defaults to the input's own.
auto narrow = tf::triangulated<std::int32_t>(polygons);
See Polygons Buffer for details on accessing the underlying data.
Polygon Soup
A soup — tf::make_polygons over a range of polygons, where a corner two faces
share is stated once per face — triangulates through the same entry:
auto soup = tf::make_polygons(range_of_polygons);
// Index NAMES the output here: a soup carries no index type of its own, so
// the request is the caller's naming and it defaults to int.
auto triangle_mesh = tf::triangulated<std::int32_t>(soup);
tf::cleaned to shared-vertex identity first, so the triangulation machinery only ever sees indexed meshes — a corner two faces share becomes one point, and the shared edge is one identity in both, exactly as for a mesh that arrived indexed. tf::return_refused takes indexed meshes only: the clean drops the faces that bound nothing, so a soup has no face identity to refuse.labels != 0: a face's product is everything its triangulation kept inside its own boundary, and the hull exterior is the only thing dropped. For a simple loop that is the even-odd rule exactly; for a loop that overlaps itself it is not — a doubly wound region is inside, and this keeps it.Refused Faces
A face that holds no product is answered with emptiness. Pass tf::return_refused to also receive, ascending, the ids of the faces the triangulator refused:
// Returns std::pair<tf::polygons_buffer<Index, RealT, Dims, 3>, tf::buffer<Index>>
auto [triangle_mesh, refused] = tf::triangulated(polygons, tf::return_refused);
| Parameter | Type | Description |
|---|---|---|
polygons | tf::polygons<Policy> | Input mesh |
tf::return_refused | tf::return_refused_t | Tag requesting the refused ids |
The mesh is exactly the one the untagged call returns. The ids are ascending face ids of the input — a different identity space from the mesh's corners — so they carry the input's own index type whatever width the mesh was asked for.
A face is refused when its triangulation refused every round of the resolution wave — it holds no product and its triangles are absent. A face the wave resolves is not refused, and a face that bounds no area holds its product by emitting nothing, on the same terms as every other carrier.
Curves
When a curve lies on a plane (e.g., isocontours extracted from a mesh surface), it can be triangulated into a filled polygon. Simply reinterpret the curves as polygons using tf::make_polygon or tf::make_polygons and use the same algorithm:
Triangulate a single curve:
// curve is a range of 3D points forming a closed planar loop
auto triangle_mesh = tf::triangulated(tf::make_polygon(curve));
Triangulate multiple curves:
// curves is a tf::curves (paths + points)
auto triangle_mesh = tf::triangulated(
tf::make_polygons(curves.paths(), curves.points()));
Point Cloud Alignment
Compute rigid transformations to align point sets. Choose a method based on your scenario:
| Scenario | Function | Notes |
|---|---|---|
| Known correspondences | fit_rigid_alignment | Optimal closed-form solution |
| Known correspondences + scale | fit_similarity_alignment | Includes uniform scale |
| No correspondences, need initialization | fit_obb_alignment | Coarse alignment |
| No correspondences, need refinement | fit_icp_alignment | Full ICP loop |
| Custom ICP with special logic | fit_knn_alignment | Single ICP step |
All alignment functions support lazy transformation tagging. Instead of copying transformed points, tag an initial transformation onto the source and the function applies it on-the-fly:
// Tag initial transform - no data copy
auto T_delta = tf::fit_icp_alignment(X.points() | tf::tag(T_init), Y_with_tree, config);
// Returns DELTA transformation (source_world -> target_world)
// Compose with initial transform for total:
auto T_total = tf::transformed(T_init, T_delta);
→ Point Cloud Alignment Example — A complete walkthrough from OBB initialization through ICP convergence.
With Correspondences
These methods require paired points where X[i] corresponds to Y[i].
Rigid Alignment (fit_rigid_alignment)
Use the Kabsch/Procrustes algorithm for optimal rotation + translation. Returns a DELTA transformation mapping source world coordinates to target world coordinates.
Point-to-Point — Minimizes Euclidean distance:
// X and Y must have the same number of points, X[i] corresponds to Y[i]
// Returns delta: source_world -> target_world
auto T_delta = tf::fit_rigid_alignment(X.points(), Y.points());
Point-to-Plane — When target has normals, minimizes distance along normal direction:
auto Y_with_normals = Y.points() | tf::tag_normals(normals.unit_vectors());
auto T = tf::fit_rigid_alignment(X.points(), Y_with_normals);
Point-to-Plane with Normal Weighting — When both have normals, uses normal agreement for weighting:
auto X_with_normals = X.points() | tf::tag_normals(source_normals.unit_vectors());
auto Y_with_normals = Y.points() | tf::tag_normals(target_normals.unit_vectors());
auto T = tf::fit_rigid_alignment(X_with_normals, Y_with_normals);
Similarity Alignment (fit_similarity_alignment)
When scales may differ, use similarity alignment (rotation + translation + uniform scale):
auto T = tf::fit_similarity_alignment(X.points(), Y.points());
// Extract scale factor
float scale = std::sqrt(T(0,0)*T(0,0) + T(1,0)*T(1,0) + T(2,0)*T(2,0));
Without Correspondences
These methods work when point correspondences are unknown or point counts differ.
Coarse Alignment (fit_obb_alignment)
Align using oriented bounding boxes (OBBs) for initialization. Returns a DELTA transformation mapping source world coordinates to target world coordinates.
// No correspondence required - points can have different counts
// Returns delta: source_world -> target_world
auto T_delta = tf::fit_obb_alignment(X.points(), Y.points());
To resolve the ambiguity, attach a tree to the target:
// Build tree on target for disambiguation
tf::aabb_tree<int, float, 3> tree(Y.points(), tf::config_tree(4, 4));
auto Y_with_tree = Y.points() | tf::tag(tree);
// Tests all 4 orientations, returns the one with lowest chamfer distance
auto T = tf::fit_obb_alignment(X.points(), Y_with_tree);
Iterative Refinement (fit_icp_alignment)
Refine any initial alignment using ICP. This is the main entry point for iterative closest point registration.
// Configure ICP
tf::icp_config config;
config.max_iterations = 50;
config.n_samples = 1000; // subsample for speed
config.k = 1; // k=1 is classic ICP
config.min_relative_improvement = 1e-6f;
// Build tree on target
tf::aabb_tree<int, float, 3> tree(Y.points(), tf::config_tree(4, 4));
Point-to-Point — Classic ICP using nearest neighbor distance:
auto target = Y.points() | tf::tag(tree);
auto T = tf::fit_icp_alignment(X.points() | tf::tag(T_init), target, config);
Point-to-Plane — When target has normals, converges 2-3x faster:
auto target = Y.points()
| tf::tag(tree)
| tf::tag_normals(normals.unit_vectors());
auto T = tf::fit_icp_alignment(X.points() | tf::tag(T_init), target, config);
Point-to-Plane with Normal Weighting — When both have normals, uses normal agreement for weighting:
auto X_with_normals = X.points() | tf::tag(T_init)
| tf::tag_normals(source_normals.unit_vectors());
auto Y_with_normals = Y.points() | tf::tag(tree)
| tf::tag_normals(target_normals.unit_vectors());
auto T = tf::fit_icp_alignment(X_with_normals, Y_with_normals, config);
icp_config parameters:
| Parameter | Default | Description |
|---|---|---|
max_iterations | 100 | Maximum iterations |
min_relative_improvement | 1e-6 | Convergence threshold |
ema_alpha | 0.3 | EMA smoothing factor |
n_samples | 1000 | Subsample size (0 = all) |
k | 1 | Nearest neighbors |
sigma | -1 | Gaussian width (-1 = adaptive) |
outlier_proportion | 0 | Outlier rejection ratio |
Single ICP Step (fit_knn_alignment)
For custom ICP pipelines (multi-start, early termination, special convergence logic), use fit_knn_alignment. Returns a DELTA transformation mapping source world coordinates to target world coordinates. Supports the same variants as fit_icp_alignment.
Point-to-Point:
// Returns delta: source_world -> target_world
auto T_iter = tf::fit_knn_alignment(X.points() | tf::tag(T_accum), Y_with_tree, {.k = 1});
T_accum = tf::transformed(T_accum, T_iter); // Compose: total = old_total @ delta
Point-to-Plane:
auto Y_with_normals = Y.points() | tf::tag(tree) | tf::tag_normals(normals.unit_vectors());
auto T_iter = tf::fit_knn_alignment(X.points() | tf::tag(T_accum), Y_with_normals, {.k = 1});
Point-to-Plane with Normal Weighting:
auto X_with_normals = X.points() | tf::tag(T_accum) | tf::tag_normals(source_normals.unit_vectors());
auto Y_with_normals = Y.points() | tf::tag(tree) | tf::tag_normals(target_normals.unit_vectors());
auto T_iter = tf::fit_knn_alignment(X_with_normals, Y_with_normals, {.k = 1});
knn_alignment_config parameters:
| Parameter | Default | Description |
|---|---|---|
k | 1 | Nearest neighbors (k=1 is classic ICP) |
sigma | -1 | Gaussian width (-1 = adaptive to k-th neighbor distance) |
outlier_proportion | 0 | Outlier rejection ratio |
For soft correspondences, use k > 1 with Gaussian weighting:
auto T_soft = tf::fit_knn_alignment(X.points(), Y_with_tree, {.k = 5, .sigma = -1.f});
Error Metrics
Chamfer Error
Compute one-way chamfer error (mean nearest-neighbor distance) between point sets:
// Target must have tree policy for efficient search
tf::aabb_tree<int, float, 3> tree(Y.points(), tf::config_tree(4, 4));
auto Y_with_tree = Y.points() | tf::tag(tree);
// One-way error: mean distance from X to nearest point in Y
float error_xy = tf::chamfer_error(X.points(), Y_with_tree);
// For symmetric chamfer distance, compute both directions
tf::aabb_tree<int, float, 3> tree_x(X.points(), tf::config_tree(4, 4));
auto X_with_tree = X.points() | tf::tag(tree_x);
float error_yx = tf::chamfer_error(Y.points(), X_with_tree);
float symmetric = (error_xy + error_yx) / 2.0f;
Both source and target accept transformation tagging. Use this to evaluate alignment quality without copying transformed points:
float error = tf::chamfer_error(X.points() | tf::tag(T), Y_with_tree);
Smoothing
Laplacian Smoothing
Smooth point positions by iteratively moving vertices towards their neighbors' centroid:
// Build vertex connectivity
auto vlink = tf::make_vertex_link(polygons);
// Tag points with vertex link
auto points_with_link = points | tf::tag(vlink);
// Smooth: 100 iterations, lambda=0.5 (how much to move towards centroid)
auto smoothed = tf::laplacian_smoothed(points_with_link, 100, 0.5f);
// Result is a points_buffer with smoothed positions
for (auto pt : smoothed.points()) {
// Use smoothed point
}
Parameters:
iterations: Number of smoothing passeslambda: Movement factor in 0,1. 0 = no movement, 1 = move fully to centroid
Taubin Smoothing
Smooth point positions while preserving volume by alternating shrink (positive λ) and inflate (negative μ) passes:
// Build vertex connectivity
auto vlink = tf::make_vertex_link(polygons);
// Tag points with vertex link
auto points_with_link = points | tf::tag(vlink);
// Smooth: 50 iterations, lambda=0.5, pass-band frequency kpb=0.1
auto smoothed = tf::taubin_smoothed(points_with_link, 50, 0.5f, 0.1f);
// Result is a points_buffer with smoothed positions
for (auto pt : smoothed.points()) {
// Use smoothed point
}
Parameters:
iterations: Number of smoothing passes (each pass = shrink + inflate)lambda: Shrink factor in (0,1]. Controls smoothing strength (default: 0.5)kpb: Pass-band frequency in (0,1). Controls volume preservation (default: 0.1)
The inflate factor μ is computed automatically as μ = 1 / (kpb - 1/λ) to ensure the smoothing filter passes through zero at frequency kpb, preventing low-frequency shrinkage.
