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(演示取景脚本会临时调窄,产品默认值另议)。
385 lines
13 KiB
Python
385 lines
13 KiB
Python
"""Acceptance for the panel layout, on its own.
|
|
|
|
``ViewLayoutController`` owns what a *view* is: which panels are on screen, in what
|
|
arrangement, and which preset is active. It drives the panels through their own small
|
|
interfaces and borrows a handful of window-level operations through ``LayoutHost``, so
|
|
both can be stood in for — which is the point of the split, and what these tests use.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from orisys_studio.view_layout import ViewLayoutController
|
|
|
|
VIEW_PRESETS = ("custom", "live", "replay", "calibration")
|
|
VIEW_SHOWS_3D = ("custom", "live", "replay")
|
|
VIEW_SPLITTER_SIZES = {"live": (0, 360, 1080, 0), "replay": (0, 360, 1080, 0), "calibration": (0, 0, 560, 880)}
|
|
DEFAULT_SPLITTER_SIZES = (300, 360, 680, 0)
|
|
MIN_PANEL_WIDTHS = {"mesh3d": 260, "arrow": 300, "plots": 340, "cal": 300}
|
|
|
|
|
|
class _Action:
|
|
def __init__(self, checked: bool = False) -> None:
|
|
self.checked = bool(checked)
|
|
self.enabled: bool | None = None
|
|
self.blocked = 0
|
|
self.toggled = _Signal()
|
|
|
|
def isChecked(self) -> bool: # noqa: N802
|
|
return self.checked
|
|
|
|
def setChecked(self, value: bool) -> None: # noqa: N802
|
|
self.checked = bool(value)
|
|
|
|
def setEnabled(self, value: bool) -> None: # noqa: N802
|
|
self.enabled = bool(value)
|
|
|
|
def blockSignals(self, flag: bool) -> None: # noqa: N802
|
|
self.blocked += 1
|
|
|
|
|
|
class _Signal:
|
|
def __init__(self) -> None:
|
|
self.slots: list = []
|
|
|
|
def connect(self, slot) -> None:
|
|
self.slots.append(slot)
|
|
|
|
|
|
class _Panel:
|
|
"""A panel as the layout treats it: visibility, a minimum width, a stretch."""
|
|
|
|
def __init__(self) -> None:
|
|
self.visible: bool | None = None
|
|
self.minimum_width: int | None = None
|
|
self.views = 0
|
|
|
|
def setVisible(self, visible: bool) -> None: # noqa: N802
|
|
self.visible = bool(visible)
|
|
|
|
def setMinimumWidth(self, width: int) -> None: # noqa: N802
|
|
self.minimum_width = int(width)
|
|
|
|
def ensure_view(self):
|
|
self.views += 1
|
|
|
|
def set_enabled(self, enabled: bool) -> None:
|
|
self.enabled = bool(enabled)
|
|
|
|
def set_source_kind(self, kind: str) -> None:
|
|
self.kind = str(kind)
|
|
|
|
|
|
class _Splitter:
|
|
def __init__(self, widgets) -> None:
|
|
self._widgets = list(widgets)
|
|
self.stretches: list[tuple[int, int]] = []
|
|
self.sizes: list = []
|
|
|
|
def count(self) -> int:
|
|
return len(self._widgets)
|
|
|
|
def widget(self, index: int):
|
|
return self._widgets[index]
|
|
|
|
def setStretchFactor(self, index: int, value: int) -> None: # noqa: N802
|
|
self.stretches.append((index, value))
|
|
|
|
def setSizes(self, sizes) -> None: # noqa: N802
|
|
self.sizes = list(sizes)
|
|
|
|
|
|
class _Picker:
|
|
def __init__(self, sensor_type: str = "box") -> None:
|
|
self._sensor = sensor_type
|
|
|
|
def sensor_type(self) -> str:
|
|
return self._sensor
|
|
|
|
|
|
class _Toolbar:
|
|
def __init__(self, *, expanded: bool = False) -> None:
|
|
self._expanded = expanded
|
|
self.source_views: list[str] = []
|
|
self.run_roles: list[str] = []
|
|
self.reset_roles: list[str] = []
|
|
|
|
def set_source_view(self, view: str) -> None:
|
|
self.source_views.append(view)
|
|
|
|
def set_run_role(self, role: str) -> None:
|
|
self.run_roles.append(role)
|
|
|
|
def set_reset_role(self, role: str) -> None:
|
|
self.reset_roles.append(role)
|
|
|
|
def settings_expanded(self) -> bool:
|
|
return self._expanded
|
|
|
|
def set_settings_expanded(self, expanded: bool) -> None:
|
|
self._expanded = bool(expanded)
|
|
|
|
|
|
class _BrandBar:
|
|
def __init__(self) -> None:
|
|
self.active: list = []
|
|
|
|
def set_active_view(self, preset) -> None:
|
|
self.active.append(preset)
|
|
|
|
|
|
class _Replay:
|
|
def __init__(self) -> None:
|
|
self.paused = 0
|
|
self.sources: list[bool] = []
|
|
self.recomputed: list[bool] = []
|
|
self.refreshed = 0
|
|
self.synced = 0
|
|
|
|
def pause_playback(self) -> None:
|
|
self.paused += 1
|
|
|
|
def apply_source(self, active: bool) -> None:
|
|
self.sources.append(bool(active))
|
|
|
|
def on_recompute_toggled(self, checked: bool) -> None:
|
|
self.recomputed.append(bool(checked))
|
|
|
|
def refresh_state(self) -> None:
|
|
self.refreshed += 1
|
|
|
|
def sync_actions(self) -> None:
|
|
self.synced += 1
|
|
|
|
|
|
class _Host:
|
|
def __init__(self, prefs: dict) -> None:
|
|
self.prefs = dict(prefs)
|
|
self.persisted = 0
|
|
self.run_updates = 0
|
|
self.rail_updates = 0
|
|
self.source_kind = "live"
|
|
|
|
def _persist_prefs(self) -> None:
|
|
self.persisted += 1
|
|
|
|
def _update_run_enabled(self) -> None:
|
|
self.run_updates += 1
|
|
|
|
def _refresh_status_rail(self) -> None:
|
|
self.rail_updates += 1
|
|
|
|
def _current_source_kind(self) -> str:
|
|
return self.source_kind
|
|
|
|
def _device_geometry_provider(self):
|
|
return None
|
|
|
|
|
|
def _controller(*, prefs=None, sensor_type="box", settings_expanded=False):
|
|
prefs = dict(prefs or {})
|
|
host = _Host(prefs)
|
|
mesh, image, charts, cal = _Panel(), _Panel(), _Panel(), _Panel()
|
|
splitter = _Splitter([mesh, image, charts, cal])
|
|
toolbar = _Toolbar(expanded=settings_expanded)
|
|
brand = _BrandBar()
|
|
replay = _Replay()
|
|
controller = ViewLayoutController(
|
|
host=host,
|
|
splitter=splitter,
|
|
mesh_panel=mesh,
|
|
image_panel=image,
|
|
charts=charts,
|
|
cal_panel=cal,
|
|
sensor_picker=_Picker(sensor_type),
|
|
brand_bar=brand,
|
|
toolbar=toolbar,
|
|
replay=replay,
|
|
presets=VIEW_PRESETS,
|
|
shows_3d_in=VIEW_SHOWS_3D,
|
|
splitter_sizes=VIEW_SPLITTER_SIZES,
|
|
default_splitter_sizes=DEFAULT_SPLITTER_SIZES,
|
|
min_panel_widths=MIN_PANEL_WIDTHS,
|
|
default_3d_pane_width=340,
|
|
)
|
|
actions = {
|
|
"cal_panel": _Action(),
|
|
"finger_3d": _Action(),
|
|
"settings": _Action(),
|
|
}
|
|
controller.bind_actions(**actions)
|
|
parts = {
|
|
"host": host,
|
|
"mesh": mesh,
|
|
"image": image,
|
|
"charts": charts,
|
|
"cal": cal,
|
|
"splitter": splitter,
|
|
"toolbar": toolbar,
|
|
"brand": brand,
|
|
"replay": replay,
|
|
"actions": actions,
|
|
}
|
|
return controller, parts
|
|
|
|
|
|
# --------------------------------------------------------------------- tests
|
|
|
|
|
|
def test_a_preset_decides_which_panels_a_view_shows():
|
|
controller, parts = _controller(prefs={"view_preset": "live"})
|
|
|
|
controller.apply_preset("calibration")
|
|
assert controller.view_preset == "calibration"
|
|
assert parts["cal"].visible is True
|
|
assert parts["image"].visible is False, "the 2D panel does not belong to calibration"
|
|
assert parts["charts"].visible is True
|
|
assert parts["host"].prefs["view_preset"] == "calibration"
|
|
assert parts["toolbar"].source_views[-1] == "live"
|
|
assert parts["toolbar"].reset_roles[-1] == "reset"
|
|
assert parts["host"].persisted == 1
|
|
|
|
controller.apply_preset("replay")
|
|
assert parts["image"].visible is True
|
|
assert parts["toolbar"].source_views[-1] == "replay"
|
|
assert parts["toolbar"].run_roles[-1] == "playback"
|
|
assert parts["toolbar"].reset_roles[-1] == "stop"
|
|
# The charts follow the recording in this view, and the replay's own entries are
|
|
# re-synced with it. (Calibration did neither of those: it has no replay source.)
|
|
assert parts["replay"].sources == [True]
|
|
assert parts["replay"].refreshed == 1
|
|
assert parts["replay"].synced == 2
|
|
assert parts["brand"].active[-1] == "replay"
|
|
|
|
|
|
def test_an_unknown_preset_is_a_custom_layout_not_a_crash():
|
|
controller, parts = _controller()
|
|
controller.apply_preset("nonsense")
|
|
assert controller.view_preset == "custom"
|
|
assert parts["host"].prefs["view_preset"] == "custom"
|
|
assert parts["brand"].active[-1] is None
|
|
|
|
|
|
def test_leaving_the_replay_view_stops_the_transport_and_drops_the_pass_label():
|
|
controller, parts = _controller(prefs={"view_preset": "replay"})
|
|
controller.apply_preset("live")
|
|
assert parts["replay"].paused == 1, "the transport kept running across a view switch"
|
|
assert parts["replay"].sources[-1] is False, "the charts still read the recording"
|
|
assert parts["replay"].recomputed == [False], "the pass label survived the view"
|
|
|
|
|
|
def test_the_3d_pane_needs_a_finger_sensor_and_its_own_switch():
|
|
controller, parts = _controller(prefs={"view_preset": "live"}, sensor_type="box")
|
|
assert not controller.shows_3d()
|
|
|
|
# A finger sensor alone is not enough either: the switch has to be on.
|
|
controller._picker = _Picker("finger")
|
|
controller.show_finger_3d = False
|
|
assert not controller.shows_3d()
|
|
controller.show_finger_3d = True
|
|
assert controller.shows_3d()
|
|
|
|
# ...and the calibration view never shows it.
|
|
controller.apply_preset("calibration")
|
|
assert not controller.shows_3d()
|
|
assert parts["mesh"].visible is False
|
|
assert parts["mesh"].views == 0, "a hidden pane must not build a GL view"
|
|
|
|
|
|
def test_a_preset_that_gives_no_width_to_the_3d_pane_takes_it_from_the_widest():
|
|
controller, parts = _controller(prefs={"view_preset": "live"}, sensor_type="finger")
|
|
controller.show_finger_3d = True
|
|
|
|
sizes = controller.splitter_sizes_for("live")
|
|
assert sizes[0] == 340, sizes
|
|
assert sum(sizes) == sum(VIEW_SPLITTER_SIZES["live"]), "the layout changed width"
|
|
# Without the pane the table is used as it stands.
|
|
controller.show_finger_3d = False
|
|
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"})
|
|
|
|
controller.on_cal_panel_toggled(True)
|
|
assert controller.view_preset == "calibration"
|
|
assert controller.show_cal is True
|
|
|
|
controller.on_cal_panel_toggled(False)
|
|
assert controller.view_preset == "live"
|
|
assert controller.show_cal is False
|
|
assert parts["host"].prefs["show_cal_panel"] is False
|
|
assert parts["brand"].active[-1] == "live"
|
|
assert controller.show_cal is False
|
|
|
|
|
|
def test_the_3d_pane_switch_does_not_drive_itself():
|
|
controller, parts = _controller(prefs={"view_preset": "live"}, sensor_type="finger")
|
|
controller.show_finger_3d = True
|
|
before = len(parts["mesh"].__dict__)
|
|
|
|
# The pane is already on: the toggle says so again and nothing is written.
|
|
controller.on_3d_pane_toggled(True)
|
|
assert "show_finger_3d" not in parts["host"].prefs, (
|
|
"a no-op toggle still persisted a preference"
|
|
)
|
|
|
|
controller.on_3d_pane_toggled(False)
|
|
assert controller.show_finger_3d is False
|
|
assert parts["host"].prefs["show_finger_3d"] is False
|
|
assert parts["mesh"].visible is False
|
|
assert len(parts["mesh"].__dict__) >= before # sanity: the panel was reused
|
|
|
|
|
|
def test_the_view_menu_entries_follow_what_is_on_screen():
|
|
controller, parts = _controller(prefs={"view_preset": "live"}, sensor_type="box")
|
|
controller.sync_view_checks()
|
|
assert parts["actions"]["finger_3d"].enabled is False, "a box sensor has no scene"
|
|
assert parts["actions"]["finger_3d"].checked is False
|
|
assert parts["actions"]["cal_panel"].checked is False
|
|
|
|
controller.on_cal_panel_toggled(True)
|
|
assert parts["actions"]["cal_panel"].checked is True
|
|
assert parts["actions"]["cal_panel"].blocked > 0, (
|
|
"the check was set without blocking its own signal"
|
|
)
|
|
|
|
|
|
def test_the_settings_drawer_keeps_the_menu_entry_in_step():
|
|
controller, parts = _controller(prefs={"view_preset": "live"})
|
|
controller.set_settings_expanded(True)
|
|
assert parts["toolbar"].settings_expanded() is True
|
|
assert parts["actions"]["settings"].checked is True
|
|
assert parts["host"].prefs["settings_expanded"] is True
|
|
assert parts["host"].persisted == 1
|
|
|
|
controller.set_settings_expanded(False, persist=False)
|
|
assert parts["actions"]["settings"].checked is False
|
|
assert parts["host"].persisted == 1, "persist=False still wrote the preferences"
|
|
|
|
|
|
def test_the_3d_pane_gets_the_keys_and_the_sensor_it_should_read():
|
|
controller, parts = _controller(prefs={"view_preset": "live"}, sensor_type="finger")
|
|
seen = {}
|
|
parts["mesh"].set_keys = lambda keys: seen.setdefault("keys", keys)
|
|
parts["mesh"].set_sensor = lambda *a, **k: seen.setdefault("sensor", (a, k))
|
|
|
|
controller.sync_3d_source()
|
|
assert seen["sensor"][0] == ("finger",), seen["sensor"]
|
|
assert "keys" in seen
|