Support theme selection on export with horizontal bottom legend and block connections
- Добавлена возможность выбора тёмной или светлой темы для экспортируемого изображения. - Легенда перенесена вниз и отрисовывается слева направо в виде колонок одинаковой ширины. - Реализованы пунктирные линии связи между соседними блоками одной ленты. - На соединительных линиях рисуются стрелки направления соединения светодиодов. - Добавлен механизм сдвига номеров светодиодов для предотвращения наложений друг на друга и их ухода за пределы холста.
This commit is contained in:
parent
82ce556847
commit
fa1497bee7
@ -978,6 +978,22 @@ class MainWindow(QMainWindow):
|
||||
else:
|
||||
self._save_to(self.document.path)
|
||||
|
||||
# Choose theme for export
|
||||
theme_msg = QMessageBox(self)
|
||||
theme_msg.setIcon(QMessageBox.Icon.Question)
|
||||
theme_msg.setWindowTitle("Выбор темы экспорта")
|
||||
theme_msg.setText("Выберите цветовую тему для экспортируемого изображения:")
|
||||
dark_btn = theme_msg.addButton("Тёмная тема", QMessageBox.ButtonRole.AcceptRole)
|
||||
light_btn = theme_msg.addButton("Светлая тема", QMessageBox.ButtonRole.ActionRole)
|
||||
cancel_theme_btn = theme_msg.addButton("Отмена", QMessageBox.ButtonRole.RejectRole)
|
||||
theme_msg.exec()
|
||||
|
||||
clicked_theme = theme_msg.clickedButton()
|
||||
if clicked_theme == cancel_theme_btn:
|
||||
return
|
||||
|
||||
theme = "dark" if clicked_theme == dark_btn else "light"
|
||||
|
||||
default_name = "mapping.png"
|
||||
if self.document.path is not None:
|
||||
default_name = self.document.path.stem + ".png"
|
||||
@ -996,7 +1012,7 @@ class MainWindow(QMainWindow):
|
||||
if not file_path:
|
||||
return
|
||||
|
||||
success = render_mapping_image(self.document, file_path)
|
||||
success = render_mapping_image(self.document, file_path, theme=theme)
|
||||
if success:
|
||||
self.status.setText(f"Схема успешно экспортирована в: {file_path}")
|
||||
else:
|
||||
|
||||
@ -2,20 +2,44 @@
|
||||
|
||||
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 (
|
||||
QColor,
|
||||
QBrush,
|
||||
QColor,
|
||||
QFont,
|
||||
QFontMetricsF,
|
||||
QImage,
|
||||
QPainter,
|
||||
QPainterPath,
|
||||
QPen,
|
||||
)
|
||||
|
||||
@ -27,230 +51,437 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# Layout 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"
|
||||
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_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
|
||||
UNASSIGNED = "#3b4f6b"
|
||||
BADGE_START = "#10b981"
|
||||
BADGE_END = "#ef4444"
|
||||
BADGE_SINGLE = "#3b82f6"
|
||||
|
||||
|
||||
def render_mapping_image(doc: "MappingDocument", file_path: str | Path) -> bool:
|
||||
"""Render *doc* to *file_path* (PNG or JPEG determined by extension).
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Returns True on success, False on failure.
|
||||
"""
|
||||
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
|
||||
|
||||
# 1. Compute world bounds
|
||||
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
|
||||
|
||||
# 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
|
||||
# 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))
|
||||
|
||||
def to_px(x: float, y: float) -> tuple[float, float]:
|
||||
scale = map_px_w / draw_w # == map_px_h / draw_h
|
||||
|
||||
def to_px(x: float, y: float) -> QPointF:
|
||||
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
|
||||
py = ((max_y + pad_y) - y) * scale # flip y
|
||||
return QPointF(px, py)
|
||||
|
||||
dot_r = max(4.0, min(14.0, scale * 1.4))
|
||||
dot_r = max(5.0, min(14.0, scale * 1.6))
|
||||
|
||||
# 3. Build line / block lookup maps
|
||||
# ── 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, str]] = {} # uid -> (text, badge_type, line_color)
|
||||
badge_info: dict[int, tuple[str, str]] = {} # uid → (text, badge_type)
|
||||
|
||||
for line in doc.lines:
|
||||
current_offset = 0
|
||||
cur = 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
|
||||
|
||||
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[start_uid] = (f"#{start_idx}", "single", line.color)
|
||||
badge_info[s_uid] = (f"#{cur}", "single")
|
||||
else:
|
||||
badge_info[start_uid] = (f"#{start_idx}", "start", line.color)
|
||||
badge_info[end_uid] = (f"#{end_idx}", "end", line.color)
|
||||
badge_info[s_uid] = (f"#{cur}", "start")
|
||||
badge_info[e_uid] = (f"#{cur + n - 1}", "end")
|
||||
cur += n
|
||||
|
||||
# 4. Create image
|
||||
image = QImage(img_total_w, IMAGE_HEIGHT, QImage.Format.Format_ARGB32)
|
||||
image.fill(QColor(DARK_BG))
|
||||
# 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)
|
||||
|
||||
# 5. Draw block connection lines (drawn first so dots appear on top)
|
||||
# ── 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:
|
||||
color = QColor(line.color)
|
||||
color.setAlphaF(0.35)
|
||||
pen = QPen(color, max(1.0, dot_r * 0.35))
|
||||
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:
|
||||
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
|
||||
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
|
||||
|
||||
# 6. Draw LED dots
|
||||
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, py = to_px(point.x, point.y)
|
||||
px = 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)))
|
||||
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(QPointF(px, py), dot_r, dot_r)
|
||||
painter.drawEllipse(px, dot_r, dot_r)
|
||||
|
||||
# 7. Draw start/end badges
|
||||
badge_font = QFont("Segoe UI", max(7, int(dot_r * 0.9)))
|
||||
# ── 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)
|
||||
for uid, (text, badge_type, line_color) in badge_info.items():
|
||||
|
||||
# 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:
|
||||
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)
|
||||
|
||||
return QRectF(), QColor()
|
||||
px = to_px(p.x, p.y) # type: ignore[union-attr]
|
||||
tw = fm.horizontalAdvance(text)
|
||||
th = fm.height()
|
||||
bx = px - tw / 2 - 5
|
||||
by = py + offset_y
|
||||
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(QRectF(bx, by, bw, bh), 3, 3)
|
||||
|
||||
painter.drawRoundedRect(rect, 3, 3)
|
||||
painter.setPen(QColor(Qt.GlobalColor.white))
|
||||
painter.drawText(QRectF(bx, by, bw, bh), Qt.AlignmentFlag.AlignCenter, text)
|
||||
painter.drawText(rect, 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)
|
||||
# ── 7. Legend strip ───────────────────────────────────────────────────────
|
||||
title_font = QFont("Segoe UI", 16)
|
||||
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,
|
||||
"ЛЕГЕНДА МАППИНГА",
|
||||
)
|
||||
block_font = QFont("Segoe UI", 13)
|
||||
|
||||
y = 106.0
|
||||
line_font = QFont("Segoe UI", 15)
|
||||
line_font.setBold(True)
|
||||
block_font = QFont("Segoe UI", 12)
|
||||
legend_y0 = map_px_h
|
||||
legend_h = legend_height_pre
|
||||
|
||||
for line in doc.lines:
|
||||
if y > IMAGE_HEIGHT - 60:
|
||||
break
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QBrush(legend_bg_color))
|
||||
painter.drawRect(0, legend_y0, img_w, legend_h)
|
||||
|
||||
# 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)
|
||||
# divider
|
||||
painter.setPen(QPen(divider_color, 2))
|
||||
painter.drawLine(0, legend_y0, img_w, legend_y0)
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
# Narrow colour strip left of block row
|
||||
# Colour swatch + line name
|
||||
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}",
|
||||
painter.drawRoundedRect(
|
||||
QRectF(cx, cy + 4, LEGEND_SWATCH_W, LEGEND_SWATCH_H), 3, 3
|
||||
)
|
||||
y += 27
|
||||
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
|
||||
|
||||
y += 14
|
||||
# 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))
|
||||
|
||||
12
qa_smoke.py
12
qa_smoke.py
@ -1144,11 +1144,19 @@ def test_export_mapping_to_image() -> None:
|
||||
doc.add_manual_point(100.0, 0.0)
|
||||
doc.add_manual_point(100.0, 100.0)
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
image_path = Path(temp_dir) / "output.png"
|
||||
success = render_mapping_image(doc, str(image_path))
|
||||
# Test Dark Theme
|
||||
image_path = Path(temp_dir) / "output_dark.png"
|
||||
success = render_mapping_image(doc, str(image_path), theme="dark")
|
||||
assert success
|
||||
assert image_path.exists()
|
||||
assert image_path.stat().st_size > 0
|
||||
|
||||
# Test Light Theme
|
||||
image_path_light = Path(temp_dir) / "output_light.png"
|
||||
success_light = render_mapping_image(doc, str(image_path_light), theme="light")
|
||||
assert success_light
|
||||
assert image_path_light.exists()
|
||||
assert image_path_light.stat().st_size > 0
|
||||
|
||||
|
||||
def test_save_preserves_unknown_source_fields() -> None:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user