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
@@ -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"):