diff --git a/LEDMapper.spec b/LEDMapper.spec index 11fc3cd..0e9cf37 100644 --- a/LEDMapper.spec +++ b/LEDMapper.spec @@ -59,6 +59,7 @@ a = Analysis( 'icons', 'localization', 'main_window', + 'mapping_renderer', 'models', 'panel_mapping_generator', 'settings', diff --git a/main_window.py b/main_window.py index 0356ff7..2b73431 100644 --- a/main_window.py +++ b/main_window.py @@ -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: diff --git a/mapping_renderer.py b/mapping_renderer.py new file mode 100644 index 0000000..e7fa0d5 --- /dev/null +++ b/mapping_renderer.py @@ -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)) diff --git a/qa_smoke.py b/qa_smoke.py index 1937f05..e465a45 100644 --- a/qa_smoke.py +++ b/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: