From ba3349f80394bdc9f0b0ed84122630b0ae6205eb Mon Sep 17 00:00:00 2001 From: krande Date: Fri, 3 Jul 2026 20:19:28 +0200 Subject: [PATCH 001/151] feat: blob-backed ShapeStore + lazy ShapeProxy for imported shape geometry Large STEP/IFC imports materialised as ada.geom trees cost ~10x their serialized size in resident memory (Munin crane: 2.6 GB settled vs 275 MB of NGEOM blobs). ShapeStore keeps each solid as one compact blob (NGEOM buffers retained zero-copy as they arrive from adacpp; Python-built geometry pickled losslessly incl. bool_operations) and hydrates the Geometry tree on demand behind a WeakValueDictionary, mirroring the FEM array-substrate design. ShapeProxy subclasses Shape, holds only (store, index), and exposes the raw blob for adacpp fast paths. Co-Authored-By: Claude Fable 5 --- src/ada/api/shapes/__init__.py | 4 + src/ada/api/shapes/proxies.py | 84 +++++++++ src/ada/api/shapes/store.py | 211 ++++++++++++++++++++++ tests/core/api/shapes/__init__.py | 0 tests/core/api/shapes/test_shape_store.py | 184 +++++++++++++++++++ 5 files changed, 483 insertions(+) create mode 100644 src/ada/api/shapes/__init__.py create mode 100644 src/ada/api/shapes/proxies.py create mode 100644 src/ada/api/shapes/store.py create mode 100644 tests/core/api/shapes/__init__.py create mode 100644 tests/core/api/shapes/test_shape_store.py diff --git a/src/ada/api/shapes/__init__.py b/src/ada/api/shapes/__init__.py new file mode 100644 index 000000000..72130502b --- /dev/null +++ b/src/ada/api/shapes/__init__.py @@ -0,0 +1,4 @@ +from .proxies import ShapeProxy +from .store import ShapeRecord, ShapeStore + +__all__ = ["ShapeProxy", "ShapeRecord", "ShapeStore"] diff --git a/src/ada/api/shapes/proxies.py b/src/ada/api/shapes/proxies.py new file mode 100644 index 000000000..f47950de7 --- /dev/null +++ b/src/ada/api/shapes/proxies.py @@ -0,0 +1,84 @@ +"""Lazy shape proxies over a :class:`~ada.api.shapes.store.ShapeStore`. + +``ShapeProxy`` subclasses ``Shape`` so every ``isinstance`` check, container and +export path keeps working, but it does not hold an ``ada.geom`` tree: ``.geom`` +hydrates from the store's compact blob on access (weakref-cached there), so a +7k-solid import costs blobs + small records instead of millions of resident +dataclass instances. Unlike the FEM ``NodeProxy`` (millions of rows, base init +skipped), shape counts are ~10k per model, so the proxy runs the normal +``Shape.__init__`` and keeps material/placement/guid/pickle behaviour intact. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ada.api.primitives.base import Shape +from ada.base.units import Units + +if TYPE_CHECKING: + from ada.geom.core import Geometry + + from .store import ShapeStore + + +class ShapeProxy(Shape): + def __init__(self, name, store: ShapeStore, index: int, **shape_kwargs): + super().__init__(name, geom=None, **shape_kwargs) + self._shape_store = store + self._store_index = int(index) + self._pinned_geom = None + + @property + def geom(self) -> Geometry: + """The hydrated geometry. Transient unless pinned: edits to the returned tree + are lost when the GC reclaims it — call :meth:`pin` (or assign ``.geom``) + before mutating.""" + if self._pinned_geom is not None: + return self._pinned_geom + return self._shape_store.geometry(self._store_index) + + @geom.setter + def geom(self, value: Geometry): + # Pin, never re-serialize: the NGEOM encoder silently skips unsupported + # kinds, so writing back into the store could lose geometry. + self._pinned_geom = value + + def pin(self) -> Geometry: + """Hydrate and hold a strong reference so subsequent mutation sticks.""" + if self._pinned_geom is None: + self._pinned_geom = self._shape_store.geometry(self._store_index) + return self._pinned_geom + + def ngeom_blob(self): + """The stored NGEOM buffer, for consumers that feed adacpp directly + (tessellation, stream export) without hydrating. ``None`` when the shape + was stored from Python-built geometry (pickle kind).""" + return self._shape_store.ngeom_blob(self._store_index) + + @property + def units(self): + return self._units + + @units.setter + def units(self, value): + # Mirrors Shape.units but treats the proxy as always-having-geometry: the + # base setter keys on ``self._geom is not None``, which is always False + # here and would silently skip the scaling. + if isinstance(value, str): + value = Units.from_str(value) + if value != self._units: + from ada.occ.utils import transform_shape + + scale_factor = Units.get_scale_factor(self._units, value) + # transform_shape returns an OCC body; after a unit conversion the OCC + # cache is the source of truth, exactly as on the base class. + self._occ_cache = transform_shape(self.solid_occ(), scale_factor) + + if self.metadata.get("ifc_source") is True: + raise NotImplementedError() + + self._units = value + + def __repr__(self): + return f'{self.__class__.__name__}("{self.name}", store_index={self._store_index})' diff --git a/src/ada/api/shapes/store.py b/src/ada/api/shapes/store.py new file mode 100644 index 000000000..819e95ed7 --- /dev/null +++ b/src/ada/api/shapes/store.py @@ -0,0 +1,211 @@ +"""Buffer-backed storage for imported shape geometry. + +A large STEP/IFC import materialised as ``ada.geom`` object trees costs ~100x its +compact serialized form in resident memory (millions of small dataclass instances, +each with a ``__dict__``). ``ShapeStore`` keeps each solid as one compact blob and +hydrates the ``ada.geom.Geometry`` tree only when a consumer actually asks for it, +mirroring the FEM array-substrate design (``ada.api.mesh.store.MeshArrays``): flat +shared storage + transient proxies (``ada.api.shapes.proxies.ShapeProxy``), with a +``WeakValueDictionary`` cache so repeated access within a live scope is free and +memory returns to the blob floor when hydrated trees are dropped. + +Two blob kinds: + +- ``"ngeom"`` — an NGEOM buffer as produced by the adacpp native readers + (``StepNgeomStream``). Retained exactly as it arrives (no ``bytes()`` round-trip, + no arena append — the buffer crossing the C++ boundary is the stored object, so + the transfer stays zero-copy). These blobs double as the tessellation/export + fast path: adacpp consumes them directly, skipping hydrate + re-serialize. +- ``"pickle"`` — Python-built ``ada.geom`` geometry (the IFC reader, API shapes). + The NGEOM solid encoders lower parametric solids (Box -> extruded rectangle, + parametric profiles -> arbitrary outlines), so round-tripping Python-built + geometry through NGEOM would be lossy; pickle keeps profiles, placements and + ``bool_operations`` exact at a comparable byte size. + +The small per-shape metadata that must stay eagerly queryable (id, color, instance +transforms/paths) lives in ``ShapeRecord`` next to the blob, not inside it — the +same split the native stream readers already make (``StepRootMeta``). +""" + +from __future__ import annotations + +import pickle +import weakref +import zlib +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from ada.geom.core import Geometry + +if TYPE_CHECKING: + import numpy as np + + from ada.geom.booleans import BooleanOperation + from ada.visit.colors import Color + +_NGEOM_MAGIC = b"ADANGEOM" + + +@dataclass(slots=True) +class ShapeRecord: + """Eager per-shape metadata — one small record per stored solid.""" + + gid: str + kind: str # "ngeom" | "pickle" + color: Color | None = None + transforms: list[np.ndarray] | None = None + instance_paths: list[tuple] | None = None + compressed: bool = False + + +class ShapeStore: + """Holds many shapes' geometry as compact blobs; hydrates on demand. + + One store per import (``from_step``/``from_ifc`` call). Proxies keep a strong + reference to the store, so a surviving proxy keeps the (compact) blobs alive; + hydrated ``Geometry`` trees are only weakly cached and are reclaimed by the GC + as soon as no consumer holds them. + """ + + __slots__ = ("_blobs", "_records", "_geom_cache", "compress", "__weakref__") + + def __init__(self, compress: bool = False): + self._blobs: list[object] = [] + self._records: list[ShapeRecord] = [] + # Mirrors MeshArrays._proxy_cache: same live object for repeated access, + # GC'd when the last outside reference drops. + self._geom_cache: weakref.WeakValueDictionary[int, Geometry] = weakref.WeakValueDictionary() + self.compress = compress + + def __len__(self) -> int: + return len(self._records) + + # --- ingest ------------------------------------------------------------------------- + + def add_blob( + self, + blob, + *, + gid: str, + color: Color | None = None, + transforms: list[np.ndarray] | None = None, + instance_paths: list[tuple] | None = None, + ) -> int: + """Retain an NGEOM buffer (bytes/memoryview/uint8 ndarray) as-arrived. + + The buffer object itself is stored — no copy — unless ``compress`` is on + (compression inherently copies; that is the flag's accepted trade-off). + """ + head = bytes(memoryview(blob)[:8]) + if head != _NGEOM_MAGIC: + raise ValueError(f"not an NGEOM buffer (magic={head!r}) for {gid!r}") + compressed = False + if self.compress: + blob = zlib.compress(bytes(blob), 1) + compressed = True + self._blobs.append(blob) + self._records.append( + ShapeRecord( + gid=gid, + kind="ngeom", + color=color, + transforms=transforms, + instance_paths=instance_paths, + compressed=compressed, + ) + ) + return len(self._records) - 1 + + def add_geometry(self, geometry: Geometry) -> int: + """Store a Python-built ``Geometry`` losslessly (pickle kind) and drop the tree. + + The wrapper's metadata moves to the record; the pickled payload is the inner + geometry + ``bool_operations`` (which pickle round-trips exactly, including + half-space operands and parametric profiles the NGEOM codec would lower). + """ + payload = pickle.dumps((geometry.geometry, geometry.bool_operations), protocol=pickle.HIGHEST_PROTOCOL) + compressed = False + if self.compress: + payload = zlib.compress(payload, 1) + compressed = True + self._blobs.append(payload) + self._records.append( + ShapeRecord( + gid=str(geometry.id), + kind="pickle", + color=geometry.color, + transforms=geometry.transforms, + instance_paths=geometry.instance_paths, + compressed=compressed, + ) + ) + return len(self._records) - 1 + + # --- access ------------------------------------------------------------------------- + + def record(self, index: int) -> ShapeRecord: + return self._records[index] + + def ngeom_blob(self, index: int): + """The raw NGEOM buffer for adacpp fast-path consumers (tessellation, stream + export) — no hydration. ``None`` for pickle-kind records (those consumers + hydrate via :meth:`geometry` and serialize with booleans on the fly).""" + rec = self._records[index] + if rec.kind != "ngeom": + return None + blob = self._blobs[index] + if rec.compressed: + return zlib.decompress(blob) + return blob + + def geometry(self, index: int) -> Geometry: + """Hydrate the full ``ada.geom.Geometry`` for one shape (weakref-cached).""" + geom = self._geom_cache.get(index) + if geom is not None: + return geom + rec = self._records[index] + bool_ops: list[BooleanOperation] = [] + if rec.kind == "ngeom": + from ada.cadit.ngeom.deserialize import deserialize_geometries + + roots = deserialize_geometries(self.ngeom_blob(index)) + if not roots: + raise ValueError(f"NGEOM blob for {rec.gid!r} decoded to zero roots") + inner = roots[0][1] + else: + payload = self._blobs[index] + if rec.compressed: + payload = zlib.decompress(payload) + inner, bool_ops = pickle.loads(payload) + geom = Geometry( + id=rec.gid, + geometry=inner, + color=rec.color, + bool_operations=list(bool_ops), + transforms=rec.transforms, + instance_paths=rec.instance_paths, + ) + self._geom_cache[index] = geom + return geom + + # --- diagnostics / pickling ----------------------------------------------------------- + + @property + def nbytes(self) -> int: + """Total stored blob bytes (as-held, i.e. compressed size when compressed).""" + return sum(memoryview(b).nbytes for b in self._blobs) + + def __getstate__(self): + # Buffer views (e.g. capsule-backed ndarrays from adacpp) coerce to bytes — + # the one accepted pickle-time copy. The weak cache never travels. + return { + "blobs": [b if isinstance(b, bytes) else bytes(b) for b in self._blobs], + "records": self._records, + "compress": self.compress, + } + + def __setstate__(self, state): + self._blobs = state["blobs"] + self._records = state["records"] + self.compress = state["compress"] + self._geom_cache = weakref.WeakValueDictionary() diff --git a/tests/core/api/shapes/__init__.py b/tests/core/api/shapes/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/core/api/shapes/test_shape_store.py b/tests/core/api/shapes/test_shape_store.py new file mode 100644 index 000000000..1d19749df --- /dev/null +++ b/tests/core/api/shapes/test_shape_store.py @@ -0,0 +1,184 @@ +"""ShapeStore + ShapeProxy: blob-backed lazy shape geometry. + +Parity is asserted via re-serialization bytes (NGEOM) or field checks — Geometry +dataclass ``==`` is unusable because Point's ndarray ``__eq__`` breaks dataclass +equality. +""" + +from __future__ import annotations + +import gc +import pickle +import weakref + +import ada.geom.curves as cu +import ada.geom.solids as so +import ada.geom.surfaces as su +from ada.api.primitives.base import Shape +from ada.api.shapes import ShapeProxy, ShapeStore +from ada.cadit.ngeom import serialize_geometries +from ada.geom.booleans import BooleanOperation, BoolOpEnum +from ada.geom.core import Geometry +from ada.geom.placement import Axis2Placement3D, Direction, Point + + +def _line_oe(s, t): + ec = cu.EdgeCurve(start=s, end=t, edge_geometry=cu.Line(s, [b - a for a, b in zip(s, t)]), same_sense=True) + return cu.OrientedEdge(start=s, end=t, edge_element=ec, orientation=True) + + +def _square_face(): + p = [(0, 0, 0), (2, 0, 0), (2, 2, 0), (0, 2, 0)] + loop = cu.EdgeLoop(edge_list=[_line_oe(p[i], p[(i + 1) % 4]) for i in range(4)]) + plane = su.Plane(position=Axis2Placement3D(Point(0, 0, 0), Direction(0, 0, 1), Direction(1, 0, 0))) + return su.FaceSurface(bounds=[su.FaceBound(bound=loop, orientation=True)], face_surface=plane, same_sense=True) + + +def _shell_geometry(gid="solid1") -> Geometry: + return Geometry(id=gid, geometry=su.ClosedShell(cfs_faces=[_square_face()])) + + +def _boolean_geometry(gid="cut1") -> Geometry: + base = _shell_geometry(gid) + half_space = su.HalfSpaceSolid( + base_surface=su.Plane(position=Axis2Placement3D(Point(0, 0, 1), Direction(0, 0, 1), Direction(1, 0, 0))) + ) + box = so.Box(Axis2Placement3D(Point(0, 0, 0), Direction(0, 0, 1), Direction(1, 0, 0)), 1.0, 1.0, 1.0) + base.bool_operations = [ + BooleanOperation(Geometry("hs", half_space), BoolOpEnum.DIFFERENCE), + BooleanOperation(Geometry("box", box), BoolOpEnum.UNION), + ] + return base + + +def _ngeom_blob(geom: Geometry) -> bytes: + return serialize_geometries([(str(geom.id), geom.geometry)]) + + +def test_ngeom_blob_roundtrip_byte_parity(): + """ngeom-kind: hydrated tree re-serializes to the exact stored bytes.""" + g = _shell_geometry() + blob = _ngeom_blob(g) + store = ShapeStore() + idx = store.add_blob(blob, gid=str(g.id)) + hydrated = store.geometry(idx) + assert hydrated.id == "solid1" + assert serialize_geometries([(hydrated.id, hydrated.geometry)]) == blob + + +def test_add_blob_rejects_non_ngeom(): + store = ShapeStore() + try: + store.add_blob(b"not a buffer at all", gid="x") + except ValueError as e: + assert "magic" in str(e) + else: + raise AssertionError("expected ValueError on bad magic") + + +def test_pickle_kind_roundtrips_bool_operations(): + """pickle-kind: booleans (incl. half-space operands) hydrate exactly.""" + g = _boolean_geometry() + store = ShapeStore() + idx = store.add_geometry(g) + hydrated = store.geometry(idx) + assert hydrated.id == "cut1" + assert isinstance(hydrated.geometry, su.ClosedShell) + ops = hydrated.bool_operations + assert [op.operator for op in ops] == [BoolOpEnum.DIFFERENCE, BoolOpEnum.UNION] + hs = ops[0].second_operand.geometry + assert isinstance(hs, su.HalfSpaceSolid) + assert hs.agreement_flag is True + assert list(hs.base_surface.position.location) == [0.0, 0.0, 1.0] + box = ops[1].second_operand.geometry + assert isinstance(box, so.Box) + assert (box.x_length, box.y_length, box.z_length) == (1.0, 1.0, 1.0) + + +def test_weakref_cache_identity_and_release(): + store = ShapeStore() + idx = store.add_geometry(_shell_geometry()) + g1 = store.geometry(idx) + assert store.geometry(idx) is g1, "same live object while referenced" + ref = weakref.ref(g1) + del g1 + gc.collect() + assert ref() is None, "hydrated tree must be reclaimable once dropped" + # and a fresh access hydrates again + assert store.geometry(idx).id == "solid1" + + +def test_compression_roundtrip_both_kinds(): + g = _shell_geometry() + blob = _ngeom_blob(g) + store = ShapeStore(compress=True) + i_ngeom = store.add_blob(blob, gid=str(g.id)) + i_pickle = store.add_geometry(_boolean_geometry()) + assert store.record(i_ngeom).compressed and store.record(i_pickle).compressed + assert store.nbytes < len(blob) + 100_000 # stored compressed + assert store.ngeom_blob(i_ngeom) == blob # decompresses to the original + assert store.geometry(i_pickle).bool_operations[0].operator == BoolOpEnum.DIFFERENCE + + +def test_proxy_is_shape_and_hydrates_via_property(): + store = ShapeStore() + g = _shell_geometry() + idx = store.add_blob(_ngeom_blob(g), gid=str(g.id)) + p = ShapeProxy("solid1", store, idx) + assert isinstance(p, Shape) + assert p._geom is None, "proxy must not hold an eager tree" + # NGEOM lowers ClosedShell -> ConnectedFaceSet; today's eager native reader + # yields the same, so downstream behaviour is identical. + assert isinstance(p.geom.geometry, su.ConnectedFaceSet) + assert p.ngeom_blob() is not None + + # solid_geom() (a base-class method) works through the overridden property on + # an accepted solid type — pickle-kind keeps the ClosedShell exact. + idx2 = store.add_geometry(_shell_geometry("solid2")) + p2 = ShapeProxy("solid2", store, idx2) + solid = p2.solid_geom() + assert solid.id == "solid2" + assert isinstance(solid.geometry, su.ClosedShell) + + +def test_proxy_pin_semantics(): + store = ShapeStore() + idx = store.add_geometry(_shell_geometry()) + p = ShapeProxy("solid1", store, idx) + + # unpinned: mutation on a transient hydration does not survive a GC cycle + p.geom.color = "marker" + gc.collect() + assert p.geom.color is None + + pinned = p.pin() + pinned.color = "marker" + gc.collect() + assert p.geom.color == "marker" + + # assigning .geom pins the assigned object + other = _shell_geometry("other") + p.geom = other + assert p.geom is other + + +def test_proxy_pickle_shares_store(): + store = ShapeStore() + g = _shell_geometry() + blob = _ngeom_blob(g) + idx1 = store.add_blob(blob, gid="a") + idx2 = store.add_blob(blob, gid="b") + p1 = ShapeProxy("a", store, idx1) + p2 = ShapeProxy("b", store, idx2) + r1, r2 = pickle.loads(pickle.dumps([p1, p2])) + assert r1._shape_store is r2._shape_store, "pickle memo must keep one store" + assert r1.geom.id == "a" and r2.geom.id == "b" + assert isinstance(r1, ShapeProxy) and isinstance(r1, Shape) + + +def test_pickle_kind_has_no_ngeom_blob(): + store = ShapeStore() + idx = store.add_geometry(_boolean_geometry()) + assert store.ngeom_blob(idx) is None + p = ShapeProxy("cut1", store, idx) + assert p.ngeom_blob() is None From 124198ad1ddcf0c2955e61d58879bb5c517636d8 Mon Sep 17 00:00:00 2001 From: krande Date: Fri, 3 Jul 2026 20:40:51 +0200 Subject: [PATCH 002/151] feat: lazy blob-backed STEP import (ShapeStore default-on) + native-first auto reader - Config cad.lazy_shape_store (default on, ADA_CAD_LAZY_SHAPE_STORE=false to opt out) + cad.shape_store_compress: _read_step_streaming stores each solid as its NGEOM blob (native path, zero hydration at import) or pickled ada.geom tree (pure-Python path) and mints ShapeProxy objects. - reader='auto' now probes the native adacpp parser first (matching iter_from_step), with a first-solid hydrate probe and pure-Python fallback; StepReader.NATIVE + from_step Literal accept 'native' explicitly. - native_stream_read_step_blobs: per-solid (blob, meta) without hydration; native_stream_read_step reimplemented on top. - ConnectedFaceSet becomes a first-class B-rep root: solid_geom() accepts it, the OCC builder builds it, and NGEOM hydration promotes topologically closed face-sets back to ClosedShell (edge-pairing check, value-keyed). - AP242 stream writer shares EDGE_CURVEs by value signature instead of object identity, so NGEOM-hydrated trees (which decode one object per reference) re-emit watertight solids identical to Python-reader trees. Co-Authored-By: Claude Fable 5 --- src/ada/api/primitives/base.py | 1 + src/ada/api/shapes/store.py | 9 +- src/ada/api/spatial/part.py | 111 ++++++++++++++++++----- src/ada/cad/registry.py | 4 +- src/ada/cadit/ngeom/deserialize.py | 75 +++++++++++++++ src/ada/cadit/step/read/native_reader.py | 27 ++++-- src/ada/cadit/step/write/ap242_stream.py | 42 ++++++++- src/ada/config.py | 16 ++++ src/ada/factories.py | 2 +- src/ada/occ/geom/__init__.py | 3 + src/ada/occ/geom/surfaces.py | 18 ++++ 11 files changed, 269 insertions(+), 39 deletions(-) diff --git a/src/ada/api/primitives/base.py b/src/ada/api/primitives/base.py index e776bfde7..aa8025735 100644 --- a/src/ada/api/primitives/base.py +++ b/src/ada/api/primitives/base.py @@ -177,6 +177,7 @@ def solid_geom(self) -> Geometry: geo_su.AdvancedFace, geo_su.ClosedShell, geo_su.OpenShell, + geo_su.ConnectedFaceSet, # the native NGEOM reader's B-rep root form geo_su.ShellBasedSurfaceModel, geo_so.Box, geo_so.Sphere, diff --git a/src/ada/api/shapes/store.py b/src/ada/api/shapes/store.py index 819e95ed7..ef64cf796 100644 --- a/src/ada/api/shapes/store.py +++ b/src/ada/api/shapes/store.py @@ -166,12 +166,17 @@ def geometry(self, index: int) -> Geometry: rec = self._records[index] bool_ops: list[BooleanOperation] = [] if rec.kind == "ngeom": - from ada.cadit.ngeom.deserialize import deserialize_geometries + from ada.cadit.ngeom.deserialize import ( + deserialize_geometries, + promote_closed_shell, + ) roots = deserialize_geometries(self.ngeom_blob(index)) if not roots: raise ValueError(f"NGEOM blob for {rec.gid!r} decoded to zero roots") - inner = roots[0][1] + # NGEOM doesn't record shell closedness; restore ClosedShell for manifold + # B-rep roots so hydration matches the Python stream reader's form. + inner = promote_closed_shell(roots[0][1]) else: payload = self._blobs[index] if rec.compressed: diff --git a/src/ada/api/spatial/part.py b/src/ada/api/spatial/part.py index 10ddb97ea..1cc5c4502 100644 --- a/src/ada/api/spatial/part.py +++ b/src/ada/api/spatial/part.py @@ -707,34 +707,97 @@ def _tree_parent(paths) -> Part: parent = p return parent - # "native": adacpp's C++ NGEOM parser hydrated to Geometry (no Python tokenizer); same yield - # contract, so the Shape-wrap + product-tree below is identical. - if reader == "native": - from ada.cadit.step.read.native_reader import ( - native_adacpp_step_available, - native_stream_read_step, - ) + from ada.config import Config - if not native_adacpp_step_available(): - raise StepStreamUnsupported("reader='native' requires the adacpp stream_step_to_ngeom entry point") - geom_iter = native_stream_read_step(step_path) - else: - geom_iter = stream_read_step(step_path, local_pool=local_pool, tolerant=tolerant) + # Lazy shape store (default on): retain each solid as its compact blob and mint + # ShapeProxy objects that hydrate on demand, instead of holding the whole model + # as ada.geom trees (~10x resident memory on large assemblies). Opt out via + # ADA_CAD_LAZY_SHAPE_STORE=false. + store = None + if Config().cad_lazy_shape_store: + from ada.api.shapes import ShapeProxy, ShapeStore - new_shapes = [] # (parent_part, shape) - try: - for i, geometry in enumerate(geom_iter): - shp_name = str(geometry.id) if geometry.id not in (None, "") else f"{ada_name}_{i}" - shp = Shape( + store = ShapeStore(compress=Config().cad_shape_store_compress) + + def _mint_shape(shp_name, geometry) -> Shape: + if store is None: + return Shape( shp_name, geom=geometry, color=colour or geometry.color, opacity=opacity, units=source_units ) - parent = _tree_parent(geometry.instance_paths) if product_tree else self - new_shapes.append((parent, shp)) - except StepStreamUnsupported: - if reader != "auto": - raise - logger.info("read_step_file: streaming reader hit an unsupported entity; falling back to OCC reader") - return False + idx = store.add_geometry(geometry) + return ShapeProxy(shp_name, store, idx, color=colour or geometry.color, opacity=opacity, units=source_units) + + new_shapes = [] # (parent_part, shape) + + # "native": adacpp's C++ NGEOM parser. "auto" also probes it first (matching + # iter_from_step's read_solids): the native parse is faster and, with the lazy + # store, keeps the per-solid NGEOM buffer as-is — no hydration at import at all. + # A first-solid hydrate probe guards against files whose buffers the Python + # decode path can't handle; those fall back to the pure-Python reader ("auto") + # or raise ("native"). + use_native = False + if reader in ("native", "auto"): + from ada.cadit.step.read.native_reader import native_adacpp_step_available + + if native_adacpp_step_available(): + use_native = True + elif reader == "native": + raise StepStreamUnsupported("reader='native' requires the adacpp stream_step_to_ngeom entry point") + + if use_native: + from ada.cadit.ngeom.deserialize import deserialize_geometries + from ada.cadit.step.read.native_reader import native_stream_read_step_blobs + from ada.cadit.step.write._solid_source import NATIVE_DECODE_ERRORS + from ada.geom import Geometry + + try: + for i, (blob, gid, color, mats, paths) in enumerate(native_stream_read_step_blobs(step_path)): + if i == 0: + deserialize_geometries(blob) # hydrate-probe (result discarded) + shp_name = gid if gid not in (None, "") else f"{ada_name}_{i}" + if store is not None: + idx = store.add_blob( + blob, gid=shp_name, color=color, transforms=(mats or None), instance_paths=(paths or None) + ) + shp = ShapeProxy( + shp_name, store, idx, color=colour or color, opacity=opacity, units=source_units + ) + else: + geometry = Geometry( + id=shp_name, + geometry=deserialize_geometries(blob)[0][1], + color=color, + transforms=(mats or None), + instance_paths=(paths or None), + ) + shp = _mint_shape(shp_name, geometry) + parent = _tree_parent(paths) if product_tree else self + new_shapes.append((parent, shp)) + except (*NATIVE_DECODE_ERRORS, RuntimeError) as exc: + if reader == "native" or new_shapes: + # native was forced, or solids were already committed (a mid-stream + # failure can't be un-yielded) — fail loudly over silent truncation. + raise + logger.info("read_step_file: native STEP reader failed (%s); using the pure-Python reader", exc) + use_native = False + if use_native and not new_shapes and reader == "auto": + # Native recognised no solids; the pure-Python reader supports entity + # kinds the native one skips, so give it the file before the OCC fallback. + use_native = False + + if not use_native: + geom_iter = stream_read_step(step_path, local_pool=local_pool, tolerant=tolerant) + try: + for i, geometry in enumerate(geom_iter): + shp_name = str(geometry.id) if geometry.id not in (None, "") else f"{ada_name}_{i}" + shp = _mint_shape(shp_name, geometry) + parent = _tree_parent(geometry.instance_paths) if product_tree else self + new_shapes.append((parent, shp)) + except StepStreamUnsupported: + if reader != "auto": + raise + logger.info("read_step_file: streaming reader hit an unsupported entity; falling back to OCC reader") + return False # A zero-yield on a non-empty file means the streaming reader didn't # recognise the structure — fall back to OCC rather than silently diff --git a/src/ada/cad/registry.py b/src/ada/cad/registry.py index 421324cb7..85698dc7a 100644 --- a/src/ada/cad/registry.py +++ b/src/ada/cad/registry.py @@ -87,12 +87,14 @@ class StepReader(str, Enum): for the common case and as robust/complete as OCC for the rest (no geometry skipped). ``STREAM`` is streaming-only (raises on out-of-scope entities). ``TOLERANT`` streams and *skips* the unsupported solids (never OOMs, but drops geometry — avoid as a default). ``OCC`` forces the - whole-file OCC reader (needed for scale/transform/rotate-on-import).""" + whole-file OCC reader (needed for scale/transform/rotate-on-import). ``NATIVE`` forces + adacpp's C++ NGEOM parser (fastest; ``AUTO`` probes it first when adacpp is available).""" AUTO = "auto" STREAM = "stream" TOLERANT = "tolerant" OCC = "occ" + NATIVE = "native" @dataclass diff --git a/src/ada/cadit/ngeom/deserialize.py b/src/ada/cadit/ngeom/deserialize.py index 34f12a955..7f008fda7 100644 --- a/src/ada/cadit/ngeom/deserialize.py +++ b/src/ada/cadit/ngeom/deserialize.py @@ -341,6 +341,81 @@ def deserialize_geometries(buffer: bytes) -> list[tuple[str, object]]: return out +def connected_face_set_is_closed(cfs) -> bool: + """Topological closedness of a decoded ``ConnectedFaceSet``: every (undirected) + edge is used exactly twice across the faces' bounds — the manifold-closed-shell + condition. NGEOM tag 66 does not record whether the source shell was closed + (MANIFOLD_SOLID_BREP) or open (SHELL_BASED_SURFACE_MODEL), so the native read + paths use this to restore ``ClosedShell`` parity with the Python stream reader. + + Edges are keyed by VALUE, not object identity — the C++ emitter re-encodes an + edge record per referencing face. Endpoints alone are ambiguous for arcs (two + halves of one circle share both endpoints), so the key adds the underlying + curve's signature and the oriented edge's parameter range. Two uses of the same + key = the edge is interior; any other count = a free or non-manifold edge. A + model whose adjacent faces don't encode identical edge values conservatively + reads as open.""" + from collections import Counter + + edge_use: Counter = Counter() + for f in getattr(cfs, "cfs_faces", []) or []: + for b in getattr(f, "bounds", []) or []: + loop = getattr(b, "bound", None) + edge_list = getattr(loop, "edge_list", None) + if edge_list is not None: + for oe in edge_list: + e = oe.edge_element + a, z = tuple(map(float, e.start)), tuple(map(float, e.end)) + ts, te = getattr(oe, "t_start", None), getattr(oe, "t_end", None) + trange = None if ts is None else (min(ts, te), max(ts, te)) + key = ((a, z) if a <= z else (z, a), _edge_curve_sig(e.edge_geometry), trange) + edge_use[key] += 1 + continue + polygon = getattr(loop, "polygon", None) + if polygon is not None: + pts = [tuple(map(float, p)) for p in polygon] + for i, a in enumerate(pts): + z = pts[(i + 1) % len(pts)] + if a == z: + continue + edge_use[((a, z) if a <= z else (z, a), None, None)] += 1 + if not edge_use: + return False + return all(n == 2 for n in edge_use.values()) + + +def _edge_curve_sig(geom): + """A value signature of an edge's underlying curve, discriminating edges whose + endpoints coincide (arcs of one circle, seam edges). Decoded floats from equal + source records are bit-identical, so exact tuples work as keys.""" + if geom is None: + return None + tname = type(geom).__name__ + pos = getattr(geom, "position", None) + loc = tuple(map(float, pos.location)) if pos is not None else None + if hasattr(geom, "radius"): # Circle / cylinder-ish + return (tname, loc, float(geom.radius)) + if hasattr(geom, "semi_axis1"): # Ellipse + return (tname, loc, float(geom.semi_axis1), float(geom.semi_axis2)) + cps = getattr(geom, "control_points_list", None) + if cps is not None: # B-spline + return (tname, tuple(tuple(map(float, p)) for p in cps)) + pnt = getattr(geom, "pnt", None) + if pnt is not None: # Line + return (tname,) + return (tname, loc) + + +def promote_closed_shell(geometry): + """Return ``ClosedShell(cfs_faces)`` when ``geometry`` is a bare, topologically + closed ``ConnectedFaceSet`` root; otherwise return it unchanged. Restores the + Python stream reader's root form (solid_geom / OCC build / STEP re-emit all key + on ClosedShell vs open shells) for natively-parsed B-reps.""" + if type(geometry) is su.ConnectedFaceSet and connected_face_set_is_closed(geometry): + return su.ClosedShell(cfs_faces=geometry.cfs_faces) + return geometry + + def iter_connected_face_set_faces(buffer: bytes): """If the NGEOM buffer's single root is a ``ConnectedFaceSet`` (a B-rep solid's shell), return ``(rid, n_faces, face_gen)`` where ``face_gen`` yields each diff --git a/src/ada/cadit/step/read/native_reader.py b/src/ada/cadit/step/read/native_reader.py index e9454ebe1..522af7637 100644 --- a/src/ada/cadit/step/read/native_reader.py +++ b/src/ada/cadit/step/read/native_reader.py @@ -30,23 +30,38 @@ def native_adacpp_step_available() -> bool: return False +def native_stream_read_step_blobs(step_path: str | pathlib.Path) -> Iterator[tuple]: + """Yield ``(ngeom_blob, gid, color, world_matrices, instance_paths)`` per solid WITHOUT + hydrating the geometry — the foundation of the lazy ``ShapeStore`` import path. + + The blob is yielded exactly as it crosses the adacpp boundary (today ``bytes``; a + buffer view once the zero-copy binding lands) so the consumer can retain it with + no further copies. Hydration, when a consumer eventually wants the ``ada.geom`` + tree, is ``deserialize_geometries(blob)``.""" + import adacpp + + for nbytes, meta in adacpp.cad.StepNgeomStream(str(step_path)): + gid, color, mats, paths = decode_step_root_meta(meta) + yield nbytes, gid, color, mats, paths + + def native_stream_read_step(step_path: str | pathlib.Path) -> Iterator[Geometry]: """Yield one ``ada.geom.Geometry`` per solid, parsed natively — the inverse-serialized B-rep plus its colour, world-placement matrices and assembly paths. A drop-in for ``stream_read_step``. Uses the streaming ``StepNgeomStream`` iterator (one solid's NGEOM buffer at a time, bounded memory), so it scales to large assemblies instead of materialising the whole parsed model.""" - import adacpp - - from ada.cadit.ngeom.deserialize import deserialize_geometries + from ada.cadit.ngeom.deserialize import deserialize_geometries, promote_closed_shell - for nbytes, meta in adacpp.cad.StepNgeomStream(str(step_path)): + for nbytes, gid, color, mats, paths in native_stream_read_step_blobs(step_path): decoded = deserialize_geometries(nbytes) # exactly one root per streamed buffer if not decoded: continue - gid, color, mats, paths = decode_step_root_meta(meta) + # NGEOM does not record shell closedness; restore ClosedShell for manifold + # roots so downstream (solid_geom / STEP re-emit) matches the Python reader. + geometry = promote_closed_shell(decoded[0][1]) yield Geometry( - id=gid, geometry=decoded[0][1], color=color, transforms=(mats or None), instance_paths=(paths or None) + id=gid, geometry=geometry, color=color, transforms=(mats or None), instance_paths=(paths or None) ) diff --git a/src/ada/cadit/step/write/ap242_stream.py b/src/ada/cadit/step/write/ap242_stream.py index 4f7c0fa7d..c3baad96c 100644 --- a/src/ada/cadit/step/write/ap242_stream.py +++ b/src/ada/cadit/step/write/ap242_stream.py @@ -1041,12 +1041,25 @@ def emit(v0, p0, v1, p1): else: return None # unsupported edge geometry - # Key by the EdgeCurve OBJECT identity: a truly-shared edge resolves to the - # same ec object in both adjacent faces (reader memoisation), while the two - # semicircle arcs of one circle are distinct objects — so they are NOT merged - # (a geometric vertex-pair key collides them and corrupts the topology). + # Key by the EDGE_CURVE's VALUE, not the ec object's identity: the Python + # reader memoises shared edges into one object, but NGEOM-hydrated trees + # (native reader / lazy ShapeStore) re-decode one EdgeCurve object per + # referencing face — identity keying would emit duplicate EDGE_CURVEs and + # the shell would read back with free edges (no solid). The value key is the + # raw emit direction (unnormalised — two complementary arcs of one circle + # run opposite ways), the underlying curve's signature (arcs of one circle + # share endpoints; the signature alone doesn't split them, direction and + # t-range do), the trim range, and same_sense. + ts, te = getattr(oe, "t_start", None), getattr(oe, "t_end", None) + key = ( + self._vfor(ec.start), + self._vfor(ec.end), + _edge_curve_value_sig(g), + bool(ec.same_sense), + None if ts is None else (min(ts, te), max(ts, te)), + ) return self._shared_oriented( - id(ec), + key, self._vfor(oe.start), self._vfor(oe.end), # loop traversal direction self._vfor(ec.start), @@ -1518,6 +1531,25 @@ def _axis_or(d, default): return list(d) if d is not None else list(default) +def _edge_curve_value_sig(g): + """A value signature of an edge's underlying curve (see _brep_oriented's key). + Mirrors ada.cadit.ngeom.deserialize._edge_curve_sig, duplicated here so the + streaming writer stays numpy-free for the slim worker.""" + if g is None: + return None + tname = type(g).__name__ + pos = getattr(g, "position", None) + loc = tuple(float(v) for v in pos.location) if pos is not None else None + if hasattr(g, "radius"): + return (tname, loc, float(g.radius)) + if hasattr(g, "semi_axis1"): + return (tname, loc, float(g.semi_axis1), float(g.semi_axis2)) + cps = getattr(g, "control_points_list", None) + if cps is not None: + return (tname, tuple(tuple(float(v) for v in p) for p in cps)) + return (tname, loc) + + def _object_geom_meta(obj): """(geom, name, color, translate) for a physical object, or (None, ...) if it has no usable solid geometry. Shared by the extrusion and B-rep emit paths.""" diff --git a/src/ada/config.py b/src/ada/config.py index 325b4527e..2c5c814af 100644 --- a/src/ada/config.py +++ b/src/ada/config.py @@ -148,6 +148,22 @@ class Config: ConfigEntry("import_raise_exception_on_failed_advanced_face", bool, False), ], ), + ConfigSection( + "cad", + [ + # Lazy shape store: keep imported CAD solids as compact blobs (NGEOM + # buffers from the native readers / pickled ada.geom otherwise) in a + # shared ShapeStore, minting ShapeProxy objects that hydrate geometry + # on demand (weakref-cached). ~5x lower resident memory on large STEP + # imports (Munin crane 2.6 GB -> ~0.5 GB settled). On by default; set + # ADA_CAD_LAZY_SHAPE_STORE=false to materialise eager Shapes as before. + ConfigEntry("lazy_shape_store", bool, True, required=False), + # Compress stored blobs (zlib-1, ~3x on B-rep buffers) at the cost of a + # decompress copy per hydration/fast-path access — trades the zero-copy + # property for a smaller resident floor. + ConfigEntry("shape_store_compress", bool, False, required=False), + ], + ), ConfigSection( "occ_tess", [ diff --git a/src/ada/factories.py b/src/ada/factories.py index 58213c29a..66ccf3710 100644 --- a/src/ada/factories.py +++ b/src/ada/factories.py @@ -70,7 +70,7 @@ def from_step( colour=None, opacity: float = 1.0, include_shells: bool = False, - reader: Literal["occ", "stream", "auto", "tolerant"] | None = None, + reader: Literal["occ", "stream", "auto", "tolerant", "native"] | None = None, product_tree: bool = False, ) -> Assembly: """Create an Assembly object from a STEP file. diff --git a/src/ada/occ/geom/__init__.py b/src/ada/occ/geom/__init__.py index 0f52d3968..d0a423958 100644 --- a/src/ada/occ/geom/__init__.py +++ b/src/ada/occ/geom/__init__.py @@ -62,6 +62,9 @@ def geom_to_occ_geom(geom: Geometry) -> TopoDS_Shape | TopoDS_Solid: occ_geom = geo_su.make_open_shell_from_geom(geometry) elif isinstance(geometry, su.ShellBasedSurfaceModel): occ_geom = geo_su.make_shell_from_shell_based_surface_geom(geometry) + elif isinstance(geometry, su.ConnectedFaceSet): + # The native NGEOM reader's B-rep root form (closed/open not recorded in the buffer). + occ_geom = geo_su.make_shell_from_connected_face_set_geom(geometry) elif isinstance(geometry, su.PolygonalFaceSet): occ_geom = geo_su.make_shell_from_polygonal_face_set_geom(geometry) diff --git a/src/ada/occ/geom/surfaces.py b/src/ada/occ/geom/surfaces.py index 196fdc4ec..f23dfe5cd 100644 --- a/src/ada/occ/geom/surfaces.py +++ b/src/ada/occ/geom/surfaces.py @@ -1977,6 +1977,24 @@ def make_open_shell_from_geom(shell: geo_su.OpenShell) -> TopoDS_Shell: return occ_shell +def make_shell_from_connected_face_set_geom(cfs: geo_su.ConnectedFaceSet) -> TopoDS_Shell: + """Bare CONNECTED_FACE_SET — the native NGEOM reader's B-rep root form (the buffer + does not record whether the source shell was closed). Build the faces like the + closed/open shells above and let OCC determine closedness, so a manifold solid + B-rep read natively behaves like the Python reader's ClosedShell downstream.""" + builder = BRep_Builder() + occ_shell = TopoDS_Shell() + builder.MakeShell(occ_shell) + _add_cfs_faces_to_shell(builder, occ_shell, cfs.cfs_faces) + try: + from OCC.Core.BRep import BRep_Tool + + occ_shell.Closed(BRep_Tool.IsClosed(occ_shell)) + except Exception: # noqa: BLE001 - closedness flag is an optimisation, not correctness + pass + return occ_shell + + def make_shell_from_polygonal_face_set_geom(pfs: geo_su.PolygonalFaceSet) -> TopoDS_Shape: """Build an IfcPolygonalFaceSet — a shared point list plus n-gon faces — into a sewn OCC shell. Each face is a planar polygon wire (1-based indices into the point list); From 647c68199dfded3f32feaf32da4ff9173e69ca47 Mon Sep 17 00:00:00 2001 From: krande Date: Fri, 3 Jul 2026 20:45:51 +0200 Subject: [PATCH 003/151] feat: lazy ShapeProxy blob fast path + consumer compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdaCppBackend.tessellate_stream_buffer: tessellate a pre-encoded NGEOM buffer (a ShapeStore blob) directly — no hydration, no re-serialization; tessellate_stream now routes through it. - BatchTessellator: ShapeProxy solids tessellate straight from their stored blob when the stream kernel is selected; the bare-curve sniff skips proxies (it would hydrate the whole tree, and stored solids are never bare curves). - IFC writer: ShapeProxy accepted alongside Shape for parametric export; bare ConnectedFaceSet roots map to the ClosedShell face-set emit. Co-Authored-By: Claude Fable 5 --- src/ada/cad/__init__.py | 32 +++++++++++++-- src/ada/cadit/ifc/write/write_shapes.py | 8 +++- src/ada/occ/tessellating.py | 50 ++++++++++++++++++++--- tests/core/api/shapes/test_shape_store.py | 23 +++++++++++ 4 files changed, 104 insertions(+), 9 deletions(-) diff --git a/src/ada/cad/__init__.py b/src/ada/cad/__init__.py index 59894ba48..1e111680f 100644 --- a/src/ada/cad/__init__.py +++ b/src/ada/cad/__init__.py @@ -934,6 +934,31 @@ def tessellate_stream( item's position). ``pipeline``: ``libtess2`` (OCC-free) | ``occ`` | ``cgal`` (ifcopenshell taxonomy kernels). ``geometry`` is an ``ada.geom`` ``FaceSurface`` or ``ConnectedFaceSet`` (unmappable items are skipped by the serializer).""" + from ada.cadit.ngeom import serialize_geometries + + return self.tessellate_stream_buffer( + serialize_geometries(items), + pipeline=pipeline, + deflection=deflection, + angular_deg=angular_deg, + settings=settings, + threads=threads, + ) + + def tessellate_stream_buffer( + self, + buffer, + *, + pipeline: str = "libtess2", + deflection: float = 0.0, + angular_deg: float = 20.0, + settings: "dict | None" = None, + threads: int = 1, + ) -> "BatchMesh": + """Tessellate a pre-encoded NGEOM buffer — the fast path for blobs already in + the neutral form (a lazy ``ShapeStore``'s stored solid, a cached ``.ngeom`` + file): no hydration and no re-serialization, the buffer goes straight to the + C++ kernel.""" fn = getattr(self._cad, "tessellate_stream", None) if fn is None: raise NotImplementedError( @@ -941,9 +966,10 @@ def tessellate_stream( ) import numpy as np - from ada.cadit.ngeom import serialize_geometries - - buffer = serialize_geometries(items) + if not isinstance(buffer, (bytes, bytearray)): + # adacpp's binding currently takes nb::bytes only; drop this coercion once + # the buffer-protocol/zero-copy adacpp change lands. + buffer = bytes(buffer) # ``settings`` overrides the ifcopenshell ConversionSettings for the taxonomy paths # (occ/cgal/hybrid); ignored by libtess2. ``threads`` (>1) parallelises a root's faces # in the libtess2 path — opt-in, so the STEP->GLB process pool (which parallelises across diff --git a/src/ada/cadit/ifc/write/write_shapes.py b/src/ada/cadit/ifc/write/write_shapes.py index 2f4178df6..8d2727a42 100644 --- a/src/ada/cadit/ifc/write/write_shapes.py +++ b/src/ada/cadit/ifc/write/write_shapes.py @@ -195,6 +195,10 @@ def generate_parametric_solid(shape: Shape | PrimSphere, f): geo_su.AdvancedFace: advanced_face, geo_su.CurveBoundedPlane: curve_bounded_plane, geo_su.ClosedShell: create_closed_shell, + # Bare CONNECTED_FACE_SET: the native NGEOM reader's B-rep root form when the + # closedness promotion couldn't verify the shell (e.g. edges split differently + # across adjacent faces). Same face-set emit as ClosedShell. + geo_su.ConnectedFaceSet: create_closed_shell, # Curve-only bodies (SAT wire bodies import as bare curve geometry): # emitted as a Curve3D representation instead of failing solid_occ(). geo_cu.Edge: _edge_as_polyline, @@ -204,7 +208,9 @@ def generate_parametric_solid(shape: Shape | PrimSphere, f): BoolHalfSpace: create_half_space_geom, } - if type(shape) is Shape: + from ada.api.shapes import ShapeProxy + + if type(shape) in (Shape, ShapeProxy): param_geo = shape.geom.geometry else: param_geo = shape diff --git a/src/ada/occ/tessellating.py b/src/ada/occ/tessellating.py index 4f9f483ba..7c2e3f378 100644 --- a/src/ada/occ/tessellating.py +++ b/src/ada/occ/tessellating.py @@ -740,19 +740,44 @@ def _tessellate_geom_via_stream(self, geom: Geometry, node_ref, force_pipeline: except Exception as e: # noqa: BLE001 - fall back to the OCC build path on any stream failure self._log_tess_fallback(node_ref, pipeline, f"tessellate_stream raised {type(e).__name__}: {e}", geom) return None + ms = self._mesh_store_from_batch(bm, node_ref, geom.color) + if ms is None: + self._log_tess_fallback(node_ref, pipeline, "empty mesh (geom type not NGEOM-serializable)", geom) + return ms + + def _tessellate_blob_via_stream(self, blob, node_ref, color) -> MeshStore | None: + """Tessellate a lazy shape's stored NGEOM buffer directly — no hydration, no + re-serialization: the ShapeStore blob goes straight to the C++ kernel.""" + pipeline = os.environ.get("ADA_STREAM_TESS_PIPELINE") + if not pipeline: + return None + be = active_backend() + if not hasattr(be, "tessellate_stream_buffer"): + return None + defl = float(os.environ.get("ADA_STREAM_TESS_DEFLECTION", "2.0")) + ang = float(os.environ.get("ADA_STREAM_TESS_ANGULAR", "20.0")) + try: + bm = be.tessellate_stream_buffer(blob, pipeline=pipeline, deflection=defl, angular_deg=ang) + except Exception as e: # noqa: BLE001 - fall back to the hydrate path on any stream failure + self._log_tess_fallback( + node_ref, pipeline, f"tessellate_stream_buffer raised {type(e).__name__}: {e}", None + ) + return None + return self._mesh_store_from_batch(bm, node_ref, color) + + def _mesh_store_from_batch(self, bm, node_ref, color) -> MeshStore | None: pos = getattr(bm, "positions", None) idx = getattr(bm, "indices", None) if pos is None or idx is None or len(idx) == 0: - self._log_tess_fallback(node_ref, pipeline, "empty mesh (geom type not NGEOM-serializable)", geom) return None pos = np.ascontiguousarray(pos, dtype=np.float32) idx = np.ascontiguousarray(idx, dtype=np.uint32) nrm = getattr(bm, "normals", None) nrm = np.ascontiguousarray(nrm, dtype=np.float32) if nrm is not None and len(nrm) else None - mat_id = self.material_store.get(geom.color, None) + mat_id = self.material_store.get(color, None) if mat_id is None: mat_id = len(self.material_store) - self.material_store[geom.color] = mat_id + self.material_store[color] = mat_id return MeshStore(node_ref, None, pos, idx, nrm, mat_id, MeshType.TRIANGLES, node_ref) def batch_tessellate( @@ -766,16 +791,31 @@ def batch_tessellate( for obj in objects: if isinstance(obj, BackendGeom): + from ada.api.shapes import ShapeProxy + ada_obj = obj geom_repr = render_override.get(obj.guid, GeomRepr.SOLID) # A Shape carrying a bare curve geometry (sectionless SAT wire body, open - # wireframe) has no solid/shell — render it as glTF line geometry. - if geom_repr == GeomRepr.SOLID: + # wireframe) has no solid/shell — render it as glTF line geometry. Lazy + # ShapeProxy objects skip the sniff: it would hydrate the whole tree, and + # the store only ever holds B-rep solids/shells, never bare curves. + if geom_repr == GeomRepr.SOLID and not isinstance(obj, ShapeProxy): _g = getattr(obj, "geom", None) if _g is not None and isinstance(getattr(_g, "geometry", None), _CURVE_GEOM_TUPLE): geom_repr = GeomRepr.LINE node_ref = graph_store.hash_map.get(obj.guid) if graph_store is not None else getattr(obj, "guid", None) + # Lazy-blob fast path: a ShapeProxy backed by an NGEOM buffer tessellates + # straight from the stored blob when the stream kernel is selected — no + # ada.geom hydration and no re-serialization for the whole model. + if geom_repr == GeomRepr.SOLID and isinstance(obj, ShapeProxy): + blob = obj.ngeom_blob() + if blob is not None: + ms_blob = self._tessellate_blob_via_stream(blob, node_ref, obj.color) + if ms_blob is not None: + yield ms_blob + continue + # PlateCurved: prism-extrude the BSpline face by # thickness so the GLB ships a solid (matching what # a flat Plate produces) rather than a thin shell. diff --git a/tests/core/api/shapes/test_shape_store.py b/tests/core/api/shapes/test_shape_store.py index 1d19749df..015a84e62 100644 --- a/tests/core/api/shapes/test_shape_store.py +++ b/tests/core/api/shapes/test_shape_store.py @@ -176,6 +176,29 @@ def test_proxy_pickle_shares_store(): assert isinstance(r1, ShapeProxy) and isinstance(r1, Shape) +def test_blob_fast_path_matches_serialized_tessellation(): + """A stored NGEOM blob tessellates via tessellate_stream_buffer to the same mesh + the hydrate+re-serialize route produces (the lazy fast path skips both steps).""" + import pytest + + from ada.cad import active_backend + + be = active_backend() + if not hasattr(be, "tessellate_stream_buffer") or not hasattr(be, "tessellate_stream"): + pytest.skip("active CAD backend has no NGEOM stream tessellation") + + g = _shell_geometry() + store = ShapeStore() + idx = store.add_blob(_ngeom_blob(g), gid=str(g.id)) + p = ShapeProxy("solid1", store, idx) + + via_blob = be.tessellate_stream_buffer(p.ngeom_blob(), pipeline="libtess2") + hydrated = p.geom + via_items = be.tessellate_stream([(str(hydrated.id), hydrated.geometry)], pipeline="libtess2") + assert via_blob.positions.tobytes() == via_items.positions.tobytes() + assert via_blob.indices.tobytes() == via_items.indices.tobytes() + + def test_pickle_kind_has_no_ngeom_blob(): store = ShapeStore() idx = store.add_geometry(_boolean_geometry()) From db75a909151a62549e7fb27d59e2b878f01f95cd Mon Sep 17 00:00:00 2001 From: krande Date: Fri, 3 Jul 2026 20:48:49 +0200 Subject: [PATCH 004/151] feat: lazy shape store for IFC import (native-parametric products) import_ifc_shape stores natively-read product geometry as one compact pickled blob per product (bool_operations, half-space operands and parametric profiles round-trip exactly) on a per-IfcStore ShapeStore and mints ShapeProxy objects; kernel-fallback products keep eager Shapes. Gated by the same cad.lazy_shape_store config (default on). Crane numbers for the STEP side of the store (native reader, 7291 solids): eager 2723/2556 MB peak/settled 96 s -> lazy 709/543 MB 56 s; all 7291 blobs hydrate cleanly and RSS returns to the blob floor after dropping the trees. Co-Authored-By: Claude Fable 5 --- src/ada/cadit/ifc/read/read_shapes.py | 22 +++++++++++++++++++--- tests/core/api/shapes/test_shape_store.py | 21 +++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/ada/cadit/ifc/read/read_shapes.py b/src/ada/cadit/ifc/read/read_shapes.py index 01e580935..9cb310647 100644 --- a/src/ada/cadit/ifc/read/read_shapes.py +++ b/src/ada/cadit/ifc/read/read_shapes.py @@ -38,9 +38,7 @@ def import_ifc_shape(product: ifcopenshell.entity_instance, name, ifc_store: Ifc local_placement = get_local_placement(obj_placement) extra_opts["placement"] = Placement.from_4x4_matrix(local_placement) - shape = Shape( - name, - geom=geom, + common = dict( guid=product.GlobalId, ifc_store=ifc_store, units=ifc_store.assembly.units, @@ -49,6 +47,24 @@ def import_ifc_shape(product: ifcopenshell.entity_instance, name, ifc_store: Ifc **extra_opts, ) + if geom is not None and Config().cad_lazy_shape_store: + # Lazy shape store (default on): keep the natively-read geometry as one + # compact pickled blob (bool_operations, half-space operands and parametric + # profiles round-trip exactly) and mint a ShapeProxy that hydrates on + # demand — large IFC imports stop holding every product's ada.geom tree. + # Kernel-fallback products (occ_body) stay eager Shapes: their geometry is + # the transient OCC body, and there is nothing heavy retained to avoid. + from ada.api.shapes import ShapeProxy, ShapeStore + + store = getattr(ifc_store, "_lazy_shape_store", None) + if store is None: + store = ShapeStore(compress=Config().cad_shape_store_compress) + ifc_store._lazy_shape_store = store + idx = store.add_geometry(geom) + return ShapeProxy(name, store, idx, **common) + + shape = Shape(name, geom=geom, **common) + # Assign the kernel-built OCC body to the transient cache explicitly rather than via # the constructor: the constructor only routes ``geom`` to ``_occ_cache`` when the # *active* backend recognises it as a shape handle, which is false under adacpp (the diff --git a/tests/core/api/shapes/test_shape_store.py b/tests/core/api/shapes/test_shape_store.py index 015a84e62..efe45994e 100644 --- a/tests/core/api/shapes/test_shape_store.py +++ b/tests/core/api/shapes/test_shape_store.py @@ -205,3 +205,24 @@ def test_pickle_kind_has_no_ngeom_blob(): assert store.ngeom_blob(idx) is None p = ShapeProxy("cut1", store, idx) assert p.ngeom_blob() is None + + +def test_ifc_roundtrip_imports_lazy_proxies_with_booleans(tmp_path): + """from_ifc mints ShapeProxy objects (lazy store default-on) and a boolean cut + (IfcBooleanClippingResult -> bool_operations) survives the store round-trip.""" + import ada + + box = ada.PrimBox("bx", (0, 0, 0), (1, 1, 1)) + box.add_boolean(ada.BoolHalfSpace((0.5, 0.5, 0.9), (0, 0, 1), name="cut")) + a = ada.Assembly("m") / (ada.Part("p") / [box]) + f = tmp_path / "lazy_bool.ifc" + a.to_ifc(f) + + b = ada.from_ifc(f) + shapes = [s for p in b.get_all_parts_in_assembly(include_self=True) for s in p.shapes] + assert shapes, "no shapes imported" + proxies = [s for s in shapes if isinstance(s, ShapeProxy)] + assert proxies, f"expected lazy proxies, got {[type(s).__name__ for s in shapes]}" + geom = proxies[0].geom + assert geom.bool_operations, "boolean clipping lost through the lazy store" + assert geom.bool_operations[0].operator == BoolOpEnum.DIFFERENCE From 42d1941553bb18f1c43dc2e80a21252d42de0658 Mon Sep 17 00:00:00 2001 From: krande Date: Fri, 3 Jul 2026 20:52:08 +0200 Subject: [PATCH 005/151] fix: ShapeProxy counts as Shape in get_all_physical_objects by_type filter The exact-type filter excluded lazy proxies from by_type=Shape queries (topology from_assembly found no solids). A proxy normalises to its public type; Shape subclasses like PrimBox stay excluded as before. Co-Authored-By: Claude Fable 5 --- src/ada/api/spatial/part.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/ada/api/spatial/part.py b/src/ada/api/spatial/part.py index 1cc5c4502..3b74e536d 100644 --- a/src/ada/api/spatial/part.py +++ b/src/ada/api/spatial/part.py @@ -1244,9 +1244,21 @@ def get_all_physical_objects( physical_objects.append(all_as_iterable) if by_type is not None: + from ada.api.shapes import ShapeProxy + if not isinstance(by_type, (list, tuple)): by_type = (by_type,) - res = filter(lambda x: type(x) in by_type, chain.from_iterable(physical_objects)) + + def _match_type(x, _by=tuple(by_type)): + # Exact-type filter, but a lazy ShapeProxy counts as its public type + # Shape (a proxy is an implementation detail of the import path, not + # a distinct kind — Shape subclasses like PrimBox stay excluded). + t = type(x) + if t is ShapeProxy: + t = Shape + return t in _by + + res = filter(_match_type, chain.from_iterable(physical_objects)) elif by_metadata is not None: res = filter( lambda x: all(x.metadata.get(key) == value for key, value in by_metadata.items()), From aa58d3ea9c8fd723f1fab580032caf8d9cc0b34f Mon Sep 17 00:00:00 2001 From: krande Date: Fri, 3 Jul 2026 20:58:58 +0200 Subject: [PATCH 006/151] feat: boolean operations flow through the NGEOM stream path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Geometry.bool_operations were dropped by every stream-tessellation caller (the wrapper was stripped before serialization), so IFC clipping cuts and API booleans only evaluated on the OCC build path. Now: - serialize root() accepts core.Geometry wrappers and folds bool_operations into a nested BOOLEAN_RESULT (tag 52) chain — base as first operand, ops in order — which adacpp already decodes and evaluates via Manifold. - HalfSpaceSolid operands are lowered at encode time to a finite box on the material side of the plane, sized by the base solid's loose bbox — the exact lowering adacpp's native STEP/IFC readers apply (mk_halfspace), so no new NGEOM tag and no adacpp change is needed; the neutral path stays OCC-free (pure ada.geom bbox math). - BatchTessellator passes the wrapper through to tessellate_stream. Native-read CSG STEP roots (BOOLEAN_RESULT in an adacpp blob) still have no Python-side decode: the auto reader's hydrate-probe falls back to the pure- Python reader for such files, which parses BooleanResult natively. Co-Authored-By: Claude Fable 5 --- src/ada/cad/__init__.py | 5 +- src/ada/cadit/ngeom/serialize.py | 200 +++++++++++++++++++++- src/ada/occ/tessellating.py | 6 +- tests/core/api/shapes/test_shape_store.py | 33 ++++ 4 files changed, 238 insertions(+), 6 deletions(-) diff --git a/src/ada/cad/__init__.py b/src/ada/cad/__init__.py index 1e111680f..f6008f651 100644 --- a/src/ada/cad/__init__.py +++ b/src/ada/cad/__init__.py @@ -932,8 +932,9 @@ def tessellate_stream( per-object ``build``/ShapeHandle round-trip) and tessellates it in one C++ call, returning a combined ``BatchMesh`` with a group per input id (``node_id`` = the item's position). ``pipeline``: ``libtess2`` (OCC-free) | ``occ`` | ``cgal`` - (ifcopenshell taxonomy kernels). ``geometry`` is an ``ada.geom`` ``FaceSurface`` or - ``ConnectedFaceSet`` (unmappable items are skipped by the serializer).""" + (ifcopenshell taxonomy kernels). ``geometry`` is an ``ada.geom`` solid/face-set, + or a ``core.Geometry`` wrapper — the wrapper's ``bool_operations`` are folded + into the buffer as a BOOLEAN_RESULT chain (unmappable items are skipped).""" from ada.cadit.ngeom import serialize_geometries return self.tessellate_stream_buffer( diff --git a/src/ada/cadit/ngeom/serialize.py b/src/ada/cadit/ngeom/serialize.py index 419d2a17e..e77d8ac45 100644 --- a/src/ada/cadit/ngeom/serialize.py +++ b/src/ada/cadit/ngeom/serialize.py @@ -652,6 +652,65 @@ def boolean_result(self, br) -> int: b = self._dispatch(br.second_operand) return self._add(_BOOLEAN_RESULT, self.i32(op) + self.i32(a) + self.i32(b)) + def half_space_box(self, hs, ref_min, ref_max) -> int: + """HalfSpaceSolid -> a finite box (EXTRUDED_AREA_SOLID) on the material side + of the plane, sized to cover the reference bbox — the same lowering adacpp's + native STEP/IFC readers apply (``mk_halfspace``), so the neutral buffer needs + no half-space entity and the boolean evaluates identically everywhere. + ``agreement_flag=True`` keeps the material BELOW the plane (-normal side).""" + import math + + pos = hs.base_surface.position + o, z, x_ref = _frame_vectors(pos) + agree = bool(getattr(hs, "agreement_flag", True)) + hd = tuple(-c for c in z) if agree else z + + c = tuple((mn + mx) / 2.0 for mn, mx in zip(ref_min, ref_max)) + diag = math.sqrt(sum((mx - mn) ** 2 for mn, mx in zip(ref_min, ref_max))) + s = diag * 1.5 + 1e-6 + # project the bbox centre onto the cutting plane -> box origin ON the plane + d = sum(zc * (cc - oc) for zc, cc, oc in zip(z, c, o)) + cp = tuple(cc - zc * d for cc, zc in zip(c, z)) + # frame: local Z = material side; X from the plane frame, orthonormalised + t = x_ref if abs(sum(a * b for a, b in zip(hd, x_ref))) < 0.9 else _perp_of(z, x_ref) + dt = sum(a * b for a, b in zip(hd, t)) + fx = tuple(tc - hc * dt for tc, hc in zip(t, hd)) + n = math.sqrt(sum(v * v for v in fx)) or 1.0 + fx = tuple(v / n for v in fx) + + pts = [(-s, -s, 0.0), (s, -s, 0.0), (s, s, 0.0), (-s, s, 0.0)] + loop = self._add(_POLY_LOOP, self.i32(4) + self._v3_raw(pts)) + bound = self._add(_FACE_BOUND, self.i32(loop) + self.i32(1)) + face = self._planar_face([bound]) + place = self._add(_PLACEMENT3, self.v3(cp) + self.v3(hd) + self.v3(fx)) + body = self.i32(face) + self.i32(place) + self.v3((0.0, 0.0, 1.0)) + self.f64(s) + return self._add(_EXTRUDED_AREA_SOLID, body) + + def _fold_bool_ops(self, base_idx: int, base_geom, bool_ops) -> int: + """Chain a base solid's ``Geometry.bool_operations`` into nested + BOOLEAN_RESULT records (base as first operand, applied in order) so cuts + reach the neutral kernel instead of being dropped with the wrapper.""" + idx = base_idx + bbox = None + for op in bool_ops: + og = op.second_operand + while hasattr(og, "geometry") and hasattr(og, "bool_operations"): + og = og.geometry # unwrap core.Geometry + import ada.geom.surfaces as _su + + if isinstance(og, _su.HalfSpaceSolid): + if bbox is None: + bbox = _loose_bbox(base_geom) + if bbox is None: + raise _Unsupported("HalfSpaceSolid operand without a boundable base solid") + op_idx = self.half_space_box(og, bbox[0], bbox[1]) + else: + op_idx = self._dispatch(og) + op_name = getattr(op.operator, "value", op.operator) + opi = {"DIFFERENCE": 0, "UNION": 1, "INTERSECTION": 2}.get(str(op_name).upper(), 0) + idx = self._add(_BOOLEAN_RESULT, self.i32(opi) + self.i32(idx) + self.i32(op_idx)) + return idx + # --- root + finish ----------------------------------------------------------------- def _dispatch(self, geom) -> int: """Serialize one geometry instance to its record index (used for both @@ -690,8 +749,20 @@ def _dispatch(self, geom) -> int: raise _Unsupported(f"geometry {type(geom).__name__}") def root(self, geom) -> int: - """Serialize a top-level geometry instance, returning its record index.""" - return self._dispatch(geom) + """Serialize a top-level geometry instance, returning its record index. + + Accepts a raw ``ada.geom`` type or a ``core.Geometry`` wrapper — the wrapper's + ``bool_operations`` are folded into a BOOLEAN_RESULT chain (previously every + caller stripped the wrapper, silently dropping IFC clipping cuts and API + booleans from the stream-tessellation/export paths).""" + ops = [] + while hasattr(geom, "geometry") and hasattr(geom, "bool_operations"): + ops.extend(geom.bool_operations or []) + geom = geom.geometry + idx = self._dispatch(geom) + if ops: + idx = self._fold_bool_ops(idx, geom, ops) + return idx def finish(self, roots: list[tuple[int, str]]) -> bytes: out = bytearray(b"ADANGEOM") @@ -709,6 +780,131 @@ class _Unsupported(Exception): pass +def _frame_vectors(pos) -> tuple[tuple, tuple, tuple]: + """(origin, z, x) of an Axis2Placement3D with the usual defaults, as plain tuples.""" + import math + + o = tuple(float(v) for v in pos.location) if pos is not None else (0.0, 0.0, 0.0) + z = tuple(float(v) for v in pos.axis) if pos is not None and pos.axis is not None else (0.0, 0.0, 1.0) + n = math.sqrt(sum(v * v for v in z)) or 1.0 + z = tuple(v / n for v in z) + if pos is not None and getattr(pos, "ref_direction", None) is not None: + x = tuple(float(v) for v in pos.ref_direction) + else: + x = _perp_of(z, (1.0, 0.0, 0.0)) + return o, z, x + + +def _perp_of(z, seed) -> tuple: + """A unit vector perpendicular to ``z``, seeded by ``seed`` (world X/Y fallback).""" + import math + + d = sum(a * b for a, b in zip(z, seed)) + if abs(d) > 0.9: + seed = (0.0, 1.0, 0.0) + d = sum(a * b for a, b in zip(z, seed)) + v = tuple(s - zc * d for s, zc in zip(seed, z)) + n = math.sqrt(sum(c * c for c in v)) or 1.0 + return tuple(c / n for c in v) + + +def _loose_bbox(geom) -> tuple[tuple, tuple] | None: + """Loose world-frame bbox of an ada.geom solid — over-estimate is fine, it only + sizes the finite box a HalfSpaceSolid operand is lowered to (mirrors adacpp's + ``solid_item_bbox``). Pure Python/ada.geom — the neutral path stays OCC-free. + Returns ``((minx,miny,minz), (maxx,maxy,maxz))`` or ``None``.""" + import ada.geom.booleans as _bo + import ada.geom.solids as _so + import ada.geom.surfaces as _su + + while hasattr(geom, "geometry") and hasattr(geom, "bool_operations"): + geom = geom.geometry + + def frame_pts(pos, local_pts): + o, z, x = _frame_vectors(pos) + y = ( + z[1] * x[2] - z[2] * x[1], + z[2] * x[0] - z[0] * x[2], + z[0] * x[1] - z[1] * x[0], + ) + return [tuple(o[i] + x[i] * p[0] + y[i] * p[1] + z[i] * p[2] for i in range(3)) for p in local_pts] + + pts: list[tuple] = [] + if isinstance(geom, _bo.BooleanResult): + return _loose_bbox(geom.first_operand) # the result is contained in operand a + if isinstance(geom, _so.ExtrudedAreaSolid): + profile = geom.swept_area + outer = getattr(profile, "outer_curve", None) + if outer is None: + try: + from ada.api.beams.geom_beams import parametric_profile_to_arbitrary + + outer = parametric_profile_to_arbitrary(profile).outer_curve + except Exception: # noqa: BLE001 + return None + base = _profile_curve_pts(outer) + if base is None: + return None + d = tuple(float(v) * float(geom.depth) for v in geom.extruded_direction) + local = base + [(p[0] + d[0], p[1] + d[1], p[2] + d[2]) for p in base] + pts = frame_pts(geom.position, local) + elif isinstance(geom, _so.RevolvedAreaSolid): + base = _profile_curve_pts(getattr(geom.swept_area, "outer_curve", None)) + if base is None: + return None + r = max((p[0] ** 2 + p[1] ** 2 + p[2] ** 2) ** 0.5 for p in base) + world = frame_pts(geom.position, base) + pts = [tuple(c + s * r for c in p) for p in world for s in (-1.0, 1.0)] + elif isinstance(geom, _so.Box): + x, y, z = float(geom.x_length), float(geom.y_length), float(geom.z_length) + pts = frame_pts(geom.position, [(0, 0, 0), (x, 0, 0), (x, y, 0), (0, y, 0), (0, 0, z), (x, y, z)]) + elif isinstance(geom, _so.Cylinder) or isinstance(geom, _so.Cone): + r = float(getattr(geom, "radius", 0.0) or getattr(geom, "bottom_radius", 0.0)) + h = float(geom.height) + pts = frame_pts(geom.position, [(-r, -r, 0), (r, r, 0), (-r, -r, h), (r, r, h)]) + elif isinstance(geom, _so.Sphere): + c, r = geom.center, float(geom.radius) + pts = [tuple(float(cc) + s * r for cc in c) for s in (-1.0, 1.0)] + else: + faces = getattr(geom, "cfs_faces", None) + if faces is None and hasattr(geom, "outer"): # AdvancedBrep / FacetedBrep + faces = getattr(geom.outer, "cfs_faces", None) + if faces is None and hasattr(geom, "bounds"): # a single face + faces = [geom] + if faces is None: + return None + for f in faces: + for b in getattr(f, "bounds", []) or []: + loop = getattr(b, "bound", None) + for oe in getattr(loop, "edge_list", None) or []: + e = getattr(oe, "edge_element", oe) + pts.append(tuple(float(v) for v in e.start)) + pts.append(tuple(float(v) for v in e.end)) + for p in getattr(loop, "polygon", None) or []: + pts.append(tuple(float(v) for v in p)) + if not pts: + return None + mn = tuple(min(p[i] for p in pts) for i in range(3)) + mx = tuple(max(p[i] for p in pts) for i in range(3)) + return mn, mx + + +def _profile_curve_pts(curve) -> list[tuple[float, float, float]] | None: + """Boundary points of a profile curve (shared with the encoder's poly path); + conics fall back to their bounding square.""" + if curve is None: + return None + r = float(getattr(curve, "radius", 0.0) or getattr(curve, "semi_axis1", 0.0) or 0.0) + if r > 0.0: + pos = getattr(curve, "position", None) + o = tuple(float(v) for v in pos.location) if pos is not None else (0.0, 0.0, 0.0) + return [(o[0] + sx * r, o[1] + sy * r, o[2]) for sx in (-1, 1) for sy in (-1, 1)] + try: + return _Encoder._loop_points_3d(curve) + except _Unsupported: + return None + + def serialize_geometries(items: Iterable[tuple[str, object]]) -> bytes: """Serialize ``(id, geometry)`` pairs into one NGEOM buffer. diff --git a/src/ada/occ/tessellating.py b/src/ada/occ/tessellating.py index 7c2e3f378..f91108694 100644 --- a/src/ada/occ/tessellating.py +++ b/src/ada/occ/tessellating.py @@ -732,11 +732,13 @@ def _tessellate_geom_via_stream(self, geom: Geometry, node_ref, force_pipeline: if not hasattr(be, "tessellate_stream"): self._log_tess_fallback(node_ref, pipeline, "active backend has no tessellate_stream", geom) return None - gi = geom.geometry.geometry if hasattr(geom.geometry, "geometry") else geom.geometry + # Pass the Geometry wrapper through: the serializer unwraps it and folds any + # bool_operations into a BOOLEAN_RESULT chain, so IFC clipping cuts and API + # booleans reach the stream kernel instead of being dropped with the wrapper. defl = float(os.environ.get("ADA_STREAM_TESS_DEFLECTION", "2.0")) ang = float(os.environ.get("ADA_STREAM_TESS_ANGULAR", "20.0")) try: - bm = be.tessellate_stream([(str(node_ref), gi)], pipeline=pipeline, deflection=defl, angular_deg=ang) + bm = be.tessellate_stream([(str(node_ref), geom)], pipeline=pipeline, deflection=defl, angular_deg=ang) except Exception as e: # noqa: BLE001 - fall back to the OCC build path on any stream failure self._log_tess_fallback(node_ref, pipeline, f"tessellate_stream raised {type(e).__name__}: {e}", geom) return None diff --git a/tests/core/api/shapes/test_shape_store.py b/tests/core/api/shapes/test_shape_store.py index efe45994e..5acdd5413 100644 --- a/tests/core/api/shapes/test_shape_store.py +++ b/tests/core/api/shapes/test_shape_store.py @@ -207,6 +207,39 @@ def test_pickle_kind_has_no_ngeom_blob(): assert p.ngeom_blob() is None +def test_stream_tessellation_applies_bool_operations(): + """A Geometry wrapper's bool_operations reach the stream kernel: the serializer + folds them into a BOOLEAN_RESULT chain (half-space lowered to a finite box, the + same lowering adacpp's readers use) and Manifold evaluates the cut.""" + import pytest + + import ada + from ada.cad import active_backend + + be = active_backend() + if not hasattr(be, "tessellate_stream"): + pytest.skip("active CAD backend has no NGEOM stream tessellation") + + box = ada.PrimBox("bx", (0, 0, 0), (1, 1, 1)) + box.add_boolean(ada.BoolHalfSpace((0.5, 0.5, 0.6), (0, 0, 1), name="cut")) + geom = box.solid_geom() + assert geom.bool_operations, "fixture must carry a boolean" + + bm = be.tessellate_stream([("bx", geom)], pipeline="libtess2") + z = bm.positions.reshape(-1, 3)[:, 2] if bm.positions.ndim == 1 else bm.positions[:, 2] + assert len(z) > 0, "boolean-bearing solid tessellated to nothing" + zmax = float(z.max()) + assert abs(zmax - 0.6) < 1e-6, f"half-space cut not applied (zmax={zmax}, expected 0.6)" + + # solid second operand (UNION): a box fused on top raises the extent instead + box2 = ada.PrimBox("bx2", (0, 0, 0), (1, 1, 1)) + box2.add_boolean(ada.PrimBox("cap", (0.25, 0.25, 0.5), (0.75, 0.75, 1.4)), "union") + g2 = box2.solid_geom() + bm2 = be.tessellate_stream([("bx2", g2)], pipeline="libtess2") + z2 = bm2.positions.reshape(-1, 3)[:, 2] if bm2.positions.ndim == 1 else bm2.positions[:, 2] + assert abs(float(z2.max()) - 1.4) < 1e-6, "union operand not applied" + + def test_ifc_roundtrip_imports_lazy_proxies_with_booleans(tmp_path): """from_ifc mints ShapeProxy objects (lazy store default-on) and a boolean cut (IfcBooleanClippingResult -> bool_operations) survives the store round-trip.""" From 167efcaa75b8393151e2d17d80ef93e420165a75 Mon Sep 17 00:00:00 2001 From: krande Date: Fri, 3 Jul 2026 21:04:58 +0200 Subject: [PATCH 007/151] perf: slots=True on all ada.geom dataclasses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the per-instance __dict__ from every curve/surface/solid/placement/ boolean dataclass — the dominant per-object overhead when a B-rep model is hydrated (millions of instances). core.Geometry stays un-slotted: it is the weakref-cache value in the lazy ShapeStore (weakref_slot needs py>=3.11) and there is only one per solid. Two NGEOM serializer tests attached weights_data ad hoc to the non-rational B-spline classes; they now construct the Rational subclasses (real field). Co-Authored-By: Claude Fable 5 --- src/ada/geom/booleans.py | 4 +- src/ada/geom/curves.py | 52 +++++++++--------- src/ada/geom/placement.py | 6 +-- src/ada/geom/solids.py | 26 ++++----- src/ada/geom/surfaces.py | 68 ++++++++++++------------ tests/core/cadit/ngeom/test_serialize.py | 10 ++-- 6 files changed, 84 insertions(+), 82 deletions(-) diff --git a/src/ada/geom/booleans.py b/src/ada/geom/booleans.py index 7699b376c..21134c8bb 100644 --- a/src/ada/geom/booleans.py +++ b/src/ada/geom/booleans.py @@ -19,14 +19,14 @@ def from_str(cls, value) -> BoolOpEnum: return enum_map.get(value.lower()) -@dataclass +@dataclass(slots=True) class BooleanResult: first_operand: Any second_operand: Any operator: BoolOpEnum -@dataclass +@dataclass(slots=True) class BooleanOperation: second_operand: Geometry operator: BoolOpEnum diff --git a/src/ada/geom/curves.py b/src/ada/geom/curves.py index 9a1cb5ebb..73acd7c70 100644 --- a/src/ada/geom/curves.py +++ b/src/ada/geom/curves.py @@ -39,7 +39,7 @@ ] -@dataclass +@dataclass(slots=True) class Line: """ IFC4x3 https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcLine.htm @@ -56,7 +56,7 @@ def __post_init__(self): self.dir = Direction(*self.dir) -@dataclass +@dataclass(slots=True) class ArcLine: """ IFC4x3 https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcArcIndex.htm @@ -87,12 +87,12 @@ def __iter__(self): return iter((self.start, self.midpoint, self.end)) -@dataclass +@dataclass(slots=True) class PolyLine: points: list[Point] -@dataclass +@dataclass(slots=True) class TrimmedCurve: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcTrimmedCurve.htm) @@ -111,7 +111,7 @@ class TrimmedCurve: master_representation: str = "PARAMETER" -@dataclass +@dataclass(slots=True) class CompositeCurveSegment: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcCompositeCurveSegment.htm) @@ -124,7 +124,7 @@ class CompositeCurveSegment: transition: str = "CONTINUOUS" -@dataclass +@dataclass(slots=True) class CompositeCurve: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcCompositeCurve.htm) @@ -137,7 +137,7 @@ class CompositeCurve: self_intersect: bool = False -@dataclass +@dataclass(slots=True) class Clothoid: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcClothoid.htm) @@ -153,7 +153,7 @@ class Clothoid: clothoid_constant: float -@dataclass +@dataclass(slots=True) class CurveSegment: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcCurveSegment.htm) @@ -172,7 +172,7 @@ class CurveSegment: parent_curve: CURVE_GEOM_TYPES -@dataclass +@dataclass(slots=True) class GradientCurve: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcGradientCurve.htm) @@ -186,7 +186,7 @@ class GradientCurve: self_intersect: bool = False -@dataclass +@dataclass(slots=True) class IndexedPolyCurve: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcIndexedPolyCurve.htm) @@ -257,12 +257,12 @@ def to_points2d(self): return local_points -@dataclass +@dataclass(slots=True) class GeometricCurveSet: elements: list[CURVE_GEOM_TYPES] -@dataclass +@dataclass(slots=True) class Circle: """ IFC4x3 https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcCircle.htm @@ -273,7 +273,7 @@ class Circle: radius: float -@dataclass +@dataclass(slots=True) class Ellipse: """ IFC4x3 https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcEllipse.htm @@ -285,7 +285,7 @@ class Ellipse: semi_axis2: float -@dataclass +@dataclass(slots=True) class Parabola: """STEP AP242 https://www.steptools.com/stds/stp_aim/html/t_parabola.html @@ -296,7 +296,7 @@ class Parabola: focal_dist: float -@dataclass +@dataclass(slots=True) class PointOnCurve: """STEP AP242 t_point_on_curve — a point located at ``parameter`` on a basis curve.""" @@ -304,7 +304,7 @@ class PointOnCurve: parameter: float -@dataclass +@dataclass(slots=True) class OffsetCurve3D: """STEP AP242 t_offset_curve_3d — a curve offset from a basis curve by ``distance``.""" @@ -314,7 +314,7 @@ class OffsetCurve3D: ref_direction: "Direction | None" = None -@dataclass +@dataclass(slots=True) class Hyperbola: """STEP AP242 https://www.steptools.com/stds/stp_aim/html/t_hyperbola.html @@ -356,7 +356,7 @@ def from_str(value: str) -> KnotType: return KnotType(value) -@dataclass +@dataclass(slots=True) class BSplineCurveWithKnots: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcBSplineCurveWithKnots.htm) @@ -373,7 +373,7 @@ class BSplineCurveWithKnots: knot_spec: KnotType -@dataclass +@dataclass(slots=True) class PCurve: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcPcurve.htm) @@ -383,7 +383,7 @@ class PCurve: reference_curve: CURVE_GEOM_TYPES -@dataclass +@dataclass(slots=True) class RationalBSplineCurveWithKnots(BSplineCurveWithKnots): """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcRationalBSplineCurveWithKnots.htm) @@ -392,7 +392,7 @@ class RationalBSplineCurveWithKnots(BSplineCurveWithKnots): weights_data: list[float] -@dataclass +@dataclass(slots=True) class Edge: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcEdge.htm) @@ -430,7 +430,7 @@ def __iter__(self): return iter((self.start, self.end)) -@dataclass +@dataclass(slots=True) class Pcurve2dBSpline: """A 2D B-spline curve in the parameter space (UV) of a surface — the p-curve attached to a coedge in ACIS / STEP / IFC. Carrying the @@ -450,7 +450,7 @@ class Pcurve2dBSpline: closed: bool = False -@dataclass +@dataclass(slots=True) class OrientedEdge(Edge): """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcOrientedEdge.htm) @@ -479,7 +479,7 @@ class OrientedEdge(Edge): t_end: float | None = None -@dataclass +@dataclass(slots=True) class EdgeCurve(Edge): """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcEdgeCurve.htm) @@ -490,7 +490,7 @@ class EdgeCurve(Edge): same_sense: bool -@dataclass +@dataclass(slots=True) class PolyLoop: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcPolyLoop.htm) @@ -499,7 +499,7 @@ class PolyLoop: polygon: list[Point] -@dataclass +@dataclass(slots=True) class EdgeLoop: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcEdgeLoop.htm) diff --git a/src/ada/geom/placement.py b/src/ada/geom/placement.py index 46b87ac25..b9ea72ab2 100644 --- a/src/ada/geom/placement.py +++ b/src/ada/geom/placement.py @@ -30,7 +30,7 @@ def ZV() -> Direction: # noqa return Direction(0, 0, 1) -@dataclass +@dataclass(slots=True) class Axis2Placement3D: location: Point | Iterable = field(default_factory=O) @@ -52,13 +52,13 @@ def get_pdir(self): return Direction(*np.cross(self.axis, self.ref_direction)) -@dataclass +@dataclass(slots=True) class IfcLocalPlacement: relative_placement: Axis2Placement3D placement_rel_to: IfcLocalPlacement | None = None -@dataclass +@dataclass(slots=True) class Axis1Placement: location: Point axis: Direction diff --git a/src/ada/geom/solids.py b/src/ada/geom/solids.py index 2d0a629d5..76773b557 100644 --- a/src/ada/geom/solids.py +++ b/src/ada/geom/solids.py @@ -17,7 +17,7 @@ ) -@dataclass +@dataclass(slots=True) class ExtrudedAreaSolid: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3_0_0/lexical/IfcExtrudedAreaSolid.htm) @@ -30,7 +30,7 @@ class ExtrudedAreaSolid: extruded_direction: Direction -@dataclass +@dataclass(slots=True) class ExtrudedAreaSolidTapered(ExtrudedAreaSolid): """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3_0_0/lexical/IfcExtrudedAreaSolidTapered.htm) @@ -39,7 +39,7 @@ class ExtrudedAreaSolidTapered(ExtrudedAreaSolid): end_swept_area: ProfileDef -@dataclass +@dataclass(slots=True) class RevolvedAreaSolid: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcRevolvedAreaSolid.htm) @@ -52,7 +52,7 @@ class RevolvedAreaSolid: angle: float -@dataclass +@dataclass(slots=True) class FixedReferenceSweptAreaSolid: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3_0_0/lexical/IfcFixedReferenceSweptAreaSolid.htm) @@ -69,7 +69,7 @@ class FixedReferenceSweptAreaSolid: fixed_reference: Direction = field(default_factory=lambda: Direction(0.0, 0.0, 1.0)) -@dataclass +@dataclass(slots=True) class SweptDiskSolid: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcSweptDiskSolid.htm) @@ -88,7 +88,7 @@ class SweptDiskSolid: fillet_radius: float | None = None -@dataclass +@dataclass(slots=True) class Box: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcBlock.htm) @@ -118,7 +118,7 @@ def from_2points(p1: Point, p2: Point) -> Box: return Box.from_xyz_and_dims(x, y, z, x_length, y_length, z_length) -@dataclass +@dataclass(slots=True) class RectangularPyramid: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3_0_0/lexical/IfcRectangularPyramid.htm) @@ -131,7 +131,7 @@ class RectangularPyramid: z_length: float -@dataclass +@dataclass(slots=True) class Cone: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3_0_0/lexical/IfcRightCircularCone.htm) @@ -152,7 +152,7 @@ def from_2points(p1: Point, p2: Point, r: float) -> Cone: return Cone(axis3d, r, height) -@dataclass +@dataclass(slots=True) class Cylinder: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3_0_0/lexical/IfcRightCircularCylinder.htm) @@ -173,7 +173,7 @@ def from_2points(p1: Point, p2: Point, r: float) -> Cylinder: return Cylinder(axis3d, r, height) -@dataclass +@dataclass(slots=True) class Sphere: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3_0_0/lexical/IfcSphere.htm) @@ -184,7 +184,7 @@ class Sphere: radius: float -@dataclass +@dataclass(slots=True) class Torus: """STEP AP242 https://www.steptools.com/stds/stp_aim/html/t_torus.html @@ -196,7 +196,7 @@ class Torus: minor_radius: float -@dataclass +@dataclass(slots=True) class AdvancedBrep: """ IFC4x3 (https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcAdvancedBrep.htm) @@ -205,7 +205,7 @@ class AdvancedBrep: outer: ConnectedFaceSet -@dataclass +@dataclass(slots=True) class FacetedBrep: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcFacetedBrep.htm) diff --git a/src/ada/geom/surfaces.py b/src/ada/geom/surfaces.py index 3f404231e..22d869372 100644 --- a/src/ada/geom/surfaces.py +++ b/src/ada/geom/surfaces.py @@ -12,12 +12,12 @@ # STEP AP242 and IFC 4x3 -@dataclass +@dataclass(slots=True) class Plane: position: Axis2Placement3D -@dataclass +@dataclass(slots=True) class CylindricalSurface: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcCylindricalSurface.htm) @@ -28,7 +28,7 @@ class CylindricalSurface: radius: float -@dataclass +@dataclass(slots=True) class ConicalSurface: """ STEP AP242 (https://www.steptools.com/stds/stp_aim/html/t_conical_surface.html) @@ -46,7 +46,7 @@ class ConicalSurface: semi_angle: float # Cone half-angle in radians -@dataclass +@dataclass(slots=True) class SphericalSurface: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcSphericalSurface.htm) @@ -57,7 +57,7 @@ class SphericalSurface: radius: float -@dataclass +@dataclass(slots=True) class ToroidalSurface: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcToroidalSurface.htm) @@ -83,12 +83,12 @@ def from_str(profile_type: str) -> "ProfileType": raise ValueError(f"Invalid profile type {profile_type}") -@dataclass +@dataclass(slots=True) class ProfileDef: profile_type: ProfileType -@dataclass +@dataclass(slots=True) class ArbitraryProfileDef(ProfileDef): """ IFC4x3 https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcArbitraryProfileDefWithVoids.htm @@ -99,7 +99,7 @@ class ArbitraryProfileDef(ProfileDef): profile_name: str = None -@dataclass +@dataclass(slots=True) class FaceBound: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcFaceBound.htm) @@ -109,7 +109,7 @@ class FaceBound: orientation: bool -@dataclass +@dataclass(slots=True) class Face: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcFace.htm) @@ -118,7 +118,7 @@ class Face: bounds: list[FaceBound] -@dataclass +@dataclass(slots=True) class FaceSurface(Face): """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcFaceSurface.htm) @@ -129,7 +129,7 @@ class FaceSurface(Face): same_sense: bool = True -@dataclass +@dataclass(slots=True) class ConnectedFaceSet: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcConnectedFaceSet.htm) @@ -139,7 +139,7 @@ class ConnectedFaceSet: cfs_faces: list["Face | FaceSurface"] -@dataclass +@dataclass(slots=True) class FaceBasedSurfaceModel: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcFaceBasedSurfaceModel.htm) @@ -148,7 +148,7 @@ class FaceBasedSurfaceModel: fbsm_faces: list[ConnectedFaceSet] -@dataclass +@dataclass(slots=True) class CurveBoundedPlane: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcCurveBoundedPlane.htm) @@ -159,13 +159,13 @@ class CurveBoundedPlane: inner_boundaries: list[geo_cu.CURVE_GEOM_TYPES] = field(default_factory=list) -@dataclass +@dataclass(slots=True) class HalfSpaceSolid: base_surface: Plane agreement_flag: bool = True -@dataclass +@dataclass(slots=True) class SurfaceOfLinearExtrusion: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcSurfaceOfLinearExtrusion.htm) @@ -177,7 +177,7 @@ class SurfaceOfLinearExtrusion: depth: float -@dataclass +@dataclass(slots=True) class SurfaceOfRevolution: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcSurfaceOfRevolution.htm) @@ -193,7 +193,7 @@ class SurfaceOfRevolution: position: Axis2Placement3D = None -@dataclass +@dataclass(slots=True) class RectangularTrimmedSurface: """STEP AP242 https://www.steptools.com/stds/stp_aim/html/t_rectangular_trimmed_surface.html @@ -209,7 +209,7 @@ class RectangularTrimmedSurface: vsense: bool = True -@dataclass +@dataclass(slots=True) class OffsetSurface: """STEP AP242 https://www.steptools.com/stds/stp_aim/html/t_offset_surface.html @@ -221,7 +221,7 @@ class OffsetSurface: self_intersect: bool = False -@dataclass +@dataclass(slots=True) class PointOnSurface: """STEP AP242 t_point_on_surface — a point at (u, v) on a basis surface.""" @@ -230,7 +230,7 @@ class PointOnSurface: v: float -@dataclass +@dataclass(slots=True) class RectangularCompositeSurface: """STEP AP242 t_rectangular_composite_surface — a grid of surface patches. @@ -240,7 +240,7 @@ class RectangularCompositeSurface: segments: list -@dataclass +@dataclass(slots=True) class IShapeProfileDef(ProfileDef): """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcIShapeProfileDef.htm) @@ -255,7 +255,7 @@ class IShapeProfileDef(ProfileDef): flange_slope: float -@dataclass +@dataclass(slots=True) class TShapeProfileDef(ProfileDef): """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcTShapeProfileDef.htm) @@ -272,7 +272,7 @@ class TShapeProfileDef(ProfileDef): flange_slope: float -@dataclass +@dataclass(slots=True) class CircleProfileDef(ProfileDef): """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcCircleProfileDef.htm) @@ -281,7 +281,7 @@ class CircleProfileDef(ProfileDef): radius: float -@dataclass +@dataclass(slots=True) class TriangulatedFaceSet: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcTriangulatedFaceSet.htm) @@ -292,7 +292,7 @@ class TriangulatedFaceSet: indices: list[int] -@dataclass +@dataclass(slots=True) class PolygonalFaceSet: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcPolygonalFaceSet.htm) @@ -307,7 +307,7 @@ class PolygonalFaceSet: closed: bool = True -@dataclass +@dataclass(slots=True) class RectangleProfileDef(ProfileDef): """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcRectangleProfileDef.htm) @@ -335,7 +335,7 @@ def from_str(value: str) -> BSplineSurfaceForm: return BSplineSurfaceForm(value) -@dataclass +@dataclass(slots=True) class BSplineSurface: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcBSplineSurface.htm) @@ -350,7 +350,7 @@ class BSplineSurface: self_intersect: bool -@dataclass +@dataclass(slots=True) class BSplineSurfaceWithKnots(BSplineSurface): """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcBSplineSurfaceWithKnots.htm) @@ -385,7 +385,7 @@ def to_json(self): } -@dataclass +@dataclass(slots=True) class RationalBSplineSurfaceWithKnots(BSplineSurfaceWithKnots): """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcRationalBSplineSurfaceWithKnots.htm) @@ -394,7 +394,7 @@ class RationalBSplineSurfaceWithKnots(BSplineSurfaceWithKnots): weights_data: list[list[float]] -@dataclass +@dataclass(slots=True) class ClosedShell: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcClosedShell.htm) @@ -403,7 +403,7 @@ class ClosedShell: cfs_faces: list[Face | FaceSurface | Plane] -@dataclass +@dataclass(slots=True) class OpenShell: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcClosedShell.htm) @@ -412,7 +412,7 @@ class OpenShell: cfs_faces: list[Face | FaceSurface | Plane] -@dataclass +@dataclass(slots=True) class AdvancedFace: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcAdvancedFace.htm) @@ -435,7 +435,7 @@ class AdvancedFace: same_sense: bool = True -@dataclass +@dataclass(slots=True) class WireFilledFace: """A face defined only by its boundary wire — filled by OCC. @@ -451,7 +451,7 @@ class WireFilledFace: bounds: list[FaceBound] -@dataclass +@dataclass(slots=True) class ShellBasedSurfaceModel: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcShellBasedSurfaceModel.htm) diff --git a/tests/core/cadit/ngeom/test_serialize.py b/tests/core/cadit/ngeom/test_serialize.py index 1a60add20..4da79e90c 100644 --- a/tests/core/cadit/ngeom/test_serialize.py +++ b/tests/core/cadit/ngeom/test_serialize.py @@ -103,7 +103,9 @@ def test_dependency_order_no_forward_refs(): def _bspline_surface_face(): nu, nv = 4, 6 # 24 control points > _BULK_MIN rows = [[Point(float(i), float(j), float(i * j) * 0.25) for j in range(nv)] for i in range(nu)] - surf = su.BSplineSurfaceWithKnots( + # Rational subclass carries the weights as a real field (the geom dataclasses are + # slotted — ad-hoc attributes on the non-rational class no longer stick). + surf = su.RationalBSplineSurfaceWithKnots( u_degree=3, v_degree=3, control_points_list=rows, @@ -116,15 +118,15 @@ def _bspline_surface_face(): u_knots=[float(i) for i in range(20)], v_knots=[float(i) * 0.5 for i in range(20)], knot_spec=cu.KnotType.UNSPECIFIED, + weights_data=[[1.0 + 0.01 * (i + j) for j in range(nv)] for i in range(nu)], # >16 flat weights ) - surf.weights_data = [[1.0 + 0.01 * (i + j) for j in range(nv)] for i in range(nu)] # >16 flat weights poly = cu.PolyLoop(polygon=[Point(math.cos(0.3 * k), math.sin(0.3 * k), 0.1 * k) for k in range(20)]) # >16 pts return su.FaceSurface(bounds=[su.FaceBound(bound=poly, orientation=True)], face_surface=surf, same_sense=True) def _bspline_curve_edge_face(): cps = [Point(float(i), float(i % 3), 0.0) for i in range(20)] # >16 control points - bc = cu.BSplineCurveWithKnots( + bc = cu.RationalBSplineCurveWithKnots( degree=3, control_points_list=cps, curve_form=cu.BSplineCurveFormEnum.POLYLINE_FORM, @@ -133,8 +135,8 @@ def _bspline_curve_edge_face(): knot_multiplicities=[1] * 24, # >16 multiplicities knots=[float(i) for i in range(24)], # >16 knots knot_spec=cu.KnotType.UNSPECIFIED, + weights_data=[1.0 + 0.001 * i for i in range(20)], # >16 weights (real field; slotted classes) ) - bc.weights_data = [1.0 + 0.001 * i for i in range(20)] # >16 weights s, t = (0.0, 0.0, 0.0), (19.0, 0.0, 0.0) ec = cu.EdgeCurve(start=s, end=t, edge_geometry=bc, same_sense=True) oe = cu.OrientedEdge(start=s, end=t, edge_element=ec, orientation=True) From e8c2b8efc3e6bbbb37000fdd0992fcae51c20f0c Mon Sep 17 00:00:00 2001 From: krande Date: Fri, 3 Jul 2026 21:16:56 +0200 Subject: [PATCH 008/151] =?UTF-8?q?feat:=20native=20IFC=20blob=20path=20?= =?UTF-8?q?=E2=80=94=20IfcNgeomStream=20feeds=20the=20lazy=20shape=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B-rep IFC products the Python-native readers can't resolve previously fell to the eager IfcOpenShell OCC kernel (unpicklable transient bodies, whole- model materialisation). They now try adacpp's dep-free IfcNgeomStream first: one guid-keyed scan per import, buffers retained zero-copy in the per-IfcStore ShapeStore, single-instance placement restored from the stream's composed world matrix. Color/props/spatial hierarchy still come from ifcopenshell (the stream's v1 carries geometry+guid+name+placement only); kernel fallback unchanged for products the native resolver skips, and adacpp builds without IfcNgeomStream degrade gracefully. Co-Authored-By: Claude Fable 5 --- src/ada/cadit/ifc/read/read_shapes.py | 94 ++++++++++++++++++----- tests/core/api/shapes/test_shape_store.py | 30 ++++++++ 2 files changed, 103 insertions(+), 21 deletions(-) diff --git a/src/ada/cadit/ifc/read/read_shapes.py b/src/ada/cadit/ifc/read/read_shapes.py index 9cb310647..8689524b3 100644 --- a/src/ada/cadit/ifc/read/read_shapes.py +++ b/src/ada/cadit/ifc/read/read_shapes.py @@ -24,8 +24,9 @@ def import_ifc_shape(product: ifcopenshell.entity_instance, name, ifc_store: Ifc geom = None occ_body = None + blob_rec = None if Config().ifc_import_shape_geom or force_geom: - geom, occ_body = _read_shape_geometry(product, color) + geom, occ_body, blob_rec = _read_shape_geometry(product, color, ifc_store) extra_opts = {} # Only apply the IFC local placement when we keep the native (parametric) geometry, @@ -47,20 +48,35 @@ def import_ifc_shape(product: ifcopenshell.entity_instance, name, ifc_store: Ifc **extra_opts, ) - if geom is not None and Config().cad_lazy_shape_store: - # Lazy shape store (default on): keep the natively-read geometry as one - # compact pickled blob (bool_operations, half-space operands and parametric - # profiles round-trip exactly) and mint a ShapeProxy that hydrates on - # demand — large IFC imports stop holding every product's ada.geom tree. - # Kernel-fallback products (occ_body) stay eager Shapes: their geometry is - # the transient OCC body, and there is nothing heavy retained to avoid. + if (geom is not None or blob_rec is not None) and Config().cad_lazy_shape_store: + # Lazy shape store (default on): keep the geometry as one compact blob and + # mint a ShapeProxy that hydrates on demand — large IFC imports stop holding + # every product's ada.geom tree. Python-native geometry pickles losslessly + # (bool_operations, half-space operands, parametric profiles); products the + # Python readers can't resolve keep the adacpp IfcNgeomStream NGEOM buffer + # as-arrived (zero-copy, tessellation fast-path capable). Kernel-fallback + # products (occ_body) stay eager Shapes: their geometry is the transient + # OCC body, and there is nothing heavy retained to avoid. from ada.api.shapes import ShapeProxy, ShapeStore store = getattr(ifc_store, "_lazy_shape_store", None) if store is None: store = ShapeStore(compress=Config().cad_shape_store_compress) ifc_store._lazy_shape_store = store - idx = store.add_geometry(geom) + if geom is not None: + idx = store.add_geometry(geom) + else: + blob, meta = blob_rec + # meta.transforms is the composed world placement (column-major 16-float, + # like the STEP path); a single instance becomes the Shape placement so + # downstream behaves exactly like the eager IFC path (local geometry + + # Placement). Multi-instance products were filtered out at lookup time. + if len(meta.transforms) == 1: + import numpy as np + + mat = np.asarray(meta.transforms[0], dtype=float).reshape(4, 4, order="F") + common["placement"] = Placement.from_4x4_matrix(mat) + idx = store.add_blob(blob, gid=product.GlobalId, color=color) return ShapeProxy(name, store, idx, **common) shape = Shape(name, geom=geom, **common) @@ -76,18 +92,19 @@ def import_ifc_shape(product: ifcopenshell.entity_instance, name, ifc_store: Ifc return shape -def _read_shape_geometry(product: ifcopenshell.entity_instance, color): +def _read_shape_geometry(product: ifcopenshell.entity_instance, color, ifc_store: IfcStore = None): """Resolve a product's body geometry, preferring adapy's native (parametric) reader. - Returns ``(geom, occ_body)``: a native :class:`~ada.geom.Geometry` when every body - item is a type adapy reads natively, else ``(None, occ_body)`` where ``occ_body`` is a - world-placed OCC ``TopoDS`` built by the IfcOpenShell geometry kernel. The kernel + Returns ``(geom, occ_body, blob_rec)``: a native :class:`~ada.geom.Geometry` when + every body item is a type adapy reads natively; else ``blob_rec = (ngeom_buffer, + meta)`` from adacpp's dep-free ``IfcNgeomStream`` when it resolved the product + (B-reps and analytic solids, kernel-free and lazily storable); else ``occ_body``, + a world-placed OCC ``TopoDS`` built by the IfcOpenShell geometry kernel. The kernel fallback is what lets ``ifc.import_shape_geom`` be on by default: any IFC geometry - representation (swept/half-space/mapped/b-spline/...) still imports, just as a faceted - B-rep rather than a parametric one. Returns ``(None, None)`` only when no geometry can - be produced at all (logged).""" + representation still imports, just as a faceted B-rep rather than a parametric one. + ``(None, None, None)`` only when no geometry can be produced at all (logged).""" if product.Representation is None: - return None, None + return None, None, None try: geometries = get_product_definitions(product) @@ -104,8 +121,8 @@ def _read_shape_geometry(product: ifcopenshell.entity_instance, color): if isinstance(geometry, Geometry): geometry.id = product.GlobalId geometry.color = color - return geometry, None - return Geometry(product.GlobalId, geometry, color), None + return geometry, None, None + return Geometry(product.GlobalId, geometry, color), None, None # Only fall back to the kernel for products that carry a *Body* representation, i.e. an # actual solid/surface to render. Curve-only products (e.g. IfcAlignmentSegment, whose @@ -115,12 +132,47 @@ def _read_shape_geometry(product: ifcopenshell.entity_instance, color): # break. The native reader already restricts itself to "Body" items, so this keeps the # fallback aligned with it. if not _has_body_representation(product): - return None, None + return None, None, None + + # Between the Python-native readers and the OCC kernel: adacpp's dep-free native IFC + # resolver (advanced/faceted B-reps + analytic solids the Python readers don't cover). + blob_rec = _native_geom_blob(product, ifc_store) + if blob_rec is not None: + return None, None, blob_rec occ_body = _kernel_occ_shape(product) if occ_body is None: logger.warning(f"No geometry could be produced for product {product}") - return None, occ_body + return None, occ_body, None + + +def _native_geom_blob(product: ifcopenshell.entity_instance, ifc_store: IfcStore): + """``(ngeom_buffer, meta)`` for a product from adacpp's ``IfcNgeomStream``, or + ``None``. The whole file is scanned ONCE per store on first need (guid-keyed map; + buffers retained zero-copy as they arrive). Multi-instance (mapped-item) products + are excluded — the lazy Shape path models one placement per Shape.""" + if ifc_store is None or not Config().cad_lazy_shape_store: + return None + path = getattr(ifc_store, "ifc_file_path", None) + if path is None: + return None + cache = getattr(ifc_store, "_native_geom_blobs", None) + if cache is None: + cache = {} + try: + import adacpp + + stream = adacpp.cad.IfcNgeomStream(str(path)) + for blob, meta in stream: + if meta.guid and len(meta.transforms) <= 1: + cache[meta.guid] = (blob, meta) + except (ImportError, AttributeError): + pass # no adacpp / build predates IfcNgeomStream + except Exception as exc: # noqa: BLE001 - a native scan failure must not break the import + logger.warning("native IFC NGEOM scan failed (%s); using the kernel fallback", exc) + cache = {} + ifc_store._native_geom_blobs = cache + return cache.get(product.GlobalId) def _has_body_representation(product: ifcopenshell.entity_instance) -> bool: diff --git a/tests/core/api/shapes/test_shape_store.py b/tests/core/api/shapes/test_shape_store.py index 5acdd5413..fa5bcb9d1 100644 --- a/tests/core/api/shapes/test_shape_store.py +++ b/tests/core/api/shapes/test_shape_store.py @@ -240,6 +240,36 @@ def test_stream_tessellation_applies_bool_operations(): assert abs(float(z2.max()) - 1.4) < 1e-6, "union operand not applied" +def test_native_ifc_brep_products_import_as_ngeom_blobs(tmp_path): + """B-rep IFC products the Python-native readers can't resolve import via adacpp's + IfcNgeomStream as zero-copy ngeom-kind proxies instead of eager OCC kernel bodies.""" + import pytest + + import ada + + adacpp = pytest.importorskip("adacpp") + if not hasattr(adacpp.cad, "IfcNgeomStream"): + pytest.skip("adacpp build predates IfcNgeomStream") + + box = ada.PrimBox("bx", (0, 0, 0), (1, 1, 1)) + a = ada.Assembly("m") / (ada.Part("p") / [box]) + stp = tmp_path / "nb.stp" + ifc = tmp_path / "nb.ifc" + a.to_stp(stp, writer="stream") + adacpp.cad.stream_step_to_ifc(str(stp), str(ifc)) # advanced-brep IFC4 + + b = ada.from_ifc(ifc) + shapes = [s for p in b.get_all_parts_in_assembly(include_self=True) for s in p.shapes] + assert shapes + for s in shapes: + assert isinstance(s, ShapeProxy), f"{s.name}: expected lazy proxy, got {type(s).__name__}" + assert s._occ_cache is None, f"{s.name}: eager OCC body retained" + rec = s._shape_store.record(s._store_index) + assert rec.kind == "ngeom", f"{s.name}: expected native blob, got {rec.kind}" + assert s.ngeom_blob() is not None + assert s.geom.geometry is not None # hydrates + + def test_ifc_roundtrip_imports_lazy_proxies_with_booleans(tmp_path): """from_ifc mints ShapeProxy objects (lazy store default-on) and a boolean cut (IfcBooleanClippingResult -> bool_operations) survives the store round-trip.""" From e439a887a5e1f0914a9a4f4fc0e7c467c7e8dd5e Mon Sep 17 00:00:00 2001 From: krande Date: Fri, 3 Jul 2026 22:08:46 +0200 Subject: [PATCH 009/151] fix: AdacppBackend.build accepts ConnectedFaceSet and FaceSurface roots Audit regression: non-promoted native NGEOM roots hydrate as bare ConnectedFaceSet with FaceSurface members; the adacpp backend's build dispatch only knew the ClosedShell/OpenShell + AdvancedFace spellings and raised 'not yet ported'. ConnectedFaceSet sews like the other shells; FaceSurface is AdvancedFace's structurally-identical sibling. Co-Authored-By: Claude Fable 5 --- src/ada/cad/__init__.py | 12 +++++++++--- tests/core/api/shapes/test_shape_store.py | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/ada/cad/__init__.py b/src/ada/cad/__init__.py index f6008f651..fd53c8080 100644 --- a/src/ada/cad/__init__.py +++ b/src/ada/cad/__init__.py @@ -396,12 +396,15 @@ def _arbitrary(area): ) polygons.append([self._xyz(p) for p in fb.bound.polygon]) shape = self._cad.build_face_based_surface_model(polygons) - elif isinstance(g, (su.ShellBasedSurfaceModel, su.OpenShell, su.ClosedShell)): + elif isinstance(g, (su.ShellBasedSurfaceModel, su.OpenShell, su.ClosedShell, su.ConnectedFaceSet)): # Sew the member faces into one shell handle. Each face is built through the # AdvancedFace path; open shells (IfcShellBasedSurfaceModel) don't bound a # volume, so sew_faces (BRepBuilderAPI_Sewing) is used rather than # make_volumes_from_faces. Mirrors OccBackend's make_shell_from_shell_based_ - # surface_geom / make_open_shell_from_geom. + # surface_geom / make_open_shell_from_geom. Bare ConnectedFaceSet is the + # native NGEOM reader's B-rep root form (closedness not recorded in the + # buffer; hydration promotes verified-closed sets to ClosedShell, the rest + # arrive here) — same face-sew as the shells. from ada.geom import Geometry as _Geometry boundary_shells = g.sbsm_boundary if isinstance(g, su.ShellBasedSurfaceModel) else [g] @@ -409,12 +412,15 @@ def _arbitrary(area): if not face_handles: raise NotImplementedError("AdacppBackend.build: shell model has no faces") shape = self._cad.sew_faces(face_handles) - elif isinstance(g, su.AdvancedFace): + elif isinstance(g, (su.AdvancedFace, su.FaceSurface)): # B-spline (PlateCurved / loft-derived / SAT) faces. With bounds, the # surface is trimmed to the boundary wire(s) — each OrientedEdge with # a supplied pcurve drives its edge from surface(pcurve(t)) (the # SAT-pcurve path of OccBackend.make_face_from_geom). Without bounds, # the natural-UV face. Analytic surfaces aren't ported yet. + # FaceSurface is AdvancedFace's structurally-identical sibling (same + # face_surface/bounds/same_sense, NOT a subclass) — it is what NGEOM + # hydration yields for native-read B-rep faces. surf = g.face_surface if isinstance(surf, su.Plane) and g.bounds: # Planar AdvancedFace (flat SAT/IFC plates): plane inferred from the boundary diff --git a/tests/core/api/shapes/test_shape_store.py b/tests/core/api/shapes/test_shape_store.py index fa5bcb9d1..4275dc8f4 100644 --- a/tests/core/api/shapes/test_shape_store.py +++ b/tests/core/api/shapes/test_shape_store.py @@ -240,6 +240,21 @@ def test_stream_tessellation_applies_bool_operations(): assert abs(float(z2.max()) - 1.4) < 1e-6, "union operand not applied" +def test_backend_builds_hydrated_connected_face_set(): + """Audit regression: a non-promoted native root hydrates as bare ConnectedFaceSet + and must build on the ACTIVE backend (adacpp raised 'not yet ported'); it sews + like the ClosedShell/OpenShell shells.""" + from ada.cad import active_backend + + store = ShapeStore() + g = _shell_geometry() + idx = store.add_blob(_ngeom_blob(g), gid=str(g.id)) + hydrated = store.geometry(idx) + assert isinstance(hydrated.geometry, su.ConnectedFaceSet) # single face -> not promoted + handle = active_backend().build(hydrated) + assert handle is not None + + def test_native_ifc_brep_products_import_as_ngeom_blobs(tmp_path): """B-rep IFC products the Python-native readers can't resolve import via adacpp's IfcNgeomStream as zero-copy ngeom-kind proxies instead of eager OCC kernel bodies.""" From a5289ad01f50d0c1bb94baac591bd5cbd9804275 Mon Sep 17 00:00:00 2001 From: krande Date: Sat, 4 Jul 2026 07:19:06 +0200 Subject: [PATCH 010/151] =?UTF-8?q?fix:=20audit=20regressions=20=E2=80=94?= =?UTF-8?q?=20curve=20shapes=20through=20the=20lazy=20store=20+=20hydratio?= =?UTF-8?q?n=20LRU?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SAT wire bodies round-tripped through IFC are bare-curve shapes; the proxy curve-sniff skip forced them down solid_geom() and they vanished from scenes (parity ifc 1->0). ShapeRecord now records curve-ness at store time and the tessellator asks the record — line rendering restored with zero hydration. - ~40% conversion slowdown: consumers read .geom 3-5x per call and each temporary died between property evaluations, so the weak cache re-decoded the blob per access. ShapeStore adds a small strong LRU window (hydration_cache_size=16, ~5 MB) over the weak cache; hydrate-all still returns to the blob floor (eviction test included). Co-Authored-By: Claude Fable 5 --- src/ada/api/shapes/proxies.py | 5 +++ src/ada/api/shapes/store.py | 46 +++++++++++++++++++---- src/ada/occ/tessellating.py | 18 +++++---- tests/core/api/shapes/test_shape_store.py | 25 +++++++++++- 4 files changed, 78 insertions(+), 16 deletions(-) diff --git a/src/ada/api/shapes/proxies.py b/src/ada/api/shapes/proxies.py index f47950de7..59712cdbc 100644 --- a/src/ada/api/shapes/proxies.py +++ b/src/ada/api/shapes/proxies.py @@ -56,6 +56,11 @@ def ngeom_blob(self): was stored from Python-built geometry (pickle kind).""" return self._shape_store.ngeom_blob(self._store_index) + def is_bare_curve(self) -> bool: + """The stored geometry is a bare curve (wire body) — render as line geometry. + Answered from the record, no hydration.""" + return self._shape_store.record(self._store_index).curve + @property def units(self): return self._units diff --git a/src/ada/api/shapes/store.py b/src/ada/api/shapes/store.py index ef64cf796..49afe13b6 100644 --- a/src/ada/api/shapes/store.py +++ b/src/ada/api/shapes/store.py @@ -32,6 +32,7 @@ import pickle import weakref import zlib +from collections import OrderedDict from dataclasses import dataclass from typing import TYPE_CHECKING @@ -56,6 +57,10 @@ class ShapeRecord: transforms: list[np.ndarray] | None = None instance_paths: list[tuple] | None = None compressed: bool = False + # The stored geometry is a bare curve (SAT wire body / open wireframe round- + # tripped through IFC) — consumers render it as line geometry. Recorded at + # store time so the tessellator's curve sniff never has to hydrate. + curve: bool = False class ShapeStore: @@ -67,14 +72,22 @@ class ShapeStore: as soon as no consumer holds them. """ - __slots__ = ("_blobs", "_records", "_geom_cache", "compress", "__weakref__") + __slots__ = ("_blobs", "_records", "_geom_cache", "_geom_lru", "hydration_cache_size", "compress", "__weakref__") - def __init__(self, compress: bool = False): + def __init__(self, compress: bool = False, hydration_cache_size: int = 16): self._blobs: list[object] = [] self._records: list[ShapeRecord] = [] # Mirrors MeshArrays._proxy_cache: same live object for repeated access, # GC'd when the last outside reference drops. self._geom_cache: weakref.WeakValueDictionary[int, Geometry] = weakref.WeakValueDictionary() + # Small strong LRU on top of the weak cache. Consumers touch ``.geom`` + # several times in one call (``solid_geom()`` alone reads it 3-5x) and each + # temporary dies between property evaluations, so a purely weak cache + # re-decodes the blob per access (~40% slower conversions on the audit). + # A bounded strong window keeps the hot shape alive without giving up the + # hydrate-all-then-drop memory floor (N x ~0.3 MB, evicted FIFO). + self._geom_lru: OrderedDict[int, Geometry] = OrderedDict() + self.hydration_cache_size = hydration_cache_size self.compress = compress def __len__(self) -> int: @@ -123,6 +136,8 @@ def add_geometry(self, geometry: Geometry) -> int: geometry + ``bool_operations`` (which pickle round-trips exactly, including half-space operands and parametric profiles the NGEOM codec would lower). """ + from ada.geom.curves import CURVE_GEOM_TUPLE + payload = pickle.dumps((geometry.geometry, geometry.bool_operations), protocol=pickle.HIGHEST_PROTOCOL) compressed = False if self.compress: @@ -137,6 +152,7 @@ def add_geometry(self, geometry: Geometry) -> int: transforms=geometry.transforms, instance_paths=geometry.instance_paths, compressed=compressed, + curve=isinstance(geometry.geometry, CURVE_GEOM_TUPLE), ) ) return len(self._records) - 1 @@ -159,9 +175,11 @@ def ngeom_blob(self, index: int): return blob def geometry(self, index: int) -> Geometry: - """Hydrate the full ``ada.geom.Geometry`` for one shape (weakref-cached).""" + """Hydrate the full ``ada.geom.Geometry`` for one shape (weakref-cached, with + a small strong LRU window for back-to-back access).""" geom = self._geom_cache.get(index) if geom is not None: + self._lru_touch(index, geom) return geom rec = self._records[index] bool_ops: list[BooleanOperation] = [] @@ -182,7 +200,7 @@ def geometry(self, index: int) -> Geometry: if rec.compressed: payload = zlib.decompress(payload) inner, bool_ops = pickle.loads(payload) - geom = Geometry( + hydrated = Geometry( id=rec.gid, geometry=inner, color=rec.color, @@ -190,8 +208,19 @@ def geometry(self, index: int) -> Geometry: transforms=rec.transforms, instance_paths=rec.instance_paths, ) - self._geom_cache[index] = geom - return geom + self._geom_cache[index] = hydrated + self._lru_touch(index, hydrated) + return hydrated + + def _lru_touch(self, index: int, geom: Geometry) -> None: + cap = self.hydration_cache_size + if cap <= 0: + return + lru = self._geom_lru + lru[index] = geom + lru.move_to_end(index) + while len(lru) > cap: + lru.popitem(last=False) # --- diagnostics / pickling ----------------------------------------------------------- @@ -202,15 +231,18 @@ def nbytes(self) -> int: def __getstate__(self): # Buffer views (e.g. capsule-backed ndarrays from adacpp) coerce to bytes — - # the one accepted pickle-time copy. The weak cache never travels. + # the one accepted pickle-time copy. The hydration caches never travel. return { "blobs": [b if isinstance(b, bytes) else bytes(b) for b in self._blobs], "records": self._records, "compress": self.compress, + "hydration_cache_size": self.hydration_cache_size, } def __setstate__(self, state): self._blobs = state["blobs"] self._records = state["records"] self.compress = state["compress"] + self.hydration_cache_size = state.get("hydration_cache_size", 16) self._geom_cache = weakref.WeakValueDictionary() + self._geom_lru = OrderedDict() diff --git a/src/ada/occ/tessellating.py b/src/ada/occ/tessellating.py index f91108694..34f429173 100644 --- a/src/ada/occ/tessellating.py +++ b/src/ada/occ/tessellating.py @@ -798,13 +798,17 @@ def batch_tessellate( ada_obj = obj geom_repr = render_override.get(obj.guid, GeomRepr.SOLID) # A Shape carrying a bare curve geometry (sectionless SAT wire body, open - # wireframe) has no solid/shell — render it as glTF line geometry. Lazy - # ShapeProxy objects skip the sniff: it would hydrate the whole tree, and - # the store only ever holds B-rep solids/shells, never bare curves. - if geom_repr == GeomRepr.SOLID and not isinstance(obj, ShapeProxy): - _g = getattr(obj, "geom", None) - if _g is not None and isinstance(getattr(_g, "geometry", None), _CURVE_GEOM_TUPLE): - geom_repr = GeomRepr.LINE + # wireframe — incl. one round-tripped through IFC into the lazy store) has + # no solid/shell — render it as glTF line geometry. Lazy proxies answer + # from their store record instead of hydrating the whole tree. + if geom_repr == GeomRepr.SOLID: + if isinstance(obj, ShapeProxy): + if obj.is_bare_curve(): + geom_repr = GeomRepr.LINE + else: + _g = getattr(obj, "geom", None) + if _g is not None and isinstance(getattr(_g, "geometry", None), _CURVE_GEOM_TUPLE): + geom_repr = GeomRepr.LINE node_ref = graph_store.hash_map.get(obj.guid) if graph_store is not None else getattr(obj, "guid", None) # Lazy-blob fast path: a ShapeProxy backed by an NGEOM buffer tessellates diff --git a/tests/core/api/shapes/test_shape_store.py b/tests/core/api/shapes/test_shape_store.py index 4275dc8f4..12d555e20 100644 --- a/tests/core/api/shapes/test_shape_store.py +++ b/tests/core/api/shapes/test_shape_store.py @@ -96,7 +96,8 @@ def test_pickle_kind_roundtrips_bool_operations(): def test_weakref_cache_identity_and_release(): - store = ShapeStore() + # hydration_cache_size=0 -> pure weakref semantics (no strong LRU window) + store = ShapeStore(hydration_cache_size=0) idx = store.add_geometry(_shell_geometry()) g1 = store.geometry(idx) assert store.geometry(idx) is g1, "same live object while referenced" @@ -108,6 +109,24 @@ def test_weakref_cache_identity_and_release(): assert store.geometry(idx).id == "solid1" +def test_hydration_lru_keeps_hot_and_evicts_cold(): + """Back-to-back .geom access must not re-decode (consumers read it 3-5x per + call), while the strong window stays bounded — hydrate-all still returns to + the blob floor.""" + store = ShapeStore(hydration_cache_size=2) + idxs = [store.add_geometry(_shell_geometry(f"s{i}")) for i in range(4)] + + g0 = store.geometry(idxs[0]) + assert store.geometry(idxs[0]) is g0, "hot entry must be identity-stable with no outside refs" + + ref0 = weakref.ref(g0) + del g0 + for i in idxs[1:]: # push idx0 out of the 2-slot window + store.geometry(i) + gc.collect() + assert ref0() is None, "evicted entry must be reclaimable" + + def test_compression_roundtrip_both_kinds(): g = _shell_geometry() blob = _ngeom_blob(g) @@ -142,7 +161,9 @@ def test_proxy_is_shape_and_hydrates_via_property(): def test_proxy_pin_semantics(): - store = ShapeStore() + # hydration_cache_size=0: the strong LRU window would keep an unpinned mutation + # alive until eviction — the CONTRACT (pin before mutating) is what's tested. + store = ShapeStore(hydration_cache_size=0) idx = store.add_geometry(_shell_geometry()) p = ShapeProxy("solid1", store, idx) From 7997610567d47a2f36c1428462bb74cb7278cdd6 Mon Sep 17 00:00:00 2001 From: krande Date: Sat, 4 Jul 2026 08:53:33 +0200 Subject: [PATCH 011/151] perf: move ClosedShell promotion out of the streaming reader hot loop promote_closed_shell (edge-pairing closedness check) cost ~17% of native_stream_read_step on the crane, and the streaming exporters (obj/stl/step emit) that iterate it don't care about shell closedness. Promotion now happens only where it matters: lazy-store hydration (already there) and the eager from_step Assembly wrap. Co-Authored-By: Claude Fable 5 --- src/ada/api/spatial/part.py | 10 ++++++++-- src/ada/cadit/step/read/native_reader.py | 12 +++++++----- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/ada/api/spatial/part.py b/src/ada/api/spatial/part.py index 3b74e536d..c48fdff5b 100644 --- a/src/ada/api/spatial/part.py +++ b/src/ada/api/spatial/part.py @@ -745,7 +745,10 @@ def _mint_shape(shp_name, geometry) -> Shape: raise StepStreamUnsupported("reader='native' requires the adacpp stream_step_to_ngeom entry point") if use_native: - from ada.cadit.ngeom.deserialize import deserialize_geometries + from ada.cadit.ngeom.deserialize import ( + deserialize_geometries, + promote_closed_shell, + ) from ada.cadit.step.read.native_reader import native_stream_read_step_blobs from ada.cadit.step.write._solid_source import NATIVE_DECODE_ERRORS from ada.geom import Geometry @@ -763,9 +766,12 @@ def _mint_shape(shp_name, geometry) -> Shape: shp_name, store, idx, color=colour or color, opacity=opacity, units=source_units ) else: + # Eager Shapes get the ClosedShell promotion here (the lazy + # store applies it at hydration) — the streaming reader itself + # yields bare face-sets to keep the exporters' hot loop lean. geometry = Geometry( id=shp_name, - geometry=deserialize_geometries(blob)[0][1], + geometry=promote_closed_shell(deserialize_geometries(blob)[0][1]), color=color, transforms=(mats or None), instance_paths=(paths or None), diff --git a/src/ada/cadit/step/read/native_reader.py b/src/ada/cadit/step/read/native_reader.py index 522af7637..06ba1959a 100644 --- a/src/ada/cadit/step/read/native_reader.py +++ b/src/ada/cadit/step/read/native_reader.py @@ -51,17 +51,19 @@ def native_stream_read_step(step_path: str | pathlib.Path) -> Iterator[Geometry] Uses the streaming ``StepNgeomStream`` iterator (one solid's NGEOM buffer at a time, bounded memory), so it scales to large assemblies instead of materialising the whole parsed model.""" - from ada.cadit.ngeom.deserialize import deserialize_geometries, promote_closed_shell + from ada.cadit.ngeom.deserialize import deserialize_geometries for nbytes, gid, color, mats, paths in native_stream_read_step_blobs(step_path): decoded = deserialize_geometries(nbytes) # exactly one root per streamed buffer if not decoded: continue - # NGEOM does not record shell closedness; restore ClosedShell for manifold - # roots so downstream (solid_geom / STEP re-emit) matches the Python reader. - geometry = promote_closed_shell(decoded[0][1]) + # Roots are yielded as decoded (bare ConnectedFaceSet). The ClosedShell + # promotion (edge-pairing closedness check) costs ~17% of this loop on the + # crane and only matters where shells are re-exported or solid-built — the + # Assembly import paths apply promote_closed_shell at wrap/hydration; the + # streaming exporters (obj/stl/step emit) don't need it. yield Geometry( - id=gid, geometry=geometry, color=color, transforms=(mats or None), instance_paths=(paths or None) + id=gid, geometry=decoded[0][1], color=color, transforms=(mats or None), instance_paths=(paths or None) ) From ebf798ae018a4179ec28927864cc759a179147e5 Mon Sep 17 00:00:00 2001 From: krande Date: Sat, 4 Jul 2026 09:55:57 +0200 Subject: [PATCH 012/151] chore: use generic model descriptions in comments --- src/ada/cadit/step/read/native_reader.py | 8 ++++---- src/ada/config.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ada/cadit/step/read/native_reader.py b/src/ada/cadit/step/read/native_reader.py index 06ba1959a..4bc4de9ae 100644 --- a/src/ada/cadit/step/read/native_reader.py +++ b/src/ada/cadit/step/read/native_reader.py @@ -58,10 +58,10 @@ def native_stream_read_step(step_path: str | pathlib.Path) -> Iterator[Geometry] if not decoded: continue # Roots are yielded as decoded (bare ConnectedFaceSet). The ClosedShell - # promotion (edge-pairing closedness check) costs ~17% of this loop on the - # crane and only matters where shells are re-exported or solid-built — the - # Assembly import paths apply promote_closed_shell at wrap/hydration; the - # streaming exporters (obj/stl/step emit) don't need it. + # promotion (edge-pairing closedness check) costs ~17% of this loop on a + # large B-rep assembly and only matters where shells are re-exported or + # solid-built — the Assembly import paths apply promote_closed_shell at + # wrap/hydration; the streaming exporters (obj/stl/step emit) don't need it. yield Geometry( id=gid, geometry=decoded[0][1], color=color, transforms=(mats or None), instance_paths=(paths or None) ) diff --git a/src/ada/config.py b/src/ada/config.py index 2c5c814af..e8d90540e 100644 --- a/src/ada/config.py +++ b/src/ada/config.py @@ -155,7 +155,7 @@ class Config: # buffers from the native readers / pickled ada.geom otherwise) in a # shared ShapeStore, minting ShapeProxy objects that hydrate geometry # on demand (weakref-cached). ~5x lower resident memory on large STEP - # imports (Munin crane 2.6 GB -> ~0.5 GB settled). On by default; set + # imports (a 778 MB / 7291-solid STEP: 2.6 GB -> ~0.5 GB settled). On by default; set # ADA_CAD_LAZY_SHAPE_STORE=false to materialise eager Shapes as before. ConfigEntry("lazy_shape_store", bool, True, required=False), # Compress stored blobs (zlib-1, ~3x on B-rep buffers) at the cost of a From 8f11db489ba1ceb23c59f7ae210a5a005eed9952 Mon Sep 17 00:00:00 2001 From: krande Date: Sat, 4 Jul 2026 11:45:34 +0200 Subject: [PATCH 013/151] feat: audit profiling toggle also enables the adacpp C++ pipeline profiler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the admin profile_conversions setting is on, the fork child gets ADACPP_STEP_PROFILE=1 alongside cProfile: adacpp prints [STEPPROF] phase wall times, RSS at phase boundaries, VmHWM peak, per-solid stats and parallelism/IO pressure to stderr, which the captured job Log keeps — the C++ sibling of the .prof artefact. Scoped per job like the other env overrides; nothing is baked into the container. Co-Authored-By: Claude Fable 5 --- src/ada/comms/rest/worker.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/ada/comms/rest/worker.py b/src/ada/comms/rest/worker.py index 668be7c08..15e0c2f46 100644 --- a/src/ada/comms/rest/worker.py +++ b/src/ada/comms/rest/worker.py @@ -1172,6 +1172,14 @@ async def _read_bool_setting(key: str) -> str | None: v = await _read_bool_setting("profile_conversions") profile_enabled = (v or "").strip().lower() in {"1", "true", "yes", "on"} + if profile_enabled: + # The C++ sibling of the cProfile artefact: adacpp's env-gated + # [STEPPROF] pipeline profiler (phase wall times, RSS at phase + # boundaries, VmHWM peak, per-solid stats, parallelism/IO + # pressure) prints to stderr, which the captured job Log keeps. + # Applied inside the child fork only, so sibling jobs and the + # parent worker keep their pristine env. + env_overrides["ADACPP_STEP_PROFILE"] = "1" # Optional per-job wall-clock budget. Empty / 0 / non- # numeric leaves the watchdog off so legitimately-long From 4f1e960c636d49d315511a21150bb9cf8fb9161b Mon Sep 17 00:00:00 2001 From: krande Date: Sat, 4 Jul 2026 12:04:45 +0200 Subject: [PATCH 014/151] feat: C++ pipeline profiles in the audit Metrics panel When profile_conversions is on, the worker parses the adacpp profiler's [STEPPROF-JSON] summaries out of the captured child log into convert_meta.cpp_profile, and the Metrics tab renders them next to the Python profile: per-pipeline phase wall/RSS breakdown with share bars, kernel-exact peak (VmHWM), per-solid stats, achieved parallelism, disk IO pressure and per-thread utilisation. Torn/interleaved lines are skipped; profiling off leaves no trace in meta or output. Co-Authored-By: Claude Fable 5 --- src/ada/comms/rest/worker.py | 32 +++++++ .../src/components/admin/AuditLogTab.tsx | 85 +++++++++++++++++++ src/frontend/src/services/viewerApi.ts | 33 +++++++ 3 files changed, 150 insertions(+) diff --git a/src/ada/comms/rest/worker.py b/src/ada/comms/rest/worker.py index 15e0c2f46..49156884e 100644 --- a/src/ada/comms/rest/worker.py +++ b/src/ada/comms/rest/worker.py @@ -201,6 +201,35 @@ def _convert_meta_for(job: "Job", env_overrides: dict | None) -> dict | None: return meta or None +def _attach_cpp_profiles(convert_meta: dict | None, log_bytes: bytes | None) -> None: + """Parse the adacpp pipeline profiler's machine-readable summaries out of the + captured child output into ``convert_meta["cpp_profile"]``. + + When the ``profile_conversions`` toggle is on, the child runs with + ``ADACPP_STEP_PROFILE=1`` and each instrumented C++ pipeline prints ONE + ``[STEPPROF-JSON] {...}`` line at teardown (phase wall/RSS, VmHWM peak, + per-solid stats, parallelism/IO pressure, per-thread utilisation). Attaching + them to convert_meta puts the C++ side in the audit Metrics panel with the + same visibility as the Python timings. No-op when profiling was off (no + marker lines) or the log is empty.""" + if not isinstance(convert_meta, dict) or not log_bytes: + return + import json + + marker = b"[STEPPROF-JSON] " + profiles: list[dict] = [] + for line in log_bytes.splitlines(): + i = line.find(marker) + if i < 0: + continue + try: + profiles.append(json.loads(line[i + len(marker) :].decode("utf-8", "replace"))) + except (ValueError, UnicodeDecodeError): + continue # a torn/interleaved line must not fail the job + if profiles: + convert_meta["cpp_profile"] = profiles + + def _capture_worker_packages() -> list[dict]: """Snapshot the worker env's installed packages — the conda-meta manifest (authoritative for occt / pythonocc-core / ada-cpp / ifcopenshell / numpy …) @@ -1564,6 +1593,7 @@ async def _cancel_check() -> bool: metrics = dict(iresult.final_metrics) metrics["profile_key"] = await _maybe_upload_profile_bytes(iresult.profile_bytes) metrics["log_key"] = await _maybe_upload_log_bytes(iresult.log_bytes) + _attach_cpp_profiles(convert_meta, iresult.log_bytes) metrics["convert_meta"] = convert_meta await _audit_done( db_pool, @@ -1655,6 +1685,7 @@ async def _cancel_check() -> bool: metrics = dict(iresult.final_metrics) metrics["profile_key"] = await _maybe_upload_profile_bytes(iresult.profile_bytes) metrics["log_key"] = await _maybe_upload_log_bytes(iresult.log_bytes) + _attach_cpp_profiles(convert_meta, iresult.log_bytes) metrics["convert_meta"] = convert_meta await _audit_done( db_pool, @@ -1681,6 +1712,7 @@ async def _cancel_check() -> bool: metrics = dict(iresult.final_metrics) metrics["profile_key"] = await _maybe_upload_profile_bytes(iresult.profile_bytes) metrics["log_key"] = await _maybe_upload_log_bytes(iresult.log_bytes) + _attach_cpp_profiles(convert_meta, iresult.log_bytes) metrics["convert_meta"] = convert_meta await queue.update(job_id, status=JOB_STATUS_DONE, stage="ready", progress=1.0, error=None) diff --git a/src/frontend/src/components/admin/AuditLogTab.tsx b/src/frontend/src/components/admin/AuditLogTab.tsx index 0ab28a582..1a1ca9ba5 100644 --- a/src/frontend/src/components/admin/AuditLogTab.tsx +++ b/src/frontend/src/components/admin/AuditLogTab.tsx @@ -783,6 +783,88 @@ const WorkerPackages: React.FC<{imageTag: string}> = ({imageTag}) => { ); }; +// adacpp [STEPPROF-JSON] pipeline summaries (captured when "Profile conversions" +// is on): per-pipeline phase wall/RSS breakdown, kernel-exact peak (VmHWM), +// per-solid stats, achieved parallelism / IO pressure and per-thread utilisation +// — the C++ sibling of the Python profile below. +const CppProfilePanel: React.FC<{profiles: import("@/services/viewerApi").CppProfile[]}> = ({profiles}) => ( +
+
C++ pipeline profile
+ {profiles.map((p, i) => { + const wall = p.wall_ms > 0 ? p.wall_ms : 1; + return ( +
+
+
Pipeline
+
{p.label}
+
Wall
+
{formatDuration(p.wall_ms)}
+
Peak RSS
+
{formatBytes(p.peak_rss_mb * 1024 * 1024)} (VmHWM)
+ {p.cpu_s != null && ( + <>
CPU
+
{p.cpu_s.toFixed(1)} s{p.parallelism != null ? ` — ${p.parallelism.toFixed(2)}x cores busy` : ""}
+ )} + {(p.solids ?? 0) > 0 && ( + <>
Solids
+
{p.solids}{(p.tris ?? 0) > 0 ? ` (${p.tris} tris, max ${p.max_tris_solid}/solid)` : ""}
+ )} + {p.disk_read_mb != null && p.disk_read_mb > 0 && ( + <>
Disk read
+
{p.disk_read_mb.toFixed(0)} MB physical{p.majflt != null ? `, ${p.majflt} major faults` : ""}
+ )} +
+ {p.phases.length > 0 && ( + + + + + + + + + + + {p.phases.map((ph) => ( + + + + + + + ))} + +
phasemsRSSshare
{ph.name}{Math.round(ph.ms)}{Math.round(ph.rss_mb)} MB +
+
+
+
+ )} + {p.notes && Object.keys(p.notes).length > 0 && ( +
+ {Object.entries(p.notes).map(([k, v]) => ( + +
{k}
+
{v}
+
+ ))} +
+ )} + {(p.threads?.length ?? 0) > 0 && ( +
+ threads:{" "} + {p.threads!.map((t) => `t${t.tid}=${Math.round(t.busy_ms)}ms/${t.solids}s`).join(" ")} +
+ )} +
+ ); + })} +
+); + const MetricsTab: React.FC<{ entry: AuditEntry; onDownloadProfile: () => void; @@ -812,6 +894,9 @@ const MetricsTab: React.FC<{ {entry.convert_meta && } + {(entry.convert_meta?.cpp_profile?.length ?? 0) > 0 && ( + + )} {entry.worker_image_tag && } ; + // adacpp [STEPPROF-JSON] pipeline summaries, parsed from the captured child log when + // the profile_conversions toggle was on — one entry per instrumented C++ pipeline run. + cpp_profile?: CppProfile[]; +} + +export interface CppProfilePhase { + name: string; + ms: number; + rss_mb: number; +} + +export interface CppProfileThread { + tid: number; + solids: number; + busy_ms: number; +} + +export interface CppProfile { + label: string; + wall_ms: number; + peak_rss_mb: number; + cpu_s?: number; + parallelism?: number; + vctx?: number; + nvctx?: number; + disk_read_mb?: number; + majflt?: number; + solids?: number; + tris?: number; + max_tris_solid?: number; + phases: CppProfilePhase[]; + notes?: Record; + threads?: CppProfileThread[]; } export interface WorkerPackage { From 318afaed82c0acf1ecd12e38d7838bec83c3fd8d Mon Sep 17 00:00:00 2001 From: krande Date: Sat, 4 Jul 2026 12:29:33 +0200 Subject: [PATCH 015/151] feat: state filter dropdown in the admin audit log The audit log panel gains a state dropdown (queued / running / done / error) next to the action/target filters. Server-side: the /admin/audit route exposes a status param wired to list_audit's existing statuses filter, so it composes with keyset pagination instead of filtering one page client-side. Co-Authored-By: Claude Fable 5 --- src/ada/comms/rest/app.py | 4 ++++ src/frontend/src/components/admin/AuditLogTab.tsx | 8 ++++++++ src/frontend/src/services/viewerApi.ts | 2 ++ 3 files changed, 14 insertions(+) diff --git a/src/ada/comms/rest/app.py b/src/ada/comms/rest/app.py index a677a2b36..da82f2a7d 100644 --- a/src/ada/comms/rest/app.py +++ b/src/ada/comms/rest/app.py @@ -5063,6 +5063,7 @@ async def admin_audit( scope_id: str | None = None, action: str | None = None, target: str | None = None, + status: str | None = None, key: str | None = None, before_id: int | None = None, limit: int = 100, @@ -5073,6 +5074,8 @@ async def admin_audit( key_like = (key or "").strip() or None # ``target`` filters by the conversion's target format (glb / ifc / step / …). target_format = (target or "").strip().lstrip(".").lower() or None + # ``status`` filters by job state (queued / running / done / error). + status_norm = (status or "").strip().lower() or None rows = await db_module.list_audit( pool, user_sub=user_sub, @@ -5080,6 +5083,7 @@ async def admin_audit( scope_id=scope_id, action=action, target_format=target_format, + statuses=[status_norm] if status_norm else None, key_like=key_like, limit=limit, before_id=before_id, diff --git a/src/frontend/src/components/admin/AuditLogTab.tsx b/src/frontend/src/components/admin/AuditLogTab.tsx index 1a1ca9ba5..99566c773 100644 --- a/src/frontend/src/components/admin/AuditLogTab.tsx +++ b/src/frontend/src/components/admin/AuditLogTab.tsx @@ -20,6 +20,8 @@ import { const ACTIONS = ["", "upload", "download", "convert", "view", "render"]; const KINDS = ["", "shared", "project", "user"]; const TARGETS = ["", "glb", "ifc", "xml", "step", "stl", "obj", "sat"]; +// Job states the queue writes (queue.py JOB_STATUS_*) — server-side filter. +const STATUSES = ["", "queued", "running", "done", "error"]; const PROFILE_SETTING_KEY = "profile_conversions"; @@ -205,6 +207,12 @@ const AuditLogTab: React.FC = () => { onChange={(v) => onFilter({target: v || undefined})} placeholder="any target" /> + onFilter({status: v || undefined})} + placeholder="any state" + /> Date: Sun, 5 Jul 2026 12:50:09 +0200 Subject: [PATCH 016/151] feat: corpus tree file management, upload destination prompt, and Delete-key + shift-arrow selection Corpus tab tree view gains the storage panel's organize toolkit via a shared FileTreeView mutations layer: inline rename of files/folders, client-side pending folders (New folder / New subfolder), kebab menus (download, move-to-folder picker, upload-here, delete with cascades), and drag-and-drop moves of files and whole folder subtrees with a move-to-root strip. Drag payloads embed the source scope in a distinct MIME so drags straying between panels/scopes are ignored, not mis-moved. Checkbox multi-select feeds a bulk Move/Copy/Delete toolbar and multi-key drags. Both panels: "Upload files" now prompts for the destination (existing folder / new path / top level, default top level) via FolderPickerModal allowRoot; keyboard lists support shift+Arrow selection extension and Delete-key deletes (selection first, else the focused file/folder) with confirm dialogs listing exactly which keys go (previewKeyList). Corpus files can be server-side copied into the caller's personal scope (Garage CopyObject, keys preserved, existing keys skipped) per file or per selection. InlineNameInput extracted from StorageBrowser into components/common so both panels share it. Read-only FileTreeView callers are unaffected. Co-Authored-By: Claude Fable 5 --- .../src/components/admin/CorpusTab.tsx | 419 +++++++++++- .../src/components/admin/FileTreeView.tsx | 618 +++++++++++++++++- .../components/common/FolderPickerModal.tsx | 40 +- .../src/components/common/InlineNameInput.tsx | 52 ++ .../src/components/storage/StorageBrowser.tsx | 138 ++-- src/frontend/src/utils/storage/fileTree.ts | 9 + 6 files changed, 1156 insertions(+), 120 deletions(-) create mode 100644 src/frontend/src/components/common/InlineNameInput.tsx diff --git a/src/frontend/src/components/admin/CorpusTab.tsx b/src/frontend/src/components/admin/CorpusTab.tsx index b114d6020..d26d112a9 100644 --- a/src/frontend/src/components/admin/CorpusTab.tsx +++ b/src/frontend/src/components/admin/CorpusTab.tsx @@ -1,7 +1,14 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from "react"; import {Corpus, FileEntry, viewerApi} from "@/services/viewerApi"; -import {buildFileTree} from "@/utils/storage/fileTree"; -import FileTreeView from "./FileTreeView"; +import { + buildFileTree, + collectFolderPaths, + loadPendingFolders, + previewKeyList, + savePendingFolders, +} from "@/utils/storage/fileTree"; +import FileTreeView, {FileTreeMutations} from "./FileTreeView"; +import FolderPickerModal from "@/components/common/FolderPickerModal"; // Admin tab — manage proprietary regression corpora (M3 of the audit // panel design in plan/v2/notes_admin_audit_panel.md). @@ -14,6 +21,13 @@ import FileTreeView from "./FileTreeView"; // // The trigger form on the Audit Runs tab picks a corpus by slug from // the same /admin/corpora list this tab maintains. +// +// Tree mode carries the storage panel's organize affordances (via the +// shared FileTreeView mutations): rename / move / delete files and +// folders, drag-and-drop moves, client-side pending folders, and a +// checkbox / shift+arrow multi-select feeding a bulk Move/Delete +// toolbar. Server ops go through the admin endpoints (corpus scopes +// are admin-only on every axis). const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; @@ -24,6 +38,19 @@ function fmtBytes(n: number): string { return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`; } +function dirnameOf(key: string): string { + const i = key.lastIndexOf("/"); + return i >= 0 ? key.slice(0, i) : ""; +} + +function basenameOf(key: string): string { + return key.split("/").pop() ?? key; +} + +function normKey(key: string): string { + return key.replace(/^\/+/, ""); +} + // Flat-list ⇄ folder-tree representation switch. Storage is flat on the // server; tree mode just groups the keys' "/" segments. Shared by the // corpus file overview and the copy-from-scope modal. @@ -388,6 +415,8 @@ const CorpusFiles: React.FC<{corpus: Corpus}> = ({corpus}) => { const scope = `corpus:${corpus.slug}`; const [files, setFiles] = useState([]); const [err, setErr] = useState(null); + // Transient success line (e.g. copy-to-personal outcome). + const [note, setNote] = useState(null); const [uploading, setUploading] = useState(null); const [progress, setProgress] = useState(0); const [copyOpen, setCopyOpen] = useState(false); @@ -421,24 +450,103 @@ const CorpusFiles: React.FC<{corpus: Corpus}> = ({corpus}) => { useEffect(() => { void reload(); }, [reload]); - const onPick = useCallback(async (e: React.ChangeEvent) => { - const files = Array.from(e.target.files ?? []); - if (files.length === 0) return; + // Client-side "pending" empty folders — storage is prefix-based so + // they have no server representation until a file lands in them. + // Persisted per corpus scope; pruned once a real key appears + // underneath (same mechanics as StorageBrowser). + const [pendingFolders, setPendingFolders] = useState( + () => loadPendingFolders("corpus", scope), + ); + useEffect(() => { + savePendingFolders("corpus", scope, pendingFolders); + }, [scope, pendingFolders]); + useEffect(() => { + setPendingFolders((prev) => { + const next = prev.filter( + (p) => !files.some((f) => normKey(f.key).startsWith(p + "/")), + ); + return next.length === prev.length ? prev : next; + }); + }, [files]); + const removePendingFoldersUnder = (path: string) => { + setPendingFolders((prev) => + prev.filter((p) => p !== path && !p.startsWith(path + "/")), + ); + }; + // Rename/move of a pending (empty) folder is pure client state. + const rekeyPendingFolders = (oldPath: string, newPath: string) => { + setPendingFolders((prev) => prev.map((p) => ( + p === oldPath + ? newPath + : p.startsWith(oldPath + "/") + ? newPath + p.slice(oldPath.length) + : p + ))); + }; + const folderHasKeys = useCallback( + (path: string) => files.some((f) => normKey(f.key).startsWith(path + "/")), + [files], + ); + + // Where the tree's "new folder" inline input shows ("" = top level). + const [newFolderAt, setNewFolderAt] = useState(null); + // Destination-folder modal shared by the upload and move flows. + const [picker, setPicker] = useState<{ + title: string; + allowRoot?: boolean; + submitLabel?: string; + onPick: (folder: string) => Promise | void; + } | null>(null); + + // Multi-select (tree mode): checkbox / shift+arrow selection set + // feeding the bulk Move/Delete toolbar. Dragging a selected row + // drags the whole set (FileTreeView handles that). + const [selected, setSelected] = useState>(() => new Set()); + const setSelection = useCallback((keys: string[], select: boolean) => { + setSelected((prev) => { + const next = new Set(prev); + if (select) keys.forEach((k) => next.add(k)); + else keys.forEach((k) => next.delete(k)); + return next; + }); + }, []); + const clearSelection = () => setSelected(new Set()); + // Drop selection entries whose keys vanished (moved/renamed/deleted). + useEffect(() => { + setSelected((prev) => { + const live = new Set(files.map((f) => f.key)); + const next = new Set(Array.from(prev).filter((k) => live.has(k))); + return next.size === prev.size ? prev : next; + }); + }, [files]); + + const existingFolderPaths = useMemo( + () => Array.from(new Set([ + ...collectFolderPaths(files, (f) => f.key), + ...pendingFolders, + ])).sort((a, b) => a.localeCompare(b)), + [files, pendingFolders], + ); + + // Upload a batch sequentially into an optional folder prefix. Pin + // autoConvert:false so we don't auto-generate derived blobs for + // corpus uploads — the audit dispatcher does that on demand when + // the sweep fires. A failed file is reported without aborting the + // batch. + const uploadFilesTo = useCallback(async (list: File[], folder?: string) => { + if (list.length === 0) return; setErr(null); - // Reuse the existing per-scope upload path. Pin autoConvert:false so we - // don't auto-generate derived blobs for corpus uploads — the audit - // dispatcher does that on demand when the sweep fires. Sequential; a - // failed file is reported without aborting the batch. const {uploadFile} = await import("@/utils/scene/handlers/upload_source_file"); const failures: string[] = []; - for (let i = 0; i < files.length; i++) { - const file = files[i]; - setUploading(files.length > 1 ? `${file.name} (${i + 1}/${files.length})` : file.name); + for (let i = 0; i < list.length; i++) { + const file = list[i]; + setUploading(list.length > 1 ? `${file.name} (${i + 1}/${list.length})` : file.name); setProgress(0); try { await uploadFile(file, { autoConvert: false, scope, + folder, onProgress: (loaded, total) => setProgress(total > 0 ? loaded / total : 0), }); } catch (e) { @@ -447,11 +555,25 @@ const CorpusFiles: React.FC<{corpus: Corpus}> = ({corpus}) => { } setUploading(null); setProgress(0); - if (inputRef.current) inputRef.current.value = ""; if (failures.length) setErr(failures.join("; ")); await reload(); }, [scope, reload]); + // Upload button flow: pick the files first, then prompt for the + // destination folder — an existing folder, a new path, or the top + // level (the default). + const onPickUpload = useCallback((e: React.ChangeEvent) => { + const picked = Array.from(e.target.files ?? []); + if (inputRef.current) inputRef.current.value = ""; + if (picked.length === 0) return; + setPicker({ + title: `Upload ${picked.length} file${picked.length === 1 ? "" : "s"} to`, + allowRoot: true, + submitLabel: "Upload", + onPick: (folder) => void uploadFilesTo(picked, folder || undefined), + }); + }, [uploadFilesTo]); + const onDelete = useCallback(async (key: string) => { if (!confirm(`Delete ${key} from ${corpus.slug}? This can't be undone.`)) return; try { @@ -462,6 +584,182 @@ const CorpusFiles: React.FC<{corpus: Corpus}> = ({corpus}) => { } }, [scope, corpus.slug, reload]); + const alertFailures = (failed: Array<{key: string; reason: string}>) => { + if (failed.length > 0) { + window.alert(failed.map((f) => `${f.key}: ${f.reason}`).join("\n")); + } + }; + + const runFolderMove = useCallback(async (folderPath: string, newPath: string) => { + if (newPath === folderPath) return; + try { + const allKeys = files.map((f) => f.key); + const r = await viewerApi.adminRenameOrMoveFolder(scope, folderPath, newPath, allKeys); + alertFailures(r.failed); + removePendingFoldersUnder(folderPath); + await reload(); + } catch (e) { + setErr((e as Error).message || "folder move failed"); + } + }, [files, scope, reload]); + + const moveKeys = useCallback(async (keys: string[], destFolder: string) => { + try { + if (destFolder === "") { + // Move-to-root: the move endpoint requires a non-empty + // folder, so root moves are per-key renames to the + // basename. + for (const k of keys) { + await viewerApi.adminRenameKey(scope, k, basenameOf(k)); + } + } else { + const r = await viewerApi.adminMoveKeysToFolder(scope, keys, destFolder); + alertFailures(r.failed); + } + clearSelection(); + await reload(); + } catch (e) { + setErr((e as Error).message || "move failed"); + } + }, [scope, reload]); + + // Folder subtree lands at ``destFolder``/``basename`` ("" = root). + const moveFolderTo = (path: string, destFolder: string) => { + const base = basenameOf(path); + const newPath = destFolder ? `${destFolder}/${base}` : base; + if (!folderHasKeys(path)) { + rekeyPendingFolders(path, newPath); + return; + } + void runFolderMove(path, newPath); + }; + + // Shared by the bulk toolbar and the Delete key: confirm with an + // overview of exactly what goes, then delete sequentially. + const deleteKeysWithConfirm = useCallback(async (keys: string[]) => { + if (keys.length === 0) return; + if (!confirm( + `Delete ${keys.length} file${keys.length === 1 ? "" : "s"} from ${corpus.slug}? ` + + "This can't be undone.\n\n" + + previewKeyList(keys), + )) return; + try { + for (const k of keys) { + await viewerApi.adminDeleteBlob(scope, k); + } + setSelected(new Set()); + await reload(); + } catch (e) { + setErr((e as Error).message || "delete failed"); + } + }, [scope, corpus.slug, reload]); + + // Server-side copy (Garage CopyObject) corpus → the caller's + // personal scope, preserving keys. Existing keys are skipped, not + // overwritten — same semantics as the copy-into-corpus modal. + const copyToPersonal = useCallback(async (keys: string[]) => { + if (keys.length === 0) return; + setNote(null); + try { + const r = await viewerApi.adminCopyKeysFromScope("user:me", scope, keys); + alertFailures(r.failed); + setNote( + `copied ${r.copied.length} to your files` + + (r.skipped.length > 0 ? ` · skipped ${r.skipped.length} (already there)` : ""), + ); + } catch (e) { + setErr((e as Error).message || "copy to personal scope failed"); + } + }, [scope]); + + const mutations: FileTreeMutations = { + renameFile: (key, newName) => { + const dir = dirnameOf(key); + const newKey = dir ? `${dir}/${newName}` : newName; + void (async () => { + try { + await viewerApi.adminRenameKey(scope, key, newKey); + await reload(); + } catch (e) { + setErr((e as Error).message || "rename failed"); + } + })(); + }, + renameFolder: (path, newName) => { + const parent = dirnameOf(path); + const newPath = parent ? `${parent}/${newName}` : newName; + if (!folderHasKeys(path)) { + rekeyPendingFolders(path, newPath); + return; + } + void runFolderMove(path, newPath); + }, + moveKeys: (keys, destFolder) => void moveKeys(keys, destFolder), + moveFolder: moveFolderTo, + deleteFile: (key) => void onDelete(key), + deleteFolder: (path, fileCount) => { + if (fileCount === 0) { + // Pending (empty) folder — pure client state. + removePendingFoldersUnder(path); + return; + } + const prefix = path + "/"; + const targets = files.filter((f) => normKey(f.key).startsWith(prefix)); + if (!confirm( + `Delete folder "${path}" and its ${fileCount} file${fileCount === 1 ? "" : "s"} ` + + `from ${corpus.slug}? This can't be undone.\n\n` + + previewKeyList(targets.map((t) => t.key)), + )) return; + void (async () => { + try { + // Sequential: deletes cascade derived blobs server-side + // and parallel calls would race on the storage listing. + for (const t of targets) { + await viewerApi.adminDeleteBlob(scope, t.key); + } + removePendingFoldersUnder(path); + await reload(); + } catch (e) { + setErr((e as Error).message || "folder delete failed"); + } + })(); + }, + createFolder: (parent, name) => { + // ``_derived`` is where the converter parks derived blobs — + // a user folder with that name would collide with the cache + // prefix. + if (!parent && name === "_derived") { + window.alert(`"${name}" is a reserved name`); + return; + } + const path = parent ? `${parent}/${name}` : name; + setPendingFolders((prev) => (prev.includes(path) ? prev : [...prev, path])); + }, + requestMoveFile: (key) => setPicker({ + title: `Move "${key}" to folder`, + onPick: (folder) => void moveKeys([key], folder), + }), + requestMoveFolder: (path) => setPicker({ + title: `Move folder "${path}" into`, + onPick: (dest) => moveFolderTo(path, dest), + }), + deleteKeys: (keys) => void deleteKeysWithConfirm(keys), + uploadTo: (folder, list) => void uploadFilesTo(list, folder || undefined), + downloadFile: (key) => void viewerApi.downloadBlob(scope, key, basenameOf(key)), + }; + + const onMoveSelected = () => { + const keys = Array.from(selected); + if (keys.length === 0) return; + setPicker({ + title: `Move ${keys.length} file${keys.length === 1 ? "" : "s"} to folder`, + onPick: (folder) => void moveKeys(keys, folder), + }); + }; + + const showTree = viewMode === "tree" && + (files.length > 0 || pendingFolders.length > 0 || newFolderAt !== null); + return (
@@ -472,14 +770,22 @@ const CorpusFiles: React.FC<{corpus: Corpus}> = ({corpus}) => { )}
- {files.length > 0 && ( - + + {viewMode === "tree" && ( + )} @@ -514,8 +820,47 @@ const CorpusFiles: React.FC<{corpus: Corpus}> = ({corpus}) => { {err && (
{err}
)} + {note && !err && ( +
{note}
+ )} + {viewMode === "tree" && selected.size > 0 && ( +
+ + {selected.size} selected + + + + + +
+ )}
- {files.length === 0 && !err && ( + {files.length === 0 && !err && pendingFolders.length === 0 && newFolderAt === null && (
No files yet. Upload representative source files (STEP / IFC / RMED / etc.) to drive regression sweeps from the @@ -547,7 +892,7 @@ const CorpusFiles: React.FC<{corpus: Corpus}> = ({corpus}) => { - + {fmtBytes(f.size)} )} />
)}
+ setPicker(null)} + onPick={(folder) => { + const action = picker?.onPick; + setPicker(null); + if (action) void action(folder); + }} + />
); }; diff --git a/src/frontend/src/components/admin/FileTreeView.tsx b/src/frontend/src/components/admin/FileTreeView.tsx index 4b9373912..9b4e26a1f 100644 --- a/src/frontend/src/components/admin/FileTreeView.tsx +++ b/src/frontend/src/components/admin/FileTreeView.tsx @@ -8,14 +8,43 @@ import FileTypeIcon from "../icons/FileTypeIcon"; import FolderClosedIcon from "../icons/FolderClosedIcon"; import FolderOpenIcon from "../icons/FolderOpenIcon"; import ChevronRightIcon from "../icons/ChevronRightIcon"; +import InlineNameInput from "@/components/common/InlineNameInput"; +import {RowKebabMenu, KebabMenuItem} from "@/components/common/RowKebabMenu"; -// Generic, read-mostly folder-tree view shared by the admin Corpus tab's -// file overview and its "Copy from scope" modal. Storage stays flat on the -// server; folders are presentational (a key's "/" segments). The tree shape -// itself is built by the caller via ``buildFileTree`` from -// ``@/utils/storage/fileTree`` — this component only renders + manages -// collapse state, mirroring StorageBrowser's row look (indent per depth, -// chevron + folder glyph, file rows) without its drag/rename/scene machinery. +// Generic folder-tree view shared by the admin Corpus tab's file +// overview, its "Copy from scope" modal, and the utility file picker. +// Storage stays flat on the server; folders are presentational (a +// key's "/" segments). The tree shape itself is built by the caller +// via ``buildFileTree`` from ``@/utils/storage/fileTree`` — this +// component renders + manages collapse state, mirroring +// StorageBrowser's row look (indent per depth, chevron + folder +// glyph, file rows). +// +// Read-only by default. Passing ``mutations`` turns on the organize +// affordances from the storage panel: per-row kebab menus (rename / +// move / delete / new subfolder / upload here), inline rename inputs, +// and drag-and-drop moves between folders (plus a move-to-root strip). +// The component owns the interaction state; the server semantics — +// endpoints, confirm dialogs, list reloads — stay with the caller. + +// Drag payload MIMEs. Deliberately distinct from StorageBrowser's +// ``application/x-adapy-keys`` (which carries a bare key array): the +// payload here embeds the source scope and drops verify it, so a drag +// that strays in from another panel (possibly another scope) is +// ignored instead of mis-moved. Types are readable during dragover, +// the payload only on drop — presence of the type alone distinguishes +// internal drags from OS-file drops. +const TREE_KEYS_MIME = "application/x-adapy-tree-keys"; +const TREE_FOLDER_MIME = "application/x-adapy-tree-folder"; + +function dirnameOf(key: string): string { + const i = key.lastIndexOf("/"); + return i >= 0 ? key.slice(0, i) : ""; +} + +function basenameOf(key: string): string { + return key.split("/").pop() ?? key; +} export interface FileTreeSelection { selected: ReadonlySet; @@ -24,6 +53,39 @@ export interface FileTreeSelection { onSelect: (keys: string[], select: boolean) => void; } +/** Write affordances. All semantics (server calls, confirm dialogs, + * list reloads, pending-folder bookkeeping) live in the caller — the + * tree only runs the interaction (menus, inline inputs, drag & drop) + * and validates names before dispatching. */ +export interface FileTreeMutations { + /** Inline rename committed: new basename for the file (no "/"). */ + renameFile: (key: string, newName: string) => void; + /** Inline rename committed: new single-segment name for the folder. */ + renameFolder: (path: string, newName: string) => void; + /** Drag-drop move of file keys into ``destFolder`` ("" = root). */ + moveKeys: (keys: string[], destFolder: string) => void; + /** Drag-drop move of a whole folder under ``destFolder`` ("" = root). */ + moveFolder: (folderPath: string, destFolder: string) => void; + deleteFile: (key: string) => void; + /** ``fileCount`` = files under the prefix; 0 means a client-side + * pending (empty) folder the caller can drop without a server call. */ + deleteFolder: (path: string, fileCount: number) => void; + /** Bulk delete — the Delete key targets the whole selection when + * one exists. The caller confirms with an overview of the keys. */ + deleteKeys?: (keys: string[]) => void; + /** "New folder" input committed (single-segment name; ``parent`` + * "" = root). The caller materialises it (pending-folder state). */ + createFolder: (parent: string, name: string) => void; + /** Open the caller's move-to-folder picker for one file. */ + requestMoveFile?: (key: string) => void; + /** Open the caller's move-folder-into picker. */ + requestMoveFolder?: (path: string) => void; + /** Upload OS files into a folder ("" = root) — used by the folder + * menu's "Upload here…" and by OS-file drops. Omit to disable both. */ + uploadTo?: (folder: string, files: File[]) => void; + downloadFile?: (key: string) => void; +} + interface FileTreeViewProps { nodes: FileTreeNode[]; /** Stable identity for a file entry — the storage key. */ @@ -39,6 +101,17 @@ interface FileTreeViewProps { isDisabled?: (file: T) => boolean; /** Right-aligned per-file slot for actions / labels (size, delete, …). */ renderFileTail?: (file: T) => React.ReactNode; + /** Enable the organize affordances (see FileTreeMutations). */ + mutations?: FileTreeMutations; + /** Caller-specific kebab items appended between the standard file + * actions and Delete (e.g. the corpus tab's "Copy to my files"). */ + extraFileMenuItems?: (key: string) => KebabMenuItem[]; + /** Where the "new folder" inline input shows: "" = top level, a + * folder path = inside it, null/undefined = hidden. Owned by the + * caller so a header-level button can trigger root creation; the + * folder menu's "New subfolder…" routes through it too. */ + newFolderAt?: string | null; + onNewFolderAtChange?: (v: string | null) => void; } // Every enabled descendant file key under a node — drives folder-level @@ -90,6 +163,10 @@ export function FileTreeView({ selection, isDisabled, renderFileTail, + mutations, + extraFileMenuItems, + newFolderAt, + onNewFolderAtChange, }: FileTreeViewProps): React.ReactElement { // Default fully collapsed; persisted per-scope so expand state survives // reloads but doesn't leak across scopes (matches StorageBrowser). @@ -110,6 +187,255 @@ export function FileTreeView({ return next; }); }; + // Optimistic expand-state re-key after a rename/move dispatch — + // harmless if the server-side move fails (a stale entry just means + // a collapsed folder). + const rekeyExpanded = (oldPath: string, newPath: string) => { + setExpanded((prev) => { + const next = new Set(); + for (const p of prev) { + if (p === oldPath) next.add(newPath); + else if (p.startsWith(oldPath + "/")) next.add(newPath + p.slice(oldPath.length)); + else next.add(p); + } + return next; + }); + }; + + // Inline rename target. One at a time, like StorageBrowser. + const [renaming, setRenaming] = useState<{kind: "file" | "folder"; path: string} | null>(null); + // In-flight drag payload (for row dimming + the move-to-root strip). + const [dragKeys, setDragKeys] = useState(null); + const [dragFolder, setDragFolder] = useState(null); + // Folder path currently hovered by an accepted drag. + const [dropTarget, setDropTarget] = useState(null); + // Keyboard-navigation focus, keyed `folder:` / `file:`. + // Pointer interactions move it too, so arrows continue from the + // last clicked row (mirrors StorageBrowser). + const [focusedKey, setFocusedKey] = useState(null); + const wrapRef = useRef(null); + useEffect(() => { + if (!focusedKey) return; + const el = wrapRef.current?.querySelector( + `[data-rowkey="${CSS.escape(focusedKey)}"]`, + ) as HTMLElement | null; + el?.scrollIntoView({block: "nearest"}); + }, [focusedKey]); + + // Hidden input backing the folder menu's "Upload here…". Clicked + // synchronously inside the menu item's onClick so the file picker + // keeps the user-activation gesture (iOS Safari refuses otherwise). + const uploadInputRef = useRef(null); + const uploadTargetRef = useRef(""); + + const acceptsDrop = (e: React.DragEvent) => + !!mutations && + (e.dataTransfer.types.includes(TREE_KEYS_MIME) || + e.dataTransfer.types.includes(TREE_FOLDER_MIME) || + (!!mutations.uploadTo && e.dataTransfer.types.includes("Files"))); + + const onDragStartFile = (key: string) => (e: React.DragEvent) => { + // Dragging a selected row drags the whole selection; dragging + // an unselected row drags just that file (matches StorageBrowser). + const keys = selection?.selected.has(key) + ? Array.from(selection.selected) + : [key]; + e.dataTransfer.setData(TREE_KEYS_MIME, JSON.stringify({scope, keys})); + e.dataTransfer.effectAllowed = "move"; + setDragKeys(keys); + }; + const onDragStartFolder = (path: string) => (e: React.DragEvent) => { + e.dataTransfer.setData(TREE_FOLDER_MIME, JSON.stringify({scope, path})); + e.dataTransfer.effectAllowed = "move"; + setDragFolder(path); + }; + const clearDragState = () => { + setDragKeys(null); + setDragFolder(null); + setDropTarget(null); + }; + + // Drop onto a folder path ("" = root). Internal drags move keys or + // a whole folder subtree; OS-file drops upload into the folder. + const handleDrop = (target: string, e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + clearDragState(); + if (!mutations) return; + const rawFolder = e.dataTransfer.getData(TREE_FOLDER_MIME); + if (rawFolder) { + let p: {scope?: unknown; path?: unknown}; + try { + p = JSON.parse(rawFolder); + } catch { + return; + } + if (p.scope !== scope || typeof p.path !== "string") return; + const folderPath = p.path; + // No-ops: into itself, into its own subtree, or where it + // already lives. + if (target === folderPath || target.startsWith(folderPath + "/")) return; + if (dirnameOf(folderPath) === target) return; + const base = basenameOf(folderPath); + rekeyExpanded(folderPath, target ? `${target}/${base}` : base); + mutations.moveFolder(folderPath, target); + return; + } + const rawKeys = e.dataTransfer.getData(TREE_KEYS_MIME); + if (rawKeys) { + let p: {scope?: unknown; keys?: unknown}; + try { + p = JSON.parse(rawKeys); + } catch { + return; + } + if (p.scope !== scope || !Array.isArray(p.keys)) return; + const keys = p.keys.filter( + (k): k is string => typeof k === "string" && dirnameOf(k) !== target, + ); + if (keys.length > 0) mutations.moveKeys(keys, target); + return; + } + if (e.dataTransfer.files?.length && mutations.uploadTo) { + mutations.uploadTo(target, Array.from(e.dataTransfer.files)); + } + }; + + // ── Commit handlers (validate, then dispatch to the caller) ───── + const onRenameFileCommit = (key: string, displayName: string, raw: string) => { + setRenaming(null); + if (!mutations) return; + const name = raw.trim(); + if (!name || name === displayName) return; + if (name.includes("/")) { + window.alert("Name must not contain '/' — use Move to folder… instead"); + return; + } + mutations.renameFile(key, name); + }; + const onRenameFolderCommit = (path: string, raw: string) => { + setRenaming(null); + if (!mutations) return; + const name = raw.trim().replace(/^\/+|\/+$/g, ""); + if (!name || name === basenameOf(path)) return; + if (name.includes("/")) { + window.alert("Rename must be a single name; use Move folder into… for nested moves"); + return; + } + const parent = dirnameOf(path); + rekeyExpanded(path, parent ? `${parent}/${name}` : name); + mutations.renameFolder(path, name); + }; + const onCreateFolderCommit = (parent: string, raw: string) => { + onNewFolderAtChange?.(null); + if (!mutations) return; + const name = raw.trim().replace(/^\/+|\/+$/g, ""); + if (!name) return; + if (name.includes("/")) { + window.alert("Folder name must not contain '/'"); + return; + } + setExpanded((prev) => { + const next = new Set(prev); + if (parent) next.add(parent); + next.add(parent ? `${parent}/${name}` : name); + return next; + }); + mutations.createFolder(parent, name); + }; + + // ── Kebab menu builders ────────────────────────────────────────── + const fileMenuItems = (key: string): KebabMenuItem[] => { + if (!mutations) return []; + const items: KebabMenuItem[] = []; + if (mutations.downloadFile) { + items.push({ + key: "download", + label: "Download", + onClick: () => mutations.downloadFile!(key), + }); + } + items.push({ + key: "rename", + label: "Rename…", + onClick: () => setRenaming({kind: "file", path: key}), + }); + if (mutations.requestMoveFile) { + items.push({ + key: "move-to-folder", + label: "Move to folder…", + onClick: () => mutations.requestMoveFile!(key), + }); + } + items.push(...(extraFileMenuItems?.(key) ?? [])); + items.push({ + key: "delete", + label: "Delete", + destructive: true, + separatorBefore: true, + onClick: () => mutations.deleteFile(key), + }); + return items; + }; + const folderMenuItems = (path: string, fileCount: number): KebabMenuItem[] => { + if (!mutations) return []; + const items: KebabMenuItem[] = []; + if (mutations.uploadTo) { + items.push({ + key: "upload-here", + label: "Upload here…", + onClick: () => { + uploadTargetRef.current = path; + uploadInputRef.current?.click(); + }, + }); + } + items.push({ + key: "new-subfolder", + label: "New subfolder…", + onClick: () => { + setExpanded((prev) => new Set(prev).add(path)); + onNewFolderAtChange?.(path); + }, + }); + items.push({ + key: "rename", + label: "Rename folder…", + title: "Sibling-name rename. Subfolders preserved.", + onClick: () => setRenaming({kind: "folder", path}), + }); + if (mutations.requestMoveFolder) { + items.push({ + key: "move-into", + label: "Move folder into…", + title: "Move under a destination prefix. Subfolders preserved.", + onClick: () => mutations.requestMoveFolder!(path), + }); + } + items.push({ + key: "delete", + label: `Delete folder (${fileCount} file${fileCount === 1 ? "" : "s"})`, + destructive: true, + separatorBefore: true, + onClick: () => mutations.deleteFolder(path, fileCount), + }); + return items; + }; + + const newFolderInputRow = (parent: string, depth: number) => ( +
  • + + onCreateFolderCommit(parent, v)} + onCancel={() => onNewFolderAtChange?.(null)} + /> +
  • + ); const renderNode = (node: FileTreeNode, depth: number): React.ReactNode => { const indentPx = depth * 12; @@ -121,11 +447,18 @@ export function FileTreeView({ if (!selection || disabled) return; selection.onSelect([key], !checked); }; + const isRenaming = renaming?.kind === "file" && renaming.path === key; + const menuItems = fileMenuItems(key); + const rowKey = `file:${key}`; return (
  • ({ : "hover:bg-gray-800/60 ") } style={{paddingLeft: 8 + indentPx}} - onClick={onRowSelect} + onClick={() => { + setFocusedKey(rowKey); + onRowSelect(); + }} + draggable={(!!mutations && !isRenaming) || undefined} + onDragStart={mutations ? onDragStartFile(key) : undefined} + onDragEnd={mutations ? clearDragState : undefined} + // Internal drops on a file row are a no-op (folders are + // the targets) — swallowed so they don't bubble to the + // background handler and move to root. OS-file drops + // land in the row's folder. + onDragOver={mutations ? (e) => { + if (!acceptsDrop(e)) return; + e.preventDefault(); + e.stopPropagation(); + } : undefined} + onDrop={mutations ? (e) => { + e.preventDefault(); + e.stopPropagation(); + clearDragState(); + if ( + e.dataTransfer.getData(TREE_KEYS_MIME) || + e.dataTransfer.getData(TREE_FOLDER_MIME) + ) return; + if (e.dataTransfer.files?.length && mutations.uploadTo) { + mutations.uploadTo(dirnameOf(key), Array.from(e.dataTransfer.files)); + } + } : undefined} > {selection && ( ({ /> )} - - {node.displayName} - + {isRenaming ? ( + onRenameFileCommit(key, node.displayName, v)} + onCancel={() => setRenaming(null)} + /> + ) : ( + + {node.displayName} + + )} {renderFileTail && ( e.stopPropagation()}> {renderFileTail(node.file)} )} + {menuItems.length > 0 && ( + e.stopPropagation()}> + {key}} + items={menuItems} + /> + + )}
  • ); } @@ -171,18 +550,43 @@ export function FileTreeView({ if (!selection || total === 0) return; selection.onSelect(fileKeys, !allSelected); }; + const isRenaming = renaming?.kind === "folder" && renaming.path === node.path; + const menuItems = folderMenuItems(node.path, total); + const isDropTarget = dropTarget === node.path; + const rowKey = `folder:${node.path}`; return ( - +
  • toggleFolder(node.path)} + onClick={() => { + setFocusedKey(rowKey); + toggleFolder(node.path); + }} role="button" aria-expanded={isOpen} aria-label={`${isOpen ? "Collapse" : "Expand"} folder ${node.name}`} + draggable={(!!mutations && !isRenaming) || undefined} + onDragStart={mutations ? onDragStartFolder(node.path) : undefined} + onDragEnd={mutations ? clearDragState : undefined} + onDragOver={mutations ? (e) => { + if (!acceptsDrop(e)) return; + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = "move"; + setDropTarget(node.path); + } : undefined} + onDragLeave={mutations ? () => { + setDropTarget((prev) => (prev === node.path ? null : prev)); + } : undefined} + onDrop={mutations ? (e) => handleDrop(node.path, e) : undefined} > {selection && ( ({ ) : ( )} - - {node.name}/ + {isRenaming ? ( + onRenameFolderCommit(node.path, v)} + onCancel={() => setRenaming(null)} + /> + ) : ( + + {node.name}/ + + )} + + {total === 0 ? "empty" : total} - {total} + {menuItems.length > 0 && ( + e.stopPropagation()}> + {node.path}/} + items={menuItems} + /> + + )}
  • + {isOpen && newFolderAt === node.path && newFolderInputRow(node.path, depth + 1)} {isOpen && node.children.map((c) => renderNode(c, depth + 1))}
    ); }; + const showRootDropStrip = + (dragKeys !== null && dragKeys.some((k) => dirnameOf(k) !== "")) || + (dragFolder !== null && dirnameOf(dragFolder) !== ""); + + // ── Keyboard navigation over the visible rows ──────────────────── + // Flattened render order of what's currently on screen. Shift+ + // Arrow extends the selection while moving focus (multi-select + // without a pointer); plain arrows just move focus. Space toggles + // the focused file; Enter toggles a folder; Delete removes the + // selection (or the focused row when nothing is selected). + type FlatRow = + | {kind: "folder"; path: string; count: number} + | {kind: "file"; key: string; disabled: boolean}; + const flatRows: FlatRow[] = []; + { + const walk = (ns: FileTreeNode[]) => { + for (const n of ns) { + if (n.kind === "folder") { + flatRows.push({ + kind: "folder", + path: n.path, + count: collectFileKeys(n, getKey).length, + }); + if (expanded.has(n.path)) walk(n.children); + } else { + const k = getKey(n.file); + flatRows.push({ + kind: "file", + key: k, + disabled: isDisabled?.(n.file) ?? false, + }); + } + } + }; + walk(nodes); + } + const rowKeyOf = (r: FlatRow) => (r.kind === "folder" ? `folder:${r.path}` : `file:${r.key}`); + + const onListKeyDown = (e: React.KeyboardEvent) => { + if (flatRows.length === 0) return; + if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Enter", " ", "Delete"].includes(e.key)) return; + // Don't steal keys from the inline rename/new-folder inputs. + if ((e.target as HTMLElement).tagName === "INPUT") return; + e.preventDefault(); + e.stopPropagation(); + const idx = focusedKey ? flatRows.findIndex((r) => rowKeyOf(r) === focusedKey) : -1; + const row = idx >= 0 ? flatRows[idx] : null; + const selectRow = (r: FlatRow | null) => { + if (!selection || !r || r.kind !== "file" || r.disabled) return; + selection.onSelect([r.key], true); + }; + const focusAt = (i: number, extendSelection: boolean) => { + const clamped = Math.max(0, Math.min(flatRows.length - 1, i)); + if (extendSelection) { + // Anchor the range on the row we're leaving, then take + // the row we land on with us. + selectRow(row); + selectRow(flatRows[clamped]); + } + setFocusedKey(rowKeyOf(flatRows[clamped])); + }; + switch (e.key) { + case "ArrowDown": + focusAt(idx < 0 ? 0 : idx + 1, e.shiftKey); + break; + case "ArrowUp": + focusAt(idx < 0 ? flatRows.length - 1 : idx - 1, e.shiftKey); + break; + case "ArrowRight": + if (row?.kind === "folder" && !expanded.has(row.path)) toggleFolder(row.path); + break; + case "ArrowLeft": + if (row?.kind === "folder" && expanded.has(row.path)) toggleFolder(row.path); + break; + case "Enter": + if (row?.kind === "folder") toggleFolder(row.path); + break; + case " ": + if (row?.kind === "file" && selection && !row.disabled) { + selection.onSelect([row.key], !selection.selected.has(row.key)); + } + break; + case "Delete": + if (!mutations) break; + if (selection && selection.selected.size > 0) { + // A selection takes precedence over the focused row — + // no deleteKeys handler means no keyboard bulk delete + // (never silently fall back to the focused file). + mutations.deleteKeys?.(Array.from(selection.selected)); + break; + } + if (row?.kind === "file" && !row.disabled) { + mutations.deleteFile(row.key); + } else if (row?.kind === "folder") { + mutations.deleteFolder(row.path, row.count); + } + break; + } + }; + return ( -
      - {nodes.map((n) => renderNode(n, 0))} -
    +
    { + if (acceptsDrop(e)) e.preventDefault(); + } : undefined} + onDrop={mutations ? (e) => handleDrop("", e) : undefined} + > + {mutations?.uploadTo && ( + { + const picked = Array.from(e.target.files ?? []); + e.target.value = ""; + if (picked.length > 0) { + mutations.uploadTo!(uploadTargetRef.current, picked); + } + }} + /> + )} + {showRootDropStrip && ( +
    { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + }} + onDrop={(e) => handleDrop("", e)} + > + Drop here to move to root / +
    + )} +
      + {newFolderAt === "" && newFolderInputRow("", 0)} + {nodes.map((n) => renderNode(n, 0))} +
    +
    ); } diff --git a/src/frontend/src/components/common/FolderPickerModal.tsx b/src/frontend/src/components/common/FolderPickerModal.tsx index e6e331c4b..6778d3e4b 100644 --- a/src/frontend/src/components/common/FolderPickerModal.tsx +++ b/src/frontend/src/components/common/FolderPickerModal.tsx @@ -33,13 +33,21 @@ export interface FolderPickerModalProps { /** Button label — defaults to "Move". Folder-rename / move-into * flows pass a more specific verb. */ submitLabel?: string; + /** Offer a "Top level (root)" choice and make it the default. + * Used by upload-destination prompts where root is the common + * case; ``onPick`` receives ``""`` when it's chosen. Move flows + * leave this off (a move to "" is handled by drag-to-root). */ + allowRoot?: boolean; } +type PickMode = "root" | "existing" | "new"; + const FolderPickerModal: React.FC = ({ - open, title, existingFolders, initialNew, onCancel, onPick, submitLabel, + open, title, existingFolders, initialNew, onCancel, onPick, submitLabel, allowRoot, }) => { const hasExisting = existingFolders.length > 0; - const [mode, setMode] = useState<"existing" | "new">(hasExisting ? "existing" : "new"); + const defaultMode: PickMode = allowRoot ? "root" : hasExisting ? "existing" : "new"; + const [mode, setMode] = useState(defaultMode); const [selected, setSelected] = useState(existingFolders[0] ?? ""); const [newPath, setNewPath] = useState(initialNew ?? ""); const newInputRef = useRef(null); @@ -49,10 +57,10 @@ const FolderPickerModal: React.FC = ({ // would see stale state. useEffect(() => { if (!open) return; - setMode(hasExisting ? "existing" : "new"); + setMode(defaultMode); setSelected(existingFolders[0] ?? ""); setNewPath(initialNew ?? ""); - }, [open, hasExisting, existingFolders, initialNew]); + }, [open, defaultMode, existingFolders, initialNew]); // Focus the new-folder input the moment the user switches to // that mode, mirroring the prompt() ergonomics. @@ -65,6 +73,10 @@ const FolderPickerModal: React.FC = ({ if (!open) return null; const submit = () => { + if (mode === "root") { + onPick(""); + return; + } const value = mode === "existing" ? selected : newPath; const trimmed = value.trim().replace(/^\/+|\/+$/g, ""); if (!trimmed) return; @@ -89,6 +101,21 @@ const FolderPickerModal: React.FC = ({ {title}
    + {allowRoot && ( + + )} {hasExisting && (
    {/* Flat list of every loaded model, whatever storage folder @@ -53,6 +55,8 @@ const SceneInfoBox = () => { ) : mode === "section" ? ( + ) : mode === "mesh" ? ( + ) : ( )} diff --git a/src/frontend/src/state/meshPanelStore.ts b/src/frontend/src/state/meshPanelStore.ts index 8cfa2df02..70cebbdb1 100644 --- a/src/frontend/src/state/meshPanelStore.ts +++ b/src/frontend/src/state/meshPanelStore.ts @@ -2,17 +2,12 @@ import {create} from "zustand"; import {persist} from "zustand/middleware"; import {DEFAULT_SPIKE_THRESHOLDS} from "@/utils/mesh_select/meshStats"; -// State for the dedicated "Mesh" inspection panel (Menu bar → Mesh). The panel scans every geom in -// the scene for "crows-nest" tessellation spikes (see meshStats.ts) and lists the offenders in a -// distortion-sorted table. The two spike thresholds are editable here so a scan can be re-run -// tighter or looser than the gallery's fixed defaults; they persist as a viewing preference. The -// scan RESULTS are transient and live in the panel component, not the store. +// Editable spike thresholds for the Scene panel's "Mesh" mode (Scene dropdown → Mesh). The section +// scans every geom in the scene for "crows-nest" tessellation spikes (see meshStats.ts) and lists +// the offenders in a distortion-sorted table; these two thresholds let a scan be re-run tighter or +// looser than the gallery's fixed defaults, and persist as a viewing preference. Visibility is owned +// by sceneInfoStore (mode === "mesh"); scan RESULTS are transient in the section component. interface MeshPanelState { - visible: boolean; - setVisible: (v: boolean) => void; - toggle: () => void; - - // Editable spike thresholds (defaults mirror meshStats' module constants). spikeAspectMin: number; spikeOutlierK: number; setSpikeAspectMin: (v: number) => void; @@ -23,10 +18,6 @@ interface MeshPanelState { export const useMeshPanelStore = create()( persist( (set) => ({ - visible: false, - setVisible: (visible) => set({visible}), - toggle: () => set((s) => ({visible: !s.visible})), - spikeAspectMin: DEFAULT_SPIKE_THRESHOLDS.spikeAspectMin, spikeOutlierK: DEFAULT_SPIKE_THRESHOLDS.spikeOutlierK, setSpikeAspectMin: (spikeAspectMin) => set({spikeAspectMin}), @@ -39,10 +30,7 @@ export const useMeshPanelStore = create()( }), { name: "ada-mesh-panel", - // Persist the editable thresholds; visibility is a per-session UI state we still keep so - // the panel re-opens where the user left it. partialize: (s) => ({ - visible: s.visible, spikeAspectMin: s.spikeAspectMin, spikeOutlierK: s.spikeOutlierK, }), diff --git a/src/frontend/src/state/sceneInfoStore.ts b/src/frontend/src/state/sceneInfoStore.ts index 18533829d..c1ee360ce 100644 --- a/src/frontend/src/state/sceneInfoStore.ts +++ b/src/frontend/src/state/sceneInfoStore.ts @@ -45,7 +45,7 @@ export interface UtilityResult { summary?: Record; } -export type SceneInfoMode = 'info' | 'utilities' | 'section' | 'fem'; +export type SceneInfoMode = 'info' | 'utilities' | 'section' | 'fem' | 'mesh'; interface SceneInfoState { show_scene_info_box: boolean; From e2327e54fbd3e6279403bca8f4e908742fdc1056 Mon Sep 17 00:00:00 2001 From: krande Date: Thu, 9 Jul 2026 19:33:42 +0200 Subject: [PATCH 096/151] =?UTF-8?q?feat(viewer):=20mesh=20triangle-visibil?= =?UTF-8?q?ity=20toggles=20in=20the=20Scene=E2=86=92Mesh=20distortion=20se?= =?UTF-8?q?ction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit To judge tessellation/welding quality, the Mesh section now surfaces two edge controls (reusing the existing optionsStore edge overlay): "Mesh edges" (live on/off of the geometry-edge overlay) and "Triangles" (shows the full triangulation grid, not just feature edges — baked at load, so it carries the same "(reload)" note as the options drawer). Lets you see the actual triangles when comparing the welded vs unwelded / native vs Python alternatives. tsc clean, vite build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../info_box_scene/MeshDistortionSection.tsx | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/frontend/src/components/info_box_scene/MeshDistortionSection.tsx b/src/frontend/src/components/info_box_scene/MeshDistortionSection.tsx index 0a52784cb..c4eac535b 100644 --- a/src/frontend/src/components/info_box_scene/MeshDistortionSection.tsx +++ b/src/frontend/src/components/info_box_scene/MeshDistortionSection.tsx @@ -1,5 +1,6 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from "react"; import {useMeshPanelStore} from "@/state/meshPanelStore"; +import {useOptionsStore} from "@/state/optionsStore"; import {collectGeomEntries, focusGeomEntry, endGeomWalk, type GeomEntry} from "@/utils/scene/galleryWalk"; import {queryNameFromRangeId} from "@/utils/mesh_select/queryMeshDrawRange"; @@ -26,6 +27,7 @@ const num = (n: number, digits = 1) => (Number.isFinite(n) ? n.toFixed(digits) : const MeshDistortionSection: React.FC = () => { const {spikeAspectMin, spikeOutlierK, setSpikeAspectMin, setSpikeOutlierK, resetThresholds} = useMeshPanelStore(); + const {showEdges, setShowEdges, hideTessellationEdges, setHideTessellationEdges} = useOptionsStore(); const [rows, setRows] = useState([]); const [scanning, setScanning] = useState(false); @@ -102,6 +104,29 @@ const MeshDistortionSection: React.FC = () => { + {/* Mesh triangle visibility — see the triangulation to judge tessellation/welding. Edge + overlay toggles live; the full triangulation grid is baked at load (needs a reload). */} +
    + + +
    + {/* Thresholds — edit then Rescan. */}