界面:三维面板只留画面与图例,全部配置进「三维显示设置」对话框;三维占比提为最宽
- 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:
@@ -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()
|
||||
@@ -299,6 +299,20 @@ def test_a_preset_that_gives_no_width_to_the_3d_pane_takes_it_from_the_widest():
|
||||
assert controller.splitter_sizes_for("live") == list(VIEW_SPLITTER_SIZES["live"])
|
||||
|
||||
|
||||
def test_the_shipped_live_layout_makes_the_3d_pane_the_widest_panel():
|
||||
"""The 3D view is the subject when it is on, not an inset beside the charts."""
|
||||
from orisys_studio.app_window import DEFAULT_SPLITTER_SIZES, VIEW_SPLITTER_SIZES as LIVE
|
||||
|
||||
mesh, image, charts = DEFAULT_SPLITTER_SIZES
|
||||
assert mesh > image, DEFAULT_SPLITTER_SIZES
|
||||
assert mesh > charts, DEFAULT_SPLITTER_SIZES
|
||||
live_mesh, live_image, live_charts, _cal_live = LIVE["live"]
|
||||
assert live_mesh > live_image and live_mesh > live_charts, LIVE["live"]
|
||||
assert LIVE["replay"] == LIVE["live"], (
|
||||
"the replay view is the live layout with another source"
|
||||
)
|
||||
|
||||
|
||||
def test_the_calibration_panel_toggle_is_the_calibration_view_switch():
|
||||
controller, parts = _controller(prefs={"view_preset": "live"})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user