From 71fe6383a64e24ef34dac7e831f6d56d0f95051d Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Thu, 26 Mar 2026 16:57:28 -0400 Subject: [PATCH 01/18] [FXC-5651] feat: add client-side OBB computation via DraftContext.compute_obb() Implements oriented bounding box (OBB) estimation on the Python client, enabling users to compute center, rotation axis, and radius for selected surfaces without a backend API endpoint. Key components: - OBB PCA computation (obb/compute.py) with OBBResult dataclass - UVF tessellation parser (obb/uvf_parser.py) for manifest + bin extraction - TessellationFileLoader with two-layer caching (L1 memory + L2 disk via CloudFileCache) - CloudFileCache: general-purpose size-based LRU disk cache (flow360/cloud/file_cache.py) - DraftContext.compute_obb() supporting List[Surface], single Surface, and SurfaceSelector inputs - Dimensioned output (unyt) using project length unit cached on Geometry Co-Authored-By: Claude Opus 4.6 (1M context) --- flow360/cloud/file_cache.py | 130 +++++ flow360/component/geometry.py | 9 + flow360/component/project.py | 20 + .../simulation/draft_context/context.py | 101 +++- .../simulation/draft_context/obb/__init__.py | 1 + .../simulation/draft_context/obb/compute.py | 105 ++++ .../draft_context/obb/tessellation_loader.py | 190 ++++++ .../draft_context/obb/uvf_parser.py | 117 ++++ .../geo-1/visualize/manifest/body00001.bin | Bin 0 -> 378240 bytes .../geo-1/visualize/manifest/manifest.json | 543 ++++++++++++++++++ .../draft_context/test_obb_compute.py | 173 ++++++ .../simulation/draft_context/test_obb_e2e.py | 250 ++++++++ .../draft_context/test_uvf_parser.py | 322 +++++++++++ tests/test_cloud_file_cache.py | 108 ++++ 14 files changed, 2068 insertions(+), 1 deletion(-) create mode 100644 flow360/cloud/file_cache.py create mode 100644 flow360/component/simulation/draft_context/obb/__init__.py create mode 100644 flow360/component/simulation/draft_context/obb/compute.py create mode 100644 flow360/component/simulation/draft_context/obb/tessellation_loader.py create mode 100644 flow360/component/simulation/draft_context/obb/uvf_parser.py create mode 100755 tests/data/tessellation/geo-1/visualize/manifest/body00001.bin create mode 100755 tests/data/tessellation/geo-1/visualize/manifest/manifest.json create mode 100644 tests/simulation/draft_context/test_obb_compute.py create mode 100644 tests/simulation/draft_context/test_obb_e2e.py create mode 100644 tests/simulation/draft_context/test_uvf_parser.py create mode 100644 tests/test_cloud_file_cache.py diff --git a/flow360/cloud/file_cache.py b/flow360/cloud/file_cache.py new file mode 100644 index 000000000..0c5b133b3 --- /dev/null +++ b/flow360/cloud/file_cache.py @@ -0,0 +1,130 @@ +"""General-purpose size-based LRU disk cache for cloud file downloads. + +Stores files under ``~/.flow360/cache///`` +with a configurable total size limit. Eviction granularity is the resource +directory — all files for a resource are deleted together to avoid partial +state (e.g. manifest present but bin evicted). +""" + +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import List, Optional, Tuple + +from ..log import log + +CLOUD_FILE_CACHE_MAX_SIZE_MB: int = 2048 # default 2 GB, user-adjustable + + +class CloudFileCache: + """Size-based LRU disk cache. + + Keys are ``(namespace, resource_id, file_path)`` triples. + All namespaces share a single total-size budget. + """ + + def __init__( + self, + cache_root: Optional[Path] = None, + max_size_bytes: Optional[int] = None, + ) -> None: + self._cache_root = (cache_root or Path("~/.flow360/cache")).expanduser() + self._max_size_bytes = ( + CLOUD_FILE_CACHE_MAX_SIZE_MB * 1024 * 1024 if max_size_bytes is None else max_size_bytes + ) + self._disabled = False + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def get(self, namespace: str, resource_id: str, file_path: str) -> Optional[bytes]: + """Return cached bytes or ``None``. Touches ``.last_access`` on hit.""" + if self._disabled: + return None + + target = self._file_path(namespace, resource_id, file_path) + if not target.is_file(): + return None + + try: + data = target.read_bytes() + except OSError: + return None + + self._touch_last_access(namespace, resource_id) + return data + + def put(self, namespace: str, resource_id: str, file_path: str, data: bytes) -> None: + """Write *data* to disk, evicting oldest resources if over size limit.""" + if self._disabled: + return + + try: + self._evict_if_needed(len(data)) + target = self._file_path(namespace, resource_id, file_path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + self._touch_last_access(namespace, resource_id) + except OSError as exc: + log.warning(f"CloudFileCache: disk write failed ({exc}), disabling cache") + self._disabled = True + + # ------------------------------------------------------------------ + # Internals + # ------------------------------------------------------------------ + + def _file_path(self, namespace: str, resource_id: str, file_path: str) -> Path: + return self._cache_root / namespace / resource_id / file_path + + def _resource_dir(self, namespace: str, resource_id: str) -> Path: + return self._cache_root / namespace / resource_id + + def _last_access_path(self, namespace: str, resource_id: str) -> Path: + return self._resource_dir(namespace, resource_id) / ".last_access" + + def _touch_last_access(self, namespace: str, resource_id: str) -> None: + """Create or update the ``.last_access`` sentinel in the resource dir.""" + path = self._last_access_path(namespace, resource_id) + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + + def _collect_resource_dirs(self) -> Tuple[int, List[Tuple[float, int, Path]]]: + """Scan cache and return ``(total_size, [(mtime, size, dir), ...])``. + + Single pass: computes both the aggregate size and per-resource metadata + needed for LRU eviction. + """ + entries: List[Tuple[float, int, Path]] = [] + total_size = 0 + if not self._cache_root.exists(): + return total_size, entries + + for namespace_dir in self._cache_root.iterdir(): + if not namespace_dir.is_dir(): + continue + for resource_dir in namespace_dir.iterdir(): + if not resource_dir.is_dir(): + continue + last_access = resource_dir / ".last_access" + mtime = last_access.stat().st_mtime if last_access.exists() else 0.0 + size = sum(f.stat().st_size for f in resource_dir.rglob("*") if f.is_file()) + total_size += size + entries.append((mtime, size, resource_dir)) + return total_size, entries + + def _evict_if_needed(self, incoming_bytes: int) -> None: + """Delete oldest resource dirs until total size + *incoming_bytes* fits the budget.""" + current_size, entries = self._collect_resource_dirs() + if current_size + incoming_bytes <= self._max_size_bytes: + return + + # Sort by last-access time ascending (oldest first) + entries.sort(key=lambda e: e[0]) + + for _mtime, size, resource_dir in entries: + if current_size + incoming_bytes <= self._max_size_bytes: + break + shutil.rmtree(resource_dir, ignore_errors=True) + current_size -= size diff --git a/flow360/component/geometry.py b/flow360/component/geometry.py index b692f93fe..fe4c6bf10 100644 --- a/flow360/component/geometry.py +++ b/flow360/component/geometry.py @@ -389,6 +389,7 @@ class Geometry(AssetBase): def __init__(self, id: Union[str, None]): super().__init__(id) self.snappy_body_registry = None + self._project_length_unit = None @property def face_group_tag(self): @@ -447,6 +448,14 @@ def _get_default_geometry_accuracy(simulation_dict: dict) -> LengthType.Positive else _get_default_geometry_accuracy(simulation_dict=simulation_dict) ) + # Cache project length unit for OBB (avoids extra API call in create_draft) + asset_cache = simulation_dict.get("private_attribute_asset_cache", {}) + length_unit_raw = asset_cache.get("project_length_unit") + # pylint: disable=no-member + self._project_length_unit = ( + LengthType.validate(length_unit_raw) if length_unit_raw is not None else None + ) + @classmethod # pylint: disable=redefined-builtin def from_cloud(cls, id: str, **kwargs) -> Geometry: diff --git a/flow360/component/project.py b/flow360/component/project.py index f36deaa41..2a9ec95f9 100644 --- a/flow360/component/project.py +++ b/flow360/component/project.py @@ -13,6 +13,7 @@ import typing_extensions from pydantic import PositiveInt +from flow360.cloud.file_cache import CloudFileCache from flow360.cloud.flow360_requests import ( CloneVolumeMeshRequest, LengthUnitType, @@ -49,6 +50,9 @@ CoordinateSystemStatus, ) from flow360.component.simulation.draft_context.mirror import MirrorStatus +from flow360.component.simulation.draft_context.obb.tessellation_loader import ( + TessellationFileLoader, +) from flow360.component.simulation.entity_info import ( GeometryEntityInfo, merge_geometry_entity_info, @@ -292,12 +296,28 @@ def _merge_geometry_entity_info( cache_key="coordinate_system_status", ) + # Build tessellation loader for geometry-root drafts (enables compute_obb) + tessellation_loader = None + length_unit = None + if isinstance(new_run_from, Geometry): + # pylint: disable=protected-access + geometry_resources: Dict[str, Flow360Resource] = {new_run_from.id: new_run_from._webapi} + geometry_resources.update( + {geo.id: geo._webapi for geo in active_geometry_dependencies.values()} + ) + tessellation_loader = TessellationFileLoader(geometry_resources, CloudFileCache()) + + # Use length unit cached on Geometry during from_cloud (no extra API call) + length_unit = getattr(new_run_from, "_project_length_unit", None) + return DraftContext( entity_info=entity_info_copy, mirror_status=mirror_status, coordinate_system_status=coordinate_system_status, imported_surfaces=imported_surfaces, imported_geometries=list(active_geometry_dependencies.values()), + tessellation_loader=tessellation_loader, + length_unit=length_unit, ) diff --git a/flow360/component/simulation/draft_context/context.py b/flow360/component/simulation/draft_context/context.py index ecbfd453e..b87cbae2d 100644 --- a/flow360/component/simulation/draft_context/context.py +++ b/flow360/component/simulation/draft_context/context.py @@ -5,7 +5,7 @@ from contextlib import AbstractContextManager from contextvars import ContextVar, Token from dataclasses import dataclass -from typing import List, Optional, get_args +from typing import TYPE_CHECKING, List, Optional, Union, get_args from flow360.component.simulation.draft_context.coordinate_system_manager import ( CoordinateSystemManager, @@ -38,6 +38,11 @@ from flow360.exceptions import Flow360RuntimeError, Flow360ValueError from flow360.log import log +if TYPE_CHECKING: + from flow360.component.simulation.draft_context.obb.tessellation_loader import ( + TessellationFileLoader, + ) + __all__ = [ "DraftContext", "get_active_draft", @@ -77,6 +82,10 @@ class DraftContext( # pylint: disable=too-many-instance-attributes "_mirror_status", # Lightweight coordinate system relationships storage (compared to entity storages) "_coordinate_system_manager", + # OBB tessellation data loader (only available for geometry-root drafts) + "_tessellation_loader", + # Project length unit for dimensioned OBB results + "_length_unit", "_token", ) @@ -89,6 +98,8 @@ def __init__( imported_surfaces: Optional[List[ImportedSurface]] = None, mirror_status: Optional[MirrorStatus] = None, coordinate_system_status: Optional[CoordinateSystemStatus] = None, + tessellation_loader: Optional[TessellationFileLoader] = None, + length_unit=None, ) -> None: """ Data members: @@ -106,6 +117,8 @@ def __init__( "[Internal] DraftContext requires `entity_info` to initialize." ) self._token: Optional[Token] = None + self._tessellation_loader = tessellation_loader + self._length_unit = length_unit # DraftContext owns a deep copy of entity_info and mirror_status (created by create_draft()). # This signals transfer of entity ownership from the asset to the draft (context). @@ -347,4 +360,90 @@ class MockEntityList: return [entity.name for entity in matched_entities] return matched_entities + def compute_obb( + self, + entities: Union[Surface, List[Surface], EntityRegistryView, EntitySelector], + *, + lod_level: Optional[int] = None, + ): + """Compute oriented bounding box for the given surface entities. + + Args: + entities: Surface entities or an EntitySelector that resolves to surfaces. + Accepts: a single Surface, a list of Surface, an EntityRegistryView, + or an EntitySelector. + lod_level: LOD level override for tessellation data. + + Returns: + OBBResult with center, axes, extents and derived rotation axis/radius methods. + + Raises: + Flow360RuntimeError: If this draft was not created from a Geometry resource. + Flow360ValueError: If no face IDs could be collected from the provided entities. + """ + if self._tessellation_loader is None: + raise Flow360RuntimeError( + "compute_obb() requires a draft created from a Geometry resource. " + "Drafts from SurfaceMesh or VolumeMesh do not have tessellation data." + ) + + # Resolve entities to a flat list of Surface + if isinstance(entities, EntitySelector): + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.framework.entity_selector import ( + expand_entity_list_selectors, + ) + + @dataclass + class _MockEntityList: + """Temporary wrapper for EntityList to satisfy expand_entity_list_selectors.""" + + selectors: List[EntitySelector] + + surface_list = expand_entity_list_selectors( + registry=self._entity_registry, + entity_list=_MockEntityList(selectors=[entities]), + ) + elif isinstance(entities, EntityRegistryView): + surface_list = list(entities) + elif isinstance(entities, Surface): + surface_list = [entities] + else: + surface_list = entities + + # Collect face IDs from surface sub-components + face_ids = [] + for surface in surface_list: + sub_components = surface.private_attribute_sub_components + if sub_components: + face_ids.extend(sub_components) + + if not face_ids: + raise Flow360ValueError( + "No face IDs could be collected from the provided entities. " + "Ensure the entities have valid sub-component data." + ) + + # Lazy import to avoid circular dependency + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.draft_context.obb.compute import compute_obb + + log.info("Computing Oriented Bounding Box (OBB)...") + + vertices = self._tessellation_loader.load_vertices(face_ids, lod_level) + log.info(f"OBB: extracted {len(vertices)} vertices, computing PCA...") + + result = compute_obb(vertices) + + # Apply length unit to dimensioned fields if available + if self._length_unit is not None: + result = type(result)( + center=result.center * self._length_unit, + axes=result.axes, + extents=result.extents * self._length_unit, + ) + + log.info("OBB computation complete.") + return result + # endregion ------------------------------------------------------------------------------------ diff --git a/flow360/component/simulation/draft_context/obb/__init__.py b/flow360/component/simulation/draft_context/obb/__init__.py new file mode 100644 index 000000000..a984eda33 --- /dev/null +++ b/flow360/component/simulation/draft_context/obb/__init__.py @@ -0,0 +1 @@ +"""Oriented Bounding Box (OBB) computation from UVF tessellation data.""" diff --git a/flow360/component/simulation/draft_context/obb/compute.py b/flow360/component/simulation/draft_context/obb/compute.py new file mode 100644 index 000000000..f5f8989fc --- /dev/null +++ b/flow360/component/simulation/draft_context/obb/compute.py @@ -0,0 +1,105 @@ +"""Oriented Bounding Box computation via PCA. + +Computes an OBB from an (N, 3) vertex point cloud, with helpers to derive +rotation axis and radius for cylindrical geometry estimation. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import List, Optional + +import numpy as np + + +@dataclass(frozen=True) +class OBBResult: + """Oriented Bounding Box computed from a point cloud. + + Attributes: + center: (3,) geometric center of the OBB. + axes: (3, 3) principal axes as row vectors, descending by extent magnitude. + extents: (3,) half-extents along each axis. + """ + + center: np.ndarray + axes: np.ndarray + extents: np.ndarray + + def _rotation_axis_index(self, rotation_axis_hint: Optional[np.ndarray] = None) -> 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) + # Axis with maximum absolute dot product is the best alignment + dots = np.abs(self.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] = [self.extents[j] for j in range(3) if j != i] + ratio = others[0] / others[1] if others[1] > others[0] else others[1] / others[0] + if ratio > best_ratio: + best_ratio = ratio + best_index = i + return best_index + + def axis_of_rotation(self, rotation_axis_hint: Optional[np.ndarray] = None) -> np.ndarray: + """Return the (3,) unit vector along the inferred rotation axis.""" + return self.axes[self._rotation_axis_index(rotation_axis_hint)].copy() + + def radius(self, rotation_axis_hint: Optional[np.ndarray] = None) -> float: + """Return the estimated cylinder radius perpendicular to the rotation axis.""" + idx = self._rotation_axis_index(rotation_axis_hint) + perpendicular_extents = [self.extents[j] for j in range(3) if j != idx] + return (perpendicular_extents[0] + perpendicular_extents[1]) / 2.0 + + +def compute_obb(vertices: np.ndarray) -> OBBResult: + """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. + """ + 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) diff --git a/flow360/component/simulation/draft_context/obb/tessellation_loader.py b/flow360/component/simulation/draft_context/obb/tessellation_loader.py new file mode 100644 index 000000000..f2d12c3e0 --- /dev/null +++ b/flow360/component/simulation/draft_context/obb/tessellation_loader.py @@ -0,0 +1,190 @@ +"""Download and cache tessellation files (manifest + bin) for OBB computation. + +Two-layer cache strategy: + L1: in-memory dict (instance-level, lives with the draft object) + L2: disk cache via CloudFileCache (cross-session persistence) +""" + +from __future__ import annotations + +import os +from tempfile import NamedTemporaryFile +from typing import Dict, List, Optional, Tuple + +import numpy as np + +from flow360.cloud.file_cache import CloudFileCache +from flow360.component.resource_base import Flow360Resource +from flow360.log import log + +from .uvf_parser import extract_face_vertices, parse_manifest, resolve_buffers + + +class TessellationFileLoader: # pylint: disable=too-few-public-methods + """Downloads and caches tessellation files (manifest + bin) for OBB computation. + + L1: in-memory cache (instance-level, lives with the draft object) + L2: disk cache via CloudFileCache (cross-session persistence) + """ + + NAMESPACE = "tessellation" + MANIFEST_PATH = "visualize/manifest/manifest.json" + MANIFEST_DIR = "visualize/manifest" + + def __init__( + self, + geometry_resources: Dict[str, Flow360Resource], + cloud_cache: CloudFileCache, + ) -> None: + """ + Args: + geometry_resources: {geometry_id: Flow360Resource} for all active geometries. + cloud_cache: Shared disk cache instance. + """ + self._resources = geometry_resources + self._cloud_cache = cloud_cache + + # L1 memory caches + self._manifest_cache: Dict[str, List[dict]] = {} + self._memory_cache: Dict[Tuple[str, str], bytes] = {} + + # Lazy-built indices + self._face_to_geometry: Optional[Dict[str, str]] = None + + def load_vertices(self, face_ids: List[str], lod_level: Optional[int] = None) -> np.ndarray: + """Extract vertices for the given face IDs across all geometries. + + This is the main entry point for DraftContext.compute_obb(). + Handles manifest loading, face-to-geometry mapping, bin downloading, + and vertex extraction. + + Args: + face_ids: Face IDs to extract vertices for. + lod_level: LOD level override. None means use the manifest default. + + Returns: + (N, 3) float32 array of vertex positions. + """ + self._ensure_manifests_loaded() + self._ensure_face_index_built() + + # Group face_ids by geometry + geometry_faces: Dict[str, List[str]] = {} + for fid in face_ids: + geometry_id = self._face_to_geometry[fid] + geometry_faces.setdefault(geometry_id, []).append(fid) + + all_vertices: List[np.ndarray] = [] + for geometry_id, geo_face_ids in geometry_faces.items(): + manifest = self._manifest_cache[geometry_id] + bin_data_map = self._load_required_bins(geometry_id, manifest, geo_face_ids, lod_level) + vertices = extract_face_vertices(manifest, geo_face_ids, bin_data_map, lod_level) + all_vertices.append(vertices) + + return np.concatenate(all_vertices, axis=0) + + # ------------------------------------------------------------------ + # Manifest loading + # ------------------------------------------------------------------ + + def _ensure_manifests_loaded(self) -> None: + """Lazy-load manifests for all geometries (L1 -> L2 -> download).""" + missing = [gid for gid in self._resources if gid not in self._manifest_cache] + if missing: + log.info("OBB: loading tessellation data from cloud...") + for geometry_id in self._resources: + if geometry_id in self._manifest_cache: + continue + raw = self._load_file(geometry_id, self.MANIFEST_PATH) + self._manifest_cache[geometry_id] = parse_manifest(raw) + + # ------------------------------------------------------------------ + # Face index + # ------------------------------------------------------------------ + + def _ensure_face_index_built(self) -> None: + """Build global face_id -> geometry_id index from cached manifests.""" + if self._face_to_geometry is not None: + return + self._face_to_geometry = {} + for geometry_id, manifest in self._manifest_cache.items(): + for entry in manifest: + entry_id = entry.get("id") + if entry_id is not None: + self._face_to_geometry[entry_id] = geometry_id + + # ------------------------------------------------------------------ + # Bin file resolution + # ------------------------------------------------------------------ + + def _load_required_bins( # pylint: disable=too-many-locals + self, + geometry_id: str, + manifest: List[dict], + face_ids: List[str], + lod_level: Optional[int], + ) -> Dict[str, bytes]: + """Determine which bin files are needed and load them. + + Only downloads bins that contain data for the requested face_ids. + """ + by_id = {entry["id"]: entry for entry in manifest} + + needed_bin_paths: set = set() + for fid in face_ids: + face = by_id[fid] + parent_id = face["attributions"]["packedParentId"] + solid = by_id[parent_id] + buffer_entry = resolve_buffers(solid, lod_level) + needed_bin_paths.add(buffer_entry["path"]) + + bin_data_map: Dict[str, bytes] = {} + for bin_path in needed_bin_paths: + full_path = f"{self.MANIFEST_DIR}/{bin_path}" + raw = self._load_file(geometry_id, full_path) + bin_data_map[bin_path] = raw + + return bin_data_map + + # ------------------------------------------------------------------ + # Two-layer file loading (L1 -> L2 -> download) + # ------------------------------------------------------------------ + + def _load_file(self, geometry_id: str, file_path: str) -> bytes: + """Load a file through L1 memory -> L2 disk -> cloud download.""" + key = (geometry_id, file_path) + + # L1: memory + cached = self._memory_cache.get(key) + if cached is not None: + return cached + + # L2: disk + data = self._cloud_cache.get(self.NAMESPACE, geometry_id, file_path) + if data is not None: + self._memory_cache[key] = data + return data + + # Download from cloud + log.debug(f"OBB: downloading tessellation file '{file_path}' ({geometry_id})") + data = self._download_to_bytes(geometry_id, file_path) + self._memory_cache[key] = data + self._cloud_cache.put(self.NAMESPACE, geometry_id, file_path, data) + return data + + def _download_to_bytes(self, geometry_id: str, file_path: str) -> bytes: + """Download a file from cloud storage and return raw bytes.""" + resource = self._resources[geometry_id] + # Use the remote file's extension as suffix so _download_file doesn't + # append an extra extension to the temp path (see s3_utils.get_local_filename_and_create_folders). + _, ext = os.path.splitext(file_path) + with NamedTemporaryFile(delete=False, suffix=ext) as tmp: + tmp_path = tmp.name + try: + # pylint: disable=protected-access + resource._download_file(file_path, to_file=tmp_path, log_error=False, verbose=False) + with open(tmp_path, "rb") as fh: + return fh.read() + finally: + if os.path.exists(tmp_path): + os.remove(tmp_path) diff --git a/flow360/component/simulation/draft_context/obb/uvf_parser.py b/flow360/component/simulation/draft_context/obb/uvf_parser.py new file mode 100644 index 000000000..728f9261e --- /dev/null +++ b/flow360/component/simulation/draft_context/obb/uvf_parser.py @@ -0,0 +1,117 @@ +"""UVF manifest parsing and vertex extraction. + +Provides pure functions to parse UVF manifest JSON and extract face vertices +from binary tessellation data. No I/O — callers supply manifest and bin data. +""" + +from __future__ import annotations + +import json +from typing import Dict, List, Optional, Union + +import numpy as np + + +def parse_manifest(manifest_data: Union[str, bytes]) -> List[dict]: + """Parse raw manifest JSON into a list of manifest entries.""" + return json.loads(manifest_data) + + +def extract_face_vertices( # pylint: disable=too-many-locals + manifest: List[dict], + face_ids: List[str], + bin_data_map: Dict[str, bytes], + lod_level: Optional[int] = None, +) -> np.ndarray: + """Extract merged vertex positions for the given face IDs. + + Reads the UVF manifest structure to locate each face's vertex data within + the binary buffers, supporting both indexed and unindexed geometry. + + Args: + manifest: Parsed UVF manifest entries (list of dicts with "id", "resources", etc.). + face_ids: Face IDs to extract vertices for. + bin_data_map: Mapping of bin filename to raw bytes, as referenced by manifest entries. + lod_level: LOD level override. None means use the manifest default. + + Returns: + (N, 3) float32 array of vertex positions. + + Raises: + KeyError: If any face_id is not found in the manifest. + ValueError: If a solid body has no position section in its binary buffer. + """ + by_id = {entry["id"]: entry for entry in manifest} + + missing = [fid for fid in face_ids if fid not in by_id] + if missing: + raise KeyError(f"face_ids not found in manifest: {missing}") + + # Group faces by packed parent to avoid re-parsing the same bin per parent + parent_faces: Dict[str, List[dict]] = {} + for fid in face_ids: + face = by_id[fid] + parent_id = face["attributions"]["packedParentId"] + parent_faces.setdefault(parent_id, []).append(face) + + all_vertices: List[np.ndarray] = [] + + for parent_id, faces in parent_faces.items(): + solid = by_id[parent_id] + buffer_entry = resolve_buffers(solid, lod_level) + bin_path = buffer_entry["path"] + bin_data = bin_data_map[bin_path] + + sections = buffer_entry["sections"] + index_section = _find_section(sections, "indices") + position_section = _find_section(sections, "position") + + if position_section is None: + raise ValueError(f"solid '{parent_id}' has no position section in bin") + + position_array = np.frombuffer( + bin_data, + dtype=np.float32, + offset=position_section["offset"], + count=position_section["length"] // 4, + ).reshape(-1, 3) + + if index_section is not None: + # Indexed geometry: face buffer locations reference into an index array + index_array = np.frombuffer( + bin_data, + dtype=np.uint32, + offset=index_section["offset"], + count=index_section["length"] // 4, + ) + for face in faces: + for location in face["properties"]["bufferLocations"]["indices"]: + face_indices = index_array[location["startIndex"] : location["endIndex"]] + all_vertices.append(position_array[face_indices]) + else: + # Unindexed geometry: buffer locations are float32 element offsets into position + for face in faces: + for location in face["properties"]["bufferLocations"]["indices"]: + start_vertex = location["startIndex"] // 3 + end_vertex = location["endIndex"] // 3 + all_vertices.append(position_array[start_vertex:end_vertex]) + + return np.concatenate(all_vertices, axis=0) + + +def resolve_buffers(solid: dict, lod_level: Optional[int]) -> dict: + """Resolve LOD to a plain buffer entry with path and sections. + + If the buffer uses LOD, selects the specified level (or the manifest default). + Otherwise returns the buffer entry as-is. + """ + buffers = solid["resources"]["buffers"] + if buffers["type"] == "lod": + resolved_level = lod_level if lod_level is not None else buffers.get("default", 0) + return buffers["levels"][resolved_level] + return buffers + + +def _find_section(sections: List[dict], name: str) -> Optional[dict]: + """Find a named section within a buffer's section list.""" + return next((s for s in sections if s["name"] == name), None) diff --git a/tests/data/tessellation/geo-1/visualize/manifest/body00001.bin b/tests/data/tessellation/geo-1/visualize/manifest/body00001.bin new file mode 100755 index 0000000000000000000000000000000000000000..7ef6f0319ed8789f238316e03fa080434d09c373 GIT binary patch literal 378240 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⋙|(?ZK;dhdYgp=C3@cfpL%vYB3UFf+7lruQD0 z69qk1Ld#}) zZNb9OvYB2xuqd=_ruQLO99lNh`v@!vEt~1J2TMcCW_lgKve2@bUPrJzv}~r=39JY$ zo9T51D?`g>dan65V%?X`T4JlXxU7!JNPEFY^K)(tPL%j z>3s~=g_h0qdV=+#Wi!1`z=qJWnck;hV`$k-uNT-9S~k<`4K|0C&Gh; zXxU7!AJ`UJHq+}5wuhF@^ag+(p=C3@fnaB7*-UQ`*cDnf(;EzShnCIshJZbxWi!2@ zU~g#IOm7(27g{#c8xHn|md*4=fCHgrGriBi!O*gq-bipLv}~p~3LFkCo9T@PM?%YH zdY^-%p=C3@FTkTt@vYFo3;MdTynci%0A+&6!HwRn{Et~1h1;2%s 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¨k1;_UD^Auy9#(SLl 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⪼}E?0^jUp@fLictCT##9oE3db|CtviV`(JmQ` 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 A%>V!Z literal 0 HcmV?d00001 diff --git a/tests/data/tessellation/geo-1/visualize/manifest/manifest.json b/tests/data/tessellation/geo-1/visualize/manifest/manifest.json new file mode 100755 index 000000000..16a4431bb --- /dev/null +++ b/tests/data/tessellation/geo-1/visualize/manifest/manifest.json @@ -0,0 +1,543 @@ +[ + { + "attributions": { + "members": [ + "body00001" + ] + }, + "id": "root_group", + "properties": { + "transform": [ + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0 + ], + "type": 0 + }, + "type": "GeometryGroup", + "version": "v1.0.0" + }, + { + "attributions": { + "edges": [ + "body00001_edge00001", + "body00001_edge00002", + "body00001_edge00003", + "body00001_edge00004", + "body00001_edge00005", + "body00001_edge00006", + "body00001_edge00007", + "body00001_edge00008", + "body00001_edge00009", + "body00001_edge00010", + "body00001_edge00011", + "body00001_edge00012" + ], + "faces": [ + "body00001_face00001", + "body00001_face00002", + "body00001_face00003", + "body00001_face00004", + "body00001_face00005", + "body00001_face00006" + ], + "vertices": [] + }, + "id": "body00001", + "properties": { + "boundsMax": [ + 0.050000100000000026, + 0.010000100000000005, + 0.050000100000000026 + ], + "boundsMin": [ + -0.050000100000000026, + -0.010000100000000005, + -0.050000100000000026 + ] + }, + "resources": { + "buffers": { + "path": "body00001.bin", + "sections": [ + { + "dType": "uint32", + "dimension": 1, + "length": 180624, + "name": "indices", + "offset": 0 + }, + { + "dType": "float32", + "dimension": 3, + "length": 94584, + "name": "position", + "offset": 180624 + }, + { + "dType": "float32", + "dimension": 3, + "length": 94584, + "name": "normal", + "offset": 275208 + }, + { + "dType": "float32", + "dimension": 3, + "length": 8448, + "name": "edgePosition", + "offset": 369792 + } + ], + "type": "buffers" + }, + "gltf": { + "path": "body00001.gltf", + "type": "json" + } + }, + "tags": [ + "GAIGroup/wheel.step" + ], + "type": "SolidGeometry" + }, + { + "attributions": { + "packedParentId": "body00001" + }, + "id": "body00001_face00001", + "properties": { + "alpha": 1.0, + "area": 0.0006283185307179593, + "bufferLocations": { + "indices": [ + { + "bufNum": 0, + "endIndex": 768, + "startIndex": 0 + } + ] + }, + "color": 16777215 + }, + "type": "Face" + }, + { + "attributions": { + "packedParentId": "body00001" + }, + "id": "body00001_face00002", + "properties": { + "alpha": 1.0, + "area": 0.0006283185307179592, + "bufferLocations": { + "indices": [ + { + "bufNum": 0, + "endIndex": 1536, + "startIndex": 768 + } + ] + }, + "color": 16777215 + }, + "type": "Face" + }, + { + "attributions": { + "packedParentId": "body00001" + }, + "id": "body00001_face00003", + "properties": { + "alpha": 1.0, + "area": 0.007539822368615513, + "bufferLocations": { + "indices": [ + { + "bufNum": 0, + "endIndex": 20232, + "startIndex": 1536 + } + ] + }, + "color": 16777215 + }, + "type": "Face" + }, + { + "attributions": { + "packedParentId": "body00001" + }, + "id": "body00001_face00004", + "properties": { + "alpha": 1.0, + "area": 0.0031415926535897963, + "bufferLocations": { + "indices": [ + { + "bufNum": 0, + "endIndex": 23304, + "startIndex": 20232 + } + ] + }, + "color": 16777215 + }, + "type": "Face" + }, + { + "attributions": { + "packedParentId": "body00001" + }, + "id": "body00001_face00005", + "properties": { + "alpha": 1.0, + "area": 0.0031415926535897963, + "bufferLocations": { + "indices": [ + { + "bufNum": 0, + "endIndex": 26376, + "startIndex": 23304 + } + ] + }, + "color": 16777215 + }, + "type": "Face" + }, + { + "attributions": { + "packedParentId": "body00001" + }, + "id": "body00001_face00006", + "properties": { + "alpha": 1.0, + "area": 0.007539822368615513, + "bufferLocations": { + "indices": [ + { + "bufNum": 0, + "endIndex": 45156, + "startIndex": 26376 + } + ] + }, + "color": 16777215 + }, + "type": "Face" + }, + { + "attributions": { + "packedParentId": "body00001" + }, + "id": "body00001_edge00001", + "properties": { + "alpha": 1.0, + "bufferLocations": { + "indices": [ + { + "bufNum": 0, + "endIndex": 96, + "startIndex": 0 + } + ] + }, + "color": 0, + "length": 0.03141592653589795, + "sides": [ + "body00001_face00001", + "body00001_face00003" + ] + }, + "type": "Edge" + }, + { + "attributions": { + "packedParentId": "body00001" + }, + "id": "body00001_edge00002", + "properties": { + "alpha": 1.0, + "bufferLocations": { + "indices": [ + { + "bufNum": 0, + "endIndex": 144, + "startIndex": 96 + } + ] + }, + "color": 0, + "length": 0.020000000000000014, + "sides": [ + "body00001_face00001", + "body00001_face00002" + ] + }, + "type": "Edge" + }, + { + "attributions": { + "packedParentId": "body00001" + }, + "id": "body00001_edge00003", + "properties": { + "alpha": 1.0, + "bufferLocations": { + "indices": [ + { + "bufNum": 0, + "endIndex": 240, + "startIndex": 144 + } + ] + }, + "color": 0, + "length": 0.03141592653589795, + "sides": [ + "body00001_face00001", + "body00001_face00006" + ] + }, + "type": "Edge" + }, + { + "attributions": { + "packedParentId": "body00001" + }, + "id": "body00001_edge00004", + "properties": { + "alpha": 1.0, + "bufferLocations": { + "indices": [ + { + "bufNum": 0, + "endIndex": 288, + "startIndex": 240 + } + ] + }, + "color": 0, + "length": 0.02000000000000001, + "sides": [ + "body00001_face00001", + "body00001_face00002" + ] + }, + "type": "Edge" + }, + { + "attributions": { + "packedParentId": "body00001" + }, + "id": "body00001_edge00005", + "properties": { + "alpha": 1.0, + "bufferLocations": { + "indices": [ + { + "bufNum": 0, + "endIndex": 384, + "startIndex": 288 + } + ] + }, + "color": 0, + "length": 0.03141592653589795, + "sides": [ + "body00001_face00002", + "body00001_face00006" + ] + }, + "type": "Edge" + }, + { + "attributions": { + "packedParentId": "body00001" + }, + "id": "body00001_edge00006", + "properties": { + "alpha": 1.0, + "bufferLocations": { + "indices": [ + { + "bufNum": 0, + "endIndex": 480, + "startIndex": 384 + } + ] + }, + "color": 0, + "length": 0.03141592653589795, + "sides": [ + "body00001_face00002", + "body00001_face00003" + ] + }, + "type": "Edge" + }, + { + "attributions": { + "packedParentId": "body00001" + }, + "id": "body00001_edge00007", + "properties": { + "alpha": 1.0, + "bufferLocations": { + "indices": [ + { + "bufNum": 0, + "endIndex": 864, + "startIndex": 480 + } + ] + }, + "color": 0, + "length": 0.15707963267948974, + "sides": [ + "body00001_face00003", + "body00001_face00004" + ] + }, + "type": "Edge" + }, + { + "attributions": { + "packedParentId": "body00001" + }, + "id": "body00001_edge00008", + "properties": { + "alpha": 1.0, + "bufferLocations": { + "indices": [ + { + "bufNum": 0, + "endIndex": 1248, + "startIndex": 864 + } + ] + }, + "color": 0, + "length": 0.15707963267948974, + "sides": [ + "body00001_face00003", + "body00001_face00005" + ] + }, + "type": "Edge" + }, + { + "attributions": { + "packedParentId": "body00001" + }, + "id": "body00001_edge00009", + "properties": { + "alpha": 1.0, + "bufferLocations": { + "indices": [ + { + "bufNum": 0, + "endIndex": 1296, + "startIndex": 1248 + } + ] + }, + "color": 0, + "length": 0.020000000000000014, + "sides": [ + "body00001_face00004", + "body00001_face00005" + ] + }, + "type": "Edge" + }, + { + "attributions": { + "packedParentId": "body00001" + }, + "id": "body00001_edge00010", + "properties": { + "alpha": 1.0, + "bufferLocations": { + "indices": [ + { + "bufNum": 0, + "endIndex": 1680, + "startIndex": 1296 + } + ] + }, + "color": 0, + "length": 0.15707963267948974, + "sides": [ + "body00001_face00004", + "body00001_face00006" + ] + }, + "type": "Edge" + }, + { + "attributions": { + "packedParentId": "body00001" + }, + "id": "body00001_edge00011", + "properties": { + "alpha": 1.0, + "bufferLocations": { + "indices": [ + { + "bufNum": 0, + "endIndex": 1728, + "startIndex": 1680 + } + ] + }, + "color": 0, + "length": 0.02000000000000001, + "sides": [ + "body00001_face00004", + "body00001_face00005" + ] + }, + "type": "Edge" + }, + { + "attributions": { + "packedParentId": "body00001" + }, + "id": "body00001_edge00012", + "properties": { + "alpha": 1.0, + "bufferLocations": { + "indices": [ + { + "bufNum": 0, + "endIndex": 2112, + "startIndex": 1728 + } + ] + }, + "color": 0, + "length": 0.15707963267948974, + "sides": [ + "body00001_face00005", + "body00001_face00006" + ] + }, + "type": "Edge" + } +] diff --git a/tests/simulation/draft_context/test_obb_compute.py b/tests/simulation/draft_context/test_obb_compute.py new file mode 100644 index 000000000..2d2dbb7b0 --- /dev/null +++ b/tests/simulation/draft_context/test_obb_compute.py @@ -0,0 +1,173 @@ +"""Tests for OBB (Oriented Bounding Box) computation via PCA.""" + +import numpy as np +import pytest + +from flow360.component.simulation.draft_context.obb.compute import ( + OBBResult, + compute_obb, +) + +# --------------------------------------------------------------------------- +# Fixtures / helpers +# --------------------------------------------------------------------------- + + +def _box_vertices(half_x, half_y, half_z, center=(0, 0, 0)): + """Generate 8 corner vertices of an axis-aligned box.""" + cx, cy, cz = center + signs = np.array([[sx, sy, sz] for sx in (-1, 1) for sy in (-1, 1) for sz in (-1, 1)]) + return signs * np.array([half_x, half_y, half_z]) + np.array([cx, cy, cz]) + + +def _cylinder_vertices(radius, half_height, axis="z", n_ring=64, n_height=10): + """Generate a sampled cylinder surface for testing.""" + theta = np.linspace(0, 2 * np.pi, n_ring, endpoint=False) + heights = np.linspace(-half_height, half_height, n_height) + rows = [] + for h in heights: + x = radius * np.cos(theta) + y = radius * np.sin(theta) + z = np.full_like(theta, h) + if axis == "z": + rows.append(np.column_stack([x, y, z])) + elif axis == "x": + rows.append(np.column_stack([z, x, y])) + elif axis == "y": + rows.append(np.column_stack([x, z, y])) + return np.vstack(rows) + + +# --------------------------------------------------------------------------- +# compute_obb tests +# --------------------------------------------------------------------------- + + +class TestComputeObb: + def test_axis_aligned_box(self): + """OBB of an axis-aligned box should recover the half-extents.""" + verts = _box_vertices(3, 2, 1) + obb = compute_obb(verts) + + sorted_extents = np.sort(obb.extents)[::-1] + np.testing.assert_allclose(sorted_extents, [3, 2, 1], atol=1e-10) + + def test_center_at_origin(self): + verts = _box_vertices(1, 1, 1) + obb = compute_obb(verts) + np.testing.assert_allclose(obb.center, [0, 0, 0], atol=1e-10) + + def test_translated_box(self): + verts = _box_vertices(2, 1, 1, center=(10, 20, 30)) + obb = compute_obb(verts) + np.testing.assert_allclose(obb.center, [10, 20, 30], atol=1e-10) + + def test_axes_are_orthonormal(self): + verts = _box_vertices(5, 3, 1) + obb = compute_obb(verts) + + # Each axis should be unit length + for i in range(3): + np.testing.assert_allclose(np.linalg.norm(obb.axes[i]), 1.0, atol=1e-10) + + # Axes should be mutually orthogonal + gram = obb.axes @ obb.axes.T + np.testing.assert_allclose(gram, np.eye(3), atol=1e-10) + + def test_right_handed_coordinate_system(self): + verts = _box_vertices(5, 3, 1) + obb = compute_obb(verts) + det = np.linalg.det(obb.axes) + assert det > 0, f"determinant should be positive (right-handed), got {det}" + + def test_rotated_box(self): + """A 45-degree rotated box should still recover correct extents.""" + angle = np.pi / 4 + rotation = np.array( + [ + [np.cos(angle), -np.sin(angle), 0], + [np.sin(angle), np.cos(angle), 0], + [0, 0, 1], + ] + ) + verts = _box_vertices(4, 1, 1) @ rotation.T + obb = compute_obb(verts) + + sorted_extents = np.sort(obb.extents)[::-1] + np.testing.assert_allclose(sorted_extents, [4, 1, 1], atol=1e-10) + + +# --------------------------------------------------------------------------- +# OBBResult method tests +# --------------------------------------------------------------------------- + + +class TestObbResultRotationAxis: + def test_hint_selects_closest_axis(self): + """axis_of_rotation with a hint should pick the most-aligned OBB axis.""" + obb = OBBResult( + center=np.zeros(3), + axes=np.eye(3), # x, y, z as rows + extents=np.array([5.0, 1.0, 1.0]), + ) + axis = obb.axis_of_rotation(rotation_axis_hint=np.array([1, 0, 0])) + np.testing.assert_allclose(axis, [1, 0, 0], atol=1e-10) + + def test_hint_handles_non_unit_vector(self): + obb = OBBResult( + center=np.zeros(3), + axes=np.eye(3), + extents=np.array([5.0, 1.0, 1.0]), + ) + axis = obb.axis_of_rotation(rotation_axis_hint=np.array([100, 0, 0])) + np.testing.assert_allclose(axis, [1, 0, 0], atol=1e-10) + + def test_circularity_heuristic_picks_cylinder_axis(self): + """Without a hint, the axis whose perpendicular extents are most equal wins.""" + # Cylinder-like: long along axis 0, circular cross-section (equal extents 1, 2) + obb = OBBResult( + center=np.zeros(3), + axes=np.eye(3), + extents=np.array([10.0, 2.0, 2.0]), + ) + axis = obb.axis_of_rotation() + np.testing.assert_allclose(axis, [1, 0, 0], atol=1e-10) + + def test_circularity_with_slight_asymmetry(self): + """Even slightly unequal perpendicular extents should still pick the best axis.""" + obb = OBBResult( + center=np.zeros(3), + axes=np.eye(3), + extents=np.array([10.0, 2.1, 1.9]), + ) + axis = obb.axis_of_rotation() + np.testing.assert_allclose(axis, [1, 0, 0], atol=1e-10) + + +class TestObbResultRadius: + def test_radius_is_average_of_perpendicular_extents(self): + obb = OBBResult( + center=np.zeros(3), + axes=np.eye(3), + extents=np.array([10.0, 3.0, 5.0]), + ) + # Rotation axis = index 0 (circularity: perpendicular extents 3, 5 ratio 0.6) + # Compare with axis 1 (perp 10, 5, ratio 0.5) and axis 2 (perp 10, 3, ratio 0.3) + # So axis 0 wins; radius = (3 + 5) / 2 = 4.0 + assert obb.radius() == pytest.approx(4.0) + + def test_radius_with_hint(self): + obb = OBBResult( + center=np.zeros(3), + axes=np.eye(3), + extents=np.array([10.0, 3.0, 5.0]), + ) + # Force axis 2 as rotation axis; perpendicular extents are 10.0 and 3.0 + radius = obb.radius(rotation_axis_hint=np.array([0, 0, 1])) + assert radius == pytest.approx(6.5) + + def test_radius_for_perfect_cylinder(self): + verts = _cylinder_vertices(radius=5.0, half_height=20.0, axis="z") + obb = compute_obb(verts) + # The radius should be close to 5.0 + assert obb.radius(rotation_axis_hint=np.array([0, 0, 1])) == pytest.approx(5.0, abs=0.2) diff --git a/tests/simulation/draft_context/test_obb_e2e.py b/tests/simulation/draft_context/test_obb_e2e.py new file mode 100644 index 000000000..117d8f662 --- /dev/null +++ b/tests/simulation/draft_context/test_obb_e2e.py @@ -0,0 +1,250 @@ +"""End-to-end tests for DraftContext.compute_obb() pipeline. + +Uses local geo-1 (hollow cylinder) test data with a mocked download layer. +Verifies the full pipeline: Surface → face_ids → TessellationFileLoader → UVF parser → PCA → OBBResult. +Also tests the SurfaceSelector and List[Surface] input routes. +""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +from flow360.cloud.file_cache import CloudFileCache +from flow360.component.simulation.draft_context.obb.compute import ( + OBBResult, + compute_obb, +) +from flow360.component.simulation.draft_context.obb.tessellation_loader import ( + TessellationFileLoader, +) +from flow360.component.simulation.draft_context.obb.uvf_parser import parse_manifest +from flow360.component.simulation.framework.entity_selector import SurfaceSelector +from flow360.component.simulation.primitives import GeometryBodyGroup, Surface + +GEO_1_DIR = Path(__file__).resolve().parents[2] / "data" / "tessellation" / "geo-1" +GEOMETRY_ID = "geo-1-test" + +# POC reference values for geo-1 (all 6 faces) +POC_ALL_FACES = { + "center": [1.2756629078265226e-10, 1.0117358560906686e-10, 1.4181101660255997e-09], + "axes": [ + [-0.07556681447845126, -0.0003246319295740485, 0.9971406877485693], + [0.9971377618421228, -0.0024688959043392446, 0.0755657889623094], + [0.0024373054921518525, 0.9999968995787557, 0.0005102693544478121], + ], + "extents": [0.04999054650361766, 0.050011838526111047, 0.01012446986866631], + "rotation_axis_index": 2, + "radius": 0.05000119251486435, + "num_vertices": 45156, +} + +ALL_FACE_IDS = [ + "body00001_face00001", + "body00001_face00002", + "body00001_face00003", + "body00001_face00004", + "body00001_face00005", + "body00001_face00006", +] + + +@pytest.fixture() +def local_tessellation_loader(tmp_path): + """TessellationFileLoader that reads from local geo-1 test data instead of cloud.""" + mock_resource = MagicMock() + + def fake_download(file_path, *, to_file, log_error=True, verbose=True): + # Map cloud path to local test data + local_file = GEO_1_DIR / file_path + assert local_file.exists(), f"Test data missing: {local_file}" + with open(local_file, "rb") as src, open(to_file, "wb") as dst: + dst.write(src.read()) + return to_file + + mock_resource._download_file = lambda file_path, to_file, **kwargs: fake_download( + file_path, to_file=to_file, **kwargs + ) + + cache = CloudFileCache(cache_root=tmp_path / "cache", max_size_bytes=100 * 1024 * 1024) + return TessellationFileLoader( + geometry_resources={GEOMETRY_ID: mock_resource}, + cloud_cache=cache, + ) + + +def _make_surface(name, face_ids): + """Create a minimal Surface entity for testing.""" + return Surface( + name=name, + private_attribute_sub_components=face_ids, + ) + + +class TestTessellationLoaderE2E: + """Test TessellationFileLoader with real geo-1 data.""" + + def test_load_all_vertices(self, local_tessellation_loader): + vertices = local_tessellation_loader.load_vertices(ALL_FACE_IDS) + assert vertices.shape == (POC_ALL_FACES["num_vertices"], 3) + assert vertices.dtype == np.float32 + + def test_obb_matches_poc_reference(self, local_tessellation_loader): + vertices = local_tessellation_loader.load_vertices(ALL_FACE_IDS) + result = compute_obb(vertices) + + np.testing.assert_allclose(result.center, POC_ALL_FACES["center"], atol=1e-6) + np.testing.assert_allclose( + sorted(result.extents.tolist(), reverse=True), + sorted(POC_ALL_FACES["extents"], reverse=True), + atol=1e-6, + ) + + # Axes alignment (sign-agnostic) + poc_axes = np.array(POC_ALL_FACES["axes"]) + dots = np.abs(result.axes @ poc_axes.T) + assert np.all(dots.max(axis=0) > 0.999) + + def test_radius_matches_poc(self, local_tessellation_loader): + vertices = local_tessellation_loader.load_vertices(ALL_FACE_IDS) + result = compute_obb(vertices) + assert abs(result.radius() - POC_ALL_FACES["radius"]) < 1e-6 + + def test_rotation_axis_matches_poc(self, local_tessellation_loader): + vertices = local_tessellation_loader.load_vertices(ALL_FACE_IDS) + result = compute_obb(vertices) + poc_rot_axis = POC_ALL_FACES["axes"][POC_ALL_FACES["rotation_axis_index"]] + dot = abs(np.dot(result.axis_of_rotation(), poc_rot_axis)) + assert dot > 0.999 + + def test_caching_returns_same_result(self, local_tessellation_loader): + """Second call should hit L1 cache and return identical results.""" + v1 = local_tessellation_loader.load_vertices(ALL_FACE_IDS) + v2 = local_tessellation_loader.load_vertices(ALL_FACE_IDS) + np.testing.assert_array_equal(v1, v2) + + def test_subset_faces(self, local_tessellation_loader): + """Loading a subset of faces returns fewer vertices.""" + v_all = local_tessellation_loader.load_vertices(ALL_FACE_IDS) + v_two = local_tessellation_loader.load_vertices(ALL_FACE_IDS[:2]) + assert len(v_two) < len(v_all) + + def test_disk_cache_hit(self, local_tessellation_loader, tmp_path): + """After L2 cache is populated, a new loader instance can read from disk.""" + local_tessellation_loader.load_vertices(ALL_FACE_IDS) + + # Create a second loader pointing at the same disk cache + mock_resource = MagicMock() + mock_resource._download_file = MagicMock( + side_effect=AssertionError("Should not download — disk cache should serve") + ) + cache2 = CloudFileCache(cache_root=tmp_path / "cache", max_size_bytes=100 * 1024 * 1024) + loader2 = TessellationFileLoader( + geometry_resources={GEOMETRY_ID: mock_resource}, + cloud_cache=cache2, + ) + v2 = loader2.load_vertices(ALL_FACE_IDS) + assert v2.shape[0] == POC_ALL_FACES["num_vertices"] + + +class TestDraftContextComputeObb: + """Test DraftContext.compute_obb() with mocked tessellation loader.""" + + @pytest.fixture() + def draft_with_surfaces(self, local_tessellation_loader): + """Create a DraftContext with mock surfaces and a real tessellation loader.""" + from flow360.component.simulation.draft_context.context import DraftContext + from flow360.component.simulation.entity_info import GeometryEntityInfo + + body_group = GeometryBodyGroup( + name="body00001", + private_attribute_tag_key="groupByFile", + private_attribute_sub_components=["body00001"], + ) + + entity_info = GeometryEntityInfo( + face_ids=ALL_FACE_IDS, + face_attribute_names=["faceId"], + face_group_tag="faceId", + body_ids=["body00001"], + body_attribute_names=["groupByFile"], + body_group_tag="groupByFile", + grouped_faces=[ + [_make_surface(fid, [fid]) for fid in ALL_FACE_IDS], + ], + grouped_bodies=[[body_group]], + bodies_face_edge_ids={ + "body00001": {"face_ids": ALL_FACE_IDS}, + }, + ) + + return DraftContext( + entity_info=entity_info, + tessellation_loader=local_tessellation_loader, + ) + + def test_compute_obb_with_surface_list(self, draft_with_surfaces): + """Test passing a list of Surface entities.""" + surfaces = [draft_with_surfaces.surfaces[fid] for fid in ALL_FACE_IDS] + result = draft_with_surfaces.compute_obb(surfaces) + + assert isinstance(result, OBBResult) + np.testing.assert_allclose(result.center, POC_ALL_FACES["center"], atol=1e-6) + + def test_compute_obb_with_single_surface(self, draft_with_surfaces): + """Test passing a single Surface entity.""" + surface = draft_with_surfaces.surfaces[ALL_FACE_IDS[0]] + result = draft_with_surfaces.compute_obb(surface) + assert isinstance(result, OBBResult) + assert result.center is not None + + def test_compute_obb_with_selector(self, draft_with_surfaces): + """Test passing a SurfaceSelector with glob pattern.""" + selector = SurfaceSelector(name="test").match("body00001_face0000*") + result = draft_with_surfaces.compute_obb(selector) + assert isinstance(result, OBBResult) + # Glob matches all 6 faces (face00001-face00006) + np.testing.assert_allclose(result.center, POC_ALL_FACES["center"], atol=1e-6) + + def test_compute_obb_no_loader_raises(self, draft_with_surfaces): + """DraftContext without tessellation loader should raise on compute_obb.""" + from flow360.component.simulation.draft_context.context import DraftContext + + # Create a new draft using the same entity_info but WITHOUT a loader + draft_no_loader = DraftContext( + entity_info=draft_with_surfaces._entity_info, + tessellation_loader=None, + ) + surfaces = [draft_no_loader.surfaces[ALL_FACE_IDS[0]]] + with pytest.raises(Exception, match="compute_obb.*requires.*Geometry"): + draft_no_loader.compute_obb(surfaces) + + def test_compute_obb_with_length_unit(self, draft_with_surfaces, local_tessellation_loader): + """When length_unit is provided, center/extents/radius have units.""" + import unyt + + from flow360.component.simulation.draft_context.context import DraftContext + + length_unit = 1.0 * unyt.m + draft = DraftContext( + entity_info=draft_with_surfaces._entity_info, + tessellation_loader=local_tessellation_loader, + length_unit=length_unit, + ) + + surfaces = [draft.surfaces[fid] for fid in ALL_FACE_IDS] + result = draft.compute_obb(surfaces) + + # Center and extents should carry units + assert isinstance(result.center, unyt.unyt_array) + assert isinstance(result.extents, unyt.unyt_array) + assert str(result.center.units) == "m" + assert str(result.extents.units) == "m" + + # Radius should also carry units + radius = result.radius() + assert isinstance(radius, unyt.unyt_quantity) + + # Axes should remain dimensionless numpy + assert not isinstance(result.axes, unyt.unyt_array) diff --git a/tests/simulation/draft_context/test_uvf_parser.py b/tests/simulation/draft_context/test_uvf_parser.py new file mode 100644 index 000000000..9bef359ec --- /dev/null +++ b/tests/simulation/draft_context/test_uvf_parser.py @@ -0,0 +1,322 @@ +"""Tests for UVF manifest parsing and vertex extraction.""" + +import json + +import numpy as np +import pytest + +from flow360.component.simulation.draft_context.obb.uvf_parser import ( + _find_section, + extract_face_vertices, + parse_manifest, + resolve_buffers, +) + +# --------------------------------------------------------------------------- +# Helpers to build manifest / binary fixtures +# --------------------------------------------------------------------------- + + +def _make_position_buffer(vertices: np.ndarray) -> bytes: + """Encode an (N,3) float32 array to raw bytes.""" + return vertices.astype(np.float32).tobytes() + + +def _make_index_buffer(indices: np.ndarray) -> bytes: + """Encode a uint32 index array to raw bytes.""" + return indices.astype(np.uint32).tobytes() + + +def _make_solid_entry( + solid_id: str, + bin_path: str, + position_offset: int, + position_length: int, + index_offset: int = None, + index_length: int = None, + lod: bool = False, + lod_default: int = 0, +): + """Build a manifest solid entry.""" + sections = [{"name": "position", "offset": position_offset, "length": position_length}] + if index_offset is not None: + sections.append({"name": "indices", "offset": index_offset, "length": index_length}) + + buffer_payload = {"path": bin_path, "sections": sections} + if lod: + buffers = {"type": "lod", "default": lod_default, "levels": [buffer_payload]} + else: + buffers = {**buffer_payload, "type": "plain"} + + return { + "id": solid_id, + "resources": {"buffers": buffers}, + } + + +def _make_face_entry(face_id: str, parent_id: str, start_index: int, end_index: int): + """Build a manifest face entry.""" + return { + "id": face_id, + "attributions": {"packedParentId": parent_id}, + "properties": { + "bufferLocations": { + "indices": [{"startIndex": start_index, "endIndex": end_index}], + } + }, + } + + +# --------------------------------------------------------------------------- +# parse_manifest +# --------------------------------------------------------------------------- + + +class TestParseManifest: + def test_parses_json_string(self): + data = json.dumps([{"id": "a"}, {"id": "b"}]) + result = parse_manifest(data) + assert len(result) == 2 + assert result[0]["id"] == "a" + + def test_parses_bytes(self): + data = json.dumps([{"id": "x"}]).encode("utf-8") + result = parse_manifest(data) + assert result[0]["id"] == "x" + + +# --------------------------------------------------------------------------- +# resolve_buffers +# --------------------------------------------------------------------------- + + +class TestResolveBuffers: + def test_plain_returns_as_is(self): + solid = {"resources": {"buffers": {"type": "plain", "path": "a.bin", "sections": []}}} + result = resolve_buffers(solid, lod_level=None) + assert result["path"] == "a.bin" + + def test_lod_uses_specified_level(self): + solid = { + "resources": { + "buffers": { + "type": "lod", + "default": 0, + "levels": [ + {"path": "lod0.bin", "sections": []}, + {"path": "lod1.bin", "sections": []}, + ], + } + } + } + result = resolve_buffers(solid, lod_level=1) + assert result["path"] == "lod1.bin" + + def test_lod_falls_back_to_default(self): + solid = { + "resources": { + "buffers": { + "type": "lod", + "default": 1, + "levels": [ + {"path": "lod0.bin", "sections": []}, + {"path": "lod1.bin", "sections": []}, + ], + } + } + } + result = resolve_buffers(solid, lod_level=None) + assert result["path"] == "lod1.bin" + + +# --------------------------------------------------------------------------- +# _find_section +# --------------------------------------------------------------------------- + + +class TestFindSection: + def test_finds_existing_section(self): + sections = [{"name": "position", "offset": 0}, {"name": "indices", "offset": 100}] + assert _find_section(sections, "position")["offset"] == 0 + + def test_returns_none_for_missing(self): + sections = [{"name": "position", "offset": 0}] + assert _find_section(sections, "normals") is None + + +# --------------------------------------------------------------------------- +# extract_face_vertices — indexed geometry +# --------------------------------------------------------------------------- + + +class TestExtractFaceVerticesIndexed: + @pytest.fixture() + def indexed_scene(self): + """A minimal indexed scene: 1 solid, 2 faces, shared vertex pool.""" + vertices = np.array( + [[0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0], [0.5, 0.5, 1]], + dtype=np.float32, + ) + indices = np.array([0, 1, 2, 2, 1, 3, 0, 1, 4], dtype=np.uint32) + + pos_bytes = _make_position_buffer(vertices) + idx_bytes = _make_index_buffer(indices) + bin_data = idx_bytes + pos_bytes + + solid = _make_solid_entry( + solid_id="solid_1", + bin_path="mesh.bin", + position_offset=len(idx_bytes), + position_length=len(pos_bytes), + index_offset=0, + index_length=len(idx_bytes), + ) + # face_a: indices 0..6 (two triangles), face_b: indices 6..9 (one triangle) + face_a = _make_face_entry("face_a", "solid_1", start_index=0, end_index=6) + face_b = _make_face_entry("face_b", "solid_1", start_index=6, end_index=9) + + manifest = [solid, face_a, face_b] + bin_data_map = {"mesh.bin": bin_data} + return manifest, bin_data_map + + def test_single_face(self, indexed_scene): + manifest, bin_data_map = indexed_scene + verts = extract_face_vertices(manifest, ["face_a"], bin_data_map) + assert verts.shape == (6, 3) + + def test_multiple_faces(self, indexed_scene): + manifest, bin_data_map = indexed_scene + verts = extract_face_vertices(manifest, ["face_a", "face_b"], bin_data_map) + assert verts.shape == (9, 3) + + def test_vertex_values(self, indexed_scene): + manifest, bin_data_map = indexed_scene + verts = extract_face_vertices(manifest, ["face_b"], bin_data_map) + # face_b indices are [0, 1, 4] in the original index array at positions 6..9 + expected = np.array([[0, 0, 0], [1, 0, 0], [0.5, 0.5, 1]], dtype=np.float32) + np.testing.assert_array_equal(verts, expected) + + +# --------------------------------------------------------------------------- +# extract_face_vertices — unindexed geometry +# --------------------------------------------------------------------------- + + +class TestExtractFaceVerticesUnindexed: + @pytest.fixture() + def unindexed_scene(self): + """A scene with no index section — buffer locations are float32 element offsets.""" + # 4 triangles worth of vertices (12 vertices, 36 floats) + vertices = np.arange(36, dtype=np.float32).reshape(12, 3) + pos_bytes = _make_position_buffer(vertices) + + solid = _make_solid_entry( + solid_id="solid_1", + bin_path="mesh.bin", + position_offset=0, + position_length=len(pos_bytes), + ) + # face covers elements 0..18 (6 vertices = 2 triangles) + face = _make_face_entry("face_1", "solid_1", start_index=0, end_index=18) + + manifest = [solid, face] + bin_data_map = {"mesh.bin": pos_bytes} + return manifest, bin_data_map + + def test_extracts_correct_vertex_count(self, unindexed_scene): + manifest, bin_data_map = unindexed_scene + verts = extract_face_vertices(manifest, ["face_1"], bin_data_map) + assert verts.shape == (6, 3) + + def test_vertex_values(self, unindexed_scene): + manifest, bin_data_map = unindexed_scene + verts = extract_face_vertices(manifest, ["face_1"], bin_data_map) + expected = np.arange(18, dtype=np.float32).reshape(6, 3) + np.testing.assert_array_equal(verts, expected) + + +# --------------------------------------------------------------------------- +# extract_face_vertices — LOD support +# --------------------------------------------------------------------------- + + +class TestExtractFaceVerticesLod: + def test_lod_level_selects_correct_buffer(self): + """When using LOD buffers, the specified level should be used.""" + verts_lod0 = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0]], dtype=np.float32) + verts_lod1 = np.array([[9, 9, 9], [8, 8, 8], [7, 7, 7]], dtype=np.float32) + indices = np.array([0, 1, 2], dtype=np.uint32) + + idx_bytes = _make_index_buffer(indices) + pos0_bytes = _make_position_buffer(verts_lod0) + pos1_bytes = _make_position_buffer(verts_lod1) + + bin0 = idx_bytes + pos0_bytes + bin1 = idx_bytes + pos1_bytes + + solid = { + "id": "solid_1", + "resources": { + "buffers": { + "type": "lod", + "default": 0, + "levels": [ + { + "path": "lod0.bin", + "sections": [ + {"name": "indices", "offset": 0, "length": len(idx_bytes)}, + { + "name": "position", + "offset": len(idx_bytes), + "length": len(pos0_bytes), + }, + ], + }, + { + "path": "lod1.bin", + "sections": [ + {"name": "indices", "offset": 0, "length": len(idx_bytes)}, + { + "name": "position", + "offset": len(idx_bytes), + "length": len(pos1_bytes), + }, + ], + }, + ], + } + }, + } + face = _make_face_entry("face_1", "solid_1", start_index=0, end_index=3) + manifest = [solid, face] + bin_data_map = {"lod0.bin": bin0, "lod1.bin": bin1} + + verts = extract_face_vertices(manifest, ["face_1"], bin_data_map, lod_level=1) + np.testing.assert_array_equal(verts, verts_lod1) + + +# --------------------------------------------------------------------------- +# Error cases +# --------------------------------------------------------------------------- + + +class TestExtractFaceVerticesErrors: + def test_missing_face_id_raises_key_error(self): + manifest = [ + { + "id": "solid_1", + "resources": {"buffers": {"type": "plain", "path": "a.bin", "sections": []}}, + } + ] + with pytest.raises(KeyError, match="nonexistent"): + extract_face_vertices(manifest, ["nonexistent"], {}) + + def test_missing_position_section_raises_value_error(self): + solid = _make_solid_entry("solid_1", "mesh.bin", position_offset=0, position_length=0) + # Remove the position section + solid["resources"]["buffers"]["sections"] = [] + face = _make_face_entry("face_1", "solid_1", start_index=0, end_index=3) + manifest = [solid, face] + + with pytest.raises(ValueError, match="no position section"): + extract_face_vertices(manifest, ["face_1"], {"mesh.bin": b"\x00" * 100}) diff --git a/tests/test_cloud_file_cache.py b/tests/test_cloud_file_cache.py new file mode 100644 index 000000000..c0e179148 --- /dev/null +++ b/tests/test_cloud_file_cache.py @@ -0,0 +1,108 @@ +"""Tests for CloudFileCache size-based LRU disk cache.""" + +import time + +import pytest + +from flow360.cloud.file_cache import CloudFileCache + + +@pytest.fixture() +def cache(tmp_path): + """A small cache (1 KB limit) rooted in a temp directory.""" + return CloudFileCache(cache_root=tmp_path / "cache", max_size_bytes=1024) + + +class TestGetPut: + def test_roundtrip(self, cache): + data = b"hello world" + cache.put("ns", "res1", "file.bin", data) + assert cache.get("ns", "res1", "file.bin") == data + + def test_get_miss_returns_none(self, cache): + assert cache.get("ns", "nonexistent", "file.bin") is None + + def test_multiple_files_per_resource(self, cache): + cache.put("ns", "res1", "a.bin", b"aaa") + cache.put("ns", "res1", "b.bin", b"bbb") + assert cache.get("ns", "res1", "a.bin") == b"aaa" + assert cache.get("ns", "res1", "b.bin") == b"bbb" + + def test_separate_namespaces(self, cache): + cache.put("ns1", "res", "f.bin", b"one") + cache.put("ns2", "res", "f.bin", b"two") + assert cache.get("ns1", "res", "f.bin") == b"one" + assert cache.get("ns2", "res", "f.bin") == b"two" + + +class TestEviction: + def test_evicts_oldest_when_over_budget(self, tmp_path): + # 500-byte budget: writing two 300-byte entries should evict the first + cache = CloudFileCache(cache_root=tmp_path / "cache", max_size_bytes=500) + cache.put("ns", "old", "f.bin", b"x" * 300) + time.sleep(0.05) # ensure distinct mtime + cache.put("ns", "new", "f.bin", b"y" * 300) + + assert cache.get("ns", "old", "f.bin") is None + assert cache.get("ns", "new", "f.bin") == b"y" * 300 + + def test_lru_order_respected(self, tmp_path): + cache = CloudFileCache(cache_root=tmp_path / "cache", max_size_bytes=800) + + cache.put("ns", "A", "f.bin", b"a" * 250) + time.sleep(0.05) + cache.put("ns", "B", "f.bin", b"b" * 250) + time.sleep(0.05) + + # Touch A so B becomes oldest + cache.get("ns", "A", "f.bin") + time.sleep(0.05) + + # This should evict B (oldest access), not A + cache.put("ns", "C", "f.bin", b"c" * 400) + + assert cache.get("ns", "A", "f.bin") == b"a" * 250 + assert cache.get("ns", "B", "f.bin") is None + assert cache.get("ns", "C", "f.bin") == b"c" * 400 + + def test_no_eviction_when_within_budget(self, cache): + cache.put("ns", "r1", "f.bin", b"x" * 100) + cache.put("ns", "r2", "f.bin", b"y" * 100) + assert cache.get("ns", "r1", "f.bin") == b"x" * 100 + assert cache.get("ns", "r2", "f.bin") == b"y" * 100 + + +class TestDisabled: + def test_disabled_cache_returns_none(self, tmp_path): + cache = CloudFileCache(cache_root=tmp_path / "cache", max_size_bytes=1024) + cache._disabled = True + cache.put("ns", "res", "f.bin", b"data") + assert cache.get("ns", "res", "f.bin") is None + + def test_put_on_read_only_fs_disables_cache(self, tmp_path): + # Point to a non-writable root + bad_root = tmp_path / "no_exist" / "deep" / "path" + bad_root.mkdir(parents=True) + bad_root.chmod(0o000) + + cache = CloudFileCache(cache_root=bad_root / "cache", max_size_bytes=1024) + cache.put("ns", "res", "f.bin", b"data") + assert cache._disabled + + # Cleanup: restore permissions so tmp_path cleanup succeeds + bad_root.chmod(0o755) + + +class TestLastAccess: + def test_put_creates_last_access_sentinel(self, cache, tmp_path): + cache.put("ns", "res1", "file.bin", b"data") + sentinel = tmp_path / "cache" / "ns" / "res1" / ".last_access" + assert sentinel.exists() + + def test_get_updates_last_access_sentinel(self, cache, tmp_path): + cache.put("ns", "res1", "file.bin", b"data") + sentinel = tmp_path / "cache" / "ns" / "res1" / ".last_access" + mtime_before = sentinel.stat().st_mtime + time.sleep(0.05) + cache.get("ns", "res1", "file.bin") + assert sentinel.stat().st_mtime >= mtime_before From 0c2ad03cb7f187bb5e69c6248d2b88ccb56eec14 Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Thu, 26 Mar 2026 19:51:19 -0400 Subject: [PATCH 02/18] [FXC-5651] refactor: move rotation_axis_hint to compute_obb(), make all OBBResult fields properties rotation_axis_hint is now passed at compute_obb() call time and baked into the result. OBBResult.radius and OBBResult.axis_of_rotation are now frozen dataclass fields instead of methods, giving a consistent property-only access pattern. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../simulation/draft_context/context.py | 10 +- .../simulation/draft_context/obb/compute.py | 95 +++++++++------ .../draft_context/test_obb_compute.py | 110 +++++++++--------- .../simulation/draft_context/test_obb_e2e.py | 10 +- 4 files changed, 123 insertions(+), 102 deletions(-) diff --git a/flow360/component/simulation/draft_context/context.py b/flow360/component/simulation/draft_context/context.py index b87cbae2d..12a70d0f9 100644 --- a/flow360/component/simulation/draft_context/context.py +++ b/flow360/component/simulation/draft_context/context.py @@ -364,6 +364,7 @@ def compute_obb( self, entities: Union[Surface, List[Surface], EntityRegistryView, EntitySelector], *, + rotation_axis_hint=None, lod_level: Optional[int] = None, ): """Compute oriented bounding box for the given surface entities. @@ -372,10 +373,13 @@ def compute_obb( entities: Surface entities or an EntitySelector that resolves to surfaces. Accepts: a single Surface, a list of Surface, an EntityRegistryView, or an EntitySelector. + rotation_axis_hint: optional approximate rotation axis direction (e.g. [0, 0, 1]). + If provided, the PCA axis most aligned with this hint is chosen as rotation axis. + If None, the axis whose perpendicular cross-section is most circular is used. lod_level: LOD level override for tessellation data. Returns: - OBBResult with center, axes, extents and derived rotation axis/radius methods. + OBBResult with center, axes, extents, axis_of_rotation, and radius as properties. Raises: Flow360RuntimeError: If this draft was not created from a Geometry resource. @@ -433,7 +437,7 @@ class _MockEntityList: vertices = self._tessellation_loader.load_vertices(face_ids, lod_level) log.info(f"OBB: extracted {len(vertices)} vertices, computing PCA...") - result = compute_obb(vertices) + result = compute_obb(vertices, rotation_axis_hint=rotation_axis_hint) # Apply length unit to dimensioned fields if available if self._length_unit is not None: @@ -441,6 +445,8 @@ class _MockEntityList: center=result.center * self._length_unit, axes=result.axes, extents=result.extents * self._length_unit, + axis_of_rotation=result.axis_of_rotation, + radius=result.radius * self._length_unit, ) log.info("OBB computation complete.") diff --git a/flow360/component/simulation/draft_context/obb/compute.py b/flow360/component/simulation/draft_context/obb/compute.py index f5f8989fc..c9fc7bd81 100644 --- a/flow360/component/simulation/draft_context/obb/compute.py +++ b/flow360/component/simulation/draft_context/obb/compute.py @@ -12,68 +12,75 @@ import numpy as np +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) + 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] + ratio = others[0] / others[1] if others[1] > others[0] else others[1] / others[0] + if ratio > best_ratio: + best_ratio = ratio + best_index = i + return best_index + + @dataclass(frozen=True) class OBBResult: """Oriented Bounding Box computed from a point cloud. + All fields are properties — no method calls needed. + Attributes: center: (3,) geometric center of the OBB. axes: (3, 3) principal axes as row vectors, descending by extent magnitude. extents: (3,) half-extents along each axis. + axis_of_rotation: (3,) unit vector along the inferred rotation axis. + radius: estimated cylinder radius perpendicular to the rotation axis. """ center: np.ndarray axes: np.ndarray extents: np.ndarray + axis_of_rotation: np.ndarray + radius: float + - def _rotation_axis_index(self, rotation_axis_hint: Optional[np.ndarray] = None) -> 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) - # Axis with maximum absolute dot product is the best alignment - dots = np.abs(self.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] = [self.extents[j] for j in range(3) if j != i] - ratio = others[0] / others[1] if others[1] > others[0] else others[1] / others[0] - if ratio > best_ratio: - best_ratio = ratio - best_index = i - return best_index - - def axis_of_rotation(self, rotation_axis_hint: Optional[np.ndarray] = None) -> np.ndarray: - """Return the (3,) unit vector along the inferred rotation axis.""" - return self.axes[self._rotation_axis_index(rotation_axis_hint)].copy() - - def radius(self, rotation_axis_hint: Optional[np.ndarray] = None) -> float: - """Return the estimated cylinder radius perpendicular to the rotation axis.""" - idx = self._rotation_axis_index(rotation_axis_hint) - perpendicular_extents = [self.extents[j] for j in range(3) if j != idx] - return (perpendicular_extents[0] + perpendicular_extents[1]) / 2.0 - - -def compute_obb(vertices: np.ndarray) -> OBBResult: +def compute_obb( + vertices: np.ndarray, + rotation_axis_hint: Optional[np.ndarray] = None, +) -> OBBResult: """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. + 4. Infer rotation axis from hint or circularity heuristic. Args: vertices: (N, 3) array of 3D positions. + rotation_axis_hint: optional approximate rotation axis direction. + If provided, the PCA axis most aligned with this hint is chosen. + If None, the axis whose perpendicular cross-section is most circular is used. Returns: - OBBResult with center, axes, and extents. + OBBResult with center, axes, extents, axis_of_rotation, and radius. """ center = vertices.mean(axis=0) centered = vertices - center @@ -102,4 +109,16 @@ def compute_obb(vertices: np.ndarray) -> OBBResult: # Axes as row vectors axes = eigenvectors.T - return OBBResult(center=obb_center, axes=axes, extents=extents) + # Derive rotation axis and radius + rot_idx = _select_rotation_axis_index(axes, extents, rotation_axis_hint) + rot_axis = axes[rot_idx].copy() + perpendicular = [extents[j] for j in range(3) if j != rot_idx] + radius = (perpendicular[0] + perpendicular[1]) / 2.0 + + return OBBResult( + center=obb_center, + axes=axes, + extents=extents, + axis_of_rotation=rot_axis, + radius=radius, + ) diff --git a/tests/simulation/draft_context/test_obb_compute.py b/tests/simulation/draft_context/test_obb_compute.py index 2d2dbb7b0..94f714f39 100644 --- a/tests/simulation/draft_context/test_obb_compute.py +++ b/tests/simulation/draft_context/test_obb_compute.py @@ -5,6 +5,7 @@ from flow360.component.simulation.draft_context.obb.compute import ( OBBResult, + _select_rotation_axis_index, compute_obb, ) @@ -66,11 +67,9 @@ def test_axes_are_orthonormal(self): verts = _box_vertices(5, 3, 1) obb = compute_obb(verts) - # Each axis should be unit length for i in range(3): np.testing.assert_allclose(np.linalg.norm(obb.axes[i]), 1.0, atol=1e-10) - # Axes should be mutually orthogonal gram = obb.axes @ obb.axes.T np.testing.assert_allclose(gram, np.eye(3), atol=1e-10) @@ -96,78 +95,75 @@ def test_rotated_box(self): sorted_extents = np.sort(obb.extents)[::-1] np.testing.assert_allclose(sorted_extents, [4, 1, 1], atol=1e-10) + def test_result_has_all_fields(self): + verts = _box_vertices(3, 2, 1) + obb = compute_obb(verts) + assert obb.center is not None + assert obb.axes is not None + assert obb.extents is not None + assert obb.axis_of_rotation is not None + assert obb.radius is not None + # --------------------------------------------------------------------------- -# OBBResult method tests +# Rotation axis selection tests # --------------------------------------------------------------------------- -class TestObbResultRotationAxis: +class TestRotationAxisSelection: def test_hint_selects_closest_axis(self): - """axis_of_rotation with a hint should pick the most-aligned OBB axis.""" - obb = OBBResult( - center=np.zeros(3), - axes=np.eye(3), # x, y, z as rows - extents=np.array([5.0, 1.0, 1.0]), - ) - axis = obb.axis_of_rotation(rotation_axis_hint=np.array([1, 0, 0])) - np.testing.assert_allclose(axis, [1, 0, 0], atol=1e-10) + axes = np.eye(3) + extents = np.array([5.0, 1.0, 1.0]) + idx = _select_rotation_axis_index(axes, extents, rotation_axis_hint=np.array([1, 0, 0])) + assert idx == 0 def test_hint_handles_non_unit_vector(self): - obb = OBBResult( - center=np.zeros(3), - axes=np.eye(3), - extents=np.array([5.0, 1.0, 1.0]), - ) - axis = obb.axis_of_rotation(rotation_axis_hint=np.array([100, 0, 0])) - np.testing.assert_allclose(axis, [1, 0, 0], atol=1e-10) + axes = np.eye(3) + extents = np.array([5.0, 1.0, 1.0]) + idx = _select_rotation_axis_index(axes, extents, rotation_axis_hint=np.array([100, 0, 0])) + assert idx == 0 def test_circularity_heuristic_picks_cylinder_axis(self): """Without a hint, the axis whose perpendicular extents are most equal wins.""" - # Cylinder-like: long along axis 0, circular cross-section (equal extents 1, 2) - obb = OBBResult( - center=np.zeros(3), - axes=np.eye(3), - extents=np.array([10.0, 2.0, 2.0]), - ) - axis = obb.axis_of_rotation() - np.testing.assert_allclose(axis, [1, 0, 0], atol=1e-10) + axes = np.eye(3) + extents = np.array([10.0, 2.0, 2.0]) + idx = _select_rotation_axis_index(axes, extents, rotation_axis_hint=None) + assert idx == 0 def test_circularity_with_slight_asymmetry(self): - """Even slightly unequal perpendicular extents should still pick the best axis.""" - obb = OBBResult( - center=np.zeros(3), - axes=np.eye(3), - extents=np.array([10.0, 2.1, 1.9]), - ) - axis = obb.axis_of_rotation() - np.testing.assert_allclose(axis, [1, 0, 0], atol=1e-10) + axes = np.eye(3) + extents = np.array([10.0, 2.1, 1.9]) + idx = _select_rotation_axis_index(axes, extents, rotation_axis_hint=None) + assert idx == 0 -class TestObbResultRadius: - def test_radius_is_average_of_perpendicular_extents(self): - obb = OBBResult( - center=np.zeros(3), - axes=np.eye(3), - extents=np.array([10.0, 3.0, 5.0]), - ) - # Rotation axis = index 0 (circularity: perpendicular extents 3, 5 ratio 0.6) - # Compare with axis 1 (perp 10, 5, ratio 0.5) and axis 2 (perp 10, 3, ratio 0.3) - # So axis 0 wins; radius = (3 + 5) / 2 = 4.0 - assert obb.radius() == pytest.approx(4.0) +class TestComputeObbRotationAxis: + def test_hint_baked_into_result(self): + """rotation_axis_hint at compute_obb time is baked into the result fields.""" + verts = _cylinder_vertices(radius=5.0, half_height=20.0, axis="z") + obb = compute_obb(verts, rotation_axis_hint=[0, 0, 1]) + assert abs(np.dot(obb.axis_of_rotation, [0, 0, 1])) > 0.99 + + def test_default_circularity(self): + """Without hint, circularity picks the cylinder axis.""" + verts = _cylinder_vertices(radius=5.0, half_height=20.0, axis="z") + obb = compute_obb(verts) + # Z-axis cylinder: rotation axis should align with Z + assert abs(np.dot(obb.axis_of_rotation, [0, 0, 1])) > 0.99 def test_radius_with_hint(self): - obb = OBBResult( - center=np.zeros(3), - axes=np.eye(3), - extents=np.array([10.0, 3.0, 5.0]), - ) - # Force axis 2 as rotation axis; perpendicular extents are 10.0 and 3.0 - radius = obb.radius(rotation_axis_hint=np.array([0, 0, 1])) - assert radius == pytest.approx(6.5) + verts = _box_vertices(10, 3, 5) + obb = compute_obb(verts, rotation_axis_hint=[0, 0, 1]) + # Hint Z → axis 2 → perpendicular extents are 10 and 3 → radius = 6.5 + assert obb.radius == pytest.approx(6.5) + + def test_radius_default(self): + verts = _box_vertices(10, 3, 5) + obb = compute_obb(verts) + # Circularity: axis 0 (perp 3,5 ratio 0.6) wins → radius = (3+5)/2 = 4.0 + assert obb.radius == pytest.approx(4.0) def test_radius_for_perfect_cylinder(self): verts = _cylinder_vertices(radius=5.0, half_height=20.0, axis="z") - obb = compute_obb(verts) - # The radius should be close to 5.0 - assert obb.radius(rotation_axis_hint=np.array([0, 0, 1])) == pytest.approx(5.0, abs=0.2) + obb = compute_obb(verts, rotation_axis_hint=[0, 0, 1]) + assert obb.radius == pytest.approx(5.0, abs=0.2) diff --git a/tests/simulation/draft_context/test_obb_e2e.py b/tests/simulation/draft_context/test_obb_e2e.py index 117d8f662..d18cc7c34 100644 --- a/tests/simulation/draft_context/test_obb_e2e.py +++ b/tests/simulation/draft_context/test_obb_e2e.py @@ -109,13 +109,13 @@ def test_obb_matches_poc_reference(self, local_tessellation_loader): def test_radius_matches_poc(self, local_tessellation_loader): vertices = local_tessellation_loader.load_vertices(ALL_FACE_IDS) result = compute_obb(vertices) - assert abs(result.radius() - POC_ALL_FACES["radius"]) < 1e-6 + assert abs(result.radius - POC_ALL_FACES["radius"]) < 1e-6 def test_rotation_axis_matches_poc(self, local_tessellation_loader): vertices = local_tessellation_loader.load_vertices(ALL_FACE_IDS) result = compute_obb(vertices) poc_rot_axis = POC_ALL_FACES["axes"][POC_ALL_FACES["rotation_axis_index"]] - dot = abs(np.dot(result.axis_of_rotation(), poc_rot_axis)) + dot = abs(np.dot(result.axis_of_rotation, poc_rot_axis)) assert dot > 0.999 def test_caching_returns_same_result(self, local_tessellation_loader): @@ -243,8 +243,8 @@ def test_compute_obb_with_length_unit(self, draft_with_surfaces, local_tessellat assert str(result.extents.units) == "m" # Radius should also carry units - radius = result.radius() - assert isinstance(radius, unyt.unyt_quantity) + assert isinstance(result.radius, unyt.unyt_quantity) - # Axes should remain dimensionless numpy + # Axes and axis_of_rotation should remain dimensionless numpy assert not isinstance(result.axes, unyt.unyt_array) + assert not isinstance(result.axis_of_rotation, unyt.unyt_array) From 524b8c52f9d98a7dbecdbe0ee0b8cbf3a455b38f Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Thu, 26 Mar 2026 20:20:07 -0400 Subject: [PATCH 03/18] linter --- flow360/component/simulation/draft_context/obb/compute.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flow360/component/simulation/draft_context/obb/compute.py b/flow360/component/simulation/draft_context/obb/compute.py index c9fc7bd81..eb6a09775 100644 --- a/flow360/component/simulation/draft_context/obb/compute.py +++ b/flow360/component/simulation/draft_context/obb/compute.py @@ -61,7 +61,7 @@ class OBBResult: radius: float -def compute_obb( +def compute_obb( # pylint:disable = too-many-locals vertices: np.ndarray, rotation_axis_hint: Optional[np.ndarray] = None, ) -> OBBResult: From 5a6e6f04f6a5e9d329c9e2e02ee3786cc2b71b1a Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Thu, 26 Mar 2026 20:37:35 -0400 Subject: [PATCH 04/18] [FXC-5651] fix: skip caching entries that exceed total cache size limit A single file larger than _max_size_bytes would evict everything then still be written, violating the cache cap. Now put() returns early without writing when incoming_bytes > _max_size_bytes. Co-Authored-By: Claude Opus 4.6 (1M context) --- flow360/cloud/file_cache.py | 4 ++++ tests/test_cloud_file_cache.py | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/flow360/cloud/file_cache.py b/flow360/cloud/file_cache.py index 0c5b133b3..b0769d98c 100644 --- a/flow360/cloud/file_cache.py +++ b/flow360/cloud/file_cache.py @@ -61,6 +61,10 @@ def put(self, namespace: str, resource_id: str, file_path: str, data: bytes) -> if self._disabled: return + # Skip caching entries that exceed the entire cache budget + if len(data) > self._max_size_bytes: + return + try: self._evict_if_needed(len(data)) target = self._file_path(namespace, resource_id, file_path) diff --git a/tests/test_cloud_file_cache.py b/tests/test_cloud_file_cache.py index c0e179148..ded94473b 100644 --- a/tests/test_cloud_file_cache.py +++ b/tests/test_cloud_file_cache.py @@ -71,6 +71,17 @@ def test_no_eviction_when_within_budget(self, cache): assert cache.get("ns", "r1", "f.bin") == b"x" * 100 assert cache.get("ns", "r2", "f.bin") == b"y" * 100 + def test_skip_entry_exceeding_total_budget(self, tmp_path): + """A single entry larger than the entire cache budget is silently skipped.""" + cache = CloudFileCache(cache_root=tmp_path / "cache", max_size_bytes=500) + cache.put("ns", "small", "f.bin", b"x" * 100) + cache.put("ns", "huge", "f.bin", b"y" * 1000) # exceeds 500-byte budget + + # Oversized entry was not cached + assert cache.get("ns", "huge", "f.bin") is None + # Existing entry was not evicted + assert cache.get("ns", "small", "f.bin") == b"x" * 100 + class TestDisabled: def test_disabled_cache_returns_none(self, tmp_path): From 3d70686d2b082aa84e83611cf27c091d0655c3f9 Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Thu, 26 Mar 2026 20:43:50 -0400 Subject: [PATCH 05/18] [FXC-5651] fix: reject non-Surface selectors in compute_obb() upfront Passing EdgeSelector/BodyGroupSelector would expand to non-surface entities and fail with a low-level KeyError during tessellation lookup. Now validates target_class == "Surface" immediately with a clear Flow360ValueError. Co-Authored-By: Claude Opus 4.6 (1M context) --- flow360/component/simulation/draft_context/context.py | 5 +++++ tests/simulation/draft_context/test_obb_e2e.py | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/flow360/component/simulation/draft_context/context.py b/flow360/component/simulation/draft_context/context.py index 12a70d0f9..05f2d5512 100644 --- a/flow360/component/simulation/draft_context/context.py +++ b/flow360/component/simulation/draft_context/context.py @@ -393,6 +393,11 @@ def compute_obb( # Resolve entities to a flat list of Surface if isinstance(entities, EntitySelector): + if entities.target_class != "Surface": + raise Flow360ValueError( + f"compute_obb() requires a SurfaceSelector, " + f"got selector with target_class='{entities.target_class}'." + ) # pylint: disable=import-outside-toplevel from flow360.component.simulation.framework.entity_selector import ( expand_entity_list_selectors, diff --git a/tests/simulation/draft_context/test_obb_e2e.py b/tests/simulation/draft_context/test_obb_e2e.py index d18cc7c34..539084f6b 100644 --- a/tests/simulation/draft_context/test_obb_e2e.py +++ b/tests/simulation/draft_context/test_obb_e2e.py @@ -207,6 +207,13 @@ def test_compute_obb_with_selector(self, draft_with_surfaces): # Glob matches all 6 faces (face00001-face00006) np.testing.assert_allclose(result.center, POC_ALL_FACES["center"], atol=1e-6) + def test_compute_obb_rejects_non_surface_selector(self, draft_with_surfaces): + """Passing an EdgeSelector or BodyGroupSelector should raise immediately.""" + from flow360.component.simulation.framework.entity_selector import EdgeSelector + + with pytest.raises(Exception, match="SurfaceSelector"): + draft_with_surfaces.compute_obb(EdgeSelector(name="bad").match("*")) + def test_compute_obb_no_loader_raises(self, draft_with_surfaces): """DraftContext without tessellation loader should raise on compute_obb.""" from flow360.component.simulation.draft_context.context import DraftContext From c140dabd4ada48da7155f377807a796eda0f71c9 Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Thu, 26 Mar 2026 21:06:44 -0400 Subject: [PATCH 06/18] [FXC-5651] fix: prevent self-eviction and overwrite size overestimation in CloudFileCache Two edge cases fixed: 1. Eviction now protects the resource directory currently being populated, preventing a second put (e.g., bin file) from evicting the first (manifest). 2. Overwriting an existing file accounts for net size delta instead of gross incoming size, avoiding unnecessary eviction of other resources. Co-Authored-By: Claude Opus 4.6 (1M context) --- flow360/cloud/file_cache.py | 18 +++++++++++++++--- tests/test_cloud_file_cache.py | 27 +++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/flow360/cloud/file_cache.py b/flow360/cloud/file_cache.py index b0769d98c..73878db35 100644 --- a/flow360/cloud/file_cache.py +++ b/flow360/cloud/file_cache.py @@ -66,8 +66,14 @@ def put(self, namespace: str, resource_id: str, file_path: str, data: bytes) -> return try: - self._evict_if_needed(len(data)) + # Account for the file being overwritten (net size delta, not gross) target = self._file_path(namespace, resource_id, file_path) + existing_size = target.stat().st_size if target.is_file() else 0 + net_incoming = len(data) - existing_size + + current_resource_dir = self._resource_dir(namespace, resource_id) + self._evict_if_needed(net_incoming, protect=current_resource_dir) + target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(data) self._touch_last_access(namespace, resource_id) @@ -118,8 +124,12 @@ def _collect_resource_dirs(self) -> Tuple[int, List[Tuple[float, int, Path]]]: entries.append((mtime, size, resource_dir)) return total_size, entries - def _evict_if_needed(self, incoming_bytes: int) -> None: - """Delete oldest resource dirs until total size + *incoming_bytes* fits the budget.""" + def _evict_if_needed(self, incoming_bytes: int, protect: Optional[Path] = None) -> None: + """Delete oldest resource dirs until total size + *incoming_bytes* fits the budget. + + *protect*, if given, is a resource directory that must not be evicted + (the resource currently being populated by the caller). + """ current_size, entries = self._collect_resource_dirs() if current_size + incoming_bytes <= self._max_size_bytes: return @@ -130,5 +140,7 @@ def _evict_if_needed(self, incoming_bytes: int) -> None: for _mtime, size, resource_dir in entries: if current_size + incoming_bytes <= self._max_size_bytes: break + if protect is not None and resource_dir == protect: + continue shutil.rmtree(resource_dir, ignore_errors=True) current_size -= size diff --git a/tests/test_cloud_file_cache.py b/tests/test_cloud_file_cache.py index ded94473b..3a3ce37a8 100644 --- a/tests/test_cloud_file_cache.py +++ b/tests/test_cloud_file_cache.py @@ -82,6 +82,33 @@ def test_skip_entry_exceeding_total_budget(self, tmp_path): # Existing entry was not evicted assert cache.get("ns", "small", "f.bin") == b"x" * 100 + def test_no_self_eviction_during_multi_file_put(self, tmp_path): + """Putting a second file for the same resource must not evict the first.""" + # Budget fits one resource with two files (~600 bytes) but not two resources + cache = CloudFileCache(cache_root=tmp_path / "cache", max_size_bytes=700) + cache.put("ns", "res", "manifest.json", b"m" * 300) + time.sleep(0.05) + # Second file for same resource: should NOT evict its own manifest + cache.put("ns", "res", "body.bin", b"b" * 300) + + assert cache.get("ns", "res", "manifest.json") == b"m" * 300 + assert cache.get("ns", "res", "body.bin") == b"b" * 300 + + def test_overwrite_accounts_for_existing_size(self, tmp_path): + """Overwriting a file should not over-count size pressure.""" + # Budget = 500. Put 300, then overwrite with 300 again. + # Net delta is 0, so no eviction should be needed. + cache = CloudFileCache(cache_root=tmp_path / "cache", max_size_bytes=500) + cache.put("ns", "other", "f.bin", b"o" * 200) + time.sleep(0.05) + cache.put("ns", "res", "f.bin", b"x" * 300) + time.sleep(0.05) + # Overwrite with same size — should not evict "other" + cache.put("ns", "res", "f.bin", b"y" * 300) + + assert cache.get("ns", "other", "f.bin") == b"o" * 200 + assert cache.get("ns", "res", "f.bin") == b"y" * 300 + class TestDisabled: def test_disabled_cache_returns_none(self, tmp_path): From 1e35f61276ad993311a334506a1e5457dbf1435c Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Thu, 26 Mar 2026 21:32:24 -0400 Subject: [PATCH 07/18] [FXC-5651] fix: face index collision detection + singleton CloudFileCache 1. _ensure_face_index_built now only indexes Face entries (not solids/groups) and raises ValueError on duplicate face IDs across geometries instead of silently overwriting. 2. CloudFileCache uses a module-level singleton (get_shared_cloud_file_cache) to avoid redundant directory scans across multiple create_draft calls. Co-Authored-By: Claude Opus 4.6 (1M context) --- flow360/cloud/file_cache.py | 10 ++++++++++ flow360/component/project.py | 4 ++-- .../draft_context/obb/tessellation_loader.py | 20 +++++++++++++++---- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/flow360/cloud/file_cache.py b/flow360/cloud/file_cache.py index 73878db35..2595cba2e 100644 --- a/flow360/cloud/file_cache.py +++ b/flow360/cloud/file_cache.py @@ -16,6 +16,16 @@ CLOUD_FILE_CACHE_MAX_SIZE_MB: int = 2048 # default 2 GB, user-adjustable +_shared_cache_instance: Optional["CloudFileCache"] = None + + +def get_shared_cloud_file_cache() -> "CloudFileCache": + """Return the module-level shared CloudFileCache instance (created on first call).""" + global _shared_cache_instance # pylint: disable=global-statement + if _shared_cache_instance is None: + _shared_cache_instance = CloudFileCache() + return _shared_cache_instance + class CloudFileCache: """Size-based LRU disk cache. diff --git a/flow360/component/project.py b/flow360/component/project.py index 2a9ec95f9..1b647e967 100644 --- a/flow360/component/project.py +++ b/flow360/component/project.py @@ -13,7 +13,7 @@ import typing_extensions from pydantic import PositiveInt -from flow360.cloud.file_cache import CloudFileCache +from flow360.cloud.file_cache import get_shared_cloud_file_cache from flow360.cloud.flow360_requests import ( CloneVolumeMeshRequest, LengthUnitType, @@ -305,7 +305,7 @@ def _merge_geometry_entity_info( geometry_resources.update( {geo.id: geo._webapi for geo in active_geometry_dependencies.values()} ) - tessellation_loader = TessellationFileLoader(geometry_resources, CloudFileCache()) + tessellation_loader = TessellationFileLoader(geometry_resources, get_shared_cloud_file_cache()) # Use length unit cached on Geometry during from_cloud (no extra API call) length_unit = getattr(new_run_from, "_project_length_unit", None) diff --git a/flow360/component/simulation/draft_context/obb/tessellation_loader.py b/flow360/component/simulation/draft_context/obb/tessellation_loader.py index f2d12c3e0..a660980ff 100644 --- a/flow360/component/simulation/draft_context/obb/tessellation_loader.py +++ b/flow360/component/simulation/draft_context/obb/tessellation_loader.py @@ -103,15 +103,27 @@ def _ensure_manifests_loaded(self) -> None: # ------------------------------------------------------------------ def _ensure_face_index_built(self) -> None: - """Build global face_id -> geometry_id index from cached manifests.""" + """Build global face_id -> geometry_id index from cached manifests. + + Only indexes Face entries (type == "Face") to avoid collisions from + shared structural IDs (e.g. root groups) across geometries. + Raises on duplicate face IDs across geometries. + """ if self._face_to_geometry is not None: return self._face_to_geometry = {} for geometry_id, manifest in self._manifest_cache.items(): for entry in manifest: - entry_id = entry.get("id") - if entry_id is not None: - self._face_to_geometry[entry_id] = geometry_id + if entry.get("type") != "Face": + continue + face_id = entry["id"] + existing = self._face_to_geometry.get(face_id) + if existing is not None and existing != geometry_id: + raise ValueError( + f"Duplicate face ID '{face_id}' found in geometries " + f"'{existing}' and '{geometry_id}'." + ) + self._face_to_geometry[face_id] = geometry_id # ------------------------------------------------------------------ # Bin file resolution From b091ed90c1ac22ac6c12800976a306bf938fa87a Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Thu, 26 Mar 2026 21:36:29 -0400 Subject: [PATCH 08/18] format --- flow360/component/project.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/flow360/component/project.py b/flow360/component/project.py index 1b647e967..0ac2807db 100644 --- a/flow360/component/project.py +++ b/flow360/component/project.py @@ -305,7 +305,9 @@ def _merge_geometry_entity_info( geometry_resources.update( {geo.id: geo._webapi for geo in active_geometry_dependencies.values()} ) - tessellation_loader = TessellationFileLoader(geometry_resources, get_shared_cloud_file_cache()) + tessellation_loader = TessellationFileLoader( + geometry_resources, get_shared_cloud_file_cache() + ) # Use length unit cached on Geometry during from_cloud (no extra API call) length_unit = getattr(new_run_from, "_project_length_unit", None) From ed456b367b6c0ba1b7b49a8675f56b4952746ff5 Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Thu, 26 Mar 2026 21:40:20 -0400 Subject: [PATCH 09/18] [FXC-5651] fix: handle zero extents in circularity heuristic When both perpendicular extents are zero (degenerate geometry), 0/0 produced NaN which silently fell through to default index 0. Now treats equal extents (including both-zero) as perfect circularity (ratio = 1.0). Co-Authored-By: Claude Opus 4.6 (1M context) --- flow360/component/simulation/draft_context/obb/compute.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flow360/component/simulation/draft_context/obb/compute.py b/flow360/component/simulation/draft_context/obb/compute.py index eb6a09775..b23688d32 100644 --- a/flow360/component/simulation/draft_context/obb/compute.py +++ b/flow360/component/simulation/draft_context/obb/compute.py @@ -33,7 +33,8 @@ def _select_rotation_axis_index( best_ratio = -1.0 for i in range(3): others: List[float] = [extents[j] for j in range(3) if j != i] - ratio = others[0] / others[1] if others[1] > others[0] else others[1] / others[0] + 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 From c4c9e83c133b830859adc8c581588376ca8e6df3 Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Thu, 26 Mar 2026 21:46:20 -0400 Subject: [PATCH 10/18] [FXC-5651] fix: filter out MirroredSurface in compute_obb() SurfaceSelector expansion can return MirroredSurface entities which lack private_attribute_sub_components and have no tessellation data. Now filters to Surface instances only before collecting face IDs. Co-Authored-By: Claude Opus 4.6 (1M context) --- flow360/component/simulation/draft_context/context.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/flow360/component/simulation/draft_context/context.py b/flow360/component/simulation/draft_context/context.py index 05f2d5512..1a81fffd9 100644 --- a/flow360/component/simulation/draft_context/context.py +++ b/flow360/component/simulation/draft_context/context.py @@ -420,6 +420,17 @@ class _MockEntityList: else: surface_list = entities + # Filter to Surface only (selector expansion may include MirroredSurface + # which lacks sub_components and has no tessellation data) + non_surface = [s for s in surface_list if not isinstance(s, Surface)] + if non_surface: + names = [s.name for s in non_surface] + log.warning( + f"compute_obb(): skipping {len(non_surface)} non-Surface entity(ies) " + f"(e.g. MirroredSurface) — not yet supported: {names}" + ) + surface_list = [s for s in surface_list if isinstance(s, Surface)] + # Collect face IDs from surface sub-components face_ids = [] for surface in surface_list: From 7eee5ee6a3f87222b56cc3e9e94b8945bfef6771 Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Fri, 27 Mar 2026 09:27:45 -0400 Subject: [PATCH 11/18] format Co-Authored-By: Claude Opus 4.6 (1M context) --- .../simulation/draft_context/obb/tessellation_loader.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/flow360/component/simulation/draft_context/obb/tessellation_loader.py b/flow360/component/simulation/draft_context/obb/tessellation_loader.py index a660980ff..6c2043af6 100644 --- a/flow360/component/simulation/draft_context/obb/tessellation_loader.py +++ b/flow360/component/simulation/draft_context/obb/tessellation_loader.py @@ -111,19 +111,21 @@ def _ensure_face_index_built(self) -> None: """ if self._face_to_geometry is not None: return - self._face_to_geometry = {} + # Build into local to avoid leaving partial state on error + index: Dict[str, str] = {} for geometry_id, manifest in self._manifest_cache.items(): for entry in manifest: if entry.get("type") != "Face": continue face_id = entry["id"] - existing = self._face_to_geometry.get(face_id) + existing = index.get(face_id) if existing is not None and existing != geometry_id: raise ValueError( f"Duplicate face ID '{face_id}' found in geometries " f"'{existing}' and '{geometry_id}'." ) - self._face_to_geometry[face_id] = geometry_id + index[face_id] = geometry_id + self._face_to_geometry = index # ------------------------------------------------------------------ # Bin file resolution From 9fcfa648f2c45ce1f601cf219b8da74b33c790ae Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Fri, 27 Mar 2026 10:00:30 -0400 Subject: [PATCH 12/18] [FXC-5651] refactor: deduplicate MockEntityList into shared _SelectorWrapper Both preview_selector() and compute_obb() defined identical inline dataclasses to wrap selectors for expand_entity_list_selectors. Replaced with a single module-level _SelectorWrapper. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../simulation/draft_context/context.py | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/flow360/component/simulation/draft_context/context.py b/flow360/component/simulation/draft_context/context.py index 1a81fffd9..6e6a5e711 100644 --- a/flow360/component/simulation/draft_context/context.py +++ b/flow360/component/simulation/draft_context/context.py @@ -56,6 +56,13 @@ ) +@dataclass +class _SelectorWrapper: + """Minimal wrapper to satisfy expand_entity_list_selectors's entity_list parameter.""" + + selectors: List[EntitySelector] + + def get_active_draft() -> Optional["DraftContext"]: """Return the current active draft context if any.""" return _ACTIVE_DRAFT.get() @@ -341,15 +348,9 @@ def preview_selector(self, selector: "EntitySelector", *, return_names: bool = T "Use fl.SurfaceSelector, fl.EdgeSelector, fl.VolumeSelector, or fl.BodyGroupSelector." ) - @dataclass - class MockEntityList: - """Temporary mock for EntityList to avoid metaclass constraints.""" - - selectors: List[EntitySelector] - matched_entities = expand_entity_list_selectors( registry=self._entity_registry, - entity_list=MockEntityList(selectors=[selector]), + entity_list=_SelectorWrapper(selectors=[selector]), ) if not matched_entities: @@ -403,15 +404,9 @@ def compute_obb( expand_entity_list_selectors, ) - @dataclass - class _MockEntityList: - """Temporary wrapper for EntityList to satisfy expand_entity_list_selectors.""" - - selectors: List[EntitySelector] - surface_list = expand_entity_list_selectors( registry=self._entity_registry, - entity_list=_MockEntityList(selectors=[entities]), + entity_list=_SelectorWrapper(selectors=[entities]), ) elif isinstance(entities, EntityRegistryView): surface_list = list(entities) From d336e1cd28a3d2914701285e7582bdc798722fad Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Fri, 27 Mar 2026 10:10:46 -0400 Subject: [PATCH 13/18] [FXC-5651] fix: use monkeypatch instead of chmod for write-failure test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chmod(0o000) doesn't restrict writes on Windows. Mock Path.mkdir to raise OSError instead — works cross-platform. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/test_cloud_file_cache.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/test_cloud_file_cache.py b/tests/test_cloud_file_cache.py index 3a3ce37a8..e38ead8ef 100644 --- a/tests/test_cloud_file_cache.py +++ b/tests/test_cloud_file_cache.py @@ -1,6 +1,7 @@ """Tests for CloudFileCache size-based LRU disk cache.""" import time +from pathlib import Path import pytest @@ -117,19 +118,20 @@ def test_disabled_cache_returns_none(self, tmp_path): cache.put("ns", "res", "f.bin", b"data") assert cache.get("ns", "res", "f.bin") is None - def test_put_on_read_only_fs_disables_cache(self, tmp_path): - # Point to a non-writable root - bad_root = tmp_path / "no_exist" / "deep" / "path" - bad_root.mkdir(parents=True) - bad_root.chmod(0o000) + def test_put_on_write_failure_disables_cache(self, tmp_path, monkeypatch): + """OSError during write disables the cache (cross-platform).""" + cache = CloudFileCache(cache_root=tmp_path / "cache", max_size_bytes=1024) + + original_mkdir = Path.mkdir + + def failing_mkdir(self, *args, **kwargs): + raise OSError("simulated disk write failure") + + monkeypatch.setattr(Path, "mkdir", failing_mkdir) - cache = CloudFileCache(cache_root=bad_root / "cache", max_size_bytes=1024) cache.put("ns", "res", "f.bin", b"data") assert cache._disabled - # Cleanup: restore permissions so tmp_path cleanup succeeds - bad_root.chmod(0o755) - class TestLastAccess: def test_put_creates_last_access_sentinel(self, cache, tmp_path): From 78232b3c3b35c342ed1953e5427cfb85dbaf0db6 Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Fri, 27 Mar 2026 10:23:33 -0400 Subject: [PATCH 14/18] [FXC-5651] fix: descriptive error for unknown face IDs in tessellation lookup Bare KeyError on missing face ID gave no context. Now reports the face ID and lists available geometry IDs to help diagnose mismatches between entity info and manifest content. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../simulation/draft_context/obb/tessellation_loader.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/flow360/component/simulation/draft_context/obb/tessellation_loader.py b/flow360/component/simulation/draft_context/obb/tessellation_loader.py index 6c2043af6..6dd5a4f20 100644 --- a/flow360/component/simulation/draft_context/obb/tessellation_loader.py +++ b/flow360/component/simulation/draft_context/obb/tessellation_loader.py @@ -71,7 +71,12 @@ def load_vertices(self, face_ids: List[str], lod_level: Optional[int] = None) -> # Group face_ids by geometry geometry_faces: Dict[str, List[str]] = {} for fid in face_ids: - geometry_id = self._face_to_geometry[fid] + geometry_id = self._face_to_geometry.get(fid) + if geometry_id is None: + raise KeyError( + f"Face ID '{fid}' not found in any loaded geometry manifest. " + f"Available geometries: {list(self._resources.keys())}" + ) geometry_faces.setdefault(geometry_id, []).append(fid) all_vertices: List[np.ndarray] = [] From 52f738ccab3a5696253ba3ace52a27f39bea927b Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Fri, 27 Mar 2026 10:33:21 -0400 Subject: [PATCH 15/18] [FXC-5651] fix: clear error when faces yield no vertex data np.concatenate on empty list crashes with unhelpful ValueError. Now raises a descriptive error when all requested faces have empty buffer locations (degenerate geometry). Co-Authored-By: Claude Opus 4.6 (1M context) --- flow360/component/simulation/draft_context/obb/uvf_parser.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/flow360/component/simulation/draft_context/obb/uvf_parser.py b/flow360/component/simulation/draft_context/obb/uvf_parser.py index 728f9261e..248a53ca8 100644 --- a/flow360/component/simulation/draft_context/obb/uvf_parser.py +++ b/flow360/component/simulation/draft_context/obb/uvf_parser.py @@ -96,6 +96,11 @@ def extract_face_vertices( # pylint: disable=too-many-locals end_vertex = location["endIndex"] // 3 all_vertices.append(position_array[start_vertex:end_vertex]) + if not all_vertices: + raise ValueError( + f"No vertex data found for face_ids {face_ids}. " + f"The faces may have empty buffer locations." + ) return np.concatenate(all_vertices, axis=0) From aa4f393c5771a1795fe14d99cf9458a0f746b989 Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Fri, 27 Mar 2026 10:57:50 -0400 Subject: [PATCH 16/18] [FXC-5651] fix: consistent cache size accounting + EntityRegistryView type validation 1. _collect_resource_dirs now excludes .last_access sentinel from size calculation, matching put()'s net_incoming accounting. 2. compute_obb() rejects non-Surface EntityRegistryView upfront (e.g. draft.edges) with a clear error instead of falling through to "No face IDs collected". Co-Authored-By: Claude Opus 4.6 (1M context) --- flow360/cloud/file_cache.py | 6 +++++- flow360/component/simulation/draft_context/context.py | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/flow360/cloud/file_cache.py b/flow360/cloud/file_cache.py index 2595cba2e..dd2898ac4 100644 --- a/flow360/cloud/file_cache.py +++ b/flow360/cloud/file_cache.py @@ -129,7 +129,11 @@ def _collect_resource_dirs(self) -> Tuple[int, List[Tuple[float, int, Path]]]: continue last_access = resource_dir / ".last_access" mtime = last_access.stat().st_mtime if last_access.exists() else 0.0 - size = sum(f.stat().st_size for f in resource_dir.rglob("*") if f.is_file()) + size = sum( + f.stat().st_size + for f in resource_dir.rglob("*") + if f.is_file() and f.name != ".last_access" + ) total_size += size entries.append((mtime, size, resource_dir)) return total_size, entries diff --git a/flow360/component/simulation/draft_context/context.py b/flow360/component/simulation/draft_context/context.py index 6e6a5e711..276e7c62d 100644 --- a/flow360/component/simulation/draft_context/context.py +++ b/flow360/component/simulation/draft_context/context.py @@ -409,6 +409,11 @@ def compute_obb( entity_list=_SelectorWrapper(selectors=[entities]), ) elif isinstance(entities, EntityRegistryView): + if hasattr(entities, "_entity_type") and not issubclass(entities._entity_type, Surface): + raise Flow360ValueError( + f"compute_obb() requires a Surface view, " + f"got EntityRegistryView of {entities._entity_type.__name__}." + ) surface_list = list(entities) elif isinstance(entities, Surface): surface_list = [entities] From f5009cc4af68064235d08eb1f71204fdadf549cc Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Fri, 27 Mar 2026 11:18:04 -0400 Subject: [PATCH 17/18] [FXC-5651] fix: type validation for else branch, path traversal guard, cleanup getattr 1. compute_obb() else branch now raises Flow360ValueError for unexpected input types instead of silently treating them as a list. 2. CloudFileCache._file_path validates resolved path stays under cache_root, preventing path traversal via malicious file_path keys. 3. Replaced getattr fallback with direct _project_length_unit access since Geometry.__init__ always initializes it. Co-Authored-By: Claude Opus 4.6 (1M context) --- flow360/cloud/file_cache.py | 5 ++++- flow360/component/project.py | 2 +- flow360/component/simulation/draft_context/context.py | 7 ++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/flow360/cloud/file_cache.py b/flow360/cloud/file_cache.py index dd2898ac4..0b920280d 100644 --- a/flow360/cloud/file_cache.py +++ b/flow360/cloud/file_cache.py @@ -96,7 +96,10 @@ def put(self, namespace: str, resource_id: str, file_path: str, data: bytes) -> # ------------------------------------------------------------------ def _file_path(self, namespace: str, resource_id: str, file_path: str) -> Path: - return self._cache_root / namespace / resource_id / file_path + target = (self._cache_root / namespace / resource_id / file_path).resolve() + if not target.is_relative_to(self._cache_root.resolve()): + raise ValueError(f"Path traversal detected in cache key: {file_path!r}") + return target def _resource_dir(self, namespace: str, resource_id: str) -> Path: return self._cache_root / namespace / resource_id diff --git a/flow360/component/project.py b/flow360/component/project.py index 0ac2807db..b27011a5e 100644 --- a/flow360/component/project.py +++ b/flow360/component/project.py @@ -310,7 +310,7 @@ def _merge_geometry_entity_info( ) # Use length unit cached on Geometry during from_cloud (no extra API call) - length_unit = getattr(new_run_from, "_project_length_unit", None) + length_unit = new_run_from._project_length_unit return DraftContext( entity_info=entity_info_copy, diff --git a/flow360/component/simulation/draft_context/context.py b/flow360/component/simulation/draft_context/context.py index 276e7c62d..5ce690110 100644 --- a/flow360/component/simulation/draft_context/context.py +++ b/flow360/component/simulation/draft_context/context.py @@ -417,8 +417,13 @@ def compute_obb( surface_list = list(entities) elif isinstance(entities, Surface): surface_list = [entities] - else: + elif isinstance(entities, list): surface_list = entities + else: + raise Flow360ValueError( + f"compute_obb() expected Surface, List[Surface], EntityRegistryView, " + f"or SurfaceSelector, got {type(entities).__name__}." + ) # Filter to Surface only (selector expansion may include MirroredSurface # which lacks sub_components and has no tessellation data) From e32bf53de3cc5190473cbb4096259292cefd4b44 Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Fri, 27 Mar 2026 11:36:47 -0400 Subject: [PATCH 18/18] [FXC-5651] fix: safe .name access in non-Surface warning log Use getattr with fallback for .name in the warning message so objects without a name attribute don't cause AttributeError during filtering. Co-Authored-By: Claude Opus 4.6 (1M context) --- flow360/component/simulation/draft_context/context.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/flow360/component/simulation/draft_context/context.py b/flow360/component/simulation/draft_context/context.py index 5ce690110..ea44019f8 100644 --- a/flow360/component/simulation/draft_context/context.py +++ b/flow360/component/simulation/draft_context/context.py @@ -361,7 +361,7 @@ def preview_selector(self, selector: "EntitySelector", *, return_names: bool = T return [entity.name for entity in matched_entities] return matched_entities - def compute_obb( + def compute_obb( # pylint:disable=too-many-branches self, entities: Union[Surface, List[Surface], EntityRegistryView, EntitySelector], *, @@ -409,6 +409,7 @@ def compute_obb( entity_list=_SelectorWrapper(selectors=[entities]), ) elif isinstance(entities, EntityRegistryView): + # pylint:disable = protected-access if hasattr(entities, "_entity_type") and not issubclass(entities._entity_type, Surface): raise Flow360ValueError( f"compute_obb() requires a Surface view, " @@ -429,7 +430,7 @@ def compute_obb( # which lacks sub_components and has no tessellation data) non_surface = [s for s in surface_list if not isinstance(s, Surface)] if non_surface: - names = [s.name for s in non_surface] + names = [getattr(s, "name", type(s).__name__) for s in non_surface] log.warning( f"compute_obb(): skipping {len(non_surface)} non-Surface entity(ies) " f"(e.g. MirroredSurface) — not yet supported: {names}"