autocal: 测试序列页与几何与安装页共用 2D 平面图和 3D 预览

- FingerPanel 新增 release_views/restore_views,把表面选点图和三维预览
  整体借出,不复制,已拾取的点、传感器包和 GL 视图仍是同一份状态
- SequencePanel 用共用视图替换“仿真阶段实现”的占位框(adopt_views/release_views)
- 主窗口按当前标签页把两个视图挂到序列页或几何页
- 新增 tests/test_shared_views.py
- 序列页借用视图时给两个框固定最小高度并隐藏长图例,修复 3D 视图下按钮与勾选框叠压
This commit is contained in:
ykcheungab
2026-10-19 10:35:04 +08:00
parent a225905b49
commit 566f1bf390
4 changed files with 165 additions and 0 deletions
@@ -261,6 +261,9 @@ class FingerPanel(QGroupBox):
body = QHBoxLayout() body = QHBoxLayout()
body.setSpacing(10) 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() picker_box = self._build_picker()
readout_box = self._build_readouts() readout_box = self._build_readouts()
preview_box = self._build_preview() preview_box = self._build_preview()
@@ -279,6 +282,44 @@ class FingerPanel(QGroupBox):
self.set_tool_xy(*tool_xy_mm) self.set_tool_xy(*tool_xy_mm)
self.retranslate() 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 # ------------------------------------------------------------------ widgets
def _build_toolbar(self, bundle_path: str) -> QWidget: def _build_toolbar(self, bundle_path: str) -> QWidget:
@@ -117,6 +117,9 @@ VALIDATION_CHECKS: tuple[tuple[str, str], ...] = (
("contact_confirmable", "sequence.check_contact"), ("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 #: Placeholder rows, so the flow shows its shape. Marked as unmeasured: a row
#: with no status is a design, not a measurement. #: with no status is a design, not a measurement.
_EXAMPLE_POINTS: tuple[tuple[float, float, float], ...] = ( _EXAMPLE_POINTS: tuple[tuple[float, float, float], ...] = (
@@ -164,6 +167,8 @@ class SequencePanel(QGroupBox):
self._check_rows: dict[str, QLabel] = {} self._check_rows: dict[str, QLabel] = {}
self._summary: dict[str, Any] = {} self._summary: dict[str, Any] = {}
self._dirty = False 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 = QVBoxLayout(self)
outer.setContentsMargins(10, 8, 10, 10) outer.setContentsMargins(10, 8, 10, 10)
@@ -267,6 +272,13 @@ class SequencePanel(QGroupBox):
self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
column.addWidget(self.preview_label, 1) 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 = QLabel(box)
self.preview_hint.setObjectName("hintLabel") self.preview_hint.setObjectName("hintLabel")
self.preview_hint.setWordWrap(True) self.preview_hint.setWordWrap(True)
@@ -274,6 +286,47 @@ class SequencePanel(QGroupBox):
self._preview_box = box self._preview_box = box
return 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 # --------------------------------------------------------------- validation
def _build_validation(self) -> QWidget: def _build_validation(self) -> QWidget:
@@ -328,6 +328,9 @@ class AutoCalMainWindow(QMainWindow):
sensor_panel=self.calibration_panel, sensor_panel=self.calibration_panel,
) )
self.calibration_panel.reviewRequested.connect(self._on_review_records) 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 = QSplitter(Qt.Orientation.Horizontal, body)
self.main_splitter.addWidget(self.left_rail) self.main_splitter.addWidget(self.left_rail)
@@ -405,6 +408,18 @@ class AutoCalMainWindow(QMainWindow):
# ----------------------------------------------------------- device setup # ----------------------------------------------------------- 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: def _show_device_setup(self, tab: str | None = None) -> None:
"""Open the connection/bus dialog, optionally on one of its tabs.""" """Open the connection/bus dialog, optionally on one of its tabs."""
self.device_setup.open_for(tab) self.device_setup.open_for(tab)
+56
View File
@@ -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()