"""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. """ from __future__ import annotations from pathlib import Path from typing import TYPE_CHECKING from PySide6.QtCore import QPointF, QRectF, Qt from PySide6.QtGui import ( QColor, QBrush, QFont, QFontMetricsF, QImage, QPainter, QPen, ) if TYPE_CHECKING: try: from models import MappingDocument except ImportError: from .models import MappingDocument # type: ignore[no-redef] # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- MAPPING_WIDTH = 2400 # pixels for the main mapping area SIDEBAR_WIDTH = 800 # pixels for the legend sidebar IMAGE_HEIGHT = 2400 # total image height PADDING_FRAC = 0.04 # 4% padding around the whole mapping DARK_BG = "#0f172a" DARK_SIDEBAR = "#1e293b" DARK_DIVIDER = "#334155" DARK_TITLE = "#f8fafc" DARK_LINE_TEXT = "#f1f5f9" DARK_BLOCK_TEXT = "#94a3b8" UNASSIGNED_DOT = "#3b4f6b" # colour for unassigned (no-line) dots BADGE_START = "#10b981" # green - first LED of block BADGE_END = "#ef4444" # red - last LED of block BADGE_SINGLE = "#3b82f6" # blue - block with 1 LED def render_mapping_image(doc: "MappingDocument", file_path: str | Path) -> bool: """Render *doc* to *file_path* (PNG or JPEG determined by extension). Returns True on success, False on failure. """ if not doc.points: return False # 1. Compute 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 # 2. Scale to fit mapping area scale = min(MAPPING_WIDTH / draw_w, IMAGE_HEIGHT / draw_h) img_map_w = MAPPING_WIDTH img_total_w = MAPPING_WIDTH + SIDEBAR_WIDTH def to_px(x: float, y: float) -> tuple[float, float]: px = (x - draw_x0) * scale # flip y: in document coords Y grows up, but image Y grows down py = ((max_y + pad_y) - y) * scale return px, py dot_r = max(4.0, min(14.0, scale * 1.4)) # 3. Build line / block lookup maps 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, str]] = {} # uid -> (text, badge_type, line_color) for line in doc.lines: current_offset = 0 for block in line.blocks: n = len(block.point_ids) if n == 0: continue start_uid = block.point_ids[0] end_uid = block.point_ids[-1] start_idx = current_offset end_idx = current_offset + n - 1 current_offset += n for uid in block.point_ids: point_line[uid] = line if n == 1: badge_info[start_uid] = (f"#{start_idx}", "single", line.color) else: badge_info[start_uid] = (f"#{start_idx}", "start", line.color) badge_info[end_uid] = (f"#{end_idx}", "end", line.color) # 4. Create image image = QImage(img_total_w, IMAGE_HEIGHT, QImage.Format.Format_ARGB32) image.fill(QColor(DARK_BG)) painter = QPainter(image) painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setRenderHint(QPainter.RenderHint.TextAntialiasing) # 5. Draw block connection lines (drawn first so dots appear on top) for line in doc.lines: color = QColor(line.color) color.setAlphaF(0.35) pen = QPen(color, max(1.0, dot_r * 0.35)) pen.setCapStyle(Qt.PenCapStyle.RoundCap) painter.setPen(pen) for block in line.blocks: prev = None for uid in block.point_ids: p = point_by_uid.get(uid) if p is None: continue cur = to_px(p.x, p.y) if prev is not None: painter.drawLine(QPointF(*prev), QPointF(*cur)) prev = cur # 6. Draw LED dots for point in doc.points: px, py = to_px(point.x, point.y) line = point_line.get(point.uid) color = QColor(line.color if line else UNASSIGNED_DOT) pen_color = color.darker(160) painter.setPen(QPen(pen_color, max(1.0, dot_r * 0.12))) painter.setBrush(QBrush(color)) painter.drawEllipse(QPointF(px, py), dot_r, dot_r) # 7. Draw start/end badges badge_font = QFont("Segoe UI", max(7, int(dot_r * 0.9))) badge_font.setBold(True) painter.setFont(badge_font) fm = QFontMetricsF(badge_font) for uid, (text, badge_type, line_color) in badge_info.items(): p = point_by_uid.get(uid) if p is None: continue px, py = to_px(p.x, p.y) if badge_type == "start": bg = QColor(BADGE_START) offset_y = -(dot_r + fm.height() + 6) elif badge_type == "end": bg = QColor(BADGE_END) offset_y = dot_r + 4 else: # single bg = QColor(BADGE_SINGLE) offset_y = -(dot_r + fm.height() + 6) tw = fm.horizontalAdvance(text) th = fm.height() bx = px - tw / 2 - 5 by = py + offset_y bw = tw + 10 bh = th + 4 painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(QBrush(bg)) painter.drawRoundedRect(QRectF(bx, by, bw, bh), 3, 3) painter.setPen(QColor(Qt.GlobalColor.white)) painter.drawText(QRectF(bx, by, bw, bh), Qt.AlignmentFlag.AlignCenter, text) # 8. Fill sidebar painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(QBrush(QColor(DARK_SIDEBAR))) painter.drawRect(img_map_w, 0, SIDEBAR_WIDTH, IMAGE_HEIGHT) # Divider line painter.setPen(QPen(QColor(DARK_DIVIDER), 2)) painter.drawLine(img_map_w, 0, img_map_w, IMAGE_HEIGHT) # 9. Draw legend SB_PAD = 40 sb_x = img_map_w + SB_PAD title_font = QFont("Segoe UI", 22) title_font.setBold(True) painter.setFont(title_font) painter.setPen(QColor(DARK_TITLE)) painter.drawText( QRectF(sb_x, 36, SIDEBAR_WIDTH - SB_PAD * 2, 48), Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, "ЛЕГЕНДА МАППИНГА", ) y = 106.0 line_font = QFont("Segoe UI", 15) line_font.setBold(True) block_font = QFont("Segoe UI", 12) for line in doc.lines: if y > IMAGE_HEIGHT - 60: break # Colour swatch swatch_size = 24.0 painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(QBrush(QColor(line.color))) painter.drawRoundedRect(QRectF(sb_x, y + 2, swatch_size, swatch_size), 4, 4) painter.setFont(line_font) painter.setPen(QColor(DARK_LINE_TEXT)) painter.drawText( QRectF(sb_x + swatch_size + 12, y, SIDEBAR_WIDTH - SB_PAD * 2 - swatch_size - 12, 30), Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, line.name, ) y += 38 painter.setFont(block_font) current_offset = 0 for block in line.blocks: if y > IMAGE_HEIGHT - 40: break n = len(block.point_ids) range_str = "пусто" if n == 0 else f"{current_offset}-{current_offset + n - 1}" current_offset += n # Narrow colour strip left of block row painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(QBrush(QColor(line.color))) painter.drawRect(QRectF(sb_x + 8, y + 3, 4, 18)) painter.setPen(QColor(DARK_BLOCK_TEXT)) painter.drawText( QRectF(sb_x + 22, y, SIDEBAR_WIDTH - SB_PAD * 2 - 22, 24), Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, f"{block.name}: {range_str}", ) y += 27 y += 14 painter.end() return image.save(str(file_path))