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:
Your Name
2026-05-27 10:05:56 -07:00
parent 02327a7a21
commit 81fd015520
3 changed files with 190 additions and 33 deletions
+36 -4
View File
@@ -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
# -----------------------------------------------------------------------
+21
View File
@@ -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
View File
@@ -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,