diff --git a/flow360/cloud/file_cache.py b/flow360/cloud/file_cache.py new file mode 100644 index 000000000..0b920280d --- /dev/null +++ b/flow360/cloud/file_cache.py @@ -0,0 +1,163 @@ +"""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 + +_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. + + 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 + + # Skip caching entries that exceed the entire cache budget + if len(data) > self._max_size_bytes: + return + + try: + # 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) + 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: + 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 + + 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() and f.name != ".last_access" + ) + total_size += size + entries.append((mtime, size, resource_dir)) + return total_size, entries + + 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 + + # 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 + if protect is not None and resource_dir == protect: + continue + 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..b27011a5e 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 get_shared_cloud_file_cache 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,30 @@ 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, get_shared_cloud_file_cache() + ) + + # Use length unit cached on Geometry during from_cloud (no extra API call) + length_unit = new_run_from._project_length_unit + 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..ea44019f8 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", @@ -51,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() @@ -77,6 +89,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 +105,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 +124,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). @@ -328,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: @@ -347,4 +361,117 @@ class MockEntityList: return [entity.name for entity in matched_entities] return matched_entities + def compute_obb( # pylint:disable=too-many-branches + 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. + + Args: + 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, axis_of_rotation, and radius as properties. + + 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): + 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, + ) + + surface_list = expand_entity_list_selectors( + registry=self._entity_registry, + 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, " + f"got EntityRegistryView of {entities._entity_type.__name__}." + ) + surface_list = list(entities) + elif isinstance(entities, Surface): + surface_list = [entities] + 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) + non_surface = [s for s in surface_list if not isinstance(s, Surface)] + if 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}" + ) + 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: + 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, rotation_axis_hint=rotation_axis_hint) + + # 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, + axis_of_rotation=result.axis_of_rotation, + radius=result.radius * 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..b23688d32 --- /dev/null +++ b/flow360/component/simulation/draft_context/obb/compute.py @@ -0,0 +1,125 @@ +"""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 + + +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] + larger = max(others[0], others[1]) + ratio = min(others[0], others[1]) / larger if larger > 0 else 1.0 + if ratio > best_ratio: + best_ratio = ratio + best_index = i + return best_index + + +@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 compute_obb( # pylint:disable = too-many-locals + 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, extents, axis_of_rotation, and radius. + """ + 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 + + # 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/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..6dd5a4f20 --- /dev/null +++ b/flow360/component/simulation/draft_context/obb/tessellation_loader.py @@ -0,0 +1,209 @@ +"""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.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] = [] + 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. + + 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 + # 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 = 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}'." + ) + index[face_id] = geometry_id + self._face_to_geometry = index + + # ------------------------------------------------------------------ + # 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..248a53ca8 --- /dev/null +++ b/flow360/component/simulation/draft_context/obb/uvf_parser.py @@ -0,0 +1,122 @@ +"""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]) + + 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) + + +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 000000000..7ef6f0319 Binary files /dev/null and b/tests/data/tessellation/geo-1/visualize/manifest/body00001.bin differ 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..94f714f39 --- /dev/null +++ b/tests/simulation/draft_context/test_obb_compute.py @@ -0,0 +1,169 @@ +"""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, + _select_rotation_axis_index, + 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) + + for i in range(3): + np.testing.assert_allclose(np.linalg.norm(obb.axes[i]), 1.0, atol=1e-10) + + 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) + + 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 + + +# --------------------------------------------------------------------------- +# Rotation axis selection tests +# --------------------------------------------------------------------------- + + +class TestRotationAxisSelection: + def test_hint_selects_closest_axis(self): + 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): + 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.""" + 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): + 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 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): + 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, 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 new file mode 100644 index 000000000..539084f6b --- /dev/null +++ b/tests/simulation/draft_context/test_obb_e2e.py @@ -0,0 +1,257 @@ +"""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_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 + + # 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 + assert isinstance(result.radius, unyt.unyt_quantity) + + # 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) 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..e38ead8ef --- /dev/null +++ b/tests/test_cloud_file_cache.py @@ -0,0 +1,148 @@ +"""Tests for CloudFileCache size-based LRU disk cache.""" + +import time +from pathlib import Path + +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 + + 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 + + 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): + 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_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.put("ns", "res", "f.bin", b"data") + assert cache._disabled + + +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