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