Files
orisys-app/apps/studio/tests/test_scene3d_pane_ui.py
T
ykcheungab c2293d4d41 界面:三维可选「深度响应 / 光流幅值」上色(默认深度),并加一个「诊断信息」开关收拾面板脚下的文字
- What(颜色来源): `DisplaySettings` 增 `field`(`"depth"` 默认 / `"flow"`),随显示设置持久化
  (`scene3d/prefs.py`:新增 `FIELD_CHOICES`/`DEFAULT_FIELD`,手改文件里的非法值修回默认)。
  `FingerFrameAdapter` 按所选字段先试、失败再用另一个,并把"换了量"这件事记在
  `last_substitution`(新诊断码 `field_substituted`,info 级);两个字段都拿不到时,
  `last_diagnostic` 取**信息量最大**的那条(域不符/过期/未声明 > 没这个字段),而不是"所选字段"的
  那条空话。设置对话框新增「颜色来源」下拉,下面一行 `field_note` 直接写"这一帧画的是 X,不是所选的 Y";
  图例(`legend_label`)永远标注**实际**画出来的量,所以设定与画面不一致时两处都能看出来。
  深度响应 `[1]`、光流幅值 `[px]`,两者都是 `approximate`——代码与文案都不把它们说成毫米。
- What(诊断信息开关): 视图菜单新增 `诊断信息`(可勾选、持久化 `show_diagnostics`、**默认关**,
  由 `DisplayPrefsController` 持有并推给两个面板)。关掉后:2D 面板只留"画面 + 计划说明 + 深度色标",
  三条解释流水线的标签(箭头按位移放大、原始图像 → 算法输出、全部帧)隐藏;3D 面板只留
  色条 + 一行图例 + 来源,info 级诊断隐藏。**永不隐藏**:warning/error 级诊断(测量是否过期、
  字段是否本帧的、是否与设备核对过——`geometry_unverified` 虽然是 info 也白名单保留)与
  深度色标。3D 图例改成 `ShrinkableLabel`(一行、裁切 + tooltip,不再折行),色标数值从 4 位有效数字
  收到 3 位("0.0334 – 0.899")。设置对话框控件宽度 88 → 108 px(不再需要迁就 240 px 面板)。
- What(取景脚本): `tools/ui_snapshot.py --scene flow3d` 现在同时给 flow 与 depth(各带 provenance),
  所以「颜色来源」开关有东西可切;再加 `.repro/shot_variants.py` 一次出四张图
  (深度版 / 光流版 / 诊断开 / 设置对话框)。
- What(测试): `test_scene3d_adapters.py` 增 3 项(默认深度、换量时给出 `field_substituted` 并在恢复后
  清掉、depth 不在场景栅格时按 `field_domain_mismatch` 拒绝而不是缩放);`test_scene3d_view.py` 增
  1 项(field 的持久化与修复,并把 `field in FIELD_CHOICES` 加进"手改文件会被修复"的参数化);
  `test_scene3d_pane_ui.py` 增 3 项(两种量可选 + 说明行 + 恢复的偏好会移动下拉;诊断开关只隐藏 info、
  保留 warning 与"未核对";2D 脚注三标签的显隐与窗口开关一致);`test_display_prefs.py` 增 1 项
  (开关推到两个面板、写偏好)。`test_ui_smoke.py` 与 `tools/ui_check.py` 里"箭头图例可见"的四条断言
  改为按新规则检查(默认隐藏、开诊断后可见、关掉再隐藏)。
- Why: ①"深度"比"光流"更贴近操作员口中的"压得多深",但两者都只是近似量,所以做成开关而不是替换——
  并且换量必须说出来(否则画面会被读成所选的量)。②面板脚下的解释文字是"内部管线"而不是"画面是什么",
  拍视频与日常看都只需要后者;用一个开关收起,比做一个滚动日志面板诚实(这些是每帧状态,不是事件)。
- Verification: pytest shared/tests apps/studio/tests(offscreen)658 passed / 0 failed / 15 skipped;
  真 GL 组(gl_smoke + view + pane_ui)70 passed / 0 failed / 1 skipped;
  `tools/ui_check.py` 590 项检查 0 失败;`.repro/locale_check.py` 0 缺失(新增 7 个 key 双语齐备)。
  目视验收:同一批合成数据渲染的四张图(深色主题)——深度版(默认)、光流版、诊断打开、设置对话框。
