Files
orisys-app/apps/studio/tests/test_scene3d_view.py
T
ykcheungab ac4dcd643f 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` 包(发布产物,且其单位与变换必须由人声明)。
2026-09-14 15:47:32 +08:00

536 lines
21 KiB
Python

"""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()