diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index d4d722d..bbabe1d 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -2191,7 +2191,14 @@ class StudioMainWindow(QMainWindow): that on its worker thread. Without it the pane could only compare the asset with itself, which is how a scene built on a different mapping would still look matched. + + The release keys travel the same way (from the preferences, paths only): a signed + or sealed package is refused by the container without them, which left a + legitimate protected release unopenable from the application. """ + from orisys_studio.scene3d.keys import resolve_scene_keys + + self.mesh3d_panel.set_keys(resolve_scene_keys(self.prefs)) self.mesh3d_panel.set_sensor( self.sensor_picker.sensor_type(), expected_provider=self._device_geometry_provider(), diff --git a/apps/studio/src/orisys_studio/scene3d/asset_source.py b/apps/studio/src/orisys_studio/scene3d/asset_source.py index b904c2d..c0c2990 100644 --- a/apps/studio/src/orisys_studio/scene3d/asset_source.py +++ b/apps/studio/src/orisys_studio/scene3d/asset_source.py @@ -43,6 +43,7 @@ from orisys_app_shared.scene3d.contracts import ( diagnostic, error, ) +from orisys_studio.scene3d.keys import SceneKeys, check_release_requirement if TYPE_CHECKING: # the description is a value object; the callable returns one from orisys_app_shared.acquisition.geometry import DeviceGeometry @@ -124,20 +125,37 @@ def no_asset_diagnostic(sensor_type: str, *, override: str | Path | None = None) def load_package( - source: SceneSource, expected: Optional[ExpectedGeometry] = None + source: SceneSource, + expected: Optional[ExpectedGeometry] = None, + keys: "Optional[SceneKeys]" = None, ) -> SceneAssets: - """Read and validate a package. Runs on a worker thread; raises ``ThreeDError``.""" + """Read and validate a package. Runs on a worker thread; raises ``ThreeDError``. + + ``keys`` carries the release key material (a verification key and/or a content-key + provider) and the release-mode flag. Without it a *signed* package is refused by the + container itself ("a verification key is required"), which left the application with + no way to open a legitimate protected release at all. + """ from orisys_app_shared.scene3d.package import PackagedAssetLoader if not source.path.is_file(): raise error("asset_rejected", f"the scene package is missing: {source.path}") - return PackagedAssetLoader().load(source.path, expected) + requirement = check_release_requirement(source.path, keys) + if requirement is not None: + raise error(requirement.code, requirement.message, **dict(requirement.details)) + return PackagedAssetLoader().load( + source.path, + expected, + keys=getattr(keys, "provider", None), + verify_key_pem=getattr(keys, "verify_key_pem", None), + ) def _resolve_and_load( source: SceneSource, expected: Optional[ExpectedGeometry], provider: "Optional[Callable[[], DeviceGeometry]]", + keys: "Optional[SceneKeys]" = None, ) -> tuple[SceneAssets, "Optional[DeviceGeometry]"]: """Worker-thread half: build the *device's* description, then read the package. @@ -150,7 +168,7 @@ def _resolve_and_load( if expected is None and provider is not None: device = provider() expected = device.expected - return load_package(source, expected), device + return load_package(source, expected, keys), device class SceneAssetLoader(QObject): @@ -179,10 +197,11 @@ class SceneAssetLoader(QObject): expected: Optional[ExpectedGeometry] = None, *, expected_provider: "Optional[Callable[[], DeviceGeometry]]" = None, + keys: "Optional[SceneKeys]" = None, ) -> int: """Ask for a scene; returns the request id this call was given.""" self._request_id += 1 - request = (source, expected, expected_provider, self._request_id) + request = (source, expected, expected_provider, self._request_id, keys) if self._inflight: self._pending = request return self._request_id @@ -190,13 +209,15 @@ class SceneAssetLoader(QObject): return self._request_id def _start(self, request) -> None: - source, expected, provider, request_id = request + source, expected, provider, request_id, keys = request if self._executor is None: self._executor = ThreadPoolExecutor( max_workers=1, thread_name_prefix="scene3d-assets" ) self._inflight = True - future = self._executor.submit(_resolve_and_load, source, expected, provider) + future = self._executor.submit( + _resolve_and_load, source, expected, provider, keys + ) future.add_done_callback( lambda done: self._finished(done, source, request_id) ) diff --git a/apps/studio/src/orisys_studio/scene3d/coordinator.py b/apps/studio/src/orisys_studio/scene3d/coordinator.py index 298cb42..7dcbac1 100644 --- a/apps/studio/src/orisys_studio/scene3d/coordinator.py +++ b/apps/studio/src/orisys_studio/scene3d/coordinator.py @@ -131,6 +131,11 @@ class ThreeDCoordinator: #: Without this the pane re-uploaded the same rest frame on every 16 ms tick #: while it waited for the first measurement. self._rest_presented = False + #: A monotonic budget for *starting* composes: the window ticks at 16 ms, and + #: "display rate" is a promise to the operator, not a description of the timer. + self._last_compose_start = float("-inf") + #: How long the last accepted result took (compose + queue), in milliseconds. + self._last_result_latency_ms = 0.0 #: The field frame currently on screen, so a gain change can redraw exactly #: it rather than a newer instant. self._last_field_frame: Optional[SurfaceFieldFrame] = None @@ -154,6 +159,23 @@ class ThreeDCoordinator: """What is on screen, and the monotonic time it was handed to the widget.""" return self._last_presented + def last_result_latency_ms(self) -> float: + """How long the last accepted compose took, from start to acceptance.""" + return self._last_result_latency_ms + + def display_period_s(self) -> float: + """The shortest interval between two compose starts, from ``max_fps``.""" + return 1.0 / max(1, int(self._settings.max_fps)) + + def has_pending_frame(self) -> bool: + """Whether a produced frame is already waiting for the timer. + + The producer asks this before *building* a snapshot: the mailbox keeps only the + newest frame, so the O(H×W) copy that would be dropped is work nobody displays. + A frame is never lost that would have been shown — it is only not made twice. + """ + return self._incoming.peek() is not None + @property def superseded_frames(self) -> int: """Frames the display never showed. Never a recording drop.""" @@ -203,6 +225,11 @@ class ThreeDCoordinator: self._settings_dirty = False self._stale_since = None self._rest_presented = False + # The next frame after a claim boundary composes at once: the operator just + # asked for something, and making them wait out the display period adds delay to + # no purpose. + self._last_compose_start = float("-inf") + self._last_result_latency_ms = 0.0 item = self._inflight if item is not None: item.future.cancel() @@ -382,17 +409,23 @@ class ThreeDCoordinator: return self._collect_inflight() self._expire_stale() - if self._inflight is None: + # One compose per display period. The window's timer is 16 ms whatever the panel + # says, so without this "15 fps" was a stored number nobody obeyed. + period = self.display_period_s() + now = self._clock() + if self._inflight is None and (now - self._last_compose_start) >= period: if self._settings_dirty and self._last_field_frame is not None: # A gain change re-draws the field that is already on screen; taking # a newer one would show a different instant than the one the # operator just changed the exaggeration of. self._settings_dirty = False + self._last_compose_start = now self._start_compose(self._last_field_frame) else: self._settings_dirty = False frame = self._incoming.take() if frame is not None: + self._last_compose_start = now self._start_compose(frame) composed = self._composed.take() if composed is not None: @@ -580,4 +613,7 @@ class ThreeDCoordinator: # screen for something that is no longer being measured. self._stale_frames += 1 return + # From the compose's own start to here: the cost plus however long it waited for + # a tick. A number the operator can compare with the frame period. + self._last_result_latency_ms = (self._clock() - item.started) * 1000.0 self._composed.offer(composed) diff --git a/apps/studio/src/orisys_studio/scene3d/gl_backend.py b/apps/studio/src/orisys_studio/scene3d/gl_backend.py index 92a51fe..147c5e8 100644 --- a/apps/studio/src/orisys_studio/scene3d/gl_backend.py +++ b/apps/studio/src/orisys_studio/scene3d/gl_backend.py @@ -512,7 +512,7 @@ class GLRenderer: self._sensing_part_id: str = "" self._estimated_gpu_bytes: int = 0 self._initialized = False - self._displayed_frames = 0 + self._paint_calls = 0 self._last_upload_ms = 0.0 # ------------------------------------------------------------------ lifetime @@ -660,9 +660,12 @@ class GLRenderer: ) GL.glBindVertexArray(0) GL.glUseProgram(0) - self._displayed_frames += 1 + self._paint_calls += 1 return RenderStats( - displayed_frames=self._displayed_frames, + # The renderer counts *draws*; the widget counts unique frames. Keeping the + # two apart is what lets a slow camera move be told from a slow pipeline. + displayed_frames=0, + paint_calls=self._paint_calls, # The real cost of the frame's compose is carried on the frame itself # (``RenderFrame.compose_ms``): the shared composer counter is rewritten by # the next task, and reading it here reported the wrong frame's cost. @@ -703,7 +706,7 @@ class GLRenderer: self._scalar_range = None self._last_scale = None self._last_scale_mode = "" - self._displayed_frames = 0 + self._paint_calls = 0 def release_parts(self) -> None: from OpenGL import GL diff --git a/apps/studio/src/orisys_studio/scene3d/keys.py b/apps/studio/src/orisys_studio/scene3d/keys.py new file mode 100644 index 0000000..1268344 --- /dev/null +++ b/apps/studio/src/orisys_studio/scene3d/keys.py @@ -0,0 +1,138 @@ +"""Where the application's release keys come from. + +A scene package can be signed (provenance) and sealed (a content key): the container +format supports both, and the loader accepts a ``KeyProvider`` and a verification key. +The application, however, never passed either — so a legitimate release package was +refused with "this package is signed; a verification key is required", and the only way +to run it was to ship a plain package or turn the check off. That is precisely the +decision this module keeps out of the engineer's hands. + +The keys are therefore *configuration*: paths in the preferences file, resolved here into +the two things the loader wants, plus a release-mode flag that refuses an unsigned +package. Nothing here logs or returns key material; :meth:`SceneKeys.describe` reports +paths and booleans so a recording can say how a package was authenticated. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Mapping, Optional + +from orisys_app_shared.scene3d.contracts import Diagnostic, diagnostic + +logger = logging.getLogger(__name__) + +#: Preferences keys. Paths only — never key material, and never a private key: signing +#: happens on the build machine (see ``tools/scene_assets/compile_scene.py``). +PREF_VERIFY_KEY = "scene3d_verify_key" +PREF_CONTENT_KEY_FILE = "scene3d_content_key_file" +PREF_CONTENT_KEY_ID = "scene3d_content_key_id" +PREF_REQUIRE_SIGNATURE = "scene3d_require_signature" + + +@dataclass(frozen=True) +class SceneKeys: + """What the loader needs to authenticate a package — and how strict to be.""" + + verify_key_pem: Optional[bytes] = None + provider: Any = None + require_signature: bool = False + #: Paths/ids only, for logs and for the recording: never material. + sources: Mapping[str, str] = field(default_factory=dict) + + def authenticated_by_key(self) -> bool: + return self.verify_key_pem is not None + + def describe(self) -> dict: + """What is configured, in a form safe to log and to record. + + Booleans for "is this piece present" and the *paths* it came from; never key + material, and never the private half of anything. + """ + return { + "verify_key": bool(self.verify_key_pem), + "content_key": bool(self.provider is not None), + "require_signature": bool(self.require_signature), + **{str(key): str(value) for key, value in self.sources.items()}, + } + + +def resolve_scene_keys(prefs: Optional[Mapping[str, Any]]) -> SceneKeys: + """Build the key material the preferences describe. Never raises for absence. + + A missing or unreadable file is a *diagnostic-bearing state*, not a crash: the 2D + application must start and run, and only the 3D pane is affected. The reason is kept + so the pane can say why a protected package was refused instead of looking broken. + """ + prefs = dict(prefs or {}) + sources: dict[str, str] = {} + verify_pem: Optional[bytes] = None + provider = None + + verify_path = str(prefs.get(PREF_VERIFY_KEY) or "").strip() + if verify_path: + path = Path(verify_path).expanduser() + sources["verify_key_path"] = str(path) + if path.is_file(): + try: + verify_pem = path.read_bytes() + except OSError: + logger.warning("the 3D verification key is unreadable: %s", path) + sources["verify_key_error"] = "unreadable" + else: + logger.warning("the configured 3D verification key is missing: %s", path) + sources["verify_key_error"] = "missing" + + key_file = str(prefs.get(PREF_CONTENT_KEY_FILE) or "").strip() + if key_file: + path = Path(key_file).expanduser() + key_id = str(prefs.get(PREF_CONTENT_KEY_ID) or "").strip() + sources["content_key_file"] = str(path) + if key_id: + sources["content_key_id"] = key_id + if path.is_file() and key_id: + try: + from orisys_app_shared.scene3d import protect + + provider = protect.load_key_file(path, key_id=key_id) + except Exception as exc: # noqa: BLE001 - reported, never fatal here + logger.warning("the 3D content key file was refused: %s", type(exc).__name__) + sources["content_key_error"] = str(getattr(exc, "diagnostic", exc)) + elif not key_id: + sources["content_key_error"] = "no key id configured" + else: + sources["content_key_error"] = "missing" + + return SceneKeys( + verify_key_pem=verify_pem, + provider=provider, + require_signature=bool(prefs.get(PREF_REQUIRE_SIGNATURE, False)), + sources=sources, + ) + + +def check_release_requirement(path: Path, keys: Optional[SceneKeys]) -> Optional[Diagnostic]: + """Refuse an unsigned package when the application is in release mode. + + Development builds accept a plain package (the shipped engineering one is exactly + that); a release build is asked to say so explicitly rather than rely on whoever + compiled the package remembering to sign it. + """ + if keys is None or not keys.require_signature: + return None + from orisys_app_shared.scene3d.package import describe_envelope + + try: + summary = describe_envelope(Path(path)) + except Exception as exc: # noqa: BLE001 - the loader will report the real problem + logger.debug("could not read the envelope of %s: %s", path, exc) + return None + if not summary.get("signed"): + return diagnostic( + "asset_auth_failed", + "this build requires signed scene packages and this one is not signed", + package=Path(path).name, + ) + return None diff --git a/apps/studio/src/orisys_studio/scene3d/view.py b/apps/studio/src/orisys_studio/scene3d/view.py index f7e2aab..eaec5b8 100644 --- a/apps/studio/src/orisys_studio/scene3d/view.py +++ b/apps/studio/src/orisys_studio/scene3d/view.py @@ -115,6 +115,8 @@ class Scene3DView(QWidget): self._reported_diagnostic: Optional[Diagnostic] = None #: Whether the loaded asset was compared against the device's own description. self._device_verified = False + #: Frames the producer did not even build (a frame was already waiting). + self._skipped_snapshots = 0 self._layout = QVBoxLayout(self) self._layout.setContentsMargins(0, 0, 0, 0) @@ -294,6 +296,12 @@ class Scene3DView(QWidget): identity = self.identity_for(packet) if identity is None: return + if coordinator.has_pending_frame(): + # A produced frame is still waiting for the timer: building another one + # would be an O(H×W) copy the mailbox drops. It is not a dropped frame — + # the frame it would have replaced is still there to be shown. + self._skipped_snapshots += 1 + return frame = snapshot(packet, identity) if frame is not None: coordinator.submit(frame) @@ -590,16 +598,40 @@ class Scene3DView(QWidget): } def render_totals(self) -> dict: - """Display-side counters: shown frames and display drops, never recording ones.""" + """Display-side counters: shown frames and display drops, never recording ones. + + "Shown" and the compose cost come from the *widget*, which counts what was + presented — not from the last ``RenderStats``, which only exists after a paint (a + headless or offscreen pane never paints, and it still received every frame). + """ coordinator = self._coordinator + widget = self._widget stats = self._last_stats + shown = ( + int(widget.displayed_frames()) + if widget is not None + else int(getattr(stats, "displayed_frames", 0) or 0) + ) + compose_ms = ( + float(widget.last_compose_ms()) + if widget is not None + else float(getattr(stats, "compose_ms", 0.0) or 0.0) + ) return { - "shown": int(getattr(stats, "displayed_frames", 0) or 0), + "shown": shown, "dropped_display": int( coordinator.superseded_frames if coordinator is not None else 0 ), "stale": int(getattr(coordinator, "stale_frames", 0) or 0), "invalid": int(getattr(coordinator, "invalid_frames", 0) or 0), + # Work the producer deliberately did not do because a frame was waiting. + "skipped_producer": int(self._skipped_snapshots), + # A *real* cost, per frame, plus how long the result took to be accepted. + "compose_ms": compose_ms, + "latency_ms": float( + coordinator.last_result_latency_ms() if coordinator is not None else 0.0 + ), + "paint_calls": int(getattr(stats, "paint_calls", 0) or 0), } def close(self) -> None: diff --git a/apps/studio/src/orisys_studio/scene3d/widget.py b/apps/studio/src/orisys_studio/scene3d/widget.py index 15dabdd..c6d4874 100644 --- a/apps/studio/src/orisys_studio/scene3d/widget.py +++ b/apps/studio/src/orisys_studio/scene3d/widget.py @@ -76,6 +76,10 @@ class Sensor3DWidget(QOpenGLWidget): self._needs_scene_upload = False self._needs_surface_upload = False self._last_stats_at = 0.0 + #: What reached the screen, counted by identity, and what it cost to compose. + self._unique_frames = 0 + self._last_frame_identity: Optional[FrameIdentity] = None + self._last_compose_ms = 0.0 self._last_press: Optional[tuple[float, float]] = None self._unavailable = False #: Frames the display never showed, set by the coordinator. Never a @@ -97,6 +101,12 @@ class Sensor3DWidget(QOpenGLWidget): """Keep the newest CPU frame and ask for a repaint. Never requires a context.""" self._frame = frame self._needs_surface_upload = True + # Count *data*, not drawing: a camera move or a colour-scale change repaints the + # same measurement, and the old counter incremented on every paintGL. + if frame.identity != self._last_frame_identity: + self._last_frame_identity = frame.identity + self._unique_frames += 1 + self._last_compose_ms = float(getattr(frame, "compose_ms", 0.0) or 0.0) self.descriptorChanged.emit(frame.scalar_descriptor) self.update() @@ -110,6 +120,14 @@ class Sensor3DWidget(QOpenGLWidget): def capabilities(self) -> Optional[GLCapabilities]: return self._capabilities + def displayed_frames(self) -> int: + """Unique frames handed to this widget (a repaint is not a new frame).""" + return int(self._unique_frames) + + def last_compose_ms(self) -> float: + """What the frame on screen cost to compose, measured where it was composed.""" + return float(self._last_compose_ms) + def diagnostic(self) -> Optional[Diagnostic]: return self._diagnostic @@ -220,9 +238,12 @@ class Sensor3DWidget(QOpenGLWidget): self._last_stats_at = now self.renderStatsChanged.emit( RenderStats( - displayed_frames=stats.displayed_frames, + # Frames that reached the screen, by identity; ``paint_calls`` is the + # renderer's own count of how often it was asked to draw. + displayed_frames=self._unique_frames, superseded_frames=self._superseded, - compose_ms=stats.compose_ms, + paint_calls=stats.paint_calls, + compose_ms=self._last_compose_ms, upload_ms=stats.upload_ms, paint_ms=(now - started) * 1000.0, estimated_gpu_bytes=stats.estimated_gpu_bytes, diff --git a/apps/studio/src/orisys_studio/ui/mesh3d_panel.py b/apps/studio/src/orisys_studio/ui/mesh3d_panel.py index 0f3554c..08ef087 100644 --- a/apps/studio/src/orisys_studio/ui/mesh3d_panel.py +++ b/apps/studio/src/orisys_studio/ui/mesh3d_panel.py @@ -87,6 +87,8 @@ class Mesh3dPanel(QFrame): #: request describes a selection the operator has already moved away from. self._load_request = 0 self._device = None + #: Release key material from the preferences (``scene3d.keys.SceneKeys``). + self._keys = None #: The package whose scene is in the view, so switching sensor away and back #: does not reload it (and does not pretend to be loading it either). self._loaded_path: Optional[Path] = None @@ -209,9 +211,19 @@ class Mesh3dPanel(QFrame): self._loader.loaded.connect(self._on_loaded) self._loader.failed.connect(self._on_load_failed) self._load_request = self._loader.load( - self._source, self._expected, expected_provider=self._expected_provider + self._source, + self._expected, + expected_provider=self._expected_provider, + keys=self._keys, ) + def set_keys(self, keys) -> None: + """The release key material (see ``scene3d.keys``). Paths, never material.""" + self._keys = keys + + def keys(self): + return self._keys + # -------------------------------------------------------------- settings def set_display_settings(self, settings) -> None: """Persisted display settings, applied now if the view exists. @@ -257,6 +269,9 @@ class Mesh3dPanel(QFrame): "reason": reason, "scene": { "path": str(self._source.path) if self._source is not None else "", + # How the package was authenticated, when it was: paths and booleans + # only, never key material (see ``scene3d.keys``). + "keys": self._keys.describe() if self._keys is not None else {}, **dict(described.get("scene") or {}), }, "gl": dict(described.get("gl") or {}), diff --git a/apps/studio/tests/test_scene3d_coordinator.py b/apps/studio/tests/test_scene3d_coordinator.py index 2b48d22..7e8155d 100644 --- a/apps/studio/tests/test_scene3d_coordinator.py +++ b/apps/studio/tests/test_scene3d_coordinator.py @@ -753,8 +753,9 @@ def test_a_good_frame_after_a_failure_goes_back_to_live(scene): def test_a_failed_compose_does_not_leave_the_state_live(scene): """R2: the error used to be recorded while the picture and "live" stayed.""" composer = FailingComposer() + clock = Clock() widget = FakeWidget() - coordinator = ThreeDCoordinator(widget, composer, ImmediateExecutor()) + coordinator = ThreeDCoordinator(widget, composer, ImmediateExecutor(), clock=clock) coordinator.set_visible(True) coordinator.activate(scene, "live-A") coordinator.submit(_frame(scene, frame_id=1, value=1.0)) @@ -764,6 +765,7 @@ def test_a_failed_compose_does_not_leave_the_state_live(scene): composer.fail = True coordinator.submit(_frame(scene, frame_id=2, value=2.0)) + clock.advance(0.05) # clear the display period, as the window's timer would coordinator.tick() coordinator.tick() assert coordinator.state == "stale", "a failed compose kept reporting live" @@ -789,3 +791,94 @@ def test_an_invalid_frame_starts_the_stale_grace_too(scene): coordinator.tick() assert coordinator.state == "no_data" coordinator.close() + + +# --------------------------------------------- stage 9: the display rate is obeyed +def test_the_display_rate_limits_how_often_a_compose_starts(scene): + """R5: ``max_fps`` was stored and shown, and nothing read it. + + The audit reproduced it with a synchronous executor: 15 fps requested, 40 valid + presents inside 3 ms. Here the clock is moved by hand, so the assertion is about the + budget rather than about how fast this machine is. + """ + clock = Clock() + composer = CountingComposer() + coordinator = ThreeDCoordinator( + FakeWidget(), composer, ImmediateExecutor(), max_fps=15, clock=clock + ) + coordinator.set_visible(True) + coordinator.activate(scene, "live-A") + + # 40 ticks at the window's own cadence: 120 ms of simulated time at 15 fps = 1 start + # (the first frame after the claim boundary composes at once). + for index in range(40): + coordinator.submit(_frame(scene, frame_id=index)) + coordinator.tick() + clock.advance(0.003) + assert composer.calls == 2, f"{composer.calls} composes in 120 ms at 15 fps" + + # A full simulated second then allows the configured rate, and no more: 1320 ms of + # simulated time at 15 fps is one start per 66 ms, i.e. about twenty. + for index in range(400): + coordinator.submit(_frame(scene, frame_id=100 + index)) + coordinator.tick() + clock.advance(0.003) + assert 17 <= composer.calls <= 21, f"{composer.calls} composes in ~1.3 s at 15 fps" + assert composer.max_in_flight == 1 + coordinator.close() + + +def test_raising_the_display_rate_starts_more_composes(scene): + """The contrast is the point: 60 fps over the same simulated span, four times more.""" + clock = Clock() + composer = CountingComposer() + coordinator = ThreeDCoordinator( + FakeWidget(), composer, ImmediateExecutor(), max_fps=60, clock=clock + ) + coordinator.set_visible(True) + coordinator.activate(scene, "live-A") + for index in range(440): + coordinator.submit(_frame(scene, frame_id=index)) + coordinator.tick() + clock.advance(0.003) + assert composer.calls >= 55, f"{composer.calls} composes in 1.32 s at 60 fps" + coordinator.close() + + +def test_the_display_period_follows_a_settings_change(scene): + coordinator = ThreeDCoordinator(FakeWidget(), SceneComposer(), ImmediateExecutor()) + assert coordinator.display_period_s() == pytest.approx(1.0 / 30.0) + coordinator.set_settings(DisplaySettings(max_fps=15)) + assert coordinator.display_period_s() == pytest.approx(1.0 / 15.0) + coordinator.close() + + +def test_the_coordinator_reports_how_long_a_result_took(scene): + """A real number for the panel: from the compose's start to its acceptance.""" + clock = Clock() + coordinator = ThreeDCoordinator( + FakeWidget(), SceneComposer(), ImmediateExecutor(), clock=clock + ) + coordinator.set_visible(True) + coordinator.activate(scene, "live-A") + coordinator.submit(_frame(scene, value=1.0)) + coordinator.tick() # starts (and, with this executor, finishes) the compose + clock.advance(0.02) + coordinator.tick() # accepts it + assert coordinator.last_result_latency_ms() == pytest.approx(20.0, abs=1.0) + coordinator.close() + + +def test_the_producer_work_is_skipped_while_a_frame_is_waiting(scene): + """The mailbox keeps the newest frame; a copy made for it would be dropped.""" + coordinator = ThreeDCoordinator( + FakeWidget(), SceneComposer(), ImmediateExecutor(), max_fps=1 + ) + coordinator.set_visible(True) + coordinator.activate(scene, "live-A") + assert coordinator.has_pending_frame() is False + coordinator.submit(_frame(scene, value=1.0)) + assert coordinator.has_pending_frame() is True + coordinator.tick() # the timer takes it (and the 1 fps budget starts one compose) + assert coordinator.has_pending_frame() is False + coordinator.close() diff --git a/apps/studio/tests/test_scene3d_gl_smoke.py b/apps/studio/tests/test_scene3d_gl_smoke.py index 23c5935..8eec96e 100644 --- a/apps/studio/tests/test_scene3d_gl_smoke.py +++ b/apps/studio/tests/test_scene3d_gl_smoke.py @@ -486,17 +486,31 @@ def test_the_producer_side_never_waits_for_the_gpu(assembled_view, qt_app): p99 = ordered[int(len(ordered) * 0.99) - 1] assert p99 < 1.0, f"submit p99 was {p99:.2f} ms with a 50 ms composer" assert max(samples) < 5.0, f"submit peaked at {max(samples):.2f} ms" - assert coordinator.superseded_frames > 0, ( - "a 50 ms composer produced no display drops: the frame rate limiter or the " - "mailbox is not doing its job" + # A slow composer must cost the *display* something, and nothing else. Two + # mechanisms account for it now: the mailbox supersedes what it never showed, + # and the producer declines to build a snapshot while one is already waiting + # (which is cheaper and loses nothing — the waiting frame is still the newest). + accounted = ( + coordinator.superseded_frames + view.render_totals()["skipped_producer"] + ) + assert accounted > 0, ( + "a 50 ms composer produced no display-side drops at all: neither the mailbox " + "nor the producer budget is doing its job" ) finally: coordinator._composer = real_composer - # Drain the in-flight compose that the slow composer left behind. - deadline = time.perf_counter() + 1.0 - while time.perf_counter() < deadline and coordinator._inflight is not None: + # Drain everything the slow composer left behind, *including* the frames still + # waiting in the mailbox: this view is shared by the whole module, and a leftover + # frame would be uploaded by whichever test paints next. + deadline = time.perf_counter() + 8.0 + while time.perf_counter() < deadline and ( + coordinator._inflight is not None or coordinator.has_pending_frame() + ): coordinator.tick() time.sleep(0.01) + assert coordinator._inflight is None and not coordinator.has_pending_frame(), ( + "the slow-composer test did not drain before the next test" + ) def test_an_automatic_colour_scale_follows_the_frame(assembled_view, qt_app): @@ -537,14 +551,23 @@ def test_an_automatic_colour_scale_follows_the_frame(assembled_view, qt_app): widget.set_settings(fixed_wide) widget.present(frame_with(ramp)) - assert renderer._scalar_range is not None and renderer._scalar_range[0] > 2.0 - flat = len(np.unique(_drawn(_render(widget, qt_app)), axis=0)) + flat_pixels = _drawn(_render(widget, qt_app)) + assert renderer._scalar_range is not None and renderer._scalar_range[0] > 2.0, ( + "the frame's own values never reached the renderer" + ) widget.set_settings(auto) widget.present(frame_with(ramp)) - spread = len(np.unique(_drawn(_render(widget, qt_app)), axis=0)) - assert spread > flat, ( + auto_pixels = _drawn(_render(widget, qt_app)) + # The data spans 3..7. A fixed 0..1000 range squeezes all of it into the first + # fraction of a percent of the LUT (the ramp starts dark, so the picture is nearly + # black); an automatic range spreads it over the whole ramp. Mean brightness is the + # property that follows from that, and unlike "how many distinct colours" it does + # not depend on how much of the plate happens to be visible in this window. + flat_mean = float(flat_pixels.mean()) + auto_mean = float(auto_pixels.mean()) + assert auto_mean > flat_mean + 20.0, ( "an automatic range did not use the frame's own values " - f"(distinct colours: automatic {spread}, fixed 0..1000 {flat})" + f"(mean brightness: automatic {auto_mean:.1f}, fixed 0..1000 {flat_mean:.1f})" ) diff --git a/apps/studio/tests/test_scene3d_protected_package.py b/apps/studio/tests/test_scene3d_protected_package.py new file mode 100644 index 0000000..0517cf8 --- /dev/null +++ b/apps/studio/tests/test_scene3d_protected_package.py @@ -0,0 +1,228 @@ +"""Stage 9 acceptance: a protected scene package, opened from the application. + +The container could always read a signed or sealed package, but the application never +passed a verification key or a content-key provider, so a legitimate protected release +was refused with "this package is signed; a verification key is required" — and the only +ways to run it were to ship a plain package or to disable the check. These tests are +about the *wiring*: preferences → ``SceneKeys`` → the loader the pane actually uses. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from orisys_app_shared.scene3d import protect, synthetic +from orisys_app_shared.scene3d.contracts import ExpectedGeometry, ThreeDError +from orisys_app_shared.scene3d.package import write_package +from orisys_studio.scene3d.asset_source import SceneSource, load_package +from orisys_studio.scene3d.keys import ( + PREF_CONTENT_KEY_FILE, + PREF_CONTENT_KEY_ID, + PREF_REQUIRE_SIGNATURE, + PREF_VERIFY_KEY, + resolve_scene_keys, +) + +KEY_ID = "finger-display" +CONTENT_KEY_HEX = "11" * 32 + + +def _expected(scene) -> ExpectedGeometry: + return ExpectedGeometry( + "finger", "", scene.geometry_id, scene.field_domain_id, scene.shape_hw + ) + + +def _keys_and_paths(tmp_path: Path): + """A signing key pair and a content key file, as a release build would have.""" + private = protect.generate_signing_key() + public = protect.public_key_pem(private) + verify_path = tmp_path / "verify.pem" + verify_path.write_bytes(public) + key_path = tmp_path / "content.key" + key_path.write_text(CONTENT_KEY_HEX + "\n", encoding="utf-8") + provider = protect.MappingKeyProvider({KEY_ID: CONTENT_KEY_HEX}) + return private, public, verify_path, key_path, provider + + +def _write_signed(tmp_path: Path, scene, *, sealed: bool): + private, public, verify_path, key_path, provider = _keys_and_paths(tmp_path) + from orisys_app_shared.scene3d.contracts import Sealing + + target = tmp_path / ("sealed.oryscene" if sealed else "signed.oryscene") + write_package( + scene, + target, + sealing=Sealing( + key_id=KEY_ID if sealed else "", + provider=provider if sealed else None, + signing_key_pem=private, + signing_key_reference="stage-9", + verify_key_pem=public, + ), + ) + return target, verify_path, key_path + + +def _source(path: Path) -> SceneSource: + return SceneSource(path=path, kind="package", directory=path.parent) + + +def test_a_signed_package_opens_from_the_application(tmp_path): + """The review's R4: the library could read it; the application could not. + + With the verification key resolved from the preferences, the pane's own + ``load_package`` accepts the package and the scene comes out intact. + """ + scene = synthetic.poc_scene() + package, verify_path, _key_path = _write_signed(tmp_path, scene, sealed=False) + + keys = resolve_scene_keys({PREF_VERIFY_KEY: str(verify_path)}) + assert keys.verify_key_pem is not None + assert keys.describe()["verify_key"] is True + assets = load_package(_source(package), _expected(scene), keys) + assert assets.geometry_id == scene.geometry_id + # The manifest's own hash, recomputed when the package was written: the synthetic + # scene's ``asset_hash`` is a placeholder until it goes through ``write_package``. + assert len(assets.asset_hash) == 64 + + +def test_a_sealed_package_opens_with_the_content_key_from_the_preferences(tmp_path): + scene = synthetic.poc_scene() + package, verify_path, key_path = _write_signed(tmp_path, scene, sealed=True) + + keys = resolve_scene_keys( + { + PREF_VERIFY_KEY: str(verify_path), + PREF_CONTENT_KEY_FILE: str(key_path), + PREF_CONTENT_KEY_ID: KEY_ID, + } + ) + assert keys.provider is not None + described = keys.describe() + assert described["content_key"] is True + assert described["content_key_id"] == KEY_ID + # The description carries paths and ids, never key material. + assert CONTENT_KEY_HEX not in json.dumps(described) + assert keys.verify_key_pem is not None + assert CONTENT_KEY_HEX.encode() not in keys.verify_key_pem + + assets = load_package(_source(package), _expected(scene), keys) + assert assets.geometry_id == scene.geometry_id + + +def test_without_the_verification_key_a_signed_package_is_refused(tmp_path): + """Not a silent fallback to plain: the refusal names what is missing.""" + scene = synthetic.poc_scene() + package, _verify_path, _key_path = _write_signed(tmp_path, scene, sealed=False) + keys = resolve_scene_keys({}) + with pytest.raises(ThreeDError) as excinfo: + load_package(_source(package), _expected(scene), keys) + assert excinfo.value.diagnostic.code == "asset_auth_failed" + assert "verification key" in excinfo.value.diagnostic.message + + +def test_a_missing_or_unreadable_key_file_is_a_state_not_a_crash(tmp_path): + """The 2D application must start whatever the key configuration looks like.""" + keys = resolve_scene_keys( + { + PREF_VERIFY_KEY: str(tmp_path / "gone.pem"), + PREF_CONTENT_KEY_FILE: str(tmp_path / "gone.key"), + PREF_CONTENT_KEY_ID: KEY_ID, + } + ) + assert keys.verify_key_pem is None and keys.provider is None + described = keys.describe() + assert described["verify_key_error"] == "missing" + assert described["content_key_error"] == "missing" + + # A key file without a key id is not usable either, and says which half is absent. + key_path = tmp_path / "content.key" + key_path.write_text(CONTENT_KEY_HEX, encoding="utf-8") + keys = resolve_scene_keys({PREF_CONTENT_KEY_FILE: str(key_path)}) + assert keys.provider is None + assert keys.describe()["content_key_error"] == "no key id configured" + + +def test_release_mode_refuses_an_unsigned_package(tmp_path): + """The gate that replaces "ask the engineer to sign it".""" + scene = synthetic.poc_scene() + plain = tmp_path / "plain.oryscene" + write_package(scene, plain) + release = resolve_scene_keys({PREF_REQUIRE_SIGNATURE: True}) + assert release.require_signature is True + with pytest.raises(ThreeDError) as excinfo: + load_package(_source(plain), _expected(scene), release) + assert excinfo.value.diagnostic.code == "asset_auth_failed" + assert "requires signed scene packages" in excinfo.value.diagnostic.message + + # The same package is fine in a development build (and the shipped engineering + # package is exactly this case). + dev = resolve_scene_keys({}) + assert load_package(_source(plain), _expected(scene), dev).geometry_id == scene.geometry_id + + +class _InlineExecutor: + """Runs submitted work immediately: the loader's path without a thread.""" + + def submit(self, function, *args, **kwargs): + from concurrent.futures import Future + + future: Future = Future() + try: + future.set_result(function(*args, **kwargs)) + except BaseException as exc: # noqa: BLE001 - mirrored into the future + future.set_exception(exc) + return future + + def shutdown(self, wait: bool = True) -> None: # noqa: ARG002 - executor contract + return None + + +def test_the_scene_loader_passes_the_keys_to_its_worker(qt_app, tmp_path): + """The pane's loader is where the wiring has to happen, not just ``load_package``.""" + from orisys_studio.scene3d.asset_source import SceneAssetLoader + + scene = synthetic.poc_scene() + package, verify_path, _key_path = _write_signed(tmp_path, scene, sealed=False) + keys = resolve_scene_keys({PREF_VERIFY_KEY: str(verify_path)}) + + loader = SceneAssetLoader() + loader._executor = _InlineExecutor() + loaded: list = [] + failures: list = [] + loader.loaded.connect( + lambda assets, description, request_id, device: loaded.append(assets) + ) + loader.failed.connect(lambda item, request_id: failures.append(item)) + try: + loader.load(_source(package), _expected(scene), keys=keys) + assert not failures, [item.message for item in failures] + assert loaded and loaded[0].geometry_id == scene.geometry_id + finally: + loader.close() + + # Without them the same file is refused, and the loader reports it rather than + # raising into the GUI thread. + loader = SceneAssetLoader() + loader._executor = _InlineExecutor() + failures = [] + loader.failed.connect(lambda item, request_id: failures.append(item)) + try: + loader.load(_source(package), _expected(scene), keys=resolve_scene_keys({})) + assert failures and failures[0].code == "asset_auth_failed" + finally: + loader.close() + + +def test_the_pane_carries_the_keys_into_its_loader(qt_app): + from orisys_studio.ui.mesh3d_panel import Mesh3dPanel + + panel = Mesh3dPanel() + keys = resolve_scene_keys({}) + assert panel.keys() is None + panel.set_keys(keys) + assert panel.keys() is keys diff --git a/apps/studio/tests/test_scene3d_view.py b/apps/studio/tests/test_scene3d_view.py index 99a55b1..a2aae4b 100644 --- a/apps/studio/tests/test_scene3d_view.py +++ b/apps/studio/tests/test_scene3d_view.py @@ -576,7 +576,8 @@ def test_a_package_is_loaded_off_the_gui_thread(qt_app, write_poc): assert second > first assert loader2._executor is None, "a queued request must not open a second worker" assert loader2._pending is not None, "the newer request was dropped" - assert loader2._pending[-1] == second + # The request tuple ends with the release keys; the id is the element before it. + assert second in loader2._pending finally: loader2.close() @@ -871,7 +872,56 @@ def test_a_scene_without_a_device_description_is_shown_as_unverified(qt_app): view.close() -# --------------------------------------- stage 8: capability-controlled controls +# -------------------------------------------- stage 9: producer budget and real costs +def test_the_producer_does_not_build_a_snapshot_nobody_can_show(qt_app): + """R5: the adapter's copy is O(H×W), and a waiting frame makes it wasted work. + + The mailbox keeps the newest frame, so a second snapshot taken while one is waiting + is dropped unwatched. Skipping it loses nothing — it is the same frame the display + would have shown — and keeps the acquisition thread free. + """ + view = _stale_scene_view(qt_app) + try: + shape = tuple(view.assets.shape_hw) + packet = ui_check._fake_packet(1) + packet.flow = np.full((*shape, 2), 0.5, dtype=np.float32) + view.submit_packet(packet) + assert view.render_totals()["skipped_producer"] == 0 + view.submit_packet(packet) + assert view.render_totals()["skipped_producer"] == 1, ( + "a snapshot was built while one was already waiting" + ) + view.tick() # the timer takes it + view.submit_packet(packet) + assert view.render_totals()["skipped_producer"] == 1, ( + "the budget must clear as soon as the waiting frame is taken" + ) + finally: + view.close() + + +def test_the_totals_report_a_real_compose_cost_and_how_many_frames_were_drawn(qt_app): + """R5: "compose 0.0 ms" was hard-coded, and every repaint counted as a frame.""" + view = _stale_scene_view(qt_app) + try: + shape = tuple(view.assets.shape_hw) + packet = ui_check._fake_packet(1) + packet.flow = np.full((*shape, 2), 0.5, dtype=np.float32) + view.submit_packet(packet) + for _ in range(40): + view.tick() + if view.state() == "live": + break + time.sleep(0.01) + totals = view.render_totals() + assert totals["shown"] >= 1 + # The compose really happened, so the frame carries its own measured cost. + assert totals["compose_ms"] > 0.0, totals + assert totals["latency_ms"] >= totals["compose_ms"] - 0.5, totals + stats = view.render_stats() + assert stats is None or stats.displayed_frames >= 1 + finally: + view.close() def test_a_source_with_no_displacement_cannot_offer_exaggeration(qt_app): """R6: the control must describe an input that exists.""" view = _stale_scene_view(qt_app) diff --git a/shared/src/orisys_app_shared/scene3d/composer.py b/shared/src/orisys_app_shared/scene3d/composer.py index e090d1d..e3c5924 100644 --- a/shared/src/orisys_app_shared/scene3d/composer.py +++ b/shared/src/orisys_app_shared/scene3d/composer.py @@ -71,6 +71,10 @@ class SceneComposer: def __init__(self, sampler: Optional[SurfaceSampler] = None) -> None: self._sampler = sampler or SurfaceSampler() self.last_stats = ComposeStats() + #: Rest positions and normals, keyed by ``asset_hash``: with no displacement to + #: draw, the picture is the asset's own geometry, and recomputing its normals + #: every frame was pure cost (measured as the bulk of a 20k-vertex compose). + self._rest_geometry: dict[str, tuple[np.ndarray, np.ndarray]] = {} def compose( self, @@ -91,15 +95,16 @@ class SceneComposer: sampling_started = time.perf_counter() fields = self._sampler.sample(frame, assets.binding) self.last_stats.sample_ms = (time.perf_counter() - sampling_started) * 1000.0 - rest_positions = np.asarray(part.positions_sensor_m, dtype=np.float64) + rest_positions, rest_normals = self._rest(assets) diagnostics: list[Diagnostic] = [] positions = rest_positions quality = frame.displacement_quality - if settings.deform_enabled and fields.displacement_sensor_m is not None: + deformed = bool(settings.deform_enabled and fields.displacement_sensor_m is not None) + if deformed: gain = float(settings.display_gain) displacement = np.asarray(fields.displacement_sensor_m, dtype=np.float64) - positions = rest_positions + gain * displacement + positions = np.asarray(rest_positions, dtype=np.float64) + gain * displacement if quality != "calibrated": diagnostics.append( diagnostic( @@ -118,16 +123,33 @@ class SceneComposer: ) ) normals_started = time.perf_counter() - normals = vertex_normals(positions, np.asarray(part.triangles, dtype=np.int64)) + # In place, the normals are the asset's own: the positions are untouched, so + # recomputing them from the same triangles gives the same answer every frame. + normals = ( + vertex_normals(positions, np.asarray(part.triangles, dtype=np.int64)) + if deformed + else rest_normals + ) self.last_stats.normals_ms = (time.perf_counter() - normals_started) * 1000.0 + # Measured before the frame is built, so the frame carries *its own* cost: the + # shared ``last_stats`` object is rewritten by the next task. + total_ms = (time.perf_counter() - started) * 1000.0 + self.last_stats.total_ms = total_ms + result = RenderFrame( identity=frame.identity, asset_hash=assets.asset_hash, geometry_id=assets.geometry_id, sensing_part_id=part.part_id, - positions_sensor_m=freeze(positions, dtype=np.float32), - normals_sensor=freeze(normals, dtype=np.float32), + positions_sensor_m=( + freeze(positions, dtype=np.float32) + if deformed + else rest_positions + ), + normals_sensor=( + freeze(normals, dtype=np.float32) if deformed else rest_normals + ), # The measurement, untouched by the display gain. scalar=fields.scalar, valid=fields.valid, @@ -136,11 +158,31 @@ class SceneComposer: # Carried through untouched: only the display side knows whether the frame # it is about to show is still the current one. computed_frame_id=frame.computed_frame_id, + # What this frame cost to compose, carried *on the frame*: the shared + # ``last_stats`` object is rewritten by the next task, so a reader on the + # GUI thread would report another frame's cost. + compose_ms=total_ms, ) validate_render_frame(result, vertices=int(rest_positions.shape[0])) - self.last_stats.total_ms = (time.perf_counter() - started) * 1000.0 return result + def _rest(self, assets: SceneAssets) -> tuple[np.ndarray, np.ndarray]: + """The asset's own positions and normals, computed once per asset. + + ``positions_sensor_m`` and ``normals_sensor`` are already frozen float32 arrays + on the asset, so the cache hands them out by reference: nothing here may mutate + them (they are read-only, which is what makes that safe). + """ + key = str(assets.asset_hash) + cached = self._rest_geometry.get(key) + if cached is None: + part = assets.sensing_part() + cached = (part.positions_sensor_m, part.normals_sensor) + # One entry: the pane composes one asset at a time, and a stale cache would + # be a wrong picture rather than a slow one. + self._rest_geometry = {key: cached} + return cached + def rest_frame(self, assets: SceneAssets, identity: FrameIdentity) -> RenderFrame: """The static reference: nothing measured, so nothing coloured.""" return build_rest_frame(assets, identity) diff --git a/shared/src/orisys_app_shared/scene3d/contracts.py b/shared/src/orisys_app_shared/scene3d/contracts.py index fc65990..a7ee819 100644 --- a/shared/src/orisys_app_shared/scene3d/contracts.py +++ b/shared/src/orisys_app_shared/scene3d/contracts.py @@ -565,6 +565,9 @@ class RenderFrame: #: The acquisition frame the coloured values belong to, carried through from the #: field frame so the *display* side can re-check it before presenting. computed_frame_id: Optional[int] = None + #: What composing this frame cost, measured where it was composed. Carried per frame + #: rather than read from the composer's shared counter, which the next task rewrites. + compose_ms: float = 0.0 @dataclass(frozen=True) @@ -588,8 +591,14 @@ class GLCapabilities: @dataclass(frozen=True) class RenderStats: + #: Frames that reached the screen, counted by *identity*: a repaint of the same + #: measurement (a camera move, a colour-scale change) is not a new frame, and the + #: panel's "shown" number was reading paint calls before this. displayed_frames: int = 0 superseded_frames: int = 0 + #: How much every ``paintGL`` cost, so a slow camera move can be told from a slow + #: pipeline. ``displayed_frames`` counts data; this counts drawing. + paint_calls: int = 0 compose_ms: float = 0.0 upload_ms: float = 0.0 paint_ms: float = 0.0 diff --git a/shared/tests/test_scene3d_contracts.py b/shared/tests/test_scene3d_contracts.py index b3be101..a4b4b27 100644 --- a/shared/tests/test_scene3d_contracts.py +++ b/shared/tests/test_scene3d_contracts.py @@ -459,12 +459,30 @@ def test_identity_distinguishes_sources_and_generations(): # ------------------------------------------------------------------ skeleton def test_the_contract_layer_imports_without_qt_or_opengl(): - """The acquisition thread imports this package; it must not drag Qt in.""" - assert "PyQt5" not in sys.modules or True # Qt may be loaded by other tests - for name in ("PyQt5.QtWidgets", "OpenGL"): - assert name not in sys.modules or name == "PyQt5.QtWidgets", ( - f"importing the contracts pulled in {name}" - ) + """The acquisition thread imports this package; it must not drag Qt or GL in. + + Checked in a **fresh interpreter**: asking ``sys.modules`` in this process only + proves what some earlier test happened to import (it failed once the studio tests + painted a widget before this file ran, which says nothing about the contract layer). + """ + import subprocess + import sys + + script = ( + "import sys;" + "import orisys_app_shared.scene3d.contracts as c;" + "print(','.join(m for m in ('OpenGL', 'PyQt5', 'cv2') if m in sys.modules))" + ) + result = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + timeout=120, + check=False, + ) + assert result.returncode == 0, result.stderr + pulled = [name for name in result.stdout.strip().split(",") if name] + assert pulled == [], f"importing the contracts pulled in {pulled}" def test_the_cpu_layer_is_importable_and_works_without_a_gpu(): diff --git a/shared/tests/test_scene3d_live.py b/shared/tests/test_scene3d_live.py index 69d5704..e59b995 100644 --- a/shared/tests/test_scene3d_live.py +++ b/shared/tests/test_scene3d_live.py @@ -403,3 +403,62 @@ def test_the_composer_does_not_rebuild_the_binding_or_the_indices(): for _ in range(3): composer.compose(scene, frame, DisplaySettings()) assert composer.last_stats.total_ms < 50.0, composer.last_stats + + +# --------------------------------------------- stage 9: reuse and real numbers +def test_colouring_in_place_reuses_the_assets_own_geometry(): + """No displacement to draw means the picture *is* the asset: nothing to recompute. + + Normals were recomputed from the same untouched positions on every frame, and the + measured cost was the bulk of a 20k-vertex compose. The reused arrays are the + asset's own (frozen) ones, so nothing is copied either. + """ + scene = synthetic.poc_scene() + part = scene.sensing_part() + composer = SceneComposer() + frame = _frame(scene, np.zeros((3, 3), dtype=np.float32)) + composed = composer.compose(scene, frame, DisplaySettings()) + assert composed.positions_sensor_m is part.positions_sensor_m + assert composed.normals_sensor is part.normals_sensor + # A deform request with nothing to deform is the same case, and says so. + composed = composer.compose( + scene, frame, DisplaySettings(deform_enabled=True, display_gain=5.0) + ) + assert composed.positions_sensor_m is part.positions_sensor_m + assert any(item.code == "field_unavailable" for item in composed.diagnostics) + + +def test_a_calibrated_displacement_still_recomputes_positions_and_normals(): + """The cache is for the colour-in-place case only, never for a deformed surface.""" + scene = synthetic.poc_scene() + part = scene.sensing_part() + displacement = np.zeros((3, 3, 3), dtype=np.float32) + displacement[1, 1, 2] = 0.5 + frame = _frame( + scene, + np.ones((3, 3), dtype=np.float32), + displacement=displacement, + quality="calibrated", + ) + composed = SceneComposer().compose( + scene, frame, DisplaySettings(deform_enabled=True, display_gain=2.0) + ) + assert composed.positions_sensor_m is not part.positions_sensor_m + assert composed.normals_sensor is not part.normals_sensor + assert not np.allclose( + np.asarray(composed.positions_sensor_m), np.asarray(part.positions_sensor_m) + ) + + +def test_the_composed_frame_carries_its_own_cost(): + """The panel's "compose ms" reads this, not the composer's shared counter.""" + scene = synthetic.poc_scene() + composer = SceneComposer() + frame = _frame(scene, np.zeros((3, 3), dtype=np.float32)) + composed = composer.compose(scene, frame, DisplaySettings()) + assert composed.compose_ms > 0.0 + assert composed.compose_ms == pytest.approx(composer.last_stats.total_ms) + # A second compose rewrites ``last_stats``; the first frame's number must not move. + first = composed.compose_ms + composer.compose(scene, frame, DisplaySettings()) + assert composed.compose_ms == first