The Geometry module provides tools for computing geometric properties of meshes, including normals, curvatures, triangulation, and point cloud alignment.
Compute face normals and vertex normals for 3D meshes. Normals are lazily computed on first access and cached for subsequent use.
Access face normals via the normals property:
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)
# Get face normals (one unit normal per face)
normals = mesh.normals # shape: (num_faces, 3)
# Prebuild for performance (optional)
mesh.build_normals()
# Set custom normals
mesh.normals = custom_normals
Vertex normals are computed by averaging adjacent face normals, weighted by face area:
# Get vertex normals (one unit normal per vertex)
point_normals = mesh.point_normals # shape: (num_points, 3)
# Prebuild for performance (optional)
mesh.build_point_normals()
# Set custom vertex normals
mesh.point_normals = custom_point_normals
| Property | Returns | Description |
|---|---|---|
normals | ndarray | Unit face normals, shape (num_faces, 3) |
point_normals | ndarray | Unit vertex normals, shape (num_points, 3) |
normals or point_normals on a 2D mesh raises ValueError.For convenience, standalone functions accept either a Mesh or a (faces, points) tuple:
import trueform as tf
# From Mesh
normals = tf.normals(mesh)
point_normals = tf.point_normals(mesh)
# From tuple
normals = tf.normals((faces, points))
point_normals = tf.point_normals((faces, points))
# Dynamic mesh (OffsetBlockedArray)
normals = tf.normals((dyn_faces, points))
Compute surface curvature properties at each vertex by fitting a quadric to the local k-ring neighborhood.
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 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,) |
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:
tf.orient_faces_consistently (unless is_consistent=True)Compute transformations to align point sets. Methods are organized by whether they require known point correspondences.
Use the Kabsch/Procrustes algorithm for optimal rotation + translation. Requires paired points where X[i] corresponds to Y[i]:
import trueform as tf
import numpy as np
# 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)
# Compute optimal rigid transformation T such that T(X) ≈ Y
T = tf.fit_rigid_alignment(cloud_x, cloud_y)
# T is a 4x4 homogeneous transformation matrix
print(T.shape) # (4, 4)
# Apply the transformation
cloud_x.transformation = T
| Parameter | Type | Description |
|---|---|---|
cloud0 | PointCloud | Source point cloud |
cloud1 | PointCloud | Target point cloud (same size as cloud0) |
| Returns | Type | Description |
|---|---|---|
transformation | ndarray | (3,3) for 2D or (4,4) for 3D homogeneous matrix |
These methods work when point correspondences are unknown or point counts differ.
Align using oriented bounding boxes (OBBs):
# 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)
# Apply transformation
cloud_x.transformation = T
| Parameter | Type | Default | Description |
|---|---|---|---|
cloud0 | PointCloud | Source point cloud | |
cloud1 | PointCloud | Target point cloud | |
sample_size | int | 100 | Points to sample for orientation disambiguation |
| Returns | Type | Description |
|---|---|---|
transformation | ndarray | (3,3) for 2D or (4,4) for 3D homogeneous matrix |
For iterative closest point (ICP) registration, use k-nearest neighbor alignment:
# Single ICP iteration
T_iter = tf.fit_knn_alignment(cloud_x, cloud_y)
# Soft correspondences with k > 1 neighbors
T_soft = tf.fit_knn_alignment(cloud_x, cloud_y, k=5)
# Custom sigma for Gaussian weighting
T_custom = tf.fit_knn_alignment(cloud_x, cloud_y, k=5, sigma=0.1)
| Parameter | Type | Default | Description |
|---|---|---|---|
cloud0 | PointCloud | Source point cloud | |
cloud1 | PointCloud | Target point cloud | |
k | int | 1 | Number of nearest neighbors (k=1 is classic ICP) |
sigma | float or None | None | Gaussian kernel width. If None, uses k-th neighbor distance |
| Returns | Type | Description |
|---|---|---|
transformation | ndarray | (3,3) for 2D or (4,4) for 3D homogeneous matrix |
Iteratively refine an initial alignment:
# Start from an initial transformation
cloud_source.transformation = T_initial
# ICP refinement loop
for i in range(50):
T_iter = tf.fit_knn_alignment(cloud_source, cloud_target, k=5)
cloud_source.transformation = T_iter @ cloud_source.transformation
error = tf.chamfer_error(cloud_source, cloud_target)
if error < threshold:
break
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 |
|---|---|---|
cloud0 | PointCloud | Source point cloud |
cloud1 | PointCloud | Target point cloud |
| Returns | Type | Description |
|---|---|---|
error | float | Mean nearest-neighbor distance |
All alignment functions respect transformations set on point clouds. The computation is performed in world space:
cloud_a.transformation = initial_transform
# Alignment computed in world space
T = tf.fit_knn_alignment(cloud_a, cloud_b)
# Accumulate transformations
cloud_a.transformation = T @ cloud_a.transformation
Convert polygon meshes to triangle meshes using ear-cutting triangulation.
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)
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) |