From 7e584e08e8bf5e0df49f5e0c79b556cc5458d912 Mon Sep 17 00:00:00 2001 From: jack Date: Mon, 15 Jun 2026 11:20:55 -0700 Subject: [PATCH] feat(visual): implement square wafer (X) template, inward label alignment, and density scaling --- .../backend/visualization/wafer_map_item.py | 185 ++++++++++++++---- 1 file changed, 145 insertions(+), 40 deletions(-) diff --git a/src/pygui/backend/visualization/wafer_map_item.py b/src/pygui/backend/visualization/wafer_map_item.py index 022cd09..a76d960 100644 --- a/src/pygui/backend/visualization/wafer_map_item.py +++ b/src/pygui/backend/visualization/wafer_map_item.py @@ -39,6 +39,8 @@ class WaferMapItem(QQuickPaintedItem): blendChanged = Signal() showLabelsChanged = Signal() colorsChanged = Signal() + shapeChanged = Signal() + sizeChanged = Signal() def __init__(self, parent=None): super().__init__(parent) @@ -49,6 +51,8 @@ class WaferMapItem(QQuickPaintedItem): self._margin: float = 1.0 self._blend: float = 0.0 self._show_labels: bool = True + self._shape: str = "round" + self._size: float = 300.0 # Dark-theme color defaults (match Theme.qml tokens) self._ring_color = QColor("#2A3441") # waferRingColor (toneBorder) @@ -74,8 +78,17 @@ class WaferMapItem(QQuickPaintedItem): @sensors.setter def sensors(self, val: list) -> None: - self._sensors = [Sensor(label=d["label"], x=float(d["x"]), y=float(d["y"])) - for d in (val or [])] + self._sensors = [ + Sensor( + label=d["label"], + x=float(d["x"]), + y=float(d["y"]), + side=d.get("side", "right"), + offset_x=float(d.get("offset_x", 0.0)), + offset_y=float(d.get("offset_y", 0.0)) + ) + for d in (val or []) + ] self._rebuild() self.sensorsChanged.emit() @@ -144,6 +157,26 @@ class WaferMapItem(QQuickPaintedItem): self.showLabelsChanged.emit() self.update() + @Property(str, notify=shapeChanged) + def shape(self) -> str: + return self._shape + + @shape.setter + def shape(self, val: str) -> None: + self._shape = str(val).lower() + self._rebuild() + self.shapeChanged.emit() + + @Property(float, notify=sizeChanged) + def size(self) -> float: + return self._size + + @size.setter + def size(self, val: float) -> None: + self._size = float(val) + self._rebuild() + self.sizeChanged.emit() + # Colour properties — QML can bind these to Theme tokens @Property(QColor, notify=colorsChanged) def ringColor(self) -> QColor: return self._ring_color @@ -262,7 +295,7 @@ class WaferMapItem(QQuickPaintedItem): xs, ys, vs, width=ds, height=ds, extent=(-r_mm, r_mm, -r_mm, r_mm), - round_clip=True, + round_clip=(self._shape == "round"), ) except Exception: self._heatmap = None @@ -328,46 +361,68 @@ class WaferMapItem(QQuickPaintedItem): r_mm = self._wafer_radius_mm() sc = self._scale(ds, r_mm) - # Concentric rings at actual sensor group radii (falls back to 25/50/75/100% when no sensors). - ring_pen = QPen(self._ring_color, 1, Qt.PenStyle.SolidLine) - painter.setPen(ring_pen) - for ring_r_mm in self._sensor_ring_radii_mm(): - rr = max(1, int(ring_r_mm * sc)) - painter.drawEllipse(cx - rr, cy - rr, 2 * rr, 2 * rr) + if self._shape == "square": + # Draw square boundary (thick pen) + border_pen = QPen(self._ring_color, 2, Qt.PenStyle.SolidLine) + painter.setPen(border_pen) + half_size_px = int(self._size / 2 * sc) + painter.drawRect(cx - half_size_px, cy - half_size_px, 2 * half_size_px, 2 * half_size_px) - # Crosshair axes - axis_pen = QPen(self._axis_color, 1, Qt.PenStyle.DashLine) - painter.setPen(axis_pen) - painter.drawLine(cx, cy - r_px, cx, cy + r_px) - painter.drawLine(cx - r_px, cy, cx + r_px, cy) + # Crosshair axes + axis_pen = QPen(self._axis_color, 1, Qt.PenStyle.DashLine) + painter.setPen(axis_pen) + painter.drawLine(cx, cy - half_size_px, cx, cy + half_size_px) + painter.drawLine(cx - half_size_px, cy, cx + half_size_px, cy) - # Top notch triangle (wafer orientation marker) - nw = max(6, ds // 25) - nh = max(4, ds // 35) - notch = QPolygon([ - QPoint(cx, cy - r_px), - QPoint(cx - nw // 2, cy - r_px + nh), - QPoint(cx + nw // 2, cy - r_px + nh), - ]) - painter.setPen(Qt.PenStyle.NoPen) - painter.setBrush(QBrush(self._axis_color)) - painter.drawPolygon(notch) + # Draw concentric square guide lines at the distinct radii of sensor rings + grid_pen = QPen(self._ring_color, 1, Qt.PenStyle.SolidLine) + painter.setPen(grid_pen) + for ring_r_mm in self._sensor_ring_radii_mm()[:-1]: # exclude the outermost border + rr = max(1, int(ring_r_mm * sc)) + painter.drawRect(cx - rr, cy - rr, 2 * rr, 2 * rr) + else: + # Concentric rings + ring_pen = QPen(self._ring_color, 1, Qt.PenStyle.SolidLine) + painter.setPen(ring_pen) + for ring_r_mm in self._sensor_ring_radii_mm(): + rr = max(1, int(ring_r_mm * sc)) + painter.drawEllipse(cx - rr, cy - rr, 2 * rr, 2 * rr) + + # Crosshair axes + axis_pen = QPen(self._axis_color, 1, Qt.PenStyle.DashLine) + painter.setPen(axis_pen) + painter.drawLine(cx, cy - r_px, cx, cy + r_px) + painter.drawLine(cx - r_px, cy, cx + r_px, cy) + + # Top notch triangle (wafer orientation marker) + nw = max(6, ds // 25) + nh = max(4, ds // 35) + notch = QPolygon([ + QPoint(cx, cy - r_px), + QPoint(cx - nw // 2, cy - r_px + nh), + QPoint(cx + nw // 2, cy - r_px + nh), + ]) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QBrush(self._axis_color)) + painter.drawPolygon(notch) def _paint_markers(self, painter: QPainter) -> None: r = self._marker_r + # Scale font size based on the number of sensors to prevent overlap on dense wafers + num_sensors = len(self._sensors) + font_scale = 1.0 + if num_sensors > 60: + font_scale = 0.7 # reduce font size by 30% for dense wafers + elif num_sensors > 40: + font_scale = 0.85 + id_font = QFont() - id_font.setPointSize(max(5, r)) + id_font.setPointSize(max(4, int(r * font_scale))) id_font.setBold(True) temp_font = QFont() - temp_font.setPointSize(max(4, r - 1)) - - # Pre-compute ID font metrics for vertical centering - painter.setFont(id_font) - id_fm = painter.fontMetrics() - id_line_h = id_fm.height() - id_ascent = id_fm.ascent() + temp_font.setPointSize(max(3, int((r - 1) * font_scale))) band_color = { "in_range": self._in_range_color, @@ -390,17 +445,67 @@ class WaferMapItem(QQuickPaintedItem): if self._show_labels: has_temp = i < len(self._values) - lx = px + r + 3 - # Two-line block: split the gap at dot center; single-line: original position - y1 = (py - id_line_h // 2) if has_temp else (py + id_ascent // 2) - # Sensor ID — bold, muted text color + # Fetch text alignment side and offsets + side = getattr(s, "side", "right").lower() + ox = getattr(s, "offset_x", 0.0) * r + oy = getattr(s, "offset_y", 0.0) * r + + # Pre-compute metrics using current scaled fonts + painter.setFont(id_font) + id_fm = painter.fontMetrics() + id_line_h = id_fm.height() + id_ascent = id_fm.ascent() + + painter.setFont(temp_font) + temp_fm = painter.fontMetrics() + temp_line_h = temp_fm.height() + temp_ascent = temp_fm.ascent() + + id_text = s.label + temp_text = f"{self._values[i]:.2f}" if has_temp else "" + + id_w = id_fm.horizontalAdvance(id_text) + temp_w = temp_fm.horizontalAdvance(temp_text) if has_temp else 0 + text_w = max(id_w, temp_w) + + # Height of the 1 or 2-line text block + text_h = (id_line_h + temp_line_h) if has_temp else id_line_h + + # Calculate box top-left (lx, ly) relative to dot center (px, py) + gap = 3 + if side == "left": + lx = px - r - gap - text_w + ox + ly = py - text_h // 2 + oy + elif side == "top": + lx = px - text_w // 2 + ox + ly = py - r - gap - text_h + oy + elif side == "bottom": + lx = px - text_w // 2 + ox + ly = py + r + gap + oy + else: # "right" or default + lx = px + r + gap + ox + ly = py - text_h // 2 + oy + + # Draw Sensor ID (first line) painter.setFont(id_font) painter.setPen(QPen(self._text_color)) - painter.drawText(lx, y1, s.label) + y1 = ly + id_ascent + if side in ("top", "bottom"): + painter.drawText(lx + (text_w - id_w) // 2, y1, id_text) + elif side == "left": + painter.drawText(lx + (text_w - id_w), y1, id_text) + else: + painter.drawText(lx, y1, id_text) - # Temperature — band color, smaller font, below ID + # Draw Temperature (second line) if has_temp: painter.setFont(temp_font) painter.setPen(QPen(color)) - painter.drawText(lx, y1 + id_line_h, f"{self._values[i]:.2f}") + y2 = ly + id_line_h + temp_ascent + if side in ("top", "bottom"): + painter.drawText(lx + (text_w - temp_w) // 2, y2, temp_text) + elif side == "left": + painter.drawText(lx + (text_w - temp_w), y2, temp_text) + else: + painter.drawText(lx, y2, temp_text)