Spatial
The Spatial module provides all query operations — distance, intersection, ray casting, neighbor search, and element gathering. Queries work uniformly across primitives and forms, and all support batch broadcasting.
Forms are geometric collections backed by spatial acceleration structures: Mesh, EdgeMesh, and PointCloud. The tree is built automatically on first query, enabling efficient search over large datasets.
Forms
| Form | Description | Elements | Dimensions |
|---|---|---|---|
PointCloud | Collection of points | Points | 2D, 3D |
EdgeMesh | Collection of line segments (edges) | Edges (2 vertices) | 2D, 3D |
Mesh | Polygonal mesh (triangles or dynamic n-gons) | Faces (triangles or n-gons) | 2D, 3D |
PointCloud
import trueform as tf
import numpy as np
points = np.random.rand(100, 3).astype(np.float32)
cloud = tf.PointCloud(points)
print(cloud.size) # 100
print(cloud.dims) # 3
print(cloud.dtype) # float32
| Property | Returns | Description |
|---|---|---|
points | ndarray | Vertex coordinates |
size | int | Number of points |
dims | int | Dimensionality (2 or 3) |
dtype | np.dtype | Data type (float32 or float64) |
transformation | ndarray or None | Transformation matrix |
EdgeMesh
edges = np.array([[0, 1], [1, 2], [2, 3]], dtype=np.int32)
points = np.array([[0, 0], [1, 0], [1, 1], [0, 1]], dtype=np.float32)
edge_mesh = tf.EdgeMesh(edges, points)
| Property | Returns | Description |
|---|---|---|
edges | ndarray | Edge connectivity (N, 2) |
points | ndarray | Vertex coordinates |
number_of_edges | int | Number of edges |
number_of_points | int | Number of vertices |
dims | int | Dimensionality (2 or 3) |
dtype | np.dtype | Data type (float32 or float64) |
transformation | ndarray or None | Transformation matrix |
EdgeMesh also provides topology properties. See Topology for details:
| Property | Returns | Description |
|---|---|---|
edge_membership | OffsetBlockedArray | Vertex → edges containing it |
vertex_link | OffsetBlockedArray | Vertex → connected vertices |
Mesh
Supports both fixed-size triangles and dynamic n-gons via OffsetBlockedArray:
# Triangle mesh
faces = np.array([[0, 1, 2], [1, 2, 3]], dtype=np.int32)
points = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0]], dtype=np.float32)
mesh = tf.Mesh(faces, points)
print(mesh.is_dynamic) # False
print(mesh.ngon) # 3 (triangles)
# Dynamic mesh (variable-sized faces)
quads = np.array([[0, 1, 2, 3], [4, 5, 6, 7]], dtype=np.int32)
faces = tf.as_offset_blocked(quads)
mesh = tf.Mesh(faces, points)
print(mesh.is_dynamic) # True
print(mesh.ngon) # None
| Property | Returns | Description |
|---|---|---|
faces | ndarray or OffsetBlockedArray | Face connectivity |
points | ndarray | Vertex coordinates |
number_of_faces | int | Number of faces |
number_of_points | int | Number of vertices |
dims | int | Dimensionality (2 or 3) |
is_dynamic | bool | True if mesh uses variable-sized faces |
ngon | int or None | Vertices per face (3 for triangles, None if dynamic) |
dtype | np.dtype | Data type (float32 or float64) |
transformation | ndarray or None | Transformation matrix |
Mesh also provides topology and geometry properties. See Topology and Geometry for details:
| Property | Returns | Description |
|---|---|---|
face_membership | OffsetBlockedArray | Vertex → faces containing it |
manifold_edge_link | ndarray or OffsetBlockedArray | Face edge → adjacent face |
face_link | OffsetBlockedArray | Face → adjacent faces |
vertex_link | OffsetBlockedArray | Vertex → connected vertices |
normals | ndarray | Face normals (3D only) |
point_normals | ndarray | Vertex normals (3D only) |
form.build_tree() to prebuild it if you want to control when the cost is paid. mesh.build_winding_moments() does the same for the winding moments tf.signed_distance signs by — they mirror the tree's nodes and are invalidated with it.Transformations on Forms
Forms can carry a transformation matrix. Queries use the transformation on the fly — the underlying data and acceleration structures stay unchanged.
# 4x4 homogeneous transformation (3D)
translation = np.eye(4, dtype=np.float32)
translation[:3, 3] = [5, 0, 0]
# Attach at construction
mesh = tf.Mesh(faces, points, transformation=translation)
# Or set later
mesh.transformation = translation
# Queries use the transformation automatically
d = tf.distance(mesh, other_mesh)
All forms support transformations: PointCloud, EdgeMesh, and Mesh. The original data remains unchanged.
Shared Views
shared_view() creates a new instance sharing the same underlying data (points, faces, spatial tree) but with its own transformation. Use this to query the same geometry at multiple poses without duplicating anything.
# Build once
mesh = tf.Mesh(faces, points)
mesh.build_tree()
# Create views with different poses
mesh_a = mesh.shared_view()
mesh_a.transformation = transform_A
mesh_b = mesh.shared_view()
mesh_b.transformation = transform_B
# Both share the same data and tree
d = tf.distance(mesh_a, mesh_b)
Queries
All query functions accept any combination of primitives and forms. Both operands must have the same dtype and dims.
Queries broadcast like numpy operations. A single primitive paired with a batch of N primitives produces N results — the single operand is reused for each element in the batch. Two batches of the same size are paired element-wise. A form paired with a batch queries each element against the spatial tree independently.
Distance
tf.distance(a, b) and tf.distance2(a, b) compute the distance (or squared distance) between any two geometric objects.
# Primitive × Primitive
d = tf.distance(tf.Point([0, 0, 0]), tf.Segment([[1, 0, 0], [1, 1, 0]]))
# Form × Primitive
d = tf.distance(mesh, tf.Point([0, 0, 0]))
# Form × Form
d = tf.distance(mesh_a, mesh_b)
# Batch → array of distances
pts = tf.Point(np.random.rand(1000, 3).astype(np.float32))
distances = tf.distance(pts, mesh) # shape (1000,)
| A | B | Returns |
|---|---|---|
| primitive | primitive | float |
| batch(N) | primitive or form | ndarray(N,) |
| batch(N) | batch(N) | ndarray(N,) |
| form | primitive | float |
| form | form | float |
Signed Distance
tf.signed_distance(mesh, query) computes the distance from a point query to the closest point on a 3D mesh surface, signed by the generalized winding number: negative inside, positive outside. Either argument order is accepted.
d = tf.signed_distance(mesh, [0.0, 0.0, 0.0]) # scalar, negative inside
# Batch → array of signed distances
pts = tf.Point(np.random.rand(1000, 3).astype(np.float32))
d = tf.signed_distance(mesh, pts) # shape (1000,), mesh dtype
The mesh must be 3D with outward-oriented faces. The query is a Point (single or batch) or an array-like of shape (3,) or (N, 3) — array-likes are converted to the mesh's dtype.
The sign is a floating-point test on the winding number, not an exact predicate: robust in practice — including on open meshes and soups, where it degrades gracefully — but a query exactly on the surface may land on either side.
The winding number is read off the winding moments of the spatial tree, so the sign stays graceful on open meshes and soups. The moments build on first query; call mesh.build_winding_moments() to prebuild them.
| Mesh | Query | Returns |
|---|---|---|
| form | point | float |
| form | batch(N) | ndarray(N,) |
Closest Point
tf.closest_metric_point(a, b) returns the squared distance and closest point on a to b. tf.closest_metric_point_pair(a, b) returns closest points on both sides.
These work on primitives only. For forms, use tf.neighbor_search which returns element IDs alongside closest points.
# Closest point on a polygon to a point
dist2, pt = tf.closest_metric_point(polygon, point)
# Closest points between two segments
dist2, pt_on_a, pt_on_b = tf.closest_metric_point_pair(segment_a, segment_b)
# Batch → arrays
pts = tf.Point(np.random.rand(100, 3).astype(np.float32))
dist2s, closest_pts = tf.closest_metric_point(polygon, pts)
# dist2s.shape = (100,), closest_pts.shape = (100, 3)
| A | B | closest_metric_point | closest_metric_point_pair |
|---|---|---|---|
| primitive | primitive | (float, ndarray[D]) | (float, ndarray[D], ndarray[D]) |
| batch(N) | primitive | (ndarray[N], ndarray[N,D]) | (ndarray[N], ndarray[N,D], ndarray[N,D]) |
| primitive | batch(N) | (ndarray[N], ndarray[N,D]) | (ndarray[N], ndarray[N,D], ndarray[N,D]) |
| batch(N) | batch(N) | (ndarray[N], ndarray[N,D]) | (ndarray[N], ndarray[N,D], ndarray[N,D]) |
Intersection Testing
tf.intersects(a, b) tests whether two geometric objects intersect.
# Primitive × Primitive
hit = tf.intersects(tf.AABB(min=[0,0,0], max=[1,1,1]), tf.Segment([[0,0,0], [2,2,2]]))
# Form × Primitive
hit = tf.intersects(mesh, polygon)
# Form × Form
hit = tf.intersects(mesh_a, mesh_b)
# Batch → array
segs = tf.Segment(np.random.rand(100, 2, 3).astype(np.float32))
hits = tf.intersects(mesh, segs) # shape (100,)
| A | B | Returns |
|---|---|---|
| primitive | primitive | bool |
| batch(N) | primitive or form | ndarray(N,) |
| batch(N) | batch(N) | ndarray(N,) |
| form | primitive | bool |
| form | form | bool |
Ray Casting
tf.ray_cast(ray, target, config) casts a ray and returns the parametric distance t where hit_point = ray.origin + t * ray.direction. The config tuple (min_t, max_t) constrains the valid range. For batch rays, config can be per-ray arrays.
config = (0.0, 100.0)
# Ray × Primitive — returns t or None
t = tf.ray_cast(ray, triangle, config)
# Ray × Form — returns (element_id, t) or None
result = tf.ray_cast(ray, mesh, config)
if result is not None:
face_id, t = result
# Batch rays × Primitive — returns ndarray with NaN for misses
rays = tf.Ray(np.random.rand(100, 2, 3).astype(np.float32))
ts = tf.ray_cast(rays, triangle, config) # shape (100,)
# Batch rays × Form — returns (element_ids, ts) with -1/NaN for misses
ids, ts = tf.ray_cast(rays, mesh, config) # both shape (100,)
# Per-ray config — each ray gets its own (min_t, max_t)
min_ts = np.zeros(100, dtype=np.float32)
max_ts = np.full(100, 50.0, dtype=np.float32)
ids, ts = tf.ray_cast(rays, mesh, config=(min_ts, max_ts))
| Ray | Target | Returns (hit) | Returns (miss) |
|---|---|---|---|
| single | primitive | float | None |
| single | form | (element_id, t) | None |
| batch(N) | primitive | ndarray(N,) | NaN entries |
| batch(N) | form | (ndarray[N] ids, ndarray[N] ts) | -1/NaN entries |
Neighbor Search
tf.neighbor_search(form, query) finds the nearest element in a form. This is the form equivalent of closest_metric_point — it returns element IDs alongside closest points.
# Single query — returns (element_id, dist², closest_point)
idx, dist2, pt = tf.neighbor_search(mesh, point)
# With search radius — returns None if nothing within radius
result = tf.neighbor_search(mesh, point, radius=1.0)
if result is not None:
idx, dist2, pt = result
# Batch query — returns arrays
pts = tf.Point(np.random.rand(1000, 3).astype(np.float32))
ids, dist2s, closest_pts = tf.neighbor_search(mesh, pts)
# ids.shape = (1000,), dist2s.shape = (1000,), closest_pts.shape = (1000, 3)
# ids[i] = -1 and dist2s[i] = inf when nothing found within radius
# Form × Form — closest pair between two forms
(id0, id1), (dist2, pt0, pt1) = tf.neighbor_search(mesh_a, mesh_b)
k-Nearest Neighbors (kNN)
Pass k to tf.neighbor_search for k-nearest neighbor queries:
# Single query — list of (id, dist², point), sorted by distance
neighbors = tf.neighbor_search(mesh, point, k=10)
for idx, dist2, pt in neighbors:
pass
# With radius limit — may return fewer than k
neighbors = tf.neighbor_search(mesh, point, k=10, radius=5.0)
# Batch query — adds a K dimension and a counts array
ids, dist2s, pts, counts = tf.neighbor_search(mesh, pts, k=10)
# ids.shape = (N, K), dist2s.shape = (N, K), pts.shape = (N, K, 3)
# counts[i] = number of valid neighbors for query i
Gathering Element IDs
tf.gather_intersecting_ids and tf.gather_ids_within_distance collect all element IDs matching a spatial predicate.
# Elements that intersect a primitive
ids = tf.gather_intersecting_ids(mesh, polygon) # 1D array of face IDs
# Elements within a distance of a primitive
ids = tf.gather_ids_within_distance(mesh, point, distance=0.5) # 1D array
# Form × Form — returns (M, 2) array of ID pairs
pairs = tf.gather_intersecting_ids(mesh_a, mesh_b)
pairs = tf.gather_ids_within_distance(mesh_a, mesh_b, distance=0.5)
