release: prepare LED Mapper 3.1.0

- исправить точное и атомарное сохранение схем
- добавить настройку шага сетки и абсолютного размера LED
- добавить пакетное удаление выбранных блоков
- исключить генерацию устаревшего INO-файла
- синхронизировать сетку обычного режима и режима редактирования
- устранить движение схемы при переключении режима редактирования
- расширить автоматические проверки до 158 сценариев
This commit is contained in:
Данил Ярош 2026-09-08 14:13:00 +03:00
parent 691bfee068
commit f2108e053f
8 changed files with 1107 additions and 414 deletions

View File

@ -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`

171
canvas.py
View File

@ -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)

View File

@ -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)}"

163
models.py
View File

@ -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:

View File

@ -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 <initializer_list>\n\n")
file.write("#ifndef ARDUINO\n#include <Arduino.h>\n#endif\n\n")
file.write("#include <Adafruit_NeoPixel.h>\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<uint16_t> 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<uint16_t> 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` больше не создаётся.",
"",
]
),

View File

@ -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()

View File

@ -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[:]

View File

@ -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)