界面:三维面板只留画面与图例,全部配置进「三维显示设置」对话框;三维占比提为最宽
- 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:
@@ -30,6 +30,7 @@
|
||||
"lang_en": "English",
|
||||
"calibration_panel": "Calibration panel",
|
||||
"finger_3d": "Finger 3D view",
|
||||
"scene3d_settings": "3D display settings…",
|
||||
"show_fps": "Show FPS",
|
||||
"settings": "Settings area",
|
||||
"plot_settings": "Plot settings…",
|
||||
@@ -179,7 +180,9 @@
|
||||
"mesh3d_no_asset": "No compiled 3D scene installed.\nA scene package (*.oryscene) is looked for in:\n{directory}\nBuild one with tools/scene_assets/compile_scene.py — the names of the files it needs are in its --help. Only the 3D view is affected; measuring, recording and the charts work as usual.",
|
||||
"mesh3d_loading": "Loading 3D scene {name}…",
|
||||
"mesh3d_rejected": "The 3D scene was refused: {message}\nOnly the 3D view is affected; measuring, recording and the charts work as usual.",
|
||||
"mesh3d_unavailable_gl_reason": "3D is not available on this machine: {reason}\nMeasuring, recording and the charts work as usual."
|
||||
"mesh3d_unavailable_gl_reason": "3D is not available on this machine: {reason}\nMeasuring, recording and the charts work as usual.",
|
||||
"mesh3d_settings_tip": "3D display settings (view, housing, colour scale, frame rate)",
|
||||
"mesh3d_reset_tip": "Reset view: back to the framing the scene itself uses"
|
||||
},
|
||||
"chart": {
|
||||
"fn": "FN",
|
||||
@@ -417,6 +420,8 @@
|
||||
"max_fps": "Display rate",
|
||||
"max_fps_tip": "How many frames per second the 3D view draws. A display budget only: the camera and the recording are unaffected, and a dropped frame here is never a dropped sample.",
|
||||
"reset_view": "Reset view",
|
||||
"settings_title": "3D display settings",
|
||||
"settings_hint": "Everything here affects the 3D view's display only: measuring, recording and the charts are untouched, and each change applies at once.",
|
||||
"legend": "{quantity} [{unit}] · {quality}",
|
||||
"legend_none": "No measurement on screen — the surface model is shown uncoloured",
|
||||
"quality_calibrated": "calibrated",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"lang_en": "English",
|
||||
"calibration_panel": "标定面板",
|
||||
"finger_3d": "手指 3D 视图",
|
||||
"scene3d_settings": "三维显示设置…",
|
||||
"show_fps": "显示 FPS",
|
||||
"settings": "设置区",
|
||||
"plot_settings": "绘图设置…",
|
||||
@@ -179,7 +180,9 @@
|
||||
"mesh3d_no_asset": "本机未安装已编译的三维场景。\n场景包(*.oryscene)的查找位置:\n{directory}\n可用 tools/scene_assets/compile_scene.py 生成,它需要哪些文件见 --help。只影响三维视图:采集、记录与力曲线照常工作。",
|
||||
"mesh3d_loading": "正在加载三维场景 {name}…",
|
||||
"mesh3d_rejected": "三维场景被拒绝:{message}\n只影响三维视图:采集、记录与力曲线照常工作。",
|
||||
"mesh3d_unavailable_gl_reason": "本机无法使用三维显示:{reason}\n采集、记录与力曲线照常工作。"
|
||||
"mesh3d_unavailable_gl_reason": "本机无法使用三维显示:{reason}\n采集、记录与力曲线照常工作。",
|
||||
"mesh3d_settings_tip": "三维显示设置(视角、外壳、色标、显示频率)",
|
||||
"mesh3d_reset_tip": "复位视角:回到场景自带的取景"
|
||||
},
|
||||
"chart": {
|
||||
"fn": "FN",
|
||||
@@ -417,6 +420,8 @@
|
||||
"max_fps": "显示帧率",
|
||||
"max_fps_tip": "三维视图每秒绘制多少帧。这只是显示预算:摄像头与记录不受影响,这里丢的帧永远不会变成丢的样本。",
|
||||
"reset_view": "复位视角",
|
||||
"settings_title": "三维显示设置",
|
||||
"settings_hint": "这里的每一项都只影响三维视图的显示:采集、记录与力曲线完全不受影响,改完立即生效。",
|
||||
"legend": "{quantity} [{unit}] · {quality}",
|
||||
"legend_none": "屏幕上没有测量值 — 仅显示不着色的表面模型",
|
||||
"quality_calibrated": "已标定",
|
||||
|
||||
@@ -80,13 +80,13 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
RUNNABLE_TYPES = frozenset({"box", "mini", "finger"})
|
||||
ARROW_ROTATE_TYPES = frozenset({"box", "mini"})
|
||||
DEFAULT_SPLITTER_SIZES = (340, 480, 400)
|
||||
DEFAULT_SPLITTER_SIZES = (620, 320, 500)
|
||||
MIN_PANEL_WIDTHS = {
|
||||
# The 3D pane is the only one whose content is a real control panel rather than a
|
||||
# label or a picture, and its minimum is measured under the application
|
||||
# stylesheet in both languages: the widest case is English at 241 px
|
||||
# (``tests/test_scene3d_view.py`` pins it), so 240 would have been 1 px short.
|
||||
"mesh3d": 260,
|
||||
# The 3D pane used to be the one panel whose content was a stack of form fields, so
|
||||
# its minimum was measured under the stylesheet in both languages (241 px in English)
|
||||
# and could not go below 260. The controls now live in the settings dialog and what
|
||||
# is left wraps, so the pane can be dragged narrow again — the picture is the point.
|
||||
"mesh3d": 200,
|
||||
"arrow": 300,
|
||||
"plots": 340,
|
||||
"cal": 300,
|
||||
@@ -99,9 +99,11 @@ VIEW_PRESETS = ("custom", "live", "replay", "calibration")
|
||||
#: ``custom`` keeps whatever the user arranged (see ``_restore_splitter_sizes``).
|
||||
#: Replay is the live layout on purpose: the two views differ in their source
|
||||
#: (a camera, or a recorded video), not in their furniture.
|
||||
#: The 3D pane is the widest of the three: it is the view's subject when it is on, and
|
||||
#: the 2D panel is a reading of the same measurement rather than a second subject.
|
||||
VIEW_SPLITTER_SIZES = {
|
||||
"live": (0, 360, 1080, 0),
|
||||
"replay": (0, 360, 1080, 0),
|
||||
"live": (620, 300, 520, 0),
|
||||
"replay": (620, 300, 520, 0),
|
||||
"calibration": (0, 0, 560, 880),
|
||||
}
|
||||
#: The 3D pane belongs to the live view and to history browsing, and only for a
|
||||
@@ -114,7 +116,7 @@ VIEW_SHOWS_3D = ("custom", "live", "replay")
|
||||
#: Width the 3D pane takes from the widest other panel when a preset's table gives it
|
||||
#: nothing. The table describes the two-dimensional layout; the pane is optional, and
|
||||
#: "not in the table" must not mean "standing at its minimum width".
|
||||
DEFAULT_3D_PANE_WIDTH = 340
|
||||
DEFAULT_3D_PANE_WIDTH = 620
|
||||
|
||||
|
||||
#: Kept under its historical name for ``tools/ui_snapshot.py``, which imports it from
|
||||
@@ -239,7 +241,7 @@ class StudioMainWindow(QMainWindow):
|
||||
self.status_chip = self.toolbar.status_chip
|
||||
|
||||
self.content_splitter = QSplitter(Qt.Horizontal)
|
||||
self.mesh3d_panel = Mesh3dPanel()
|
||||
self.mesh3d_panel = Mesh3dPanel(theme=self._theme)
|
||||
self.arrow_panel = ArrowPanel(theme=self._theme)
|
||||
self.force_plots = ForcePlotsPanel(theme=self._theme)
|
||||
# The charts browse the whole session, not just their rolling buffer.
|
||||
@@ -899,6 +901,13 @@ class StudioMainWindow(QMainWindow):
|
||||
self.act_finger_3d.setChecked(self._show_finger_3d)
|
||||
self.view_menu.addAction(self.act_finger_3d)
|
||||
|
||||
# Everything the 3D pane can be told lives in one dialog: the pane itself is for
|
||||
# looking at (see ``Mesh3dPanel``). This is the menu way in; the pane's header
|
||||
# has the gear, which is the same call.
|
||||
self.act_scene3d_settings = QAction(self)
|
||||
self.act_scene3d_settings.triggered.connect(self._on_scene3d_settings)
|
||||
self.view_menu.addAction(self.act_scene3d_settings)
|
||||
|
||||
self.act_show_fps = QAction(self)
|
||||
self.act_show_fps.setCheckable(True)
|
||||
self.act_show_fps.setChecked(self._show_fps)
|
||||
@@ -1145,6 +1154,7 @@ class StudioMainWindow(QMainWindow):
|
||||
self.view_menu.setTitle(t("menu.view"))
|
||||
self.act_cal_panel.setText(t("menu.calibration_panel"))
|
||||
self.act_finger_3d.setText(t("menu.finger_3d"))
|
||||
self.act_scene3d_settings.setText(t("menu.scene3d_settings"))
|
||||
self.act_show_fps.setText(t("menu.show_fps"))
|
||||
self.act_settings.setText(t("menu.settings"))
|
||||
self.act_plot_settings.setText(t("menu.plot_settings"))
|
||||
@@ -1350,6 +1360,7 @@ class StudioMainWindow(QMainWindow):
|
||||
self.brand_bar.set_theme(theme)
|
||||
self.toolbar.set_theme(theme)
|
||||
self.arrow_panel.set_theme(theme)
|
||||
self.mesh3d_panel.set_theme(theme)
|
||||
self._refresh_status_chip()
|
||||
self._refresh_status_rail()
|
||||
self._refresh_record_state()
|
||||
@@ -1652,6 +1663,14 @@ class StudioMainWindow(QMainWindow):
|
||||
return
|
||||
view.submit_packet(packet)
|
||||
|
||||
def _on_scene3d_settings(self) -> None:
|
||||
"""Open the 3D settings dialog — or say why there is nothing to configure."""
|
||||
if not self.mesh3d_panel.open_settings():
|
||||
self._notify(
|
||||
self.mesh3d_panel.message.text().strip()
|
||||
or t("panels.mesh3d_no_scene")
|
||||
)
|
||||
|
||||
def _on_sensor_type_changed(self, sensor_type: str) -> None:
|
||||
self.prefs["sensor_type"] = str(sensor_type)
|
||||
self.prefs["config_name"] = self.sensor_picker.config_name()
|
||||
|
||||
@@ -136,21 +136,34 @@ class ThreeDPanel(QFrame):
|
||||
self._effective_scale: Optional[RenderStats] = None
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(8, 6, 8, 8)
|
||||
root.setContentsMargins(8, 6, 8, 6)
|
||||
root.setSpacing(6)
|
||||
|
||||
# ------------------------------------------------------------------ controls
|
||||
# Every *setting* lives in one block that the pane itself never shows. What the
|
||||
# pane is for is the picture, and a column of form fields beside it was what left
|
||||
# the 3D view a corner of its own splitter slot. The block is handed to
|
||||
# ``Scene3dSettingsDialog`` — opened from 视图 ▸ 三维显示设置 or from the gear on
|
||||
# the pane's header — and the widget attributes below stay on this object, so the
|
||||
# rest of the application (and its tests) reach the controls the same way as
|
||||
# before.
|
||||
self.controls_widget = QWidget(self)
|
||||
self.controls_widget.setObjectName("scene3dControls")
|
||||
self.controls_widget.setVisible(False)
|
||||
controls = QVBoxLayout(self.controls_widget)
|
||||
controls.setContentsMargins(0, 0, 0, 0)
|
||||
controls.setSpacing(6)
|
||||
|
||||
self.enable_check = QCheckBox()
|
||||
self.enable_check.toggled.connect(self._on_enabled_toggled)
|
||||
root.addWidget(self.enable_check)
|
||||
controls.addWidget(self.enable_check)
|
||||
|
||||
form = QFormLayout()
|
||||
form.setContentsMargins(0, 0, 0, 0)
|
||||
self.preset_label = QLabel()
|
||||
self.preset_combo = QComboBox()
|
||||
# The pane is 240 px wide at its narrowest (``MIN_PANEL_WIDTHS["mesh3d"]``),
|
||||
# so the fields are compact and the panel's own minimum stays below that.
|
||||
# Without this the layout's minimum is set by two spin boxes side by side,
|
||||
# the splitter cannot honour the panel width, and the picture is squeezed out.
|
||||
# The dialog is a dialog: the fields are comfortable there, and the pane no
|
||||
# longer has to be at least as wide as two spin boxes side by side.
|
||||
self.preset_combo.setMaximumWidth(FIELD_WIDTH)
|
||||
self.preset_combo.currentIndexChanged.connect(self._on_preset_chosen)
|
||||
form.addRow(self.preset_label, self.preset_combo)
|
||||
@@ -192,9 +205,7 @@ class ThreeDPanel(QFrame):
|
||||
form.addRow(self.scale_label, self.scale_combo)
|
||||
|
||||
# The two bounds get their own row rather than sharing the mode's: three
|
||||
# controls side by side made this row the widest thing in the pane, and a pane
|
||||
# whose content demands more than ``MIN_PANEL_WIDTHS["mesh3d"]`` clips its own
|
||||
# controls when the operator drags the splitter to the declared minimum.
|
||||
# controls side by side made this row the widest thing in the pane.
|
||||
self.range_label = QLabel()
|
||||
self.lower_spin = QDoubleSpinBox()
|
||||
self.lower_spin.setRange(-1e6, 1e6)
|
||||
@@ -221,26 +232,39 @@ class ThreeDPanel(QFrame):
|
||||
self.fps_combo.setMaximumWidth(FIELD_WIDTH)
|
||||
self.fps_combo.currentIndexChanged.connect(self._emit_settings)
|
||||
form.addRow(self.fps_label, self.fps_combo)
|
||||
root.addLayout(form)
|
||||
controls.addLayout(form)
|
||||
|
||||
buttons = QHBoxLayout()
|
||||
self.reset_button = QPushButton()
|
||||
self.reset_button.clicked.connect(self.resetRequested.emit)
|
||||
buttons.addWidget(self.reset_button)
|
||||
buttons.addStretch(1)
|
||||
root.addLayout(buttons)
|
||||
controls.addLayout(buttons)
|
||||
|
||||
# Display-side statistics: an engineering readout, not something to read while
|
||||
# measuring, so it belongs with the settings rather than under the picture.
|
||||
self.stats_label = QLabel()
|
||||
self.stats_label.setObjectName("panelMeta")
|
||||
self.stats_label.setWordWrap(True)
|
||||
self.stats_label.setMinimumWidth(1)
|
||||
controls.addWidget(self.stats_label)
|
||||
controls.addStretch(1)
|
||||
|
||||
# -------------------------------------------------------------------- legend
|
||||
# What is on screen, in three short pieces: the ramp the shader used, the numbers
|
||||
# it used it for, and which source produced the field. Reading the picture needs
|
||||
# the first two, so they stay; nothing here is a setting.
|
||||
self.scale_bar = ColorScaleBar()
|
||||
root.addWidget(self.scale_bar)
|
||||
|
||||
legend_row = QHBoxLayout()
|
||||
legend_row.setContentsMargins(0, 0, 0, 0)
|
||||
legend_row.setSpacing(8)
|
||||
self.legend_label = QLabel()
|
||||
self.legend_label.setObjectName("panelMeta")
|
||||
self.legend_label.setWordWrap(True)
|
||||
self.legend_label.setMinimumWidth(1)
|
||||
root.addWidget(self.legend_label)
|
||||
|
||||
# The ramp the shader used, under the numbers it used it for. Text alone could
|
||||
# not express an *automatic* range, and the disabled spin boxes next to it held
|
||||
# values that were not being drawn with.
|
||||
self.scale_bar = ColorScaleBar()
|
||||
root.addWidget(self.scale_bar)
|
||||
legend_row.addWidget(self.legend_label, 3)
|
||||
|
||||
#: Which *source* the field on screen came from. A separate line from the
|
||||
#: scene state, because they answer different questions: the state says whether
|
||||
@@ -250,7 +274,9 @@ class ThreeDPanel(QFrame):
|
||||
self.source_label.setObjectName("panelMeta")
|
||||
self.source_label.setWordWrap(True)
|
||||
self.source_label.setMinimumWidth(1)
|
||||
root.addWidget(self.source_label)
|
||||
self.source_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
legend_row.addWidget(self.source_label, 2)
|
||||
root.addLayout(legend_row)
|
||||
|
||||
self.state_label = QLabel()
|
||||
self.state_label.setObjectName("hintText")
|
||||
@@ -258,13 +284,6 @@ class ThreeDPanel(QFrame):
|
||||
self.state_label.setMinimumWidth(1)
|
||||
root.addWidget(self.state_label)
|
||||
|
||||
self.stats_label = QLabel()
|
||||
self.stats_label.setObjectName("panelMeta")
|
||||
self.stats_label.setWordWrap(True)
|
||||
self.stats_label.setMinimumWidth(1)
|
||||
root.addWidget(self.stats_label)
|
||||
|
||||
root.addStretch(1)
|
||||
self.retranslate()
|
||||
self.set_scene(None)
|
||||
self.set_state("disabled")
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""``Scene3dSettingsDialog``: the 3D pane's settings, out of the picture's way.
|
||||
|
||||
The pane is for looking at a picture. Every *setting* the three-dimensional view has —
|
||||
whether it is on, which camera preset, the housing, the exaggeration, the colour range
|
||||
and the display rate — therefore lives in one dialog, opened from 视图 ▸ 三维显示设置 or
|
||||
from the gear on the pane's header. What stays under the picture is only what *reading*
|
||||
it needs: the colour ramp, the numbers it was drawn with, and which source produced the
|
||||
field (see ``ThreeDPanel``).
|
||||
|
||||
The dialog owns no state of its own: it hosts the panel's control block
|
||||
(``ThreeDPanel.controls_widget``) and closes with it untouched. That keeps one set of
|
||||
widgets — and one set of signals — whether or not the dialog has ever been opened.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtWidgets import (
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QLabel,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
if TYPE_CHECKING: # the panel is passed in; only its interfaces are used here
|
||||
from orisys_studio.scene3d.panel import ThreeDPanel
|
||||
|
||||
|
||||
class Scene3dSettingsDialog(QDialog):
|
||||
"""A modeless home for the 3D pane's controls."""
|
||||
|
||||
def __init__(
|
||||
self, panel: "ThreeDPanel", parent: Optional[QWidget] = None
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("scene3dSettings")
|
||||
self.setWindowTitle(t("scene3d.settings_title"))
|
||||
# Modeless on purpose: the operator tunes the colour range while watching the
|
||||
# picture change, which a modal dialog over the pane would prevent.
|
||||
self.setModal(False)
|
||||
self._panel = panel
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(16, 14, 16, 12)
|
||||
root.setSpacing(10)
|
||||
|
||||
self.hint = QLabel()
|
||||
self.hint.setObjectName("hintText")
|
||||
self.hint.setWordWrap(True)
|
||||
root.addWidget(self.hint)
|
||||
|
||||
# Adding the block to this layout reparents it: the controls now live here, and
|
||||
# the pane keeps only its legend. They are the *same* widgets the panel holds
|
||||
# references to, so nothing about the settings path changes with the dialog.
|
||||
root.addWidget(panel.controls_widget)
|
||||
panel.controls_widget.setVisible(True)
|
||||
|
||||
self.buttons = QDialogButtonBox(QDialogButtonBox.Close)
|
||||
self.buttons.rejected.connect(self.close)
|
||||
self.buttons.accepted.connect(self.close)
|
||||
root.addWidget(self.buttons)
|
||||
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.setWindowTitle(t("scene3d.settings_title"))
|
||||
self.hint.setText(t("scene3d.settings_hint"))
|
||||
|
||||
# ------------------------------------------------------------------ Qt events
|
||||
def closeEvent(self, event) -> None: # noqa: N802 - Qt naming
|
||||
"""Closing *hides* the dialog: the controls have to stay alive in it."""
|
||||
self.hide()
|
||||
event.ignore()
|
||||
|
||||
def keyPressEvent(self, event) -> None: # noqa: N802 - Qt naming
|
||||
"""Esc closes the dialog, as it does everywhere else in this application."""
|
||||
if event.key() == Qt.Key_Escape:
|
||||
self.hide()
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
@@ -117,6 +117,9 @@ class Scene3DView(QWidget):
|
||||
self._device_verified = False
|
||||
#: Frames the producer did not even build (a frame was already waiting).
|
||||
self._skipped_snapshots = 0
|
||||
#: The settings dialog, built on first use. Held (never deleted) because the
|
||||
#: control widgets live in it once it has been opened.
|
||||
self._settings_dialog = None
|
||||
|
||||
self._layout = QVBoxLayout(self)
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -425,6 +428,29 @@ class Scene3DView(QWidget):
|
||||
if self._panel is not None:
|
||||
self._panel.set_capabilities(self.capabilities())
|
||||
|
||||
# ------------------------------------------------------------------ settings
|
||||
def open_settings(self) -> bool:
|
||||
"""Show the settings dialog. ``False`` when there is nothing to configure.
|
||||
|
||||
The dialog hosts the panel's control block, so it is built here (where the panel
|
||||
is) and only once: a second open reuses it, and the controls' signals were wired
|
||||
at construction either way.
|
||||
"""
|
||||
if self._panel is None:
|
||||
return False
|
||||
if self._settings_dialog is None:
|
||||
from orisys_studio.scene3d.settings_dialog import Scene3dSettingsDialog
|
||||
|
||||
self._settings_dialog = Scene3dSettingsDialog(self._panel, self)
|
||||
self._settings_dialog.show()
|
||||
self._settings_dialog.raise_()
|
||||
self._settings_dialog.activateWindow()
|
||||
return True
|
||||
|
||||
def settings_dialog(self):
|
||||
"""The dialog, if it has been opened (for tests and for the window)."""
|
||||
return self._settings_dialog
|
||||
|
||||
def begin_session(self) -> None:
|
||||
"""The application is starting an acquisition session for this pane.
|
||||
|
||||
@@ -636,6 +662,10 @@ class Scene3DView(QWidget):
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release the GPU resources, stop the worker. Idempotent."""
|
||||
if self._settings_dialog is not None:
|
||||
# Hidden, not deleted: the control widgets live in it, and the panel keeps
|
||||
# referring to them (a session's display settings are read back from there).
|
||||
self._settings_dialog.hide()
|
||||
if self._coordinator is not None:
|
||||
self._coordinator.close()
|
||||
if self._widget is not None:
|
||||
|
||||
@@ -20,7 +20,7 @@ from PyQt5.QtGui import QColor, QIcon, QPainter, QPen, QPixmap
|
||||
from orisys_app_shared.theme import color
|
||||
|
||||
#: Names of the icons this module can paint.
|
||||
NAMES = ("zoom_fit", "box_zoom", "latest", "settings")
|
||||
NAMES = ("zoom_fit", "box_zoom", "latest", "settings", "reset_view")
|
||||
|
||||
ICON_SIZE = 16
|
||||
_PEN_WIDTH = 1.4
|
||||
@@ -93,11 +93,49 @@ def _paint_settings(painter: QPainter, size: int) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _paint_reset_view(painter: QPainter, size: int) -> None:
|
||||
"""A circular arrow: put the view back the way the asset framed it."""
|
||||
import math
|
||||
|
||||
centre = size / 2.0
|
||||
radius = size * 0.30
|
||||
# Three quarters of a circle, drawn as short chords: Qt's arc API needs a rect and
|
||||
# sixteenths of a degree, and the gap is what makes it read as "again".
|
||||
start = -0.30 * math.pi
|
||||
end = 1.45 * math.pi
|
||||
steps = 18
|
||||
previous = None
|
||||
for index in range(steps + 1):
|
||||
angle = start + (end - start) * index / steps
|
||||
point = (
|
||||
centre + radius * math.cos(angle),
|
||||
centre + radius * math.sin(angle),
|
||||
)
|
||||
if previous is not None:
|
||||
painter.drawLine(
|
||||
int(round(previous[0])), int(round(previous[1])),
|
||||
int(round(point[0])), int(round(point[1])),
|
||||
)
|
||||
previous = point
|
||||
# The head sits at the open end of the arc, pointing the way the arrow runs.
|
||||
head = size * 0.20
|
||||
tip_x, tip_y = previous
|
||||
painter.drawLine(
|
||||
int(round(tip_x)), int(round(tip_y)),
|
||||
int(round(tip_x - head)), int(round(tip_y - head * 0.2)),
|
||||
)
|
||||
painter.drawLine(
|
||||
int(round(tip_x)), int(round(tip_y)),
|
||||
int(round(tip_x - head * 0.2)), int(round(tip_y + head)),
|
||||
)
|
||||
|
||||
|
||||
_PAINTERS = {
|
||||
"zoom_fit": _paint_zoom_fit,
|
||||
"box_zoom": _paint_box_zoom,
|
||||
"latest": _paint_latest,
|
||||
"settings": _paint_settings,
|
||||
"reset_view": _paint_reset_view,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -16,8 +16,16 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Callable, Optional
|
||||
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
from PyQt5.QtWidgets import QFrame, QLabel, QStackedLayout, QVBoxLayout, QWidget
|
||||
from PyQt5.QtCore import QSize, Qt, pyqtSignal
|
||||
from PyQt5.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QStackedLayout,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
@@ -32,6 +40,7 @@ from orisys_studio.scene3d.asset_source import (
|
||||
find_package,
|
||||
no_asset_diagnostic,
|
||||
)
|
||||
from orisys_studio.ui import icons
|
||||
|
||||
if TYPE_CHECKING: # the view imports GL bindings lazily; the type is enough here
|
||||
from orisys_app_shared.acquisition.geometry import DeviceGeometry
|
||||
@@ -71,9 +80,12 @@ class Mesh3dPanel(QFrame):
|
||||
#: from this, so no packet is adapted while the pane is off.
|
||||
enabledChanged = pyqtSignal(bool)
|
||||
|
||||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||
def __init__(
|
||||
self, parent: Optional[QWidget] = None, *, theme: str = "dark"
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("contentPanel")
|
||||
self._theme = str(theme)
|
||||
self._view: Optional["Scene3DView"] = None
|
||||
self._loader: Optional[SceneAssetLoader] = None
|
||||
self._source: Optional[SceneSource] = None
|
||||
@@ -99,9 +111,31 @@ class Mesh3dPanel(QFrame):
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
# The header carries the title and the two actions that belong to *looking*:
|
||||
# put the view back to the asset's own framing, and open the settings. Everything
|
||||
# else the pane can be told lives in that dialog, so the picture is the pane.
|
||||
header = QHBoxLayout()
|
||||
header.setContentsMargins(8, 4, 8, 0)
|
||||
header.setSpacing(4)
|
||||
self.title = QLabel()
|
||||
self.title.setObjectName("panelTitle")
|
||||
root.addWidget(self.title)
|
||||
header.addWidget(self.title)
|
||||
header.addStretch(1)
|
||||
|
||||
self.btn_reset_view = QPushButton()
|
||||
self.btn_reset_view.setProperty("compact", "true")
|
||||
self.btn_reset_view.setProperty("iconOnly", "true")
|
||||
self.btn_reset_view.setCursor(Qt.PointingHandCursor)
|
||||
self.btn_reset_view.clicked.connect(self.reset_view)
|
||||
header.addWidget(self.btn_reset_view)
|
||||
|
||||
self.btn_settings = QPushButton()
|
||||
self.btn_settings.setProperty("compact", "true")
|
||||
self.btn_settings.setProperty("iconOnly", "true")
|
||||
self.btn_settings.setCursor(Qt.PointingHandCursor)
|
||||
self.btn_settings.clicked.connect(self.open_settings)
|
||||
header.addWidget(self.btn_settings)
|
||||
root.addLayout(header)
|
||||
|
||||
self._stack = QStackedLayout()
|
||||
root.addLayout(self._stack, 1)
|
||||
@@ -112,8 +146,39 @@ class Mesh3dPanel(QFrame):
|
||||
self.message.setWordWrap(True)
|
||||
self._stack.addWidget(self.message)
|
||||
|
||||
self._apply_icons()
|
||||
self.retranslate()
|
||||
|
||||
# ------------------------------------------------------------------ actions
|
||||
def open_settings(self) -> bool:
|
||||
"""Show the 3D settings dialog. ``False`` when there is nothing to configure.
|
||||
|
||||
The pane builds the view on first use, so pressing the gear is also what makes
|
||||
the 3D view exist — the same path the View menu's 「三维网格」 takes.
|
||||
"""
|
||||
if self._view is None:
|
||||
self.ensure_view()
|
||||
if self._view is None:
|
||||
return False
|
||||
return bool(self._view.open_settings())
|
||||
|
||||
def reset_view(self) -> None:
|
||||
"""Go back to the framing the asset itself calls its own."""
|
||||
if self._view is not None:
|
||||
self._view.reset_view()
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
"""Re-tint the header icons: they are painted from the theme's own colours."""
|
||||
self._theme = str(theme)
|
||||
self._apply_icons()
|
||||
|
||||
def _apply_icons(self) -> None:
|
||||
size = QSize(icons.ICON_SIZE, icons.ICON_SIZE)
|
||||
self.btn_reset_view.setIcon(icons.icon("reset_view", self._theme))
|
||||
self.btn_reset_view.setIconSize(size)
|
||||
self.btn_settings.setIcon(icons.icon("settings", self._theme))
|
||||
self.btn_settings.setIconSize(size)
|
||||
|
||||
# ------------------------------------------------------------------ scene
|
||||
def set_sensor(
|
||||
self,
|
||||
@@ -400,5 +465,7 @@ class Mesh3dPanel(QFrame):
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.title.setText(t("panels.mesh3d"))
|
||||
self.btn_reset_view.setToolTip(t("panels.mesh3d_reset_tip"))
|
||||
self.btn_settings.setToolTip(t("panels.mesh3d_settings_tip"))
|
||||
self.setToolTip(t("panels.mesh3d_empty_tip"))
|
||||
self._render_message()
|
||||
|
||||
@@ -208,14 +208,16 @@ class ViewLayoutController(QObject):
|
||||
self._cal_panel.setMinimumWidth(self._min_panel_widths["cal"])
|
||||
self._set_check(self._actions.finger_3d if self._actions else None, show_3d, enabled=is_finger)
|
||||
self._set_check(self._actions.cal_panel if self._actions else None, show_cal)
|
||||
# Panel weights: the charts are the widest panel in every view, and the 2D
|
||||
# panel is deliberately the smaller one.
|
||||
# Panel weights: the charts are the widest panel in a view without 3D, the 3D
|
||||
# pane is the widest one when it is on (it is the subject, not an inset), and
|
||||
# the 2D panel is deliberately the smaller one.
|
||||
arrow_weight = 3
|
||||
plots_weight = 4
|
||||
mesh_weight = 5
|
||||
for i in range(self._splitter.count()):
|
||||
widget = self._splitter.widget(i)
|
||||
if widget is self._mesh_panel:
|
||||
self._splitter.setStretchFactor(i, 2 if show_3d else 0)
|
||||
self._splitter.setStretchFactor(i, mesh_weight if show_3d else 0)
|
||||
elif widget is self._image_panel:
|
||||
self._splitter.setStretchFactor(i, arrow_weight if show_arrow else 0)
|
||||
elif widget is self._charts:
|
||||
|
||||
@@ -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"})
|
||||
|
||||
|
||||
@@ -147,6 +147,112 @@ def _run_scene(window, app, scene: str):
|
||||
app.processEvents()
|
||||
|
||||
|
||||
def _flow_scene(window, app, *, frames: int = 240) -> None:
|
||||
"""Drive a synthetic session whose frames carry a real optical-flow field.
|
||||
|
||||
The 3D pane is coloured by the frame's *own* flow (see
|
||||
``orisys_studio.scene3d.adapters``): a packet that does not state one is refused,
|
||||
so a picture with colour needs packets that do. This is the scene the interface is
|
||||
reviewed from — the real widgets, the real renderer, the real adapter, and a field
|
||||
shaped like a pressed fingertip (strongest at the centre, fading to the rim).
|
||||
"""
|
||||
import time as _time
|
||||
|
||||
import numpy as np
|
||||
from orisys_app_shared.acquisition.sdk_worker import SdkPacket
|
||||
|
||||
from ui_check import _StuckWorker
|
||||
|
||||
stuck = _StuckWorker()
|
||||
window._ensure_workers = lambda: None
|
||||
window._box_worker = stuck
|
||||
window._finger_worker = stuck
|
||||
window.toolbar.btn_run.setChecked(True)
|
||||
for _ in range(4):
|
||||
app.processEvents()
|
||||
|
||||
# The unrolled finger domain: 280x300, which is the grid the scene's UV island
|
||||
# uses. The flow is already unrolled, so the adapter is given no remap.
|
||||
height, width = 300, 280
|
||||
grid_y, grid_x = np.mgrid[0:height, 0:width].astype(np.float32)
|
||||
centre_y, centre_x = height * 0.52, width / 2.0
|
||||
dy, dx = grid_y - centre_y, grid_x - centre_x
|
||||
radius = np.sqrt(dx * dx + dy * dy) + 1e-3
|
||||
profile = np.exp(-((radius / (width * 0.30)) ** 2)).astype(np.float32)
|
||||
flow = np.stack(
|
||||
[dx / radius * profile * 7.0, dy / radius * profile * 7.0], axis=-1
|
||||
).astype(np.float32)
|
||||
# A recognisable camera image as well, so the 2D panel is not black next to it.
|
||||
frame = np.zeros((height, width, 3), np.uint8)
|
||||
frame[..., 1] = (40.0 + profile * 150.0).astype(np.uint8)
|
||||
frame[..., 2] = (profile * 90.0).astype(np.uint8)
|
||||
|
||||
for index in range(frames):
|
||||
# The forces move: a flat line reads as "the demo is not running", and the
|
||||
# charts are half of what the video has to show.
|
||||
phase = index / 30.0
|
||||
# A period of a couple of seconds, so the trace visibly moves inside the chart's
|
||||
# rolling window instead of looking like a slow ramp.
|
||||
normal = 3.4 + 2.4 * math.sin(phase * 2.4)
|
||||
shear_x = 1.6 * math.sin(phase * 3.1 + 0.4)
|
||||
shear_y = -1.3 * math.cos(phase * 2.7)
|
||||
stuck.emit(
|
||||
SdkPacket(
|
||||
raw_frame=frame,
|
||||
processed_frame=frame,
|
||||
flow=flow,
|
||||
timestamp=1000.0 + index / 30.0,
|
||||
mono_t=float(index) / 30.0,
|
||||
fps=30.0,
|
||||
process_ms=6.0,
|
||||
capture_fps=30.0,
|
||||
session_id=1,
|
||||
frame_id=index,
|
||||
valid=True,
|
||||
raw_available=True,
|
||||
normal_force=normal,
|
||||
shear_x=shear_x,
|
||||
shear_y=shear_y,
|
||||
normal_force_raw=9.0 + index * 0.1,
|
||||
shear_x_raw=0.4,
|
||||
shear_y_raw=-0.4,
|
||||
force_unit="N",
|
||||
force_calibrated=True,
|
||||
calibrated_axes=("fn", "fx", "fy"),
|
||||
flow_valid=True,
|
||||
flow_frame_id=index,
|
||||
depth_valid=None,
|
||||
depth_frame_id=None,
|
||||
forces_valid=True,
|
||||
)
|
||||
)
|
||||
# The window's own presenter runs *while* the frames arrive, the way it does in
|
||||
# a session: the charts then hold a real time series instead of one sample.
|
||||
if index % 4 == 0:
|
||||
window._on_run_tick()
|
||||
app.processEvents()
|
||||
|
||||
# The 3D picture is composed on the view's own worker and taken one finished frame
|
||||
# at a time by the run timer, so pump both until something has actually been shown.
|
||||
view = window.mesh3d_panel.view
|
||||
deadline = _time.perf_counter() + 20.0
|
||||
while _time.perf_counter() < deadline:
|
||||
window._on_run_tick()
|
||||
app.processEvents()
|
||||
if view is not None and view.render_totals().get("shown", 0) > 0:
|
||||
break
|
||||
_time.sleep(0.01)
|
||||
window._run_timer.stop()
|
||||
# The overview strip is rebuilt at most once a second (it is a whole-session
|
||||
# decimation) and this scene fills eight seconds of session in well under that, so
|
||||
# it is rebuilt once here — the same thing ``_demo_data`` does for the same reason.
|
||||
window.force_plots._overview_dirty = True
|
||||
window.force_plots._last_overview_at = 0.0
|
||||
window.force_plots._sync_overview()
|
||||
for _ in range(6):
|
||||
app.processEvents()
|
||||
|
||||
|
||||
def _demo_data(window) -> None:
|
||||
"""Fill the plots, the calibration table and the arrow canvas."""
|
||||
from orisys_app_shared.force.session import SessionSample, SessionStore
|
||||
@@ -242,14 +348,16 @@ def main() -> int:
|
||||
parser.add_argument("--demo", action="store_true", help="inject demo plot/table data")
|
||||
parser.add_argument(
|
||||
"--scene",
|
||||
choices=("idle", "connecting", "recording", "replay"),
|
||||
choices=("idle", "connecting", "recording", "replay", "flow3d"),
|
||||
default="idle",
|
||||
help=(
|
||||
"connecting renders the Stop/cancel state of a hung device open; "
|
||||
"recording renders the record strip of a running recording "
|
||||
"(mocked stats: no file is written, no device is opened); "
|
||||
"replay writes one short synthetic recording and opens it in the "
|
||||
"replay view so the progress bar can be reviewed"
|
||||
"replay view so the progress bar can be reviewed; "
|
||||
"flow3d drives packets that carry a real optical-flow field, which is "
|
||||
"what colours the 3D pane (use with --sensor finger --panels 3d)"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
@@ -317,7 +425,10 @@ def main() -> int:
|
||||
if args.scene == "replay":
|
||||
_run_scene(window, app, "replay")
|
||||
|
||||
if args.demo and args.scene != "replay":
|
||||
if args.scene == "flow3d":
|
||||
_flow_scene(window, app)
|
||||
|
||||
if args.demo and args.scene not in ("replay", "flow3d"):
|
||||
_demo_data(window)
|
||||
for _ in range(4):
|
||||
app.processEvents()
|
||||
|
||||
@@ -636,10 +636,15 @@ shared 全部 **262 项**。`ui_check.py` **566 项检查 0 失败**(新增 "3
|
||||
|
||||
---
|
||||
|
||||
1. **阶段 8 第二批(画布布局)**:2D/3D 页签容器(默认页签切换、可选并排对照)、参数收进"显示设置"抽屉、
|
||||
常驻只留视角/复位/外壳/紧凑图例、窄窗口回退、三维画布在 1210×800 下 ≥320 px 高、曲线"可见区间自动/
|
||||
固定量程"开关、调试统计移入诊断抽屉。为什么留后:它要动 splitter 的槽位与 `ui_check` 的多处布局断言,
|
||||
并且需要与用户一起看截图定稿(报告里的建议是"传感器视图 + 力曲线两栏")。
|
||||
1. **阶段 8 第二批(画布布局)——部分已做(2026-09-15)**:
|
||||
*已做*:三维面板本身只留画面 + 一行紧凑图例(色条 + 来源/状态)+ 两个图标(复位视角、齿轮),
|
||||
全部**配置**(总开关、观察方向、显示外壳、放大形变与增益、色标模式与范围、显示帧率、显示统计)
|
||||
搬进 `视图 ▸ 三维显示设置…` 和面板齿轮打开的非模态对话框(`scene3d/settings_dialog.py`,
|
||||
它承载的还是面板自己那套控件,所以设置通路没有分叉);live/replay 预设把三维设为**最宽**面板
|
||||
(620 : 300 : 520),三维最小宽度 260 → 200(没有表单要放,画面才是重点)。
|
||||
*仍未做*:2D/3D 页签容器与"并排对照"、窄窗口回退策略、曲线"可见区间自动/固定量程"开关、
|
||||
调试统计移入独立诊断抽屉(统计现在在设置对话框底部);阶段 9 的真机/板卡门禁同前。
|
||||
为什么仍留后:页签容器要动 splitter 的槽位与 `ui_check` 的多处布局断言,需要与用户一起看截图定稿。
|
||||
2. **阶段 9 门禁运行**:GUI 模式 **2 小时**长时(`soak_scene.py --minutes 120 --fps 30`)——本阶段已跑完
|
||||
10 分钟的一份(见验收表格:增长 4.83 MB、尾段 0.371 MB/min,外推 2 小时约 45 MB,低于 64 MB 上限,但
|
||||
**外推不是门禁**,2 小时整仍需实跑);以及 Jetson Orin Nano / QCS6490 上的 `probe_gl.py --json`、
|
||||
|
||||
Reference in New Issue
Block a user