diff --git a/src/wafer_edge/App.py b/src/wafer_edge/App.py index f98f244..37e3c8e 100644 --- a/src/wafer_edge/App.py +++ b/src/wafer_edge/App.py @@ -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 # ----------------------------------------------------------------------- diff --git a/src/wafer_edge/ui/Theme.qml b/src/wafer_edge/ui/Theme.qml index dae22f3..209717c 100644 --- a/src/wafer_edge/ui/Theme.qml +++ b/src/wafer_edge/ui/Theme.qml @@ -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; diff --git a/src/wafer_edge/ui/widgets.py b/src/wafer_edge/ui/widgets.py index 90cc486..c6f0dc6 100644 --- a/src/wafer_edge/ui/widgets.py +++ b/src/wafer_edge/ui/widgets.py @@ -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,