From 566f1bf39069216042140e68a9c8c3991e3577b5 Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Mon, 19 Oct 2026 10:35:04 +0800 Subject: [PATCH] =?UTF-8?q?autocal:=20=E6=B5=8B=E8=AF=95=E5=BA=8F=E5=88=97?= =?UTF-8?q?=E9=A1=B5=E4=B8=8E=E5=87=A0=E4=BD=95=E4=B8=8E=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E9=A1=B5=E5=85=B1=E7=94=A8=202D=20=E5=B9=B3=E9=9D=A2=E5=9B=BE?= =?UTF-8?q?=E5=92=8C=203D=20=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FingerPanel 新增 release_views/restore_views,把表面选点图和三维预览 整体借出,不复制,已拾取的点、传感器包和 GL 视图仍是同一份状态 - SequencePanel 用共用视图替换“仿真阶段实现”的占位框(adopt_views/release_views) - 主窗口按当前标签页把两个视图挂到序列页或几何页 - 新增 tests/test_shared_views.py - 序列页借用视图时给两个框固定最小高度并隐藏长图例,修复 3D 视图下按钮与勾选框叠压 --- .../src/orisys_autocal/ui/sensor_panel.py | 41 ++++++++++++++ .../src/orisys_autocal/ui/sequence_panel.py | 53 ++++++++++++++++++ apps/autocal/src/orisys_autocal/ui/window.py | 15 +++++ apps/autocal/tests/test_shared_views.py | 56 +++++++++++++++++++ 4 files changed, 165 insertions(+) create mode 100644 apps/autocal/tests/test_shared_views.py diff --git a/apps/autocal/src/orisys_autocal/ui/sensor_panel.py b/apps/autocal/src/orisys_autocal/ui/sensor_panel.py index 2551a29..f1489b0 100644 --- a/apps/autocal/src/orisys_autocal/ui/sensor_panel.py +++ b/apps/autocal/src/orisys_autocal/ui/sensor_panel.py @@ -261,6 +261,9 @@ class FingerPanel(QGroupBox): body = QHBoxLayout() body.setSpacing(10) + self._body = body + #: True while the 2D picker and 3D preview are shown on the sequence page. + self._views_lent = False picker_box = self._build_picker() readout_box = self._build_readouts() preview_box = self._build_preview() @@ -279,6 +282,44 @@ class FingerPanel(QGroupBox): self.set_tool_xy(*tool_xy_mm) self.retranslate() + # ------------------------------------------------------------- shared views + + def release_views(self) -> tuple[QWidget, QWidget]: + """Hand the 2D picker and the 3D preview to another page. + + The sequence page needs the same two views, and a widget has one parent, so + they are *moved* rather than copied: one picked point, one bundle and one GL + context stay the single source of truth. Whoever takes them must give them + back through :meth:`restore_views`. + """ + if not self._views_lent: + self._body.removeWidget(self._picker_box) + self._body.removeWidget(self._preview_box) + self._views_lent = True + # Short on height elsewhere, the colour key goes to the tooltip only. + self.legend_label.setVisible(False) + return self._picker_box, self._preview_box + + def restore_views(self) -> None: + """Put the two views back in this page, after the readouts between them.""" + if not self._views_lent: + return + self._body.insertWidget(0, self._picker_box, 2) + self._body.insertWidget(2, self._preview_box, 6) + self.legend_label.setVisible(True) + for box in (self._picker_box, self._preview_box): + layout = box.layout() + if layout is not None: + layout.invalidate() + layout.activate() + self._picker_box.show() + self._preview_box.show() + self._views_lent = False + + @property + def views_lent(self) -> bool: + return self._views_lent + # ------------------------------------------------------------------ widgets def _build_toolbar(self, bundle_path: str) -> QWidget: diff --git a/apps/autocal/src/orisys_autocal/ui/sequence_panel.py b/apps/autocal/src/orisys_autocal/ui/sequence_panel.py index e42761f..ed17a34 100644 --- a/apps/autocal/src/orisys_autocal/ui/sequence_panel.py +++ b/apps/autocal/src/orisys_autocal/ui/sequence_panel.py @@ -117,6 +117,9 @@ VALIDATION_CHECKS: tuple[tuple[str, str], ...] = ( ("contact_confirmable", "sequence.check_contact"), ) +#: Height the shared 2D and 3D boxes get on this page, in pixels. +SHARED_VIEW_MIN_HEIGHT = 420 + #: Placeholder rows, so the flow shows its shape. Marked as unmeasured: a row #: with no status is a design, not a measurement. _EXAMPLE_POINTS: tuple[tuple[float, float, float], ...] = ( @@ -164,6 +167,8 @@ class SequencePanel(QGroupBox): self._check_rows: dict[str, QLabel] = {} self._summary: dict[str, Any] = {} self._dirty = False + #: The 2D picker and 3D preview, while the geometry page has lent them. + self._views: tuple[QWidget, QWidget] | None = None outer = QVBoxLayout(self) outer.setContentsMargins(10, 8, 10, 10) @@ -267,6 +272,13 @@ class SequencePanel(QGroupBox): self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter) column.addWidget(self.preview_label, 1) + # The geometry page's two views are shown here while this page is open: + # a sequence is designed against the surface and the pose, not a table. + self._view_row = QHBoxLayout() + self._view_row.setContentsMargins(0, 0, 0, 0) + self._view_row.setSpacing(8) + column.addLayout(self._view_row, 1) + self.preview_hint = QLabel(box) self.preview_hint.setObjectName("hintLabel") self.preview_hint.setWordWrap(True) @@ -274,6 +286,47 @@ class SequencePanel(QGroupBox): self._preview_box = box return box + def adopt_views(self, picker: QWidget, preview: QWidget) -> None: + """Show the shared 2D picker and 3D preview in place of the placeholder.""" + if self._views is not None: + return + self._views = (picker, preview) + self.preview_label.setVisible(False) + # An explicit floor. Left to its contents the box was allotted less height + # than its rows (a 3D view that will not shrink below 240, check boxes, + # buttons) need, and the layout then drew them on top of each other. + for widget in (picker, preview): + widget.setMinimumHeight(SHARED_VIEW_MIN_HEIGHT) + self._view_row.addWidget(picker, 2) + self._view_row.addWidget(preview, 3) + picker.show() + preview.show() + self._relayout(preview) + + def release_views(self) -> bool: + """Stop showing the shared views; the caller puts them back where they live.""" + if self._views is None: + return False + for widget in self._views: + self._view_row.removeWidget(widget) + widget.setMinimumHeight(0) + self._views = None + self.preview_label.setVisible(True) + return True + + @staticmethod + def _relayout(widget: QWidget) -> None: + """Recompute a moved widget's inner layout for its new size.""" + layout = widget.layout() + if layout is not None: + layout.invalidate() + layout.activate() + widget.updateGeometry() + + @property + def has_views(self) -> bool: + return self._views is not None + # --------------------------------------------------------------- validation def _build_validation(self) -> QWidget: diff --git a/apps/autocal/src/orisys_autocal/ui/window.py b/apps/autocal/src/orisys_autocal/ui/window.py index 6d1203a..d8a7f6e 100644 --- a/apps/autocal/src/orisys_autocal/ui/window.py +++ b/apps/autocal/src/orisys_autocal/ui/window.py @@ -328,6 +328,9 @@ class AutoCalMainWindow(QMainWindow): sensor_panel=self.calibration_panel, ) self.calibration_panel.reviewRequested.connect(self._on_review_records) + # The geometry page's 2D and 3D views are also the sequence page's. + self.workspace.pageChanged.connect(self._place_shared_views) + self._place_shared_views(self.workspace.current_key()) self.main_splitter = QSplitter(Qt.Orientation.Horizontal, body) self.main_splitter.addWidget(self.left_rail) @@ -405,6 +408,18 @@ class AutoCalMainWindow(QMainWindow): # ----------------------------------------------------------- device setup + def _place_shared_views(self, key: str) -> None: + """Show the surface picker and 3D preview on whichever of the two pages is open. + + One set of widgets, so a point picked on either page, the loaded bundle and + the simulation are the same state; only the parent changes with the tab. + """ + if key == "sequence": + picker, preview = self.finger_panel.release_views() + self.sequence_panel.adopt_views(picker, preview) + elif self.sequence_panel.release_views() or self.finger_panel.views_lent: + self.finger_panel.restore_views() + def _show_device_setup(self, tab: str | None = None) -> None: """Open the connection/bus dialog, optionally on one of its tabs.""" self.device_setup.open_for(tab) diff --git a/apps/autocal/tests/test_shared_views.py b/apps/autocal/tests/test_shared_views.py new file mode 100644 index 0000000..dca8335 --- /dev/null +++ b/apps/autocal/tests/test_shared_views.py @@ -0,0 +1,56 @@ +"""The surface picker and 3D preview are one set of widgets shared by two pages.""" + +from __future__ import annotations + + +def _inside(widget, page) -> bool: + while widget is not None: + if widget is page: + return True + widget = widget.parentWidget() + return False + + +def test_the_views_follow_the_open_tab(make_window): + window = make_window() + finger, sequence = window.finger_panel, window.sequence_panel + preview, picker = finger.preview, finger.view + + window.workspace.set_current_key("sequence") + assert _inside(preview, sequence) and _inside(picker, sequence) + assert not sequence.preview_label.isVisibleTo(sequence) + assert finger.views_lent + + window.workspace.set_current_key("finger") + assert _inside(preview, finger) and _inside(picker, finger) + assert sequence.preview_label.isVisibleTo(sequence) or not sequence.has_views + assert not finger.views_lent + + # Going back and forth never creates a second copy. + window.workspace.set_current_key("sequence") + window.workspace.set_current_key("axes") + assert finger.preview is preview and finger.view is picker + assert _inside(preview, finger) + window.close() + + +def test_a_point_picked_on_the_sequence_page_is_the_geometry_pages_point(make_window): + window = make_window() + window.workspace.set_current_key("sequence") + seen: list[tuple[int, int]] = [] + window.finger_panel.pickRequested.connect(lambda x, y: seen.append((x, y))) + window.finger_panel.view.pixelPicked.emit(10, 20) + assert seen == [(10, 20)] + window.close() + + +def test_the_sequence_page_gives_the_views_room_and_drops_the_long_legend(make_window): + window = make_window() + finger, sequence = window.finger_panel, window.sequence_panel + window.workspace.set_current_key("sequence") + assert finger.preview.parentWidget().minimumHeight() >= 400 + assert not finger.legend_label.isVisibleTo(finger.legend_label.parentWidget()) + window.workspace.set_current_key("finger") + assert finger.preview.parentWidget().minimumHeight() == 0 + assert not finger.legend_label.isHidden() + window.close()