界面:三维面板只留画面与图例,全部配置进「三维显示设置」对话框;三维占比提为最宽

- What(隐藏配置): `ThreeDPanel` 把**所有设置**收进一个块
  (`controls_widget`:总开关、观察方向、显示外壳、放大形变+增益、色标模式与范围、显示帧率、
  复位按钮、显示统计),面板本身只保留读图需要的东西:色条 + 一行紧凑图例(数量/自动色标范围/
  来源)+ 状态行。新增 `scene3d/settings_dialog.py` 的 `Scene3dSettingsDialog`:非模态
  (调参时画面还在动),承载的就是面板那套控件本身(同一批 widget,信号通路不变),
  关闭只是隐藏(控件必须活着,下一次读设置还要用)。
  两个入口:视图菜单新增 `三维显示设置…`(`act_scene3d_settings` → `Mesh3dPanel.open_settings()`)
  与面板标题栏的齿轮;无三维可配时(box 传感器 / 无 GL / 无场景包)菜单项会把面板上的原因
  原样提示出来,而不是弹一个空对话框。标题栏另有「复位视角」图标(新画的 `reset_view` 图标,
  环形箭头,随主题着色)。
- What(提高占比): live/replay 预设把三维面板设为最宽:`VIEW_SPLITTER_SIZES` (620, 300, 520)、
  `DEFAULT_SPLITTER_SIZES` (620, 320, 500)、`DEFAULT_3D_PANE_WIDTH` 340 → 620、
  `MIN_PANEL_WIDTHS["mesh3d"]` 260 → 200(面板里没有表单要放了,画面才是重点)、
  `ViewLayoutController.apply_panel_visibility` 的拉伸权重 2 → 5(2D 3、曲线 4)。
  1440×880 实测:三维约 430 px(原约 280)、2D 约 265、曲线约 320。
- What(拍视频/评审用): `tools/ui_snapshot.py` 新增 `--scene flow3d`:驱动一批**带真实光流场**的
  packets(适配器只接受声明了 provenance 的字段),所以三维面板真的有颜色、曲线也真的在动;
  这是本次界面评审与演示视频的取景脚本。配 `--sensor finger --panels 3d --preset live`。
- What(测试): 新增 `apps/studio/tests/test_scene3d_pane_ui.py`(5 项):面板只有图例没有设置、
  对话框承载的是面板自己的控件且设置信号照旧、关闭只隐藏不销毁、标题栏两个动作可用、
  无场景时 `open_settings()` 返回 False、`reset_view` 转发、`reset_view` 图标两种主题都能画、
  视图菜单项能打开对话框(无 GL 时给原因)。`test_view_layout.py` 增一条"出厂 live 布局里
  三维最宽"的断言(并更正我自己那条 340 的旧期望)。
- 文档: `studio-3d-stages.md` 的「尚未完成」第 1 条(阶段 8 第二批)改写为"部分已做",
  写清已完成的部分与仍留后的四项(页签容器/窄窗口回退/曲线量程开关/独立诊断抽屉)。
- Why: 三维面板原来下半屏全是表单,画面被挤成一小块——拍视频要的是"三维是主角"。
  配置不是不需要,而是不该和画面抢地方;放进对话框后画面变大、参数反而更好找(一处看全)。
- Verification: pytest shared/tests apps/studio/tests(offscreen)650 passed / **2 failed** / 15 skipped,
  两个失败都是 `shared/tests/test_worker_frame_rate.py` 的**墙钟计时**用例(30 fps 设备被测成
  24–25 fps):已在 `d539ce9` 的干净 worktree 上复现同样的失败,属本机负载(DSH/Cursor/飞书 占 CPU)
  而非本次改动;真 GL 用例 `test_scene3d_gl_smoke` + `test_scene3d_view` 61 项 0 失败(1 跳过);
  `tools/ui_check.py` 587 项检查 0 失败;`.repro/locale_check.py` 0 缺失(新增 6 个 key 双语齐备)。
  目视验收:`--scene flow3d` 渲染的 1440×880 截图(拆分前后各一张)见提交说明附言。
- Not in this change: 不改三维渲染/契约/记录;不做页签容器与窄窗口回退;
  曲线量程仍是出厂 ±20 N(演示取景脚本会临时调窄,产品默认值另议)。
