Geometry
The Geometry module provides tools for computing geometric properties of meshes, including normals, curvatures, triangulation, and point cloud alignment.
Mesh Generation
Generate common primitive meshes for testing, or as building blocks.
Sphere
Create a UV sphere mesh:
import trueform as tf
# Basic sphere (radius, stacks, segments)
faces, points = tf.make_sphere_mesh(1.0, 20, 20)
# With custom dtypes
faces, points = tf.make_sphere_mesh(1.0, 20, 20, dtype=np.float64, index_dtype=np.int64)
Cylinder
Create a cylinder mesh centered at origin along the z-axis:
# Basic cylinder (radius, height, segments)
faces, points = tf.make_cylinder_mesh(1.0, 2.0, 20)
Box
Create an axis-aligned box mesh centered at origin:
# Simple box (width, height, depth) -> 8 vertices, 12 triangles
faces, points = tf.make_box_mesh(2.0, 1.0, 3.0)
# Subdivided box for deformation/simulation
faces, points = tf.make_box_mesh(2.0, 1.0, 3.0, 4, 2, 6) # + width_ticks, height_ticks, depth_ticks
Plane
Create a flat rectangular plane mesh in the XY plane, centered at origin:
# Simple plane (width, height) -> 4 vertices, 2 triangles
faces, points = tf.make_plane_mesh(10.0, 5.0)
# Subdivided plane for deformation/simulation
faces, points = tf.make_plane_mesh(10.0, 5.0, 20, 10) # + width_ticks, height_ticks
| Function | Parameters | Description |
|---|---|---|
make_sphere_mesh | radius, stacks=20, segments=20 | UV sphere |
make_cylinder_mesh | radius, height, segments=20 | Cylinder along z-axis |
make_box_mesh | width, height, depth, [ticks] | Axis-aligned box |
make_plane_mesh | width, height, [ticks] | Flat plane in XY |
All functions accept dtype and index_dtype keyword arguments (default np.float32 and np.int32).
Measurements
Area
Compute the area of a triangle, polygon, or total surface area of a mesh. Supports single and batch primitives.
import trueform as tf
import numpy as np
# Single triangle
tri = tf.Triangle(a=[0, 0, 0], b=[1, 0, 0], c=[0, 1, 0])
tf.area(tri) # 0.5
# Batch of triangles → array of areas
tris = tf.Triangle(np.random.rand(50, 3, 3).astype(np.float32))
areas = tf.area(tris) # shape (50,)
# Single polygon
quad = tf.Polygon([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]])
tf.area(quad) # 1.0
# Batch of polygons
polys = tf.Polygon(np.random.rand(50, 4, 3).astype(np.float32))
areas = tf.area(polys) # shape (50,)
# Raw ndarray polygon
polygon = np.array([[0, 0], [2, 0], [2, 3], [0, 3]], dtype=np.float32)
tf.area(polygon) # 6.0
# Mesh total surface area
faces, points = tf.make_box_mesh(2.0, 3.0, 4.0)
tf.area((faces, points)) # 52.0
tf.area(tf.Mesh(faces, points)) # 52.0
Volume
Compute the volume of a closed 3D mesh:
faces, points = tf.make_box_mesh(2.0, 3.0, 4.0)
tf.volume((faces, points)) # 24.0
# Signed volume (positive = outward normals, negative = inward)
tf.signed_volume((faces, points)) # 24.0
tf.signed_volume((faces[:, ::-1], points)) # -24.0
Mean Edge Length
Compute the mean edge length of a triangle, polygon, or mesh:
# Primitives (single or batch)
tf.mean_edge_length(tri)
tf.mean_edge_length(tris) # mean over all edges in the batch
# Mesh
tf.mean_edge_length(mesh)
tf.mean_edge_length((faces, points))
Summary
| Function | Input | Returns |
|---|---|---|
area | Triangle, Polygon, ndarray, Mesh, or tuple | float (single/mesh) or ndarray(N,) (batch) |
volume | Mesh or tuple (3D only) | float |
signed_volume | Mesh or tuple (3D only) | float |
mean_edge_length | Triangle, Polygon, Mesh, or tuple | float |
area, volume, signed_volume, and mean_edge_length respect the Meshtransformation. When a Mesh has a transformation, measurements are computed in the transformed coordinate space.Normals
Primitive Normals
tf.normals() computes unit normals for 3D Triangle and Polygon primitives, with batch support:
import trueform as tf
import numpy as np
# Single triangle
tri = tf.Triangle(a=[0, 0, 0], b=[1, 0, 0], c=[0, 1, 0])
tf.normals(tri) # [0. 0. 1.]
# Batch of triangles → array of normals
tris = tf.Triangle(np.random.rand(50, 3, 3).astype(np.float32))
norms = tf.normals(tris) # shape (50, 3), unit vectors
# Polygons work the same way
quad = tf.Polygon([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]])
tf.normals(quad) # [0. 0. 1.]
ValueError.Mesh Normals
Face and vertex normals for meshes. Lazily computed on first access and cached.
mesh = tf.Mesh(faces, points)
# Face normals (one unit normal per face)
normals = mesh.normals # shape: (num_faces, 3)
# Vertex normals (area-weighted average of adjacent face normals)
point_normals = mesh.point_normals # shape: (num_points, 3)
# Prebuild for performance (optional)
mesh.build_normals()
mesh.build_point_normals()
| Property | Returns | Description |
|---|---|---|
normals | ndarray | Unit face normals, shape (num_faces, 3) |
point_normals | ndarray | Unit vertex normals, shape (num_points, 3) |
Standalone functions accept a Mesh, (faces, points) tuple, or primitives:
# From Mesh or tuple
normals = tf.normals(mesh)
normals = tf.normals((faces, points))
point_normals = tf.point_normals(mesh)
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:
import trueform as tf
# Basic usage
k0, k1 = tf.principal_curvatures(mesh)
# With custom k-ring size (default k=2)
k0, k1 = tf.principal_curvatures(mesh, k=3)
# Also compute principal directions
k0, k1, d0, d1 = tf.principal_curvatures(mesh, directions=True)
# Works with tuples
k0, k1 = tf.principal_curvatures((faces, points))
# Derived curvature measures
gaussian_curvature = k0 * k1
mean_curvature = (k0 + k1) / 2
| Parameter | Type | Default | Description |
|---|---|---|---|
data | Mesh or tuple | Input mesh or (faces, points) tuple | |
k | int | 2 | k-ring neighborhood size |
directions | bool | False | If True, also return principal directions |
| Returns | Type | Description |
|---|---|---|
k0 | ndarray | Maximum principal curvature, shape (num_points,) |
k1 | ndarray | Minimum principal curvature, shape (num_points,) |
d0 | ndarray | Direction of max curvature, shape (num_points, 3) (only if directions=True) |
d1 | ndarray | Direction of min curvature, shape (num_points, 3) (only if directions=True) |
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) |
import trueform as tf
# Basic usage
si = tf.shape_index(mesh)
# With custom k-ring size
si = tf.shape_index(mesh, k=3)
# Works with tuples
si = tf.shape_index((faces, points))
| Parameter | Type | Default | Description |
|---|---|---|---|
data | Mesh or tuple | Input mesh or (faces, points) tuple | |
k | int | 2 | k-ring neighborhood size |
| Returns | Type | Description |
|---|---|---|
shape_index | ndarray | Shape index at each vertex, shape (num_points,) |
Orientation
Ensure Positive Orientation
For closed 3D meshes, ensure faces are oriented so that normals point outward (positive signed volume):
import trueform as tf
# Orient mesh to have outward-facing normals
new_faces = tf.ensure_positive_orientation(mesh)
# Or with tuple input
new_faces = tf.ensure_positive_orientation((faces, points))
# Skip consistency step if mesh is already consistently oriented
new_faces = tf.ensure_positive_orientation(mesh, is_consistent=True)
| Parameter | Type | Default | Description |
|---|---|---|---|
data | Mesh or tuple | Input mesh or (faces, points) tuple (3D only) | |
is_consistent | bool | False | Skip orient_faces_consistently step |
| Returns | Type | Description |
|---|---|---|
faces | ndarray or OffsetBlockedArray | Reoriented face indices |
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
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 |
| No correspondences, need initialization | fit_obb_alignment | Coarse alignment via OBBs |
| 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, set an initial transformation on the source and the function applies it on-the-fly:
# Tag initial transform - no data copy
source.transformation = T_init
T_delta = tf.fit_icp_alignment(source, target)
# Returns DELTA transformation (source_world -> target_world)
# Compose with initial transform for total:
T_total = T_delta @ T_init
source.transformation = T_total
Normals via Tuples: Pass (cloud, normals) to enable point-to-plane or normal weighting. This mirrors C++ points | tf::tag_normals(normals).
→ See python/examples/alignment.py for 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:
import trueform as tf
# X and Y must have the same number of points, X[i] corresponds to Y[i]
cloud_x = tf.PointCloud(pts_x)
cloud_y = tf.PointCloud(pts_y)
T = tf.fit_rigid_alignment(cloud_x, cloud_y)
Point-to-Plane — When target has normals, minimizes distance along normal direction:
# Pass (cloud, normals) tuple for point-to-plane
T = tf.fit_rigid_alignment(cloud_x, (cloud_y, target_normals))
Point-to-Plane with Normal Weighting — When both have normals, uses normal agreement for weighting:
T = tf.fit_rigid_alignment((cloud_x, source_normals), (cloud_y, target_normals))
| Parameter | Type | Description |
|---|---|---|
cloud0 | PointCloud or (PointCloud, normals) | Source, optionally with normals for weighting |
cloud1 | PointCloud or (PointCloud, normals) | Target, optionally with normals for point-to-plane |
| Returns | Type | Description |
|---|---|---|
transformation | ndarray | (3,3) for 2D or (4,4) for 3D homogeneous matrix |
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:
# Different point counts allowed
cloud_x = tf.PointCloud(pts_x) # 1000 points
cloud_y = tf.PointCloud(pts_y) # 500 points
# Align OBB axes and centers
T = tf.fit_obb_alignment(cloud_x, cloud_y)
| Parameter | Type | Default | Description |
|---|---|---|---|
cloud0 | PointCloud | Source point cloud | |
cloud1 | PointCloud | Target point cloud | |
sample_size | int | 100 | Points to sample for orientation disambiguation (0 = no disambiguation) |
| Returns | Type | Description |
|---|---|---|
transformation | ndarray | (3,3) for 2D or (4,4) for 3D homogeneous matrix |
sample_size > 0, the function tests all 4 orientations and selects the one with lowest chamfer distance.Iterative Refinement (fit_icp_alignment)
Refine any initial alignment using ICP. This is the main entry point for iterative closest point registration. Returns a DELTA transformation mapping source world coordinates to target world coordinates.
Point-to-Point — Classic ICP using nearest neighbor distance:
source.transformation = T_init # Start from OBB alignment
T = tf.fit_icp_alignment(source, target, max_iterations=50, n_samples=1000)
Point-to-Plane — When target has normals, converges 2-3x faster:
# Compute normals from mesh
target_normals = tf.point_normals(mesh)
# Pass (cloud, normals) tuple
T = tf.fit_icp_alignment(source, (target, target_normals), max_iterations=50)
Point-to-Plane with Normal Weighting — When both have normals, uses normal agreement for weighting:
T = tf.fit_icp_alignment(
(source, source_normals),
(target, target_normals),
max_iterations=50
)
| Parameter | Type | Default | Description |
|---|---|---|---|
cloud0 | PointCloud or (PointCloud, normals) | Source, optionally with normals for weighting | |
cloud1 | PointCloud or (PointCloud, normals) | Target, optionally with normals for point-to-plane | |
max_iterations | int | 100 | Maximum iterations |
n_samples | int | 1000 | Subsample size per iteration (0 = all) |
k | int | 1 | Nearest neighbors (k=1 is classic ICP) |
sigma | float or None | None | Gaussian width (None = adaptive) |
outlier_proportion | float | 0 | Outlier rejection ratio (0-1) |
min_relative_improvement | float | 1e-6 | Convergence threshold |
ema_alpha | float | 0.3 | EMA smoothing factor |
| Returns | Type | Description |
|---|---|---|
transformation | ndarray | (3,3) for 2D or (4,4) for 3D homogeneous matrix |
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:
T_iter = tf.fit_knn_alignment(source, target, k=1)
source.transformation = T_iter @ source.transformation
Point-to-Plane:
T_iter = tf.fit_knn_alignment(source, (target, target_normals), k=1)
Point-to-Plane with Normal Weighting:
T_iter = tf.fit_knn_alignment(
(source, source_normals),
(target, target_normals),
k=1
)
| Parameter | Type | Default | Description |
|---|---|---|---|
cloud0 | PointCloud or (PointCloud, normals) | Source, optionally with normals for weighting | |
cloud1 | PointCloud or (PointCloud, normals) | Target, optionally with normals for point-to-plane | |
k | int | 1 | Nearest neighbors (k=1 is classic ICP) |
sigma | float or None | None | Gaussian width (None = adaptive to k-th neighbor distance) |
outlier_proportion | float | 0 | Outlier rejection ratio (0-1) |
| Returns | Type | Description |
|---|---|---|
transformation | ndarray | (3,3) for 2D or (4,4) for 3D homogeneous matrix |
For soft correspondences, use k > 1 with Gaussian weighting:
T_soft = tf.fit_knn_alignment(source, target, k=5)
Error Metrics
Chamfer Error
Compute one-way chamfer error (mean nearest-neighbor distance):
# One-way error: mean distance from X to nearest point in Y
error_xy = tf.chamfer_error(cloud_x, cloud_y)
# Symmetric chamfer distance
error_yx = tf.chamfer_error(cloud_y, cloud_x)
symmetric_error = (error_xy + error_yx) / 2
| Parameter | Type | Description |
|---|---|---|
source | PointCloud, ndarray, or tuple | Source points (see below) |
target | PointCloud | Target point cloud |
| Returns | Type | Description |
|---|---|---|
error | float | Mean nearest-neighbor distance |
The source parameter accepts:
PointCloud- use directlyndarray- points array, shape(N, dims)tuple(ndarray, transformation)- points with 4x4 transformation matrix
Subsampling for faster computation on large point sets:
# Every 10th point for quick estimate
error = tf.chamfer_error(points[::10], target)
# With transformation
error = tf.chamfer_error((points[::10], T), target)
Both source and target accept transformations. Use this to evaluate alignment quality without copying transformed points:
source.transformation = T
error = tf.chamfer_error(source, target)
Triangulation
Convert polygon meshes to triangle meshes using ear-cutting triangulation.
Single Polygon
Triangulate a single polygon defined by its boundary vertices:
import trueform as tf
import numpy as np
# Single polygon (just points defining the polygon boundary)
polygon = np.array([
[0, 0, 0],
[1, 0, 0],
[1, 1, 0],
[0.5, 1.5, 0],
[0, 1, 0]
], dtype=np.float32)
faces, points = tf.triangulated(polygon)
# faces.shape = (3, 3) # 5-gon -> 3 triangles
# points.shape = (5, 3)
Polygon Mesh
For polygon meshes (quads, n-gons), pass a tuple of (faces, points):
# Quad mesh
quads = np.array([[0, 1, 2, 3], [1, 4, 5, 2]], dtype=np.int32)
pts = np.array([
[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0],
[2, 0, 0], [2, 1, 0]
], dtype=np.float32)
faces, points = tf.triangulated((quads, pts))
# faces.shape = (4, 3) # 2 quads -> 4 triangles
For variable-size polygons, use OffsetBlockedArray:
# Mixed n-gons (triangle + quad + pentagon)
offsets = np.array([0, 3, 7, 12], dtype=np.int32)
data = np.array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], dtype=np.int32)
dyn_faces = tf.OffsetBlockedArray(offsets, data)
faces, points = tf.triangulated((dyn_faces, pts))
# Triangle (1) + Quad (2) + Pentagon (3) = 6 triangles
| Parameter | Type | Description |
|---|---|---|
data | ndarray, Mesh, or tuple | Input polygon(s) to triangulate |
Input formats:
| Format | Description |
|---|---|
ndarray shape (N, D) | Single polygon with N vertices in D dimensions |
Mesh | Triangle mesh (returned as-is) or dynamic n-gon mesh |
(faces, points) tuple | Fixed n-gon mesh (triangles, quads, etc.) |
(OffsetBlockedArray, points) tuple | Variable n-gon mesh |
| Returns | Type | Description |
|---|---|---|
faces | ndarray | Triangle indices, shape (num_triangles, 3) |
points | ndarray | Vertex coordinates (copied from input) |
Smoothing
Laplacian Smoothing
Smooth point positions by iteratively moving vertices towards their neighbors' centroid:
import trueform as tf
import numpy as np
faces = np.array([[0, 1, 2], [1, 3, 2]], dtype=np.int32)
points = np.array([
[0, 0, 0], [1, 0, 0], [0.5, 1, 0], [1.5, 1, 0]
], dtype=np.float32)
mesh = tf.Mesh(faces, points)
# Smooth: 100 iterations, lambda=0.5
smoothed_points = tf.laplacian_smoothed(mesh, iterations=100, lambda_=0.5)
# Or with tuple (points, vertex_link)
vl = mesh.vertex_link
smoothed_points = tf.laplacian_smoothed((points, vl), iterations=100, lambda_=0.5)
| Parameter | Type | Default | Description |
|---|---|---|---|
data | Mesh or tuple | Input mesh or (points, vertex_link) tuple | |
iterations | int | 1 | Number of smoothing passes |
lambda_ | float | 0.5 | Movement factor in 0,1 |
| Returns | Type | Description |
|---|---|---|
points | ndarray | Smoothed point positions, shape (num_points, dims) |
The lambda_ parameter controls how much each vertex moves towards its neighbors' centroid:
0: No movement (returns original points)1: Move fully to centroid0.5: Move halfway (default)
Taubin Smoothing
Smooth point positions while preserving volume by alternating shrink (positive λ) and inflate (negative μ) passes:
import trueform as tf
import numpy as np
# Create mesh
faces = np.array([[0, 1, 2], [1, 3, 2]], dtype=np.int32)
points = np.array([
[0, 0, 0], [1, 0, 0], [0.5, 1, 0], [1.5, 1, 0]
], dtype=np.float32)
mesh = tf.Mesh(faces, points)
# Smooth: 50 iterations, lambda=0.5, pass-band frequency kpb=0.1
smoothed_points = tf.taubin_smoothed(mesh, iterations=50, lambda_=0.5, kpb=0.1)
# Or with tuple (points, vertex_link)
vl = mesh.vertex_link
smoothed_points = tf.taubin_smoothed((points, vl), iterations=50, lambda_=0.5, kpb=0.1)
| Parameter | Type | Default | Description |
|---|---|---|---|
data | Mesh or tuple | Input mesh or (points, vertex_link) tuple | |
iterations | int | 1 | Number of smoothing passes (each = shrink + inflate) |
lambda_ | float | 0.5 | Shrink factor in (0,1] |
kpb | float | 0.1 | Pass-band frequency in (0,1) |
| Returns | Type | Description |
|---|---|---|
points | ndarray | Smoothed point positions, shape (num_points, dims) |
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.