- Not in this change: 不改采集/记录/契约之外的三维渲染;不做页签容器与窄窗口回退;曲线量程仍是出厂 ±20 N。
  真机待验证:手指路径上 `packet.depth` 的栅格是否就是场景的 300×280(若不是,面板会写明并回退到光流)。
2026-09-15 21:23:18 +08:00

272 lines
11 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()
# --------------------------------------------------------------- which quantity
def test_the_pane_offers_the_two_quantities_and_says_which_one_it_drew(qt_app, panel):
"""The choice is the operator's; a frame that cannot honour it says so."""
from orisys_app_shared.scene3d.contracts import FieldDescriptor
assert [panel.field_combo.itemData(i) for i in range(panel.field_combo.count())] == [
"depth",
"flow",
]
assert panel.field() == "depth", "the depth response is what the pane offers first"
assert panel.settings().field == "depth"
# The chosen quantity is drawn: no note.
panel.set_descriptor(FieldDescriptor("depth_response", "1", "approximate", False))
assert not panel.field_note.isVisible() or not panel.field_note.text()
# The other one is drawn instead: the note names both, and the legend still shows
# what is really on screen (the range is for the field that was drawn).
panel.set_descriptor(FieldDescriptor("flow_magnitude", "px", "approximate", False))
note = panel.field_note.text()
assert t("scene3d.field_depth") in note and t("scene3d.field_flow") in note, note
assert "flow_magnitude" not in panel.legend_label.text()
# Choosing flow makes that frame the honoured one, and the note goes away.
index = panel.field_combo.findData("flow")
panel.field_combo.setCurrentIndex(index)
assert panel.settings().field == "flow"
panel.set_descriptor(FieldDescriptor("flow_magnitude", "px", "approximate", False))
assert not panel.field_note.isVisible() or not panel.field_note.text()
# A restored preference moves the combo (and therefore the adapter).
from orisys_app_shared.scene3d.contracts import DisplaySettings
panel.apply_settings(DisplaySettings(field="depth"))
assert panel.field() == "depth"
def test_the_diagnostics_switch_governs_what_the_pane_explains(qt_app, panel):
"""Warnings are claims about the measurement and always show; info is explanation."""
from orisys_app_shared.scene3d.contracts import diagnostic
panel.set_descriptor(None)
panel.set_diagnostics_visible(False)
panel.set_diagnostic(diagnostic("field_stale", "the array is from another frame"))
assert "another frame" in panel.legend_label.text(), (
"a warning about the measurement was hidden"
)
panel.set_diagnostic(diagnostic("geometry_unverified", "the scene was not compared"))
assert "not compared" in panel.legend_label.text(), (
"a claim about verification was hidden"
)
panel.set_diagnostic(diagnostic("stale_frame", "the picture is not current"))
assert "not current" not in panel.legend_label.text(), (
"an informational line survived diagnostics being off"
)
panel.set_diagnostics_visible(True)
panel.set_diagnostic(diagnostic("stale_frame", "the picture is not current"))
assert "not current" in panel.legend_label.text()
def test_the_2d_footer_shows_the_picture_and_hides_the_pipeline(qt_app, make_window):
"""One line under the canvas: what the picture is, not how it was made."""
from PyQt5.QtWidgets import QApplication
window = make_window()
window.show()
QApplication.processEvents()
try:
arrow = window.arrow_panel
arrow.set_plan(None)
arrow.set_diagnostics_visible(False)
assert not arrow.scale_label.isVisible()
assert not arrow.pipeline_label.isVisible()
assert not arrow.area_label.isVisible()
arrow.set_diagnostics_visible(True)
assert arrow.pipeline_label.isVisible() or not arrow.view_modes(), (
"the pipeline line came back"
)
# The window's switch is the same thing, and it is remembered.
window.act_show_diagnostics.setChecked(True)
QApplication.processEvents()
assert window.display_ctl.show_diagnostics is True
assert arrow.diagnostics_visible() is True
assert window.prefs["show_diagnostics"] is True
finally:
window.close()
# --------------------------------------------------------------------- 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()