feat(studio-3d): 设备几何身份接入 —— 同尺寸不同映射/不同表面被拒绝,资产加载只采用最新请求

- What:
  1. **设备侧自己说几何**(新增 `shared/.../acquisition/geometry.py`):`describe_sensor_geometry()`
     从运行时真正加载的文件推导——`manifest.json` 的 `assets_dir`、`sensor.json` 的
     `camera.resolution_stitch`/`source_scale`、以及据此命名的 unroll map(`unroll_map_uv_s0.4.npz`)。
     产出 `DeviceGeometry(expected, verified, detail)`:`field_domain_id` + `shape_hw`(映射身份,含
     flip/scale/映射内容摘要)与 **无单位** 的 `source_geometry_id`(地图里 `world_xyz` 的内容摘要)。
     米制 `geometry_id` 需要"1 源单位 = N 米"这句只有人能做的声明,设备侧**故意留空**。两类失败区分对待:
     定位不到 bundle / 没写 `assets_dir` → `verified=False`(可显示,但不声明已校验);bundle 变了
     (map 缺失、与 `resolution_stitch` 不符)→ 直接拒绝并指名两个形状/文件。
  2. **契约**:`ExpectedGeometry += source_geometry_id`;`SceneAssets += source_geometry_id,
     compatible_sensor_model, compatible_hardware_revisions`;诊断码 `geometry_unverified`(info);
     `digest.source_geometry_digest(world_xyz)`;`legacy.convert()` 写入 `source_geometry_id`;
     `package.py` 的 manifest 增 `source_geometry` 块(数组名与单位写明),读取时把 `compatible` 与
     `source_geometry` 一并还原(旧包读到空串)。`validate_assets()` 的 expected 段重写:**只比较已声明的
     字段**,`source_geometry_id` 不符即拒绝,包内未记录该身份 → 返回 `geometry_unverified` 警告而不是
     静默通过;顺带修掉 `expected.sensor_model != assets.parts[0].role` 这行毫无意义的比较,改成真正的
     型号/硬件版本比对(`*` = 任意版本)。
  3. **接线**:`app_window._sync_3d_source()` 传 `expected_provider=`(一个闭包,指向当前传感器配置);
     `SceneAssetLoader` 在**工作线程**上解析它(`_resolve_and_load`);`view.use_scene(assets, source_id,
     expected)` 改为 `validate_assets(assets, expected)` 并把警告送到面板,同时记录 `device_verified`;
     `_make_snapshot()` 用**设备**的身份构造 adapter(设备没声明的字段才回退资产),使
     "帧与场景匹配" 第一次成为独立检查而不是自证;`describe()` 记录 `scene.device_verified` 与设备身份。
  4. **资产加载请求身份(R7-a)**:`SceneAssetLoader` 增请求号与 pending 槽——忙时不再丢弃新请求,而是
     保留最新一个并在前一个结束后启动;完成信号带请求号(`loaded(assets, 描述, id, device)` /
     `failed(diag, id)`),`Mesh3dPanel` 丢弃过期完成(否则旧文件的场景会落回新选择上)。失败后可重试。
  5. **工具与包**:`compile_scene.py`/`validate_scene.py` 报告 `geometry_id`/`field_domain_id`/
     `source_geometry_id`/兼容信息;README 写明无单位身份是什么、为什么设备侧用它;**重编发布包**
     `apps/studio/config/sensors/finger/scene/finger-display-1.0.0.oryscene`(geometry_id 仍为
     `1a2e0e92…`、1032 顶点 / 1974 三角形,新增 `source_geometry_id=46a03a5f…`,因此 asset_hash 变为 `ea4beab6…`)。
- Why: 审查报告 P1/R3:主窗口只传 sensor_type 与 package_override,`Mesh3dPanel._expected` 永远是 None,
  `validate_assets(assets, None)` 跳过全部设备比对,而 adapter 的 geometry_id/field_domain_id/shape_hw
  全部来自**已选中的资产**——于是收到的数据被贴上"资产自己期望的身份"。结果:shape 相同但 UV 映射、
  flip_v、source_scale 或相机配置不同时无法区分。P1/R7:`SceneAssetLoader.load()` 在忙时直接 return,
  既没保留最新请求也没有 generation,`_on_loaded` 又用当前 `_source` 标记已加载路径,快速切换配置时旧
  文件的任务会回到新选择上。
