feat(studio): 三维视图接入产品(阶段 4:界面、生命周期与记录隔离)

- What: `Scene3DView` 装配(widget/相机/渲染器/合成器/协调器/适配器),`asset_source`
  只查找并后台加载已编译的 `.oryscene` 包,`scene3d/prefs` 持久化并**修复**显示设置,
  `ui/mesh3d_panel` 用真实容器替换占位标签(无 GL/无包/包被拒/传感器不匹配各显示一行原因),
  `main.py` 在 `QApplication` 之前选定 GL profile,`app_window` 接线:3D 作为采集线程上的
  第二个 sink、`_on_run_tick` 驱动 `tick()`、视图切换与 `closeEvent` 释放、preferences 落盘。
- Why: 阶段 4 的验收要求"三维开关不影响采集/记录/力曲线",并把阶段 1 留下的
  "开/关三维 CSV 一致"这条补上;同时把"没有 context"从"看起来像在工作"变成可显示的终态。
- Verification: app 侧 test_scene3d_view 28 项、test_scene3d_gl_smoke 14 项(真实 GPU)、
  adapters 17 项、skeleton 4 项、stage1 24 项、coordinator 13 项、ui_smoke 86 项;
  shared 侧 contract 26 / assets 33 / package 19 / live 25;`ui_check.py` 566 项检查 0 失败
  (新增 "3D pane" 段 12 项);`locale_check.py` 302 个字面 key 0 缺失、中英各 377 key 对齐;
  记录隔离:同一 96 帧会话挂/不挂 3D 消费者,`forces.csv` 逐字节相同、有效样本数与记录问题数相同;
  生产者预算:合成器放慢到 50 ms 时 `submit_packet` p99 < 1 ms。
- Not in this change: 示意位移(`displacement_sensor_m`)仍未接入(需 SDK lift 与已确认尺度);
  ARM 板卡适配(按用户要求暂缓);包签名/AES-GCM 与长时性能报告(阶段 5);
  仓库中仍未提供 `.oryscene` 包(发布产物,且其单位与变换必须由人声明)。
