Modules | PY

Geometry

Geometric analysis, surface properties, and point cloud alignment.

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
FunctionParametersDescription
make_sphere_meshradius, stacks=20, segments=20UV sphere
make_cylinder_meshradius, height, segments=20Cylinder along z-axis
make_box_meshwidth, height, depth, [ticks]Axis-aligned box
make_plane_meshwidth, height, [ticks]Flat plane in XY

All functions accept dtype and index_dtype keyword arguments (default np.float32 and np.int32).

All generated meshes have consistent outward-facing normals (positive orientation).

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

FunctionInputReturns
areaTriangle, Polygon, ndarray, Mesh, or tuplefloat (single/mesh) or ndarray(N,) (batch)
volumeMesh or tuple (3D only)float
signed_volumeMesh or tuple (3D only)float
mean_edge_lengthTriangle, Polygon, Mesh, or tuplefloat
Volume functions require 3D meshes. Signed volume is positive when face normals point outward (CCW winding).
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.]
Normals require 3D primitives. Passing a 2D triangle or polygon raises 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()
PropertyReturnsDescription
normalsndarrayUnit face normals, shape (num_faces, 3)
point_normalsndarrayUnit 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
ParameterTypeDefaultDescription
dataMesh or tupleInput mesh or (faces, points) tuple
kint2k-ring neighborhood size
directionsboolFalseIf True, also return principal directions
ReturnsTypeDescription
k0ndarrayMaximum principal curvature, shape (num_points,)
k1ndarrayMinimum principal curvature, shape (num_points,)
d0ndarrayDirection of max curvature, shape (num_points, 3) (only if directions=True)
d1ndarrayDirection 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 RangeSurface 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, 1Convex 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))
ParameterTypeDefaultDescription
dataMesh or tupleInput mesh or (faces, points) tuple
kint2k-ring neighborhood size
ReturnsTypeDescription
shape_indexndarrayShape 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)
ParameterTypeDefaultDescription
dataMesh or tupleInput mesh or (faces, points) tuple (3D only)
is_consistentboolFalseSkip orient_faces_consistently step
ReturnsTypeDescription
facesndarray or OffsetBlockedArrayReoriented face indices

This function:

  1. Calls tf.orient_faces_consistently (unless is_consistent=True)
  2. Computes the signed volume of the mesh
  3. Reverses all face windings if the signed volume is negative
Only works with 3D meshes. Signed volume is positive when face normals point outward from a closed mesh.

Point Cloud Alignment

Compute rigid transformations to align point sets. Choose a method based on your scenario:

ScenarioFunctionNotes
Known correspondencesfit_rigid_alignmentOptimal closed-form solution
No correspondences, need initializationfit_obb_alignmentCoarse alignment via OBBs
No correspondences, need refinementfit_icp_alignmentFull ICP loop
Custom ICP with special logicfit_knn_alignmentSingle 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))
ParameterTypeDescription
cloud0PointCloud or (PointCloud, normals)Source, optionally with normals for weighting
cloud1PointCloud or (PointCloud, normals)Target, optionally with normals for point-to-plane
ReturnsTypeDescription
transformationndarray(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)
ParameterTypeDefaultDescription
cloud0PointCloudSource point cloud
cloud1PointCloudTarget point cloud
sample_sizeint100Points to sample for orientation disambiguation (0 = no disambiguation)
ReturnsTypeDescription
transformationndarray(3,3) for 2D or (4,4) for 3D homogeneous matrix
OBB alignment is inherently ambiguous up to 180° rotations about each axis. With 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
)
ParameterTypeDefaultDescription
cloud0PointCloud or (PointCloud, normals)Source, optionally with normals for weighting
cloud1PointCloud or (PointCloud, normals)Target, optionally with normals for point-to-plane
max_iterationsint100Maximum iterations
n_samplesint1000Subsample size per iteration (0 = all)
kint1Nearest neighbors (k=1 is classic ICP)
sigmafloat or NoneNoneGaussian width (None = adaptive)
outlier_proportionfloat0Outlier rejection ratio (0-1)
min_relative_improvementfloat1e-6Convergence threshold
ema_alphafloat0.3EMA smoothing factor
ReturnsTypeDescription
transformationndarray(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
)
ParameterTypeDefaultDescription
cloud0PointCloud or (PointCloud, normals)Source, optionally with normals for weighting
cloud1PointCloud or (PointCloud, normals)Target, optionally with normals for point-to-plane
kint1Nearest neighbors (k=1 is classic ICP)
sigmafloat or NoneNoneGaussian width (None = adaptive to k-th neighbor distance)
outlier_proportionfloat0Outlier rejection ratio (0-1)
ReturnsTypeDescription
transformationndarray(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
ParameterTypeDescription
sourcePointCloud, ndarray, or tupleSource points (see below)
targetPointCloudTarget point cloud
ReturnsTypeDescription
errorfloatMean nearest-neighbor distance

The source parameter accepts:

  • PointCloud - use directly
  • ndarray - points array, shape (N, dims)
  • tuple(ndarray, transformation) - points with 4x4 transformation matrix
Chamfer error is asymmetric. For alignment quality assessment, compute both directions.

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
ParameterTypeDescription
datandarray, Mesh, or tupleInput polygon(s) to triangulate

Input formats:

FormatDescription
ndarray shape (N, D)Single polygon with N vertices in D dimensions
MeshTriangle mesh (returned as-is) or dynamic n-gon mesh
(faces, points) tupleFixed n-gon mesh (triangles, quads, etc.)
(OffsetBlockedArray, points) tupleVariable n-gon mesh
ReturnsTypeDescription
facesndarrayTriangle indices, shape (num_triangles, 3)
pointsndarrayVertex coordinates (copied from input)
Uses the ear-cutting algorithm. For polygons with more than 80 vertices, z-order curve indexing is used. For N-dimensional polygons, faces are projected to 2D before triangulation.

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)
ParameterTypeDefaultDescription
dataMesh or tupleInput mesh or (points, vertex_link) tuple
iterationsint1Number of smoothing passes
lambda_float0.5Movement factor in 0,1
ReturnsTypeDescription
pointsndarraySmoothed 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 centroid
  • 0.5: Move halfway (default)
Laplacian smoothing shrinks the mesh. For volume-preserving smoothing, use Taubin smoothing below.

Taubin Smoothing

Smooth point positions while preserving volume by alternating shrink (positive λ) and inflate (negative μ) passes:

taubin_smoothed.py
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)
ParameterTypeDefaultDescription
dataMesh or tupleInput mesh or (points, vertex_link) tuple
iterationsint1Number of smoothing passes (each = shrink + inflate)
lambda_float0.5Shrink factor in (0,1]
kpbfloat0.1Pass-band frequency in (0,1)
ReturnsTypeDescription
pointsndarraySmoothed 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.

Unlike Laplacian smoothing, Taubin smoothing preserves mesh volume by alternating contraction and expansion. Use this when volume preservation is important.