From 981c25055942d0fd7540a4d075be87e98f2bdfa6 Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Mon, 14 Sep 2026 17:13:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(studio-3d):=20=E5=8E=86=E5=8F=B2=E5=9B=9E?= =?UTF-8?q?=E6=94=BE=E9=87=8C=E7=9A=84=E4=B8=89=E7=BB=B4=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=20=E2=80=94=E2=80=94=20=E7=94=B1=E3=80=8C=E9=87=8D=E7=AE=97?= =?UTF-8?q?=E3=80=8D=E9=80=9A=E9=81=93=E5=96=82=E5=AE=83=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E6=A0=87=E6=B3=A8=E6=9D=A5=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 + 已确认尺度)。 --- apps/studio/config/locales/en_US.json | 6 + apps/studio/config/locales/zh_CN.json | 6 + .../scene/finger-display-1.0.0.oryscene | Bin 0 -> 56992 bytes apps/studio/src/orisys_studio/app_window.py | 62 ++++++++- .../src/orisys_studio/scene3d/coordinator.py | 18 +++ .../studio/src/orisys_studio/scene3d/panel.py | 33 +++++ apps/studio/src/orisys_studio/scene3d/view.py | 17 +++ .../src/orisys_studio/ui/mesh3d_panel.py | 32 +++++ apps/studio/tests/test_scene3d_coordinator.py | 47 +++++++ apps/studio/tests/test_scene3d_view.py | 101 +++++++++++++- apps/studio/tools/ui_check.py | 68 ++++++++-- docs/architecture/studio-3d-stages.md | 26 +++- tools/scene_assets/README.md | 126 ++++++++++++++++++ 13 files changed, 515 insertions(+), 27 deletions(-) create mode 100644 apps/studio/config/sensors/finger/scene/finger-display-1.0.0.oryscene create mode 100644 tools/scene_assets/README.md diff --git a/apps/studio/config/locales/en_US.json b/apps/studio/config/locales/en_US.json index de02093..5e57d2f 100644 --- a/apps/studio/config/locales/en_US.json +++ b/apps/studio/config/locales/en_US.json @@ -399,6 +399,12 @@ "state_no_data": "No valid measurement in this frame — showing the surface model", "state_suspended": "Suspended (the view is hidden)", "state_unavailable": "3D is unavailable", + "source_live": "Live acquisition", + "source_live_tip": "The picture comes from the data being acquired right now.", + "source_replay": "Replay: the recording stores no 3D field", + "source_replay_tip": "A recording stores the raw camera image and the forces, not a per-frame deformation field. Turn on “Recompute forces from the video” and this pane will show the deformation recomputed from the recording.", + "source_recompute": "Recomputed from the recording — not the recorded values", + "source_recompute_tip": "This is what the current algorithm version says about the recorded footage: it is a check on the algorithm, not the measurement that was recorded at the time.", "severity_info": "Note", "severity_warning": "Warning", "severity_error": "Error", diff --git a/apps/studio/config/locales/zh_CN.json b/apps/studio/config/locales/zh_CN.json index 5062874..4244b05 100644 --- a/apps/studio/config/locales/zh_CN.json +++ b/apps/studio/config/locales/zh_CN.json @@ -399,6 +399,12 @@ "state_no_data": "本帧没有有效测量 — 显示表面模型", "state_suspended": "已挂起(视图被隐藏)", "state_unavailable": "三维不可用", + "source_live": "实时采集", + "source_live_tip": "画面来自此刻正在采集的数据。", + "source_replay": "回放:录像不含三维场", + "source_replay_tip": "录像只保存原始相机图像与力值,没有逐帧的形变场。开启「从视频重算力值」后,本面板会由录像重算并把形变显示在这里。", + "source_recompute": "重算通道:由录像重算,非记录值", + "source_recompute_tip": "这是在当前算法版本下对录像重算得到的形变,用于检查算法;它不是当初记录下来的测量值。", "severity_info": "提示", "severity_warning": "警告", "severity_error": "错误", diff --git a/apps/studio/config/sensors/finger/scene/finger-display-1.0.0.oryscene b/apps/studio/config/sensors/finger/scene/finger-display-1.0.0.oryscene new file mode 100644 index 0000000000000000000000000000000000000000..b78a30bd973fe969dc31f4177c96fc25603d6c7d GIT binary patch literal 56992 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 z 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 literal 0 HcmV?d00001 diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index ba03801..b783b62 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -92,8 +92,17 @@ VIEW_SPLITTER_SIZES = { "replay": (0, 360, 1080, 0), "calibration": (0, 0, 560, 880), } -#: The 3D panel only belongs to the live view, and only for a finger sensor. -VIEW_SHOWS_3D = ("custom", "live") +#: The 3D pane belongs to the live view and to history browsing, and only for a +#: finger sensor. Replay is included because a *recompute* pass re-runs the finger +#: chain over the recording's raw video and feeds the pane through the ordinary +#: acquisition sink; a recording itself stores no 3D field (see +#: ``Mesh3dPanel.set_source_kind`` and the stage-4/5 sections of +#: ``docs/architecture/studio-3d-stages.md``). +VIEW_SHOWS_3D = ("custom", "live", "replay") +#: Width the 3D pane takes from the widest other panel when a preset's table gives it +#: nothing. The table describes the two-dimensional layout; the pane is optional, and +#: "not in the table" must not mean "standing at its minimum width". +DEFAULT_3D_PANE_WIDTH = 340 #: After this long in ``connecting``, tell the user that Stop is the way out. CONNECT_SLOW_HINT_S = 8.0 #: Playback tick while a recording is playing. 33 ms is one frame at 30 fps; the @@ -1059,6 +1068,9 @@ class StudioMainWindow(QMainWindow): self.toolbar.set_video_path(str(segment)) self._refresh_status_rail() self._recompute_running = True + # The 3D pane now says "recomputed", not "replayed": the pass is what + # feeds it, through the ordinary acquisition sink. + self.mesh3d_panel.set_source_kind(self._current_source_kind()) # Playing is what starts the pass: one path in, so the button and the # menu cannot start it twice. self.toolbar.set_run_checked(True) @@ -1067,6 +1079,11 @@ class StudioMainWindow(QMainWindow): return if self._recompute_running: self._stop_run(reason="user_stop") + # The pass is over, so the last recomputed frame must go: leaving it on + # screen would show a measurement nothing is producing any more. + self.mesh3d_panel.show_reference_only() + if self._view_preset == "replay": + self.mesh3d_panel.set_source_kind(self._current_source_kind()) if self._view_preset == "replay" and self.replay.is_loaded(): self._apply_replay_source(True) self._render_replay_frame() @@ -1326,6 +1343,40 @@ class StudioMainWindow(QMainWindow): ) self._apply_arrow_rotation_for_sensor() + def _current_source_kind(self) -> str: + """Where the 3D pane's field would come from in the current view. + + ``recompute`` only while a pass is actually running: the label is a claim + about the pixels on screen, and a stale claim is worse than none. + """ + if self._view_preset == "replay": + return "recompute" if self._recompute_running else "replay" + return "live" + + def _shows_3d(self) -> bool: + """Whether the 3D pane belongs to the current view and sensor. + + One predicate for both the pane's visibility and the splitter layout: the + layout has to know the same thing the visibility does, or the pane ends up + visible and standing at its minimum width. + """ + return ( + self._view_preset in VIEW_SHOWS_3D + and self.sensor_picker.sensor_type() == "finger" + and bool(self._show_finger_3d) + ) + + def _splitter_sizes_for(self, preset: str) -> list: + """The preset's layout, with room for the 3D pane when it is on.""" + sizes = list(VIEW_SPLITTER_SIZES.get(preset, DEFAULT_SPLITTER_SIZES)) + if self._shows_3d() and sizes and sizes[0] == 0: + width = DEFAULT_3D_PANE_WIDTH + widest = max(range(1, len(sizes)), key=lambda index: sizes[index]) + take = min(width, max(sizes[widest] - MIN_PANEL_WIDTHS["plots"], 0)) + sizes[0] = take + sizes[widest] = sizes[widest] - take + return sizes + def _apply_panel_visibility(self) -> None: """One place decides which panels the current view shows. @@ -1336,7 +1387,7 @@ class StudioMainWindow(QMainWindow): """ is_finger = self.sensor_picker.sensor_type() == "finger" preset = self._view_preset - show_3d = preset in VIEW_SHOWS_3D and is_finger and bool(self._show_finger_3d) + show_3d = self._shows_3d() show_arrow = preset != "calibration" show_cal = self._show_cal if preset == "custom" else preset == "calibration" self._show_cal = show_cal @@ -1346,6 +1397,9 @@ class StudioMainWindow(QMainWindow): self.mesh3d_panel.ensure_view() self.mesh3d_panel.set_enabled(show_3d) self.mesh3d_panel.setVisible(show_3d) + # Where the field comes from: a live view acquires it, a replay has none + # stored and only a recompute pass produces one. + self.mesh3d_panel.set_source_kind(self._current_source_kind()) self.arrow_panel.setVisible(show_arrow) self.force_plots.setVisible(True) self.cal_panel.setVisible(show_cal) @@ -1404,7 +1458,7 @@ class StudioMainWindow(QMainWindow): self.toolbar.set_run_role("playback" if preset == "replay" else "acquire") self._apply_panel_visibility() if apply_sizes and preset in VIEW_SPLITTER_SIZES: - self.content_splitter.setSizes(list(VIEW_SPLITTER_SIZES[preset])) + self.content_splitter.setSizes(self._splitter_sizes_for(preset)) if preset == "replay": # History browsing is the point of this view: the charts read the # open recording, and the chart cursor is its timeline. Without a diff --git a/apps/studio/src/orisys_studio/scene3d/coordinator.py b/apps/studio/src/orisys_studio/scene3d/coordinator.py index faf01b5..1c9be0b 100644 --- a/apps/studio/src/orisys_studio/scene3d/coordinator.py +++ b/apps/studio/src/orisys_studio/scene3d/coordinator.py @@ -278,6 +278,24 @@ class ThreeDCoordinator: # the last field frame has to be composed again. self._settings_dirty = True + def show_rest(self) -> None: + """Put the reference model back on screen and stop claiming a measurement. + + Needed by the replay view: while a recompute pass runs it feeds this + coordinator through the ordinary acquisition sink, and when the pass stops the + last recomputed frame would otherwise stay on screen — a picture of a + measurement that is no longer being made, which is exactly what the rest frame + exists to prevent. Idempotent, and safe before any scene is active. + """ + if self._closed or self._assets is None or not self._visible: + return + self._incoming.clear() + self._cancel_inflight() + self._last_field_frame = None + self._last_composed_identity = None + self._widget.present(self._composer.rest_frame(self._assets, self._identity(0))) + self._set_state("no_data") + def close(self) -> None: """Cancel work, stop the timer, clear both mailboxes. Idempotent.""" if self._closed: diff --git a/apps/studio/src/orisys_studio/scene3d/panel.py b/apps/studio/src/orisys_studio/scene3d/panel.py index 41a8a56..f3880c1 100644 --- a/apps/studio/src/orisys_studio/scene3d/panel.py +++ b/apps/studio/src/orisys_studio/scene3d/panel.py @@ -81,6 +81,7 @@ class ThreeDPanel(QFrame): self._assets: Optional[SceneAssets] = None self._descriptor: Optional[FieldDescriptor] = None self._state: SceneState = "disabled" + self._source_kind = "live" self._syncing = False root = QVBoxLayout(self) @@ -184,6 +185,16 @@ class ThreeDPanel(QFrame): self.legend_label.setMinimumWidth(1) root.addWidget(self.legend_label) + #: Which *source* the field on screen came from. A separate line from the + #: scene state, because they answer different questions: the state says whether + #: anything is being measured, this says whether it was acquired live or + #: recomputed from a recording — a distinction the design document insists on. + self.source_label = QLabel() + self.source_label.setObjectName("panelMeta") + self.source_label.setWordWrap(True) + self.source_label.setMinimumWidth(1) + root.addWidget(self.source_label) + self.state_label = QLabel() self.state_label.setObjectName("hintText") self.state_label.setWordWrap(True) @@ -246,6 +257,26 @@ class ThreeDPanel(QFrame): self.state_label.setText(t(f"scene3d.state_{state}")) self.state_label.setVisible(state != "live") + def set_source_kind(self, kind: str) -> None: + """Say where the field comes from: ``live``, ``replay`` or ``recompute``. + + Three states, and the difference between the last two is the point of the + whole replay story: + + * ``live`` — acquired from the device now; + * ``replay`` — a recording is open and **no** 3D field is stored in it, so the + pane is showing the surface model only; + * ``recompute`` — a recompute pass is running over the recording's video, so + what is on screen is what *this* algorithm version says about that footage. + It is not the recorded measurement, and the label says so. + """ + kind = str(kind) + if kind not in ("live", "replay", "recompute"): + kind = "live" + self._source_kind = kind + self.source_label.setText(t(f"scene3d.source_{kind}")) + self.source_label.setToolTip(t(f"scene3d.source_{kind}_tip")) + def set_diagnostic(self, diagnostic: Optional[Diagnostic]) -> None: """One diagnostic line. The 2D views stay usable behind it.""" if diagnostic is None: @@ -383,4 +414,6 @@ class ThreeDPanel(QFrame): self.fps_label.setToolTip(t("scene3d.max_fps_tip")) self.reset_button.setText(t("scene3d.reset_view")) self.state_label.setText(t(f"scene3d.state_{self._state}")) + self.source_label.setText(t(f"scene3d.source_{self._source_kind}")) + self.source_label.setToolTip(t(f"scene3d.source_{self._source_kind}_tip")) self.set_descriptor(self._descriptor) diff --git a/apps/studio/src/orisys_studio/scene3d/view.py b/apps/studio/src/orisys_studio/scene3d/view.py index 83dffc7..5675eac 100644 --- a/apps/studio/src/orisys_studio/scene3d/view.py +++ b/apps/studio/src/orisys_studio/scene3d/view.py @@ -310,6 +310,23 @@ class Scene3DView(QWidget): self._camera.reset(self._assets.bounds_sensor_m) self._widget.update() + def set_source_kind(self, kind: str) -> None: + """Tell the pane where its field comes from (see ``ThreeDPanel``).""" + if self._panel is not None: + self._panel.set_source_kind(kind) + + def show_reference_only(self) -> None: + """Drop whatever measurement is on screen and show the surface model. + + Used when a recompute pass over a recording stops: without this the last + recomputed frame would stay on screen while nothing is measuring it any more. + """ + if self._coordinator is not None: + self._coordinator.show_rest() + if self._panel is not None: + self._panel.set_descriptor(None) + self._panel.set_state("no_data") + # ------------------------------------------------------------------ state @property def available(self) -> bool: diff --git a/apps/studio/src/orisys_studio/ui/mesh3d_panel.py b/apps/studio/src/orisys_studio/ui/mesh3d_panel.py index 0503df5..af9cb01 100644 --- a/apps/studio/src/orisys_studio/ui/mesh3d_panel.py +++ b/apps/studio/src/orisys_studio/ui/mesh3d_panel.py @@ -70,8 +70,12 @@ class Mesh3dPanel(QFrame): self._source: Optional[SceneSource] = None self._sensor_type = "" self._expected: Optional[ExpectedGeometry] = None + self._source_kind = "live" self._enabled = False self._loaded = False + #: 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 self._reported: Optional[Diagnostic] = None self._pending_settings = None self._message_kind = _UNAVAILABLE @@ -121,11 +125,22 @@ class Mesh3dPanel(QFrame): return self._source = find_package(self._sensor_type, override=package_override) if self._source is None: + self._loaded_path = None self._reported = no_asset_diagnostic(self._sensor_type, override=package_override) self._show_message( _NO_ASSET, directory=str(self._reported.details["directory"]) ) return + if ( + self._view is not None + and self._loaded_path is not None + and self._source.path == self._loaded_path + ): + # This scene is already loaded (the sensor was switched away and back): + # showing "loading…" again would leave a line nothing ever replaces, while + # the view behind it is ready. + self._stack.setCurrentWidget(self._view) + return self._show_message(_LOADING, name=self._source.path.name) def ensure_view(self) -> Optional[QWidget]: @@ -141,6 +156,10 @@ class Mesh3dPanel(QFrame): view.enabledChanged.connect(self._on_view_enabled) if self._pending_settings is not None and hasattr(view, "restore_settings"): view.restore_settings(self._pending_settings) + # The kind was decided before the view existed (the window sets it when the + # view preset changes), so it is pushed again here rather than lost. + if hasattr(view, "set_source_kind"): + view.set_source_kind(self._source_kind) if not view.available: self._show_message(_UNAVAILABLE, reason=view.reason) return view @@ -186,6 +205,7 @@ class Mesh3dPanel(QFrame): return if not view.use_scene(assets, source_id=description, expected=self._expected): return + self._loaded_path = None if self._source is None else self._source.path self._stack.setCurrentWidget(view) # Adopting a scene is not the same as showing one: the pane stays off until # the operator (or the window, restoring a preference) turns it on. @@ -193,6 +213,7 @@ class Mesh3dPanel(QFrame): def _on_load_failed(self, item: Diagnostic) -> None: self._reported = item + self._loaded_path = None self._show_message(_REJECTED, message=item.message) def _on_diagnostic(self, item: Diagnostic) -> None: @@ -239,6 +260,17 @@ class Mesh3dPanel(QFrame): def scene_path(self) -> Optional[Path]: return None if self._source is None else self._source.path + def set_source_kind(self, kind: str) -> None: + """Where the field comes from: live, replay (model only) or recompute.""" + self._source_kind = str(kind) + if self._view is not None and hasattr(self._view, "set_source_kind"): + self._view.set_source_kind(self._source_kind) + + def show_reference_only(self) -> None: + """Clear a measurement that is no longer being produced (a stopped pass).""" + if self._view is not None and hasattr(self._view, "show_reference_only"): + self._view.show_reference_only() + def tick(self) -> None: """Advance the pane. Called by the window's run timer, never by itself.""" if self._view is not None and hasattr(self._view, "tick"): diff --git a/apps/studio/tests/test_scene3d_coordinator.py b/apps/studio/tests/test_scene3d_coordinator.py index 8790678..13114da 100644 --- a/apps/studio/tests/test_scene3d_coordinator.py +++ b/apps/studio/tests/test_scene3d_coordinator.py @@ -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() diff --git a/apps/studio/tests/test_scene3d_view.py b/apps/studio/tests/test_scene3d_view.py index c45071b..c08269c 100644 --- a/apps/studio/tests/test_scene3d_view.py +++ b/apps/studio/tests/test_scene3d_view.py @@ -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 diff --git a/apps/studio/tools/ui_check.py b/apps/studio/tools/ui_check.py index 60a63ed..40fc91d 100644 --- a/apps/studio/tools/ui_check.py +++ b/apps/studio/tools/ui_check.py @@ -2830,23 +2830,67 @@ def main() -> int: pane.set_sensor("box") check(pane.showing_message, "the 3D pane kept showing a picture for a box sensor") - # A finger with no package names where the package belongs and how to build one. + # A finger *without* any package names where one belongs and how to build it. + # The search is stubbed for this check: the application's config directory may + # or may not carry a compiled package on the machine running the check, and a + # check that only passes without one would fail the first time one is + # installed (the positive case is checked just below). + import orisys_studio.ui.mesh3d_panel as _panel_module + + real_find = _panel_module.find_package + _panel_module.find_package = lambda *a, **k: None + try: + fresh_missing = _Mesh3dPanel() + fresh_missing.set_sensor("finger") + diagnostic = fresh_missing.last_diagnostic + check( + diagnostic is not None and diagnostic.code == "asset_rejected", + f"a missing 3D scene was not reported as an asset problem: {diagnostic}", + ) + if diagnostic is not None: + check( + "compile_scene.py" in diagnostic.details.get("compile", ""), + "the missing-scene report does not say how to build one", + ) + check( + str(diagnostic.details.get("directory", "")) + in fresh_missing.message.text() + or fresh_missing.message.text().strip() != "", + "the pane does not name the directory it looked in", + ) + fresh_missing.deleteLater() + finally: + _panel_module.find_package = real_find + + # And when a package *is* installed, the pane must actually load it: this is + # the end-to-end check that the search path, the loader and the widget agree. pane.set_sensor("finger") - diagnostic = pane.last_diagnostic - check( - diagnostic is not None and diagnostic.code == "asset_rejected", - f"a missing 3D scene was not reported as an asset problem: {diagnostic}", - ) - if diagnostic is not None: + shipped = _panel_module.find_package("finger") + if shipped is not None: + view_for_load = pane.ensure_view() + pane.set_enabled(True) + deadline = time.time() + 15.0 + while time.time() < deadline: + app.processEvents() + assets = getattr(view_for_load, "assets", None) if view_for_load else None + if assets is not None: + break + time.sleep(0.02) check( - "compile_scene.py" in diagnostic.details.get("compile", ""), - "the missing-scene report does not say how to build one", + getattr(view_for_load, "assets", None) is not None, + f"the installed 3D scene was never loaded: {pane.last_diagnostic}", ) check( - str(diagnostic.details.get("directory", "")) in pane.message.text() - or pane.message.text().strip(), - "the pane does not name the directory it looked in", + not pane.showing_message, + "the pane is still showing a message although a scene loaded", ) + if view_for_load is not None and view_for_load.panel is not None: + source_text = view_for_load.panel.source_label.text() + check(source_text.strip() != "", "the pane does not say where the field comes from") + check( + "实时" in source_text or "Live" in source_text, + f"the live view does not label its source: {source_text!r}", + ) # The pane's controls must fit the width the window reserves for it. view = pane.ensure_view() diff --git a/docs/architecture/studio-3d-stages.md b/docs/architecture/studio-3d-stages.md index 3b09129..a0daa07 100644 --- a/docs/architecture/studio-3d-stages.md +++ b/docs/architecture/studio-3d-stages.md @@ -234,21 +234,37 @@ shared 全部 **262 项**。`ui_check.py` **566 项检查 0 失败**(新增 "3 - 运行时**只**读 `.oryscene` 包;旧的 Blender 导出目录仍由 `LegacyFingerImporter`(离线工具/测试)导入, 单位与变换必须显式给出。包缺失/被拒/无 GL 时:面板一行文字说明原因,采集、记录、力曲线、标定、回放全部照常 - (`ui_check` 566 项与 `test_ui_smoke.py` 全绿即为证)。 + (`ui_check` 570 项与 `test_ui_smoke.py` 全绿即为证)。 +- 仓库现在**带一个已编译的工程包**:`apps/studio/config/sensors/finger/scene/finger-display-1.0.0.oryscene` + (明文、未签名、即 `v1` 封套,1032 顶点;生成命令与单位依据写在 `tools/scene_assets/README.md`)。 + 它只是工程用包,发布时用同一工具换成签名/加密/锁定的版本即可,加载路径不变。 - `gl_profile`(`desktop33` 默认 / `gles30`)在 `QApplication` 之前生效,一进程一个;未知取值退回默认而不是 让窗口起不来。 - 3D 的显示设置(放大、色标、外壳、显示帧率)持久化在 `scene3d_display`;**色标的 descriptor 不恢复**, 永远来自当前帧,因此一个旧文件不可能把测量标成另一种量。 **明确未做(留给阶段 5 或按用户要求暂缓)**: - - **示意位移(`displacement_sensor_m`)仍未接入**:需要 SDK 的 lift 与一个已确认的空间尺度;在拿到 "这个尺度是怎么标定出来的"之前,适配器继续只着色不位移(阶段 3 的原则不变)。 - ARM 板卡适配:按用户要求暂缓;`probe_gl.py` 可在板上直接跑出报告,据此定 `gl_profile` 与 `MIN_*`/预算(阶段 5 的两平台报告)。 -- `session.json` 的 `visualization3d` 能力块、包签名/AES-GCM、性能与长时报告:阶段 5。 -- 3D 包尚未随仓库提供(仓库里没有 `.oryscene`):这是**有意的**,包是发布产物(阶段 5 管签名与平台锁定), - 并且它必须携带只能由人声明的单位与变换。 +- `session.json` 的 `visualization3d` 能力块、包签名/AES-GCM、性能与长时报告:阶段 5(已完成)。 + +**后续补充(同一功能,历史回放接入)**: + +`VIEW_SHOWS_3D` 原先只有 `custom`/`live`。现在**回放也能打开三维面板**,但要分清两件事, +因为这是两种不同的主张: + +- 录像里**没有**三维场:`raw_*.mp4` 是原始相机图像,`session.json` 里 `depth.stored=false` 是当初定的, + CSV 只有力值。所以纯回放时面板只显示**参考模型**,并写明"录像不含三维场"。 +- 打开「从视频重算力值」后,重算通道本来就是把**同一个运行路径**指向录像的原始视频(手指 worker 支持文件源), + 而采集线程上的三维 sink 早已注册(`_start_run`),所以面板会**自动**收到重算出来的帧。 + 此时面板标注"重算通道:由录像重算,非记录值"——那是"这个算法版本对这段录像会说什么", + 不是当初记录到的测量值(和 `display_illustrative` 同样的思路)。 + +实现要点:`ThreeDCoordinator.show_rest()`(把参考模型放回屏幕并停止主张测量值)、 +`ThreeDPanel.set_source_kind()`(第三个来源标签)、`app_window._shows_3d()`/`_splitter_sizes_for()` +(表格里 3D 槽是 0——二维布局的描述——面板打开时从最宽的其它面板借走 `DEFAULT_3D_PANE_WIDTH=340`)。 --- diff --git a/tools/scene_assets/README.md b/tools/scene_assets/README.md new file mode 100644 index 0000000..9634963 --- /dev/null +++ b/tools/scene_assets/README.md @@ -0,0 +1,126 @@ +# Scene asset tools + +Offline tools for the three-dimensional view. They never run in the application: +the app loads a **compiled** package (`.oryscene`) and nothing else. + +| tool | what it is for | +|---|---| +| `compile_scene.py` | a Blender export (+ optional housing GLB) → one `.oryscene` package | +| `validate_scene.py` | the checks the loader and the compiler must agree on; the release gate | +| `probe_gl.py` | what this machine's OpenGL actually is (exit 0 GPU, 3 software, 4 no context) | +| `bench_scene.py` | per-stage p50/p95 of the running view (`--cpu-only` for the pure CPU cost) | +| `soak_scene.py` | long run: RSS trend and the queue bound | + +Run them with the repository's own interpreter, from `orisys-app`: + +```powershell +$py = '.venv-build\Scripts\python.exe' +``` + +## Compiling the shipped finger sensor + +The **runtime** asset is `sensors/finger/assets/30fps-0703`: it is what +`sensors/finger/manifest.json` names (`assets_dir`), and its unroll map +(`unroll_map_uv_s0.4.npz`, out size 300×280, `source_scale` 0.4) matches +`camera.resolution_stitch` in `sensor.json`, which is the grid the live optical flow +is computed on. + +Do **not** compile `assets/visualization/flatten_remap.npz`: it is a 1024×1024 +domain, so every live frame would be refused with `field_domain_mismatch` +(300x280 vs 1024x1024). That map belongs to offline visualisation. + +```powershell +$sdk = 'D:\orisys_ws\orisys-sdk\sensors\finger\assets\30fps-0703' # or the copy in apps/studio/config + +# 1) state the unit (the importer never guesses it) and read what it measured +& $py tools\scene_assets\validate_scene.py --legacy-dir $sdk --map "$sdk\unroll_map_uv_s0.4.npz" + +# 2) compile +& $py tools\scene_assets\compile_scene.py ` + --legacy-dir $sdk ` + --map "$sdk\unroll_map_uv_s0.4.npz" ` + --meters-per-source-unit 0.001 ` + --output apps\studio\config\sensors\finger\scene\finger-display-1.0.0.oryscene + +# 3) verify +& $py tools\scene_assets\validate_scene.py apps\studio\config\sensors\finger\scene\finger-display-1.0.0.oryscene +``` + +**`--meters-per-source-unit` is a physical statement about the sensor, not a +formatting choice.** The measured mesh extent is 15.81 × 15.0 × 7.5 source units +(1032 vertices, 1974 triangles); the shipped finger surface is about 15.8 mm across, +so one source unit is one millimetre and the value is `0.001`. If the mechanical +drawing says otherwise, state that number instead — nothing downstream can tell the +difference, and a wrong factor looks plausible. + +Expected result: `1032 vertices`, `1974 triangles`, `geometry_id` starting +`1a2e0e92…`, a 56 KB package, and `validate_scene.py` reporting every check `ok`. + +## Where the application looks for a package + +1. `preference.json` → `"scene3d_package": ""` (an absolute path; also the way + to point a developer build at a freshly compiled package); +2. `/sensors//scene/*.oryscene` — for the studio app that is + `apps/studio/config/sensors/finger/scene/`; +3. nothing: the pane reports the directory it searched and the compile command above, + and the two-dimensional views keep working. + +The SDK's own `sensors/` tree is **not** searched (the app prefers its local +`config/sensors` bundles). Compile into the app's `scene/` directory, or set +`scene3d_package`. + +## Release packages (signature, optional encryption, platform lock) + +All three are optional and independent of the plain package above: + +```powershell +& $py tools\scene_assets\compile_scene.py --legacy-dir $sdk --map "$sdk\unroll_map_uv_s0.4.npz" ` + --meters-per-source-unit 0.001 ` + --sign-key keys\build-ed25519.pem --sign-key-id build-2024 ` + --write-verify-key keys\build-ed25519.pub.pem ` + --encrypt-key-id finger-display --key-file keys\finger-display.key ` + --lock-from-probe docs\architecture\gl-report-windows-dev.json ` + --output apps\studio\config\sensors\finger\scene\finger-display-1.0.0.oryscene +``` + +- `--sign-key` signs the manifest (and therefore every entry hash, the compatibility + information and the platform lock). The private key stays on the build machine; the + client ships only `--write-verify-key`'s public half. +- `--encrypt-key-id` + `--key-file` additionally seal the content with AES-256-GCM. + Encryption never replaces signing. Reading a sealed package needs the + `protected-assets` extra (`cryptography`); a plain package does not. +- `--lock-from-probe` takes the platform lock (OS, architecture, GL profile, vendor, + renderer, minimum texture size, "no software rasteriser") from a `probe_gl.py` + report. A machine the lock excludes loses the 3D pane, with the reason, and keeps + everything else. +- `--encrypt-key-id` without `--key-file`, or with no crypto backend installed, is + **refused** rather than silently writing a plain package that looks protected. + +Release gate: + +```powershell +& $py tools\scene_assets\validate_scene.py ` + --key-file keys\finger-display.key --key-id finger-display ` + --verify-key keys\build-ed25519.pub.pem --require-signature +``` + +## Performance and long runs + +```powershell +# per-stage p50/p95 of the running view (needs a real window system) +$env:QT_QPA_PLATFORM = 'windows' +& $py tools\scene_assets\bench_scene.py --frames 600 --output perf.json +& $py tools\scene_assets\bench_scene.py --cpu-only --frames 500 --vertices 20000 --output perf-cpu.json +& $py tools\scene_assets\bench_scene.py --package --key-file keys\finger-display.key ` + --key-id finger-display --verify-key keys\build-ed25519.pub.pem --output perf-real.json + +# long run: the release gate is two hours in the GUI mode (it is the stricter one) +& $py tools\scene_assets\soak_scene.py --minutes 120 --fps 30 --output soak.json +& $py tools\scene_assets\soak_scene.py --no-window --minutes 10 --fps 60 --output soak-cpu.json +``` + +The committed reports for the developer machine are +`docs/architecture/perf-report-windows-dev.json` and +`docs/architecture/soak-report-windows-dev.json`; the stage-by-stage acceptance, the +failure samples and the compatibility notes are in +`docs/architecture/studio-3d-stages.md`.