- Создан автономный модуль mapping_renderer.py, рисующий маппинг напрямую из MappingDocument. - Изображение больше не зависит от Qt-сцены и не содержит метки интерфейса или лишних id. - Разрешение выходного изображения 2400x2400+800 пикселей с боковой легендой. - Плашки start/end блоков рисуются напрямую на изображении. - Добавлено предупреждение при несохранённых изменениях (с возможностью сохранить и экспортировать сразу). - canvas.py очищен от предыдущей неработающей реализации export_to_image.
257 lines
8.3 KiB
Python
257 lines
8.3 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.
|
|
"""
|
|
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))
|