Source code for flow360.component.simulation.draft_context.obb.compute

"""Oriented Bounding Box computation via PCA.

Computes an OBB from an (N, 3) vertex point cloud.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from operator import index
from typing import List, Optional, SupportsIndex

import numpy as np

from flow360.exceptions import Flow360ValueError
from flow360.log import log


def _select_rotation_axis_index(
    axes: np.ndarray,
    extents: np.ndarray,
    rotation_axis_hint: Optional[np.ndarray],
) -> int:
    """Determine which OBB axis is the rotation axis.

    If *rotation_axis_hint* is provided, picks the axis most aligned with it.
    Otherwise infers by circularity — the axis whose perpendicular cross-section
    has the most equal pair of extents.
    """
    if rotation_axis_hint is not None:
        hint = np.asarray(rotation_axis_hint, dtype=np.float64)
        if np.linalg.norm(hint) <= np.finfo(float).eps:
            raise Flow360ValueError("rotation_axis_hint must be a non-zero vector.")
        dots = np.abs(axes @ hint)
        return int(np.argmax(dots))

    # Circularity heuristic: for each axis, ratio of the two perpendicular extents
    best_index = 0
    best_ratio = -1.0
    for i in range(3):
        others: List[float] = [extents[j] for j in range(3) if j != i]
        larger = max(others[0], others[1])
        ratio = min(others[0], others[1]) / larger if larger > 0 else 1.0
        if ratio > best_ratio:
            best_ratio = ratio
            best_index = i
    return best_index


[docs] @dataclass(frozen=True) class RotationAxisAndRadius: """Rotation axis and averaged radius derived from an oriented bounding box. Attributes: axis_of_rotation (numpy.ndarray): Unit vector along the selected rotation axis, shape ``(3,)``. averaged_radius (float): Estimated cylinder radius, averaged from the two OBB half-extents perpendicular to the rotation axis. """ axis_of_rotation: np.ndarray averaged_radius: float _averaged_radius_formula: str = field( default="(perpendicular_extent_1 + perpendicular_extent_2) / 2", repr=False, compare=False, ) def __str__(self) -> str: return ( f"{type(self).__name__}(" f"axis_of_rotation={self.axis_of_rotation}, " f"averaged_radius={self.averaged_radius} " f"(calculated as {self._averaged_radius_formula})" ")" ) __repr__ = __str__
[docs] @dataclass(frozen=True) class OBBResult: """Oriented Bounding Box computed from a point cloud. OBBResult stores bounding-box geometry only. Use :meth:`get_rotation_axis_and_radius` when a cylinder-like rotation axis and averaged radius are needed. Attributes: center (numpy.ndarray): Geometric center of the OBB, shape ``(3,)``. axes (numpy.ndarray): Principal axes as row vectors of shape ``(3, 3)``, ordered by descending extent magnitude. extents (numpy.ndarray): Half-extents along each axis, shape ``(3,)``. """ center: np.ndarray axes: np.ndarray extents: np.ndarray
[docs] def get_rotation_axis_and_radius( self, axis_index: Optional[SupportsIndex] = None, rotation_axis_hint: Optional[np.ndarray] = None, ) -> RotationAxisAndRadius: """Derive a rotation axis and averaged radius from this oriented bounding box. Args: axis_index: Principal axis index to use as the rotation axis. Valid values are 0, 1, and 2. rotation_axis_hint: Approximate rotation-axis direction. The principal axis most aligned with this hint is selected. Returns: RotationAxisAndRadius with the selected axis and averaged perpendicular half-extent radius. Raises: Flow360ValueError: If both selection inputs are provided, if axis_index is invalid, or if rotation_axis_hint is zero. If neither axis_index nor rotation_axis_hint is provided, the rotation axis and averaged radius are guessed from the principal axis whose perpendicular bounding-box extents are most similar. A warning is emitted in that case because this is an inference, not a known geometric property. """ if axis_index is not None and rotation_axis_hint is not None: raise Flow360ValueError("axis_index and rotation_axis_hint cannot both be provided.") if axis_index is not None: try: rotation_axis_index = index(axis_index) except TypeError as exc: raise Flow360ValueError("axis_index must be one of 0, 1, or 2.") from exc if rotation_axis_index not in range(3): raise Flow360ValueError("axis_index must be one of 0, 1, or 2.") else: if rotation_axis_hint is None: log.warning( "No axis_index or rotation_axis_hint was provided. " "The rotation axis and averaged radius are guessed by selecting the " "principal axis whose perpendicular bounding-box extents are most similar." ) rotation_axis_index = _select_rotation_axis_index( self.axes, self.extents, rotation_axis_hint ) perpendicular = [self.extents[j] for j in range(3) if j != rotation_axis_index] averaged_radius = (perpendicular[0] + perpendicular[1]) / 2.0 return RotationAxisAndRadius( axis_of_rotation=self.axes[rotation_axis_index].copy(), averaged_radius=averaged_radius, _averaged_radius_formula=f"({perpendicular[0]} + {perpendicular[1]}) / 2", )
def compute_obb(vertices: np.ndarray) -> OBBResult: # pylint:disable = too-many-locals """Compute an oriented bounding box for an (N, 3) point cloud via PCA. Steps: 1. PCA on the covariance matrix to find principal axes. 2. Project points onto those axes to get half-extents. 3. Re-center to the geometric center of the bounding box. Args: vertices: (N, 3) array of 3D positions. Returns: OBBResult with center, axes, and extents. Use OBBResult.get_rotation_axis_and_radius() to derive cylinder-like rotation metadata when needed. """ center = vertices.mean(axis=0) centered = vertices - center # PCA via eigendecomposition of covariance cov = np.cov(centered, rowvar=False) eigenvalues, eigenvectors = np.linalg.eigh(cov) # eigh returns ascending order; flip to descending (primary variance first) order = eigenvalues.argsort()[::-1] eigenvectors = eigenvectors[:, order] # Ensure right-handed coordinate system if np.linalg.det(eigenvectors) < 0: eigenvectors[:, 2] *= -1 # Project onto principal axes to get half-extents projected = centered @ eigenvectors mins = projected.min(axis=0) maxs = projected.max(axis=0) extents = (maxs - mins) / 2.0 # Re-center to geometric center of the OBB (not the centroid) obb_center = center + eigenvectors @ ((maxs + mins) / 2.0) # Axes as row vectors axes = eigenvectors.T return OBBResult( center=obb_center, axes=axes, extents=extents, )