"""Standalone high-quality mapping image renderer. Draws the LED mapping directly from MappingDocument data, completely independent of the Qt scene/canvas so no UI labels or zoom state leak in. Layout ------ * Top part : LED mapping (square canvas) * Bottom part: colour legend arranged left-to-right for lines, with blocks listed top-to-bottom inside each column. Connections ----------- * Inner connections (within a block): solid line, full opacity. * Bridge connections (between consecutive blocks of the same line): dashed line, slightly thinner, same colour. * Overlapping segments are deflected with a quadratic Bezier (same algorithm as the Qt canvas). * Mid-segment arrow heads indicate direction. Badges ------ * Start badge (green, above dot) : first LED of a block. * End badge (red, below dot) : last LED of a block. * Single badge (blue, above dot) : block with exactly one LED. * An anti-overlap pass shifts badges that would otherwise overlap. """ from __future__ import annotations import math from pathlib import Path from typing import TYPE_CHECKING from PySide6.QtCore import QPointF, QRectF, Qt from PySide6.QtGui import ( QBrush, QColor, QFont, QFontMetricsF, QImage, QPainter, QPainterPath, QPen, ) if TYPE_CHECKING: try: from models import MappingDocument except ImportError: from .models import MappingDocument # type: ignore[no-redef] # --------------------------------------------------------------------------- # Layout constants # --------------------------------------------------------------------------- MAPPING_MAX = 2200 # longest side of mapping area (pixels) LEGEND_HEIGHT = 480 # legend strip at the bottom PADDING_FRAC = 0.04 # % padding inside mapping area # Legend layout LEGEND_H_PAD = 60 # horizontal padding of legend area LEGEND_V_PAD = 28 # vertical padding of legend area LEGEND_COL_GAP = 24 # gap between line columns LEGEND_SWATCH_W = 22 LEGEND_SWATCH_H = 22 LEGEND_LINE_ROW = 38 # height of each line header row LEGEND_BLOCK_ROW = 26 # height of each block row # Colours DARK_BG = "#0f172a" DARK_LEGEND_BG = "#111827" DIVIDER = "#1e3a5f" DARK_TITLE = "#f8fafc" DARK_LINE_TEXT = "#f1f5f9" DARK_BLOCK_TEXT = "#94a3b8" UNASSIGNED = "#3b4f6b" BADGE_START = "#10b981" BADGE_END = "#ef4444" BADGE_SINGLE = "#3b82f6" # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _overlap_control_point( start: QPointF, end: QPointF, segments: list[tuple[QPointF, QPointF]], ) -> QPointF | None: """Return a quadratic Bezier control point if *start-end* overlaps an existing segment, otherwise None (straight line).""" dx = end.x() - start.x() dy = end.y() - start.y() length = math.hypot(dx, dy) if length < 0.001: return None unit_ax = dx / length unit_ay = dy / length previous_count = 0 for prev_s, prev_e in segments: # Parallel check pdx = prev_e.x() - prev_s.x() pdy = prev_e.y() - prev_s.y() plen = math.hypot(pdx, pdy) if plen < 0.001: continue unit_bx = pdx / plen unit_by = pdy / plen if abs(unit_ax * unit_bx + unit_ay * unit_by) < 0.985: continue # Perpendicular distance d0 = abs((prev_s.x() - start.x()) * unit_ay - (prev_s.y() - start.y()) * unit_ax) d1 = abs((prev_e.x() - start.x()) * unit_ay - (prev_e.y() - start.y()) * unit_ax) if max(d0, d1) > 5.0: continue # Overlap projection b0 = (prev_s.x() - start.x()) * unit_ax + (prev_s.y() - start.y()) * unit_ay b1 = (prev_e.x() - start.x()) * unit_ax + (prev_e.y() - start.y()) * unit_ay ov_s = max(0.0, min(b0, b1)) ov_e = min(length, max(b0, b1)) if (ov_e - ov_s) >= min(10.0, min(length, plen) * 0.35): previous_count += 1 segments.append((QPointF(start), QPointF(end))) if previous_count == 0: return None bend = min(16.0, max(7.0, length * 0.24)) side = 1.0 if previous_count % 2 == 1 else -1.0 layer = 1.0 + (previous_count - 1) // 2 offset = side * bend * layer perp_x = -dy / length perp_y = dx / length mid_x = (start.x() + end.x()) / 2 mid_y = (start.y() + end.y()) / 2 return QPointF(mid_x + perp_x * offset, mid_y + perp_y * offset) def _append_connection( path: QPainterPath, start: QPointF, end: QPointF, segments: list[tuple[QPointF, QPointF]], ) -> QPointF | None: path.moveTo(start) control = _overlap_control_point(start, end, segments) if control is None: path.lineTo(end) else: path.quadTo(control, end) return control def _append_arrow(path: QPainterPath, start: QPointF, end: QPointF, control: QPointF | None) -> None: dx = end.x() - start.x() dy = end.y() - start.y() length = math.hypot(dx, dy) if length < 8.0: return t = 0.55 if control is None: unit_x = dx / length unit_y = dy / length mid_x = start.x() + dx * t mid_y = start.y() + dy * t else: inv_t = 1.0 - t mid_x = inv_t*inv_t*start.x() + 2*inv_t*t*control.x() + t*t*end.x() mid_y = inv_t*inv_t*start.y() + 2*inv_t*t*control.y() + t*t*end.y() tan_x = 2*inv_t*(control.x()-start.x()) + 2*t*(end.x()-control.x()) tan_y = 2*inv_t*(control.y()-start.y()) + 2*t*(end.y()-control.y()) tl = math.hypot(tan_x, tan_y) if tl < 0.001: return unit_x = tan_x / tl unit_y = tan_y / tl alen = min(7.0, length * 0.28) awid = alen * 0.55 base_x = mid_x - unit_x * alen base_y = mid_y - unit_y * alen perp_x = -unit_y perp_y = unit_x path.moveTo(QPointF(base_x + perp_x*awid, base_y + perp_y*awid)) path.lineTo(QPointF(mid_x, mid_y)) path.lineTo(QPointF(base_x - perp_x*awid, base_y - perp_y*awid)) # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- def render_mapping_image(doc: "MappingDocument", file_path: str | Path, theme: str = "dark") -> bool: """Render *doc* to *file_path* (PNG or JPEG). Returns True on success.""" if not doc.points: return False is_dark = theme == "dark" bg_color = QColor(DARK_BG if is_dark else "#ffffff") legend_bg_color = QColor(DARK_LEGEND_BG if is_dark else "#f8fafc") divider_color = QColor(DIVIDER if is_dark else "#e2e8f0") line_text_color = QColor(DARK_LINE_TEXT if is_dark else "#0f172a") block_text_color = QColor(DARK_BLOCK_TEXT if is_dark else "#475569") unassigned_color = QColor(UNASSIGNED if is_dark else "#cbd5e1") # ── 1. World bounds ───────────────────────────────────────────────────── xs = [p.x for p in doc.points] ys = [p.y for p in doc.points] min_x, max_x = min(xs), max(xs) min_y, max_y = min(ys), max(ys) world_w = max(max_x - min_x, 1.0) world_h = max(max_y - min_y, 1.0) pad_x = world_w * PADDING_FRAC pad_y = world_h * PADDING_FRAC draw_x0 = min_x - pad_x draw_y0 = min_y - pad_y draw_w = world_w + 2 * pad_x draw_h = world_h + 2 * pad_y # Compute actual pixel map dimensions (preserve aspect ratio) aspect = draw_w / draw_h if aspect >= 1.0: map_px_w = MAPPING_MAX map_px_h = max(200, round(MAPPING_MAX / aspect)) else: map_px_h = MAPPING_MAX map_px_w = max(200, round(MAPPING_MAX * aspect)) scale = map_px_w / draw_w # == map_px_h / draw_h def to_px(x: float, y: float) -> QPointF: px = (x - draw_x0) * scale py = ((max_y + pad_y) - y) * scale # flip y return QPointF(px, py) dot_r = max(5.0, min(14.0, scale * 1.6)) # ── 2. Lookups ─────────────────────────────────────────────────────────── point_by_uid: dict[int, object] = {p.uid: p for p in doc.points} point_line: dict[int, object] = {} badge_info: dict[int, tuple[str, str]] = {} # uid → (text, badge_type) for line in doc.lines: cur = 0 for block in line.blocks: n = len(block.point_ids) if n == 0: continue s_uid = block.point_ids[0] e_uid = block.point_ids[-1] for uid in block.point_ids: point_line[uid] = line if n == 1: badge_info[s_uid] = (f"#{cur}", "single") else: badge_info[s_uid] = (f"#{cur}", "start") badge_info[e_uid] = (f"#{cur + n - 1}", "end") cur += n # 3. Pre-compute legend height so image is created at the right size _lf_pre = LEGEND_LINE_ROW _bf_pre = LEGEND_BLOCK_ROW max_col_h_pre = max( (LEGEND_LINE_ROW + len(ln.blocks) * LEGEND_BLOCK_ROW for ln in doc.lines), default=0, ) legend_height_pre = LEGEND_V_PAD * 2 + max(max_col_h_pre, 60) img_w = map_px_w img_h = map_px_h + legend_height_pre image = QImage(img_w, img_h, QImage.Format.Format_ARGB32) image.fill(bg_color) painter = QPainter(image) painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setRenderHint(QPainter.RenderHint.TextAntialiasing) # ── 4. Connections ─────────────────────────────────────────────────────── segments: list[tuple[QPointF, QPointF]] = [] line_w_inner = max(1.2, dot_r * 0.30) line_w_bridge = max(1.0, dot_r * 0.20) line_w_arrow = max(1.0, dot_r * 0.22) for line in doc.lines: lc = QColor(line.color) # Inner paths inner_path = QPainterPath() arrow_path = QPainterPath() for block in line.blocks: pts = [point_by_uid.get(uid) for uid in block.point_ids] pts = [p for p in pts if p is not None] for a, b in zip(pts, pts[1:]): s = to_px(a.x, a.y) # type: ignore[union-attr] e = to_px(b.x, b.y) # type: ignore[union-attr] ctrl = _append_connection(inner_path, s, e, segments) _append_arrow(arrow_path, s, e, ctrl) c_inner = QColor(lc) c_inner.setAlphaF(0.85) pen = QPen(c_inner, line_w_inner) pen.setCapStyle(Qt.PenCapStyle.RoundCap) painter.setPen(pen) painter.setBrush(Qt.BrushStyle.NoBrush) painter.drawPath(inner_path) c_arrow = QColor(lc) c_arrow.setAlphaF(0.95) pen_a = QPen(c_arrow, line_w_arrow) pen_a.setCapStyle(Qt.PenCapStyle.RoundCap) pen_a.setJoinStyle(Qt.PenJoinStyle.RoundJoin) painter.setPen(pen_a) painter.drawPath(arrow_path) # Bridge paths (between blocks) bridge_path = QPainterPath() b_arrow_path = QPainterPath() prev_end_pt = None for block in line.blocks: if not block.point_ids: continue first_pt = point_by_uid.get(block.point_ids[0]) last_pt = point_by_uid.get(block.point_ids[-1]) if first_pt is not None and prev_end_pt is not None: s = to_px(prev_end_pt.x, prev_end_pt.y) # type: ignore[union-attr] e = to_px(first_pt.x, first_pt.y) # type: ignore[union-attr] ctrl = _append_connection(bridge_path, s, e, segments) _append_arrow(b_arrow_path, s, e, ctrl) if last_pt is not None: prev_end_pt = last_pt c_bridge = QColor(lc) c_bridge.setAlphaF(0.5) pen_b = QPen(c_bridge, line_w_bridge) pen_b.setCapStyle(Qt.PenCapStyle.RoundCap) pen_b.setStyle(Qt.PenStyle.DashLine) painter.setPen(pen_b) painter.drawPath(bridge_path) c_ba = QColor(lc) c_ba.setAlphaF(0.75) pen_ba = QPen(c_ba, line_w_arrow) pen_ba.setCapStyle(Qt.PenCapStyle.RoundCap) pen_ba.setStyle(Qt.PenStyle.SolidLine) painter.setPen(pen_ba) painter.drawPath(b_arrow_path) # ── 5. LED dots ────────────────────────────────────────────────────────── for point in doc.points: px = to_px(point.x, point.y) line = point_line.get(point.uid) color = QColor(line.color if line else unassigned_color) painter.setPen(QPen(color.darker(160), max(1.0, dot_r * 0.10))) painter.setBrush(QBrush(color)) painter.drawEllipse(px, dot_r, dot_r) # ── 6. Badges (anti-overlap) ───────────────────────────────────────────── badge_font = QFont("Segoe UI", max(7, int(dot_r * 0.82))) badge_font.setBold(True) painter.setFont(badge_font) fm = QFontMetricsF(badge_font) # First pass: compute proposed rect for each badge # badge list: (uid, text, badge_type) sorted so start badges come first badge_rects: list[tuple[str, str, QRectF, QColor]] = [] # (text, badge_type, rect, bg) def proposed_rect(uid: int, text: str, btype: str) -> tuple[QRectF, QColor]: p = point_by_uid.get(uid) if p is None: return QRectF(), QColor() px = to_px(p.x, p.y) # type: ignore[union-attr] tw = fm.horizontalAdvance(text) th = fm.height() bx = px.x() - tw / 2 - 5 bw = tw + 10 bh = th + 4 if btype in ("start", "single"): by = px.y() - dot_r - bh - 4 bg = QColor(BADGE_START if btype == "start" else BADGE_SINGLE) else: by = px.y() + dot_r + 4 bg = QColor(BADGE_END) return QRectF(bx, by, bw, bh), bg # Collect all badges raw_badges: list[tuple[int, str, str]] = [] for uid, (text, btype) in badge_info.items(): raw_badges.append((uid, text, btype)) # Build placed rects with nudge to avoid overlap placed: list[QRectF] = [] NUDGE_STEP = fm.height() + 6 # Maximum y for badge bottom must stay within mapping area badge_max_y = map_px_h - 4 for uid, text, btype in raw_badges: rect, bg = proposed_rect(uid, text, btype) if rect.isEmpty(): continue # Try shifting vertically until no overlap direction = -1 if btype in ("start", "single") else 1 for _ in range(12): overlaps = any(rect.intersects(pr) for pr in placed) if not overlaps: break rect = rect.translated(0, direction * NUDGE_STEP) # Clamp: don't let badge go outside mapping area vertically if rect.bottom() > badge_max_y: rect = rect.translated(0, badge_max_y - rect.bottom()) if rect.top() < 0: rect = rect.translated(0, -rect.top()) placed.append(rect) badge_rects.append((text, btype, rect, bg)) # Second pass: draw for text, btype, rect, bg in badge_rects: painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(QBrush(bg)) painter.drawRoundedRect(rect, 3, 3) painter.setPen(QColor(Qt.GlobalColor.white)) painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, text) # ── 7. Legend strip ─────────────────────────────────────────────────────── title_font = QFont("Segoe UI", 16) title_font.setBold(True) block_font = QFont("Segoe UI", 13) legend_y0 = map_px_h legend_h = legend_height_pre painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(QBrush(legend_bg_color)) painter.drawRect(0, legend_y0, img_w, legend_h) # divider painter.setPen(QPen(divider_color, 2)) painter.drawLine(0, legend_y0, img_w, legend_y0) if doc.lines: n_lines = len(doc.lines) avail_w = img_w - 2 * LEGEND_H_PAD col_w = (avail_w - LEGEND_COL_GAP * (n_lines - 1)) / n_lines for i, line in enumerate(doc.lines): cx = float(LEGEND_H_PAD) + i * (col_w + LEGEND_COL_GAP) cy = float(legend_y0 + LEGEND_V_PAD) # Colour swatch + line name painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(QBrush(QColor(line.color))) painter.drawRoundedRect( QRectF(cx, cy + 4, LEGEND_SWATCH_W, LEGEND_SWATCH_H), 3, 3 ) painter.setFont(title_font) painter.setPen(line_text_color) painter.drawText( QRectF(cx + LEGEND_SWATCH_W + 10, cy, col_w - LEGEND_SWATCH_W - 10, LEGEND_LINE_ROW), Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, line.name, ) cy += LEGEND_LINE_ROW # Blocks (top-to-bottom within column) cur = 0 painter.setFont(block_font) for block in line.blocks: n = len(block.point_ids) rng = f"{cur}-{cur + n - 1}" if n else "пусто" cur += n # Thin colour bar on the left painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(QBrush(QColor(line.color))) painter.drawRect(QRectF(cx + 4, cy + 4, 3, LEGEND_BLOCK_ROW - 8)) painter.setPen(block_text_color) painter.drawText( QRectF(cx + 16, cy, col_w - 16, LEGEND_BLOCK_ROW), Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, f"{block.name}: {rng}", ) cy += LEGEND_BLOCK_ROW painter.end() return image.save(str(file_path))