LED-mapper/mapping_renderer.py
Danil Iarosh 3fe3afa3d7 Increase exported mapping image resolution to 4K and scale layout proportionally
- Значение MAPPING_MAX увеличено с 2200 до 4000 для повышения разрешения экспортируемого изображения до уровня 4K.
- Добавлено пропорциональное масштабирование всех констант разметки, размеров шрифтов, толщины линий, отступов и скруглений на основе коэффициента base_scale.
- Параметр масштабирования base_scale прокинут в функции отрисовки стрелок направления и компенсации пересечений.
2026-08-06 12:24:10 +03:00

504 lines
19 KiB
Python

"""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 = 4000 # 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]],
base_scale: float = 1.0,
) -> 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 * base_scale:
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 * base_scale, min(length, plen) * 0.35):
previous_count += 1
segments.append((QPointF(start), QPointF(end)))
if previous_count == 0:
return None
bend = min(16.0 * base_scale, max(7.0 * base_scale, 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]],
base_scale: float = 1.0,
) -> QPointF | None:
path.moveTo(start)
control = _overlap_control_point(start, end, segments, base_scale)
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,
base_scale: float = 1.0,
) -> None:
dx = end.x() - start.x()
dy = end.y() - start.y()
length = math.hypot(dx, dy)
if length < 8.0 * base_scale:
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 * base_scale, 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")
# Scale factor based on resolution
base_scale = MAPPING_MAX / 2200.0
# ── 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 * base_scale, min(14.0 * base_scale, 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
legend_line_row = LEGEND_LINE_ROW * base_scale
legend_block_row = LEGEND_BLOCK_ROW * base_scale
legend_v_pad = LEGEND_V_PAD * base_scale
legend_h_pad = LEGEND_H_PAD * base_scale
legend_col_gap = LEGEND_COL_GAP * base_scale
legend_swatch_w = LEGEND_SWATCH_W * base_scale
legend_swatch_h = LEGEND_SWATCH_H * base_scale
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.0 * base_scale)
img_w = map_px_w
img_h = map_px_h + round(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 * base_scale, dot_r * 0.30)
line_w_bridge = max(1.0 * base_scale, dot_r * 0.20)
line_w_arrow = max(1.0 * base_scale, 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, base_scale)
_append_arrow(arrow_path, s, e, ctrl, base_scale)
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, base_scale)
_append_arrow(b_arrow_path, s, e, ctrl, base_scale)
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_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 * base_scale
bw = tw + 10 * base_scale
bh = th + 4 * base_scale
if btype in ("start", "single"):
by = px.y() - dot_r - bh - 4 * base_scale
bg = QColor(BADGE_START if btype == "start" else BADGE_SINGLE)
else:
by = px.y() + dot_r + 4 * base_scale
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 * base_scale
# Maximum y for badge bottom must stay within mapping area
badge_max_y = map_px_h - 4 * base_scale
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 * base_scale, 3 * base_scale)
painter.setPen(QColor(Qt.GlobalColor.white))
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, text)
# ── 7. Legend strip ───────────────────────────────────────────────────────
title_font = QFont("Segoe UI", int(16 * base_scale))
title_font.setBold(True)
block_font = QFont("Segoe UI", int(13 * base_scale))
legend_y0 = map_px_h
legend_h = round(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, int(2 * base_scale)))
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 * base_scale, legend_swatch_w, legend_swatch_h), 3 * base_scale, 3 * base_scale
)
painter.setFont(title_font)
painter.setPen(line_text_color)
painter.drawText(
QRectF(cx + legend_swatch_w + 10 * base_scale, cy, col_w - legend_swatch_w - 10 * base_scale, 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 * base_scale, cy + 4 * base_scale, 3 * base_scale, legend_block_row - 8 * base_scale))
painter.setPen(block_text_color)
painter.drawText(
QRectF(cx + 16 * base_scale, cy, col_w - 16 * base_scale, 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))