650578d738
- 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(演示取景脚本会临时调窄,产品默认值另议)。
177 lines
6.7 KiB
Python
177 lines
6.7 KiB
Python
"""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()
|