Source code for routing.core.geometry

"""Routing geometry primitives: the voxel frame (world/grid chart) and shapes that voxelize themselves into it."""

from abc import ABC, abstractmethod
from dataclasses import dataclass
from functools import cached_property
from itertools import pairwise

import numpy as np
from scipy import ndimage
from volmdlr import Frame3D, Point3D
from volmdlr.discrete_representation import MatrixBasedVoxelization
from volmdlr.primitives3d import Cylinder as CadCylinder
from volmdlr.shapes import Solid

from ._types import Coord3D, Index3D
from .functions import clipped_grid_window, cylinder_bounding_box, grid_window
from .functions_compiled import bresenham_3d_c

_MIN_POLYLINE_POINTS = 2  # below this, voxelize/smoothing return early
_MIN_SMOOTHABLE_POINTS = 2  # below this, smoothing has no interior point to average
_DEGENERATE_SEGMENT = 1e-6  # segment length below which two polyline points are the same place
_HALF_VOXEL = 0.5  # in fractional indices, the distance from a voxel centre to its faces


[docs] @dataclass(frozen=True) class VoxelFrame: """Where a voxel grid sits in space: its origin, its step and its extent. The one chart between world coordinates and grid indices, shared by everything shaped like a grid — a voxelization's matrix, a pathfinding grid's nodes, a region's fields. Its distinct roundings are distinct operations: the voxel nearest to a point (:meth:`nearest_indices`), the exact fractional position (:meth:`fractional_indices`), and the covering bounds of a world box (:meth:`floor_indices` / :meth:`ceil_indices`). :param origin: The centre of voxel (0, 0, 0) in world coordinates. :param voxel_size: The edge length of a voxel, in world units. :param shape: The grid extent in voxels per axis. """ origin: Coord3D voxel_size: float shape: Index3D
[docs] @classmethod def of(cls, voxelization: MatrixBasedVoxelization) -> "VoxelFrame": """Read the frame a voxelization is sampled on.""" return cls( tuple(float(coordinate) for coordinate in voxelization.min_grid_center), float(voxelization.voxel_size), tuple(int(side) for side in voxelization.matrix.shape), )
[docs] def fractional_indices(self, points: np.ndarray) -> np.ndarray: """Exact grid position of world ``points``, ``(3,)`` or ``(N, 3)``, without rounding to a voxel.""" return (np.asarray(points, dtype=float) - self.origin) / self.voxel_size
[docs] def nearest_indices(self, points: np.ndarray) -> np.ndarray: """Convert world ``points``, ``(3,)`` or ``(N, 3)``, to the indices of the voxels containing them.""" return np.rint(self.fractional_indices(points)).astype(int)
[docs] def nearest_index(self, point: Coord3D | np.ndarray) -> Index3D: """Index of the single voxel containing ``point``, as a plain int tuple.""" index = self.nearest_indices(point) return (int(index[0]), int(index[1]), int(index[2]))
[docs] def floor_indices(self, points: np.ndarray) -> np.ndarray: """Highest voxel indices at or below world ``points`` — the lower bound of a covering window.""" return np.floor(self.fractional_indices(points)).astype(int)
[docs] def ceil_indices(self, points: np.ndarray) -> np.ndarray: """Lowest voxel indices at or above world ``points`` — the upper bound of a covering window.""" return np.ceil(self.fractional_indices(points)).astype(int)
[docs] def contains(self, points: np.ndarray) -> np.ndarray: """Whether world ``points``, ``(3,)`` or ``(N, 3)``, fall inside the grid's extent — the outer faces of its boundary voxels.""" fractional = self.fractional_indices(np.atleast_2d(points)) return np.all((fractional >= -_HALF_VOXEL) & (fractional <= np.asarray(self.shape) - _HALF_VOXEL), axis=1)
[docs] def cylindrical_window( self, start: np.ndarray, end: np.ndarray, half_width: float ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """Dense grid window around the ``[start, end]`` segment, in cylindrical coordinates. Covers the axis-aligned box enclosing the segment inflated by ``half_width``, clipped to the grid. Radial distances and abscissas are separable along the grid axes, so no per-voxel point array is ever built. :return: Tuple ``(lower (3,) int, radial_distances (ni, nj, nk), abscissas (ni, nj, nk))`` — the window's lower corner index and, per window voxel, the world distance to the segment's supporting line and the world abscissa along it (0 at ``start``). Empty arrays when the window misses the grid. """ start = np.asarray(start, dtype=float) corners = np.stack([start, np.asarray(end, dtype=float)]) lower = np.maximum(self.floor_indices(corners.min(axis=0) - half_width), 0) upper = np.minimum(self.ceil_indices(corners.max(axis=0) + half_width) + 1, np.asarray(self.shape)) if np.any(lower >= upper): empty = np.empty((0, 0, 0)) return lower, empty, empty direction = corners[1] - corners[0] direction /= np.linalg.norm(direction) relative_axes = [ self.origin[axis] + np.arange(lower[axis], upper[axis]) * self.voxel_size - start[axis] for axis in range(3) ] abscissas = ( relative_axes[0][:, None, None] * direction[0] + relative_axes[1][None, :, None] * direction[1] + relative_axes[2][None, None, :] * direction[2] ) squared_norms = ( relative_axes[0][:, None, None] ** 2 + relative_axes[1][None, :, None] ** 2 + relative_axes[2][None, None, :] ** 2 ) radial_distances = np.sqrt(np.maximum(squared_norms - abscissas**2, 0.0)) return lower, radial_distances, abscissas
[docs] def indices_around(self, center: np.ndarray, radius: float) -> np.ndarray: """List the grid's voxels within ``radius`` (in voxels) of the fractional index ``center``, in C order.""" center = np.asarray(center, dtype=float) lower = np.maximum(np.floor(center - radius), 0).astype(int) upper = np.minimum(np.ceil(center + radius), np.asarray(self.shape) - 1).astype(int) if np.any(lower > upper): return np.empty((0, 3), dtype=int) axes = [np.arange(low, high + 1) for low, high in zip(lower, upper, strict=True)] indices = np.stack(np.meshgrid(*axes, indexing="ij"), axis=-1).reshape(-1, 3) return indices[np.linalg.norm(indices - center, axis=1) <= radius]
[docs] def centers(self, indices: np.ndarray) -> np.ndarray: """World centres of the voxels at integer or fractional ``indices``, ``(3,)`` or ``(N, 3)``.""" return np.asarray(self.origin) + np.asarray(indices, dtype=float) * self.voxel_size
[docs] def cells_covered_by(self, coarser: "VoxelFrame", indices: np.ndarray) -> np.ndarray: """Give the cells of this frame whose centre lies in the voxels ``indices`` ``(N, 3)`` of the ``coarser`` frame. The multi-scale map anchors its grids on one centre and refines by an odd factor, so a coarse voxel covers a block of cells centred on its own centre; a frame covers itself cell for cell. Not clipped. """ if not len(indices): return np.empty((0, 3), dtype=int) centres = self.nearest_indices(coarser.centers(indices)) half_span = round((coarser.voxel_size / self.voxel_size - 1) / 2) if half_span == 0: return centres offsets = np.mgrid[-half_span : half_span + 1, -half_span : half_span + 1, -half_span : half_span + 1] return (centres[:, None, :] + offsets.reshape(3, -1).T[None, :, :]).reshape(-1, 3)
[docs] def center(self, index: Index3D | np.ndarray) -> Coord3D: """World centre of the single voxel at ``index``, as a plain float tuple.""" center = self.centers(index) return (float(center[0]), float(center[1]), float(center[2]))
[docs] class RoutingShape(ABC): """A class to handle shapes in routing context."""
[docs] @abstractmethod def bounding_box(self) -> np.ndarray: """Axis-aligned bounding box as ``[minimums, maximums]``."""
[docs] @abstractmethod def voxelize_into( self, volume: np.ndarray, frame: VoxelFrame, dilation_radius: float = 0, ) -> None: """Add this shape's voxels to ``volume``, sampled on ``frame``, touching only the cells the shape occupies."""
[docs] def voxelize(self, frame: VoxelFrame, dilation_radius: float = 0) -> np.ndarray: """Boolean mask of the ``frame`` voxels intersecting the shape, on a grid of its own.""" volume = np.zeros(frame.shape, dtype=bool) self.voxelize_into(volume, frame, dilation_radius) return volume
@staticmethod def _stamp_thickened(volume: np.ndarray, indices: np.ndarray, ball_radius: int) -> None: """ Add to ``volume`` every voxel within ``ball_radius`` of ``indices``, working in their own window. :param volume: The grid to write into. :param indices: The ``(N, 3)`` voxel indices to thicken, all inside ``volume``. :param ball_radius: Thickening radius in voxels; ``0`` stamps ``indices`` as they are. """ window_low = np.maximum(indices.min(axis=0) - ball_radius, 0) window_high = np.minimum(indices.max(axis=0) + ball_radius + 1, volume.shape) thickened = np.zeros(window_high - window_low, dtype=bool) local_indices = indices - window_low thickened[local_indices[:, 0], local_indices[:, 1], local_indices[:, 2]] = True if ball_radius > 0: thickened = ndimage.distance_transform_edt(~thickened) <= ball_radius volume[grid_window(window_low, window_high)] |= thickened
[docs] @dataclass class Box(RoutingShape): """A class to handle boxes in routing context.""" minimums: np.ndarray # min_x, min_y, min_z maximums: np.ndarray # max_x, max_y, max_z @cached_property def size(self) -> np.ndarray: """Box extent along each axis.""" return self.maximums - self.minimums @cached_property def size_x(self) -> float: """Box extent along x.""" return self.size[0] @cached_property def size_y(self) -> float: """Box extent along y.""" return self.size[1] @cached_property def size_z(self) -> float: """Box extent along z.""" return self.size[2] @cached_property def center(self) -> np.ndarray: """Box geometric center.""" return (self.minimums + self.maximums) / 2 @cached_property def origin(self) -> np.ndarray: """Box origin (min corner).""" return self.minimums.copy() @cached_property def bounding_box(self) -> np.ndarray: """Box vertices as ``[minimums, maximums]``.""" return np.array([self.minimums, self.maximums]) @cached_property def points(self) -> np.ndarray: """All box's points.""" minimums, maximums = self.minimums, self.maximums return np.array( [ [minimums[0], minimums[1], minimums[2]], [maximums[0], minimums[1], minimums[2]], [minimums[0], maximums[1], minimums[2]], [maximums[0], maximums[1], minimums[2]], [minimums[0], minimums[1], maximums[2]], [maximums[0], minimums[1], maximums[2]], [minimums[0], maximums[1], maximums[2]], [maximums[0], maximums[1], maximums[2]], ] ) @cached_property def volume(self) -> float: """The volume of the box.""" return float(np.prod(self.size))
[docs] def contains(self, points: np.ndarray) -> np.ndarray: """Whether each of ``points`` ``(n, 3)`` lies in the box, its faces included.""" return np.all((points >= self.minimums) & (points <= self.maximums), axis=1)
[docs] def meets(self, boxes: np.ndarray) -> np.ndarray: """Whether each of ``boxes``, ``(n, 2, 3)`` lowest and highest corners, shares a point with this box.""" return np.all((boxes[:, 0] <= self.maximums) & (boxes[:, 1] >= self.minimums), axis=1)
[docs] def grown(self, margin: float | np.ndarray) -> "Box": """Give the box widened by ``margin`` on each of its six faces, one value per axis or one for all.""" return Box(self.minimums - margin, self.maximums + margin)
[docs] @classmethod def from_bounding_box(cls, bounding_box: np.ndarray) -> "Box": """Create a box from its extreme vertices.""" return cls(bounding_box[0], bounding_box[1])
[docs] @classmethod def from_points(cls, points: np.ndarray) -> "Box": """Create a box from points.""" return cls(np.min(points, axis=0), np.max(points, axis=0))
[docs] @classmethod def cubes_at( cls, centers: np.ndarray, edge: float, color: tuple = (0.5, 0.5, 0.5), alpha: float = 1.0, ) -> list[Solid]: """ Render one cube solid of side ``edge`` centred on each of ``centers``. :param centers: World centres, as any array of ``(x, y, z)`` rows. :param edge: The side of every cube. """ half_edge = edge / 2 return [ cls(centre - half_edge, centre + half_edge).volmdlr_primitive(color, alpha) for centre in np.asarray(centers, dtype=float).reshape(-1, 3) ]
[docs] def volmdlr_primitive(self, color: tuple = (0.5, 0.5, 0.5), alpha: float = 1.0) -> Solid: """Render this box as a volmdlr CAD solid.""" primitive = Solid.make_box( *(float(side) for side in self.size), frame=Frame3D.from_origin_xyz(Point3D(*self.center)), frame_centered=True, ) primitive.color = color primitive.alpha = alpha return primitive
[docs] def voxelize_into( self, volume: np.ndarray, frame: VoxelFrame, dilation_radius: float = 0, ) -> None: """ Fill the index block the box spans, widened by ``dilation_radius`` and clipped to the grid. :param volume: The grid to write into. :param frame: The chart ``volume`` is sampled on. :param dilation_radius: Extra half-width in voxel units (expands the filled index range). """ grid_shape = np.array(volume.shape) dilatation_offset = round(float(dilation_radius)) lower_bounds = frame.floor_indices(self.minimums) - dilatation_offset upper_bounds = frame.ceil_indices(self.maximums) + dilatation_offset volume[clipped_grid_window(lower_bounds, np.minimum(upper_bounds, grid_shape - 1) + 1, grid_shape)] = True
[docs] @dataclass class OrientedBox(RoutingShape): """A box lying in a frame of its own: the tightest box around points, seen from there. An oriented box is an aligned one seen from its frame, so it carries that frame and the :class:`Box` it spans in it. A shape lying along a diagonal keeps a tight box, where a world-aligned one would swallow the space around it. :param origin: Where the frame sits in the world. :param axes: The frame's three unit axes, as columns. :param extent: The box the shape spans in that frame. """ origin: np.ndarray axes: np.ndarray extent: Box
[docs] @classmethod def from_points(cls, points: np.ndarray, origin: np.ndarray, axes: np.ndarray) -> "OrientedBox": """Build the tightest box around ``points`` in the frame ``(origin, axes)``.""" return cls(origin, axes, Box.from_points((points - origin) @ axes))
[docs] def local_coordinates(self, points: np.ndarray) -> np.ndarray: """Give ``points`` in the box's own frame.""" return (points - self.origin) @ self.axes
[docs] def contains(self, points: np.ndarray) -> np.ndarray: """Whether each of ``points`` ``(n, 3)`` lies in the box, its faces included.""" return self.extent.contains(self.local_coordinates(points))
[docs] def grown(self, margin: float | np.ndarray) -> "OrientedBox": """Give the box widened by ``margin`` on each of its six faces, one value per axis or one for all.""" return OrientedBox(self.origin, self.axes, self.extent.grown(margin))
@cached_property def points(self) -> np.ndarray: """The box's eight vertices, in the world.""" return self.extent.points @ self.axes.T + self.origin
[docs] def bounding_box(self) -> np.ndarray: """Axis-aligned bounding box of the eight vertices, as ``[minimums, maximums]``.""" return np.array([self.points.min(axis=0), self.points.max(axis=0)])
[docs] def cells(self, frame: VoxelFrame, dilation_radius: float = 0) -> np.ndarray: """Give the cells of ``frame`` whose centre lies in the box, widened by ``dilation_radius`` voxels. The candidates are read off the aligned block the box spans, so an oblique box costs its own neighbourhood and not the grid. """ widened = self.grown(dilation_radius * frame.voxel_size) if dilation_radius else self corners = widened.bounding_box() window = clipped_grid_window( frame.floor_indices(corners[0]), frame.ceil_indices(corners[1]) + 1, np.array(frame.shape) ) candidates = np.mgrid[window].reshape(3, -1).T return candidates[widened.contains(frame.centers(candidates))] if len(candidates) else candidates
[docs] def voxelize_into(self, volume: np.ndarray, frame: VoxelFrame, dilation_radius: float = 0) -> None: """Fill the cells whose centre lies in the box, widened by ``dilation_radius`` voxels.""" cells = self.cells(frame, dilation_radius) if len(cells): volume[tuple(cells.T)] = True
[docs] @dataclass class Cylinder(RoutingShape): """A class to handle cylinders in routing context.""" start: np.ndarray end: np.ndarray radius: float @cached_property def bounding_box(self) -> np.ndarray: """Get the bounding box of the cylinder.""" return cylinder_bounding_box(self.start, self.end, self.radius) @cached_property def length(self) -> float: """Cylinder axis length.""" return float(np.linalg.norm(self.end - self.start))
[docs] def volmdlr_primitive(self, color: tuple = (0.5, 0.5, 0.5), alpha: float = 1.0) -> CadCylinder: """Render this cylinder as a volmdlr CAD primitive.""" primitive = CadCylinder.from_end_points(Point3D(*self.start), Point3D(*self.end), self.radius) primitive.color = color primitive.alpha = alpha return primitive
[docs] def fiber_indices(self, frame: VoxelFrame) -> np.ndarray: """Voxel-snapped Bresenham rasterization of the axis (neutral fiber), clipped to the grid.""" upper = np.array(frame.shape) - 1 start = np.clip(frame.nearest_indices(self.start), 0, upper) end = np.clip(frame.nearest_indices(self.end), 0, upper) return bresenham_3d_c( tuple(int(coordinate) for coordinate in start), tuple(int(coordinate) for coordinate in end) )
[docs] def ball_radius(self, voxel_size: float, dilation_radius: float = 0) -> int: """Integer thickening radius in voxels: ``ceil(radius + dilation)``. The thickening ball needs an INTEGER radius: a non-integer yields an even-sized footprint with no centre voxel, so thickening shifts the tube half a voxel off the fiber and brings the skeletonization vanish straight back. """ return int(np.ceil(self.radius / voxel_size + dilation_radius))
[docs] def voxelize_into( self, volume: np.ndarray, frame: VoxelFrame, dilation_radius: float = 0, ) -> None: """Rasterize the axis (neutral fiber) via Bresenham, then thicken it into an exact-Euclidean-ball tube.""" fiber = self.fiber_indices(frame) self._stamp_thickened(volume, fiber, self.ball_radius(frame.voxel_size, dilation_radius))
[docs] @dataclass class Polyline(RoutingShape): """An ordered sequence of points forming a broken line in 3D.""" points: np.ndarray # (N, 3) @cached_property def length(self) -> float: """Sum of segment lengths.""" return float(np.linalg.norm(np.diff(self.points, axis=0), axis=1).sum()) @cached_property def bounding_box(self) -> np.ndarray: """Polyline AABB as ``[minimums, maximums]``.""" return np.array([self.points.min(axis=0), self.points.max(axis=0)])
[docs] def voxelize_into( self, volume: np.ndarray, frame: VoxelFrame, dilation_radius: float = 0, ) -> None: """Rasterize each segment via Bresenham, then dilate over the polyline's own bounding window.""" if len(self.points) < _MIN_POLYLINE_POINTS: return grid_shape = np.array(volume.shape) voxel_coords = np.clip(frame.nearest_indices(self.points), 0, grid_shape - 1) crossed = np.concatenate( [ bresenham_3d_c(tuple(int(c) for c in start), tuple(int(c) for c in end)) for start, end in pairwise(voxel_coords) ] ) self._stamp_thickened(volume, crossed, round(float(dilation_radius)))
[docs] def to_cylinders(self, radius: float = 1e-6) -> list["Cylinder"]: """ One cylinder per segment, all of ``radius``; degenerate segments are dropped. :param radius: A polyline carries no thickness of its own, so this states the scale it should be seen at; the default draws it as a hairline. """ return [ Cylinder(start, end, radius) for start, end in pairwise(self.points) if np.linalg.norm(end - start) > _DEGENERATE_SEGMENT ]
[docs] def distances_to(self, points: np.ndarray) -> np.ndarray: """Min distance from each of ``points`` to this polyline.""" starts = self.points[:-1] ends = self.points[1:] edges = ends - starts squared_lengths = np.einsum("ij,ij->i", edges, edges) squared_lengths = np.where(squared_lengths > 0, squared_lengths, 1.0) offsets = points[:, None, :] - starts[None, :, :] parameters = np.clip(np.einsum("nsj,sj->ns", offsets, edges) / squared_lengths[None, :], 0.0, 1.0) closest = starts[None, :, :] + parameters[:, :, None] * edges[None, :, :] differences = points[:, None, :] - closest return np.sqrt(np.einsum("nsj,nsj->ns", differences, differences).min(axis=1))
[docs] def smoothed(self, window: int) -> "Polyline": """Moving-average smoothing with endpoints preserved.""" count = len(self.points) if count <= _MIN_SMOOTHABLE_POINTS or window <= 1: return Polyline(self.points.copy()) half_window = window // 2 smoothed = self.points.copy() for index in range(1, count - 1): lower = max(1, index - half_window) upper = min(count - 1, index + half_window + 1) smoothed[index] = self.points[lower:upper].mean(axis=0) return Polyline(smoothed)
[docs] def local_angles(self, tangent_window: int) -> np.ndarray: """Per-vertex bending angle measured over a ``tangent_window``-step neighborhood. Returns an array of length ``len(points)``. NaN at vertices closer than ``tangent_window`` from the endpoints (no back/forward tangent available) or where back/forward is degenerate (zero-length). """ count = len(self.points) window = tangent_window angles = np.full(count, np.nan, dtype=float) if count < 2 * window + 1: return angles for index in range(window, count - window): back = self.points[index] - self.points[index - window] forward = self.points[index + window] - self.points[index] back_length = float(np.linalg.norm(back)) forward_length = float(np.linalg.norm(forward)) if back_length == 0.0 or forward_length == 0.0: continue cosine = float(np.clip(np.dot(back, forward) / (back_length * forward_length), -1.0, 1.0)) angles[index] = np.arccos(cosine) return angles
[docs] def turn_indices(self, tangent_window: int, min_turn_angle: float) -> list[int]: """Interior indices where :meth:`local_angles` peaks above ``min_turn_angle``. Detected turns are at least ``tangent_window`` apart so a single messy corner yields a single cut. """ count = len(self.points) window = tangent_window if count < 2 * window + 1: return [] angles = self.local_angles(window) turns: list[int] = [] index = window while index < count - window: if not np.isnan(angles[index]) and angles[index] > min_turn_angle: local = angles[max(window, index - window) : min(count - window, index + window + 1)] if angles[index] == np.nanmax(local): turns.append(index) index += window continue index += 1 return turns