Source code for flow360.component.simulation.measurement.bounding_box

"""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 TYPE_CHECKING, List, Optional, SupportsIndex

import numpy as np
from unyt import unyt_quantity

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

from .geometry import (
    SurfaceSelection,
    collect_surface_face_ids,
    load_transformed_surface_vertices,
    require_geometry_root,
    require_project_length_unit,
    resolve_surface_entities,
)

if TYPE_CHECKING:
    from flow360.component.simulation.draft_context.context import DraftContext


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,)``. Always dimensionless. averaged_radius (unyt.unyt_quantity): Estimated cylinder radius, averaged from the two OBB half-extents perpendicular to the rotation axis. Carries the geometry's project length unit, inherited from the :class:`OBBResult` it was derived from. """ axis_of_rotation: np.ndarray averaged_radius: unyt_quantity _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,)``. Carries the geometry's project length unit when produced by :func:`oriented_bounding_box`. axes (numpy.ndarray): Principal axes as row vectors of shape ``(3, 3)``, ordered by descending extent magnitude. Always dimensionless. extents (numpy.ndarray): Half-extents along each axis, shape ``(3,)``. Carries the geometry's project length unit when produced by :func:`oriented_bounding_box`. """ 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_oriented_bounding_box( 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, )
[docs] def oriented_bounding_box( draft: DraftContext, *, surfaces: SurfaceSelection, ) -> OBBResult: """Compute a PCA-oriented bounding box for selected geometry surfaces. Parameters ---------- draft : Draft created from a Geometry resource. surfaces : Surfaces to bound. Accepts a single ``Surface``, a single ``SurfaceSelector``, a list mixing the two, or an ``EntityList[Surface]``. Duplicates are removed automatically. Returns ------- OBBResult ``center`` and ``extents`` are expressed in the geometry's project length unit; ``axes`` stays dimensionless. Example ------- >>> import flow360 as fl >>> geometry = fl.Geometry.from_cloud(id="...") >>> with fl.create_draft(new_run_from=geometry, face_grouping="faceId") as draft: ... wheel = fl.SurfaceSelector(name="wheel_FL").match("*rim*FL*") ... obb = fl.measure.oriented_bounding_box(draft, surfaces=wheel) ... rotation = obb.get_rotation_axis_and_radius(rotation_axis_hint=(0, 1, 0)) ==== """ operation = "oriented_bounding_box()" geometry_root = require_geometry_root(draft, operation=operation) length_unit = require_project_length_unit(geometry_root, operation=operation) resolved_surfaces = resolve_surface_entities(draft, surfaces, operation=operation) surface_face_ids = collect_surface_face_ids(resolved_surfaces, operation=operation) log.info("Computing Oriented Bounding Box (OBB)...") vertices = load_transformed_surface_vertices(draft, geometry_root, surface_face_ids) log.info(f"OBB: extracted {len(vertices)} vertices, computing PCA...") result = _compute_oriented_bounding_box(vertices) log.info("OBB computation complete.") return OBBResult( center=result.center * length_unit, axes=result.axes, extents=result.extents * length_unit, )