Rewrite export to image using standalone renderer from document data
- Создан автономный модуль mapping_renderer.py, рисующий маппинг напрямую из MappingDocument. - Изображение больше не зависит от Qt-сцены и не содержит метки интерфейса или лишних id. - Разрешение выходного изображения 2400x2400+800 пикселей с боковой легендой. - Плашки start/end блоков рисуются напрямую на изображении. - Добавлено предупреждение при несохранённых изменениях (с возможностью сохранить и экспортировать сразу). - canvas.py очищен от предыдущей неработающей реализации export_to_image.
This commit is contained in:
parent
59586baca1
commit
82ce556847
@ -59,6 +59,7 @@ a = Analysis(
|
||||
'icons',
|
||||
'localization',
|
||||
'main_window',
|
||||
'mapping_renderer',
|
||||
'models',
|
||||
'panel_mapping_generator',
|
||||
'settings',
|
||||
|
||||
@ -57,6 +57,7 @@ try:
|
||||
from .settings_dialog import SettingsDialog
|
||||
from .theme import apply_app_theme, effective_theme
|
||||
from .unfolding import MAX_LINES
|
||||
from .mapping_renderer import render_mapping_image
|
||||
except ImportError:
|
||||
from background_dialog import BackgroundDialog
|
||||
from canvas import LedCanvas
|
||||
@ -75,6 +76,7 @@ except ImportError:
|
||||
from settings_dialog import SettingsDialog
|
||||
from theme import apply_app_theme, effective_theme
|
||||
from unfolding import MAX_LINES
|
||||
from mapping_renderer import render_mapping_image
|
||||
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[1]
|
||||
@ -953,11 +955,33 @@ class MainWindow(QMainWindow):
|
||||
self.status.setText("Схема пуста. Экспорт отменён.")
|
||||
self._refresh_ui_state()
|
||||
return
|
||||
|
||||
|
||||
# Warn if there are unsaved changes
|
||||
if self._dirty:
|
||||
msg = QMessageBox(self)
|
||||
msg.setIcon(QMessageBox.Icon.Warning)
|
||||
msg.setWindowTitle("Несохранённые изменения")
|
||||
msg.setText(
|
||||
"Есть несохранённые изменения. Экспорт выполняется на основе последней сохранённой версии."
|
||||
"\nСачала сохраните файл, чтобы экспорт отражал актуальные данные."
|
||||
)
|
||||
save_btn = msg.addButton("Сохранить и экспортировать", QMessageBox.ButtonRole.AcceptRole)
|
||||
continue_btn = msg.addButton("Экспортировать без сохранения", QMessageBox.ButtonRole.ActionRole)
|
||||
cancel_btn = msg.addButton("Отмена", QMessageBox.ButtonRole.RejectRole)
|
||||
msg.exec()
|
||||
clicked = msg.clickedButton()
|
||||
if clicked == cancel_btn:
|
||||
return
|
||||
if clicked == save_btn:
|
||||
if self.document.path is None:
|
||||
self.save_file_as()
|
||||
else:
|
||||
self._save_to(self.document.path)
|
||||
|
||||
default_name = "mapping.png"
|
||||
if self.document.path is not None:
|
||||
default_name = self.document.path.stem + ".png"
|
||||
|
||||
|
||||
self._disable_shortcuts()
|
||||
try:
|
||||
file_path, _ = QFileDialog.getSaveFileName(
|
||||
@ -968,11 +992,11 @@ class MainWindow(QMainWindow):
|
||||
)
|
||||
finally:
|
||||
self._enable_shortcuts()
|
||||
|
||||
|
||||
if not file_path:
|
||||
return
|
||||
|
||||
success = self.canvas.export_to_image(file_path)
|
||||
|
||||
success = render_mapping_image(self.document, file_path)
|
||||
if success:
|
||||
self.status.setText(f"Схема успешно экспортирована в: {file_path}")
|
||||
else:
|
||||
|
||||
256
mapping_renderer.py
Normal file
256
mapping_renderer.py
Normal file
@ -0,0 +1,256 @@
|
||||
"""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))
|
||||
20
qa_smoke.py
20
qa_smoke.py
@ -1134,23 +1134,21 @@ def test_line_renaming_and_tree_shortcuts() -> None:
|
||||
|
||||
|
||||
def test_export_mapping_to_image() -> None:
|
||||
if MainWindow is None or QApplication is None:
|
||||
if QApplication is None:
|
||||
return
|
||||
app = QApplication.instance() or QApplication([])
|
||||
window = MainWindow()
|
||||
|
||||
window.document.add_manual_point(100.0, 100.0)
|
||||
window.canvas.set_document(window.document)
|
||||
window._refresh_ui_state()
|
||||
|
||||
_app = QApplication.instance() or QApplication([])
|
||||
from mapping_renderer import render_mapping_image
|
||||
from models import MappingDocument
|
||||
doc = MappingDocument()
|
||||
doc.add_manual_point(0.0, 0.0)
|
||||
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 = window.canvas.export_to_image(str(image_path))
|
||||
success = render_mapping_image(doc, str(image_path))
|
||||
assert success
|
||||
assert image_path.exists()
|
||||
assert image_path.stat().st_size > 0
|
||||
|
||||
window.close()
|
||||
|
||||
|
||||
def test_save_preserves_unknown_source_fields() -> None:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user