feat(studio-3d): 历史回放里的三维面板 —— 由「重算」通道喂它,并标注来源

- What: `VIEW_SHOWS_3D` 加入 `replay`;`app_window` 新增 `_shows_3d()`(可见性与布局共用一个判据)
  与 `_splitter_sizes_for()`(预设表里的 3D 槽是"二维布局"的描述,面板打开时从最宽的面板借
  `DEFAULT_3D_PANE_WIDTH=340`);`ThreeDPanel` 新增第三个来源标签(实时采集 / 回放:录像不含三维场 /
  重算通道:由录像重算,非记录值)与 `set_source_kind()`;`ThreeDCoordinator.show_rest()` 把参考模型
  放回屏幕并停止主张测量值(重算停止时调用,否则最后一帧会留在屏幕上);`Mesh3dPanel` 转发这两者,
  并新增"加载中"状态的修正:切换传感器再切回来时若该包已加载,直接显示已就绪的画面而不是一条永远
  不会被替换的"正在加载"。
- Why: 录像里没有三维场(raw_*.mp4 是原始相机图像,`depth.stored=false`,CSV 只有力值),所以纯回放只能
  显示参考模型;但「从视频重算力值」本来就把同一个运行路径指向录像的原始视频,而采集线程上的三维 sink
  早已注册——因此重算一开始面板就会收到真实的形变帧。这两件事必须由界面区分:一个是"没有测量",
  一个是"当前算法对这段录像会说什么",把后者当成记录值展示就是另一种 `display_illustrative`。
- Verification: apps/studio 全量通过(test_scene3d_view.py 32 项,含"回放里面板可见且标注正确""重算时
  标注切换""停止重算后清回参考模型""实时视图标为实时";test_scene3d_coordinator.py 15 项,含 show_rest
  与"停止时在飞的帧不会随后被显示");ui_check 570 项 0 失败(新增"已安装包必须被加载并标注来源");
  locale_check 0 缺失;仓库内工程包经 validate_scene 全部 ok。
- Not in this change: 逐帧保存展开场/深度(记录格式变更,设计文档明确否掉);重算通道本身(已存在,本次
  只是把它的帧接到面板上);示意位移(仍需 SDK lift + 已确认尺度)。