- Verification:
  · 证据探针 `.repro/probe_stage6_device.py` → `probe_stage6_device.json`(真实 bundle + 真实包):
    设备描述 `verified=true`、domain `feb7f71e…`、source geometry `46a03a5f…`、shape 300x280、
    米制 id 未声明、map = `unroll_map_uv_s0.4.npz`;发布包通过该描述;**同尺寸不同映射**(只改 map 一个
    像素的坐标)→ domain 变化、shape 不变、包被 `field_domain_mismatch` 拒绝;设备什么都不声明 →
    接受 + `geometry_unverified`。
  · 新增 `shared/tests/test_scene3d_device_geometry.py` 9 项:描述来源、发布包通过、同尺寸不同映射拒绝、
    同 map 不同表面拒绝、定位不到 bundle → unverified、`resolution_stitch` 与 map 不符拒绝、map 缺失
    指名文件、无 `assets_dir` → unverified、map 命名规则。
  · `test_scene3d_contracts.py` +4(未声明身份 → 警告而非匹配、source geometry 不符拒绝/缺记录 → 警告、
    型号与硬件版本、`source_geometry_digest` 的稳定性与形状校验);`test_scene3d_package.py` +2
    (`source_geometry` 往返、旧包读成"未声明");`test_scene3d_view.py` +6(adapter 身份来自设备、
    设备 domain 不符 → 拒绝且不上屏、无描述 → `geometry_unverified` 且 `device_verified=false`、
    **最新请求才运行**、面板丢弃过期完成、失败后可重试)。
  · 全量:apps/studio 0 失败(`--junitxml`:231→236 项);shared 0 失败(含 device_geometry 9 项);
    `validate_scene.py` 对重编包全项 ok。
- Not in this change: 生产侧仍不声明米制 `geometry_id`(那句单位声明只有人能写;要设备端复算它需要把
  `meters_per_source_unit` 与刚体变换记进配置,属可选加强项,已在本提交的 `DeviceGeometry.detail` 里留位);
  `ExpectedGeometry.mapping_digest` 未加——`field_domain_id` 本身就是由映射内容摘要派生的,再加一个字段
  只是同一个事实的第二个名字;`docs/architecture/studio-3d-contracts.pyi` 与本阶段新契约的同步放在阶段 6
  的文档提交里;保护包密钥注入(R4)、限速与真实统计、二维后端统一均未动。