This commit is contained in:
ykcheungab
2026-09-14 15:47:32 +08:00
parent ea1027e5a6
commit ac4dcd643f
20 changed files with 2718 additions and 83 deletions
+107 -2
View File
@@ -158,11 +158,116 @@ def test_the_finger_adapter_can_be_built_from_a_domain(scene):
assert domain.source_hw == (288, 512), domain.source_hw
def test_the_shipped_map_cannot_be_applied_to_the_shipped_flow():
"""Why the app must not remap the finger's flow: the map is domain→*source*.
The unroll map is the same size as the UV domain, but its values are pixel
coordinates in the 512x288 source image — not in the 300x280 domain. The live
flow is already computed on the unrolled frame, so feeding it through this map
would be a double remap: it would index the flow at source-pixel coordinates.
This test pins the reason the app passes no map, so a later "improvement" has to
argue with it.
Note the asymmetry that makes the mistake look plausible: in x the map exceeds
the domain's column count (so it would read out of range), while in y it stays
below the row count (so it would silently read the wrong rows). Half of a
double remap crashes and half of it lies.
"""
map_path = _run_map()
if map_path is None:
pytest.skip("the shipped unroll map is not present")
from orisys_app_shared.scene3d.legacy import load_legacy_domain
domain = load_legacy_domain(map_path)
assert domain.map_x is not None and domain.map_y is not None
rows, columns = domain.shape_hw
source_rows, source_columns = domain.source_hw
assert (rows, columns) == (300, 280) and (source_rows, source_columns) == (288, 512)
# Bounded by the source, not by the domain: that is what makes them source pixels.
assert 0.0 <= float(domain.map_x[domain.map_x >= 0].max()) <= source_columns - 1
assert 0.0 <= float(domain.map_y[domain.map_y >= 0].max()) <= source_rows - 1
assert float(domain.map_x.max()) > columns - 1, (
"x would index past the last column of a 280-wide flow field"
)
assert float(domain.map_y.max()) > rows - 1 or source_rows <= rows, (
"y stays inside the 300-row field: the y half of the mistake is silent"
)
def test_the_live_flow_is_already_the_scene_domain():
"""The measurement behind that decision, taken from the shipping config.
``camera.resolution_stitch`` is what ``Sensor`` sizes its flow buffer from and
what the unroll stage outputs, so the flow grid *is* the UV domain. If a future
config changes that, this test fails and the adapter wiring has to be revisited
rather than the numbers quietly not matching.
"""
import json
from pathlib import Path
config = Path(__file__).resolve().parents[1] / "config" / "sensors" / "finger"
map_path = _run_map()
if not (config / "sensor.json").is_file() or map_path is None:
pytest.skip("the finger config and the unroll map are needed")
camera = json.loads((config / "sensor.json").read_text(encoding="utf-8"))["camera"]
resolution = camera["resolution_stitch"]
# Sensor builds `flow` as (resolution_stitch[1], resolution_stitch[0]).
flow_hw = (int(resolution[1]), int(resolution[0]))
from orisys_app_shared.scene3d.legacy import load_legacy_domain
domain = load_legacy_domain(map_path)
assert flow_hw == tuple(domain.shape_hw) == (300, 280)
def test_the_finger_adapter_does_not_colour_outside_the_uv_island(scene):
"""The device mask is what says where the surface is; outside it there is none."""
adapter = FingerFrameAdapter(
geometry_id=scene.geometry_id,
field_domain_id=scene.field_domain_id,
shape_hw=scene.shape_hw,
)
flow = np.zeros((*scene.shape_hw, 2), dtype=np.float32)
flow[..., 0] = 1.0
mask = np.zeros(scene.shape_hw, dtype=bool)
mask[1, 1] = True
frame = adapter.snapshot(
FakePacket(flow=flow, valid_mask=mask), runtime=None, identity=_identity()
)
assert frame is not None
valid = np.asarray(frame.valid)
assert valid.sum() == 1 and bool(valid[1, 1])
assert np.asarray(frame.scalar)[0, 0] == 1.0, (
"the scalar is kept as measured; only validity says it is not a measurement"
)
def test_a_device_mask_from_another_grid_is_ignored_not_stretched(scene):
adapter = FingerFrameAdapter(
geometry_id=scene.geometry_id,
field_domain_id=scene.field_domain_id,
shape_hw=scene.shape_hw,
)
flow = np.ones((*scene.shape_hw, 2), dtype=np.float32)
wrong = np.zeros((7, 7), dtype=bool)
frame = adapter.snapshot(
FakePacket(flow=flow, valid_mask=wrong), runtime=None, identity=_identity()
)
assert frame is not None
assert np.asarray(frame.valid).all()
def _run_map():
from pathlib import Path
path = Path(r"D:\orisys_ws\orisys-sdk\sensors\finger\assets\30fps-0703\unroll_map_uv_s0.4.npz")
return path if path.is_file() else None
root = Path(__file__).resolve().parents[1] / "config" / "sensors" / "finger" / "assets"
for name in ("30fps-0703", "visualization"):
path = root / name / "unroll_map_uv_s0.4.npz"
if path.is_file():
return path
for path in sorted(root.glob("*/unroll_map_uv*.npz")):
return path
return None
# -------------------------------------------------------------------- box adapter
+181
View File
@@ -377,3 +377,184 @@ def test_the_probe_reports_this_machine_truthfully():
assert context["profile_used"] == "desktop33"
assert not context["software_renderer"], "the dev machine has a real GPU"
assert report["renderer_initialized"] and report["released"]
# ------------------------------------------------- stage 4: the assembled view
class _Packet:
"""The parts of an ``SdkPacket`` the finger adapter and the identity need."""
def __init__(self, frame_id: int, shape_hw: tuple[int, int], value: float = 0.5) -> None:
self.session_id = 1
self.frame_id = int(frame_id)
self.mono_t = float(frame_id) / 30.0
self.flow = np.full((*shape_hw, 2), float(value), dtype=np.float32)
self.raw_frame = np.zeros((4, 4, 3), dtype=np.uint8)
@pytest.fixture(scope="module")
def assembled_view(qt_app):
"""A real ``Scene3DView``: pane, coordinator, composer, widget, real context."""
from orisys_studio.scene3d.gl_backend import configure_surface_format
from orisys_studio.scene3d.view import Scene3DView
configure_surface_format("desktop33")
view = Scene3DView()
if not view.available:
pytest.skip(f"no GL view here: {view.reason}")
assets = synthetic.poc_scene()
if not view.use_scene(assets, source_id="poc-live"):
pytest.skip("the synthetic scene was refused")
view.resize(360, 300)
view.show()
view.set_enabled(True)
renderer = view.gl_widget._renderer
painted = _pump(qt_app, view.gl_widget, lambda: bool(renderer.part_ids), timeout_s=8.0)
if not painted:
view.close()
pytest.skip(f"no usable GL context here: {view.gl_widget.diagnostic()}")
yield view, renderer, assets
view.close()
view.close()
qt_app.processEvents()
def test_the_assembled_view_draws_what_the_acquisition_thread_hands_it(assembled_view, qt_app):
"""The whole stage-4 path: packet in, pixels out, and no recording involved."""
view, renderer, assets = assembled_view
widget = view.gl_widget
for index in range(30):
view.submit_packet(_Packet(index, assets.shape_hw, value=0.4 + index * 0.05))
view.tick()
qt_app.processEvents()
assert _pump(qt_app, widget, lambda: view.state() in ("live", "no_data"), timeout_s=5.0)
assert view.state() in ("live", "no_data"), view.state()
stats = widget._last_stats_at and None # stats are published on a timer
del stats
assert view.last_descriptor is not None, "the pane never learned what it is showing"
assert view.last_descriptor.quantity == "flow_magnitude"
assert view.last_descriptor.quality == "approximate"
drawn = _drawn(_render(widget, qt_app))
assert drawn.size, "the live frame was not drawn"
def test_the_producer_side_never_waits_for_the_gpu(assembled_view, qt_app):
"""The measured budget: the acquisition thread's own cost, with a slow composer.
This is the stage-3 property asserted through the *application's* entry point, so
it includes the adapter (the flow magnitude over the whole grid) and not just the
mailbox. The composer is slowed to 50 ms, which is far below any real frame rate:
frames must drop in the display and nowhere else.
"""
import time
view, _, assets = assembled_view
coordinator = view.coordinator
real_composer = coordinator._composer
class Slow:
def compose(self, *args, **kwargs):
time.sleep(0.05)
return real_composer.compose(*args, **kwargs)
def rest_frame(self, *args, **kwargs):
return real_composer.rest_frame(*args, **kwargs)
coordinator._composer = Slow()
try:
samples: list[float] = []
for index in range(200):
packet = _Packet(index, assets.shape_hw, value=0.5)
started = time.perf_counter()
view.submit_packet(packet)
samples.append((time.perf_counter() - started) * 1000.0)
view.tick()
ordered = sorted(samples)
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"
)
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:
coordinator.tick()
time.sleep(0.01)
def test_an_automatic_colour_scale_follows_the_frame(assembled_view, qt_app):
"""A range taken from the data, and a fixed range that ignores it."""
view, renderer, assets = assembled_view
widget = view.gl_widget
assets_frame = view.assets
assert assets_frame is not None
count = int(assets_frame.sensing_part().positions_sensor_m.shape[0])
def frame_with(values: np.ndarray) -> RenderFrame:
return RenderFrame(
identity=FrameIdentity("poc-live", 1, 1, 0.0, generation=view.coordinator.generation),
asset_hash=assets.asset_hash,
geometry_id=assets.geometry_id,
sensing_part_id=assets_frame.sensing_part().part_id,
positions_sensor_m=assets_frame.sensing_part().positions_sensor_m,
normals_sensor=assets_frame.sensing_part().normals_sensor,
scalar=freeze(np.asarray(values, dtype=np.float32), dtype=np.float32),
valid=freeze(np.ones(count, dtype=np.bool_), dtype=np.bool_),
scalar_descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False),
)
descriptor = FieldDescriptor("flow_magnitude", "px", "approximate", False)
auto = DisplaySettings(
housing_visible=False,
color_scale=ColorScale(
descriptor=descriptor, lower=0.0, upper=1.0, lut_id="sequential", mode="automatic"
),
)
fixed_wide = DisplaySettings(
housing_visible=False,
color_scale=ColorScale(
descriptor=descriptor, lower=0.0, upper=1000.0, lut_id="sequential", mode="fixed"
),
)
ramp = np.linspace(3.0, 7.0, count)
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))
widget.set_settings(auto)
widget.present(frame_with(ramp))
spread = len(np.unique(_drawn(_render(widget, qt_app)), axis=0))
assert spread > flat, (
"an automatic range did not use the frame's own values "
f"(distinct colours: automatic {spread}, fixed 0..1000 {flat})"
)
def test_the_pane_releases_its_context_and_survives_a_second_close(qt_app):
"""`closeEvent` calls this; a second close must be a no-op, not a crash."""
from orisys_studio.scene3d.gl_backend import configure_surface_format
from orisys_studio.scene3d.view import Scene3DView
configure_surface_format("desktop33")
view = Scene3DView()
if not view.available:
pytest.skip("no GL view here")
if not view.use_scene(synthetic.poc_scene(), source_id="poc-close"):
pytest.skip("the synthetic scene was refused")
view.resize(200, 160)
view.show()
view.set_enabled(True)
renderer = view.gl_widget._renderer
if not _pump(qt_app, view.gl_widget, lambda: bool(renderer.part_ids), timeout_s=8.0):
view.close()
pytest.skip("no usable GL context here")
view.close()
assert not renderer.is_initialized
assert not renderer.part_ids, "closing the pane left GPU buffers behind"
view.close()
view.close()
qt_app.processEvents()
+48 -17
View File
@@ -88,19 +88,25 @@ def test_the_widget_holds_cpu_state_and_touches_no_context(qt_app):
widget.deleteLater()
def test_the_remaining_stub_says_which_stage_fills_it_in(qt_app):
"""What is implemented is exercised elsewhere; what is not says so.
def test_no_scene3d_entry_point_is_still_a_stub(qt_app):
"""The stage-0 floor, kept as a gate: nothing left says "not implemented".
Stages 1 to 3 landed the camera, the dispatcher, the renderer, the sampler, the
composer, the schedule and the adapters. ``ThreeDPanel`` is the one piece left,
and it must still fail loudly with the stage that owns it rather than quietly
doing nothing.
Every stage of the plan replaces stubs with the real thing, and a stub that
survives its stage is worse than a missing feature: it looks implemented. This
fails if a ``raise NotImplementedError`` comes back into either scene3d package,
and it exercises the last entry points that used to be stubs (stage 4's panel)
so "implemented" is also checked by calling them.
"""
from pathlib import Path
import orisys_studio.scene3d
from orisys_app_shared.scene3d.contracts import (
CameraPreset,
DisplaySettings,
FieldDescriptor,
FrameIdentity,
RenderStats,
diagnostic,
)
from orisys_studio.scene3d.camera import OrbitCamera
from orisys_studio.scene3d.coordinator import ThreeDCoordinator
@@ -108,9 +114,20 @@ def test_the_remaining_stub_says_which_stage_fills_it_in(qt_app):
from orisys_studio.scene3d.panel import ThreeDPanel
from orisys_studio.scene3d.widget import Sensor3DWidget
roots = [Path(orisys_studio.scene3d.__file__).resolve().parent]
import orisys_app_shared.scene3d as shared_scene3d
roots.append(Path(shared_scene3d.__file__).resolve().parent)
for root in roots:
for path in sorted(root.rglob("*.py")):
text = path.read_text(encoding="utf-8")
assert "NotImplementedError" not in text or "__init__" in path.name, path
if path.name == "__init__.py":
# The package docstring may mention the word; code may not.
assert "raise NotImplementedError" not in text, path
camera = OrbitCamera()
renderer = GLRenderer()
widget = Sensor3DWidget(renderer, camera)
widget = Sensor3DWidget(GLRenderer(), camera)
coordinator = ThreeDCoordinator(widget, composer=object(), executor=None, max_fps=30) # type: ignore[arg-type]
panel = ThreeDPanel(coordinator, widget)
try:
@@ -126,14 +143,28 @@ def test_the_remaining_stub_says_which_stage_fills_it_in(qt_app):
assert coordinator.superseded_frames == 0
coordinator.close() # idempotent, and safe before any scene is active
for call, stage in (
(lambda: panel.set_state("ready"), "stage 4"),
(lambda: panel.set_render_stats(RenderStats()), "stage 4"),
(lambda: panel.set_diagnostic(None), "stage 4"),
):
with pytest.raises(NotImplementedError) as excinfo:
call()
assert stage in str(excinfo.value), (stage, str(excinfo.value))
del DisplaySettings, FrameIdentity
# Stage 4: the panel is the operator's view of all of the above.
panel.set_state("ready")
assert panel.state_label.text()
panel.set_descriptor(FieldDescriptor("flow_magnitude", "px", "approximate", False))
assert "flow_magnitude" in panel.legend_label.text()
panel.set_descriptor(None)
panel.set_render_stats(RenderStats(displayed_frames=3, superseded_frames=1))
assert "3" in panel.stats_label.text()
panel.set_diagnostic(None)
panel.set_diagnostic(diagnostic("field_unavailable", "no field in this frame"))
assert "no field in this frame" in panel.legend_label.text()
panel.set_scene(None)
panel.set_enabled(False)
settings = panel.settings()
assert settings.max_fps == 30
assert settings.display_gain == 0.0
assert settings.housing_visible is True, (
"the panel's resting state and DisplaySettings' default must agree"
)
assert settings.color_scale is not None
assert settings.color_scale.mode == "automatic"
assert DisplaySettings(max_fps=30).max_fps == settings.max_fps
del FrameIdentity
finally:
widget.deleteLater()
+535
View File
@@ -0,0 +1,535 @@
"""Stage 4 acceptance: the 3D pane in the product.
Four things are being checked here, all of them about integration rather than about
mathematics (the camera, the sampler and the composer have their own files):
* **the pane degrades into a sentence.** No GL context, no package, a refused
package, the wrong sensor type: each one is a line of text in the operator's
language, and every one of them leaves the two-dimensional application alone.
* **the pane is one state with two controls.** The View menu entry and the checkbox
inside the pane are the same flag, and toggling them repeatedly does not drift or
leak.
* **the record is not affected.** The 3D sink runs on the acquisition thread next to
the record sink, so the same session is run twice — once with a consumer attached
to the pane, once without — and the CSV bytes and the valid-sample count must be
identical. This is the stage-1 note ("开/关三维的 CSV 行序一致") being paid off.
* **the saved settings are repaired, not trusted.** A preferences file is editable by
hand; it must not be able to make the window fail to open or make a stale file
relabel a live measurement.
"""
from __future__ import annotations
import itertools
import shutil
from pathlib import Path
import numpy as np
import pytest
import ui_check
from orisys_app_shared.scene3d import synthetic
from orisys_app_shared.scene3d.contracts import (
ColorScale,
DisplaySettings,
FieldDescriptor,
UNAVAILABLE_DESCRIPTOR,
)
from orisys_app_shared.scene3d.package import write_package
from orisys_studio.scene3d import prefs as scene3d_prefs
from orisys_studio.scene3d.asset_source import (
PACKAGE_SUFFIX,
SceneSource,
find_package,
load_package,
no_asset_diagnostic,
)
from tests.test_ui_smoke import _finish, _running_window
#: Scratch root for the two-session comparison. One subdirectory per run, because
#: the point of the test is that two recordings are identical, not that one file
#: can be overwritten.
RECORD_SCRATCH = Path(__file__).resolve().parents[1] / "outputs" / "_test_record_3d"
@pytest.fixture
def next_record_root():
shutil.rmtree(RECORD_SCRATCH, ignore_errors=True)
RECORD_SCRATCH.mkdir(parents=True, exist_ok=True)
counter = itertools.count()
def _next() -> Path:
path = RECORD_SCRATCH / f"run{next(counter)}"
path.mkdir(parents=True, exist_ok=True)
return path
yield _next
shutil.rmtree(RECORD_SCRATCH, ignore_errors=True)
@pytest.fixture
def write_poc(tmp_path):
"""A real package on disk, produced by the shipping writer."""
def _write(name: str = "finger-display-1.0.0") -> "object":
path = tmp_path / f"{name}{PACKAGE_SUFFIX}"
write_package(
synthetic.poc_scene(),
path,
sensor_model="finger",
hardware_revisions=("*",),
)
return path
return _write
def _pump(qt_app, predicate, *, timeout_s: float = 10.0) -> bool:
"""Let Qt deliver queued signals until ``predicate`` holds."""
import time
deadline = time.perf_counter() + timeout_s
while time.perf_counter() < deadline:
qt_app.processEvents()
if predicate():
return True
time.sleep(0.01)
return False
# ------------------------------------------------------------------- the pane
def test_a_box_sensor_reports_that_it_has_no_3d_scene(qt_app):
from orisys_studio.ui.mesh3d_panel import Mesh3dPanel
panel = Mesh3dPanel()
try:
panel.set_sensor("box")
assert panel.showing_message
assert panel.view is None, "a box sensor must not build a GL widget at all"
assert "3D" in panel.message.text() or "三维" in panel.message.text()
finally:
panel.deleteLater()
def test_a_missing_package_names_where_it_looked(tmp_path, qt_app):
from orisys_studio.ui.mesh3d_panel import Mesh3dPanel
panel = Mesh3dPanel()
try:
panel.set_sensor("finger", package_override=tmp_path / "nope.oryscene")
assert panel.showing_message
diagnostic = panel.last_diagnostic
assert diagnostic is not None and diagnostic.code == "asset_rejected"
assert "directory" in diagnostic.details
assert "compile_scene.py" in diagnostic.details["compile"]
assert panel.message.text(), "the pane showed an empty line instead of the reason"
finally:
panel.deleteLater()
def test_the_view_is_built_once_and_only_for_a_finger(qt_app, write_poc):
from orisys_studio.ui.mesh3d_panel import Mesh3dPanel
panel = Mesh3dPanel()
try:
panel.set_sensor("box")
assert panel.ensure_view() is None
panel.set_sensor("finger", package_override=write_poc())
first = panel.ensure_view()
second = panel.ensure_view()
assert first is not None and first is second, "the pane rebuilt its GL widget"
assert panel.scene_path() is not None
finally:
panel.close_view()
panel.deleteLater()
def test_the_pane_fits_the_width_the_window_reserves_for_it(qt_app):
"""A pane whose content demands more than its declared minimum clips itself."""
from orisys_studio.app_window import MIN_PANEL_WIDTHS
from orisys_studio.scene3d.view import Scene3DView
view = Scene3DView()
try:
wanted = view.minimumSizeHint().width()
assert wanted <= MIN_PANEL_WIDTHS["mesh3d"], (
f"the 3D pane needs {wanted} px but the window reserves only "
f"{MIN_PANEL_WIDTHS['mesh3d']}"
)
assert view.gl_widget is None or view.gl_widget.minimumHeight() >= 100, (
"the picture must keep a usable height when the pane is short"
)
finally:
view.close()
def test_a_platform_without_a_context_says_so_instead_of_showing_an_empty_view(qt_app):
"""The offscreen plugin cannot create a context; that must not look like "no data".
Qt reports this by never calling ``initializeGL``, so the widget asks Qt the one
question that has an answer — is there a context at all — on its first paint.
"""
from orisys_studio.ui.mesh3d_panel import Mesh3dPanel
panel = Mesh3dPanel()
try:
panel.set_sensor("finger")
view = panel.ensure_view()
if view is None:
pytest.skip("the pane did not build a view here")
# The pane has to be a shown window for Qt to lay out and paint its child: a
# child of a hidden parent is never painted, which is why the widget only
# learns that it has no context once something is actually on screen.
panel.resize(240, 400)
panel.show()
view.show()
qt_app.processEvents()
widget = view.gl_widget
for _ in range(5):
widget.repaint()
qt_app.processEvents()
if widget.capabilities() is not None:
pytest.skip("this platform has a real GL context")
assert widget.unavailable, "a context-less widget claimed to be usable"
assert panel.showing_message, "the pane kept showing an empty GL widget"
assert panel.message.text().strip(), "the pane showed an empty line"
finally:
panel.close_view()
panel.deleteLater()
# --------------------------------------------------------- one state, two controls
def test_the_pane_and_the_view_menu_are_the_same_flag(make_window):
window = make_window(sensor_type="finger")
try:
assert window.mesh3d_panel.enabled() == bool(window._show_finger_3d)
for _ in range(20):
window.act_finger_3d.setChecked(not window.act_finger_3d.isChecked())
assert window.mesh3d_panel.enabled() == bool(window._show_finger_3d)
assert window.act_finger_3d.isChecked() == bool(window._show_finger_3d)
# The pane's own checkbox is the other way in, and it drives the same flag.
view = window.mesh3d_panel.view
if view is not None and view.panel is not None:
view.panel.enable_check.setChecked(not window._show_finger_3d)
assert window.act_finger_3d.isChecked() == bool(window._show_finger_3d)
assert window.mesh3d_panel.enabled() == bool(window._show_finger_3d)
finally:
window.close()
def test_panel_controls_do_not_touch_the_algorithm_side(make_window):
"""The observation camera is display state; nothing else may move with it."""
window = make_window(sensor_type="finger")
try:
before = {
"units": window.toolbar.units(),
"image_view_mode": window.arrow_panel.view_mode(),
"arrow_rotation_deg": window.arrow_panel.rotation_deg(),
"view_preset": window._view_preset,
"sensor_type": window.sensor_picker.sensor_type(),
"splitter": list(window.content_splitter.sizes()),
"samples": window.session_store.count,
}
view = window.mesh3d_panel.view
if view is None:
pytest.skip("no 3D view on this platform")
camera = view._camera
start = np.asarray(camera.matrices((240, 180)).view, dtype=float)
camera.orbit((30.0, 10.0), (240, 180))
camera.pan((5.0, 5.0), (240, 180))
camera.zoom(2.0)
moved = np.asarray(camera.matrices((240, 180)).view, dtype=float)
assert not np.allclose(start, moved), "the camera did not react to the mouse"
# Still a rigid transform: no mirroring, no scale change.
rotation = moved[:3, :3]
assert np.linalg.det(rotation) == pytest.approx(1.0, abs=1e-6)
assert rotation @ rotation.T == pytest.approx(np.eye(3), abs=1e-6)
after = {
"units": window.toolbar.units(),
"image_view_mode": window.arrow_panel.view_mode(),
"arrow_rotation_deg": window.arrow_panel.rotation_deg(),
"view_preset": window._view_preset,
"sensor_type": window.sensor_picker.sensor_type(),
"splitter": list(window.content_splitter.sizes()),
"samples": window.session_store.count,
}
assert after == before
finally:
window.close()
# ------------------------------------------------------------------ record isolation
class _RecordingView:
"""A stand-in for the pane's view that records what it was handed.
Used to attach a *real* consumer to the real sink path on a machine with no GL:
what is being tested is whether the window's second sink can change the record,
not whether the adapter can compute a flow magnitude.
"""
def __init__(self) -> None:
self.packets: list = []
def submit_packet(self, packet) -> None:
# Deliberately more work than the real one does per packet, so a leak into the
# record path would show up as a difference rather than as a timing artefact.
self.packets.append(packet)
frame = np.asarray(packet.raw_frame)
float(np.asarray(frame, dtype=np.float64).sum())
def _run_session(make_window, record_root, *, with_3d: bool):
"""One full session with the 3D sink attached, or with no pane at all."""
import orisys_studio.app_window as app_module
window, stuck = _running_window(make_window)
app_module.recordings_dir = lambda: record_root
view = None
if with_3d:
view = _RecordingView()
window.mesh3d_panel._view = view
else:
window.mesh3d_panel._view = None
window.toolbar.btn_record.setChecked(True)
for index in range(96):
stuck.emit(ui_check._fake_packet(index, mono_t=index / 30.0))
window._on_run_tick()
window.toolbar.btn_record.setChecked(False)
report = window._record_last_report
rows = ""
if report is not None and (report.directory / "forces.csv").is_file():
rows = (report.directory / "forces.csv").read_bytes().decode("utf-8")
counts = (window.session_store.count, len(list(window.session_store.iter_valid("raw"))))
_finish(window, stuck)
window.close()
return rows, counts, report, view
def test_the_3d_consumer_cannot_change_the_record(make_window, next_record_root):
"""The acceptance row: identical CSV and identical valid samples, 3D on or off."""
with_3d_csv, with_3d_counts, with_3d_report, view = _run_session(
make_window, next_record_root(), with_3d=True
)
without_csv, without_counts, without_report, _ = _run_session(
make_window, next_record_root(), with_3d=False
)
assert view is not None and len(view.packets) == 96, (
"the pane's sink did not receive the session's packets"
)
assert with_3d_csv, "the recording produced no forces.csv"
assert with_3d_csv == without_csv, (
"the record changed when a 3D consumer was attached to the same packets"
)
assert with_3d_counts == without_counts, (with_3d_counts, without_counts)
assert with_3d_report is not None and without_report is not None
assert len(with_3d_report.problems) == len(without_report.problems)
header = with_3d_csv.splitlines()[0]
assert "t_rel_s" in header and len(with_3d_csv.splitlines()) > 10
def test_the_3d_sink_is_registered_and_inert_when_the_pane_is_off(make_window, next_record_root):
"""A sink that has to be added and removed around a toggle is a state machine."""
import orisys_studio.app_window as app_module
window, stuck = _running_window(make_window)
app_module.recordings_dir = lambda: next_record_root()
try:
assert window._on_packet_recorded in stuck.sinks()
assert window._on_packet_for_3d in stuck.sinks(), (
"the 3D sink must be registered with the worker, not attached per toggle"
)
window.mesh3d_panel._view = None
for index in range(10):
stuck.emit(ui_check._fake_packet(index))
assert window.session_store.count == 10
assert window.mesh3d_panel.view is None
finally:
_finish(window, stuck)
window.close()
def test_repeated_closes_are_safe(make_window):
window = make_window(sensor_type="finger")
try:
window.mesh3d_panel.ensure_view()
window.mesh3d_panel.close_view()
window.mesh3d_panel.close_view()
window.close()
window.close()
finally:
pass
# ------------------------------------------------------------------ preferences
def test_the_display_settings_round_trip():
settings = DisplaySettings(
display_gain=12.0,
deform_enabled=True,
housing_visible=False,
max_fps=60,
color_scale=ColorScale(
descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False),
lower=0.25,
upper=4.5,
lut_id="sequential",
mode="fixed",
),
)
restored = scene3d_prefs.settings_from_prefs(scene3d_prefs.settings_to_prefs(settings))
assert restored.display_gain == 12.0
assert restored.deform_enabled is True
assert restored.housing_visible is False
assert restored.max_fps == 60
assert restored.color_scale is not None
assert (restored.color_scale.lower, restored.color_scale.upper) == (0.25, 4.5)
assert restored.color_scale.mode == "fixed"
def test_a_saved_descriptor_is_never_restored_as_a_measurement():
"""The colour bar describes the live field, not the last one that was on screen."""
block = scene3d_prefs.settings_to_prefs(
DisplaySettings(
color_scale=ColorScale(
descriptor=FieldDescriptor("displacement", "m", "calibrated", True),
lower=0.0,
upper=1.0,
lut_id="diverging",
mode="fixed",
)
)
)
restored = scene3d_prefs.settings_from_prefs(block)
assert restored.color_scale is not None
assert restored.color_scale.descriptor == UNAVAILABLE_DESCRIPTOR
given = FieldDescriptor("flow_magnitude", "px", "approximate", False)
restored_with = scene3d_prefs.settings_from_prefs(block, descriptor=given)
assert restored_with.color_scale.descriptor == given
@pytest.mark.parametrize(
"block",
[
None,
{},
{"display_gain": "lots", "max_fps": "fast", "deform_enabled": "maybe"},
{"display_gain": -5.0, "max_fps": 17},
{"display_gain": 1e9, "max_fps": 30},
{"color_scale": {"mode": "sideways", "lower": "a", "upper": "b"}},
{"color_scale": {"mode": "fixed", "lower": 5.0, "upper": 5.0}},
{"color_scale": {"mode": "fixed", "lower": float("nan"), "upper": float("inf")}},
{"color_scale": {"mode": "fixed", "lower": 1e300, "upper": 1e301}},
],
)
def test_a_hand_edited_settings_block_is_repaired(block):
settings = scene3d_prefs.settings_from_prefs(block)
assert isinstance(settings, DisplaySettings)
assert scene3d_prefs.MIN_GAIN <= settings.display_gain <= scene3d_prefs.MAX_GAIN
assert settings.max_fps in scene3d_prefs.FPS_CHOICES
if settings.color_scale is not None:
lower, upper = settings.color_scale.lower, settings.color_scale.upper
assert lower == lower and upper == upper, "a NaN survived the repair"
assert upper > lower
assert abs(lower) <= scene3d_prefs.MAX_SCALE_BOUND
assert abs(upper) <= scene3d_prefs.MAX_SCALE_BOUND
assert settings.color_scale.mode in scene3d_prefs.SCALE_MODES
def test_the_panel_offers_exactly_the_rates_the_repair_accepts(qt_app):
from orisys_studio.scene3d.panel import FPS_CHOICES
assert tuple(FPS_CHOICES) == tuple(scene3d_prefs.FPS_CHOICES)
# ------------------------------------------------------------------- asset source
def test_the_package_search_order_is_override_then_sensor_directory(tmp_path, write_poc):
explicit = write_poc("explicit")
sensor_dir = tmp_path / "sensors" / "finger" / "scene"
sensor_dir.mkdir(parents=True)
shipped = sensor_dir / f"shipped{PACKAGE_SUFFIX}"
shipped.write_bytes(explicit.read_bytes())
found = find_package("finger", override=explicit, root=tmp_path)
assert found is not None and found.path == explicit
found_dir = find_package("finger", root=tmp_path)
assert found_dir is not None and found_dir.path == shipped
assert find_package("box", root=tmp_path) is None
assert find_package("finger", override=tmp_path / "gone.oryscene", root=tmp_path) is not None, (
"a stale override must fall through to the shipped package, not disable 3D"
)
def test_a_package_is_loaded_off_the_gui_thread(qt_app, write_poc):
from orisys_studio.scene3d.asset_source import SceneAssetLoader
path = write_poc()
loader = SceneAssetLoader()
loaded: list = []
failed: list = []
loader.loaded.connect(lambda assets, description: loaded.append((assets, description)))
loader.failed.connect(failed.append)
try:
loader.load(SceneSource(path=path, kind="package", directory=path.parent))
assert _pump(qt_app, lambda: bool(loaded or failed)), "the loader never answered"
finally:
loader.close()
assert not failed, [item.message for item in failed]
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.
loader2 = SceneAssetLoader()
try:
loader2._inflight = True
loader2.load(SceneSource(path=path, kind="package", directory=path.parent))
assert loader2._executor is None
finally:
loader2.close()
def test_a_corrupt_package_is_reported_not_raised(qt_app, tmp_path):
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")
loader = SceneAssetLoader()
failed: list = []
loader.failed.connect(failed.append)
try:
loader.load(SceneSource(path=broken, kind="package", directory=tmp_path))
assert _pump(qt_app, lambda: bool(failed)), "a corrupt package produced no answer"
finally:
loader.close()
assert failed[0].code in ("asset_rejected", "asset_auth_failed")
assert failed[0].message
def test_loading_a_missing_package_raises_a_diagnostic(tmp_path):
from orisys_app_shared.scene3d.contracts import ThreeDError
with pytest.raises(ThreeDError) as excinfo:
load_package(SceneSource(path=tmp_path / "gone.oryscene", kind="package", directory=tmp_path))
assert excinfo.value.diagnostic.code == "asset_rejected"
def test_the_missing_asset_message_is_not_localised_but_the_pane_is(tmp_path):
"""Diagnostics are logged and quoted in support tickets: they stay English."""
diagnostic = no_asset_diagnostic("finger")
assert diagnostic.code == "asset_rejected"
assert PACKAGE_SUFFIX in diagnostic.message
assert "compile_scene.py" in diagnostic.details["compile"]
def test_a_finger_scene_uses_the_finger_adapter(tmp_path, write_poc, qt_app):
"""The adapter is chosen by the asset, and a box scene without a device refuses."""
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")
assert view.assets is not None
finally:
view.close()