This commit is contained in:
ykcheungab
2026-09-15 20:55:33 +08:00
parent d539ce93ad
commit 650578d738
13 changed files with 629 additions and 53 deletions
+176
View File
@@ -0,0 +1,176 @@
"""The 3D pane's interface: a picture, one legend line, and a settings dialog.
The pane used to be half form fields, and that is what left the 3D view occupying a
corner of its own splitter slot. Everything configurable now lives in
``Scene3dSettingsDialog``; what stays under the picture is what *reading* it needs — the
colour ramp, the numbers it was drawn with, and which source produced the field.
These tests pin three things: the pane shows no settings, the dialog holds the **same**
control widgets the panel has always had (so the settings path did not fork), and the
view menu / the pane's header both reach that dialog.
"""
from __future__ import annotations
import pytest
from PyQt5.QtCore import pyqtSignal
from PyQt5.QtWidgets import QWidget
from orisys_app_shared.i18n import t
from orisys_studio.ui import icons
@pytest.fixture
def panel(qt_app):
"""A ``ThreeDPanel`` with the coordinator and widget it needs, and no scene."""
from orisys_studio.scene3d.camera import OrbitCamera
from orisys_studio.scene3d.coordinator import ThreeDCoordinator
from orisys_studio.scene3d.gl_backend import GLRenderer
from orisys_studio.scene3d.panel import ThreeDPanel
from orisys_studio.scene3d.widget import Sensor3DWidget
widget = Sensor3DWidget(GLRenderer(), OrbitCamera())
coordinator = ThreeDCoordinator(widget, composer=object(), executor=None, max_fps=30)
created = ThreeDPanel(coordinator, widget)
yield created
coordinator.close()
created.deleteLater()
# --------------------------------------------------------------------- the pane
def test_the_pane_holds_a_legend_and_no_settings(qt_app, panel):
"""What is on the pane has to be readable, not adjustable."""
# The readouts are part of the pane itself...
assert panel.legend_label.parent() is panel
assert panel.source_label.parent() is panel
assert panel.scale_bar.parent() is panel
# ...and every setting is in one block that the pane never shows.
assert panel.controls_widget.parent() is panel
assert not panel.controls_widget.isVisible()
for control in (
panel.enable_check,
panel.preset_combo,
panel.housing_check,
panel.deform_check,
panel.gain_spin,
panel.scale_combo,
panel.fps_combo,
panel.reset_button,
):
assert control.parent() is panel.controls_widget, control
# The engineering counters went with the settings rather than under the picture.
assert panel.stats_label.parent() is panel.controls_widget
def test_the_settings_dialog_hosts_the_panes_own_controls(qt_app, panel):
"""One set of widgets either way: the dialog moves them, it does not copy them."""
from orisys_studio.scene3d.settings_dialog import Scene3dSettingsDialog
dialog = Scene3dSettingsDialog(panel)
try:
assert panel.controls_widget.parent() is dialog, (
"the dialog kept a second copy of the controls"
)
assert panel.controls_widget.isVisibleTo(dialog)
assert not dialog.isModal(), "the pane has to stay visible while tuning it"
assert dialog.windowTitle() == t("scene3d.settings_title")
# ...and they still work from there: the signal path is the panel's own.
seen: list[bool] = []
panel.enabledChanged.connect(seen.append)
panel.enable_check.setChecked(True)
assert seen == [True], "a control in the dialog stopped reaching the view"
# Closing hides it: the controls have to survive for the next time.
dialog.close()
assert not dialog.isVisible()
assert panel.enable_check.text(), "closing the dialog destroyed its controls"
assert panel.settings().max_fps == 30
finally:
dialog.deleteLater()
def test_the_pane_header_offers_the_two_actions(qt_app):
"""A gear and a view reset: the two things that belong to *looking*."""
from orisys_studio.ui.mesh3d_panel import Mesh3dPanel
pane = Mesh3dPanel(theme="dark")
try:
assert pane.btn_settings.toolTip() == t("panels.mesh3d_settings_tip")
assert pane.btn_reset_view.toolTip() == t("panels.mesh3d_reset_tip")
assert not pane.btn_settings.icon().isNull()
assert not pane.btn_reset_view.icon().isNull()
# A box sensor has no scene, so there is nothing to configure and the pane says
# so instead of opening an empty dialog.
pane.set_sensor("box")
assert pane.open_settings() is False
assert pane.view is None
# Reset forwards to the view when there is one.
class _View(QWidget):
diagnosticRaised = pyqtSignal(object)
enabledChanged = pyqtSignal(bool)
def __init__(self) -> None:
super().__init__()
self.resets = 0
self.available = True
def set_source_kind(self, kind: str) -> None:
self.source_kind = kind
def set_enabled(self, enabled: bool) -> None:
self.enabled = bool(enabled)
def restore_settings(self, settings) -> None:
self.settings_value = settings
def reset_view(self) -> None:
self.resets += 1
view = _View()
pane.attach_view(view) # type: ignore[arg-type]
pane.reset_view()
assert view.resets == 1
finally:
pane.deleteLater()
def test_the_reset_view_icon_is_painted_from_the_theme():
assert "reset_view" in icons.NAMES
for theme in ("dark", "light"):
assert not icons.icon("reset_view", theme).isNull()
# --------------------------------------------------------------------- the menu
def test_the_view_menu_opens_the_3d_settings(qt_app, make_window):
"""视图 ▸ 三维显示设置… is the way in that does not depend on the pane's header."""
from PyQt5.QtWidgets import QApplication
window = make_window(sensor_type="finger", show_finger_3d=True)
window.show()
QApplication.processEvents()
try:
assert window.act_scene3d_settings.text() == t("menu.scene3d_settings")
assert window.act_scene3d_settings in window.view_menu.actions()
view = window.mesh3d_panel.view
if view is None or not view.available:
# No GL here: the entry has to say why instead of doing nothing.
window.act_scene3d_settings.trigger()
QApplication.processEvents()
assert window.statusBar().currentMessage().strip()
return
window.mesh3d_panel.ensure_view()
window.act_scene3d_settings.trigger()
QApplication.processEvents()
dialog = window.mesh3d_panel.view.settings_dialog()
assert dialog is not None and dialog.isVisible()
dialog.hide()
finally:
window.close()