This commit is contained in:
ykcheungab
2026-09-14 17:13:37 +08:00
parent 2226bdde68
commit 981c250559
13 changed files with 515 additions and 27 deletions
@@ -312,3 +312,50 @@ def test_close_is_idempotent_and_stops_everything(scene, pool):
presented = len(widget.frames)
coordinator.tick()
assert len(widget.frames) == presented
# ------------------------------------------------- the reference model, on demand
def test_show_rest_puts_the_model_back_and_stops_claiming_a_measurement(scene, pool):
"""The replay view's case: a pass stops, so the last frame must go.
Without this the recomputed picture would stay on screen while nothing is
producing it — a measurement that is no longer being made.
"""
widget = FakeWidget()
coordinator = ThreeDCoordinator(widget, SceneComposer(), pool)
coordinator.set_visible(True)
coordinator.activate(scene, "replay-A")
coordinator.submit(_frame(scene, source="replay-A"))
_drain(coordinator, ticks=10)
assert coordinator.state in ("live", "no_data")
assert widget.frames[-1].valid.any(), "the measurement never arrived"
coordinator.show_rest()
last = widget.frames[-1]
assert not last.valid.any(), "the old measurement is still on screen"
assert last.scalar_descriptor.quantity == "unavailable"
assert coordinator.state == "no_data"
# And it is a no-op when there is nothing to show or the view is off.
coordinator.set_visible(False)
before = len(widget.frames)
coordinator.show_rest()
assert len(widget.frames) == before
coordinator.close()
coordinator.show_rest() # closed: still safe
assert coordinator.state == "disabled"
def test_a_frame_already_queued_is_dropped_when_the_model_comes_back(scene, pool):
"""The rest frame is not "the newest sample": a queued one must not follow it."""
widget = FakeWidget()
coordinator = ThreeDCoordinator(widget, CountingComposer(delay_s=0.02), pool)
coordinator.set_visible(True)
coordinator.activate(scene, "replay-A")
coordinator.submit(_frame(scene, source="replay-A", value=7.0))
coordinator.show_rest()
_drain(coordinator, ticks=4, sleep=0.01)
assert not widget.frames[-1].valid.any(), (
"a frame that was in flight when the pass stopped was presented anyway"
)
coordinator.close()
+95 -6
View File
@@ -37,6 +37,7 @@ from orisys_app_shared.scene3d.contracts import (
UNAVAILABLE_DESCRIPTOR,
)
from orisys_app_shared.scene3d.package import write_package
from orisys_studio.app_window import MIN_PANEL_WIDTHS
from orisys_studio.scene3d import prefs as scene3d_prefs
from orisys_studio.scene3d.asset_source import (
PACKAGE_SUFFIX,
@@ -45,7 +46,12 @@ from orisys_studio.scene3d.asset_source import (
load_package,
no_asset_diagnostic,
)
from tests.test_ui_smoke import _finish, _running_window
from tests.test_ui_smoke import ( # noqa: F401 (fixtures are imported by name)
_finish,
_running_window,
record_root,
replay_recording,
)
#: 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
@@ -112,9 +118,18 @@ def test_a_box_sensor_reports_that_it_has_no_3d_scene(qt_app):
panel.deleteLater()
def test_a_missing_package_names_where_it_looked(tmp_path, qt_app):
def test_a_missing_package_names_where_it_looked(tmp_path, qt_app, monkeypatch):
"""With no package anywhere, the pane names the directory it looked in.
The search is stubbed rather than relied on: the repository may or may not carry
a compiled package (the application's own config directory is where one belongs),
and a test that only passes on a machine without one is a test that fails the
first time somebody installs the feature.
"""
import orisys_studio.ui.mesh3d_panel as panel_module
from orisys_studio.ui.mesh3d_panel import Mesh3dPanel
monkeypatch.setattr(panel_module, "find_package", lambda *a, **k: None)
panel = Mesh3dPanel()
try:
panel.set_sensor("finger", package_override=tmp_path / "nope.oryscene")
@@ -164,17 +179,21 @@ def test_the_pane_fits_the_width_the_window_reserves_for_it(qt_app):
view.close()
def test_a_platform_without_a_context_says_so_instead_of_showing_an_empty_view(qt_app):
def test_a_platform_without_a_context_says_so_instead_of_showing_an_empty_view(
write_poc, 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.
question that has an answer — is there a context at all — on its first paint. The
scene comes from a package written for this test, so the case does not depend on
whether the repository happens to carry a compiled one.
"""
from orisys_studio.ui.mesh3d_panel import Mesh3dPanel
panel = Mesh3dPanel()
try:
panel.set_sensor("finger")
panel.set_sensor("finger", package_override=write_poc())
view = panel.ensure_view()
if view is None:
pytest.skip("the pane did not build a view here")
@@ -184,7 +203,10 @@ def test_a_platform_without_a_context_says_so_instead_of_showing_an_empty_view(q
panel.resize(240, 400)
panel.show()
view.show()
qt_app.processEvents()
panel.set_enabled(True)
assert _pump(qt_app, lambda: view.assets is not None, timeout_s=15.0), (
"the test package never loaded"
)
widget = view.gl_widget
for _ in range(5):
widget.repaint()
@@ -584,6 +606,73 @@ def test_a_recording_says_what_the_3d_pane_was(
window.close()
def test_the_replay_view_shows_the_pane_and_says_where_the_field_comes_from(
make_window, replay_recording, qt_app
):
"""History browsing gets the pane, fed by the recompute pass — and says so.
A recording stores the raw camera image and the forces, not a per-frame
deformation field, so the pane in replay is either the surface model (no field) or
what a recompute pass produces from the video. Those are two different claims and
the label has to distinguish them.
"""
from tests.test_ui_smoke import _show
window = _show(make_window(sensor_type="finger"), (1440, 880))
try:
assert window._load_recording(Path(replay_recording))
assert window._view_preset == "replay"
pane = window.mesh3d_panel
assert pane.isVisible(), "the 3D pane is hidden in the replay view"
assert pane.enabled(), "the pane is visible but not switched on"
assert window.content_splitter.sizes()[0] >= MIN_PANEL_WIDTHS["mesh3d"], (
"the replay layout left the pane at zero width"
)
view = pane.view
if view is None or view.panel is None:
pytest.skip("no 3D view on this platform")
# Wait for the scene to load, then read what the pane claims.
assert _pump(qt_app, lambda: view.assets is not None, timeout_s=15.0), (
"the scene never loaded: " + str(pane.last_diagnostic)
)
label = view.panel.source_label.text()
assert "回放" in label or "Replay" in label, label
# A running pass means the pixels are recomputed, and the label must say so.
window._recompute_running = True
window._apply_panel_visibility()
label = view.panel.source_label.text()
assert "重算" in label or "Recomputed" in label, label
# Stopping the pass must clear the picture, not leave the last frame up.
window._recompute_running = False
pane.show_reference_only()
qt_app.processEvents()
assert view.coordinator.state in ("no_data", "ready", "disabled")
assert "没有测量值" in view.panel.legend_label.text() or (
"No measurement" in view.panel.legend_label.text()
), view.panel.legend_label.text()
window._apply_panel_visibility()
label = view.panel.source_label.text()
assert "回放" in label or "Replay" in label, label
finally:
window.close()
def test_the_live_view_still_says_live(make_window, qt_app):
window = make_window(sensor_type="finger")
try:
window._apply_panel_visibility()
view = window.mesh3d_panel.view
if view is None or view.panel is None:
pytest.skip("no 3D view on this platform")
label = view.panel.source_label.text()
assert "实时" in label or "Live" in label, label
assert view.panel.source_label.toolTip()
finally:
window.close()
def test_a_platform_locked_scene_is_refused_in_the_application(qt_app):
"""A lock violation is a *pane* failure: the view refuses the scene and says why."""
from orisys_app_shared.scene3d.contracts import PlatformInfo, PlatformLock