This commit is contained in:
ykcheungab
2026-09-14 21:10:53 +08:00
parent 7d1fca4c93
commit 3c0424b90c
17 changed files with 981 additions and 40 deletions
+27 -1
View File
@@ -2130,12 +2130,38 @@ class StudioMainWindow(QMainWindow):
self._persist_prefs()
def _sync_3d_source(self) -> None:
"""Tell the 3D pane which sensor it is looking at. No IO, no scene read."""
"""Tell the 3D pane which sensor it is looking at. No IO, no scene read.
The device's own geometry description travels with it as a *provider*: deriving
it reads and digests the unroll map the runtime loads, and the scene loader does
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.
"""
self.mesh3d_panel.set_sensor(
self.sensor_picker.sensor_type(),
expected_provider=self._device_geometry_provider(),
package_override=self.prefs.get("scene3d_package"),
)
def _device_geometry_provider(self):
"""A callable the loader runs off the GUI thread: what this device really is.
``None`` when the application cannot name the sensor's configuration; the pane
then reports the scene as unverified rather than pretending it was compared.
"""
sensor_type = self.sensor_picker.sensor_type()
config_ref = self._resolve_config_ref()
if not config_ref:
return None
def describe():
from orisys_app_shared.acquisition.geometry import describe_sensor_geometry
return describe_sensor_geometry(sensor_type, config_ref)
return describe
def _restore_3d_settings(self) -> None:
"""Persisted display settings, repaired by ``scene3d.prefs`` on the way in."""
from orisys_studio.scene3d.prefs import settings_from_prefs
@@ -31,7 +31,7 @@ import logging
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from typing import TYPE_CHECKING, Any, Callable, Optional
from PyQt5.QtCore import QObject, pyqtSignal
@@ -44,6 +44,9 @@ from orisys_app_shared.scene3d.contracts import (
error,
)
if TYPE_CHECKING: # the description is a value object; the callable returns one
from orisys_app_shared.acquisition.geometry import DeviceGeometry
logger = logging.getLogger(__name__)
#: The container suffix written by ``tools/scene_assets/compile_scene.py``.
@@ -131,51 +134,102 @@ def load_package(
return PackagedAssetLoader().load(source.path, expected)
def _resolve_and_load(
source: SceneSource,
expected: Optional[ExpectedGeometry],
provider: "Optional[Callable[[], DeviceGeometry]]",
) -> tuple[SceneAssets, "Optional[DeviceGeometry]"]:
"""Worker-thread half: build the *device's* description, then read the package.
Deriving the description digests the unroll map the runtime loads, so it belongs on
this thread. A description that cannot be built raises with a diagnostic naming the
bundle: falling back to the asset's own identity would make the comparison a
sentence the asset wrote about itself, which is the bug being fixed.
"""
device = None
if expected is None and provider is not None:
device = provider()
expected = device.expected
return load_package(source, expected), device
class SceneAssetLoader(QObject):
"""Loads one scene off the GUI thread and reports the result as a signal.
One load is kept: the pane only ever needs the newest, and a second request while
one is running is a request for the same file.
One load runs at a time and the **newest request wins**: a request that arrives
while another is running replaces the pending one instead of being dropped, and a
completion carries the request id it belongs to, so a slow load cannot land on a
selection the operator has already moved away from.
"""
loaded = pyqtSignal(object, str) # SceneAssets, source description
failed = pyqtSignal(object) # Diagnostic
loaded = pyqtSignal(object, str, int, object) # SceneAssets, description, id, device
failed = pyqtSignal(object, int) # Diagnostic, request id
def __init__(self, parent: Optional[QObject] = None) -> None:
super().__init__(parent)
self._executor: Optional[ThreadPoolExecutor] = None
self._inflight = False
self._request_id = 0
#: The newest request that could not start yet (source, expected, provider, id).
self._pending: Optional[tuple[SceneSource, Optional[ExpectedGeometry], Any, int]] = None
def load(self, source: SceneSource, expected: Optional[ExpectedGeometry] = None) -> None:
def load(
self,
source: SceneSource,
expected: Optional[ExpectedGeometry] = None,
*,
expected_provider: "Optional[Callable[[], DeviceGeometry]]" = 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)
if self._inflight:
return
self._pending = request
return self._request_id
self._start(request)
return self._request_id
def _start(self, request) -> None:
source, expected, provider, request_id = request
if self._executor is None:
self._executor = ThreadPoolExecutor(
max_workers=1, thread_name_prefix="scene3d-assets"
)
self._inflight = True
future = self._executor.submit(load_package, source, expected)
future = self._executor.submit(_resolve_and_load, source, expected, provider)
future.add_done_callback(
lambda done: self._finished(done, source)
lambda done: self._finished(done, source, request_id)
)
def _finished(self, future, source: SceneSource) -> None:
def _finished(self, future, source: SceneSource, request_id: int) -> None:
self._inflight = False
try:
assets = future.result()
assets, device = future.result()
except Exception as exc: # noqa: BLE001 - a bad package is a diagnostic
item = getattr(exc, "diagnostic", None) or diagnostic(
"asset_rejected", f"{type(exc).__name__}: {exc}"
)
logger.warning("3D scene %s was rejected: %s", source.path, item.message)
# Emitted from this worker thread; the receiver is a Qt object on the
# GUI thread, so the connection is queued (see the view's `_on_loaded`).
self.failed.emit(item)
self._emit_or_queue(lambda: self.failed.emit(item, request_id))
return
logger.info("3D scene loaded from %s", source.path)
self.loaded.emit(assets, source.describe())
self._emit_or_queue(
lambda: self.loaded.emit(assets, source.describe(), request_id, device)
)
def _emit_or_queue(self, emit) -> None:
"""Emit this completion, then start the newest request that arrived meanwhile.
Emitted from the worker thread; the receivers are Qt objects on the GUI thread,
so the connection is queued (see the panel's ``_on_loaded``).
"""
pending, self._pending = self._pending, None
emit()
if pending is not None:
self._start(pending)
def close(self) -> None:
self._pending = None
if self._executor is not None:
self._executor.shutdown(wait=False)
self._executor = None
+67 -8
View File
@@ -111,6 +111,8 @@ class Scene3DView(QWidget):
self._last_stats: Optional[RenderStats] = None
#: The coordinator's last diagnostic as *this* view has already reported it.
self._reported_diagnostic: Optional[Diagnostic] = None
#: Whether the loaded asset was compared against the device's own description.
self._device_verified = False
self._layout = QVBoxLayout(self)
self._layout.setContentsMargins(0, 0, 0, 0)
@@ -160,21 +162,41 @@ class Scene3DView(QWidget):
) -> bool:
"""Adopt a validated scene. Returns whether the view accepted it.
``expected`` is the device's own description. It is optional for the finger
(whose adapter needs nothing but the packet) and required for a box, where it
is the only statement of what the device's field means — an adapter built
without it would be guessing which grid to trust.
``expected`` is the **device's own** description. It is required for a box, where
it is the only statement of what the device's field means, and it is what makes
the asset check meaningful for a finger: without it the comparison would be the
asset agreeing with itself. An absent description does not refuse the scene — the
pane can still show the surface — but it is recorded as *unverified*, and the
adapter is then built from the asset, which is exactly what "unverified" means.
"""
if not self._available or self._coordinator is None:
return False
try:
validate_assets(assets, None)
warnings = validate_assets(assets, expected)
except Exception as exc: # noqa: BLE001
self._on_diagnostic(
getattr(exc, "diagnostic", None)
or diagnostic("asset_rejected", f"{type(exc).__name__}: {exc}")
)
return False
for item in warnings:
self._on_diagnostic(item)
# "Verified" means every identity the device stated was actually compared. A
# statement that could not be compared (an older package, no source-geometry
# record) is reported as unverified rather than counted as a match.
self._device_verified = bool(
expected is not None
and (expected.geometry_id or expected.source_geometry_id)
and not any(item.code == "geometry_unverified" for item in warnings)
)
if not self._device_verified:
self._on_diagnostic(
diagnostic(
"geometry_unverified",
"no device geometry description: the scene is shown as a reference, "
"not verified against this device",
)
)
snapshot = self._make_snapshot(assets, expected)
if snapshot is None:
return False
@@ -213,13 +235,31 @@ class Scene3DView(QWidget):
from the packet, while the box adapter needs the device description to know
whether the field's grid is the one the scene was built for. Binding the call
here keeps the acquisition path free of both variants.
The identity the adapter stamps on a frame is the **device's**, not the asset's:
when the adapter is built from the asset, "the frame matches the scene" is a
sentence the asset wrote about itself. Where the device cannot state a field
(the metre-scaled geometry id, on a device that has no unit statement) the
asset's value is used and the pane is already marked unverified.
"""
asset_id = str(getattr(assets, "asset_id", "") or "")
self._adapter = None
device = expected
common = {
"geometry_id": assets.geometry_id,
"field_domain_id": assets.field_domain_id,
"shape_hw": tuple(assets.shape_hw),
"geometry_id": str(
device.geometry_id if device is not None and device.geometry_id
else assets.geometry_id
),
"field_domain_id": str(
device.field_domain_id
if device is not None and device.field_domain_id
else assets.field_domain_id
),
"shape_hw": (
tuple(int(v) for v in device.shape_hw)
if device is not None and all(int(v) for v in device.shape_hw)
else tuple(assets.shape_hw)
),
}
if asset_id.startswith(("box", "mini")):
if expected is None:
@@ -419,6 +459,7 @@ class Scene3DView(QWidget):
settings = self.settings()
scale = settings.color_scale
descriptor = self._last_descriptor
expected = self._expected
return {
"scene": {
"asset_id": str(getattr(assets, "asset_id", "")),
@@ -426,6 +467,23 @@ class Scene3DView(QWidget):
"asset_hash": str(getattr(assets, "asset_hash", "")),
"geometry_id": str(getattr(assets, "geometry_id", "")),
"field_domain_id": str(getattr(assets, "field_domain_id", "")),
"source_geometry_id": str(getattr(assets, "source_geometry_id", "")),
# Whether the asset was compared against what the device says it is. A
# picture drawn without that comparison is a reference, not a verified
# measurement, and the record has to be able to say so.
"device_verified": bool(self._device_verified),
"device": (
{
"sensor_model": str(expected.sensor_model),
"hardware_revision": str(expected.hardware_revision),
"geometry_id": str(expected.geometry_id),
"field_domain_id": str(expected.field_domain_id),
"source_geometry_id": str(expected.source_geometry_id),
"shape_hw": f"{expected.shape_hw[0]}x{expected.shape_hw[1]}",
}
if expected is not None
else {}
),
"platform_lock": (
assets.platform_lock.to_dict()
if getattr(assets, "platform_lock", None) is not None
@@ -557,6 +615,7 @@ class Scene3DView(QWidget):
self._assets = None
self._snapshot = None
self._adapter = None
self._device_verified = False
if self._coordinator is not None:
self._coordinator.close()
if self._widget is not None:
@@ -14,13 +14,18 @@ acquisition, and no path that reports a 3D problem as a recording problem.
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Optional
from typing import TYPE_CHECKING, Callable, Optional
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import QFrame, QLabel, QStackedLayout, QVBoxLayout, QWidget
from orisys_app_shared.i18n import t
from orisys_app_shared.scene3d.contracts import Diagnostic, ExpectedGeometry, SceneAssets
from orisys_app_shared.scene3d.contracts import (
Diagnostic,
ExpectedGeometry,
SceneAssets,
diagnostic,
)
from orisys_studio.scene3d.asset_source import (
SceneAssetLoader,
SceneSource,
@@ -29,6 +34,7 @@ from orisys_studio.scene3d.asset_source import (
)
if TYPE_CHECKING: # the view imports GL bindings lazily; the type is enough here
from orisys_app_shared.acquisition.geometry import DeviceGeometry
from orisys_studio.scene3d.view import Scene3DView
#: Shown when the pane is on but this sensor has no 3D scene at all (box, mini).
@@ -73,9 +79,14 @@ class Mesh3dPanel(QFrame):
self._source: Optional[SceneSource] = None
self._sensor_type = ""
self._expected: Optional[ExpectedGeometry] = None
self._expected_provider: "Optional[Callable[[], DeviceGeometry]]" = None
self._source_kind = "live"
self._enabled = False
self._loaded = False
#: The id of the load this pane is waiting for: a completion for an older
#: request describes a selection the operator has already moved away from.
self._load_request = 0
self._device = 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
@@ -107,21 +118,32 @@ class Mesh3dPanel(QFrame):
sensor_type: str,
*,
expected: Optional[ExpectedGeometry] = None,
expected_provider: "Optional[Callable[[], DeviceGeometry]]" = None,
package_override: str | Path | None = None,
) -> None:
"""Tell the pane which sensor it is looking at. Cheap; no IO.
Called when the sensor changes, so a box sensor reports "no 3D scene for this
sensor" rather than the finger's missing-package message.
``expected_provider`` is the device's own description, derived lazily: building
it reads and digests the unroll map the runtime loads, which the scene loader
does on its worker thread (never here).
"""
sensor_type = str(sensor_type)
if sensor_type == self._sensor_type and self._source is not None:
# Same sensor, already resolved: re-resolving would drop a loaded scene
# every time the window re-applied its panel visibility.
# every time the window re-applied its panel visibility. The provider is
# still refreshed — it may describe a config the operator just changed.
self._expected = expected if expected is not None else self._expected
if expected_provider is not None:
self._expected_provider = expected_provider
return
self._sensor_type = sensor_type
self._expected = expected
self._expected_provider = (
expected_provider if expected_provider is not None else self._expected_provider
)
self._loaded = False
if self._sensor_type != "finger":
self._show_message(_NO_SCENE)
@@ -186,7 +208,9 @@ class Mesh3dPanel(QFrame):
self._loader = SceneAssetLoader(self)
self._loader.loaded.connect(self._on_loaded)
self._loader.failed.connect(self._on_load_failed)
self._loader.load(self._source, self._expected)
self._load_request = self._loader.load(
self._source, self._expected, expected_provider=self._expected_provider
)
# -------------------------------------------------------------- settings
def set_display_settings(self, settings) -> None:
@@ -247,11 +271,17 @@ class Mesh3dPanel(QFrame):
return self._view.render_totals()
# ------------------------------------------------------------------ events
def _on_loaded(self, assets, description: str) -> None:
def _on_loaded(self, assets, description: str, request_id: int, device) -> None:
if int(request_id) != int(self._load_request):
# A completion for a request the operator has already replaced: adopting it
# would mark the *new* selection as loaded with the old file's scene.
return
view = self._view
if view is None:
return
if not view.use_scene(assets, source_id=description, expected=self._expected):
self._device = device
expected = device.expected if device is not None else self._expected
if not view.use_scene(assets, source_id=description, expected=expected):
return
self._loaded_path = None if self._source is None else self._source.path
self._stack.setCurrentWidget(view)
@@ -259,7 +289,9 @@ class Mesh3dPanel(QFrame):
# the operator (or the window, restoring a preference) turns it on.
view.set_enabled(self._enabled)
def _on_load_failed(self, item: Diagnostic) -> None:
def _on_load_failed(self, item: Diagnostic, request_id: int = 0) -> None:
if int(request_id) != int(self._load_request):
return
self._reported = item
self._loaded_path = None
self._show_message(_REJECTED, message=item.message)
+179 -3
View File
@@ -23,6 +23,7 @@ from __future__ import annotations
import itertools
import shutil
import time
from concurrent.futures import Future
from dataclasses import replace
from pathlib import Path
@@ -305,6 +306,8 @@ class _RecordingView(QWidget):
self.packets: list = []
self.coordinator = None
self.assets = None
self.scenes_used: list = []
self.accept_scene = True
self.available = True
self.reason = ""
self.source_kind = "live"
@@ -321,6 +324,14 @@ class _RecordingView(QWidget):
float(np.asarray(frame, dtype=np.float64).sum())
# the container's contract
def use_scene(self, assets, source_id: str, expected=None) -> bool:
"""The seam ``Mesh3dPanel._on_loaded`` adopts a scene through."""
self.scenes_used.append((assets, source_id, expected))
if not self.accept_scene:
return False
self.assets = assets
return True
def set_enabled(self, enabled: bool) -> None:
self.enabled_flag = bool(enabled)
@@ -554,16 +565,108 @@ def test_a_package_is_loaded_off_the_gui_thread(qt_app, write_poc):
assets, description = loaded[0]
assert assets.geometry_id == synthetic.poc_scene().geometry_id
assert str(path) in description
# A second load while one is in flight is the same request, not a queue.
# A second request while one is in flight is not dropped: it is the one the
# operator asked for *last*, so it waits for the first to finish and then runs.
loader2 = SceneAssetLoader()
try:
loader2._inflight = True
loader2.load(SceneSource(path=path, kind="package", directory=path.parent))
assert loader2._executor is None
first = loader2.load(SceneSource(path=path, kind="package", directory=path.parent))
second = loader2.load(SceneSource(path=path, kind="package", directory=path.parent))
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
finally:
loader2.close()
class _ManualExecutor:
"""Futures that only run when the test says so, like a loaded thread pool."""
def __init__(self) -> None:
self.pending: list = []
def submit(self, function, *args, **kwargs):
future: Future = Future()
self.pending.append((future, function, args, kwargs))
return future
def run_next(self) -> None:
future, function, args, kwargs = self.pending.pop(0)
if future.set_running_or_notify_cancel():
try:
future.set_result(function(*args, **kwargs))
except BaseException as exc: # noqa: BLE001 - mirrored into the future
future.set_exception(exc)
def shutdown(self, wait: bool = True) -> None: # noqa: ARG002 - executor contract
self.pending.clear()
def test_the_newest_load_is_the_one_that_runs(qt_app, write_poc):
"""R7: requests carry an id, and a queue keeps the newest rather than the first."""
from orisys_studio.scene3d.asset_source import SceneAssetLoader
path = write_poc()
loader = SceneAssetLoader()
loader._executor = _ManualExecutor()
answers: list = []
loader.loaded.connect(
lambda assets, description, request_id, device: answers.append(request_id)
)
loader.failed.connect(lambda item, request_id: answers.append(("failed", request_id)))
try:
first = loader.load(SceneSource(path=path, kind="package", directory=path.parent))
second = loader.load(SceneSource(path=path, kind="package", directory=path.parent))
assert answers == [], "a request was answered before its worker ran"
loader._executor.run_next()
assert answers == [first], "the pending request did not start after the first"
assert loader._pending is None
loader._executor.run_next()
assert answers == [first, second]
finally:
loader.close()
def test_the_pane_ignores_a_completion_for_a_superseded_load(qt_app):
"""The other half: an old answer must not mark the *new* selection as loaded."""
from orisys_studio.ui.mesh3d_panel import Mesh3dPanel
panel = Mesh3dPanel()
view = _RecordingView()
panel.attach_view(view)
assets = synthetic.poc_scene()
panel._load_request = 2
panel._on_loaded(assets, "old.oryscene", 1, None)
assert view.assets is None, "a completion for an older request was adopted"
panel._on_loaded(assets, "new.oryscene", 2, None)
assert view.assets is assets
assert view.scenes_used[-1][1] == "new.oryscene"
def test_a_failed_load_can_be_retried(qt_app, tmp_path, write_poc):
"""A rejection is not a dead end: the next request still runs."""
from orisys_studio.scene3d.asset_source import SceneAssetLoader
broken = tmp_path / f"broken{PACKAGE_SUFFIX}"
broken.write_bytes(b"not a zip archive at all")
good = write_poc()
loader = SceneAssetLoader()
loader._executor = _ManualExecutor()
answers: list = []
loader.loaded.connect(lambda assets, description, request_id, device: answers.append(request_id))
loader.failed.connect(lambda item, request_id: answers.append(("failed", request_id)))
try:
bad = loader.load(SceneSource(path=broken, kind="package", directory=tmp_path))
loader._executor.run_next()
assert answers == [("failed", bad)]
again = loader.load(SceneSource(path=good, kind="package", directory=good.parent))
loader._executor.run_next()
assert answers[-1] == again
finally:
loader.close()
def test_a_corrupt_package_is_reported_not_raised(qt_app, tmp_path):
from orisys_studio.scene3d.asset_source import SceneAssetLoader
@@ -694,6 +797,79 @@ def test_a_fresh_field_reaches_the_screen_and_clears_the_stale_state(qt_app):
view.close()
# ---------------------------------- stage 6: the adapter speaks for the *device* (R3)
def test_the_adapter_identity_comes_from_the_device_not_the_asset(qt_app):
"""R3: built from the asset, "the frame matches the scene" is self-certified."""
from orisys_app_shared.scene3d.contracts import ExpectedGeometry
from orisys_studio.scene3d.view import Scene3DView
view = Scene3DView()
try:
if not view.available:
pytest.skip("no GL binding here")
assets = replace(synthetic.poc_scene(), source_geometry_id="device-src")
device = ExpectedGeometry(
"finger",
"rev",
"",
assets.field_domain_id,
assets.shape_hw,
source_geometry_id="device-src",
)
assert view.use_scene(assets, source_id="poc", expected=device)
described = view.describe()["scene"]
assert described["device_verified"] is True, described
assert described["device"]["source_geometry_id"] == "device-src"
assert described["source_geometry_id"] == "device-src"
adapter = view._adapter
assert adapter is not None
# The device stated no metre-scaled id, so the asset's is used *and* the pane
# is only verified on the identities the device did state.
assert adapter.geometry_id == assets.geometry_id
assert adapter.field_domain_id == assets.field_domain_id
assert adapter.shape_hw == tuple(assets.shape_hw)
finally:
view.close()
def test_a_device_whose_domain_disagrees_refuses_the_scene(qt_app):
from orisys_app_shared.scene3d.contracts import ExpectedGeometry
from orisys_studio.scene3d.view import Scene3DView
view = Scene3DView()
try:
if not view.available:
pytest.skip("no GL binding here")
assets = synthetic.poc_scene()
device = ExpectedGeometry(
"finger", "", "", "somebody-elses-domain", assets.shape_hw
)
assert view.use_scene(assets, source_id="poc", expected=device) is False
assert view.assets is None
assert view.last_diagnostic is not None
assert view.last_diagnostic.code == "field_domain_mismatch"
finally:
view.close()
def test_a_scene_without_a_device_description_is_shown_as_unverified(qt_app):
"""Expected unknown: show the surface, but never claim it was checked."""
from orisys_studio.scene3d.view import Scene3DView
view = Scene3DView()
try:
if not view.available:
pytest.skip("no GL binding here")
assert view.use_scene(synthetic.poc_scene(), source_id="poc")
described = view.describe()["scene"]
assert described["device_verified"] is False
assert view.last_diagnostic is not None
assert view.last_diagnostic.code == "geometry_unverified"
finally:
view.close()
# ------------------------------------------------------- stage 5: the record block
def test_a_recording_says_what_the_3d_pane_was(
make_window, next_record_root, write_poc, qt_app, monkeypatch