From f2108e053ff77fc38d36e0c78683dd3791971fbd Mon Sep 17 00:00:00 2001 From: Danil Iarosh Date: Tue, 8 Sep 2026 14:13:00 +0300 Subject: [PATCH] release: prepare LED Mapper 3.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - исправить точное и атомарное сохранение схем - добавить настройку шага сетки и абсолютного размера LED - добавить пакетное удаление выбранных блоков - исключить генерацию устаревшего INO-файла - синхронизировать сетку обычного режима и режима редактирования - устранить движение схемы при переключении режима редактирования - расширить автоматические проверки до 158 сценариев --- README.md | 18 +- canvas.py | 171 ++++++++--- main_window.py | 162 +++++++---- models.py | 163 +++++++++-- panel_mapping_generator.py | 314 ++++---------------- qa_smoke.py | 583 +++++++++++++++++++++++++++++++++++-- settings.py | 39 ++- settings_dialog.py | 71 ++++- 8 files changed, 1107 insertions(+), 414 deletions(-) diff --git a/README.md b/README.md index eeb8ae8..b16052a 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,14 @@ LED Mapper — настольное приложение на Python и PySide6 - Добавление блоков вручную, из выделения, автоблоками и кистью. - Продолжение существующего блока кистью от последней точки блока. - Предпросмотр зоны действия кисти и точек, которые будут добавлены. -- Сетка 34 мм. +- Сетка с настраиваемым шагом (по умолчанию 34 мм) во всех режимах редактирования и генерации. +- Настраиваемый абсолютный диаметр LED-точек: визуальный размер не меняется при приближении. - Undo/redo для операций разметки. - Удаление назначений через `Del` и контекстные меню. +- Пакетное удаление выбранных блоков через дерево одной операцией с единым Undo. - Сворачивание боковой панели. - Сортировка таблицы выбранных точек по столбцам. -- Генерация `vm_map.h`, `virtual_matrix_legacy.ino.txt`, `ordered_circles.json`, `validation_report.txt` и README для результата. +- Генерация `vm_map.h`, `ordered_circles.json`, `validation_report.txt` и README для результата. - Сборка самостоятельного Windows EXE через PyInstaller. ## Структура приложения @@ -83,6 +85,10 @@ python -m venv .venv 6. Проверьте счетчики в верхней части боковой панели: количество линий, блоков и назначенных LED. 7. Сгенерируйте скрипт через `Файл -> Сгенерировать скрипт`. +Шаг сетки и диаметр точек меняются в `Файл -> Параметры -> Внешний вид`. Для каждого значения рядом есть кнопка `Сбросить`. Шаг сетки сохраняется в JSON текущей схемы, а диаметр точек — в пользовательских параметрах приложения. Изменение шага не перемещает уже существующие LED автоматически. + +Чтобы удалить несколько блоков, выделите их в дереве с `Ctrl` или `Shift`, затем нажмите `Ctrl+Del` или выберите удаление в контекстном меню. Приложение показывает одно подтверждение и создаёт одну операцию Undo. + Если не назначен ни один светодиод, генерация не запускается. Если назначены не все светодиоды, приложение показывает предупреждение и предлагает либо сгенерировать неполный маппинг, либо продолжить редактирование. ## Горячие клавиши @@ -96,10 +102,11 @@ python -m venv .venv | Отменить | `Ctrl+Z` | | Повторить | `Ctrl+Y` | | Удалить фиксацию | `Del` | +| Удалить выбранные точки или блоки | `Ctrl+Del` | | Удалить все | `Ctrl+Shift+Del` | | Очистить выбор | `Esc` | | Показать всю схему | `Ctrl+0` | -| Сетка 34 мм | `G` | +| Сетка | `G` | | Стрелки направления | `Ctrl+Shift+A` | | Скрыть/показать боковую панель | `F9` | | Новая линия | `Ctrl+L` | @@ -111,11 +118,14 @@ python -m venv .venv Генератор создает: - `vm_map.h` — основной header для прошивки. -- `virtual_matrix_legacy.ino.txt` — читаемая legacy-версия с `setLEDs(...)`. - `ordered_circles.json` — нормализованные точки и координаты виртуальной матрицы. - `validation_report.txt` — отчет о проверках маршрутов и ячеек. - `README.md` — краткое описание результата генерации. +Устаревший `virtual_matrix_legacy.ino.txt` больше не создаётся. Если файл с этим точным именем остался в выходной папке от прежней версии, следующая успешная генерация удалит его. Другие `.ino`-файлы не затрагиваются. + +JSON схемы хранит `coordinate_unit` и `grid_step_mm`. При первом открытии старого файла без единицы координат приложение определяет её один раз и затем сохраняет явно. Сохранение выполняется через временный файл и атомарную замену, поэтому исходный JSON не повреждается при ошибке записи. + В `vm_map.h` используются физические адреса из GUI: - `strip = line_id - 1` diff --git a/canvas.py b/canvas.py index b6c379e..47612f8 100644 --- a/canvas.py +++ b/canvas.py @@ -3,7 +3,7 @@ from __future__ import annotations import math from pathlib import Path -from PySide6.QtCore import QPointF, QRectF, QSize, Qt, Signal +from PySide6.QtCore import QPointF, QRectF, QSize, Qt, QTimer, Signal from PySide6.QtGui import QColor, QBrush, QImage, QImageReader, QPainter, QPainterPath, QPen, QPixmap, QTransform from PySide6.QtWidgets import ( QApplication, @@ -28,6 +28,7 @@ except ImportError: MAX_BACKGROUND_PIXMAP_SIDE = 8192 +MAX_GRID_LINES = 800 class LedItem(QGraphicsEllipseItem): @@ -35,6 +36,7 @@ class LedItem(QGraphicsEllipseItem): super().__init__(-radius, -radius, radius * 2, radius * 2) self.point_id = point.uid self.setFlag(QGraphicsEllipseItem.ItemIsSelectable, True) + self.setFlag(QGraphicsItem.ItemIgnoresTransformations, True) self.setToolTip(self._tooltip(point)) self.setZValue(10) @@ -129,12 +131,19 @@ class LedCanvas(QGraphicsView): self._last_brush_viewport_pos = None self._last_selected_ids: set[int] = set() self._edit_mode_enabled = False + self._pending_edit_view_state: tuple[QTransform, QPointF, QRectF] | None = None self.scene().setBackgroundBrush(QBrush(QColor("#2b2b2b"))) self.scene().selectionChanged.connect(self._emit_selection) + self.horizontalScrollBar().valueChanged.connect(self._on_grid_view_changed) + self.verticalScrollBar().valueChanged.connect(self._on_grid_view_changed) self.setToolTip("Выделение рамкой: перетащите мышью. Масштаб: колесо. Del: снять фиксацию выбранных LED.") def set_document(self, document: MappingDocument, *, reset_view: bool = True) -> None: + if document is not self.document: + self._stable_display_scale = None + self._stable_edit_bounds = None self.document = document + self._grid_step = min(1000.0, max(1.0, float(getattr(document, "grid_step_mm", 34.0)))) self.rebuild(reset_view=reset_view) def set_edit_tool(self, tool: str) -> None: @@ -156,9 +165,28 @@ class LedCanvas(QGraphicsView): def set_edit_mode_enabled(self, enabled: bool) -> None: if self._edit_mode_enabled == enabled: return + view_state = self._capture_view_state() + self._pending_edit_view_state = view_state self._edit_mode_enabled = enabled self._update_scene_rect() - self.rebuild(reset_view=False) + self._restore_view_state(view_state) + self._refresh_grid() + self._position_line_index_labels() + self._update_selection_bbox() + # Scroll bars are laid out by Qt on the next event-loop pass. Restore + # once more afterwards so their appearance/disappearance cannot move + # the scheme by several screen pixels. + QTimer.singleShot(0, self._restore_pending_edit_view_state) + + def _restore_pending_edit_view_state(self) -> None: + state = self._pending_edit_view_state + if state is None: + return + self._pending_edit_view_state = None + self._restore_view_state(state) + self._refresh_grid() + self._position_line_index_labels() + self._update_selection_bbox() def set_active_line(self, line_id: int | None) -> None: if self.active_line_id == line_id: @@ -170,6 +198,28 @@ class LedCanvas(QGraphicsView): self._grid_visible = visible self._refresh_grid() + def set_grid_step_mm(self, step_mm: float) -> None: + self._grid_step = min(1000.0, max(1.0, float(step_mm))) + self._refresh_grid() + + def _on_grid_view_changed(self, _value: int) -> None: + self._refresh_grid() + + def set_point_diameter(self, diameter_px: int) -> None: + radius = max(0.5, min(32.0, float(diameter_px) / 2.0)) + if abs(radius - self._point_radius) < 1e-9: + return + self._point_radius = radius + for item in self.items_by_id.values(): + if item is not None and isValid(item): + item.setRect(-radius, -radius, radius * 2.0, radius * 2.0) + self._update_led_content_rect() + self._update_scene_rect() + self._update_selection_bbox() + self._position_line_index_labels() + if self._last_brush_viewport_pos is not None: + self._update_brush_preview(self._last_brush_viewport_pos) + def set_direction_arrows_visible(self, visible: bool) -> None: if self._direction_arrows_visible == visible: return @@ -379,12 +429,7 @@ class LedCanvas(QGraphicsView): self.scene().addItem(item) stable_min_x, stable_max_x, stable_min_y, stable_max_y = self._stable_edit_bounds - self._content_scene_rect = QRectF( - 0, - 0, - max(stable_max_x - stable_min_x, 1.0) + self._margin * 2, - max(stable_max_y - stable_min_y, 1.0) + self._margin * 2, - ) + self._update_led_content_rect() self._refresh_background() self._update_scene_rect() self._refresh_grid() @@ -452,6 +497,16 @@ class LedCanvas(QGraphicsView): if isinstance(item, LedItem) and isValid(item) ) + def document_point_positions(self) -> list[tuple[int, float, float]]: + """Return every visible LED center converted back to document coordinates.""" + positions: list[tuple[int, float, float]] = [] + for point_id, item in sorted(self.items_by_id.items()): + if item is None or not isValid(item): + continue + document_point = self._document_point_from_scene(item.pos()) + positions.append((point_id, document_point.x(), document_point.y())) + return positions + def select_points(self, point_ids: list[int]) -> None: target_ids = set(point_ids) if target_ids == self._last_selected_ids: @@ -622,6 +677,13 @@ class LedCanvas(QGraphicsView): rect = self.sceneRect() self.fitInView(rect, Qt.KeepAspectRatio) + self._refresh_grid() + self._position_line_index_labels() + self._update_selection_bbox() + + def resizeEvent(self, event) -> None: # noqa: ANN001 + super().resizeEvent(event) + self._refresh_grid() def _capture_view_state(self) -> tuple[QTransform, QPointF, QRectF] | None: if self.viewport().rect().isEmpty(): @@ -635,12 +697,17 @@ class LedCanvas(QGraphicsView): def _restore_view_state(self, state: tuple[QTransform, QPointF, QRectF] | None) -> None: if state is None: return - transform, center, visible_rect = state + transform, _center, visible_rect = state self.setTransform(transform) + current_visible_rect = self.mapToScene(self.viewport().rect()).boundingRect() + desired_visible_rect = QRectF( + visible_rect.topLeft(), + current_visible_rect.size(), + ) scene_rect = self.scene().sceneRect() - if not scene_rect.contains(visible_rect): - self.scene().setSceneRect(scene_rect.united(visible_rect)) - self.centerOn(center) + if not scene_rect.contains(desired_visible_rect): + self.scene().setSceneRect(scene_rect.united(desired_visible_rect)) + self.centerOn(desired_visible_rect.center()) def wheelEvent(self, event) -> None: # noqa: ANN001 if self._background_drag is not None: @@ -649,9 +716,11 @@ class LedCanvas(QGraphicsView): factor = 1.15 if event.angleDelta().y() > 0 else 1 / 1.15 viewport_pos = event.position().toPoint() if hasattr(event, "position") else event.pos() self._zoom_at_viewport_pos(viewport_pos, factor) + self._refresh_grid() if self._brush_enabled: self._update_brush_zone(viewport_pos) self._position_line_index_labels() + self._update_selection_bbox() event.accept() def _zoom_at_viewport_pos(self, viewport_pos, factor: float) -> None: # noqa: ANN001 @@ -783,8 +852,7 @@ class LedCanvas(QGraphicsView): dx = scene_pos.x() - self._last_drawn_pos.x() dy = scene_pos.y() - self._last_drawn_pos.y() dist = math.hypot(dx, dy) - doc_dist = dist / (self._display_scale or 1.0) - if doc_dist >= 34.0: + if dist >= self._grid_step: point = self._document_point_from_scene(scene_pos) self.addLedRequested.emit(point.x(), point.y()) self._last_drawn_pos = scene_pos @@ -884,9 +952,22 @@ class LedCanvas(QGraphicsView): ) self._clear_rubber_band() - # Collect all LedItems inside the rubber band rectangle - items_in_rect = self.scene().items(rect, Qt.IntersectsItemBoundingRect) - hit_ids = [item.point_id for item in items_in_rect if isinstance(item, LedItem)] + # Test in viewport pixels: LED items have an absolute on-screen size. + viewport_transform = self.viewportTransform() + viewport_rect = QRectF(viewport_transform.map(origin), viewport_transform.map(scene_pos)).normalized() + hit_ids: list[int] = [] + for point_id, item in self.items_by_id.items(): + if item is None or not isValid(item): + continue + center = viewport_transform.map(item.pos()) + item_rect = QRectF( + center.x() - self._point_radius, + center.y() - self._point_radius, + self._point_radius * 2.0, + self._point_radius * 2.0, + ) + if viewport_rect.intersects(item_rect) or viewport_rect.contains(center): + hit_ids.append(point_id) keep_shift = bool(modifiers & (Qt.ShiftModifier | Qt.ControlModifier)) if keep_shift: @@ -923,8 +1004,8 @@ class LedCanvas(QGraphicsView): # Compute union bounding rect in scene coords (item pos is centre) xs = [item.pos().x() for item in selected_items] ys = [item.pos().y() for item in selected_items] - radius = selected_items[0].rect().width() / 2.0 - padding = radius + 4.0 + view_scale = max(abs(self.transform().m11()), 0.001) + padding = (self._point_radius + 4.0) / view_scale x_min = min(xs) - padding y_min = min(ys) - padding x_max = max(xs) + padding @@ -984,7 +1065,8 @@ class LedCanvas(QGraphicsView): }) self._point_drag = { - "items": drag_items + "items": drag_items, + "point_id": item.point_id if item is not None else (selected_ids[0] if len(selected_ids) == 1 else None), } self.setDragMode(QGraphicsView.NoDrag) self.viewport().setCursor(Qt.ClosedHandCursor) @@ -1023,7 +1105,10 @@ class LedCanvas(QGraphicsView): document_point = self._document_point_from_scene(item.pos()) moves.append((point_id, document_point.x(), document_point.y())) - if moves: + if len(moves) == 1: + point_id, x, y = moves[0] + self.pointMoveFinished.emit(point_id, x, y) + elif moves: self.pointsMoveFinished.emit(moves) def _start_background_edit(self, viewport_pos) -> bool: # noqa: ANN001 @@ -1327,10 +1412,25 @@ class LedCanvas(QGraphicsView): continue label_rect = label.boundingRect() label.setPos( - item.pos().x() - label_rect.width() / 2.0, + item.pos().x() - label_rect.width() / (2.0 * scale), item.pos().y() - scene_radius - scene_gap, ) + def _update_led_content_rect(self) -> None: + valid_items = [item for item in self.items_by_id.values() if item is not None and isValid(item)] + if not valid_items: + self._content_scene_rect = None + return + padding = max(self._point_radius * 4.0, 40.0) + xs = [item.pos().x() for item in valid_items] + ys = [item.pos().y() for item in valid_items] + self._content_scene_rect = QRectF( + min(xs) - padding, + min(ys) - padding, + max(max(xs) - min(xs), 1.0) + padding * 2.0, + max(max(ys) - min(ys), 1.0) + padding * 2.0, + ) + def _clear_line_index_labels(self) -> None: for label in self.line_index_label_items.values(): if isValid(label): @@ -1462,23 +1562,26 @@ class LedCanvas(QGraphicsView): if self.document is None or not self.document.points: return - min_x, max_x, min_y, max_y = self._raw_bounds + min_x, _max_x, _min_y, max_y = self._raw_bounds origin_x, origin_y = self._grid_origin_scene_units() - left_x = min_x - self._margin - right_x = max_x + self._margin - bottom_y = min_y - self._margin - top_y = max_y + self._margin - scene_rect = self.scene().sceneRect() - left_x = min(left_x, scene_rect.left() + min_x - self._margin) - right_x = max(right_x, scene_rect.right() + min_x - self._margin) - top_y = max(top_y, max_y + self._margin - scene_rect.top()) - bottom_y = min(bottom_y, max_y + self._margin - scene_rect.bottom()) + visible_scene_rect = self.mapToScene(self.viewport().rect()).boundingRect() + overscan_x = max(self._grid_step, visible_scene_rect.width() * 0.05) + overscan_y = max(self._grid_step, visible_scene_rect.height() * 0.05) + visible_scene_rect.adjust(-overscan_x, -overscan_y, overscan_x, overscan_y) + + # Convert the visible scene rectangle back to display units. The + # resulting grid density depends only on the camera, never on the + # expanded sceneRect used by scheme editing mode. + left_x = visible_scene_rect.left() - self._margin + min_x + right_x = visible_scene_rect.right() - self._margin + min_x + bottom_y = max_y - visible_scene_rect.bottom() + self._margin + top_y = max_y - visible_scene_rect.top() + self._margin first_col = math.floor((left_x - origin_x) / self._grid_step) last_col = math.ceil((right_x - origin_x) / self._grid_step) first_row = math.floor((bottom_y - origin_y) / self._grid_step) last_row = math.ceil((top_y - origin_y) / self._grid_step) - grid_span = (last_col - first_col) + (last_row - first_row) - step_multiplier = 1 + grid_line_count = max(0, last_col - first_col + 1) + max(0, last_row - first_row + 1) + step_multiplier = max(1, math.ceil(grid_line_count / MAX_GRID_LINES)) pen = QPen(QColor("#334155" if self._theme == "dark" else "#d1d5db"), 0.6) pen.setCosmetic(True) diff --git a/main_window.py b/main_window.py index 93de41d..d1bcb2f 100644 --- a/main_window.py +++ b/main_window.py @@ -205,6 +205,7 @@ class MainWindow(QMainWindow): self._redo_stack: list[tuple[MappingDocument, int | None, list[int], bool]] = [] self._pending_background_undo_state: tuple[MappingDocument, int | None, list[int], bool] | None = None self.settings = AppSettings.load() + self.document.grid_step_mm = self.settings.grid_step_mm apply_app_theme(self.settings.theme) self.actions: dict[str, QAction] = {} self.action_icon_names: dict[str, str] = {} @@ -262,7 +263,6 @@ class MainWindow(QMainWindow): self.actions["tree_rename"] = self.tree_rename_action self.tree_delete_action = QAction(self) - self.tree_delete_action.setShortcut(QKeySequence("Ctrl+Del")) self.tree_delete_action.setShortcutContext(Qt.WidgetShortcut) self.tree_delete_action.triggered.connect(self.remove_selected_tree_item) self.tree.addAction(self.tree_delete_action) @@ -379,7 +379,7 @@ class MainWindow(QMainWindow): None, "Автоматически добавить блок от выбранной LED против часовой стрелки", ) - add_action("rebuild_route", "Пересобрать", self.rebuild_active_line_blocks, "redo-2", None, "Пересобрать блоки активной линии по правилу 34 мм") + add_action("rebuild_route", "Пересобрать", self.rebuild_active_line_blocks, "redo-2", None, "Пересобрать блоки активной линии с текущим шагом сетки") brush_action = add_action("brush", "Кисть", self.toggle_brush_mode, "paintbrush", QKeySequence("B"), "Рисовать выбранный блок по точкам на схеме") brush_action.setCheckable(True) add_action("undo", "Отменить", self.undo_last_change, "undo-2", QKeySequence.Undo) @@ -403,7 +403,7 @@ class MainWindow(QMainWindow): add_action("clear_all", "Удалить все", self.clear_all_assignments, "eraser", QKeySequence("Shift+Del")) add_action("clear_selection", "Очистить выбор", lambda: self.canvas.select_points([]), "archive-restore", QKeySequence("Esc")) add_action("fit_view", "Показать всё", self.canvas.fit_to_content, "eye", QKeySequence("Ctrl+0")) - grid_action = add_action("grid", "Сетка 34 мм", self.toggle_grid, "grid-2x2", QKeySequence("G"), "Показать или скрыть сетку 34 мм") + grid_action = add_action("grid", "Сетка", self.toggle_grid, "grid-2x2", QKeySequence("G"), "Показать или скрыть сетку") grid_action.setCheckable(True) grid_action.setChecked(self.settings.show_grid_by_default) add_action("background", "Подложка", self.open_background_dialog, "image", QKeySequence("Ctrl+B"), "Добавить или изменить изображение подложки") @@ -832,8 +832,8 @@ class MainWindow(QMainWindow): actions = [ ("align_horiz", "align-horizontal", "Выровнять по горизонтали (вдоль средней линии Y)", self.align_selected_horizontally), ("align_vert", "align-vertical", "Выровнять по вертикали (вдоль средней линии X)", self.align_selected_vertically), - ("grid_horiz", "grid-horizontal", "Расставить по горизонтали с шагом 34 мм", self.arrange_selected_horizontally_grid), - ("grid_vert", "grid-vertical", "Расставить по вертикали с шагом 34 мм", self.arrange_selected_vertically_grid), + ("grid_horiz", "grid-horizontal", "Расставить по горизонтали с заданным шагом сетки", self.arrange_selected_horizontally_grid), + ("grid_vert", "grid-vertical", "Расставить по вертикали с заданным шагом сетки", self.arrange_selected_vertically_grid), ("align_grid", "grid", "Выровнять по сетке (по центру клеток)", self.align_selected_to_grid), ] @@ -892,11 +892,15 @@ class MainWindow(QMainWindow): return points = sorted([self.document.get_point(pid) for pid in selected_ids], key=lambda p: p.x) first_p = points[0] + step = self.document.grid_step_in_document_units() coords = {} for i, p in enumerate(points): - new_x = first_p.x + i * 34.0 + new_x = first_p.x + i * step coords[p.uid] = (new_x, first_p.y) - self.update_multiple_points_coordinates(coords, "Светодиоды расставлены по горизонтальной сетке с шагом 34 мм.") + self.update_multiple_points_coordinates( + coords, + f"Светодиоды расставлены по горизонтальной сетке с шагом {self.document.grid_step_mm:g} мм.", + ) def arrange_selected_vertically_grid(self) -> None: selected_ids = self.canvas.selected_point_ids() if len(selected_ids) < 2: @@ -904,11 +908,15 @@ class MainWindow(QMainWindow): return points = sorted([self.document.get_point(pid) for pid in selected_ids], key=lambda p: p.y, reverse=True) first_p = points[0] + step = self.document.grid_step_in_document_units() coords = {} for i, p in enumerate(points): - new_y = first_p.y - i * 34.0 + new_y = first_p.y - i * step coords[p.uid] = (first_p.x, new_y) - self.update_multiple_points_coordinates(coords, "Светодиоды расставлены по вертикальной сетке с шагом 34 мм.") + self.update_multiple_points_coordinates( + coords, + f"Светодиоды расставлены по вертикальной сетке с шагом {self.document.grid_step_mm:g} мм.", + ) def align_selected_to_grid(self) -> None: selected_ids = self.canvas.selected_point_ids() @@ -928,9 +936,11 @@ class MainWindow(QMainWindow): self.status.setText("Нет опорных точек для расчета сетки.") return - from models import coordinate_display_scale, infer_grid_coordinate_axes, MIN_NEIGHBOR_DISTANCE_MM - scale = coordinate_display_scale(anchors) - step = MIN_NEIGHBOR_DISTANCE_MM / scale + try: + from .models import infer_grid_coordinate_axes + except ImportError: + from models import infer_grid_coordinate_axes + step = self.document.grid_step_in_document_units() row_axis, row_sign, col_axis, col_sign = infer_grid_coordinate_axes(anchors) ref = anchors[0] @@ -1048,7 +1058,9 @@ class MainWindow(QMainWindow): self._pending_background_undo_state = None def open_settings(self) -> None: - dialog = SettingsDialog(self.settings, self) + dialog_settings = deepcopy(self.settings) + dialog_settings.grid_step_mm = self.document.grid_step_mm + dialog = SettingsDialog(dialog_settings, self) self._disable_shortcuts() try: res = dialog.exec() @@ -1056,7 +1068,14 @@ class MainWindow(QMainWindow): self._enable_shortcuts() if res != SettingsDialog.Accepted: return - self.settings = dialog.settings() + new_settings = dialog.settings() + grid_changed = abs(self.document.grid_step_mm - new_settings.grid_step_mm) > 1e-9 + if grid_changed: + previous_state = self._capture_state() + self.document.grid_step_mm = new_settings.grid_step_mm + self._push_undo_state(previous_state) + self._dirty = True + self.settings = new_settings self.settings.save() apply_app_theme(self.settings.theme) self._refresh_theme_icons() @@ -1093,16 +1112,29 @@ class MainWindow(QMainWindow): self.canvas.set_direction_arrows_visible(self.settings.show_direction_arrows_by_default) self.canvas.set_line_index_labels_visible(self.settings.show_led_ids_by_default) self.canvas.set_brush_radius(self.settings.brush_radius) + self.canvas.set_grid_step_mm(self.document.grid_step_mm) + self.canvas.set_point_diameter(self.settings.led_point_diameter_px) self.canvas.set_zoom_focus_pull(self.settings.zoom_focus_pull) self.canvas.set_pan_mode(self.settings.pan_mode) self.canvas.set_instant_space_pan(self.settings.instant_space_pan) def eventFilter(self, obj, event) -> bool: # noqa: ANN001, N802 + if event.type() == QEvent.Type.ShortcutOverride and self._is_tree_bulk_delete_event(event): + event.accept() + return self.remove_selected_tree_item() if event.type() == QEvent.Type.KeyPress and not event.isAutoRepeat(): if self._trigger_layout_independent_shortcut(event): return True return super().eventFilter(obj, event) + def _is_tree_bulk_delete_event(self, event) -> bool: # noqa: ANN001 + focus = QApplication.focusWidget() + return ( + self._physical_key_sequence(event) == "Ctrl+Del" + and focus is not None + and (focus is self.tree or self.tree.isAncestorOf(focus)) + ) + def _trigger_layout_independent_shortcut(self, event) -> bool: # noqa: ANN001 active_window = QApplication.activeWindow() if active_window is not None and active_window is not self: @@ -1117,6 +1149,8 @@ class MainWindow(QMainWindow): text_input_has_focus = isinstance(focus, (QLineEdit, QTextEdit, QPlainTextEdit, QKeySequenceEdit)) if text_input_has_focus and not (modifiers & (Qt.ControlModifier | Qt.AltModifier)): return False + if self._is_tree_bulk_delete_event(event): + return self.remove_selected_tree_item() for key in self.shortcut_action_keys: if self.settings.shortcut_for(key) != sequence: continue @@ -1294,17 +1328,13 @@ class MainWindow(QMainWindow): p = Path(path) if not p.suffix: p = p.with_suffix(".json") + loaded = MappingDocument() + loaded.grid_step_mm = self.settings.grid_step_mm try: - p.write_text('{"circles": []}', encoding="utf-8") + loaded.save(p) except Exception as exc: show_critical(self, "Ошибка создания файла", str(exc)) return - loaded = MappingDocument() - try: - loaded.load(p) - except Exception as exc: - show_critical(self, "Ошибка загрузки нового файла", str(exc)) - return self.document = loaded self._dirty = False self._undo_stack.clear() @@ -1560,6 +1590,7 @@ class MainWindow(QMainWindow): return False active_line = self._get_line_or_none(self.active_line_id) try: + self._synchronize_canvas_point_positions() self.document.save(path) except Exception as exc: self._restore_active_line(active_line) @@ -1580,6 +1611,17 @@ class MainWindow(QMainWindow): self._refresh_ui_state() return True + def _synchronize_canvas_point_positions(self) -> None: + """Commit any active graphics-item positions before serializing the document.""" + for point_id, x, y in self.canvas.document_point_positions(): + if not 0 <= point_id < len(self.document.points): + continue + point = self.document.get_point(point_id) + if abs(point.x - x) <= 1e-9 and abs(point.y - y) <= 1e-9: + continue + self.document.update_point_position(point_id, x=x, y=y) + self._dirty = True + def add_line(self) -> None: if len(self.document.lines) >= MAX_LINES: show_information( @@ -1688,7 +1730,7 @@ class MainWindow(QMainWindow): def _coordinate_spin_box(self, value: float) -> QDoubleSpinBox: spin = QDoubleSpinBox() spin.setRange(-1_000_000_000.0, 1_000_000_000.0) - spin.setDecimals(6) + spin.setDecimals(15) spin.setSingleStep(1.0) spin.setValue(float(value)) spin.setKeyboardTracking(False) @@ -1803,7 +1845,7 @@ class MainWindow(QMainWindow): answer = ask_question( self, "Пересобрать блоки", - "Пересобрать блоки активной линии по физическим соседям и правилу 34 мм?", + f"Пересобрать блоки активной линии по физическим соседям с шагом {self.document.grid_step_mm:g} мм?", ) if answer != QMessageBox.StandardButton.Yes: return @@ -1881,6 +1923,33 @@ class MainWindow(QMainWindow): self._refresh_ui_state() def remove_selected_tree_item(self) -> bool: + selected_blocks = self._selected_tree_blocks() + if selected_blocks: + blocks = [ + self._find_block(line_id, block_id) + for line_id, block_id in selected_blocks + ] + affected = len({point_id for block in blocks for point_id in block.point_ids}) + if len(selected_blocks) == 1: + if not self._confirm_delete("блок", blocks[0].name, affected): + return False + elif not self._confirm_delete_blocks(len(selected_blocks), affected): + return False + + active_line = self._get_line_or_none(self.active_line_id) + self._push_undo() + removed = self.document.remove_blocks(selected_blocks) + self._dirty = True + self._restore_active_line(active_line) + self.brush_target = None + self.canvas.select_points([]) + if len(selected_blocks) == 1: + message = f"Удален блок и фиксация LED: {removed}" + else: + message = f"Удалено блоков: {len(selected_blocks)}, снята фиксация LED: {removed}" + self._after_mapping_change(message, selection_already_updated=True) + return True + item = self.tree.currentItem() if item is None: return False @@ -1902,23 +1971,6 @@ class MainWindow(QMainWindow): self.canvas.select_points([]) self._after_mapping_change(f"Удалена линия и фиксация LED: {removed}", selection_already_updated=True) return True - if data[0] == "block": - if not self._block_exists(data[1], data[2]): - self.refresh_tree() - return False - active_line = self._get_line_or_none(data[1]) - block = self._find_block_or_none(data[1], data[2]) - if block is None: - return False - if not self._confirm_delete("блок", block.name, len(block.point_ids)): - return False - self._push_undo() - removed = self.document.remove_block(data[1], data[2]) - self._dirty = True - self._restore_active_line(active_line) - self.canvas.select_points([]) - self._after_mapping_change(f"Удален блок и фиксация LED: {removed}", selection_already_updated=True) - return True return False def clear_all_assignments(self) -> None: @@ -2250,14 +2302,9 @@ class MainWindow(QMainWindow): rename_action = menu.addAction("Переименовать блок\tF2") elif data[0] == "line": rename_action = menu.addAction("Переименовать линию\tF2") - label = "Удалить выбранную фиксацию\tCtrl+Del" - if item is not None and data is not None: - if data[0] == "line": - label = "Удалить линию\tCtrl+Del" - elif data[0] == "block": - label = "Удалить блок\tCtrl+Del" + label = self._tree_delete_label(data) delete_action = menu.addAction(label) - delete_action.setEnabled(item is not None) + delete_action.setEnabled(bool(self._selected_tree_blocks()) or item is not None) clear_all_action = menu.addAction("Удалить все фиксации\tShift+Del") clear_all_action.setEnabled(bool(self.document.lines)) fit_action = menu.addAction("Показать все точки") @@ -2583,6 +2630,16 @@ class MainWindow(QMainWindow): order[(line.id, block.id)] = len(order) return sorted(set(blocks), key=lambda block_key: order.get(block_key, 10**9)) + def _tree_delete_label(self, current_data) -> str: # noqa: ANN001 + selected_block_count = len(self._selected_tree_blocks()) + if selected_block_count > 1: + return "Удалить выбранные блоки\tCtrl+Del" + if selected_block_count == 1: + return "Удалить блок\tCtrl+Del" + if current_data and current_data[0] == "line": + return "Удалить линию\tCtrl+Del" + return "Удалить выбранную фиксацию\tCtrl+Del" + def _can_merge_selected_tree_blocks(self) -> bool: selected_blocks = self._selected_tree_blocks() return len(selected_blocks) >= 2 and len({line_id for line_id, _block_id in selected_blocks}) == 1 @@ -2942,6 +2999,14 @@ class MainWindow(QMainWindow): ) return answer == QMessageBox.StandardButton.Yes + def _confirm_delete_blocks(self, count: int, affected: int) -> bool: + answer = ask_question( + self, + "Подтвердите удаление", + f"Удалить выбранные блоки: {count} и снять фиксацию с {affected} LED?", + ) + return answer == QMessageBox.StandardButton.Yes + def _confirm_delete_points(self, count: int) -> bool: answer = ask_question( self, @@ -2981,8 +3046,7 @@ class MainWindow(QMainWindow): @staticmethod def _format_coordinate(value: float) -> str: - text = f"{value:.3f}".rstrip("0").rstrip(".") - return text if text else "0" + return repr(float(value)) def _line_summary(self, line: LedLine) -> str: return f"блоков: {len(line.blocks)}, LED: {self._line_count(line)}" diff --git a/models.py b/models.py index df07c37..65d3a4d 100644 --- a/models.py +++ b/models.py @@ -2,7 +2,9 @@ from __future__ import annotations import json import math +import os import re +import tempfile from collections import deque from dataclasses import dataclass, field from pathlib import Path @@ -14,12 +16,14 @@ except ImportError: # pragma: no cover - supports package imports in tests/tool from .unfolding import MAX_LINES, UnfoldingDescription, UnfoldingLineSummary, clockwise_sort, order_points_clockwise MIN_NEIGHBOR_DISTANCE_MM = 34.0 +DEFAULT_GRID_STEP_MM = MIN_NEIGHBOR_DISTANCE_MM MAX_NEIGHBOR_TOLERANCE_MM = 2.0 METER_LIKE_MAX_SPAN = 10.0 _DOCUMENT_KNOWN_FIELDS = { "background", "circles", "coordinate_unit", + "grid_step_mm", "leds", "lines", "unit", @@ -41,6 +45,15 @@ _LED_POINT_KNOWN_FIELDS = { } +class NominalGridDistance(float): + """Grid spacing carrying a tolerance expressed in the same coordinate unit.""" + + def __new__(cls, value: float, tolerance: float): # noqa: ANN204 + instance = super().__new__(cls, value) + instance.tolerance = tolerance + return instance + + def load_json_document(path: str | Path) -> dict[str, Any]: path = Path(path) if path.exists(): @@ -235,7 +248,8 @@ class MappingDocument: self.lines: list[LedLine] = [] self.background = BackgroundImage() self.unfolding = UnfoldingDescription() - self.coordinate_unit = "auto" + self.coordinate_unit = "mm" + self.grid_step_mm = DEFAULT_GRID_STEP_MM self.extra_payload: dict[str, Any] = {} self._next_line_id = 1 self._next_block_id = 1 @@ -247,7 +261,8 @@ class MappingDocument: self.lines.clear() self.background = BackgroundImage() self.unfolding = UnfoldingDescription() - self.coordinate_unit = "auto" + self.coordinate_unit = "mm" + self.grid_step_mm = DEFAULT_GRID_STEP_MM self.extra_payload.clear() self._next_line_id = 1 self._next_block_id = 1 @@ -261,7 +276,8 @@ class MappingDocument: for key, value in data.items() if key not in _DOCUMENT_KNOWN_FIELDS } - self.coordinate_unit = str(data.get("coordinate_unit", data.get("unit", "auto"))) + self.coordinate_unit = str(data.get("coordinate_unit", data.get("unit", "auto"))).strip().lower() + self.grid_step_mm = _positive_finite_float(data.get("grid_step_mm"), DEFAULT_GRID_STEP_MM) points_data = data.get("leds") self.collection_name = "leds" if not isinstance(points_data, list): @@ -297,6 +313,8 @@ class MappingDocument: ) ) + self._canonicalize_coordinate_unit() + self._load_lines(data) self.background = BackgroundImage.from_payload(data.get("background")) self.unfolding = UnfoldingDescription.from_payload(data.get("unfolding")) @@ -380,7 +398,29 @@ class MappingDocument: path = Path(path) self.reindex_all() payload = self.to_payload() - path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + serialized = json.dumps(payload, ensure_ascii=False, indent=2, allow_nan=False) + if json.loads(serialized) != payload: + raise ValueError("Не удалось без потерь сериализовать схему.") + temporary_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + newline="\n", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as temporary_file: + temporary_file.write(serialized) + temporary_file.write("\n") + temporary_file.flush() + os.fsync(temporary_file.fileno()) + temporary_path = Path(temporary_file.name) + os.replace(temporary_path, path) + finally: + if temporary_path is not None and temporary_path.exists(): + temporary_path.unlink() self.path = path def to_payload( @@ -398,6 +438,7 @@ class MappingDocument: ) payload["unfolding"] = self.describe_unfolding().to_payload() payload["coordinate_unit"] = self._coordinate_unit_for_save() + payload["grid_step_mm"] = self.grid_step_mm payload["background"] = self.background.to_payload() payload["lines"] = self.lines_to_payload() if lines_payload is None else lines_payload return payload @@ -603,18 +644,36 @@ class MappingDocument: return removed def remove_block(self, line_id: int, block_id: int) -> int: - line = self.get_line(line_id) + return self.remove_blocks([(line_id, block_id)]) + + def remove_blocks(self, block_keys: list[tuple[int, int]]) -> int: + target_keys = set(block_keys) + if not target_keys: + return 0 + + existing_keys = { + (line.id, block.id) + for line in self.lines + for block in line.blocks + } + missing_keys = target_keys - existing_keys + if missing_keys: + raise KeyError(f"Unknown block keys: {sorted(missing_keys)}") + removed_ids: list[int] = [] - kept_blocks: list[LedBlock] = [] - for block in line.blocks: - if block.id == block_id: - removed_ids.extend(block.point_ids) - else: - kept_blocks.append(block) - line.blocks = kept_blocks - self._clear_points(removed_ids) + for line in self.lines: + kept_blocks: list[LedBlock] = [] + for block in line.blocks: + if (line.id, block.id) in target_keys: + removed_ids.extend(block.point_ids) + else: + kept_blocks.append(block) + line.blocks = kept_blocks + + unique_removed_ids = unique_in_order(removed_ids) + self._clear_points(unique_removed_ids) self.reindex_all() - return len(removed_ids) + return len(unique_removed_ids) def remove_line(self, line_id: int) -> int: removed_ids: list[int] = [] @@ -669,7 +728,23 @@ class MappingDocument: return "m" if self.coordinate_unit in {"mm", "millimeter", "millimeters"}: return "mm" - return "m" if coordinate_display_scale(self.points) == 1000.0 else "mm" + self._canonicalize_coordinate_unit() + return self.coordinate_unit + + def _canonicalize_coordinate_unit(self) -> None: + if self.coordinate_unit in {"m", "meter", "meters"}: + unit = "m" + elif self.coordinate_unit in {"mm", "millimeter", "millimeters"}: + unit = "mm" + else: + unit = "m" if self.points and coordinate_display_scale(self.points) == 1000.0 else "mm" + self.coordinate_unit = unit + for point in self.points: + point.coordinate_unit = unit + + def grid_step_in_document_units(self) -> float: + scale = 1000.0 if self._coordinate_unit_for_save() == "m" else 1.0 + return NominalGridDistance(self.grid_step_mm / scale, MAX_NEIGHBOR_TOLERANCE_MM / scale) def _grid_cell_for_coordinate(self, x: float, y: float, *, exclude_uid: int | None = None) -> tuple[int, int]: anchors = [point for point in self.points if point.uid != exclude_uid and not point.manual] @@ -677,8 +752,7 @@ class MappingDocument: anchors = [point for point in self.points if point.uid != exclude_uid] if not anchors: return 0, 0 - scale = coordinate_display_scale(anchors) - step = MIN_NEIGHBOR_DISTANCE_MM / scale + step = self.grid_step_in_document_units() row_axis, row_sign, col_axis, col_sign = infer_grid_coordinate_axes(anchors) source_row_value = x if row_axis == "x" else y source_col_value = x if col_axis == "x" else y @@ -698,7 +772,14 @@ class MappingDocument: if reorder_leds: for block in line.blocks: start_point_id = block.point_ids[0] if block.point_ids else None - ordered_ids = [point.uid for point in order_points_like_grid_svg(self._points_for_block(block), start_point_id)] + ordered_ids = [ + point.uid + for point in order_points_like_grid_svg( + self._points_for_block(block), + start_point_id, + min_neighbor_distance=self.grid_step_in_document_units(), + ) + ] if start_point_id is not None: ordered_ids = rotate_ids_to_start(ordered_ids, start_point_id) block.point_ids = ordered_ids @@ -825,7 +906,11 @@ class MappingDocument: ] return [ [point.uid for point in route] - for route in trace_route_paths_like_grid_svg(points, start_point_id) + for route in trace_route_paths_like_grid_svg( + points, + start_point_id, + min_neighbor_distance=self.grid_step_in_document_units(), + ) ] def add_clockwise_block_from_seed(self, line_id: int, start_point_id: int) -> LedBlock: @@ -862,7 +947,7 @@ class MappingDocument: for point in self.points if point.uid == start_point_id or (point.line_id is None and point.block_id is None) ] - threshold = min_neighbor_distance_for_points(candidate_points) + threshold = self.grid_step_in_document_units() adjacency = build_route_adjacency(candidate_points, threshold) route = trace_single_route_like_grid_svg( candidate_points, @@ -887,7 +972,10 @@ class MappingDocument: ] return [ [point.uid for point in route] - for route in trace_route_paths_like_grid_svg(points) + for route in trace_route_paths_like_grid_svg( + points, + min_neighbor_distance=self.grid_step_in_document_units(), + ) ] def reindex_all(self) -> None: @@ -980,6 +1068,13 @@ def _optional_float(value: Any) -> float | None: return None +def _positive_finite_float(value: Any, fallback: float) -> float: + parsed = _optional_float(value) + if parsed is None or not math.isfinite(parsed) or parsed <= 0.0: + return fallback + return min(1000.0, max(1.0, parsed)) + + def infer_grid_coordinate_axes(points: list[LedPoint]) -> tuple[str, int, str, int]: row_x = _axis_correlation(points, "row", "x") row_y = _axis_correlation(points, "row", "y") @@ -1080,7 +1175,10 @@ def contour_groups_like_grid_svg(points: list[LedPoint]) -> list[list[LedPoint]] return sorted(groups, key=lambda group: min(point_sort_key_for_model(point) for point in group)) -def route_groups_like_grid_svg(points: list[LedPoint]) -> list[list[LedPoint]]: +def route_groups_like_grid_svg( + points: list[LedPoint], + min_neighbor_distance: float | None = None, +) -> list[list[LedPoint]]: points_by_cell: dict[tuple[int, int], list[LedPoint]] = {} for point in points: points_by_cell.setdefault((point.row, point.col), []).append(point) @@ -1089,7 +1187,7 @@ def route_groups_like_grid_svg(points: list[LedPoint]) -> list[list[LedPoint]]: seen: set[tuple[int, int]] = set() groups: list[list[LedPoint]] = [] neighbors = ((1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (-1, 1), (-1, -1), (1, -1)) - threshold = min_neighbor_distance_for_points(points) + threshold = min_neighbor_distance or min_neighbor_distance_for_points(points) for start_cell in sorted(cells): if start_cell in seen: @@ -1143,10 +1241,15 @@ def order_group_nearest(points: list[LedPoint]) -> list[LedPoint]: return ordered -def order_points_like_grid_svg(points: list[LedPoint], start_point_id: int | None = None) -> list[LedPoint]: +def order_points_like_grid_svg( + points: list[LedPoint], + start_point_id: int | None = None, + *, + min_neighbor_distance: float | None = None, +) -> list[LedPoint]: if len(points) <= 1: return points[:] - min_neighbor_distance = min_neighbor_distance_for_points(points) + min_neighbor_distance = min_neighbor_distance or min_neighbor_distance_for_points(points) remaining = points[:] if start_point_id is None: start_point = min(remaining, key=point_sort_key_for_model) @@ -1186,12 +1289,14 @@ def order_points_like_grid_svg(points: list[LedPoint], start_point_id: int | Non def trace_route_paths_like_grid_svg( points: list[LedPoint], start_point_id: int | None = None, + *, + min_neighbor_distance: float | None = None, ) -> list[list[LedPoint]]: remaining = points[:] remaining_ids = {point.uid for point in remaining} routes: list[list[LedPoint]] = [] preferred_start_id = start_point_id - min_neighbor_distance = min_neighbor_distance_for_points(points) + min_neighbor_distance = min_neighbor_distance or min_neighbor_distance_for_points(points) while remaining: if preferred_start_id is not None: start_point = next((point for point in remaining if point.uid == preferred_start_id), None) @@ -1695,11 +1800,15 @@ def max_neighbor_distance(nominal_distance: float) -> float: def neighbor_tolerance(nominal_distance: float) -> float: + explicit_tolerance = getattr(nominal_distance, "tolerance", None) + if explicit_tolerance is not None: + return float(explicit_tolerance) return MAX_NEIGHBOR_TOLERANCE_MM / (MIN_NEIGHBOR_DISTANCE_MM / nominal_distance) def min_neighbor_distance_for_points(points: list[LedPoint]) -> float: - return MIN_NEIGHBOR_DISTANCE_MM / coordinate_display_scale(points) + scale = coordinate_display_scale(points) + return NominalGridDistance(MIN_NEIGHBOR_DISTANCE_MM / scale, MAX_NEIGHBOR_TOLERANCE_MM / scale) def coordinate_display_scale(points: list[LedPoint]) -> float: diff --git a/panel_mapping_generator.py b/panel_mapping_generator.py index fdc2b43..24767e4 100644 --- a/panel_mapping_generator.py +++ b/panel_mapping_generator.py @@ -11,20 +11,20 @@ from typing import Iterable try: from .models import ( LedPoint, + MAX_NEIGHBOR_TOLERANCE_MM, MappingDocument, contour_groups_like_grid_svg as route_groups_like_grid_svg, coordinate_display_scale, - min_neighbor_distance_for_points, normalize_route_clockwise, order_points_like_grid_svg, ) except ImportError: from models import ( LedPoint, + MAX_NEIGHBOR_TOLERANCE_MM, MappingDocument, contour_groups_like_grid_svg as route_groups_like_grid_svg, coordinate_display_scale, - min_neighbor_distance_for_points, normalize_route_clockwise, order_points_like_grid_svg, ) @@ -36,6 +36,7 @@ MAX_LEDS_PER_CELL = 3 NUM_STRIPS = 4 EMPTY_STRIP = "0xFF" EMPTY_LED = "0xFFFF" +OBSOLETE_LEGACY_FILENAME = "virtual_matrix_legacy.ino.txt" @dataclass(frozen=True) @@ -69,6 +70,20 @@ class BuildResult: unassigned_count: int +def remove_obsolete_legacy_output(output_dir: Path) -> None: + """Удалить только legacy-файл, созданный прежними версиями генератора.""" + legacy_path = output_dir / OBSOLETE_LEGACY_FILENAME + if legacy_path.is_file() or legacy_path.is_symlink(): + legacy_path.unlink() + + +def cpp_float_literal(value: float) -> str: + text = format(float(value), ".9g") + if "." not in text and "e" not in text.lower(): + text += ".0" + return f"{text}f" + + def sanitize_cpp_identifier(name: str) -> str: # Transliteration dictionary for Russian Cyrillic cyrillic_translit = { @@ -133,10 +148,8 @@ def generate_panel_mapping_from_document( raise ValueError("Нет назначенных LED: перед генерацией распределите точки по линиям.") cells = build_cells(mapping_points) block_count = sum(len(line.blocks) for line in document.lines) - generated_files = { "vm_map": output_dir / "vm_map.h", - "legacy_ino": output_dir / "virtual_matrix_legacy.ino.txt", "ordered_json": output_dir / "ordered_circles.json", "validation_report": output_dir / "validation_report.txt", "readme": output_dir / "README.md", @@ -151,17 +164,10 @@ def generate_panel_mapping_from_document( generate_grid=generate_grid, generate_blocks=generate_blocks, ) - write_legacy_ino( - generated_files["legacy_ino"], - mapping_points, - cells, - document, - generate_grid=generate_grid, - generate_blocks=generate_blocks, - ) write_ordered_json(generated_files["ordered_json"], points, document) - write_validation_report(generated_files["validation_report"], points) + write_validation_report(generated_files["validation_report"], points, document.grid_step_mm) write_readme(generated_files["readme"]) + remove_obsolete_legacy_output(output_dir) vm_rows, vm_cols, grid_rows, grid_cols = get_dimensions(points) return BuildResult( @@ -182,10 +188,14 @@ def generate_panel_mapping_from_document( def normalize_document_points(document: MappingDocument) -> list[PanelLedPoint]: scale = _coordinate_scale_to_mm(document) route_groups = route_groups_like_grid_svg(document.points) + min_neighbor_distance = document.grid_step_in_document_units() ordered: list[tuple[LedPoint, int]] = [] for route_group_index, route_group in enumerate(route_groups, start=1): - route = order_points_like_grid_svg(route_group) - route = normalize_route_clockwise(route, min_neighbor_distance_for_points(route)) + route = order_points_like_grid_svg( + route_group, + min_neighbor_distance=min_neighbor_distance, + ) + route = normalize_route_clockwise(route, min_neighbor_distance) ordered.extend((point, route_group_index) for point in route) min_row = min(point.row for point, _group in ordered) @@ -299,7 +309,7 @@ def write_vm_map_header( file.write(f"#define GRID_ROW_MAX {max_row}\n") file.write(f"#define GRID_COL_MIN {min_col}\n") file.write(f"#define GRID_COL_MAX {max_col}\n") - file.write(f"#define GRID_CELL_SIZE_MM {CELL_SIZE_MM:.1f}f\n\n") + file.write(f"#define GRID_CELL_SIZE_MM {cpp_float_literal(document.grid_step_mm)}\n\n") file.write("// Source grid -> virtual matrix orientation used by this file.\n") file.write("// VM row is the source column flipped from right to left.\n") file.write("// VM column is the source row shifted to zero-based coordinates.\n") @@ -470,242 +480,6 @@ def write_vm_map_header( file.write("}\n") -def write_legacy_ino( - path: Path, - points: list[PanelLedPoint], - cells: dict[tuple[int, int], list[PanelLedPoint]], - document: MappingDocument, - generate_grid: bool = True, - generate_blocks: bool = False, -) -> None: - vm_rows, vm_cols, _grid_rows, _grid_cols = get_dimensions(points) - - # Collect blocks info - all_blocks = [] - for line in document.lines: - all_blocks.extend(line.blocks) - block_count = len(all_blocks) - - point_by_input_index = {p.input_index: p for p in points} - flat_block_leds = [] - block_ranges = [] - for block in all_blocks: - block_points = [] - for point_uid in block.point_ids: - p = point_by_input_index.get(point_uid) - if p is not None and p.strip is not None and p.led_id is not None: - block_points.append(p) - - offset = len(flat_block_leds) - count = len(block_points) - flat_block_leds.extend(block_points) - block_ranges.append((offset, count, block.name)) - - with path.open("w", encoding="utf-8", newline="\n") as file: - file.write("// ============================================================================\n") - file.write("// ARCHIVE COPY - VIRTUAL MATRIX GENERATED BY LED Mapper GUI\n") - file.write("// Saved as .ino.txt so Arduino IDE will not compile it automatically.\n") - file.write("// ============================================================================\n\n") - file.write("#include \n\n") - file.write("#ifndef ARDUINO\n#include \n#endif\n\n") - file.write("#include \n") - file.write("#ifndef NUM_STRIPS\n") - file.write(f"#define NUM_STRIPS {NUM_STRIPS}\n") - file.write("#endif\n") - file.write("extern Adafruit_NeoPixel hwStrips[NUM_STRIPS];\n\n") - - if generate_grid: - file.write("#ifndef VM_MAX_PHYS_LEDS\n") - file.write(f"#define VM_MAX_PHYS_LEDS {max((point.led_id for point in points if point.led_id is not None), default=0) + 1}\n") - file.write("#endif\n") - file.write("#ifndef VM_MAX_IDS_PER_CELL\n") - file.write(f"#define VM_MAX_IDS_PER_CELL {MAX_LEDS_PER_CELL}\n") - file.write("#endif\n") - file.write("#ifndef MATRIX_ROWS\n") - file.write(f"#define MATRIX_ROWS {vm_rows}\n") - file.write("#endif\n") - file.write("#ifndef MATRIX_COLS\n") - file.write(f"#define MATRIX_COLS {vm_cols}\n") - file.write("#endif\n\n") - file.write("struct VMPhys { uint16_t idx; uint8_t strip; };\n\n") - file.write("static uint8_t vmCount[MATRIX_ROWS][MATRIX_COLS];\n") - file.write("static VMPhys vmCell[MATRIX_ROWS][MATRIX_COLS][VM_MAX_IDS_PER_CELL];\n") - file.write("static int16_t vmRow[NUM_STRIPS][VM_MAX_PHYS_LEDS];\n") - file.write("static int16_t vmCol[NUM_STRIPS][VM_MAX_PHYS_LEDS];\n") - file.write("static uint8_t vmUsed[NUM_STRIPS][VM_MAX_PHYS_LEDS];\n") - file.write("static uint16_t vmMaxId[NUM_STRIPS];\n\n") - file.write("void clearVirtualMatrix();\n") - file.write("void setLEDs(uint16_t row, uint16_t col, uint8_t strip1based, std::initializer_list ids);\n") - file.write("void vmSetByPhys(uint8_t strip, uint16_t led, uint32_t color);\n") - - file.write("void initVirtualMatrix();\n\n") - - if generate_grid: - file.write("void clearVirtualMatrix() {\n") - file.write(" for (int r = 0; r < MATRIX_ROWS; ++r)\n") - file.write(" for (int c = 0; c < MATRIX_COLS; ++c) vmCount[r][c] = 0;\n") - file.write(" for (int s = 0; s < NUM_STRIPS; ++s) {\n") - file.write(" vmMaxId[s] = 0;\n") - file.write(" for (int i = 0; i < VM_MAX_PHYS_LEDS; ++i) {\n") - file.write(" vmRow[s][i] = -1;\n") - file.write(" vmCol[s][i] = -1;\n") - file.write(" vmUsed[s][i] = 0;\n") - file.write(" }\n") - file.write(" }\n") - file.write("}\n\n") - file.write("void setLEDs(uint16_t row, uint16_t col, uint8_t strip1based, std::initializer_list ids) {\n") - file.write(" if (row >= MATRIX_ROWS || col >= MATRIX_COLS) return;\n") - file.write(" uint8_t strip = (strip1based == 0) ? 0 : (uint8_t)(strip1based - 1);\n") - file.write(" if (strip >= NUM_STRIPS) return;\n") - file.write(" for (uint16_t id : ids) {\n") - file.write(" if (id >= VM_MAX_PHYS_LEDS) continue;\n") - file.write(" uint8_t &count = vmCount[row][col];\n") - file.write(" if (count < VM_MAX_IDS_PER_CELL) {\n") - file.write(" vmCell[row][col][count].idx = id;\n") - file.write(" vmCell[row][col][count].strip = strip;\n") - file.write(" ++count;\n") - file.write(" }\n") - file.write(" vmRow[strip][id] = (int16_t)row;\n") - file.write(" vmCol[strip][id] = (int16_t)col;\n") - file.write(" vmUsed[strip][id] = 1;\n") - file.write(" if (id > vmMaxId[strip]) vmMaxId[strip] = id;\n") - file.write(" }\n") - file.write("}\n\n") - file.write("void vmSetByPhys(uint8_t strip, uint16_t led, uint32_t color) {\n") - file.write(" if (strip >= NUM_STRIPS || led >= VM_MAX_PHYS_LEDS) return;\n") - file.write(" int16_t row = vmRow[strip][led];\n") - file.write(" int16_t col = vmCol[strip][led];\n") - file.write(" if (row < 0 || col < 0) {\n") - file.write(" if (led < hwStrips[strip].numPixels()) hwStrips[strip].setPixelColor(led, color);\n") - file.write(" return;\n") - file.write(" }\n") - file.write(" uint8_t count = vmCount[row][col];\n") - file.write(" for (uint8_t i = 0; i < count; ++i) {\n") - file.write(" const VMPhys &phys = vmCell[row][col][i];\n") - file.write(" if (phys.strip < NUM_STRIPS && phys.idx < hwStrips[phys.strip].numPixels()) {\n") - file.write(" hwStrips[phys.strip].setPixelColor(phys.idx, color);\n") - file.write(" }\n") - file.write(" }\n") - file.write("}\n\n") - - if generate_blocks: - file.write("// Compile-time block indices\n") - for index, (offset, count, name) in enumerate(block_ranges): - identifier = sanitize_cpp_identifier(name) - file.write(f"#define BLOCK_{identifier} {index}\n") - file.write("\n") - - file.write("#ifndef LED_BLOCK_COUNT\n") - file.write(f"#define LED_BLOCK_COUNT {block_count}\n") - file.write("#endif\n") - file.write("#ifndef TOTAL_BLOCK_LEDS\n") - file.write(f"#define TOTAL_BLOCK_LEDS {len(flat_block_leds)}\n") - file.write("#endif\n\n") - if not generate_grid: - file.write("struct VMPhys { uint16_t idx; uint8_t strip; };\n\n") - file.write("struct BlockRange { uint16_t offset; uint16_t count; };\n\n") - file.write("static const VMPhys PROGMEM BLOCK_LEDS[TOTAL_BLOCK_LEDS] = {\n") - for index, point in enumerate(flat_block_leds): - suffix = "," if index < len(flat_block_leds) - 1 else "" - file.write(f" {{{point.led_id}, {point.strip}}}{suffix} // block point\n") - file.write("};\n\n") - file.write("static const BlockRange PROGMEM BLOCK_RANGES[LED_BLOCK_COUNT] = {\n") - for index, (offset, count, name) in enumerate(block_ranges): - suffix = "," if index < len(block_ranges) - 1 else "" - file.write(f" {{{offset}, {count}}}{suffix} // {name}\n") - file.write("};\n\n") - - # String names array in PROGMEM - file.write("// Original string names of blocks in flash memory\n") - for index, (offset, count, name) in enumerate(block_ranges): - escaped_name = name.replace('"', '\\"') - file.write(f"static const char BLOCK_NAME_{index}[] PROGMEM = \"{escaped_name}\";\n") - file.write("\n") - - file.write("static const char* const BLOCK_NAMES[LED_BLOCK_COUNT] PROGMEM = {\n") - for index in range(len(block_ranges)): - suffix = "," if index < len(block_ranges) - 1 else "" - file.write(f" BLOCK_NAME_{index}{suffix}\n") - file.write("};\n\n") - - file.write("void setBlockLEDsCount(uint16_t blockIdx, uint16_t count, uint32_t color);\n") - file.write("void setBlockLED(uint16_t blockIdx, uint16_t ledIdxInBlock, uint32_t color);\n") - file.write("int16_t getBlockIndexByName(const char* name);\n") - file.write("void setBlockLEDsCount(const char* name, uint16_t count, uint32_t color);\n") - file.write("void setBlockLED(const char* name, uint16_t ledIdxInBlock, uint32_t color);\n\n") - - file.write("void setBlockLEDsCount(uint16_t blockIdx, uint16_t count, uint32_t color) {\n") - file.write(" if (blockIdx >= LED_BLOCK_COUNT) return;\n") - file.write(" uint16_t offset = pgm_read_word(&BLOCK_RANGES[blockIdx].offset);\n") - file.write(" uint16_t total_count = pgm_read_word(&BLOCK_RANGES[blockIdx].count);\n") - file.write(" if (count > total_count) count = total_count;\n") - file.write(" for (uint16_t i = 0; i < count; i++) {\n") - file.write(" uint8_t strip = pgm_read_byte(&BLOCK_LEDS[offset + i].strip);\n") - file.write(" uint16_t led_id = pgm_read_word(&BLOCK_LEDS[offset + i].idx);\n") - file.write(" if (strip < NUM_STRIPS) {\n") - file.write(" hwStrips[strip].setPixelColor(led_id, color);\n") - file.write(" }\n") - file.write(" }\n") - file.write("}\n\n") - - file.write("void setBlockLED(uint16_t blockIdx, uint16_t ledIdxInBlock, uint32_t color) {\n") - file.write(" if (blockIdx >= LED_BLOCK_COUNT) return;\n") - file.write(" uint16_t offset = pgm_read_word(&BLOCK_RANGES[blockIdx].offset);\n") - file.write(" uint16_t count = pgm_read_word(&BLOCK_RANGES[blockIdx].count);\n") - file.write(" if (ledIdxInBlock >= count) return;\n") - file.write(" uint8_t strip = pgm_read_byte(&BLOCK_LEDS[offset + ledIdxInBlock].strip);\n") - file.write(" uint16_t led_id = pgm_read_word(&BLOCK_LEDS[offset + ledIdxInBlock].idx);\n") - file.write(" if (strip < NUM_STRIPS) {\n") - file.write(" hwStrips[strip].setPixelColor(led_id, color);\n") - file.write(" }\n") - file.write("}\n\n") - - file.write("int16_t getBlockIndexByName(const char* name) {\n") - file.write(" for (uint16_t i = 0; i < LED_BLOCK_COUNT; i++) {\n") - file.write(" const char* blockNamePtr = (const char*)pgm_read_word(&BLOCK_NAMES[i]);\n") - file.write(" if (strcmp_P(name, blockNamePtr) == 0) {\n") - file.write(" return (int16_t)i;\n") - file.write(" }\n") - file.write(" }\n") - file.write(" return -1;\n") - file.write("}\n\n") - - file.write("void setBlockLEDsCount(const char* name, uint16_t count, uint32_t color) {\n") - file.write(" int16_t idx = getBlockIndexByName(name);\n") - file.write(" if (idx >= 0) {\n") - file.write(" setBlockLEDsCount((uint16_t)idx, count, color);\n") - file.write(" }\n") - file.write("}\n\n") - - file.write("void setBlockLED(const char* name, uint16_t ledIdxInBlock, uint32_t color) {\n") - file.write(" int16_t idx = getBlockIndexByName(name);\n") - file.write(" if (idx >= 0) {\n") - file.write(" setBlockLED((uint16_t)idx, ledIdxInBlock, color);\n") - file.write(" }\n") - file.write("}\n\n") - - file.write("void initVirtualMatrix() {\n") - if generate_grid: - file.write(" clearVirtualMatrix();\n") - last_row = None - for row in range(vm_rows): - row_cells = [(col, cells[(row, col)]) for col in range(vm_cols) if (row, col) in cells] - if not row_cells: - continue - if last_row != row: - file.write(f"\n // Row {row}\n") - last_row = row - for col, cell_points in row_cells: - by_strip: dict[int, list[int]] = defaultdict(list) - for point in cell_points: - if point.strip is not None and point.led_id is not None: - by_strip[point.strip + 1].append(point.led_id) - for strip1based, led_ids_list in sorted(by_strip.items()): - led_ids = ", ".join(str(led_id) for led_id in led_ids_list) - file.write(f" setLEDs({row}, {col:2d}, {strip1based}, {{ {led_ids} }});\n") - file.write("}\n") - - def write_ordered_json(path: Path, points: list[PanelLedPoint], document: MappingDocument) -> None: point_id_map = {point.input_index: point.generated_id for point in points} point_payloads = [] @@ -738,14 +512,18 @@ def write_ordered_json(path: Path, points: list[PanelLedPoint], document: Mappin { "source": str(document.path) if document.path is not None else "LED Mapper GUI", "unit": OUTPUT_UNIT, - "cell_size_mm": CELL_SIZE_MM, + "coordinate_unit": OUTPUT_UNIT, + "cell_size_mm": document.grid_step_mm, "ordered_id_mode": "generated_uid", } ) path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") -def collect_validation(points: list[PanelLedPoint]) -> list[str]: +def collect_validation( + points: list[PanelLedPoint], + cell_size_mm: float = CELL_SIZE_MM, +) -> list[str]: lines = [] distance_violations = [] cell_violations = [] @@ -753,7 +531,9 @@ def collect_validation(points: list[PanelLedPoint]) -> list[str]: if current.route_group != next_point.route_group: continue gap = math.hypot(current.x_mm - next_point.x_mm, current.y_mm - next_point.y_mm) - if gap > CELL_SIZE_MM + 1e-6: + diagonal = abs(current.row - next_point.row) == 1 and abs(current.col - next_point.col) == 1 + distance_limit = cell_size_mm * (math.sqrt(2.0) if diagonal else 1.0) + MAX_NEIGHBOR_TOLERANCE_MM + if gap > distance_limit + 1e-6: distance_violations.append((current, next_point, gap)) if abs(current.row - next_point.row) > 1 or abs(current.col - next_point.col) > 1: cell_violations.append((current, next_point)) @@ -763,7 +543,7 @@ def collect_validation(points: list[PanelLedPoint]) -> list[str]: lines.append(f"LED count: {len(points)}") lines.append(f"Generated ids contiguous: {not duplicate_ids}") lines.append(f"Max LEDs per cell: {max_per_cell}") - lines.append(f"Same-route distance violations > {CELL_SIZE_MM:g} mm: {len(distance_violations)}") + lines.append(f"Same-route distance violations beyond grid step + tolerance: {len(distance_violations)}") lines.append(f"Same-route 8-neighbor cell violations: {len(cell_violations)}") if max_per_cell > MAX_LEDS_PER_CELL: lines.append(f"ERROR: max LEDs per cell exceeds {MAX_LEDS_PER_CELL}") @@ -780,11 +560,15 @@ def collect_validation(points: list[PanelLedPoint]) -> list[str]: return lines -def write_validation_report(path: Path, points: list[PanelLedPoint]) -> None: +def write_validation_report( + path: Path, + points: list[PanelLedPoint], + cell_size_mm: float = CELL_SIZE_MM, +) -> None: lines = [ "Panel mapping validation", "========================", - *collect_validation(points), + *collect_validation(points, cell_size_mm), "", ] path.write_text("\n".join(lines), encoding="utf-8") @@ -794,13 +578,15 @@ def write_readme(path: Path) -> None: path.write_text( "\n".join( [ - "# Panel Mapping Build", + "# Результат генерации Panel Mapping", "", - "This folder contains files generated by the LED Mapper GUI:", - "- `vm_map.h` contains the PROGMEM virtual matrix mapping.", - "- `virtual_matrix_legacy.ino.txt` keeps the readable `setLEDs(...)` mapping.", - "- `ordered_circles.json` stores generated ids plus source row/col data.", - "- `validation_report.txt` records sequence and cell checks.", + "LED Mapper создал следующие файлы:", + "- `vm_map.h` — маппинг виртуальной матрицы в формате PROGMEM.", + "- `ordered_circles.json` — нормализованные LED и координаты матрицы.", + "- `validation_report.txt` — результаты проверки маршрутов и ячеек.", + "- `README.md` — описание набора файлов.", + "", + "Устаревший `virtual_matrix_legacy.ino.txt` больше не создаётся.", "", ] ), diff --git a/qa_smoke.py b/qa_smoke.py index 9780efc..6bd4372 100644 --- a/qa_smoke.py +++ b/qa_smoke.py @@ -2,6 +2,7 @@ from __future__ import annotations import json import importlib +import gc import re import subprocess import sys @@ -21,6 +22,7 @@ from app.models import ( build_route_adjacency, is_valid_route_neighbor, min_neighbor_distance_for_points, + neighbor_tolerance, order_points_like_grid_svg, ) from app.panel_mapping_generator import generate_panel_mapping_from_document @@ -37,6 +39,7 @@ try: from app.theme import apply_app_theme, dark_stylesheet, effective_theme, light_stylesheet from PySide6.QtCore import QPoint, QPointF, QRectF, Qt from PySide6.QtGui import QColor, QImage, QKeySequence + from PySide6.QtTest import QTest from PySide6.QtWidgets import QApplication, QDialogButtonBox, QGraphicsItem, QMessageBox, QToolBar except ImportError: canvas_module = None @@ -68,6 +71,7 @@ except ImportError: QApplication = None QDialogButtonBox = None QGraphicsItem = None + QTest = None QMessageBox = None QColor = None QImage = None @@ -92,6 +96,7 @@ def main() -> None: test_line_limit_rejects_fifth_line, test_line_indexes_are_scoped_per_line, test_remove_selected_leds_blocks_and_lines, + test_remove_blocks_across_lines_is_atomic, test_remove_block_preserves_line_numbers, test_remove_block_save_load_preserves_line_identity, test_save_load_unfolding_roundtrip, @@ -103,6 +108,9 @@ def main() -> None: test_legacy_line_without_block_id_is_preserved, test_points_without_grid_fields_load, test_manual_led_save_load_roundtrip, + test_coordinate_unit_and_exact_positions_survive_span_change, + test_failed_atomic_save_preserves_existing_file, + test_grid_step_roundtrip_and_meter_layout, test_manual_led_uses_grid_coordinates, test_manual_led_uses_transposed_source_grid_coordinates, test_remove_points_reindexes_uids_and_blocks, @@ -126,6 +134,9 @@ def main() -> None: test_saved_coordinate_unit_is_reused, test_uid_point_id_mode_does_not_fallback_to_source_ids, test_panel_mapping_generation_from_document, + test_ordered_json_meter_coordinates_reload_as_mm, + test_legacy_cleanup_waits_for_success, + test_standalone_panel_mapping_generation_without_legacy_ino, test_panel_mapping_generation_with_strategies, test_block_renaming_and_naming_strategy, test_line_renaming_and_tree_shortcuts, @@ -150,6 +161,13 @@ def main() -> None: test_canvas_shows_selected_line_index_labels, test_canvas_rebuild_can_preserve_user_zoom, test_grid_toggle_adds_grid_items, + test_edit_mode_keeps_regular_grid_spacing, + test_edit_mode_preserves_camera_and_selection, + test_led_points_keep_absolute_screen_size, + test_rubber_selection_uses_absolute_led_geometry, + test_fit_updates_absolute_selection_geometry, + test_large_absolute_leds_fit_without_clipping, + test_absolute_led_label_stays_centered_after_zoom, test_canvas_connects_new_block_to_previous_non_empty_block, test_canvas_bends_overlapping_connections, test_canvas_bends_partially_overlapping_connections, @@ -175,9 +193,11 @@ def main() -> None: test_table_uses_single_line_index_column, test_table_uid_column_displays_source_id_but_selects_internal_uid, test_led_coordinates_are_editable_in_table, + test_coordinate_editors_preserve_precision, test_main_window_can_delete_selected_points, test_table_selection_uses_selected_rows, test_tree_can_select_and_merge_multiple_blocks, + test_tree_can_delete_multiple_blocks_without_deleting_selected_lines, test_sidebar_collapses_and_expands, test_sidebar_inner_splitter_persists_sizes, test_sidebar_action_toggle_updates_state_and_constraints, @@ -187,6 +207,7 @@ def main() -> None: test_canvas_alt_drag_emits_point_move, test_canvas_alt_drag_ignores_line_index_label_hit, test_main_window_updates_point_from_canvas_drag, + test_save_commits_active_canvas_item_position, test_main_window_edit_point_dialog_imports_localized_buttons, test_canvas_pan_mode_helpers_follow_settings, test_canvas_brush_cursor_survives_pan_cursor_reset, @@ -227,9 +248,18 @@ def main() -> None: test_pyinstaller_spec_build_ref_rules, ] for test in tests: + gc.collect() + if QApplication is not None and QApplication.instance() is not None: + QApplication.instance().processEvents() + print(f"run - {test.__name__}", flush=True) test() - print(f"ok - {test.__name__}") - print(f"All {len(tests)} QA smoke checks passed.") + if QApplication is not None and QApplication.instance() is not None: + app = QApplication.instance() + for widget in app.topLevelWidgets(): + widget.deleteLater() + app.processEvents() + print(f"ok - {test.__name__}", flush=True) + print(f"All {len(tests)} QA smoke checks passed.", flush=True) def test_load_empty_json_file() -> None: @@ -380,6 +410,41 @@ def test_remove_selected_leds_blocks_and_lines() -> None: assert doc.points[4].line_id is None +def test_remove_blocks_across_lines_is_atomic() -> None: + with temp_document_path() as path: + doc = MappingDocument() + doc.load(path) + first_line = doc.add_line() + first_block = doc.add_block(first_line.id, [0, 1]) + kept_first = doc.add_block(first_line.id, [2]) + second_line = doc.add_line() + second_block = doc.add_block(second_line.id, [4, 5]) + kept_second = doc.add_block(second_line.id, [6]) + + snapshot = doc.to_payload() + try: + doc.remove_blocks([(first_line.id, first_block.id), (second_line.id, 999)]) + except KeyError: + pass + else: + raise AssertionError("Removing an unknown block must fail atomically.") + assert doc.to_payload() == snapshot + + removed = doc.remove_blocks([ + (first_line.id, first_block.id), + (second_line.id, second_block.id), + (first_line.id, first_block.id), + ]) + + assert removed == 4 + assert [line.id for line in doc.lines] == [first_line.id, second_line.id] + assert doc.get_line(first_line.id).blocks[0].point_ids == kept_first.point_ids + assert doc.get_line(second_line.id).blocks[0].point_ids == kept_second.point_ids + assert all(doc.points[point_id].line_id is None for point_id in [0, 1, 4, 5]) + assert doc.points[2].line_index == 0 + assert doc.points[6].line_index == 0 + + def test_remove_block_preserves_line_numbers() -> None: with temp_document_path() as path: doc = MappingDocument() @@ -598,6 +663,73 @@ def test_manual_led_save_load_roundtrip() -> None: assert loaded.points[0].block_id is None +def test_coordinate_unit_and_exact_positions_survive_span_change() -> None: + with tempfile.TemporaryDirectory() as temp_dir: + source = Path(temp_dir) / "legacy_without_unit.json" + saved = Path(temp_dir) / "saved.json" + source.write_text( + json.dumps({"circles": [{"id": 0, "x": 0.001, "y": 0.002}, {"id": 1, "x": 9.0, "y": 0.002}]}), + encoding="utf-8", + ) + doc = MappingDocument() + doc.load(source) + assert doc.coordinate_unit == "m" + expected = (20.1234567890123, -3.9876543210987) + doc.update_point_position(1, x=expected[0], y=expected[1]) + doc.save(saved) + + payload = json.loads(saved.read_text(encoding="utf-8")) + assert payload["coordinate_unit"] == "m" + assert payload["circles"][1]["x"] == expected[0] + assert payload["circles"][1]["y"] == expected[1] + reloaded = MappingDocument() + reloaded.load(saved) + assert (reloaded.points[1].x, reloaded.points[1].y) == expected + assert reloaded.coordinate_unit == "m" + + +def test_failed_atomic_save_preserves_existing_file() -> None: + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "mapping.json" + original = '{"sentinel": "unchanged"}' + path.write_text(original, encoding="utf-8") + doc = MappingDocument() + doc.extra_payload["not_json_serializable"] = {1, 2, 3} + try: + doc.save(path) + except TypeError: + pass + else: + raise AssertionError("Save with non-serializable payload must fail.") + assert path.read_text(encoding="utf-8") == original + assert not list(Path(temp_dir).glob(".mapping.json.*.tmp")) + + +def test_grid_step_roundtrip_and_meter_layout() -> None: + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "mapping.json" + doc = MappingDocument() + doc.coordinate_unit = "m" + doc.grid_step_mm = 50.0 + doc.points = [ + LedPoint(0, 0, 0.0, 0.0, 0, 0, coordinate_unit="m"), + LedPoint(1, 1, 0.05, 0.0, 0, 1, coordinate_unit="m"), + ] + manual = doc.add_manual_point(0.10, 0.05) + assert (manual.row, manual.col) == (1, 2) + assert abs(doc.grid_step_in_document_units() - 0.05) < 1e-12 + assert abs(neighbor_tolerance(doc.grid_step_in_document_units()) - 0.002) < 1e-12 + doc.save(path) + reloaded = MappingDocument() + reloaded.load(path) + assert reloaded.grid_step_mm == 50.0 + assert abs(reloaded.grid_step_in_document_units() - 0.05) < 1e-12 + + path.write_text(json.dumps({"circles": [], "grid_step_mm": 0.5}), encoding="utf-8") + reloaded.load(path) + assert reloaded.grid_step_mm == 1.0 + + def test_manual_led_uses_grid_coordinates() -> None: points = [ LedPoint(0, 0, 0.0, 0.0, 0, 0), @@ -947,6 +1079,7 @@ def test_counterclockwise_block_from_point_handler() -> None: window.auto_block_from_point_counterclockwise(0) assert doc.lines[0].blocks[0].point_ids == [0, 1, 2, 3] + window._dirty = False window.close() @@ -1033,9 +1166,15 @@ def test_panel_mapping_generation_from_document() -> None: with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir: doc = MappingDocument() doc.load(path) + doc.grid_step_mm = 50.0 line = doc.add_line() doc.add_block(line.id, list(range(len(doc.points)))) - result = generate_panel_mapping_from_document(doc, Path(temp_dir)) + output_dir = Path(temp_dir) + obsolete_legacy = output_dir / "virtual_matrix_legacy.ino.txt" + unrelated_ino = output_dir / "custom_controller.ino" + obsolete_legacy.write_text("stale", encoding="utf-8") + unrelated_ino.write_text("keep", encoding="utf-8") + result = generate_panel_mapping_from_document(doc, output_dir) assert result.led_count == len(doc.points) assert result.block_count == 1 @@ -1043,17 +1182,123 @@ def test_panel_mapping_generation_from_document() -> None: assert result.unassigned_count == 0 assert result.vm_rows > 0 assert result.vm_cols > 0 + assert set(result.generated_files) == { + "vm_map", + "ordered_json", + "validation_report", + "readme", + } + assert not obsolete_legacy.exists() + assert unrelated_ino.read_text(encoding="utf-8") == "keep" for output_path in result.generated_files.values(): assert output_path.exists(), output_path vm_map = result.generated_files["vm_map"].read_text(encoding="utf-8") assert "VM_LED_COUNT" in vm_map assert "#define LED_BLOCK_COUNT 1" in vm_map + assert "#define GRID_CELL_SIZE_MM 50.0f" in vm_map assert "{0, 0}" in vm_map ordered_payload = json.loads(result.generated_files["ordered_json"].read_text(encoding="utf-8")) + assert ordered_payload["cell_size_mm"] == 50.0 assert len(ordered_payload["circles"]) == len(doc.points) assert ordered_payload["circles"][0]["strip"] == 0 +def test_ordered_json_meter_coordinates_reload_as_mm() -> None: + with tempfile.TemporaryDirectory() as temp_dir: + doc = MappingDocument() + doc.coordinate_unit = "m" + doc.grid_step_mm = 50.0 + doc.points = [ + LedPoint(0, 10, 0.0, 0.0, 0, 0, coordinate_unit="m"), + LedPoint(1, 11, 0.05, 0.0, 0, 1, coordinate_unit="m"), + ] + line = doc.add_line() + doc.add_block(line.id, [0, 1]) + result = generate_panel_mapping_from_document(doc, temp_dir) + payload = json.loads(result.generated_files["ordered_json"].read_text(encoding="utf-8")) + assert payload["coordinate_unit"] == "mm" + assert payload["unit"] == "mm" + assert payload["circles"][1]["x"] == 50.0 + reloaded = MappingDocument() + reloaded.load(result.generated_files["ordered_json"]) + assert reloaded.coordinate_unit == "mm" + assert reloaded.points[1].x == 50.0 + + +def test_legacy_cleanup_waits_for_success() -> None: + import app.panel_mapping_generator as gui_generator + import panel_mapping_build.build_panel_mapping as standalone_generator + + with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir: + doc = MappingDocument() + doc.load(path) + line = doc.add_line() + doc.add_block(line.id, list(range(len(doc.points)))) + legacy = Path(temp_dir) / "virtual_matrix_legacy.ino.txt" + legacy.write_text("keep until success", encoding="utf-8") + original_writer = gui_generator.write_vm_map_header + gui_generator.write_vm_map_header = lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("write failed")) + try: + try: + gui_generator.generate_panel_mapping_from_document(doc, temp_dir) + except OSError: + pass + else: + raise AssertionError("Generator write failure must be propagated.") + finally: + gui_generator.write_vm_map_header = original_writer + assert legacy.read_text(encoding="utf-8") == "keep until success" + + with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir: + legacy = Path(temp_dir) / "virtual_matrix_legacy.ino.txt" + legacy.write_text("keep until success", encoding="utf-8") + original_writer = standalone_generator.write_vm_map_header + standalone_generator.write_vm_map_header = lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("write failed")) + try: + try: + standalone_generator.build_panel_mapping(path, temp_dir, input_unit="mm") + except OSError: + pass + else: + raise AssertionError("Standalone write failure must be propagated.") + finally: + standalone_generator.write_vm_map_header = original_writer + assert legacy.read_text(encoding="utf-8") == "keep until success" + + +def test_standalone_panel_mapping_generation_without_legacy_ino() -> None: + from panel_mapping_build.build_panel_mapping import build_panel_mapping + + with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir: + payload = json.loads(path.read_text(encoding="utf-8")) + payload["grid_step_mm"] = 50.0 + path.write_text(json.dumps(payload), encoding="utf-8") + output_dir = Path(temp_dir) + obsolete_legacy = output_dir / "virtual_matrix_legacy.ino.txt" + unrelated_ino = output_dir / "custom_controller.ino" + obsolete_legacy.write_text("stale", encoding="utf-8") + unrelated_ino.write_text("keep", encoding="utf-8") + + result = build_panel_mapping(path, output_dir, input_unit="mm") + + assert set(result.generated_files) == { + "vm_map", + "ordered_json", + "validation_report", + "readme", + } + assert all(output_path.exists() for output_path in result.generated_files.values()) + assert not obsolete_legacy.exists() + assert unrelated_ino.read_text(encoding="utf-8") == "keep" + ordered_payload = json.loads(result.generated_files["ordered_json"].read_text(encoding="utf-8")) + assert ordered_payload["cell_size_mm"] == 50.0 + assert "#define GRID_CELL_SIZE_MM 50.0f" in result.generated_files["vm_map"].read_text(encoding="utf-8") + assert [point["id"] for point in ordered_payload["leds"]] == list(range(result.led_count)) + validation_report = result.generated_files["validation_report"].read_text(encoding="utf-8") + assert "Generated ids contiguous: True" in validation_report + assert "{0, 0}" in result.generated_files["vm_map"].read_text(encoding="utf-8") + + def test_panel_mapping_generation_with_strategies() -> None: with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir: doc = MappingDocument() @@ -1073,12 +1318,6 @@ def test_panel_mapping_generation_with_strategies() -> None: assert "VM_ROWS" not in vm_map assert "VM_LED_COUNT" not in vm_map - legacy_ino = result.generated_files["legacy_ino"].read_text(encoding="utf-8") - assert "BLOCK_LEDS" in legacy_ino - assert "setBlockLED" in legacy_ino - assert "MATRIX_ROWS" not in legacy_ino - assert "vmCount" not in legacy_ino - # 2. Test Grid=True, Blocks=True result = generate_panel_mapping_from_document( doc, Path(temp_dir), generate_grid=True, generate_blocks=True @@ -1090,15 +1329,8 @@ def test_panel_mapping_generation_with_strategies() -> None: assert "VM_ROWS" in vm_map assert "VM_LED_COUNT" in vm_map - legacy_ino = result.generated_files["legacy_ino"].read_text(encoding="utf-8") - assert "BLOCK_LEDS" in legacy_ino - assert "setBlockLED" in legacy_ino - assert "MATRIX_ROWS" in legacy_ino - assert "vmCount" in legacy_ino - - def test_block_renaming_and_naming_strategy() -> None: - from panel_mapping_generator import sanitize_cpp_identifier + from app.panel_mapping_generator import sanitize_cpp_identifier assert sanitize_cpp_identifier("Блок 1") == "Blok_1" assert sanitize_cpp_identifier("Left Wing") == "Left_Wing" assert sanitize_cpp_identifier("123-Block") == "_123_Block" @@ -1119,12 +1351,6 @@ def test_block_renaming_and_naming_strategy() -> None: assert "const char BLOCK_NAME_0[] PROGMEM = \"Левое Крыло\";" in vm_map assert "getBlockIndexByName" in vm_map - legacy_ino = result.generated_files["legacy_ino"].read_text(encoding="utf-8") - assert "#define BLOCK_Levoe_Krylo 0" in legacy_ino - assert "static const char BLOCK_NAME_0[] PROGMEM = \"Левое Крыло\";" in legacy_ino - assert "getBlockIndexByName" in legacy_ino - - def test_line_renaming_and_tree_shortcuts() -> None: if MainWindow is None or QApplication is None: return @@ -1145,7 +1371,7 @@ def test_line_renaming_and_tree_shortcuts() -> None: save_path = Path(temp_dir) / "test_save.json" window.document.save(save_path) - from models import MappingDocument + from app.models import MappingDocument new_doc = MappingDocument() new_doc.load(save_path) assert len(new_doc.lines) == 1 @@ -1157,7 +1383,6 @@ def test_line_renaming_and_tree_shortcuts() -> None: assert "tree_delete" in window.actions assert "tree_merge" in window.actions assert window.actions["tree_rename"].shortcut().toString() == "F2" - assert window.actions["tree_delete"].shortcut().toString() == "Ctrl+Del" assert window.actions["tree_merge"].shortcut().toString() == "Ctrl+M" window.close() @@ -1195,7 +1420,7 @@ def test_scheme_editing_mode_actions() -> None: # Load a document with 3 points doc = MappingDocument() - from models import LedPoint + from app.models import LedPoint doc.points = [ LedPoint(uid=0, source_id=0, x=10.0, y=20.0, col=0, row=0), LedPoint(uid=1, source_id=1, x=15.0, y=10.0, col=1, row=0), @@ -1263,6 +1488,7 @@ def test_scheme_editing_mode_actions() -> None: assert abs(window.document.get_point(0).y - 20.0) < 1e-5 assert abs(window.document.get_point(1).y - 20.0) < 1e-5 # Y aligns to first point's Y + window._dirty = False window.close() @@ -1301,8 +1527,8 @@ def test_export_mapping_to_image() -> None: if QApplication is None: return _app = QApplication.instance() or QApplication([]) - from mapping_renderer import render_mapping_image - from models import MappingDocument + from app.mapping_renderer import render_mapping_image + from app.models import MappingDocument doc = MappingDocument() doc.add_manual_point(0.0, 0.0) doc.add_manual_point(100.0, 0.0) @@ -1827,6 +2053,194 @@ def test_grid_toggle_adds_grid_items() -> None: assert max(line.y1() for line in horizontal_lines) >= scene_rect.bottom() +def test_edit_mode_keeps_regular_grid_spacing() -> None: + if LedCanvas is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + with temp_document_path() as path: + doc = MappingDocument() + doc.load(path) + canvas = LedCanvas() + canvas.resize(640, 480) + canvas.set_document(doc) + canvas.set_grid_visible(True) + app.processEvents() + + def visible_grid_spacing() -> tuple[float, float]: + vertical_x = sorted( + {round(item.line().x1(), 9) for item in canvas.grid_items if item.line().x1() == item.line().x2()} + ) + horizontal_y = sorted( + {round(item.line().y1(), 9) for item in canvas.grid_items if item.line().y1() == item.line().y2()} + ) + assert len(vertical_x) >= 2 + assert len(horizontal_y) >= 2 + return vertical_x[1] - vertical_x[0], horizontal_y[1] - horizontal_y[0] + + regular_spacing = visible_grid_spacing() + canvas.set_edit_mode_enabled(True) + app.processEvents() + edit_spacing = visible_grid_spacing() + + assert abs(regular_spacing[0] - doc.grid_step_mm) < 1e-9 + assert abs(regular_spacing[1] - doc.grid_step_mm) < 1e-9 + assert edit_spacing == regular_spacing + + +def test_edit_mode_preserves_camera_and_selection() -> None: + if LedCanvas is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + with temp_document_path() as path: + doc = MappingDocument() + doc.load(path) + canvas = LedCanvas() + canvas.resize(640, 480) + canvas.set_document(doc) + canvas.resetTransform() + canvas.scale(1.75, 1.75) + canvas.centerOn(canvas.items_by_id[0].pos()) + canvas.select_points([0, 1]) + app.processEvents() + + original_items = dict(canvas.items_by_id) + original_transform = canvas.transform() + original_positions = { + point_id: canvas.mapFromScene(item.pos()) + for point_id, item in original_items.items() + } + + for enabled in (True, False): + canvas.set_edit_mode_enabled(enabled) + app.processEvents() + assert canvas.transform() == original_transform + assert canvas.selected_point_ids() == [0, 1] + assert all(canvas.items_by_id[point_id] is item for point_id, item in original_items.items()) + for point_id, item in canvas.items_by_id.items(): + current = canvas.mapFromScene(item.pos()) + original = original_positions[point_id] + assert abs(current.x() - original.x()) <= 1 + assert abs(current.y() - original.y()) <= 1 + + +def test_led_points_keep_absolute_screen_size() -> None: + if LedCanvas is None or QApplication is None or QGraphicsItem is None: + return + app = QApplication.instance() or QApplication([]) + doc = MappingDocument() + doc.points = [LedPoint(0, 0, 0.0, 0.0, 0, 0, coordinate_unit="mm")] + canvas = LedCanvas() + canvas.resize(640, 480) + canvas.set_document(doc) + canvas.set_point_diameter(17) + item = canvas.items_by_id[0] + assert item.flags() & QGraphicsItem.GraphicsItemFlag.ItemIgnoresTransformations + assert abs(item.rect().width() - 17.0) < 1e-9 + before = item.deviceTransform(canvas.viewportTransform()).mapRect(item.rect()).width() + canvas.scale(3.0, 3.0) + app.processEvents() + after = item.deviceTransform(canvas.viewportTransform()).mapRect(item.rect()).width() + assert abs(after - before) < 0.01 + canvas.close() + + +def test_rubber_selection_uses_absolute_led_geometry() -> None: + if LedCanvas is None or QApplication is None or QPointF is None: + return + app = QApplication.instance() or QApplication([]) + doc = MappingDocument() + doc.points = [LedPoint(0, 0, 0.0, 0.0, 0, 0, coordinate_unit="mm")] + canvas = LedCanvas() + canvas.resize(640, 480) + canvas.set_document(doc) + canvas.set_point_diameter(20) + canvas.resetTransform() + canvas.scale(0.25, 0.25) + center = canvas.viewportTransform().map(canvas.items_by_id[0].pos()) + canvas._rubber_origin = canvas.viewportTransform().inverted()[0].map(center + QPointF(4.0, -1.0)) + end = canvas.viewportTransform().inverted()[0].map(center + QPointF(6.0, 1.0)) + canvas._finish_rubber_band(end, Qt.NoModifier) + assert canvas.selected_point_ids() == [0] + + canvas.select_points([]) + canvas.resetTransform() + canvas.scale(4.0, 4.0) + center = canvas.viewportTransform().map(canvas.items_by_id[0].pos()) + canvas._rubber_origin = canvas.viewportTransform().inverted()[0].map(center + QPointF(20.0, -1.0)) + end = canvas.viewportTransform().inverted()[0].map(center + QPointF(22.0, 1.0)) + canvas._finish_rubber_band(end, Qt.NoModifier) + assert canvas.selected_point_ids() == [] + canvas.close() + + +def test_fit_updates_absolute_selection_geometry() -> None: + if LedCanvas is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + doc = MappingDocument() + doc.points = [ + LedPoint(0, 0, 0.0, 0.0, 0, 0, coordinate_unit="mm"), + LedPoint(1, 1, 1000.0, 0.0, 0, 1, coordinate_unit="mm"), + ] + canvas = LedCanvas() + canvas.resize(640, 480) + canvas.set_document(doc) + canvas.select_points([0]) + canvas.scale(3.0, 3.0) + canvas.fit_to_content() + app.processEvents() + item = canvas.items_by_id[0] + bbox = canvas._selection_bbox_item.rect() + screen_padding = (item.pos().x() - bbox.left()) * abs(canvas.transform().m11()) + assert abs(screen_padding - (canvas._point_radius + 4.0)) < 0.1 + canvas.close() + + +def test_large_absolute_leds_fit_without_clipping() -> None: + if LedCanvas is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + doc = MappingDocument() + doc.points = [ + LedPoint(0, 0, 0.0, 0.0, 0, 0, coordinate_unit="mm"), + LedPoint(1, 1, 1000.0, 0.0, 0, 1, coordinate_unit="mm"), + ] + canvas = LedCanvas() + canvas.resize(640, 480) + canvas.set_document(doc) + canvas.set_point_diameter(64) + canvas.fit_to_content() + app.processEvents() + viewport_width = canvas.viewport().width() + centers = [canvas.mapFromScene(canvas.items_by_id[index].pos()).x() for index in (0, 1)] + assert min(centers) >= 32 + assert max(centers) <= viewport_width - 32 + canvas.close() + + +def test_absolute_led_label_stays_centered_after_zoom() -> None: + if LedCanvas is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + doc = MappingDocument() + doc.points = [LedPoint(0, 0, 0.0, 0.0, 0, 0, coordinate_unit="mm")] + line = doc.add_line() + doc.add_block(line.id, [0]) + canvas = LedCanvas() + canvas.resize(640, 480) + canvas.set_document(doc) + canvas.set_active_line(line.id) + canvas.select_points([0]) + canvas.resetTransform() + canvas.scale(5.85, 5.85) + canvas._position_line_index_labels() + label = canvas.line_index_label_items[0] + label_center = label.deviceTransform(canvas.viewportTransform()).mapRect(label.boundingRect()).center().x() + point_center = canvas.viewportTransform().map(canvas.items_by_id[0].pos()).x() + assert abs(label_center - point_center) < 0.1 + canvas.close() + + def test_canvas_connects_new_block_to_previous_non_empty_block() -> None: if LedCanvas is None or QApplication is None: return @@ -2050,7 +2464,7 @@ def test_classic_menu_contains_expected_actions() -> None: "Правка": ["Отменить", "Повторить", "Удалить фиксацию", "Удалить точки", "Удалить все", "Очистить выбор"], "Вид": [ "Показать всё", - "Сетка 34 мм", + "Сетка", "Стрелки направления", "Отображать id светодиодов", "Подложка", @@ -2184,6 +2598,8 @@ def test_main_window_action_shortcuts_are_assigned() -> None: return app = QApplication.instance() or QApplication([]) window = MainWindow() + window.settings.shortcuts = dict(DEFAULT_SHORTCUTS) + window._apply_shortcuts_to_actions() app.processEvents() expected = { @@ -2253,6 +2669,8 @@ def test_settings_apply_to_canvas_and_generation() -> None: window.settings.zoom_focus_pull = 12 window.settings.pan_mode = "space" window.settings.instant_space_pan = True + window.settings.led_point_diameter_px = 17 + window.document.grid_step_mm = 47.5 window.settings.default_output_dir = temp_dir window.settings.confirm_partial_generation = False window._apply_settings_to_actions() @@ -2266,6 +2684,8 @@ def test_settings_apply_to_canvas_and_generation() -> None: assert window.canvas.direction_arrows_visible() is False assert window.canvas.line_index_labels_visible() is False assert window.canvas._brush_radius == 31 + assert window.canvas._grid_step == 47.5 + assert abs(window.canvas._point_radius - 8.5) < 1e-9 assert abs(window.canvas._zoom_focus_pull - 0.12) < 0.001 assert window.canvas._pan_mode == "space" assert window.canvas._instant_space_pan is True @@ -2288,6 +2708,15 @@ def test_settings_dialog_has_requested_categories() -> None: assert dialog.theme.currentData() == "system" assert dialog.pan_mode.currentData() == "middle_space" assert dialog.instant_space_pan.isChecked() is False + dialog.grid_step.setValue(50.0) + dialog.led_point_diameter.setValue(17) + updated = dialog.settings() + assert updated.grid_step_mm == 50.0 + assert updated.led_point_diameter_px == 17 + dialog.grid_step_reset.click() + dialog.led_point_diameter_reset.click() + assert dialog.grid_step.value() == 34.0 + assert dialog.led_point_diameter.value() == 9 dialog.close() @@ -2497,6 +2926,20 @@ def test_led_coordinates_are_editable_in_table() -> None: window.close() +def test_coordinate_editors_preserve_precision() -> None: + if MainWindow is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + window = MainWindow() + value = 0.0123456789012345 + text = window._format_coordinate(value) + assert float(text) == value + spin = window._coordinate_spin_box(value) + assert spin.decimals() == 15 + assert abs(spin.value() - value) < 1e-15 + window.close() + + def test_main_window_can_delete_selected_points() -> None: if MainWindow is None or QApplication is None: return @@ -2589,6 +3032,63 @@ def test_tree_can_select_and_merge_multiple_blocks() -> None: window.close() +def test_tree_can_delete_multiple_blocks_without_deleting_selected_lines() -> None: + if MainWindow is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + window = MainWindow() + with temp_document_path() as path: + doc = MappingDocument() + doc.load(path) + first_line = doc.add_line() + first_block = doc.add_block(first_line.id, [0, 1]) + kept_first = doc.add_block(first_line.id, [2]) + second_line = doc.add_line() + second_block = doc.add_block(second_line.id, [4, 5]) + kept_second = doc.add_block(second_line.id, [6]) + window.document = doc + window.active_line_id = first_line.id + window.canvas.set_document(doc) + window.refresh_tree() + window.show() + app.processEvents() + + first_line_item = window.tree.topLevelItem(0) + second_line_item = window.tree.topLevelItem(1) + first_block_item = first_line_item.child(0) + second_block_item = second_line_item.child(0) + window.tree.clearSelection() + first_line_item.setSelected(True) + first_block_item.setSelected(True) + second_block_item.setSelected(True) + app.processEvents() + + confirmations: list[tuple[int, int]] = [] + window._confirm_delete_blocks = lambda count, affected: confirmations.append((count, affected)) or True + window._confirm_delete_points = lambda _count: False + window._confirm_delete = lambda *_args: False + undo_before = len(window._undo_stack) + + assert window._tree_delete_label(first_line_item.data(0, Qt.UserRole)) == "Удалить выбранные блоки\tCtrl+Del" + window.tree.setFocus() + app.processEvents() + QTest.keyClick(window.tree, Qt.Key_Delete, Qt.ControlModifier) + app.processEvents() + assert confirmations == [(2, 4)] + assert len(window._undo_stack) == undo_before + 1 + assert [line.id for line in window.document.lines] == [first_line.id, second_line.id] + assert window.document.get_line(first_line.id).blocks[0].point_ids == kept_first.point_ids + assert window.document.get_line(second_line.id).blocks[0].point_ids == kept_second.point_ids + assert all(window.document.points[point_id].line_id is None for point_id in [0, 1, 4, 5]) + assert window.canvas.selected_point_ids() == [] + + window.undo_last_change() + assert len(window.document.get_line(first_line.id).blocks) == 2 + assert len(window.document.get_line(second_line.id).blocks) == 2 + window._dirty = False + window.close() + + def test_sidebar_collapses_and_expands() -> None: if MainWindow is None or QApplication is None: return @@ -2883,6 +3383,31 @@ def test_main_window_updates_point_from_canvas_drag() -> None: assert abs(doc.points[0].y - (fixture_points()[0]["y"] + 7.25)) < 1e-6 assert window._dirty assert window._undo_stack + window._dirty = False + window.close() + + +def test_save_commits_active_canvas_item_position() -> None: + if MainWindow is None or QApplication is None or QPointF is None: + return + app = QApplication.instance() or QApplication([]) + window = MainWindow() + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "mapping.json" + doc = MappingDocument() + doc.coordinate_unit = "mm" + doc.points = [LedPoint(0, 7, 12.25, -4.5, 0, 0, coordinate_unit="mm", manual=True)] + window.document = doc + window.canvas.set_document(doc) + item = window.canvas.items_by_id[0] + item.setPos(item.pos() + QPointF(13.125, -8.75)) + expected = window.canvas._document_point_from_scene(item.pos()) + + assert window._save_to(path) + reloaded = MappingDocument() + reloaded.load(path) + assert abs(reloaded.points[0].x - expected.x()) < 1e-12 + assert abs(reloaded.points[0].y - expected.y()) < 1e-12 window.close() diff --git a/settings.py b/settings.py index 59981fd..9c3f752 100644 --- a/settings.py +++ b/settings.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import math from dataclasses import asdict, dataclass from pathlib import Path @@ -9,6 +10,7 @@ from PySide6.QtGui import QKeySequence SHORTCUT_DEFINITIONS: list[tuple[str, str, str]] = [ + ("new", "Создать", "Ctrl+N"), ("open", "Открыть", "Ctrl+O"), ("save", "Сохранить", "Ctrl+S"), ("save_as", "Сохранить как", "Ctrl+Shift+S"), @@ -21,7 +23,7 @@ SHORTCUT_DEFINITIONS: list[tuple[str, str, str]] = [ ("clear_all", "Удалить все", "Shift+Del"), ("clear_selection", "Очистить выбор", "Esc"), ("fit_view", "Показать всё", "Ctrl+0"), - ("grid", "Сетка 34 мм", "G"), + ("grid", "Сетка", "G"), ("direction_arrows", "Стрелки направления", "Ctrl+Shift+A"), ("led_ids", "Отображать id светодиодов", "I"), ("background", "Подложка", "Ctrl+B"), @@ -40,7 +42,14 @@ SHORTCUT_DEFINITIONS: list[tuple[str, str, str]] = [ ("brush", "Кисть", "B"), ] DEFAULT_SHORTCUTS = {key: shortcut for key, _title, shortcut in SHORTCUT_DEFINITIONS} -SETTINGS_VERSION = 2 +SETTINGS_VERSION = 3 +SHORTCUT_DEFAULTS_MIGRATION_VERSION = 2 +DEFAULT_GRID_STEP_MM = 34.0 +MIN_GRID_STEP_MM = 1.0 +MAX_GRID_STEP_MM = 1000.0 +DEFAULT_LED_POINT_DIAMETER_PX = 9 +MIN_LED_POINT_DIAMETER_PX = 1 +MAX_LED_POINT_DIAMETER_PX = 64 @dataclass @@ -50,6 +59,8 @@ class AppSettings: show_grid_by_default: bool = False show_direction_arrows_by_default: bool = True show_led_ids_by_default: bool = True + grid_step_mm: float = DEFAULT_GRID_STEP_MM + led_point_diameter_px: int = DEFAULT_LED_POINT_DIAMETER_PX zoom_focus_pull: int = 35 pan_mode: str = "middle_space" instant_space_pan: bool = False @@ -74,6 +85,18 @@ class AppSettings: if hasattr(settings, key): setattr(settings, key, value) settings.version = SETTINGS_VERSION + settings.grid_step_mm = clamp_float( + settings.grid_step_mm, + MIN_GRID_STEP_MM, + MAX_GRID_STEP_MM, + DEFAULT_GRID_STEP_MM, + ) + settings.led_point_diameter_px = clamp_int( + settings.led_point_diameter_px, + MIN_LED_POINT_DIAMETER_PX, + MAX_LED_POINT_DIAMETER_PX, + DEFAULT_LED_POINT_DIAMETER_PX, + ) settings.zoom_focus_pull = clamp_int(settings.zoom_focus_pull, 0, 100, cls.zoom_focus_pull) settings.brush_radius = clamp_int(settings.brush_radius, 4, 80, cls.brush_radius) if settings.theme not in {"system", "light", "dark"}: @@ -89,7 +112,7 @@ class AppSettings: settings.sidebar_inner_sizes = normalize_splitter_sizes(settings.sidebar_inner_sizes, [360, 420]) settings.shortcuts = normalize_shortcuts( settings.shortcuts, - fill_new_defaults=saved_version < SETTINGS_VERSION, + fill_new_defaults=saved_version < SHORTCUT_DEFAULTS_MIGRATION_VERSION, ) return settings @@ -119,6 +142,16 @@ def clamp_int(value, minimum: int, maximum: int, fallback: int) -> int: # noqa: return max(minimum, min(maximum, number)) +def clamp_float(value, minimum: float, maximum: float, fallback: float) -> float: # noqa: ANN001 + try: + number = float(value) + except (TypeError, ValueError): + return fallback + if not math.isfinite(number): + return fallback + return max(minimum, min(maximum, number)) + + def normalize_splitter_sizes(value, fallback: list[int]) -> list[int]: # noqa: ANN001 if not isinstance(value, list) or len(value) != len(fallback): return fallback[:] diff --git a/settings_dialog.py b/settings_dialog.py index 950dc93..2b458db 100644 --- a/settings_dialog.py +++ b/settings_dialog.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import replace from pathlib import Path from PySide6.QtGui import QKeySequence @@ -10,6 +11,7 @@ from PySide6.QtWidgets import ( QComboBox, QDialog, QDialogButtonBox, + QDoubleSpinBox, QFileDialog, QFormLayout, QHBoxLayout, @@ -26,10 +28,32 @@ from PySide6.QtWidgets import ( try: from .localization import localize_dialog_buttons, show_warning - from .settings import DEFAULT_SHORTCUTS, SHORTCUT_DEFINITIONS, AppSettings + from .settings import ( + DEFAULT_GRID_STEP_MM, + DEFAULT_LED_POINT_DIAMETER_PX, + DEFAULT_SHORTCUTS, + MAX_GRID_STEP_MM, + MAX_LED_POINT_DIAMETER_PX, + MIN_GRID_STEP_MM, + MIN_LED_POINT_DIAMETER_PX, + SETTINGS_VERSION, + SHORTCUT_DEFINITIONS, + AppSettings, + ) except ImportError: from localization import localize_dialog_buttons, show_warning - from settings import DEFAULT_SHORTCUTS, SHORTCUT_DEFINITIONS, AppSettings + from settings import ( + DEFAULT_GRID_STEP_MM, + DEFAULT_LED_POINT_DIAMETER_PX, + DEFAULT_SHORTCUTS, + MAX_GRID_STEP_MM, + MAX_LED_POINT_DIAMETER_PX, + MIN_GRID_STEP_MM, + MIN_LED_POINT_DIAMETER_PX, + SETTINGS_VERSION, + SHORTCUT_DEFINITIONS, + AppSettings, + ) class SettingsDialog(QDialog): @@ -44,12 +68,33 @@ class SettingsDialog(QDialog): self.theme.addItem("Светлая", "light") self.theme.addItem("Темная", "dark") self.theme.setCurrentIndex(max(0, self.theme.findData(settings.theme))) - self.show_grid = QCheckBox("Показывать сетку 34 мм при запуске") + self.show_grid = QCheckBox("Показывать сетку при запуске") self.show_grid.setChecked(settings.show_grid_by_default) self.show_arrows = QCheckBox("Показывать стрелки направления при запуске") self.show_arrows.setChecked(settings.show_direction_arrows_by_default) self.show_led_ids = QCheckBox("Показывать id светодиодов при выборе") self.show_led_ids.setChecked(settings.show_led_ids_by_default) + self.grid_step = QDoubleSpinBox() + self.grid_step.setRange(MIN_GRID_STEP_MM, MAX_GRID_STEP_MM) + self.grid_step.setDecimals(2) + self.grid_step.setSingleStep(1.0) + self.grid_step.setSuffix(" мм") + self.grid_step.setValue(settings.grid_step_mm) + self.grid_step_reset = QPushButton("Сбросить") + self.grid_step_reset.clicked.connect( + lambda: self.grid_step.setValue(DEFAULT_GRID_STEP_MM) + ) + self.led_point_diameter = QSpinBox() + self.led_point_diameter.setRange( + MIN_LED_POINT_DIAMETER_PX, + MAX_LED_POINT_DIAMETER_PX, + ) + self.led_point_diameter.setSuffix(" px") + self.led_point_diameter.setValue(settings.led_point_diameter_px) + self.led_point_diameter_reset = QPushButton("Сбросить") + self.led_point_diameter_reset.clicked.connect( + lambda: self.led_point_diameter.setValue(DEFAULT_LED_POINT_DIAMETER_PX) + ) self.zoom_pull = QSpinBox() self.zoom_pull.setRange(0, 100) self.zoom_pull.setSuffix(" %") @@ -105,11 +150,15 @@ class SettingsDialog(QDialog): self.section_stack.addWidget(widget) def settings(self) -> AppSettings: - return AppSettings( + return replace( + self._settings, + version=SETTINGS_VERSION, theme=str(self.theme.currentData()), show_grid_by_default=self.show_grid.isChecked(), show_direction_arrows_by_default=self.show_arrows.isChecked(), show_led_ids_by_default=self.show_led_ids.isChecked(), + grid_step_mm=self.grid_step.value(), + led_point_diameter_px=self.led_point_diameter.value(), zoom_focus_pull=self.zoom_pull.value(), pan_mode=str(self.pan_mode.currentData()), instant_space_pan=self.instant_space_pan.isChecked(), @@ -126,10 +175,24 @@ class SettingsDialog(QDialog): layout.addRow(self.show_grid) layout.addRow(self.show_arrows) layout.addRow(self.show_led_ids) + layout.addRow("Шаг сетки", self._setting_with_reset(self.grid_step, self.grid_step_reset)) + layout.addRow( + "Диаметр LED-точек", + self._setting_with_reset(self.led_point_diameter, self.led_point_diameter_reset), + ) layout.addRow("Притяжение зума к курсору", self.zoom_pull) layout.addRow(QLabel("0% оставляет центр спокойнее, 100% сильнее тянет схему к курсору.")) return page + @staticmethod + def _setting_with_reset(editor: QWidget, reset: QPushButton) -> QWidget: + row = QWidget() + row_layout = QHBoxLayout(row) + row_layout.setContentsMargins(0, 0, 0, 0) + row_layout.addWidget(editor, 1) + row_layout.addWidget(reset) + return row + def _build_behavior_tab(self) -> QWidget: page = QWidget() layout = QFormLayout(page)