diff --git a/apps/studio/config/locales/en_US.json b/apps/studio/config/locales/en_US.json index b8489a5..54a260f 100644 --- a/apps/studio/config/locales/en_US.json +++ b/apps/studio/config/locales/en_US.json @@ -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", diff --git a/apps/studio/config/locales/zh_CN.json b/apps/studio/config/locales/zh_CN.json index 0f48dce..e7cf350 100644 --- a/apps/studio/config/locales/zh_CN.json +++ b/apps/studio/config/locales/zh_CN.json @@ -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": "已标定", diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index c4e4103..8eb1d54 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -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() diff --git a/apps/studio/src/orisys_studio/scene3d/panel.py b/apps/studio/src/orisys_studio/scene3d/panel.py index 63308bc..bf96944 100644 --- a/apps/studio/src/orisys_studio/scene3d/panel.py +++ b/apps/studio/src/orisys_studio/scene3d/panel.py @@ -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") diff --git a/apps/studio/src/orisys_studio/scene3d/settings_dialog.py b/apps/studio/src/orisys_studio/scene3d/settings_dialog.py new file mode 100644 index 0000000..54ce822 --- /dev/null +++ b/apps/studio/src/orisys_studio/scene3d/settings_dialog.py @@ -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) diff --git a/apps/studio/src/orisys_studio/scene3d/view.py b/apps/studio/src/orisys_studio/scene3d/view.py index eaec5b8..4e3f3bf 100644 --- a/apps/studio/src/orisys_studio/scene3d/view.py +++ b/apps/studio/src/orisys_studio/scene3d/view.py @@ -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: diff --git a/apps/studio/src/orisys_studio/ui/icons.py b/apps/studio/src/orisys_studio/ui/icons.py index 9def105..2546ccb 100644 --- a/apps/studio/src/orisys_studio/ui/icons.py +++ b/apps/studio/src/orisys_studio/ui/icons.py @@ -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, } diff --git a/apps/studio/src/orisys_studio/ui/mesh3d_panel.py b/apps/studio/src/orisys_studio/ui/mesh3d_panel.py index 08ef087..8a721c1 100644 --- a/apps/studio/src/orisys_studio/ui/mesh3d_panel.py +++ b/apps/studio/src/orisys_studio/ui/mesh3d_panel.py @@ -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() diff --git a/apps/studio/src/orisys_studio/view_layout.py b/apps/studio/src/orisys_studio/view_layout.py index c26daeb..72216a7 100644 --- a/apps/studio/src/orisys_studio/view_layout.py +++ b/apps/studio/src/orisys_studio/view_layout.py @@ -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: diff --git a/apps/studio/tests/test_scene3d_pane_ui.py b/apps/studio/tests/test_scene3d_pane_ui.py new file mode 100644 index 0000000..e0a52bb --- /dev/null +++ b/apps/studio/tests/test_scene3d_pane_ui.py @@ -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() diff --git a/apps/studio/tests/test_view_layout.py b/apps/studio/tests/test_view_layout.py index 472a3d4..dd30d30 100644 --- a/apps/studio/tests/test_view_layout.py +++ b/apps/studio/tests/test_view_layout.py @@ -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"}) diff --git a/apps/studio/tools/ui_snapshot.py b/apps/studio/tools/ui_snapshot.py index 545a0cb..1785c43 100644 --- a/apps/studio/tools/ui_snapshot.py +++ b/apps/studio/tools/ui_snapshot.py @@ -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() diff --git a/docs/architecture/studio-3d-stages.md b/docs/architecture/studio-3d-stages.md index b41d997..2f3dc1a 100644 --- a/docs/architecture/studio-3d-stages.md +++ b/docs/architecture/studio-3d-stages.md @@ -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`、