Enhance POSITION VISUALIZATION with Dynamic multiplier toggle and auto-clamping
- Replaced panel title with a status badge and multiplier toggle button. - Implemented multiplier cycling (1×, 50×, 100×) with auto-clamping logic. - Updated MonitorPlot to emit effective multiplier changes for UI updates. - Styled multiplier button in Theme.qml for visual feedback on clamping state. - Adjusted drawing logic in MonitorPlot to reflect effective multiplier in visual output.
This commit is contained in:
+36
-4
@@ -85,22 +85,30 @@ class UserWindow(QtWidgets.QMainWindow):
|
||||
|
||||
visualization = self._panel("POSITION VISUALIZATION")
|
||||
|
||||
# Replace the plain title label with a row: title | stretch | dot + state label
|
||||
# Drop the panel title; status badge replaces it on the left, mult toggle on the right
|
||||
panel_layout = visualization.layout()
|
||||
title_widget = panel_layout.takeAt(0).widget()
|
||||
old_title = panel_layout.takeAt(0).widget()
|
||||
old_title.deleteLater()
|
||||
self.status_dot = QtWidgets.QLabel()
|
||||
self.status_dot.setFixedSize(14, 14)
|
||||
self.system_status = QtWidgets.QLabel("—")
|
||||
self.system_status.setObjectName("SystemStatus")
|
||||
self.multiplier_button = QtWidgets.QPushButton("×50")
|
||||
self.multiplier_button.setObjectName("MultiplierToggle")
|
||||
self.multiplier_button.clicked.connect(self._on_toggle_multiplier)
|
||||
self.multiplier_button.setProperty("clamped", False)
|
||||
title_row = QtWidgets.QHBoxLayout()
|
||||
title_row.setSpacing(8)
|
||||
title_row.addWidget(title_widget)
|
||||
title_row.addStretch(1)
|
||||
title_row.addWidget(self.status_dot)
|
||||
title_row.addWidget(self.system_status)
|
||||
title_row.addStretch(1)
|
||||
title_row.addWidget(self.multiplier_button)
|
||||
panel_layout.insertLayout(0, title_row)
|
||||
|
||||
self.plot = MonitorPlot()
|
||||
self.plot.effective_multiplier_changed.connect(
|
||||
self._on_effective_multiplier_changed
|
||||
)
|
||||
visualization.layout().addWidget(self.plot, 1)
|
||||
body_layout.addWidget(visualization, 0, 0, 2, 1)
|
||||
|
||||
@@ -213,6 +221,7 @@ class UserWindow(QtWidgets.QMainWindow):
|
||||
"""Open a folder picker and load a CSV session."""
|
||||
default = Path.cwd() / "sessions"
|
||||
if not default.is_dir():
|
||||
"""Replace after MQTT integration"""
|
||||
default = Path.cwd() / "MASTER_SD"
|
||||
folder = QtWidgets.QFileDialog.getExistingDirectory(
|
||||
self,
|
||||
@@ -237,6 +246,29 @@ class UserWindow(QtWidgets.QMainWindow):
|
||||
self.data_source = DemoSource(self.engine)
|
||||
self.read_wafer()
|
||||
|
||||
def _on_toggle_multiplier(self) -> None:
|
||||
"""Cycle the operator's preferred multiplier: 1× → 50× → 100× → 1× ..."""
|
||||
cycle = {1: 50, 50: 100, 100: 1}
|
||||
next_val = cycle.get(self.plot.user_multiplier(), 50)
|
||||
self.plot.set_user_multiplier(next_val)
|
||||
# Pill text/style updates via effective_multiplier_changed signal.
|
||||
|
||||
def _on_effective_multiplier_changed(self, effective: int) -> None:
|
||||
"""Reflect auto-clamp on the pill: show effective value, amber border if clamped."""
|
||||
user_pref = self.plot.user_multiplier()
|
||||
clamped = effective != user_pref
|
||||
self.multiplier_button.setText(f"×{effective}")
|
||||
self.multiplier_button.setProperty("clamped", clamped)
|
||||
if clamped:
|
||||
self.multiplier_button.setToolTip(
|
||||
f"Auto-clamped from ×{user_pref} — offset too large for that zoom"
|
||||
)
|
||||
else:
|
||||
self.multiplier_button.setToolTip("")
|
||||
# Re-polish so the [clamped="..."] QSS selector takes effect.
|
||||
self.multiplier_button.style().unpolish(self.multiplier_button)
|
||||
self.multiplier_button.style().polish(self.multiplier_button)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Snapshot application
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
@@ -120,6 +120,27 @@ QtObject {
|
||||
background: #f1f5f9;
|
||||
border-color: ${root.border_btn_hv};
|
||||
}
|
||||
QPushButton#MultiplierToggle {
|
||||
background: ${root.bg_card_alt};
|
||||
border: 1px solid ${root.border_card};
|
||||
border-radius: 6px;
|
||||
color: ${root.fg_muted};
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 2px 10px;
|
||||
}
|
||||
QPushButton#MultiplierToggle:hover {
|
||||
background: #e2e8f0;
|
||||
color: ${root.fg_secondary};
|
||||
}
|
||||
QPushButton#MultiplierToggle[clamped="true"] {
|
||||
background: #fff3dc;
|
||||
border: 1px solid ${root.fg_warning};
|
||||
color: #835008;
|
||||
}
|
||||
QPushButton#MultiplierToggle[clamped="true"]:hover {
|
||||
background: #ffe9b8;
|
||||
}
|
||||
QLabel#HeroTitle {
|
||||
color: ${root.fg_primary};
|
||||
font-size: ${root.fs_hero_title}px;
|
||||
|
||||
+133
-29
@@ -38,15 +38,85 @@ _DEFAULT_COLOR = "#f97316"
|
||||
class MonitorPlot(QtWidgets.QWidget):
|
||||
"""Operator monitor plot driven by a UI snapshot."""
|
||||
|
||||
# Step-down ladder for auto-clamp. Try the operator's preferred multiplier
|
||||
# first; if the projected detected circle would crash into the camera
|
||||
# labels, fall through to the next entry until something fits.
|
||||
_CLAMP_LADDER: tuple[int, ...] = (100, 50, 10, 1)
|
||||
# Camera labels live at radius * 1.18. Keep the detected circle's outer
|
||||
# edge inside radius * 1.15 so there's a small visual breathing margin.
|
||||
_SAFE_ZONE_FACTOR: float = 1.15
|
||||
|
||||
# Emitted whenever the auto-clamp recomputes the effective multiplier.
|
||||
# App.py listens to this to update the pill button text and clamp style.
|
||||
effective_multiplier_changed = QtCore.Signal(int)
|
||||
|
||||
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setMinimumSize(430, 390)
|
||||
self._snapshot: Any | None = None
|
||||
self._user_multiplier: int = 50 # operator's stated preference
|
||||
self._effective_multiplier: int = 50 # actual multiplier used to draw
|
||||
|
||||
def set_snapshot(self, snapshot: Any | None) -> None:
|
||||
self._snapshot = snapshot
|
||||
self._update_effective_multiplier()
|
||||
self.update()
|
||||
|
||||
def set_user_multiplier(self, value: int) -> None:
|
||||
"""Set the operator's preferred multiplier; auto-clamps before drawing."""
|
||||
self._user_multiplier = value
|
||||
self._update_effective_multiplier()
|
||||
self.update()
|
||||
|
||||
def user_multiplier(self) -> int:
|
||||
return self._user_multiplier
|
||||
|
||||
def effective_multiplier(self) -> int:
|
||||
return self._effective_multiplier
|
||||
|
||||
def resizeEvent(self, event: QtGui.QResizeEvent) -> None:
|
||||
super().resizeEvent(event)
|
||||
# Canvas changed → safe zone changed → effective may need re-clamping.
|
||||
self._update_effective_multiplier()
|
||||
|
||||
def _update_effective_multiplier(self) -> None:
|
||||
new_effective = self._compute_effective_multiplier()
|
||||
if new_effective != self._effective_multiplier:
|
||||
self._effective_multiplier = new_effective
|
||||
self.effective_multiplier_changed.emit(new_effective)
|
||||
|
||||
def _compute_effective_multiplier(self) -> int:
|
||||
"""Walk the ladder downward from user preference until the draw fits."""
|
||||
if self._snapshot is None:
|
||||
return self._user_multiplier
|
||||
|
||||
# Replicate the geometry from paintEvent so the clamp matches the draw.
|
||||
rect = self.rect().adjusted(0, 0, -1, -1)
|
||||
legend_height = 32
|
||||
plot_rect = rect.adjusted(30, 28, -30, -(legend_height + 16))
|
||||
radius = min(plot_rect.width(), plot_rect.height()) * 0.25
|
||||
if radius <= 0:
|
||||
return self._user_multiplier
|
||||
|
||||
reference_radius = max(float(self._snapshot.reference_radius_mm), 1.0)
|
||||
detected_radius = float(self._snapshot.detected_radius_mm)
|
||||
scale = radius / reference_radius
|
||||
|
||||
dx_mm = float(self._snapshot.detected_center_mm[0])
|
||||
dy_mm = float(self._snapshot.detected_center_mm[1])
|
||||
offset_pixels = math.hypot(dx_mm, dy_mm) * scale
|
||||
det_radius_pixels = detected_radius * scale
|
||||
max_safe = radius * self._SAFE_ZONE_FACTOR
|
||||
|
||||
# Start at the operator's chosen multiplier and step down.
|
||||
candidates = [m for m in self._CLAMP_LADDER if m <= self._user_multiplier]
|
||||
if not candidates:
|
||||
candidates = [1]
|
||||
for m in candidates:
|
||||
if offset_pixels * m + det_radius_pixels <= max_safe:
|
||||
return m
|
||||
return 1 # safety net — 1× should always fit unless the offset is enormous
|
||||
|
||||
def paintEvent(self, event: QtGui.QPaintEvent) -> None:
|
||||
del event
|
||||
painter = QtGui.QPainter(self)
|
||||
@@ -57,12 +127,55 @@ class MonitorPlot(QtWidgets.QWidget):
|
||||
legend_height = 32
|
||||
plot_rect = rect.adjusted(30, 28, -30, -(legend_height + 16))
|
||||
center = QtCore.QPointF(plot_rect.center())
|
||||
radius = min(plot_rect.width(), plot_rect.height()) * 0.34
|
||||
# 0.25 (was 0.34) — shrinks the reference travel zone so x50/x100
|
||||
# exaggerated offsets stay inside the card and don't collide with labels.
|
||||
radius = min(plot_rect.width(), plot_rect.height()) * 0.25
|
||||
|
||||
# Reference circle (blue dashed)
|
||||
painter.setPen(
|
||||
QtGui.QPen(QtGui.QColor("#93c5fd"), 2, QtCore.Qt.PenStyle.DashLine)
|
||||
)
|
||||
painter.drawEllipse(center, radius, radius)
|
||||
|
||||
if self._snapshot:
|
||||
reference_radius = max(float(self._snapshot.reference_radius_mm), 1.0)
|
||||
detected_radius = float(self._snapshot.detected_radius_mm)
|
||||
detected_center_mm = self._snapshot.detected_center_mm
|
||||
scale = radius / reference_radius
|
||||
|
||||
# Exaggerated center: offset × multiplier for visual clarity only.
|
||||
# Y is negated: Qt screen +y is downward, but machine/Cartesian +y is up.
|
||||
# Without this flip, a wafer drifting toward CAM 3 (bottom) would
|
||||
# render upward toward CAM 1 — opposite of physical reality.
|
||||
exag_center = QtCore.QPointF(
|
||||
center.x() + float(detected_center_mm[0]) * scale * self._effective_multiplier,
|
||||
center.y() - float(detected_center_mm[1]) * scale * self._effective_multiplier,
|
||||
)
|
||||
color = QtGui.QColor(
|
||||
_STATE_COLORS.get(self._snapshot.alignment_state, _DEFAULT_COLOR)
|
||||
)
|
||||
|
||||
# Reference crosshair — true center (0, 0)
|
||||
_draw_crosshair(painter, center, QtGui.QColor("#2563eb"))
|
||||
# Detected circle at exaggerated position
|
||||
painter.setPen(QtGui.QPen(color, 2))
|
||||
painter.drawEllipse(
|
||||
exag_center, detected_radius * scale, detected_radius * scale
|
||||
)
|
||||
# Offset vector — thin line from reference to exaggerated detected
|
||||
painter.setPen(QtGui.QPen(color, 1.5))
|
||||
painter.drawLine(center, exag_center)
|
||||
# Detected crosshair at exaggerated center
|
||||
_draw_crosshair(painter, exag_center, color)
|
||||
else:
|
||||
painter.setPen(QtGui.QColor("#64748b"))
|
||||
painter.drawText(
|
||||
plot_rect, QtCore.Qt.AlignmentFlag.AlignCenter, "No wafer read"
|
||||
)
|
||||
|
||||
# Camera labels — drawn LAST so they stay on top of any vector/circle
|
||||
# passing behind them. Opaque white backfill ensures legibility even
|
||||
# when a fault-state offset line crosses the label rect.
|
||||
painter.setPen(QtGui.QPen(QtGui.QColor("#64748b"), 1))
|
||||
for label, angle in [
|
||||
("CAM 1", -90),
|
||||
@@ -71,39 +184,14 @@ class MonitorPlot(QtWidgets.QWidget):
|
||||
("CAM 4", 180),
|
||||
]:
|
||||
point = _polar(center, radius * 1.18, angle)
|
||||
label_rect = _camera_label_rect(point, angle)
|
||||
painter.fillRect(label_rect, QtGui.QColor("#ffffff"))
|
||||
painter.drawText(
|
||||
_camera_label_rect(point, angle),
|
||||
label_rect,
|
||||
QtCore.Qt.AlignmentFlag.AlignCenter,
|
||||
label,
|
||||
)
|
||||
|
||||
if self._snapshot:
|
||||
reference_radius = max(float(self._snapshot.reference_radius_mm), 1.0)
|
||||
detected_radius = float(self._snapshot.detected_radius_mm)
|
||||
detected_center_mm = self._snapshot.detected_center_mm
|
||||
scale = radius / reference_radius
|
||||
detected_center = QtCore.QPointF(
|
||||
center.x() + float(detected_center_mm[0]) * scale,
|
||||
center.y() + float(detected_center_mm[1]) * scale,
|
||||
)
|
||||
color = QtGui.QColor(
|
||||
_STATE_COLORS.get(self._snapshot.alignment_state, _DEFAULT_COLOR)
|
||||
)
|
||||
painter.setPen(QtGui.QPen(color, 2))
|
||||
painter.drawEllipse(
|
||||
detected_center, detected_radius * scale, detected_radius * scale
|
||||
)
|
||||
painter.setPen(QtGui.QPen(QtGui.QColor("#2563eb"), 7))
|
||||
painter.drawPoint(center)
|
||||
painter.setPen(QtGui.QPen(color, 7))
|
||||
painter.drawLine(center, detected_center)
|
||||
painter.drawPoint(detected_center)
|
||||
else:
|
||||
painter.setPen(QtGui.QColor("#64748b"))
|
||||
painter.drawText(
|
||||
plot_rect, QtCore.Qt.AlignmentFlag.AlignCenter, "No wafer read"
|
||||
)
|
||||
|
||||
_draw_plot_legend(
|
||||
painter,
|
||||
rect,
|
||||
@@ -203,6 +291,22 @@ def _polar(center: QtCore.QPointF, radius: float, angle_deg: float) -> QtCore.QP
|
||||
)
|
||||
|
||||
|
||||
def _draw_crosshair(
|
||||
painter: QtGui.QPainter,
|
||||
center: QtCore.QPointF,
|
||||
color: QtGui.QColor,
|
||||
arm: float = 14.0,
|
||||
gap: float = 5.0,
|
||||
) -> None:
|
||||
"""Classic + crosshair with a blank gap at the center point, 1.5 px thick."""
|
||||
x, y = center.x(), center.y()
|
||||
painter.setPen(QtGui.QPen(color, 1.5))
|
||||
painter.drawLine(QtCore.QPointF(x - arm, y), QtCore.QPointF(x - gap, y))
|
||||
painter.drawLine(QtCore.QPointF(x + gap, y), QtCore.QPointF(x + arm, y))
|
||||
painter.drawLine(QtCore.QPointF(x, y - arm), QtCore.QPointF(x, y - gap))
|
||||
painter.drawLine(QtCore.QPointF(x, y + gap), QtCore.QPointF(x, y + arm))
|
||||
|
||||
|
||||
def _draw_plot_legend(
|
||||
painter: QtGui.QPainter,
|
||||
rect: QtCore.QRect,
|
||||
|
||||
Reference in New Issue
Block a user