feat(visual): implement square wafer (X) template, inward label alignment, and density scaling
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user