From 59bbad8288ff1941734c94592bac5c377d9d7fd7 Mon Sep 17 00:00:00 2001 From: Danil Iarosh Date: Thu, 21 May 2026 08:16:32 +0300 Subject: [PATCH] Initial LED Mapper application --- .gitattributes | 12 + .gitignore | 25 + LEDMapper.spec | 38 + README.md | 177 ++++ __init__.py | 1 + canvas.py | 608 +++++++++++++ icons.py | 50 ++ main.py | 18 + main_window.py | 1467 ++++++++++++++++++++++++++++++ models.py | 1343 ++++++++++++++++++++++++++++ panel_mapping_generator.py | 491 ++++++++++ qa_smoke.py | 1740 ++++++++++++++++++++++++++++++++++++ requirements.txt | 1 + unfolding.py | 239 +++++ 14 files changed, 6210 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 LEDMapper.spec create mode 100644 README.md create mode 100644 __init__.py create mode 100644 canvas.py create mode 100644 icons.py create mode 100644 main.py create mode 100644 main_window.py create mode 100644 models.py create mode 100644 panel_mapping_generator.py create mode 100644 qa_smoke.py create mode 100644 requirements.txt create mode 100644 unfolding.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1972f21 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +* text=auto + +*.py text eol=lf +*.md text eol=lf +*.txt text eol=lf +*.json text eol=lf +*.h text eol=lf +*.svg text eol=lf +*.spec text eol=lf + +*.xlsx binary +*.exe binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..101ad76 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class + +# Virtual environments +.venv/ +venv/ +env/ + +# PyInstaller +build/ +dist/ +*.spec~ + +# Generated mapper output +output/ + +# IDE / local agent state +.vscode/ +.codex/ + +# OS files +Thumbs.db +Desktop.ini diff --git a/LEDMapper.spec b/LEDMapper.spec new file mode 100644 index 0000000..c882afb --- /dev/null +++ b/LEDMapper.spec @@ -0,0 +1,38 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['main.py'], + pathex=['.'], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='LEDMapper', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..bd02dcb --- /dev/null +++ b/README.md @@ -0,0 +1,177 @@ +# LED Mapper + +LED Mapper — настольное приложение на Python и PySide6 для разметки LED-точек на схеме, распределения их по физическим линиям светодиодных лент и генерации `vm_map.h` для прошивки. + +Приложение помогает вручную или полуавтоматически собрать адресацию светодиодов: выбрать точки на схеме, создать линии, разбить их на блоки, проверить индексацию и сгенерировать готовые файлы маппинга. + +## Возможности + +- Загрузка карты LED-точек из JSON. +- Отображение схемы в реальных координатах. +- Выделение точек мышью и работа через таблицу выбранных LED. +- До 4 физических линий светодиодных лент. +- Блоки внутри линий, включая пустые блоки. +- Индексация LED отдельно для каждой линии, начиная с `0`. +- Индексация внутри каждого блока. +- Добавление блоков вручную, из выделения, автоблоками и кистью. +- Продолжение существующего блока кистью от последней точки блока. +- Предпросмотр зоны действия кисти и точек, которые будут добавлены. +- Сетка 34 мм. +- Undo/redo для операций разметки. +- Удаление назначений через `Del` и контекстные меню. +- Сворачивание боковой панели. +- Сортировка таблицы выбранных точек по столбцам. +- Генерация `vm_map.h`, `virtual_matrix_legacy.ino.txt`, `ordered_circles.json`, `validation_report.txt` и README для результата. +- Сборка самостоятельного Windows EXE через PyInstaller. + +## Структура приложения + +```text +main.py Точка входа GUI-приложения +main_window.py Главное окно, меню, действия, таблица и дерево линий +canvas.py Холст схемы, выделение, кисть, сетка и связи +models.py Модель карты, линий, блоков и алгоритмы маршрутов +panel_mapping_generator.py Генерация файлов маппинга +icons.py Встроенные lucide-иконки +unfolding.py Описание развертки линий и блоков +qa_smoke.py Smoke-тесты приложения +requirements.txt Runtime-зависимости +LEDMapper.spec PyInstaller-конфигурация для сборки EXE +``` + +## Требования + +- Windows. +- Python 3.10 или новее. +- PySide6. + +Для разработки и запуска используется локальное виртуальное окружение проекта. + +## Быстрый старт + +Из корневой папки проекта создайте окружение: + +```powershell +python -m venv .venv +``` + +Установите зависимости: + +```powershell +.\.venv\Scripts\python.exe -m pip install -r app\requirements.txt +``` + +Запустите приложение: + +```powershell +.\.venv\Scripts\python.exe app\main.py +``` + +Если вы находитесь внутри папки `app`, запуск выглядит так: + +```powershell +..\.venv\Scripts\python.exe main.py +``` + +## Основной сценарий работы + +1. Откройте JSON-карту точек через `Файл -> Открыть`. +2. Создайте линию кнопкой `Линия` или через меню `Инструменты -> Новая линия`. +3. Выберите точки на схеме или в таблице. +4. Создайте блок из выбранных точек, автоблоки или включите инструмент `Кисть`. +5. Повторите разметку для остальных линий и блоков. +6. Проверьте счетчики в верхней части боковой панели: количество линий, блоков и назначенных LED. +7. Сгенерируйте скрипт через `Файл -> Сгенерировать скрипт`. + +Если не назначен ни один светодиод, генерация не запускается. Если назначены не все светодиоды, приложение показывает предупреждение и предлагает либо сгенерировать неполный маппинг, либо продолжить редактирование. + +## Горячие клавиши + +| Действие | Клавиши | +| --- | --- | +| Открыть | `Ctrl+O` | +| Сохранить | `Ctrl+S` | +| Сохранить как | `Ctrl+Shift+S` | +| Сгенерировать скрипт | `Ctrl+G` | +| Отменить | `Ctrl+Z` | +| Повторить | `Ctrl+Y` | +| Удалить фиксацию | `Del` | +| Удалить все | `Ctrl+Shift+Del` | +| Очистить выбор | `Esc` | +| Показать всю схему | `Ctrl+0` | +| Сетка 34 мм | `G` | +| Скрыть/показать боковую панель | `F9` | +| Новая линия | `Ctrl+L` | +| Автоблоки | `Ctrl+Shift+B` | +| Кисть | `B` | + +## Генерация файлов маппинга + +Генератор создает: + +- `vm_map.h` — основной header для прошивки. +- `virtual_matrix_legacy.ino.txt` — читаемая legacy-версия с `setLEDs(...)`. +- `ordered_circles.json` — нормализованные точки и координаты виртуальной матрицы. +- `validation_report.txt` — отчет о проверках маршрутов и ячеек. +- `README.md` — краткое описание результата генерации. + +В `vm_map.h` используются физические адреса из GUI: + +- `strip = line_id - 1` +- `led_id = line_index` + +Неназначенные точки не попадают в рабочий `VM_MAPPINGS`, но исходные точки могут оставаться в служебных метаданных. + +## Ориентация виртуальной матрицы + +Текущий формат явно описывает преобразование координат: + +```cpp +#define VM_ROW_FROM_GRID_COL(col) (GRID_COL_MAX - (col)) +#define VM_COL_FROM_GRID_ROW(row) ((row) - GRID_ROW_MIN) +``` + +То есть строка виртуальной матрицы получается из исходной колонки с отражением, а колонка виртуальной матрицы получается из исходного ряда. + +## Проверка качества + +Из корня проекта: + +```powershell +.\.venv\Scripts\python.exe app\qa_smoke.py +``` + +Проверка компиляции основных модулей: + +```powershell +.\.venv\Scripts\python.exe -m py_compile app\main.py app\main_window.py app\canvas.py app\models.py app\panel_mapping_generator.py app\qa_smoke.py +``` + +## Сборка EXE + +Установите PyInstaller в `.venv`: + +```powershell +.\.venv\Scripts\python.exe -m pip install pyinstaller +``` + +Сборка из корня проекта: + +```powershell +.\.venv\Scripts\python.exe -m PyInstaller --noconfirm --clean app\LEDMapper.spec +``` + +Готовый файл появится здесь: + +```text +dist/LEDMapper.exe +``` + +`build/` и `dist/` не коммитятся, потому что это воспроизводимые артефакты сборки. + +## Примечания для разработки + +- Для работы с приложением использовать только Python из `.venv`. +- Не коммитить `.venv`, `build`, `dist`, `output` и `__pycache__`. +- Перед коммитом желательно запускать `app\qa_smoke.py`. +- Максимальное количество линий светодиодных лент: 4. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..cd9b1a3 --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +"""LED Mapper Editor application package.""" diff --git a/canvas.py b/canvas.py new file mode 100644 index 0000000..0f6a3c2 --- /dev/null +++ b/canvas.py @@ -0,0 +1,608 @@ +from __future__ import annotations + +import math + +from PySide6.QtCore import QPointF, QRectF, Qt, Signal +from PySide6.QtGui import QColor, QBrush, QPainter, QPainterPath, QPen +from PySide6.QtWidgets import ( + QGraphicsEllipseItem, + QGraphicsItem, + QGraphicsLineItem, + QGraphicsPathItem, + QGraphicsScene, + QGraphicsView, + QMenu, + QScrollBar, +) +from shiboken6 import isValid + +try: + from .models import LedLine, LedPoint, MappingDocument, coordinate_display_scale +except ImportError: + from models import LedLine, LedPoint, MappingDocument, coordinate_display_scale + + +class LedItem(QGraphicsEllipseItem): + def __init__(self, point: LedPoint, radius: float) -> None: + super().__init__(-radius, -radius, radius * 2, radius * 2) + self.point_id = point.uid + self.setFlag(QGraphicsEllipseItem.ItemIsSelectable, True) + self.setToolTip(self._tooltip(point)) + self.setZValue(10) + + @staticmethod + def _tooltip(point: LedPoint) -> str: + line = "-" if point.line_id is None else str(point.line_id) + block = "-" if point.block_id is None else str(point.block_id) + line_index = "-" if point.line_id is None or point.line_index is None else f"Л{point.line_id}: {point.line_index}" + block_index = "-" if point.block_index is None else str(point.block_index) + return ( + f"Источник: {point.source_id}\n" + f"Сетка: ряд {point.row}, колонка {point.col}\n" + f"Линия: {line}, блок: {block}\n" + f"Индекс в линии: {line_index}\n" + f"Индекс в блоке: {block_index}" + ) + + +class LedCanvas(QGraphicsView): + selectionChanged = Signal(list) + deleteSelectionRequested = Signal() + autoBlockFromPointRequested = Signal(int) + brushPointTouched = Signal(int) + brushStrokeFinished = Signal() + + def __init__(self) -> None: + super().__init__() + self.setScene(QGraphicsScene(self)) + self.setRenderHint(QPainter.Antialiasing) + self.setMouseTracking(True) + self.viewport().setMouseTracking(True) + self.setDragMode(QGraphicsView.RubberBandDrag) + self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) + self.setResizeAnchor(QGraphicsView.AnchorUnderMouse) + self.document: MappingDocument | None = None + self.active_line_id: int | None = None + self.items_by_id: dict[int, LedItem] = {} + self.grid_items: list[QGraphicsLineItem] = [] + self.connection_items: list[QGraphicsPathItem] = [] + self._point_radius = 4.5 + self._brush_radius = 18.0 + self._brush_zone_item: QGraphicsEllipseItem | None = None + self._grid_visible = False + self._grid_step = 34.0 + self._raw_bounds: tuple[float, float, float, float] | None = None + self._display_scale = 1.0 + self._margin = 40.0 + self._is_middle_button_panning = False + self._last_pan_pos = None + self._brush_enabled = False + self._is_brush_stroke = False + self._brush_touched_ids: set[int] = set() + self._brush_preview_ids: set[int] = set() + self._brush_excluded_point_ids: set[int] = set() + self._last_brush_viewport_pos = None + self._last_selected_ids: set[int] = set() + self.scene().selectionChanged.connect(self._emit_selection) + self.setToolTip("Выделение рамкой: перетащите мышью. Масштаб: колесо. Del: снять фиксацию выбранных LED.") + + def set_document(self, document: MappingDocument, *, reset_view: bool = True) -> None: + self.document = document + self.rebuild(reset_view=reset_view) + + def set_active_line(self, line_id: int | None) -> None: + if self.active_line_id == line_id: + return + self.active_line_id = line_id + self.refresh_styles() + + def set_grid_visible(self, visible: bool) -> None: + self._grid_visible = visible + self._refresh_grid() + + def set_brush_enabled(self, enabled: bool) -> None: + self._brush_enabled = enabled + self._is_brush_stroke = False + self._clear_brush_state() + self.setDragMode(QGraphicsView.NoDrag if enabled else QGraphicsView.RubberBandDrag) + if enabled: + self._ensure_brush_zone() + else: + self._hide_brush_zone() + self._apply_default_cursor() + + def set_brush_excluded_point_ids(self, point_ids: set[int] | list[int]) -> None: + self._brush_excluded_point_ids = set(point_ids) + changed_ids = self._brush_preview_ids | self._brush_touched_ids + self._brush_preview_ids.difference_update(self._brush_excluded_point_ids) + self._refresh_brush_preview_styles(changed_ids) + + def rebuild(self, *, reset_view: bool = True) -> None: + old_transform = self.transform() + old_center = self.mapToScene(self.viewport().rect().center()) + scene = self.scene() + scene.blockSignals(True) + self.setUpdatesEnabled(False) + try: + self.items_by_id.clear() + self.grid_items.clear() + self.connection_items.clear() + self._brush_zone_item = None + self._brush_preview_ids.clear() + self._brush_touched_ids.clear() + self._last_selected_ids.clear() + scene.clear() + finally: + scene.blockSignals(False) + self.setUpdatesEnabled(True) + self._raw_bounds = None + if self.document is None or not self.document.points: + return + + self._display_scale = coordinate_display_scale(self.document.points) + xs = [point.x * self._display_scale for point in self.document.points] + ys = [point.y * self._display_scale for point in self.document.points] + min_x, max_x = min(xs), max(xs) + min_y, max_y = min(ys), max(ys) + self._raw_bounds = (min_x, max_x, min_y, max_y) + self._margin = max(self._point_radius * 4, 40) + + for point in self.document.points: + item = LedItem(point, self._point_radius) + item.setPos(self._to_scene_point(point.x, point.y)) + self.items_by_id[point.uid] = item + self.scene().addItem(item) + + self.scene().setSceneRect( + QRectF( + 0, + 0, + max(max_x - min_x, 1.0) + self._margin * 2, + max(max_y - min_y, 1.0) + self._margin * 2, + ) + ) + self._refresh_grid() + self.refresh_styles() + if reset_view: + self.fit_to_content() + else: + self.setTransform(old_transform) + self.centerOn(old_center) + + def refresh_styles(self, redraw_connections: bool = True) -> None: + if self.document is None: + return + if redraw_connections: + self._clear_connections() + line_by_id = {line.id: line for line in self.document.lines} + for point in self.document.points: + item = self.items_by_id.get(point.uid) + if item is None or not isValid(item): + continue + color = QColor("#94a3b8") + if point.line_id in line_by_id: + color = QColor(line_by_id[point.line_id].color) + if self.active_line_id is not None and point.line_id != self.active_line_id: + color.setAlphaF(0.45) + item.setBrush(color) + is_active_line_point = self.active_line_id is not None and point.line_id == self.active_line_id + try: + pen_color = QColor("#111827") if item.isSelected() else QColor("#ffffff") + item.setPen(QPen(pen_color, 2.0 if is_active_line_point else 1.2)) + item.setToolTip(LedItem._tooltip(point)) + except RuntimeError: + continue + self._last_selected_ids = { + item.point_id + for item in self.scene().selectedItems() + if isinstance(item, LedItem) and isValid(item) + } + if redraw_connections: + for line in self.document.lines: + self._draw_line_connections(line) + self._refresh_brush_preview_styles(self._brush_preview_ids | self._brush_touched_ids) + + def selected_point_ids(self) -> list[int]: + return sorted( + item.point_id + for item in self.scene().selectedItems() + if isinstance(item, LedItem) and isValid(item) + ) + + def select_points(self, point_ids: list[int]) -> None: + target_ids = set(point_ids) + if target_ids == self._last_selected_ids: + return + scene = self.scene() + scene.blockSignals(True) + self.setUpdatesEnabled(False) + try: + for item in self.items_by_id.values(): + if not isValid(item): + continue + should_select = item.point_id in target_ids + if item.isSelected() != should_select: + item.setSelected(should_select) + finally: + self.setUpdatesEnabled(True) + scene.blockSignals(False) + self._emit_selection() + + def fit_to_content(self) -> None: + if self.scene() is None or self.sceneRect().isEmpty(): + return + self.fitInView(self.sceneRect(), Qt.KeepAspectRatio) + + def wheelEvent(self, event) -> None: # noqa: ANN001 + factor = 1.15 if event.angleDelta().y() > 0 else 1 / 1.15 + self.scale(factor, factor) + if self._brush_enabled and self._last_brush_viewport_pos is not None: + self._update_brush_zone(self._last_brush_viewport_pos) + + def keyPressEvent(self, event) -> None: # noqa: ANN001 + if event.key() == Qt.Key_Delete: + self.deleteSelectionRequested.emit() + event.accept() + return + super().keyPressEvent(event) + + def mousePressEvent(self, event) -> None: # noqa: ANN001 + if event.button() == Qt.RightButton: + event.accept() + return + if event.button() == Qt.MiddleButton: + self._is_middle_button_panning = True + self._last_pan_pos = event.pos() + self.setCursor(Qt.ClosedHandCursor) + self.viewport().setCursor(Qt.ClosedHandCursor) + event.accept() + return + if self._brush_enabled and event.button() == Qt.LeftButton: + self._is_brush_stroke = True + self._brush_touched_ids.clear() + self._update_brush_zone(event.pos()) + self._touch_brush_point(event.pos()) + event.accept() + return + super().mousePressEvent(event) + + def mouseMoveEvent(self, event) -> None: # noqa: ANN001 + if self._is_middle_button_panning and self._last_pan_pos is not None: + delta = event.pos() - self._last_pan_pos + self._last_pan_pos = event.pos() + self._move_scrollbar(self.horizontalScrollBar(), delta.x()) + self._move_scrollbar(self.verticalScrollBar(), delta.y()) + event.accept() + return + if self._brush_enabled and self._is_brush_stroke: + self._update_brush_zone(event.pos()) + self._touch_brush_point(event.pos()) + event.accept() + return + if self._brush_enabled: + self._update_brush_zone(event.pos()) + event.accept() + return + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event) -> None: # noqa: ANN001 + if event.button() == Qt.RightButton: + event.accept() + return + if event.button() == Qt.MiddleButton and self._is_middle_button_panning: + self._is_middle_button_panning = False + self._last_pan_pos = None + self._apply_default_cursor() + event.accept() + return + if self._brush_enabled and event.button() == Qt.LeftButton and self._is_brush_stroke: + self._update_brush_zone(event.pos()) + self._touch_brush_point(event.pos()) + self._is_brush_stroke = False + self._clear_brush_state() + self.brushStrokeFinished.emit() + event.accept() + return + super().mouseReleaseEvent(event) + + def enterEvent(self, event) -> None: # noqa: ANN001 + self._apply_default_cursor() + super().enterEvent(event) + + def leaveEvent(self, event) -> None: # noqa: ANN001 + self._hide_brush_zone() + self._clear_brush_preview() + super().leaveEvent(event) + + def _apply_default_cursor(self) -> None: + cursor_shape = Qt.CrossCursor if self._brush_enabled else Qt.ArrowCursor + self.setCursor(cursor_shape) + self.viewport().setCursor(cursor_shape) + + def contextMenuEvent(self, event) -> None: # noqa: ANN001 + item = self.itemAt(event.pos()) + point_id = item.point_id if isinstance(item, LedItem) and isValid(item) else self._single_selected_point_id() + + menu = QMenu(self) + delete_action = menu.addAction("Удалить фиксацию выбранных LED") + delete_action.setEnabled(self._selection_has_assignment()) + clear_selection_action = menu.addAction("Очистить выбор") + clear_selection_action.setEnabled(bool(self.selected_point_ids())) + auto_block_action = menu.addAction("Автоматически добавить блок") + auto_block_action.setEnabled(point_id is not None) + fit_action = menu.addAction("Показать все точки") + fit_action.setEnabled(self.document is not None and bool(self.document.points)) + chosen = menu.exec(event.globalPos()) + if chosen == delete_action: + self.deleteSelectionRequested.emit() + elif chosen == clear_selection_action: + self.select_points([]) + elif chosen == auto_block_action and point_id is not None: + self.autoBlockFromPointRequested.emit(point_id) + elif chosen == fit_action: + self.fit_to_content() + + def _emit_selection(self) -> None: + selected_ids = { + item.point_id + for item in self.scene().selectedItems() + if isinstance(item, LedItem) and isValid(item) + } + if selected_ids == self._last_selected_ids: + return + self._refresh_selection_pens(selected_ids ^ self._last_selected_ids) + self._last_selected_ids = selected_ids + self.selectionChanged.emit(sorted(selected_ids)) + + def _refresh_selection_pens(self, point_ids: set[int]) -> None: + if self.document is None: + return + for point_id in point_ids: + item = self.items_by_id.get(point_id) + if item is None or not isValid(item): + continue + point = self.document.get_point(point_id) + is_active_line_point = self.active_line_id is not None and point.line_id == self.active_line_id + try: + pen_color = QColor("#111827") if item.isSelected() else QColor("#ffffff") + item.setPen(QPen(pen_color, 2.0 if is_active_line_point else 1.2)) + except RuntimeError: + continue + + def _touch_brush_point(self, viewport_pos) -> None: # noqa: ANN001 + touched = self._brush_candidate_ids(viewport_pos) + for point_id in sorted(touched): + self._brush_touched_ids.add(point_id) + self.brushPointTouched.emit(point_id) + self._update_brush_preview(viewport_pos) + + def _brush_candidate_ids(self, viewport_pos) -> list[int]: # noqa: ANN001 + return [ + item.point_id + for item in self.items_by_id.values() + if isValid(item) + and item.point_id not in self._brush_touched_ids + and item.point_id not in self._brush_excluded_point_ids + and self._is_inside_brush_zone(viewport_pos, item) + ] + + def _is_inside_brush_zone(self, viewport_center, item: LedItem) -> bool: # noqa: ANN001 + point_viewport = self.mapFromScene(item.pos()) + radius = self._brush_radius + self._point_radius + dx = viewport_center.x() - point_viewport.x() + dy = viewport_center.y() - point_viewport.y() + return dx * dx + dy * dy <= radius * radius + + def _ensure_brush_zone(self) -> QGraphicsEllipseItem | None: + if self.scene() is None: + return None + if self._brush_zone_item is not None and isValid(self._brush_zone_item): + return self._brush_zone_item + item = QGraphicsEllipseItem(-self._brush_radius, -self._brush_radius, self._brush_radius * 2, self._brush_radius * 2) + pen = QPen(QColor("#f43f5e"), 1.4) + pen.setCosmetic(True) + item.setPen(pen) + item.setBrush(QBrush(QColor(244, 63, 94, 32))) + item.setZValue(40) + item.setFlag(QGraphicsItem.ItemIgnoresTransformations, True) + item.setAcceptedMouseButtons(Qt.NoButton) + item.setVisible(False) + self.scene().addItem(item) + self._brush_zone_item = item + return item + + def _update_brush_zone(self, viewport_pos) -> None: # noqa: ANN001 + if not self._brush_enabled: + return + item = self._ensure_brush_zone() + if item is None: + return + self._last_brush_viewport_pos = viewport_pos + item.setPos(self.mapToScene(viewport_pos)) + item.setVisible(True) + self._update_brush_preview(viewport_pos) + + def _hide_brush_zone(self) -> None: + if self._brush_zone_item is not None and isValid(self._brush_zone_item): + self._brush_zone_item.setVisible(False) + + def _update_brush_preview(self, viewport_pos) -> None: # noqa: ANN001 + if not self._brush_enabled: + self._clear_brush_preview() + return + next_preview_ids = set(self._brush_candidate_ids(viewport_pos)) + changed_ids = self._brush_preview_ids ^ next_preview_ids + self._brush_preview_ids = next_preview_ids + self._refresh_brush_preview_styles(changed_ids | self._brush_touched_ids) + + def _clear_brush_preview(self) -> None: + changed_ids = self._brush_preview_ids | self._brush_touched_ids + self._brush_preview_ids.clear() + self._refresh_brush_preview_styles(changed_ids) + + def _clear_brush_state(self) -> None: + changed_ids = self._brush_preview_ids | self._brush_touched_ids + self._brush_preview_ids.clear() + self._brush_touched_ids.clear() + self._refresh_brush_preview_styles(changed_ids) + + def _refresh_brush_preview_styles(self, point_ids: set[int]) -> None: + if self.document is None: + return + line_by_id = {line.id: line for line in self.document.lines} + for point_id in point_ids: + item = self.items_by_id.get(point_id) + if item is None or not isValid(item): + continue + try: + if point_id in self._brush_touched_ids: + item.setBrush(QColor("#f43f5e")) + item.setPen(QPen(QColor("#ffffff"), 2.4)) + continue + if point_id in self._brush_preview_ids: + item.setBrush(QColor("#fbbf24")) + item.setPen(QPen(QColor("#111827"), 2.4)) + continue + point = self.document.get_point(point_id) + color = QColor("#94a3b8") + if point.line_id in line_by_id: + color = QColor(line_by_id[point.line_id].color) + if self.active_line_id is not None and point.line_id != self.active_line_id: + color.setAlphaF(0.45) + is_active_line_point = self.active_line_id is not None and point.line_id == self.active_line_id + pen_color = QColor("#111827") if item.isSelected() else QColor("#ffffff") + item.setBrush(color) + item.setPen(QPen(pen_color, 2.0 if is_active_line_point else 1.2)) + except RuntimeError: + continue + + def _clear_connections(self) -> None: + for item in self.connection_items: + if not isValid(item): + continue + self.scene().removeItem(item) + self.connection_items.clear() + + def _refresh_grid(self) -> None: + for item in self.grid_items: + if not isValid(item): + continue + self.scene().removeItem(item) + self.grid_items.clear() + if not self._grid_visible or self._raw_bounds is None: + return + + if self.document is None or not self.document.points: + return + 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 + 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) + pen = QPen(QColor("#334155"), 0.6) + pen.setCosmetic(True) + + for col in range(first_col, last_col + 1): + x = origin_x + col * self._grid_step + top = self._scene_point_from_display_units(x, top_y) + bottom = self._scene_point_from_display_units(x, bottom_y) + item = self.scene().addLine(top.x(), top.y(), bottom.x(), bottom.y(), pen) + item.setZValue(-20) + self.grid_items.append(item) + + for row in range(first_row, last_row + 1): + y = origin_y + row * self._grid_step + left = self._scene_point_from_display_units(left_x, y) + right = self._scene_point_from_display_units(right_x, y) + item = self.scene().addLine(left.x(), left.y(), right.x(), right.y(), pen) + item.setZValue(-20) + self.grid_items.append(item) + + def _draw_line_connections(self, line: LedLine) -> None: + is_active = self.active_line_id is None or line.id == self.active_line_id + inner_width = 2.8 if is_active else 1.2 + inner_alpha = 0.9 if is_active else 0.25 + bridge_width = 1.9 if is_active else 1.0 + bridge_alpha = 0.65 if is_active else 0.18 + inner_path = QPainterPath() + for block in line.blocks: + ordered_items = [self.items_by_id[point_id] for point_id in block.point_ids if point_id in self.items_by_id] + for first, second in zip(ordered_items, ordered_items[1:]): + self._append_connection(inner_path, first.pos(), second.pos()) + self._add_connection_path(inner_path, line.color, inner_width, inner_alpha) + + previous_block_end = None + bridge_path = QPainterPath() + for block in line.blocks: + if not block.point_ids: + continue + block_start = self.items_by_id.get(block.point_ids[0]) + block_end = self.items_by_id.get(block.point_ids[-1]) + if block_start is not None and previous_block_end is not None: + self._append_connection(bridge_path, previous_block_end.pos(), block_start.pos()) + if block_end is not None: + previous_block_end = block_end + self._add_connection_path(bridge_path, line.color, bridge_width, bridge_alpha) + + @staticmethod + def _append_connection(path: QPainterPath, start: QPointF, end: QPointF) -> None: + path.moveTo(start) + path.lineTo(end) + + def _add_connection_path(self, path: QPainterPath, color: str, width: float, alpha: float) -> None: + if path.isEmpty(): + return + pen_color = QColor(color) + pen_color.setAlphaF(alpha) + item = self.scene().addPath(path, QPen(pen_color, width)) + item.setZValue(1) + self.connection_items.append(item) + + def _selection_has_assignment(self) -> bool: + if self.document is None: + return False + for point_id in self.selected_point_ids(): + point = self.document.get_point(point_id) + if point.line_id is not None or point.block_id is not None: + return True + return False + + def _single_selected_point_id(self) -> int | None: + point_ids = self.selected_point_ids() + return point_ids[0] if len(point_ids) == 1 else None + + def _to_scene_point(self, x: float, y: float) -> QPointF: + if self._raw_bounds is None: + return QPointF(x, y) + x *= self._display_scale + y *= self._display_scale + min_x, _max_x, _min_y, max_y = self._raw_bounds + return QPointF((x - min_x) + self._margin, (max_y - y) + self._margin) + + def _grid_origin_scene_units(self) -> tuple[float, float]: + if self.document is None or not self.document.points: + return 0.0, 0.0 + points = self.document.points + min_row = min(point.row for point in points) + min_col = min(point.col for point in points) + anchor = min(points, key=lambda point: (point.y * self._display_scale, point.x * self._display_scale, point.uid)) + anchor_x = anchor.x * self._display_scale + anchor_y = anchor.y * self._display_scale + origin_x = anchor_x - (anchor.col - min_col + 0.5) * self._grid_step + origin_y = anchor_y - (anchor.row - min_row + 0.5) * self._grid_step + return origin_x, origin_y + + def _scene_point_from_display_units(self, x: float, y: float) -> QPointF: + if self._raw_bounds is None: + return QPointF(x, y) + min_x, _max_x, _min_y, max_y = self._raw_bounds + return QPointF((x - min_x) + self._margin, (max_y - y) + self._margin) + + @staticmethod + def _move_scrollbar(scrollbar: QScrollBar, delta: int) -> None: + scrollbar.setValue(scrollbar.value() - delta) diff --git a/icons.py b/icons.py new file mode 100644 index 0000000..b46bf18 --- /dev/null +++ b/icons.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from PySide6.QtCore import QByteArray, QSize, Qt +from PySide6.QtGui import QColor, QIcon, QPainter, QPixmap +from PySide6.QtSvg import QSvgRenderer + + +LUCIDE_PATHS = { + "archive-restore": '', + "brush": '', + "chevron-right": '', + "eraser": '', + "eye": '', + "file-plus": '', + "file-code": '', + "folder-open": '', + "grid-2x2": '', + "layers": '', + "list-plus": '', + "paintbrush": '', + "plus": '', + "redo-2": '', + "rotate-cw": '', + "save": '', + "save-all": '', + "trash-2": '', + "undo-2": '', +} + + +def lucide_icon(name: str, color: str = "#e5e7eb", size: int = 22) -> QIcon: + path = LUCIDE_PATHS.get(name) + if path is None: + path = LUCIDE_PATHS["plus"] + svg = ( + '{path}' + ).format(color=color, path=path) + renderer = QSvgRenderer(QByteArray(svg.encode("utf-8"))) + pixmap = QPixmap(QSize(size, size)) + pixmap.fill(Qt.transparent) + painter = QPainter(pixmap) + renderer.render(painter) + painter.end() + return QIcon(pixmap) + + +def recolor_icon(name: str, color: QColor | str) -> QIcon: + return lucide_icon(name, QColor(color).name() if isinstance(color, QColor) else color) diff --git a/main.py b/main.py new file mode 100644 index 0000000..520ef9d --- /dev/null +++ b/main.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import sys + +from PySide6.QtWidgets import QApplication + +from main_window import MainWindow + + +def main() -> int: + app = QApplication(sys.argv) + window = MainWindow() + window.show() + return app.exec() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/main_window.py b/main_window.py new file mode 100644 index 0000000..1d0e8d8 --- /dev/null +++ b/main_window.py @@ -0,0 +1,1467 @@ +from __future__ import annotations +from copy import deepcopy +from pathlib import Path + +from PySide6.QtCore import QSignalBlocker, QSize, Qt +from PySide6.QtGui import QAction, QColor, QKeySequence +from PySide6.QtWidgets import ( + QAbstractItemView, + QAbstractScrollArea, + QApplication, + QFileDialog, + QHBoxLayout, + QHeaderView, + QLabel, + QMainWindow, + QMenu, + QMessageBox, + QPushButton, + QSizePolicy, + QSplitter, + QStackedWidget, + QTableWidget, + QTableWidgetItem, + QToolButton, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, +) + +try: + from .canvas import LedCanvas + from .icons import lucide_icon + from .models import LedBlock, LedLine, MappingDocument + from .panel_mapping_generator import generate_panel_mapping_from_document + from .unfolding import MAX_LINES +except ImportError: + from canvas import LedCanvas + from icons import lucide_icon + from models import LedBlock, LedLine, MappingDocument + from panel_mapping_generator import generate_panel_mapping_from_document + from unfolding import MAX_LINES + + +ROOT_DIR = Path(__file__).resolve().parents[1] +SORT_ROLE = Qt.UserRole + 1 +SIDEBAR_DEFAULT_WIDTH = 260 +SIDEBAR_MIN_WIDTH = 180 +SIDEBAR_COLLAPSED_WIDTH = 34 +MAX_WIDGET_WIDTH = 16777215 + + +class SortableTableItem(QTableWidgetItem): + def __lt__(self, other: QTableWidgetItem) -> bool: + left = self.data(SORT_ROLE) + right = other.data(SORT_ROLE) + if left is not None and right is not None: + return left < right + return super().__lt__(other) + + +class SidebarTab(QWidget): + def __init__(self, expand_handler, parent: QWidget | None = None) -> None: # noqa: ANN001 + super().__init__(parent) + self._expand_handler = expand_handler + self.setCursor(Qt.PointingHandCursor) + self.setToolTip("Показать боковую панель") + + def mousePressEvent(self, event) -> None: # noqa: ANN001 + if event.button() == Qt.LeftButton: + self._expand_handler() + event.accept() + return + super().mousePressEvent(event) + + +class MainWindow(QMainWindow): + def __init__(self) -> None: + super().__init__() + self.setWindowTitle("LED Mapper Editor") + self.resize(1280, 820) + + self.document = MappingDocument() + self.active_line_id: int | None = None + self._updating_table = False + self._dirty = False + self._undo_stack: list[tuple[MappingDocument, int | None, list[int], bool]] = [] + self._redo_stack: list[tuple[MappingDocument, int | None, list[int], bool]] = [] + self.actions: dict[str, QAction] = {} + self.side_buttons: dict[str, QPushButton] = {} + self.block_append_buttons: dict[tuple[int, int], QPushButton] = {} + self.brush_target: tuple[int, int] | None = None + self._brush_stroke_started = False + self._brush_pending_point_ids: list[int] = [] + self._brush_pending_point_set: set[int] = set() + self.sidebar_splitter: QSplitter | None = None + self.sidebar_stack: QStackedWidget | None = None + self.side_panel: QWidget | None = None + self.sidebar_tab: QToolButton | None = None + self.sidebar_tab_container: SidebarTab | None = None + self._sidebar_collapsed = False + self._sidebar_last_width = SIDEBAR_DEFAULT_WIDTH + + self.canvas = LedCanvas() + self.canvas.selectionChanged.connect(self._on_selection_changed) + self.canvas.deleteSelectionRequested.connect(self.clear_selected_point_assignments) + self.canvas.autoBlockFromPointRequested.connect(self.auto_block_from_point) + self.canvas.brushPointTouched.connect(self.add_brush_point_to_block) + self.canvas.brushStrokeFinished.connect(self.finish_brush_stroke) + self.tree = QTreeWidget() + self.tree.setHeaderLabels(["Линии и блоки"]) + self.tree.setContextMenuPolicy(Qt.CustomContextMenu) + self.tree.itemSelectionChanged.connect(self._on_tree_selection) + self.tree.customContextMenuRequested.connect(self._show_tree_menu) + + self.table = QTableWidget(0, 10) + self.table.setHorizontalHeaderLabels( + ["UID", "Источник", "Ряд", "Кол.", "Линия", "Блок", "L1", "L2", "L3", "L4"] + ) + self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive) + self.table.horizontalHeader().setMinimumSectionSize(24) + self.table.horizontalHeader().setStretchLastSection(False) + for column, width in enumerate((54, 72, 54, 54, 58, 58, 48, 48, 48, 48)): + self.table.setColumnWidth(column, width) + self.table.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.table.setSizeAdjustPolicy(QAbstractScrollArea.AdjustIgnored) + self.table.setMinimumWidth(0) + self.table.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Expanding) + self.table.setAlternatingRowColors(True) + self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.table.setSelectionBehavior(QTableWidget.SelectRows) + self.table.setSortingEnabled(True) + self.table.setContextMenuPolicy(Qt.CustomContextMenu) + self.table.itemSelectionChanged.connect(self._select_rows_on_canvas) + self.table.customContextMenuRequested.connect(self._show_table_menu) + + self.summary_label = QLabel("Файл: - | Точек: 0 | Линий: 0/4 | Блоков: 0 | Назначено: 0") + self.active_label = QLabel("Активная линия: -") + self.selection_label = QLabel("Выбрано: 0 LED") + self.status = QLabel("Откройте circles.json или ordered_circles.json") + for label in (self.summary_label, self.active_label, self.selection_label, self.status): + label.setMinimumWidth(0) + label.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed) + label.setWordWrap(False) + self.tree.setMinimumWidth(0) + self.tree.setTextElideMode(Qt.ElideRight) + self.tree.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.tree.setSizeAdjustPolicy(QAbstractScrollArea.AdjustIgnored) + self.tree.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Expanding) + self._build_actions() + self._build_layout() + application = QApplication.instance() + if application is not None: + application.focusChanged.connect(lambda _old, _new: self._refresh_ui_state()) + self._refresh_ui_state() + + def _build_actions(self) -> None: + def add_action( + key: str, + title: str, + handler, + icon_name: str | None = None, + shortcut: QKeySequence | None = None, + tooltip: str | None = None, + ) -> QAction: + action = QAction(title, self) + if icon_name is not None: + action.setIcon(lucide_icon(icon_name)) + if shortcut is not None: + action.setShortcut(shortcut) + action.setShortcutContext(Qt.ApplicationShortcut) + if tooltip is not None: + action.setToolTip(tooltip) + action.setStatusTip(tooltip) + action.triggered.connect(handler) + self.actions[key] = action + return action + + add_action("open", "Открыть", self.open_file, "folder-open", QKeySequence.Open) + add_action("save", "Сохранить", self.save_file, "save", QKeySequence.Save) + add_action("save_as", "Сохранить как", self.save_file_as, "save-all", QKeySequence.SaveAs) + add_action( + "generate_script", + "Сгенерировать скрипт", + self.generate_script, + "file-code", + QKeySequence("Ctrl+G"), + "Сгенерировать vm_map.h и вспомогательные файлы развертки", + ) + add_action("add_line", "Новая линия", self.add_line, "file-plus", QKeySequence("Ctrl+L")) + add_action("add_block", "Блок из выбора", self.add_block_from_selection, "plus", None, "Создать блок в активной линии") + add_action("auto_blocks", "Автоблоки", self.auto_blocks_from_selection, "layers", QKeySequence("Ctrl+Shift+B"), "Разбить выделение на связанные блоки") + add_action("clockwise", "По часовой", self.auto_connect_active_line, "rotate-cw", None, "Пересчитать порядок LED по часовой стрелке") + add_action("rebuild_route", "Пересобрать", self.rebuild_active_line_blocks, "redo-2", None, "Пересобрать блоки активной линии по правилу 34 мм") + 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) + add_action("redo", "Повторить", self.redo_last_change, "redo-2", QKeySequence.Redo) + self.delete_action = add_action( + "delete", + "Удалить фиксацию", + self.delete_current_assignment, + "trash-2", + QKeySequence.Delete, + "Удалить выбранные назначения LED, блок или линию", + ) + add_action("clear_all", "Удалить все", self.clear_all_assignments, "eraser", QKeySequence("Ctrl+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.setCheckable(True) + add_action("toggle_sidebar", "Скрыть боковую панель", self.toggle_sidebar, "chevron-right", QKeySequence("F9")) + self._build_menu_bar() + self.addAction(self.delete_action) + + def _build_menu_bar(self) -> None: + menu_bar = self.menuBar() + menu_bar.clear() + file_menu = menu_bar.addMenu("Файл") + file_menu.addAction(self.actions["open"]) + file_menu.addAction(self.actions["save"]) + file_menu.addAction(self.actions["save_as"]) + file_menu.addSeparator() + file_menu.addAction(self.actions["generate_script"]) + + edit_menu = menu_bar.addMenu("Правка") + edit_menu.addAction(self.actions["undo"]) + edit_menu.addAction(self.actions["redo"]) + edit_menu.addSeparator() + edit_menu.addAction(self.actions["delete"]) + edit_menu.addAction(self.actions["clear_all"]) + edit_menu.addAction(self.actions["clear_selection"]) + + view_menu = menu_bar.addMenu("Вид") + view_menu.addAction(self.actions["fit_view"]) + view_menu.addAction(self.actions["grid"]) + + window_menu = menu_bar.addMenu("Окно") + window_menu.addAction(self.actions["toggle_sidebar"]) + + tools_menu = menu_bar.addMenu("Инструменты") + tools_menu.addAction(self.actions["add_line"]) + tools_menu.addAction(self.actions["generate_script"]) + tools_menu.addSeparator() + tools_menu.addAction(self.actions["auto_blocks"]) + tools_menu.addSeparator() + tools_menu.addAction(self.actions["brush"]) + + def _build_layout(self) -> None: + side = QWidget() + self.side_panel = side + side.setMinimumWidth(0) + side.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Expanding) + side_layout = QVBoxLayout(side) + side_layout.addWidget(self.summary_label) + side_layout.addWidget(self.active_label) + structure_label = QLabel("Структура") + structure_label.setMinimumWidth(0) + structure_label.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed) + side_layout.addWidget(structure_label) + side_layout.addWidget(self.tree, 2) + + buttons = QWidget() + button_layout = QHBoxLayout(buttons) + button_layout.setContentsMargins(0, 0, 0, 0) + side_button_icons = { + "add_line": "file-plus", + "auto_blocks": "layers", + } + for key, title, handler, tooltip in ( + ("add_line", "Линия", self.add_line, f"Создать линию, максимум {MAX_LINES}"), + ("auto_blocks", "Автоблоки", self.auto_blocks_from_selection, "Разбить выделение на связанные блоки"), + ): + button = QPushButton(title) + button.setIcon(lucide_icon(side_button_icons[key])) + button.setMinimumWidth(0) + button.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed) + button.setToolTip(tooltip) + button.clicked.connect(handler) + button_layout.addWidget(button) + self.side_buttons[key] = button + side_layout.addWidget(buttons) + side_layout.addWidget(self.selection_label) + side_layout.addWidget(self.table, 3) + side_layout.addWidget(self.status) + + collapsed_tab = SidebarTab(self.expand_sidebar) + self.sidebar_tab_container = collapsed_tab + collapsed_tab.setFixedWidth(SIDEBAR_COLLAPSED_WIDTH) + collapsed_layout = QVBoxLayout(collapsed_tab) + collapsed_layout.setContentsMargins(0, 0, 0, 0) + collapsed_layout.addStretch(1) + self.sidebar_tab = QToolButton() + self.sidebar_tab.setIcon(lucide_icon("chevron-right")) + self.sidebar_tab.setToolTip("Показать боковую панель") + self.sidebar_tab.setAutoRaise(True) + self.sidebar_tab.setCursor(Qt.PointingHandCursor) + self.sidebar_tab.clicked.connect(self.expand_sidebar) + collapsed_layout.addWidget(self.sidebar_tab, 0, Qt.AlignHCenter) + collapsed_layout.addStretch(1) + + self.sidebar_stack = QStackedWidget() + self.sidebar_stack.setMinimumWidth(0) + self.sidebar_stack.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Expanding) + self.sidebar_stack.addWidget(side) + self.sidebar_stack.addWidget(collapsed_tab) + + splitter = QSplitter(Qt.Horizontal) + self.sidebar_splitter = splitter + splitter.setChildrenCollapsible(False) + splitter.addWidget(self.sidebar_stack) + splitter.addWidget(self.canvas) + splitter.setCollapsible(0, False) + splitter.setCollapsible(1, False) + splitter.setStretchFactor(0, 0) + splitter.setStretchFactor(1, 1) + splitter.splitterMoved.connect(self._on_sidebar_splitter_moved) + splitter.setSizes([SIDEBAR_DEFAULT_WIDTH, 1000]) + self.setCentralWidget(splitter) + + def _on_sidebar_splitter_moved(self, _pos: int, _index: int) -> None: + if self.sidebar_splitter is None: + return + side_width = self.sidebar_splitter.sizes()[0] + if self._sidebar_collapsed: + if side_width >= SIDEBAR_MIN_WIDTH: + self._sidebar_last_width = side_width + self.expand_sidebar() + elif side_width < SIDEBAR_COLLAPSED_WIDTH: + self._set_sidebar_splitter_width(SIDEBAR_COLLAPSED_WIDTH) + return + if side_width < SIDEBAR_MIN_WIDTH: + self.collapse_sidebar() + elif side_width >= SIDEBAR_MIN_WIDTH: + self._sidebar_last_width = side_width + + def _set_sidebar_splitter_width(self, side_width: int) -> None: + if self.sidebar_splitter is None: + return + sizes = self.sidebar_splitter.sizes() + total_width = max(sum(sizes), side_width + 1) + blocker = QSignalBlocker(self.sidebar_splitter) + self.sidebar_splitter.setSizes([side_width, max(1, total_width - side_width)]) + del blocker + + def toggle_sidebar(self) -> None: + if self._sidebar_collapsed: + self.expand_sidebar() + else: + self.collapse_sidebar() + + def collapse_sidebar(self) -> None: + if self.sidebar_splitter is None or self.sidebar_stack is None or self._sidebar_collapsed: + return + sizes = self.sidebar_splitter.sizes() + if sizes and sizes[0] >= SIDEBAR_MIN_WIDTH: + self._sidebar_last_width = sizes[0] + total_width = max(sum(sizes), SIDEBAR_DEFAULT_WIDTH + 1) + blocker = QSignalBlocker(self.sidebar_splitter) + self.sidebar_stack.setCurrentIndex(1) + self.sidebar_stack.setMinimumWidth(SIDEBAR_COLLAPSED_WIDTH) + self.sidebar_stack.setMaximumWidth(MAX_WIDGET_WIDTH) + self.sidebar_splitter.setSizes([SIDEBAR_COLLAPSED_WIDTH, max(1, total_width - SIDEBAR_COLLAPSED_WIDTH)]) + del blocker + self._sidebar_collapsed = True + action = self.actions.get("toggle_sidebar") + if action is not None: + action.setText("Показать боковую панель") + action.setToolTip("Показать боковую панель") + + def expand_sidebar(self) -> None: + if self.sidebar_splitter is None or self.sidebar_stack is None: + return + sizes = self.sidebar_splitter.sizes() + total_width = max(sum(sizes), SIDEBAR_DEFAULT_WIDTH + 1) + side_width = max(SIDEBAR_MIN_WIDTH, self._sidebar_last_width) + if side_width >= total_width: + side_width = min(SIDEBAR_DEFAULT_WIDTH, total_width - 1) + blocker = QSignalBlocker(self.sidebar_splitter) + self.sidebar_stack.setMaximumWidth(MAX_WIDGET_WIDTH) + self.sidebar_stack.setMinimumWidth(0) + self.sidebar_stack.setCurrentIndex(0) + self.sidebar_splitter.setSizes([side_width, max(1, total_width - side_width)]) + del blocker + self._sidebar_collapsed = False + action = self.actions.get("toggle_sidebar") + if action is not None: + action.setText("Скрыть боковую панель") + action.setToolTip("Скрыть боковую панель") + + def open_file(self) -> None: + if not self._confirm_discard_changes(): + return + path, _ = QFileDialog.getOpenFileName( + self, + "Открыть карту LED", + str(ROOT_DIR), + "JSON files (*.json);;All files (*.*)", + ) + if not path: + return + loaded = MappingDocument() + try: + loaded.load(path) + except Exception as exc: + QMessageBox.critical(self, "Ошибка загрузки", str(exc)) + return + self.document = loaded + self._dirty = False + self._undo_stack.clear() + self._redo_stack.clear() + self.active_line_id = self.document.lines[0].id if self.document.lines else None + self.canvas.set_document(self.document) + self.canvas.set_active_line(self.active_line_id) + self.refresh_tree() + self._sync_canvas_to_active_line() + summary = self._document_summary() + self.status.setText(f"Загружено {len(self.document.points)} точек: {summary}: {path}") + self._refresh_ui_state() + + def save_file(self) -> None: + if self.document.path is None: + self.save_file_as() + return + self._save_to(self.document.path) + + def save_file_as(self) -> None: + path, _ = QFileDialog.getSaveFileName( + self, + "Сохранить карту LED", + str((self.document.path or ROOT_DIR / "led_mapping_editor.json")), + "JSON files (*.json);;All files (*.*)", + ) + if path: + self._save_to(Path(path)) + + def generate_script(self, output_dir: str | Path | None = None) -> None: + if isinstance(output_dir, bool): + output_dir = None + if not self.document.points: + QMessageBox.information(self, "Нет точек", "Откройте карту LED перед генерацией скрипта.") + self._refresh_ui_state() + return + assigned_count = self._script_assigned_count() + total_count = len(self.document.points) + if assigned_count == 0: + QMessageBox.information( + self, + "Нет назначенных LED", + "Ни один светодиод схемы не назначен. Назначьте LED в линии и блоки перед генерацией скрипта.", + ) + self.status.setText("Генерация отменена: нет назначенных LED.") + self._refresh_ui_state() + return + if assigned_count < total_count and not self._confirm_partial_script_generation(assigned_count, total_count): + self.status.setText("Генерация отменена: пользователь продолжает редактирование.") + self._refresh_ui_state() + return + if output_dir is None: + selected_dir = QFileDialog.getExistingDirectory( + self, + "Выберите папку для генерации скрипта", + str(ROOT_DIR / "panel_mapping_build"), + ) + if not selected_dir: + return + output_dir = selected_dir + try: + result = generate_panel_mapping_from_document(self.document, output_dir) + except Exception as exc: + QMessageBox.critical(self, "Ошибка генерации", str(exc)) + self.status.setText(f"Ошибка генерации скрипта: {exc}") + self._refresh_ui_state() + return + + generated_list = "\n".join(str(path) for path in result.generated_files.values()) + QMessageBox.information( + self, + "Скрипт сгенерирован", + ( + f"Точек: {result.led_count}\n" + f"Назначенных LED: {result.assigned_count}\n" + f"Свободных LED: {result.unassigned_count}\n" + f"Блоков: {result.block_count}\n" + f"Виртуальная матрица: {result.vm_rows} x {result.vm_cols}\n\n" + f"Файлы:\n{generated_list}" + ), + ) + self.status.setText( + f"Сгенерирован скрипт: {result.assigned_count}/{result.led_count} LED, " + f"матрица {result.vm_rows}x{result.vm_cols}: {result.output_dir}" + ) + self._refresh_ui_state() + + def _script_assigned_count(self) -> int: + return sum(1 for point in self.document.points if point.line_id is not None and point.line_index is not None) + + def _confirm_partial_script_generation(self, assigned_count: int, total_count: int) -> bool: + message = QMessageBox(self) + message.setIcon(QMessageBox.Icon.Warning) + message.setWindowTitle("Назначены не все LED") + message.setText( + f"Назначено {assigned_count} из {total_count} светодиодов.\n\n" + "Сгенерировать скрипт сейчас? Неназначенные LED не попадут в рабочий маппинг." + ) + generate_button = message.addButton("Сгенерировать скрипт", QMessageBox.ButtonRole.AcceptRole) + message.addButton("Продолжить редактирование", QMessageBox.ButtonRole.RejectRole) + message.setDefaultButton(generate_button) + message.exec() + return message.clickedButton() is generate_button + + def _save_to(self, path: Path) -> None: + if len(self.document.lines) > MAX_LINES: + QMessageBox.warning( + self, + "Карта не сохранена", + f"Новая модель поддерживает максимум {MAX_LINES} линии. Сейчас в карте {len(self.document.lines)}.", + ) + self.status.setText(f"Сохранение отменено: линий {len(self.document.lines)}/{MAX_LINES}") + self._refresh_ui_state() + return + active_line = self._get_line_or_none(self.active_line_id) + try: + self.document.save(path) + except Exception as exc: + self._restore_active_line(active_line) + self.canvas.refresh_styles() + self.refresh_tree() + self._on_selection_changed(self.canvas.selected_point_ids()) + QMessageBox.critical(self, "Ошибка сохранения", str(exc)) + self._refresh_ui_state() + return + self._dirty = False + self._undo_stack.clear() + self._redo_stack.clear() + self._restore_active_line(active_line) + self.canvas.refresh_styles() + self.refresh_tree() + self._on_selection_changed(self.canvas.selected_point_ids()) + self.status.setText(f"Сохранено: {self._document_summary()}: {path}") + self._refresh_ui_state() + + def add_line(self) -> None: + if len(self.document.lines) >= MAX_LINES: + QMessageBox.information( + self, + "Лимит линий", + f"Новая модель поддерживает максимум {MAX_LINES} линии. Удалите лишнюю линию, чтобы создать новую.", + ) + self.status.setText(f"Лимит модели: {MAX_LINES} линии. Сейчас: {len(self.document.lines)}") + self._refresh_ui_state() + return + self._push_undo() + line = self.document.add_line() + self._dirty = True + self.active_line_id = line.id + self.refresh_tree() + self.status.setText(f"Создана {line.name}. {self._line_summary(line)}") + self._refresh_ui_state() + + def add_block_from_selection(self) -> None: + line_id = self._require_active_line() + if line_id is None: + return + point_ids = self.canvas.selected_point_ids() + if point_ids and not self._confirm_reassignment(point_ids): + return + self._push_undo() + block = self.document.add_block(line_id, point_ids) + self._dirty = True + self.canvas.select_points(block.point_ids) + self._after_mapping_change( + f"Создан {block.name}: {len(block.point_ids)} LED. {self._unfolding_summary(line_id)}", + selection_already_updated=True, + ) + + def auto_blocks_from_selection(self) -> None: + line_id = self._require_active_line() + if line_id is None: + return + point_ids = self.canvas.selected_point_ids() + if not point_ids: + QMessageBox.information(self, "Нет выбора", "Выберите LED-точки для разбиения на блоки.") + return + preview = self.document.auto_block_preview(point_ids) + if not self._confirm_auto_blocks(point_ids, preview): + return + self._push_undo() + before = len(self.document.get_line(line_id).blocks) + self.document.auto_blocks_from_selection(line_id, point_ids, preview) + self._dirty = True + added = len(self.document.get_line(line_id).blocks) - before + self._after_mapping_change( + f"Развертка пересчитана по часовой стрелке: добавлено блоков {added}. {self._unfolding_summary(line_id)}" + ) + + def auto_block_from_point(self, point_id: int) -> None: + line_id = self.active_line_id + needs_new_line = line_id is None and not self.document.lines + if line_id is None and self.document.lines: + line_id = self.document.lines[0].id + self.active_line_id = line_id + if line_id is None and len(self.document.lines) >= MAX_LINES: + QMessageBox.information( + self, + "Лимит линий", + f"Новая модель поддерживает максимум {MAX_LINES} линии. Удалите лишнюю линию, чтобы создать новую.", + ) + return + preview = self.document.clockwise_block_from_seed_preview(point_id) + if not preview: + QMessageBox.information(self, "Нет блока", "Не удалось найти связанный блок от выбранной LED.") + return + if not self._confirm_reassignment(preview): + return + self._push_undo() + if needs_new_line: + line = self.document.add_line() + line_id = line.id + self.active_line_id = line_id + block = self.document.add_clockwise_block_from_group(line_id, point_id, preview) + self._dirty = True + self.canvas.select_points(block.point_ids) + self._after_mapping_change( + f"Автоматически добавлен {block.name}: {len(block.point_ids)} LED, стартовая точка {point_id}. " + f"{self._unfolding_summary(line_id)}", + selection_already_updated=True, + ) + + def auto_connect_active_line(self) -> None: + line_id = self._require_active_line() + if line_id is None: + return + self._push_undo() + self.document.auto_connect_line(line_id) + self._dirty = True + self._after_mapping_change(f"Развертка линии пересчитана по часовой стрелке. {self._unfolding_summary(line_id)}") + + def rebuild_active_line_blocks(self) -> None: + line_id = self._require_active_line() + if line_id is None: + return + line = self.document.get_line(line_id) + if not line.blocks: + self.status.setText("В активной линии нет блоков для пересборки.") + return + answer = QMessageBox.question( + self, + "Пересобрать блоки", + "Пересобрать блоки активной линии по физическим соседям и правилу 34 мм?", + ) + if answer != QMessageBox.StandardButton.Yes: + return + self._push_undo() + block_count = self.document.rebuild_line_blocks_by_route(line_id) + self._dirty = True + self._after_mapping_change(f"Активная линия пересобрана: блоков {block_count}. {self._unfolding_summary(line_id)}") + + def toggle_grid(self) -> None: + action = self.actions.get("grid") + self.canvas.set_grid_visible(action.isChecked() if action is not None else False) + + def delete_current_assignment(self) -> None: + focus = self.focusWidget() + if focus is not None and (focus is self.tree or self.tree.isAncestorOf(focus)): + if self.remove_selected_tree_item(): + return + self.clear_selected_point_assignments() + + def clear_selected_point_assignments(self) -> None: + point_ids = self.canvas.selected_point_ids() + if not point_ids: + return + active_line = self._get_line_or_none(self.active_line_id) + self._push_undo() + removed = self.document.clear_point_assignments(point_ids) + if removed == 0: + self._undo_stack.pop() + self.status.setText("В выбранных LED нет фиксаций для удаления.") + self._refresh_ui_state() + return + self._dirty = True + self._restore_active_line(active_line) + self.canvas.select_points(point_ids) + self._after_mapping_change(f"Удалена фиксация LED: {removed}", selection_already_updated=True) + + def remove_selected_tree_item(self) -> bool: + item = self.tree.currentItem() + if item is None: + return False + data = item.data(0, Qt.UserRole) + if not data: + return False + if data[0] == "line": + if not self._line_exists(data[1]): + self.refresh_tree() + return False + line = self.document.get_line(data[1]) + affected = self._line_count(line) + if not self._confirm_delete("линию", line.name, affected): + return False + self._push_undo() + removed = self.document.remove_line(data[1]) + self._dirty = True + self.active_line_id = self.document.lines[0].id if self.document.lines else None + 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: + if not self.document.lines: + return + answer = QMessageBox.question( + self, + "Удалить все фиксации", + "Удалить все линии, блоки и назначенные индексы LED?", + ) + if answer != QMessageBox.StandardButton.Yes: + return + self._push_undo() + removed = self.document.clear_all_assignments() + self._dirty = True + self.active_line_id = None + self.canvas.select_points([]) + self._after_mapping_change(f"Удалены все фиксации LED: {removed}", selection_already_updated=True) + + def refresh_tree(self) -> None: + self.tree.setUpdatesEnabled(False) + self.tree.blockSignals(True) + try: + self.tree.clear() + self.block_append_buttons.clear() + for line in self.document.lines: + title = f"{line.name} ({self._line_count(line)} LED, блоков: {len(line.blocks)})" + line_item = QTreeWidgetItem([title]) + line_item.setData(0, Qt.UserRole, ("line", line.id)) + line_item.setForeground(0, QColor(line.color)) + self.tree.addTopLevelItem(line_item) + line_offset = 0 + for block in line.blocks: + index_to = line_offset + len(block.point_ids) - 1 + index_range = "-" if not block.point_ids else f"{line_offset}-{index_to}" + block_item = QTreeWidgetItem([""]) + block_item.setData(0, Qt.UserRole, ("block", line.id, block.id)) + block_item.setSizeHint(0, QSize(0, 24)) + line_item.addChild(block_item) + self._attach_block_widget(block_item, line.id, block.id, block.name, len(block.point_ids), index_range) + line_offset += len(block.point_ids) + line_item.setExpanded(True) + if line.id == self.active_line_id: + self.tree.setCurrentItem(line_item) + finally: + self.tree.blockSignals(False) + self.tree.setUpdatesEnabled(True) + self._refresh_block_append_buttons() + + def _attach_block_widget( + self, + item: QTreeWidgetItem, + line_id: int, + block_id: int, + block_name: str, + led_count: int, + index_range: str, + ) -> None: + widget = QWidget() + layout = QHBoxLayout(widget) + layout.setContentsMargins(16, 0, 2, 0) + layout.setSpacing(4) + label = QLabel(f"{block_name} ({led_count} LED, idx {index_range})") + label.setAlignment(Qt.AlignVCenter) + label.setMinimumWidth(0) + label.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed) + button = QPushButton() + button.setIcon(lucide_icon("plus")) + button.setFixedSize(22, 20) + button.setToolTip("Добавить выбранные LED в конец этого блока") + button.clicked.connect(lambda _checked=False, lid=line_id, bid=block_id: self.append_selection_to_block(lid, bid)) + layout.addWidget(label, 1) + layout.addWidget(button, 0) + self.block_append_buttons[(line_id, block_id)] = button + self.tree.setItemWidget(item, 0, widget) + + def append_selection_to_block(self, line_id: int, block_id: int) -> None: + block = self._find_block_or_none(line_id, block_id) + if block is None: + self.refresh_tree() + return + point_ids = self.canvas.selected_point_ids() + append_ids = [point_id for point_id in point_ids if point_id not in set(block.point_ids)] + if not append_ids: + self.status.setText("Выберите LED, которых еще нет в этом блоке.") + self._refresh_ui_state() + return + previous_active_line_id = self.active_line_id + self.active_line_id = line_id + if not self._confirm_reassignment(append_ids): + self.active_line_id = previous_active_line_id + self._refresh_ui_state() + return + self._push_undo() + added = self.document.append_points_to_block(line_id, block_id, append_ids) + if added == 0: + self._undo_stack.pop() + self.active_line_id = previous_active_line_id + self.status.setText("В выбранных LED нет новых точек для добавления в блок.") + self._refresh_ui_state() + return + self._dirty = True + self.active_line_id = line_id + self.canvas.select_points(append_ids) + updated_block = self._find_block_or_none(line_id, block_id) + block_name = block.name if updated_block is None else updated_block.name + self._after_mapping_change( + f"В {block_name} добавлено LED: {added}. {self._unfolding_summary(line_id)}", + selection_already_updated=True, + ) + + def toggle_brush_mode(self) -> None: + action = self.actions.get("brush") + enabled = action.isChecked() if action is not None else False + self.brush_target = self._initial_brush_target() + self._brush_stroke_started = False + self.canvas.set_brush_enabled(enabled) + self._sync_brush_preview_exclusions() + if enabled and self.brush_target is not None: + line_id, block_id = self.brush_target + block = self._find_block_or_none(line_id, block_id) + block_name = "блок" if block is None else block.name + self.status.setText(f"Кисть включена: рисуйте продолжение для {block_name}.") + elif enabled: + self.status.setText("Кисть включена: начните рисовать, чтобы создать новый блок.") + else: + self.status.setText("Кисть выключена.") + self._refresh_ui_state() + + def add_brush_point_to_block(self, point_id: int) -> None: + target = self.brush_target or self._initial_brush_target() + if target is None: + if not self._brush_stroke_started: + self._push_undo() + self._brush_stroke_started = True + target = self._create_brush_target(point_id) + if target is None: + if self._undo_stack: + self._undo_stack.pop() + self._brush_stroke_started = False + self._refresh_ui_state() + return + line_id, block_id = target + block = self._find_block_or_none(line_id, block_id) + if block is None: + self._disable_brush("Целевой блок больше не существует.") + self.refresh_tree() + return + if point_id in block.point_ids or point_id in self._brush_pending_point_set: + self.active_line_id = line_id + self.brush_target = (line_id, block_id) + self._sync_brush_preview_exclusions() + return + if not self._brush_stroke_started: + self._push_undo() + self._brush_stroke_started = True + self.active_line_id = line_id + self.brush_target = (line_id, block_id) + self._brush_pending_point_ids.append(point_id) + self._brush_pending_point_set.add(point_id) + self._sync_brush_preview_exclusions() + self.status.setText(f"Кисть: добавлено в мазок LED {len(self._brush_pending_point_ids)}.") + + def finish_brush_stroke(self) -> None: + pending_ids = self._brush_pending_point_ids[:] + self._brush_pending_point_ids.clear() + self._brush_pending_point_set.clear() + self._brush_stroke_started = False + if not pending_ids: + self._sync_brush_preview_exclusions() + return + target = self.brush_target + if target is None: + if self._undo_stack: + self._restore_state(self._undo_stack.pop()) + self._sync_brush_preview_exclusions() + self._refresh_ui_state() + return + line_id, block_id = target + block = self._find_block_or_none(line_id, block_id) + if block is None: + if self._undo_stack: + self._restore_state(self._undo_stack.pop()) + self._disable_brush("Целевой блок больше не существует.") + self.refresh_tree() + return + previous_active_line_id = self.active_line_id + self.active_line_id = line_id + if not self._confirm_reassignment(pending_ids): + if self._undo_stack: + self._restore_state(self._undo_stack.pop()) + self.active_line_id = previous_active_line_id + self.canvas.select_points(pending_ids) + self._sync_brush_preview_exclusions() + self._refresh_ui_state() + return + added = self.document.append_points_to_block(line_id, block_id, pending_ids) + if added == 0: + if self._undo_stack: + self._restore_state(self._undo_stack.pop()) + self.active_line_id = previous_active_line_id + self._sync_brush_preview_exclusions() + self._refresh_ui_state() + return + self._dirty = True + self.active_line_id = line_id + self.canvas.select_points(pending_ids) + self._sync_brush_preview_exclusions() + self._after_mapping_change(f"Кисть: в {block.name} добавлено LED: {added}.", selection_already_updated=True) + + def _disable_brush(self, message: str) -> None: + action = self.actions.get("brush") + if action is not None: + action.setChecked(False) + self.canvas.set_brush_enabled(False) + self._brush_stroke_started = False + self._brush_pending_point_ids.clear() + self._brush_pending_point_set.clear() + self._sync_brush_preview_exclusions() + self.status.setText(message) + self._refresh_ui_state() + + def _on_tree_selection(self) -> None: + item = self.tree.currentItem() + if item is None: + return + data = item.data(0, Qt.UserRole) + if not data: + return + if data[0] == "line": + line = self._get_line_or_none(data[1]) + if line is None: + self.refresh_tree() + return + self.active_line_id = line.id + self.brush_target = None + self.canvas.set_active_line(self.active_line_id) + self.canvas.select_points([point_id for block in line.blocks for point_id in block.point_ids]) + elif data[0] == "block": + block = self._find_block_or_none(data[1], data[2]) + if block is None: + self.refresh_tree() + return + self.active_line_id = data[1] + self.brush_target = (data[1], data[2]) + self.canvas.set_active_line(self.active_line_id) + self.canvas.select_points(block.point_ids) + self._sync_brush_preview_exclusions() + + def _show_tree_menu(self, pos) -> None: # noqa: ANN001 + item = self.tree.itemAt(pos) + if item is not None: + self.tree.setCurrentItem(item) + menu = QMenu(self) + label = "Удалить выбранную фиксацию" + if item is not None: + data = item.data(0, Qt.UserRole) + if data and data[0] == "line": + label = "Удалить линию" + elif data and data[0] == "block": + label = "Удалить блок" + delete_action = menu.addAction(label) + delete_action.setEnabled(item is not None) + clear_all_action = menu.addAction("Удалить все фиксации") + clear_all_action.setEnabled(bool(self.document.lines)) + fit_action = menu.addAction("Показать все точки") + fit_action.setEnabled(bool(self.document.points)) + chosen = menu.exec(self.tree.viewport().mapToGlobal(pos)) + if chosen == delete_action: + self.remove_selected_tree_item() + elif chosen == clear_all_action: + self.clear_all_assignments() + elif chosen == fit_action: + self.canvas.fit_to_content() + + def _show_table_menu(self, pos) -> None: # noqa: ANN001 + item = self.table.itemAt(pos) + if item is None: + self.table.clearSelection() + self.canvas.select_points([]) + elif item.row() not in self._selected_table_rows(): + self.table.selectRow(item.row()) + menu = QMenu(self) + delete_action = menu.addAction("Удалить фиксацию выбранных LED") + delete_action.setEnabled(self._selected_table_has_assignment()) + clear_selection_action = menu.addAction("Очистить выбор") + clear_selection_action.setEnabled(bool(self._selected_table_point_ids())) + chosen = menu.exec(self.table.viewport().mapToGlobal(pos)) + if chosen == delete_action: + self.canvas.select_points(self._selected_table_point_ids()) + self.clear_selected_point_assignments() + elif chosen == clear_selection_action: + self.canvas.select_points([]) + + def _on_selection_changed(self, point_ids: list[int]) -> None: + self._updating_table = True + sort_column = self.table.horizontalHeader().sortIndicatorSection() + sort_order = self.table.horizontalHeader().sortIndicatorOrder() + sorting_enabled = self.table.isSortingEnabled() + self.table.setUpdatesEnabled(False) + self.table.setSortingEnabled(False) + self.table.blockSignals(True) + try: + self.table.setRowCount(len(point_ids)) + for row, point_id in enumerate(point_ids): + point = self.document.get_point(point_id) + values = [ + point.uid, + point.source_id, + point.row, + point.col, + "-" if point.line_id is None else point.line_id, + "-" if point.block_id is None else point.block_id, + ] + values.extend(self._line_index_columns(point.line_id, point.line_index)) + for col, value in enumerate(values): + item = SortableTableItem(str(value)) + item.setData(Qt.UserRole, point.uid) + item.setData(SORT_ROLE, self._table_sort_value(value)) + self.table.setItem(row, col, item) + self._update_line_index_column_visibility(point_ids) + self.table.setSortingEnabled(sorting_enabled) + if sorting_enabled and sort_column >= 0: + self.table.sortItems(sort_column, sort_order) + finally: + self.table.blockSignals(False) + if self.table.isSortingEnabled() != sorting_enabled: + self.table.setSortingEnabled(sorting_enabled) + self.table.setUpdatesEnabled(True) + self._updating_table = False + self.selection_label.setText(self._selection_summary(point_ids)) + self.status.setText(self._selection_summary(point_ids)) + self._refresh_ui_state() + + def _select_rows_on_canvas(self) -> None: + if self._updating_table: + return + ids = [] + for item in self.table.selectedItems(): + if item.column() == 0: + ids.append(int(item.data(Qt.UserRole))) + if ids: + self.canvas.select_points(ids) + else: + self.canvas.select_points([]) + + def _require_active_line(self) -> int | None: + if self.active_line_id is None: + self.status.setText("Создайте или выберите линию перед назначением LED.") + self._refresh_ui_state() + return None + return self.active_line_id + + def _after_mapping_change(self, message: str, *, selection_already_updated: bool = False) -> None: + self.canvas.refresh_styles() + self.refresh_tree() + if not selection_already_updated: + self._on_selection_changed(self.canvas.selected_point_ids()) + self.status.setText(message) + self._refresh_ui_state() + + def undo_last_change(self) -> None: + if not self._undo_stack: + self.status.setText("Нет действий для отмены.") + self._refresh_ui_state() + return + self._redo_stack.append(self._capture_state()) + self._restore_state(self._undo_stack.pop()) + self.status.setText("Последнее изменение отменено.") + self._refresh_ui_state() + + def redo_last_change(self) -> None: + if not self._redo_stack: + self.status.setText("Нет действий для повтора.") + self._refresh_ui_state() + return + self._undo_stack.append(self._capture_state()) + if len(self._undo_stack) > 20: + self._undo_stack.pop(0) + self._restore_state(self._redo_stack.pop()) + self.status.setText("Изменение повторено.") + self._refresh_ui_state() + + def _sync_canvas_to_active_line(self) -> None: + line = self._get_line_or_none(self.active_line_id) + if line is None: + self.canvas.set_active_line(None) + self.canvas.select_points([]) + return + self.canvas.set_active_line(self.active_line_id) + self.canvas.select_points([point_id for block in line.blocks for point_id in block.point_ids]) + + def _restore_active_line(self, line: LedLine | None) -> None: + if line is not None and line in self.document.lines: + self.active_line_id = line.id + elif self.active_line_id is not None and self._get_line_or_none(self.active_line_id) is not None: + return + else: + self.active_line_id = self.document.lines[0].id if self.document.lines else None + + def _selected_table_point_ids(self) -> list[int]: + rows = sorted({item.row() for item in self.table.selectedItems()}) + point_ids: list[int] = [] + for row in rows: + item = self.table.item(row, 0) + if item is not None: + point_ids.append(int(item.data(Qt.UserRole))) + return point_ids + + def _selected_table_rows(self) -> set[int]: + return {item.row() for item in self.table.selectedItems()} + + def _selected_table_has_assignment(self) -> bool: + for point_id in self._selected_table_point_ids(): + point = self.document.get_point(point_id) + if point.line_id is not None or point.block_id is not None: + return True + return False + + def _find_block(self, line_id: int, block_id: int) -> LedBlock: + line = self.document.get_line(line_id) + for block in line.blocks: + if block.id == block_id: + return block + raise KeyError(block_id) + + def _find_block_or_none(self, line_id: int, block_id: int) -> LedBlock | None: + line = self._get_line_or_none(line_id) + if line is None: + return None + for block in line.blocks: + if block.id == block_id: + return block + return None + + def _get_line_or_none(self, line_id: int) -> LedLine | None: + for line in self.document.lines: + if line.id == line_id: + return line + return None + + def _line_exists(self, line_id: int) -> bool: + return self._get_line_or_none(line_id) is not None + + def _block_exists(self, line_id: int, block_id: int) -> bool: + return self._find_block_or_none(line_id, block_id) is not None + + def _refresh_ui_state(self) -> None: + point_ids = self.canvas.selected_point_ids() + has_points = bool(self.document.points) + has_lines = bool(self.document.lines) + has_active_line = self._get_line_or_none(self.active_line_id) is not None + has_selection = bool(point_ids) + selected_has_assignment = any( + self.document.get_point(point_id).line_id is not None + or self.document.get_point(point_id).block_id is not None + for point_id in point_ids + ) + assigned_count = sum(1 for point in self.document.points if point.line_id is not None) + block_count = sum(len(line.blocks) for line in self.document.lines) + file_name = "-" if self.document.path is None else self.document.path.name + dirty_mark = "*" if self._dirty else "" + self.summary_label.setText( + f"Файл: {file_name}{dirty_mark} | Точек: {len(self.document.points)} | " + f"Линий: {len(self.document.lines)}/{MAX_LINES} | " + f"Блоков: {block_count} | Назначено: {assigned_count}" + ) + active_line = self._get_line_or_none(self.active_line_id) + if active_line is None: + self.active_label.setText("Активная линия: -") + self.active_label.setStyleSheet("") + else: + self.active_label.setText(f"Активная линия: {active_line.name}") + self.active_label.setStyleSheet( + f"border-left: 8px solid {active_line.color}; padding-left: 6px; font-weight: 600;" + ) + self.canvas.set_active_line(self.active_line_id if active_line is not None else None) + self.selection_label.setText(self._selection_summary(point_ids)) + + state = { + "save": has_points and self._dirty, + "save_as": has_points, + "generate_script": has_points, + "undo": bool(self._undo_stack), + "redo": bool(self._redo_stack), + "add_line": has_points and len(self.document.lines) < MAX_LINES, + "add_block": has_points and has_active_line, + "auto_blocks": has_points and has_active_line and has_selection, + "clockwise": has_active_line, + "rebuild_route": has_active_line, + "brush": has_points, + "delete": (has_selection and selected_has_assignment) or self._tree_has_focus_with_item(), + "clear_all": has_lines, + "clear_selection": has_selection, + "fit_view": has_points, + "grid": True, + "toggle_sidebar": True, + } + for key, enabled in state.items(): + action = self.actions.get(key) + if action is not None: + action.setEnabled(enabled) + button = self.side_buttons.get(key) + if button is not None: + button.setEnabled(enabled) + brush_action = self.actions.get("brush") + if brush_action is not None and not state["brush"] and brush_action.isChecked(): + brush_action.setChecked(False) + self.canvas.set_brush_enabled(False) + self._brush_stroke_started = False + self._refresh_block_append_buttons(point_ids) + self._refresh_window_title() + + def _refresh_block_append_buttons(self, point_ids: list[int] | None = None) -> None: + if point_ids is None: + point_ids = self.canvas.selected_point_ids() + selected_set = set(point_ids) + has_selection = bool(selected_set) + for (line_id, block_id), button in self.block_append_buttons.items(): + block = self._find_block_or_none(line_id, block_id) + block_point_set = set() if block is None else set(block.point_ids) + button.setEnabled( + block is not None + and has_selection + and not selected_set.issubset(block_point_set) + ) + + def _refresh_window_title(self) -> None: + file_name = "без файла" if self.document.path is None else self.document.path.name + dirty_mark = "*" if self._dirty else "" + self.setWindowTitle(f"{dirty_mark}{file_name} - LED Mapper Editor") + + def _tree_has_focus_with_item(self) -> bool: + focus = self.focusWidget() + return ( + focus is not None + and (focus is self.tree or self.tree.isAncestorOf(focus)) + and self.tree.currentItem() is not None + ) + + def _current_block_target(self) -> tuple[int, int] | None: + if self.brush_target is not None and self._find_block_or_none(*self.brush_target) is not None: + return self.brush_target + item = self.tree.currentItem() + if item is None: + return None + data = item.data(0, Qt.UserRole) + if data and data[0] == "block" and self._find_block_or_none(data[1], data[2]) is not None: + return data[1], data[2] + return None + + def _selected_block_target(self) -> tuple[int, int] | None: + for point_id in self.canvas.selected_point_ids(): + point = self.document.get_point(point_id) + if point.line_id is None or point.block_id is None: + continue + if self._find_block_or_none(point.line_id, point.block_id) is not None: + return point.line_id, point.block_id + return None + + def _initial_brush_target(self) -> tuple[int, int] | None: + return self._selected_block_target() or self._current_block_target() + + def _create_brush_target(self, point_id: int) -> tuple[int, int] | None: + line = self._get_line_or_none(self.active_line_id) + if line is None: + point = self.document.get_point(point_id) + line = self._get_line_or_none(point.line_id) if point.line_id is not None else None + if line is None and self.document.lines: + line = self.document.lines[0] + if line is None: + if len(self.document.lines) >= MAX_LINES: + self.status.setText(f"Нельзя создать блок: лимит линий {MAX_LINES}.") + return None + line = self.document.add_line() + block = self.document.add_block(line.id, []) + self.active_line_id = line.id + self.brush_target = (line.id, block.id) + self._sync_brush_preview_exclusions() + self.status.setText(f"Кисть: создан {block.name}, добавляйте LED.") + return self.brush_target + + def _sync_brush_preview_exclusions(self) -> None: + excluded_ids = set(self._brush_pending_point_ids) + target = self.brush_target + if target is not None: + block = self._find_block_or_none(*target) + if block is not None: + excluded_ids.update(block.point_ids) + self.canvas.set_brush_excluded_point_ids(excluded_ids) + + def _push_undo(self) -> None: + self._undo_stack.append(self._capture_state()) + if len(self._undo_stack) > 20: + self._undo_stack.pop(0) + self._redo_stack.clear() + self._refresh_ui_state() + + def _capture_state(self) -> tuple[MappingDocument, int | None, list[int], bool]: + return ( + deepcopy(self.document), + self.active_line_id, + self.canvas.selected_point_ids(), + self._dirty, + ) + + def _restore_state(self, state: tuple[MappingDocument, int | None, list[int], bool]) -> None: + document, active_line_id, selected_point_ids, dirty = state + self.document = document + self.active_line_id = active_line_id + self._dirty = dirty + self.canvas.set_document(self.document, reset_view=False) + self.canvas.set_active_line(self.active_line_id) + self.refresh_tree() + self.canvas.select_points([point_id for point_id in selected_point_ids if 0 <= point_id < len(self.document.points)]) + + def _selection_summary(self, point_ids: list[int]) -> str: + if not point_ids: + return "Выбранные LED: 0" + assigned = 0 + lines: set[int] = set() + for point_id in point_ids: + point = self.document.get_point(point_id) + if point.line_id is not None: + assigned += 1 + lines.add(point.line_id) + unassigned = len(point_ids) - assigned + line_text = "-" if not lines else ", ".join(str(line_id) for line_id in sorted(lines)) + return f"Выбранные LED: {len(point_ids)} | назначено: {assigned} | свободно: {unassigned} | линии: {line_text}" + + def _confirm_reassignment(self, point_ids: list[int]) -> bool: + foreign_points = [ + point_id + for point_id in point_ids + if self.document.get_point(point_id).line_id is not None + and self.document.get_point(point_id).line_id != self.active_line_id + ] + assigned_points = [ + point_id + for point_id in point_ids + if self.document.get_point(point_id).line_id is not None + or self.document.get_point(point_id).block_id is not None + ] + if not foreign_points and not assigned_points: + return True + if assigned_points and not foreign_points: + answer = QMessageBox.question( + self, + "Перезаписать блок", + f"{len(assigned_points)} выбранных LED уже находятся в блоках. Перезаписать их новым блоком?", + ) + return answer == QMessageBox.StandardButton.Yes + answer = QMessageBox.question( + self, + "Переназначить LED", + f"{len(foreign_points)} выбранных LED уже находятся в других линиях. Переназначить их в активную линию?", + ) + return answer == QMessageBox.StandardButton.Yes + + def closeEvent(self, event) -> None: # noqa: ANN001 + if self._confirm_discard_changes("Закрыть приложение и потерять несохраненные изменения текущей карты?"): + event.accept() + else: + event.ignore() + + def _confirm_auto_blocks(self, point_ids: list[int], preview: list[list[int]]) -> bool: + if not preview: + return False + assigned = sum(1 for point_id in point_ids if self.document.get_point(point_id).line_id is not None) + if assigned == 0 and len(preview) == 1: + return True + answer = QMessageBox.question( + self, + "Создать автоблоки", + f"Будет создано блоков: {len(preview)}, LED: {sum(len(group) for group in preview)}. " + f"Уже назначенных LED в выборе: {assigned}. Продолжить?", + ) + return answer == QMessageBox.StandardButton.Yes + + def _confirm_delete(self, kind: str, name: str, affected: int) -> bool: + answer = QMessageBox.question( + self, + "Подтвердите удаление", + f"Удалить {kind} '{name}' и снять фиксацию с {affected} LED?", + ) + return answer == QMessageBox.StandardButton.Yes + + def _confirm_discard_changes(self, message: str | None = None) -> bool: + if not self._dirty: + return True + answer = QMessageBox.question( + self, + "Есть несохраненные изменения", + message or "Открыть другой файл и потерять несохраненные изменения текущей карты?", + ) + return answer == QMessageBox.StandardButton.Yes + + @staticmethod + def _line_count(line: LedLine) -> int: + return sum(len(block.point_ids) for block in line.blocks) + + @staticmethod + def _line_index_columns(line_id: int | None, line_index: int | None) -> list[str]: + return [ + "-" if line_id != column or line_index is None else str(line_index) + for column in range(1, MAX_LINES + 1) + ] + + def _update_line_index_column_visibility(self, point_ids: list[int]) -> None: + visible_line_ids = { + point.line_id + for point_id in point_ids + for point in [self.document.get_point(point_id)] + if point.line_id is not None and point.line_index is not None + } + for line_id in range(1, MAX_LINES + 1): + self.table.setColumnHidden(5 + line_id, line_id not in visible_line_ids) + + @staticmethod + def _table_sort_value(value) -> tuple[int, int | str]: # noqa: ANN001 + if value == "-": + return 1, 0 + if isinstance(value, int): + return 0, value + text = str(value) + if text.lstrip("-").isdigit(): + return 0, int(text) + return 0, text + + def _line_summary(self, line: LedLine) -> str: + return f"блоков: {len(line.blocks)}, LED: {self._line_count(line)}" + + def _unfolding_summary(self, line_id: int) -> str: + line = self.document.get_line(line_id) + count = self._line_count(line) + index_range = "-" if count == 0 else f"0-{count - 1}" + return f"{line.name}: развертка {len(line.blocks)} блоков, {count} LED, индекс {index_range}" + + def _document_summary(self) -> str: + block_count = sum(len(line.blocks) for line in self.document.lines) + assigned_count = sum(1 for point in self.document.points if point.line_id is not None) + return f"линий: {len(self.document.lines)}/{MAX_LINES}, блоков: {block_count}, назначено LED: {assigned_count}" diff --git a/models.py b/models.py new file mode 100644 index 0000000..4162a20 --- /dev/null +++ b/models.py @@ -0,0 +1,1343 @@ +from __future__ import annotations + +import json +import math +import re +from collections import deque +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +try: + from unfolding import MAX_LINES, UnfoldingDescription, UnfoldingLineSummary, clockwise_sort, order_points_clockwise +except ImportError: # pragma: no cover - supports package imports in tests/tools. + from .unfolding import MAX_LINES, UnfoldingDescription, UnfoldingLineSummary, clockwise_sort, order_points_clockwise + +MIN_NEIGHBOR_DISTANCE_MM = 34.0 +MAX_NEIGHBOR_TOLERANCE_MM = 2.0 +METER_LIKE_MAX_SPAN = 10.0 + + +@dataclass +class LedPoint: + uid: int + source_id: int + x: float + y: float + row: int + col: int + line_id: int | None = None + block_id: int | None = None + line_index: int | None = None + block_index: int | None = None + coordinate_unit: str = "auto" + + +@dataclass +class LedBlock: + id: int + name: str + point_ids: list[int] = field(default_factory=list) + + +@dataclass +class LedLine: + id: int + name: str + color: str + blocks: list[LedBlock] = field(default_factory=list) + + +class MappingDocument: + def __init__(self) -> None: + self.path: Path | None = None + self.collection_name = "circles" + self.points: list[LedPoint] = [] + self.lines: list[LedLine] = [] + self.unfolding = UnfoldingDescription() + self.coordinate_unit = "auto" + self._next_line_id = 1 + self._next_block_id = 1 + + def clear(self) -> None: + self.path = None + self.collection_name = "circles" + self.points.clear() + self.lines.clear() + self.unfolding = UnfoldingDescription() + self.coordinate_unit = "auto" + self._next_line_id = 1 + self._next_block_id = 1 + + def load(self, path: str | Path) -> None: + self.clear() + self.path = Path(path) + data = json.loads(self.path.read_text(encoding="utf-8-sig")) + self.coordinate_unit = str(data.get("coordinate_unit", data.get("unit", "auto"))) + points_data = data.get("leds") + self.collection_name = "leds" + if not isinstance(points_data, list): + points_data = data.get("circles") + self.collection_name = "circles" + if not isinstance(points_data, list): + raise ValueError("JSON must contain a 'circles' or 'leds' array.") + + for index, item in enumerate(points_data): + self.points.append( + LedPoint( + uid=index, + source_id=int(item.get("source_id", item.get("id", index))), + x=float(item["x"]), + y=float(item["y"]), + row=_optional_int(item.get("row")) if item.get("row") is not None else index * 2, + col=( + _optional_int(item.get("col")) + if item.get("col") is not None + else _optional_int(item.get("column")) if item.get("column") is not None else 0 + ), + line_id=_optional_int(item.get("line_id")), + block_id=_optional_int(item.get("block_id")), + line_index=_optional_int(item.get("line_index")), + block_index=_optional_int(item.get("block_index")), + coordinate_unit=self.coordinate_unit, + ) + ) + + self._load_lines(data) + self.unfolding = UnfoldingDescription.from_payload(data.get("unfolding")) + self.unfolding.max_lines = MAX_LINES + if not self.lines and any(point.line_id is not None for point in self.points): + self._rebuild_lines_from_points() + self._deduplicate_block_memberships() + self._validate_line_limit() + self.renumber_lines() + self.reindex_all() + + def _load_lines(self, data: dict[str, Any]) -> None: + lines_data = data.get("lines", []) + if not isinstance(lines_data, list): + return + source_to_uid = {point.source_id: point.uid for point in self.points} + for line_item in lines_data: + line_id = int(line_item.get("id", self._next_line_id)) + line = LedLine( + id=line_id, + name=str(line_item.get("name", f"Line {line_id}")), + color=str(line_item.get("color", default_color(line_id))), + ) + for block_item in line_item.get("blocks", []): + block_id = int(block_item.get("id", self._next_block_id)) + line.blocks.append( + LedBlock( + id=block_id, + name=str(block_item.get("name", f"Block {block_id}")), + point_ids=self._normalize_loaded_point_ids( + block_item.get("point_ids", []), + source_to_uid, + str(line_item.get("point_id_mode", block_item.get("point_id_mode", "auto"))), + ), + ) + ) + self._next_block_id = max(self._next_block_id, block_id + 1) + self.lines.append(line) + self._next_line_id = max(self._next_line_id, line_id + 1) + + def _rebuild_lines_from_points(self) -> None: + for line_id in sorted({point.line_id for point in self.points if point.line_id is not None}): + line = LedLine(int(line_id), f"Line {line_id}", default_color(int(line_id))) + block_ids = sorted({ + point.block_id + for point in self.points + if point.line_id == line_id and point.block_id is not None + }) + if not block_ids: + ids = [ + point.uid + for point in sorted( + self.points, + key=lambda point: ( + point.line_index if point.line_index is not None else 10**9, + point.source_id, + ), + ) + if point.line_id == line_id + ] + if ids: + line.blocks.append(LedBlock(self._next_block_id, "Block 1", ids)) + self._next_block_id += 1 + for block_id in block_ids: + ids = [ + point.uid + for point in sorted( + self.points, + key=lambda point: ( + point.block_index if point.block_index is not None else 10**9, + point.source_id, + ), + ) + if point.line_id == line_id and point.block_id == block_id + ] + line.blocks.append(LedBlock(int(block_id), f"Block {block_id}", ids)) + self._next_block_id = max(self._next_block_id, int(block_id) + 1) + self.lines.append(line) + self._next_line_id = max(self._next_line_id, int(line_id) + 1) + + def save(self, path: str | Path) -> None: + path = Path(path) + self.renumber_lines() + self.reindex_all() + point_payload = [] + for point in self.points: + point_payload.append( + { + "id": point.source_id, + "x": point.x, + "y": point.y, + "row": point.row, + "col": point.col, + "line_id": point.line_id, + "block_id": point.block_id, + "line_index": point.line_index, + "block_index": point.block_index, + } + ) + + payload = { + self.collection_name: point_payload, + "unfolding": self.describe_unfolding().to_payload(), + "coordinate_unit": self._coordinate_unit_for_save(), + "lines": [ + { + "id": line.id, + "name": line.name, + "color": line.color, + "blocks": [ + {"id": block.id, "name": block.name, "point_ids": block.point_ids} + for block in line.blocks + ], + "point_id_mode": "uid", + } + for line in self.lines + ], + } + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + self.path = path + + def add_line(self) -> LedLine: + line_limit = self._line_limit() + if len(self.lines) >= line_limit: + raise ValueError(f"Mapping supports no more than {line_limit} lines.") + line = LedLine(self._next_line_id, f"Линия {self._next_line_id}", default_color(self._next_line_id)) + self._next_line_id += 1 + self.lines.append(line) + return line + + def add_block(self, line_id: int, point_ids: list[int]) -> LedBlock: + line = self.get_line(line_id) + clean_point_ids = unique_in_order([ + point_id for point_id in point_ids if 0 <= point_id < len(self.points) + ]) + self._detach_points_from_blocks(clean_point_ids) + block = LedBlock(self._next_block_id, self._next_block_name(line), clean_point_ids) + self._next_block_id += 1 + line.blocks.append(block) + for point_id in block.point_ids: + point = self.points[point_id] + point.line_id = line.id + point.block_id = block.id + self.reindex_all() + return block + + def append_points_to_block(self, line_id: int, block_id: int, point_ids: list[int]) -> int: + block = self._get_block(line_id, block_id) + existing = set(block.point_ids) + clean_point_ids = unique_in_order([ + point_id + for point_id in point_ids + if 0 <= point_id < len(self.points) and point_id not in existing + ]) + if not clean_point_ids: + return 0 + self._detach_points_from_blocks(clean_point_ids) + block = self._get_block(line_id, block_id) + block.point_ids.extend(clean_point_ids) + self.reindex_all() + return len(clean_point_ids) + + def get_line(self, line_id: int) -> LedLine: + for line in self.lines: + if line.id == line_id: + return line + raise KeyError(f"Unknown line id: {line_id}") + + def get_point(self, point_id: int) -> LedPoint: + return self.points[point_id] + + def _get_block(self, line_id: int, block_id: int) -> LedBlock: + line = self.get_line(line_id) + for block in line.blocks: + if block.id == block_id: + return block + raise KeyError(block_id) + + def clear_point_assignments(self, point_ids: list[int]) -> int: + point_id_set = set(point_ids) + removed = 0 + for line in self.lines: + for block in line.blocks: + before = len(block.point_ids) + block.point_ids = [point_id for point_id in block.point_ids if point_id not in point_id_set] + removed += before - len(block.point_ids) + + for point_id in point_id_set: + point = self.points[point_id] + if point.line_id is not None or point.block_id is not None: + point.line_id = None + point.block_id = None + point.line_index = None + point.block_index = None + self.reindex_all() + return removed + + def remove_block(self, line_id: int, block_id: int) -> int: + line = self.get_line(line_id) + 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) + self.reindex_all() + return len(removed_ids) + + def remove_line(self, line_id: int) -> int: + removed_ids: list[int] = [] + kept_lines: list[LedLine] = [] + for line in self.lines: + if line.id == line_id: + for block in line.blocks: + removed_ids.extend(block.point_ids) + else: + kept_lines.append(line) + self.lines = kept_lines + self._clear_points(removed_ids) + self.renumber_lines() + self.reindex_all() + return len(removed_ids) + + def clear_all_assignments(self) -> int: + assigned = sum(1 for point in self.points if point.line_id is not None or point.block_id is not None) + self.lines.clear() + self._clear_points([point.uid for point in self.points]) + self._next_line_id = 1 + self._next_block_id = 1 + return assigned + + def renumber_lines(self) -> None: + self.order_lines_clockwise() + id_map: dict[int, int] = {} + for new_id, line in enumerate(self.lines, start=1): + old_id = line.id + id_map[old_id] = new_id + line.id = new_id + line.name = f"Линия {new_id}" + line.color = default_color(new_id) + + for point in self.points: + if point.line_id in id_map: + point.line_id = id_map[point.line_id] + + self._next_line_id = len(self.lines) + 1 + + def _validate_line_limit(self) -> None: + line_limit = self._line_limit() + if len(self.lines) > line_limit: + raise ValueError(f"Mapping supports no more than {line_limit} lines.") + + def _line_limit(self) -> int: + return MAX_LINES + + def _coordinate_unit_for_save(self) -> str: + if self.coordinate_unit in {"m", "meter", "meters"}: + return "m" + if self.coordinate_unit in {"mm", "millimeter", "millimeters"}: + return "mm" + return "m" if coordinate_display_scale(self.points) == 1000.0 else "mm" + + def order_lines_clockwise(self) -> None: + self.lines = clockwise_sort(self.lines, self._points_for_line) + + def order_blocks_clockwise(self, line_id: int, *, reorder_leds: bool = False) -> None: + line = self.get_line(line_id) + 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)] + if start_point_id is not None: + ordered_ids = rotate_ids_to_start(ordered_ids, start_point_id) + block.point_ids = ordered_ids + line.blocks = clockwise_sort(line.blocks, self._points_for_block) + + def describe_unfolding(self) -> UnfoldingDescription: + summary = UnfoldingDescription( + max_lines=self._line_limit(), + line_order="clockwise", + block_order="clockwise", + led_index_scope="line", + block_size=self.unfolding.block_size, + ) + for line in self.lines: + led_count = sum(len(block.point_ids) for block in line.blocks) + block_count = len(line.blocks) or summary.expected_block_count(led_count) + summary.block_counts.append(UnfoldingLineSummary(line.id, led_count, block_count)) + return summary + + def _points_for_line(self, line: LedLine) -> list[LedPoint]: + return [point for block in line.blocks for point in self._points_for_block(block)] + + def _points_for_block(self, block: LedBlock) -> list[LedPoint]: + return [self.points[point_id] for point_id in block.point_ids if 0 <= point_id < len(self.points)] + + def _normalize_loaded_point_ids( + self, + values: Any, + source_to_uid: dict[int, int], + mode: str = "auto", + ) -> list[int]: + if not isinstance(values, list): + return [] + raw_ids = [int(value) for value in values] + if mode in {"source", "source_id"}: + return unique_in_order([ + source_to_uid[value] + for value in raw_ids + if value in source_to_uid + ]) + valid_uid_ids = [point_id for point_id in raw_ids if 0 <= point_id < len(self.points)] + if mode == "uid": + return unique_in_order(valid_uid_ids) + if mode == "auto" and len(valid_uid_ids) == len(raw_ids): + return unique_in_order(valid_uid_ids) + return unique_in_order([ + source_to_uid[value] + for value in raw_ids + if value in source_to_uid + ]) + + def _deduplicate_block_memberships(self) -> None: + seen: set[int] = set() + for line in self.lines: + for block in line.blocks: + unique_ids = [] + for point_id in block.point_ids: + if not 0 <= point_id < len(self.points): + continue + if point_id in seen: + continue + seen.add(point_id) + unique_ids.append(point_id) + block.point_ids = unique_ids + + def _detach_points_from_blocks(self, point_ids: list[int]) -> int: + if not point_ids: + return 0 + point_id_set = set(point_ids) + removed = 0 + for line in self.lines: + for block in line.blocks: + before = len(block.point_ids) + block.point_ids = [point_id for point_id in block.point_ids if point_id not in point_id_set] + removed += before - len(block.point_ids) + self._clear_points(list(point_id_set)) + return removed + + def _clear_points(self, point_ids: list[int]) -> None: + for point_id in point_ids: + point = self.points[point_id] + point.line_id = None + point.block_id = None + point.line_index = None + point.block_index = None + + def auto_connect_line(self, line_id: int) -> None: + line = self.get_line(line_id) + self.order_blocks_clockwise(line.id, reorder_leds=True) + self.reindex_line(line.id) + + def rebuild_line_blocks_by_route(self, line_id: int) -> int: + line = self.get_line(line_id) + point_ids = [point_id for block in line.blocks for point_id in block.point_ids] + if not point_ids: + return 0 + start_ids = [block.point_ids[0] for block in line.blocks if block.point_ids] + start_id = start_ids[0] if start_ids else point_ids[0] + groups = self.route_groups_from_points(point_ids, start_id) + line.blocks.clear() + for group in groups: + line.blocks.append(LedBlock(self._next_block_id, self._next_block_name(line), group)) + self._next_block_id += 1 + self.reindex_all() + return len(groups) + + def auto_blocks_from_selection(self, line_id: int, point_ids: list[int], groups: list[list[int]] | None = None) -> None: + groups = self.auto_block_preview(point_ids) if groups is None else groups + if not groups: + return + self._detach_points_from_blocks([point_id for group in groups for point_id in group]) + line = self.get_line(line_id) + for group_ids in groups: + block = LedBlock(self._next_block_id, self._next_block_name(line), unique_in_order(group_ids)) + self._next_block_id += 1 + line.blocks.append(block) + self.order_blocks_clockwise(line_id, reorder_leds=False) + self.reindex_all() + + def route_groups_from_points(self, point_ids: list[int], start_point_id: int | None = None) -> list[list[int]]: + points = [ + self.points[point_id] + for point_id in unique_in_order(point_ids) + if 0 <= point_id < len(self.points) + ] + return [ + [point.uid for point in route] + for route in trace_route_paths_like_grid_svg(points, start_point_id) + ] + + def add_clockwise_block_from_seed(self, line_id: int, start_point_id: int) -> LedBlock: + if not 0 <= start_point_id < len(self.points): + raise ValueError(f"Unknown LED point id: {start_point_id}") + group_ids = self.clockwise_block_from_seed_preview(start_point_id) + return self.add_clockwise_block_from_group(line_id, start_point_id, group_ids) + + def add_clockwise_block_from_group(self, line_id: int, start_point_id: int, group_ids: list[int]) -> LedBlock: + if not 0 <= start_point_id < len(self.points): + raise ValueError(f"Unknown LED point id: {start_point_id}") + block = self.add_block(line_id, group_ids) + block.point_ids = rotate_ids_to_start(block.point_ids, start_point_id) + self.reindex_line(line_id) + return block + + def clockwise_block_from_seed_preview(self, start_point_id: int) -> list[int]: + if not 0 <= start_point_id < len(self.points): + return [] + candidate_points = [ + point + 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) + adjacency = build_route_adjacency(candidate_points, threshold) + route = trace_single_route_like_grid_svg( + candidate_points, + start_point_id, + adjacency=adjacency, + allowed_ids={point.uid for point in candidate_points}, + min_neighbor_distance=threshold, + ) + if not route: + return [start_point_id] + return rotate_ids_to_start( + [point.uid for point in normalize_route_clockwise(route, threshold)], + start_point_id, + ) + + def auto_block_preview(self, point_ids: list[int]) -> list[list[int]]: + points = [ + self.points[point_id] + for point_id in sorted_unique(point_ids) + if 0 <= point_id < len(self.points) + ] + return [ + [point.uid for point in route] + for route in trace_route_paths_like_grid_svg(points) + ] + + def reindex_all(self) -> None: + for point in self.points: + point.line_id = None + point.block_id = None + point.line_index = None + point.block_index = None + for line in self.lines: + self.reindex_line(line.id) + + def reindex_line(self, line_id: int) -> None: + line = self.get_line(line_id) + line_index = 0 + for block in line.blocks: + for block_index, point_id in enumerate(block.point_ids): + point = self.points[point_id] + point.line_id = line.id + point.block_id = block.id + point.line_index = line_index + point.block_index = block_index + line_index += 1 + + @staticmethod + def _next_block_name(line: LedLine) -> str: + max_number = 0 + for block in line.blocks: + match = re.search(r"(\d+)$", block.name) + if match is not None: + max_number = max(max_number, int(match.group(1))) + return f"Блок {max_number + 1}" + + +def sorted_unique(values: list[int]) -> list[int]: + return sorted(set(values)) + + +def unique_in_order(values: list[int]) -> list[int]: + seen: set[int] = set() + result: list[int] = [] + for value in values: + if value in seen: + continue + seen.add(value) + result.append(value) + return result + + +def rotate_ids_to_start(values: list[int], start_value: int) -> list[int]: + if start_value not in values: + return values[:] + start_index = values.index(start_value) + return [*values[start_index:], *values[:start_index]] + + +def normalize_route_clockwise(route: list[LedPoint], min_neighbor_distance: float) -> list[LedPoint]: + if len(route) < 3: + return route[:] + if not is_valid_route_neighbor(route[-1], route[0], min_neighbor_distance): + return route[:] + return list(reversed(route)) if route_signed_area(route) > 0 else route[:] + + +def route_signed_area(route: list[LedPoint]) -> float: + area = 0.0 + for current, next_point in zip(route, [*route[1:], route[0]]): + area += current.x * next_point.y - next_point.x * current.y + return area / 2.0 + + +def _optional_int(value: Any) -> int | None: + return None if value is None else int(value) + + +def default_color(line_id: int) -> str: + colors = ["#e11d48", "#2563eb", "#16a34a", "#f59e0b", "#7c3aed", "#0891b2"] + return colors[(line_id - 1) % len(colors)] + + +def distance(a: LedPoint, b: LedPoint) -> float: + return math.hypot(a.x - b.x, a.y - b.y) + + +def are_neighbors(a: LedPoint, b: LedPoint) -> bool: + return abs(a.row - b.row) <= 1 and abs(a.col - b.col) <= 1 + + +def connected_groups(points: list[LedPoint]) -> list[list[LedPoint]]: + remaining = set(point.uid for point in points) + by_id = {point.uid: point for point in points} + groups: list[list[LedPoint]] = [] + while remaining: + start = remaining.pop() + queue = [start] + group = [by_id[start]] + while queue: + current_id = queue.pop() + current = by_id[current_id] + for candidate_id in list(remaining): + if are_neighbors(current, by_id[candidate_id]): + remaining.remove(candidate_id) + queue.append(candidate_id) + group.append(by_id[candidate_id]) + groups.append(group) + return sorted(groups, key=lambda group: min(point.uid for point in group)) + + +def contour_groups_like_grid_svg(points: list[LedPoint]) -> 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) + + cells = set(points_by_cell) + seen: set[tuple[int, int]] = set() + groups: list[list[LedPoint]] = [] + neighbors = ((1, 0), (-1, 0), (0, 1), (0, -1)) + + for start_cell in sorted(cells): + if start_cell in seen: + continue + queue = deque([start_cell]) + seen.add(start_cell) + group_cells: list[tuple[int, int]] = [] + while queue: + row, col = queue.popleft() + group_cells.append((row, col)) + for d_row, d_col in neighbors: + next_cell = (row + d_row, col + d_col) + if next_cell in cells and next_cell not in seen: + seen.add(next_cell) + queue.append(next_cell) + groups.append([ + point + for cell in group_cells + for point in points_by_cell[cell] + ]) + + 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]]: + points_by_cell: dict[tuple[int, int], list[LedPoint]] = {} + for point in points: + points_by_cell.setdefault((point.row, point.col), []).append(point) + + cells = set(points_by_cell) + 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) + + for start_cell in sorted(cells): + if start_cell in seen: + continue + queue = deque([start_cell]) + seen.add(start_cell) + group_cells: list[tuple[int, int]] = [] + while queue: + row, col = queue.popleft() + group_cells.append((row, col)) + for d_row, d_col in neighbors: + next_cell = (row + d_row, col + d_col) + if next_cell not in cells or next_cell in seen: + continue + if not cells_have_valid_route_neighbor(points_by_cell[(row, col)], points_by_cell[next_cell], threshold): + continue + seen.add(next_cell) + queue.append(next_cell) + groups.append([ + point + for cell in group_cells + for point in points_by_cell[cell] + ]) + + return sorted(groups, key=lambda group: min(point_sort_key_for_model(point) for point in group)) + + +def cells_have_valid_route_neighbor( + first_points: list[LedPoint], + second_points: list[LedPoint], + threshold: float, +) -> bool: + return any( + distance(first, second) <= max_neighbor_distance(threshold) + for first in first_points + for second in second_points + ) + + +def order_group_nearest(points: list[LedPoint]) -> list[LedPoint]: + if len(points) <= 1: + return points[:] + remaining = points[:] + ordered = [min(remaining, key=lambda point: (point.row, point.col, point.source_id))] + remaining.remove(ordered[0]) + while remaining: + previous = ordered[-1] + next_point = min(remaining, key=lambda point: (distance(previous, point), point.row, point.col)) + ordered.append(next_point) + remaining.remove(next_point) + return ordered + + +def order_points_like_grid_svg(points: list[LedPoint], start_point_id: int | None = None) -> list[LedPoint]: + if len(points) <= 1: + return points[:] + min_neighbor_distance = min_neighbor_distance_for_points(points) + remaining = points[:] + if start_point_id is None: + start_point = min(remaining, key=point_sort_key_for_model) + else: + start_point = next((point for point in remaining if point.uid == start_point_id), None) + if start_point is None: + start_point = min(remaining, key=point_sort_key_for_model) + ordered = [start_point] + remaining.remove(start_point) + + while remaining: + previous = ordered[-1] + neighbors = [ + point + for point in remaining + if is_valid_route_neighbor(previous, point, min_neighbor_distance) + ] + if neighbors: + next_point = min( + neighbors, + key=lambda point: route_neighbor_preference(previous, point, min_neighbor_distance), + ) + else: + next_point = min( + remaining, + key=lambda point: ( + distance(previous, point), + point_sort_key_for_model(point), + ), + ) + ordered.append(next_point) + remaining.remove(next_point) + + return ordered + + +def trace_route_paths_like_grid_svg( + points: list[LedPoint], + start_point_id: int | 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) + while remaining: + if preferred_start_id is not None: + start_point = next((point for point in remaining if point.uid == preferred_start_id), None) + else: + start_point = None + if start_point is None: + start_point = min(remaining, key=point_sort_key_for_model) + adjacency = build_route_adjacency(remaining, min_neighbor_distance) + route = trace_single_route_like_grid_svg( + remaining, + start_point.uid, + adjacency=adjacency, + allowed_ids=remaining_ids, + min_neighbor_distance=min_neighbor_distance, + ) + if not route: + break + route_ids = {point.uid for point in route} + routes.append(route) + remaining_ids -= route_ids + remaining = [point for point in remaining if point.uid in remaining_ids] + preferred_start_id = None + return routes + + +def trace_single_route_like_grid_svg( + points: list[LedPoint], + start_point_id: int, + adjacency: dict[int, list[LedPoint]] | None = None, + allowed_ids: set[int] | None = None, + min_neighbor_distance: float | None = None, +) -> list[LedPoint]: + if not points: + return [] + min_neighbor_distance = min_neighbor_distance or min_neighbor_distance_for_points(points) + adjacency = adjacency or build_route_adjacency(points, min_neighbor_distance) + remaining = points[:] + remaining_ids = set(allowed_ids) if allowed_ids is not None else {point.uid for point in remaining} + start_point = next((point for point in remaining if point.uid == start_point_id), None) + if start_point is None: + start_point = min(remaining, key=point_sort_key_for_model) + route = [start_point] + remaining.remove(start_point) + remaining_ids.remove(start_point.uid) + + while remaining: + previous = route[-1] + neighbors = [ + point + for point in adjacency.get(previous.uid, []) + if point.uid in remaining_ids + ] + if not neighbors: + neighbors = fallback_route_neighbors( + route, + previous, + remaining, + remaining_ids, + min_neighbor_distance, + ) + if not neighbors: + break + next_point = choose_next_route_point(route, neighbors, min_neighbor_distance) + route.append(next_point) + remaining.remove(next_point) + remaining_ids.remove(next_point.uid) + + return route + + +def fallback_route_neighbors( + route: list[LedPoint], + current: LedPoint, + points: list[LedPoint], + allowed_ids: set[int], + min_neighbor_distance: float, +) -> list[LedPoint]: + valid_neighbors = [ + point + for point in points + if point.uid in allowed_ids and is_valid_route_neighbor(current, point, min_neighbor_distance) + ] + if valid_neighbors or len(route) < 2: + return valid_neighbors + + trajectory_neighbors = [ + point + for point in points + if point.uid in allowed_ids and is_trajectory_continuation(route[-2], current, point, min_neighbor_distance) + ] + return trajectory_neighbors + + +def choose_next_route_point( + route: list[LedPoint], + neighbors: list[LedPoint], + min_neighbor_distance: float, +) -> LedPoint: + if len(route) < 2: + return min(neighbors, key=lambda point: route_path_preference(route, point, min_neighbor_distance)) + + neighbors = nearest_neighbor_band(route[-1], neighbors, min_neighbor_distance) + previous = route[-2] + current = route[-1] + same_vector_direction = [ + point + for point in neighbors + if same_direction_vector(previous, current, point) + ] + if same_vector_direction: + return min(same_vector_direction, key=lambda point: route_path_preference(route, point, min_neighbor_distance)) + + forward_direction = [ + point + for point in neighbors + if forward_direction_vector(previous, current, point) + ] + if forward_direction: + return min(forward_direction, key=lambda point: route_path_preference(route, point, min_neighbor_distance)) + + return min(neighbors, key=lambda point: route_path_preference(route, point, min_neighbor_distance)) + + +def nearest_neighbor_band( + current: LedPoint, + neighbors: list[LedPoint], + min_neighbor_distance: float, +) -> list[LedPoint]: + nearest_distance = min(distance(current, point) for point in neighbors) + band_limit = min(nearest_distance + neighbor_tolerance(min_neighbor_distance), max_neighbor_distance(min_neighbor_distance)) + band = [point for point in neighbors if distance(current, point) <= band_limit] + return band or [min(neighbors, key=lambda point: distance(current, point))] + + +def same_direction_vector(previous: LedPoint, current: LedPoint, candidate: LedPoint) -> bool: + incoming_x = current.x - previous.x + incoming_y = current.y - previous.y + outgoing_x = candidate.x - current.x + outgoing_y = candidate.y - current.y + if incoming_x == 0 and incoming_y == 0: + return False + if outgoing_x == 0 and outgoing_y == 0: + return False + dot = incoming_x * outgoing_x + incoming_y * outgoing_y + cross = incoming_x * outgoing_y - incoming_y * outgoing_x + return dot > 0 and abs(cross) <= max(1e-6, 0.1 * math.hypot(incoming_x, incoming_y) * math.hypot(outgoing_x, outgoing_y)) + + +def forward_direction_vector(previous: LedPoint, current: LedPoint, candidate: LedPoint) -> bool: + incoming_x = current.x - previous.x + incoming_y = current.y - previous.y + outgoing_x = candidate.x - current.x + outgoing_y = candidate.y - current.y + incoming_length = math.hypot(incoming_x, incoming_y) + outgoing_length = math.hypot(outgoing_x, outgoing_y) + if incoming_length == 0 or outgoing_length == 0: + return False + dot = incoming_x * outgoing_x + incoming_y * outgoing_y + cos_angle = dot / (incoming_length * outgoing_length) + return cos_angle > -0.25 + + +def is_trajectory_continuation( + previous: LedPoint, + current: LedPoint, + candidate: LedPoint, + min_neighbor_distance: float, +) -> bool: + if not forward_direction_vector(previous, current, candidate): + return False + return distance(current, candidate) <= route_continuation_distance_limit(current, candidate, min_neighbor_distance) + + +def route_continuation_distance_limit(current: LedPoint, candidate: LedPoint, min_neighbor_distance: float) -> float: + return min_neighbor_distance * 2.0 + neighbor_tolerance(min_neighbor_distance) + + +def build_route_adjacency( + points: list[LedPoint], + min_neighbor_distance: float, +) -> dict[int, list[LedPoint]]: + search_radius = min_neighbor_distance * math.sqrt(2.0) + neighbor_tolerance(min_neighbor_distance) + bucket_size = max(search_radius, 1e-9) + points_by_bucket: dict[tuple[int, int], list[LedPoint]] = {} + for point in points: + bucket = (math.floor(point.x / bucket_size), math.floor(point.y / bucket_size)) + points_by_bucket.setdefault(bucket, []).append(point) + + raw_adjacency: dict[int, list[LedPoint]] = {} + for point in points: + candidates: list[LedPoint] = [] + bucket = (math.floor(point.x / bucket_size), math.floor(point.y / bucket_size)) + for d_x in (-1, 0, 1): + for d_y in (-1, 0, 1): + for candidate in points_by_bucket.get((bucket[0] + d_x, bucket[1] + d_y), []): + if candidate.uid == point.uid: + continue + if is_valid_route_neighbor(point, candidate, min_neighbor_distance): + candidates.append(candidate) + raw_adjacency[point.uid] = candidates + + adjacency: dict[int, list[LedPoint]] = {} + for point in points: + candidates = [ + candidate + for candidate in raw_adjacency[point.uid] + if ( + not route_edge_skips_intermediate_point( + point, + candidate, + points_by_bucket, + bucket_size, + min_neighbor_distance, + ) + and not is_parallel_track_jump(point, candidate, raw_adjacency) + ) + ] + adjacency[point.uid] = sorted( + candidates, + key=lambda candidate: route_neighbor_preference(point, candidate, min_neighbor_distance), + ) + return adjacency + + +def route_edge_skips_intermediate_point( + current: LedPoint, + candidate: LedPoint, + points_by_bucket: dict[tuple[int, int], list[LedPoint]], + bucket_size: float, + min_neighbor_distance: float, +) -> bool: + segment_x = candidate.x - current.x + segment_y = candidate.y - current.y + segment_length_sq = segment_x * segment_x + segment_y * segment_y + if segment_length_sq <= 0: + return False + + segment_length = math.sqrt(segment_length_sq) + line_tolerance = max(neighbor_tolerance(min_neighbor_distance), min_neighbor_distance * 0.25) + end_tolerance = max(1e-6, line_tolerance * 0.25) + min_bucket_x = math.floor((min(current.x, candidate.x) - line_tolerance) / bucket_size) + max_bucket_x = math.floor((max(current.x, candidate.x) + line_tolerance) / bucket_size) + min_bucket_y = math.floor((min(current.y, candidate.y) - line_tolerance) / bucket_size) + max_bucket_y = math.floor((max(current.y, candidate.y) + line_tolerance) / bucket_size) + nearby_points = ( + point + for bucket_x in range(min_bucket_x, max_bucket_x + 1) + for bucket_y in range(min_bucket_y, max_bucket_y + 1) + for point in points_by_bucket.get((bucket_x, bucket_y), []) + ) + for point in nearby_points: + if point.uid in {current.uid, candidate.uid}: + continue + point_x = point.x - current.x + point_y = point.y - current.y + projection = (point_x * segment_x + point_y * segment_y) / segment_length_sq + if projection <= 0.0 or projection >= 1.0: + continue + perpendicular = abs(point_x * segment_y - point_y * segment_x) / segment_length + if perpendicular > line_tolerance: + continue + if distance(current, point) < segment_length - end_tolerance: + return True + return False + + +def is_parallel_track_jump( + current: LedPoint, + candidate: LedPoint, + raw_adjacency: dict[int, list[LedPoint]], +) -> bool: + d_x = candidate.x - current.x + d_y = candidate.y - current.y + if abs(d_x) > abs(d_y): + return ( + route_neighbor_axis_count(current, raw_adjacency, "vertical") >= 2 + and route_neighbor_axis_count(candidate, raw_adjacency, "vertical") >= 2 + ) + if abs(d_y) > abs(d_x): + return ( + route_neighbor_axis_count(current, raw_adjacency, "horizontal") >= 2 + and route_neighbor_axis_count(candidate, raw_adjacency, "horizontal") >= 2 + ) + return False + + +def route_neighbor_axis_count( + point: LedPoint, + raw_adjacency: dict[int, list[LedPoint]], + axis: str, +) -> int: + if axis == "horizontal": + return sum(1 for candidate in raw_adjacency[point.uid] if abs(candidate.x - point.x) > abs(candidate.y - point.y)) + return sum(1 for candidate in raw_adjacency[point.uid] if abs(candidate.y - point.y) > abs(candidate.x - point.x)) + + +def trace_closed_route( + points: list[LedPoint], + start_point: LedPoint, + min_neighbor_distance: float, + adjacency: dict[int, list[LedPoint]] | None = None, + allowed_ids: set[int] | None = None, +) -> list[LedPoint]: + adjacency = adjacency or build_route_adjacency(points, min_neighbor_distance) + allowed_ids = set(allowed_ids) if allowed_ids is not None else {point.uid for point in points} + if len(adjacency.get(start_point.uid, [])) < 2: + return [] + + route = [start_point] + visited = {start_point.uid} + stack: list[tuple[LedPoint, list[LedPoint], int]] = [( + start_point, + [point for point in adjacency[start_point.uid] if point.uid in allowed_ids], + 0, + )] + attempts = 0 + max_attempts = min(5000, max(1000, len(points) * 25)) + + while stack and attempts < max_attempts: + current, candidates, next_index = stack[-1] + attempts += 1 + + meaningful_continuations = [ + point + for point in adjacency.get(current.uid, []) + if point.uid in allowed_ids and point.uid not in visited + and len([next_point for next_point in adjacency.get(point.uid, []) if next_point.uid in allowed_ids]) > 1 + ] + if ( + len(route) >= 4 + and is_valid_route_neighbor(current, start_point, min_neighbor_distance) + and not meaningful_continuations + ): + return route[:] + + if next_index >= len(candidates): + stack.pop() + removed = route.pop() + visited.remove(removed.uid) + continue + + candidate = candidates[next_index] + stack[-1] = (current, candidates, next_index + 1) + if candidate.uid in visited: + continue + + visited.add(candidate.uid) + route.append(candidate) + next_candidates = [ + point + for point in adjacency.get(candidate.uid, []) + if point.uid in allowed_ids and point.uid not in visited + ] + stack.append((candidate, next_candidates, 0)) + + return [] + + +def point_sort_key_for_model(point: LedPoint) -> tuple[int, int, float, float, int]: + return point.row, point.col, point.x, point.y, point.uid + + +def too_close_preference(current: LedPoint, candidate: LedPoint, min_distance: float) -> int: + return 1 if distance(current, candidate) < min_distance else 0 + + +def route_neighbor_preference( + current: LedPoint, + candidate: LedPoint, + min_distance: float, +) -> tuple[int, int, int, float, tuple[int, int, float, float, int]]: + return ( + too_close_preference(current, candidate, min_distance), + diagonal_cell_preference(current, candidate), + cardinal_direction_preference(current, candidate), + distance(current, candidate), + point_sort_key_for_model(candidate), + ) + + +def route_path_preference( + route: list[LedPoint], + candidate: LedPoint, + min_distance: float, +) -> tuple[int, float, int, int, float, tuple[int, int, float, float, int]]: + current = route[-1] + if len(route) < 2: + return ( + diagonal_cell_preference(current, candidate), + 0.0, + cardinal_direction_preference(current, candidate), + too_close_preference(current, candidate, min_distance), + 0, + distance(current, candidate), + point_sort_key_for_model(candidate), + ) + + previous = route[-2] + turn_class, turn_rank = route_clockwise_turn_preference(previous, current, candidate) + return ( + turn_class, + turn_rank, + diagonal_cell_preference(current, candidate), + too_close_preference(current, candidate, min_distance), + cardinal_direction_preference(current, candidate), + distance(current, candidate), + point_sort_key_for_model(candidate), + ) + + +def route_clockwise_turn_preference(previous: LedPoint, current: LedPoint, candidate: LedPoint) -> tuple[int, float]: + incoming_x = current.x - previous.x + incoming_y = current.y - previous.y + outgoing_x = candidate.x - current.x + outgoing_y = candidate.y - current.y + cross = incoming_x * outgoing_y - incoming_y * outgoing_x + angle = route_turn_angle(previous, current, candidate) + epsilon = 1e-6 + if abs(cross) <= epsilon: + return 0, 0.0 + if angle < math.radians(35): + return 0, angle + if cross < -epsilon: + return 1, -angle + return 2, angle + + +def route_turn_angle(previous: LedPoint, current: LedPoint, candidate: LedPoint) -> float: + incoming_x = current.x - previous.x + incoming_y = current.y - previous.y + outgoing_x = candidate.x - current.x + outgoing_y = candidate.y - current.y + incoming_length = math.hypot(incoming_x, incoming_y) + outgoing_length = math.hypot(outgoing_x, outgoing_y) + if incoming_length == 0 or outgoing_length == 0: + return 0.0 + dot = incoming_x * outgoing_x + incoming_y * outgoing_y + cos_angle = max(-1.0, min(1.0, dot / (incoming_length * outgoing_length))) + return math.acos(cos_angle) + + +def diagonal_cell_preference(current: LedPoint, candidate: LedPoint) -> int: + return 1 if abs(current.row - candidate.row) == 1 and abs(current.col - candidate.col) == 1 else 0 + + +def cardinal_direction_preference(current: LedPoint, candidate: LedPoint) -> int: + d_x = candidate.x - current.x + d_y = candidate.y - current.y + if abs(d_y) >= abs(d_x) and d_y > 0: + return 0 + if abs(d_x) > abs(d_y) and d_x < 0: + return 1 + if abs(d_y) >= abs(d_x) and d_y < 0: + return 2 + if abs(d_x) > abs(d_y) and d_x > 0: + return 3 + return 4 + + +def is_valid_route_neighbor(current: LedPoint, candidate: LedPoint, nominal_distance: float) -> bool: + return distance(current, candidate) <= route_neighbor_distance_limit( + current, + candidate, + nominal_distance, + ) + + +def route_neighbor_distance_limit(current: LedPoint, candidate: LedPoint, nominal_distance: float) -> float: + if abs(current.row - candidate.row) == 1 and abs(current.col - candidate.col) == 1: + return nominal_distance * math.sqrt(2.0) + neighbor_tolerance(nominal_distance) + return max_neighbor_distance(nominal_distance) + + +def max_neighbor_distance(nominal_distance: float) -> float: + return nominal_distance + neighbor_tolerance(nominal_distance) + + +def neighbor_tolerance(nominal_distance: float) -> float: + 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) + + +def coordinate_display_scale(points: list[LedPoint]) -> float: + if not points: + return 1.0 + explicit_units = {point.coordinate_unit for point in points if point.coordinate_unit != "auto"} + if explicit_units & {"m", "meter", "meters"}: + return 1000.0 + if explicit_units & {"mm", "millimeter", "millimeters"}: + return 1.0 + xs = [point.x for point in points] + ys = [point.y for point in points] + span = max(max(xs) - min(xs), max(ys) - min(ys)) + return 1000.0 if span <= METER_LIKE_MAX_SPAN else 1.0 + + +def clockwise_preference(current: LedPoint, candidate: LedPoint) -> int: + d_row = candidate.row - current.row + d_col = candidate.col - current.col + clockwise_directions = [ + (1, 0), + (1, 1), + (0, 1), + (-1, 1), + (-1, 0), + (-1, -1), + (0, -1), + (1, -1), + ] + try: + return clockwise_directions.index((sign(d_row), sign(d_col))) + except ValueError: + return len(clockwise_directions) + + +def same_cell_preference(current: LedPoint, candidate: LedPoint) -> int: + return 0 if current.row == candidate.row and current.col == candidate.col else 1 + + +def upward_left_preference(current: LedPoint, candidate: LedPoint) -> int: + if candidate.row < current.row and candidate.col <= current.col: + return 0 + if candidate.row < current.row: + return 1 + return 2 + + +def sign(value: int) -> int: + if value > 0: + return 1 + if value < 0: + return -1 + return 0 diff --git a/panel_mapping_generator.py b/panel_mapping_generator.py new file mode 100644 index 0000000..331498a --- /dev/null +++ b/panel_mapping_generator.py @@ -0,0 +1,491 @@ +from __future__ import annotations + +import json +import math +from collections import defaultdict +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Iterable + +try: + from .models import ( + LedPoint, + 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, + 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, + ) + + +OUTPUT_UNIT = "mm" +CELL_SIZE_MM = 34.0 +MAX_LEDS_PER_CELL = 3 +NUM_STRIPS = 4 +EMPTY_STRIP = "0xFF" +EMPTY_LED = "0xFFFF" + + +@dataclass(frozen=True) +class PanelLedPoint: + generated_id: int + source_id: int + input_index: int + strip: int | None + led_id: int | None + x_mm: float + y_mm: float + row: int + col: int + matrix_row: int + matrix_col: int + route_group: int + + +@dataclass(frozen=True) +class BuildResult: + output_dir: Path + input_unit: str + led_count: int + block_count: int + vm_rows: int + vm_cols: int + grid_rows: int + grid_cols: int + generated_files: dict[str, Path] + assigned_count: int + unassigned_count: int + + +def generate_panel_mapping_from_document(document: MappingDocument, output_dir: str | Path) -> BuildResult: + if not document.points: + raise ValueError("Нет точек для генерации скрипта.") + + output_dir = Path(output_dir).resolve() + output_dir.mkdir(parents=True, exist_ok=True) + input_unit = _input_unit_for_document(document) + points = normalize_document_points(document) + mapping_points = [point for point in points if point.strip is not None and point.led_id is not None] + if not mapping_points: + 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", + } + + write_vm_map_header(generated_files["vm_map"], points, mapping_points, cells, block_count) + write_legacy_ino(generated_files["legacy_ino"], mapping_points, cells) + write_ordered_json(generated_files["ordered_json"], points, document) + write_validation_report(generated_files["validation_report"], points) + write_readme(generated_files["readme"]) + + vm_rows, vm_cols, grid_rows, grid_cols = get_dimensions(points) + return BuildResult( + output_dir=output_dir, + input_unit=input_unit, + led_count=len(points), + block_count=block_count, + vm_rows=vm_rows, + vm_cols=vm_cols, + grid_rows=grid_rows, + grid_cols=grid_cols, + generated_files=generated_files, + assigned_count=len(mapping_points), + unassigned_count=len(points) - len(mapping_points), + ) + + +def normalize_document_points(document: MappingDocument) -> list[PanelLedPoint]: + scale = _coordinate_scale_to_mm(document) + route_groups = route_groups_like_grid_svg(document.points) + 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)) + ordered.extend((point, route_group_index) for point in route) + + min_row = min(point.row for point, _group in ordered) + max_col = max(point.col for point, _group in ordered) + return [ + PanelLedPoint( + generated_id=index, + source_id=point.source_id, + input_index=point.uid, + strip=(point.line_id - 1) if point.line_id is not None else None, + led_id=point.line_index, + x_mm=point.x * scale, + y_mm=point.y * scale, + row=point.row, + col=point.col, + matrix_row=max_col - point.col, + matrix_col=point.row - min_row, + route_group=route_group_index, + ) + for index, (point, route_group_index) in enumerate(ordered) + ] + + +def build_cells(points: Iterable[PanelLedPoint]) -> dict[tuple[int, int], list[PanelLedPoint]]: + cells: dict[tuple[int, int], list[PanelLedPoint]] = defaultdict(list) + for point in points: + cells[(point.matrix_row, point.matrix_col)].append(point) + for cell_points in cells.values(): + cell_points.sort(key=lambda point: point.generated_id) + return dict(cells) + + +def get_dimensions(points: Iterable[PanelLedPoint]) -> tuple[int, int, int, int]: + points = list(points) + vm_rows = max(point.matrix_row for point in points) + 1 + vm_cols = max(point.matrix_col for point in points) + 1 + grid_rows = max(point.row for point in points) - min(point.row for point in points) + 1 + grid_cols = max(point.col for point in points) - min(point.col for point in points) + 1 + return vm_rows, vm_cols, grid_rows, grid_cols + + +def get_source_bounds(points: Iterable[PanelLedPoint]) -> tuple[int, int, int, int]: + points = list(points) + return ( + min(point.row for point in points), + max(point.row for point in points), + min(point.col for point in points), + max(point.col for point in points), + ) + + +def format_int_row(values: Iterable[int]) -> str: + return "{ " + ", ".join(str(value) for value in values) + " }" + + +def write_vm_map_header( + path: Path, + all_points: list[PanelLedPoint], + mapping_points: list[PanelLedPoint], + cells: dict[tuple[int, int], list[PanelLedPoint]], + block_count: int, +) -> None: + vm_rows, vm_cols, grid_rows, grid_cols = get_dimensions(all_points) + min_row, max_row, min_col, max_col = get_source_bounds(all_points) + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + with path.open("w", encoding="utf-8", newline="\n") as file: + file.write("#pragma once\n") + file.write("// Auto-generated by LED Mapper GUI\n") + file.write(f"// Date: {timestamp}\n") + file.write("// Virtual matrix mapping plus physical LED grid-cell metadata.\n\n") + file.write("#include \n") + file.write("#include \n\n") + file.write(f"#define VM_ROWS {vm_rows}\n") + file.write(f"#define VM_COLS {vm_cols}\n") + file.write("#define VM_CELLS (VM_ROWS * VM_COLS)\n") + file.write(f"#define VM_MAX_IDS_PER_CELL {MAX_LEDS_PER_CELL}\n") + file.write(f"#define VM_NUM_STRIPS {NUM_STRIPS}\n") + file.write(f"#define LED_COUNT {len(mapping_points)}\n") + file.write(f"#define SOURCE_POINT_COUNT {len(all_points)}\n") + file.write(f"#define GRID_ROWS {grid_rows}\n") + file.write(f"#define GRID_COLS {grid_cols}\n") + file.write(f"#define GRID_ROW_MIN {min_row}\n") + 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") + file.write(f"#define LED_BLOCK_COUNT {block_count}\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") + file.write("#define VM_ROW_FROM_GRID_COL(col) (GRID_COL_MAX - (col))\n") + file.write("#define VM_COL_FROM_GRID_ROW(row) ((row) - GRID_ROW_MIN)\n") + file.write("#define VM_GRID_ROW_FROM_VM_COL(col) ((col) + GRID_ROW_MIN)\n") + file.write("#define VM_GRID_COL_FROM_VM_ROW(row) (GRID_COL_MAX - (row))\n\n") + + file.write("struct LEDMap {\n") + file.write(" uint8_t strip;\n") + file.write(" uint16_t led_id;\n") + file.write("};\n\n") + file.write("struct GridCellRef {\n") + file.write(" uint8_t row;\n") + file.write(" uint8_t col;\n") + file.write("};\n\n") + + file.write("// Number of LEDs in each virtual cell VM[row][col].\n") + file.write("const uint8_t PROGMEM VM_LED_COUNT[VM_ROWS][VM_COLS] = {\n") + for row in range(vm_rows): + counts = [len(cells.get((row, col), [])) for col in range(vm_cols)] + suffix = "," if row < vm_rows - 1 else "" + file.write(f" {format_int_row(counts)}{suffix}\n") + file.write("};\n\n") + + file.write("// LED mappings for all virtual cells. Empty slots use {0xFF, 0xFFFF}.\n") + file.write("const LEDMap PROGMEM VM_MAPPINGS[VM_ROWS][VM_COLS][3] = {\n") + for row in range(vm_rows): + file.write(" {\n") + for col in range(vm_cols): + cell_points = cells.get((row, col), []) + entries = [] + for slot in range(MAX_LEDS_PER_CELL): + if slot < len(cell_points): + point = cell_points[slot] + entries.append(f"{{{point.strip}, {point.led_id}}}") + else: + entries.append(f"{{{EMPTY_STRIP}, {EMPTY_LED}}}") + suffix = "," if col < vm_cols - 1 else "" + file.write(f" {{ {', '.join(entries)} }}{suffix}\n") + suffix = "," if row < vm_rows - 1 else "" + file.write(f" }}{suffix}\n") + file.write("};\n\n") + + file.write("// Original 1-based grid cell for each source point id.\n") + file.write("const GridCellRef PROGMEM LED_GRID_CELL_1BASED[SOURCE_POINT_COUNT] = {\n") + for index, point in enumerate(all_points): + suffix = "," if index < len(all_points) - 1 else "" + file.write(f" {{{point.row}, {point.col}}}{suffix} // id {point.generated_id}\n") + file.write("};\n\n") + + file.write("// Zero-based virtual matrix cell for each source point id.\n") + file.write("const GridCellRef PROGMEM LED_GRID_CELL_ZERO_BASED[SOURCE_POINT_COUNT] = {\n") + for index, point in enumerate(all_points): + suffix = "," if index < len(all_points) - 1 else "" + file.write(f" {{{point.matrix_row}, {point.matrix_col}}}{suffix} // id {point.generated_id}\n") + file.write("};\n\n") + + file.write("// Source ids from the input JSON, indexed by source point id.\n") + file.write("const uint16_t PROGMEM LED_SOURCE_ID[SOURCE_POINT_COUNT] = {\n") + for start in range(0, len(all_points), 16): + values = [point.source_id for point in all_points[start : start + 16]] + suffix = "," if start + 16 < len(all_points) else "" + file.write(f" {', '.join(str(value) for value in values)}{suffix}\n") + file.write("};\n\n") + + +def write_legacy_ino( + path: Path, + points: list[PanelLedPoint], + cells: dict[tuple[int, int], list[PanelLedPoint]], +) -> None: + vm_rows, vm_cols, _grid_rows, _grid_cols = get_dimensions(points) + with path.open("w", encoding="utf-8", newline="\n") as file: + file.write("// ============================================================================\n") + file.write("// ARCHIVE COPY - VIRTUAL MATRIX GENERATED BY LED Mapper GUI\n") + file.write("// Saved as .ino.txt so Arduino IDE will not compile it automatically.\n") + file.write("// ============================================================================\n\n") + file.write("#include \n\n") + file.write("#ifndef ARDUINO\n#include \n#endif\n\n") + file.write("#include \n") + file.write("#ifndef NUM_STRIPS\n") + file.write(f"#define NUM_STRIPS {NUM_STRIPS}\n") + file.write("#endif\n") + file.write("extern Adafruit_NeoPixel hwStrips[NUM_STRIPS];\n\n") + 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) + 1}\n") + file.write("#endif\n") + file.write("#ifndef VM_MAX_IDS_PER_CELL\n") + file.write(f"#define VM_MAX_IDS_PER_CELL {MAX_LEDS_PER_CELL}\n") + file.write("#endif\n") + file.write("#ifndef MATRIX_ROWS\n") + file.write(f"#define MATRIX_ROWS {vm_rows}\n") + file.write("#endif\n") + file.write("#ifndef MATRIX_COLS\n") + file.write(f"#define MATRIX_COLS {vm_cols}\n") + file.write("#endif\n\n") + file.write("struct VMPhys { uint16_t idx; uint8_t strip; };\n\n") + file.write("static uint8_t vmCount[MATRIX_ROWS][MATRIX_COLS];\n") + file.write("static VMPhys vmCell[MATRIX_ROWS][MATRIX_COLS][VM_MAX_IDS_PER_CELL];\n") + file.write("static int16_t vmRow[NUM_STRIPS][VM_MAX_PHYS_LEDS];\n") + file.write("static int16_t vmCol[NUM_STRIPS][VM_MAX_PHYS_LEDS];\n") + file.write("static uint8_t vmUsed[NUM_STRIPS][VM_MAX_PHYS_LEDS];\n") + file.write("static uint16_t vmMaxId[NUM_STRIPS];\n\n") + file.write("void clearVirtualMatrix();\n") + file.write("void setLEDs(uint16_t row, uint16_t col, uint8_t strip1based, std::initializer_list ids);\n") + file.write("void vmSetByPhys(uint8_t strip, uint16_t led, uint32_t color);\n") + file.write("void initVirtualMatrix();\n\n") + file.write("void clearVirtualMatrix() {\n") + file.write(" for (int r = 0; r < MATRIX_ROWS; ++r)\n") + file.write(" for (int c = 0; c < MATRIX_COLS; ++c) vmCount[r][c] = 0;\n") + file.write(" for (int s = 0; s < NUM_STRIPS; ++s) {\n") + file.write(" vmMaxId[s] = 0;\n") + file.write(" for (int i = 0; i < VM_MAX_PHYS_LEDS; ++i) {\n") + file.write(" vmRow[s][i] = -1;\n") + file.write(" vmCol[s][i] = -1;\n") + file.write(" vmUsed[s][i] = 0;\n") + file.write(" }\n") + file.write(" }\n") + file.write("}\n\n") + file.write("void setLEDs(uint16_t row, uint16_t col, uint8_t strip1based, std::initializer_list ids) {\n") + file.write(" if (row >= MATRIX_ROWS || col >= MATRIX_COLS) return;\n") + file.write(" uint8_t strip = (strip1based == 0) ? 0 : (uint8_t)(strip1based - 1);\n") + file.write(" if (strip >= NUM_STRIPS) return;\n") + file.write(" for (uint16_t id : ids) {\n") + file.write(" if (id >= VM_MAX_PHYS_LEDS) continue;\n") + file.write(" uint8_t &count = vmCount[row][col];\n") + file.write(" if (count < VM_MAX_IDS_PER_CELL) {\n") + file.write(" vmCell[row][col][count].idx = id;\n") + file.write(" vmCell[row][col][count].strip = strip;\n") + file.write(" ++count;\n") + file.write(" }\n") + file.write(" vmRow[strip][id] = (int16_t)row;\n") + file.write(" vmCol[strip][id] = (int16_t)col;\n") + file.write(" vmUsed[strip][id] = 1;\n") + file.write(" if (id > vmMaxId[strip]) vmMaxId[strip] = id;\n") + file.write(" }\n") + file.write("}\n\n") + file.write("void vmSetByPhys(uint8_t strip, uint16_t led, uint32_t color) {\n") + file.write(" if (strip >= NUM_STRIPS || led >= VM_MAX_PHYS_LEDS) return;\n") + file.write(" int16_t row = vmRow[strip][led];\n") + file.write(" int16_t col = vmCol[strip][led];\n") + file.write(" if (row < 0 || col < 0) {\n") + file.write(" if (led < hwStrips[strip].numPixels()) hwStrips[strip].setPixelColor(led, color);\n") + file.write(" return;\n") + file.write(" }\n") + file.write(" uint8_t count = vmCount[row][col];\n") + file.write(" for (uint8_t i = 0; i < count; ++i) {\n") + file.write(" const VMPhys &phys = vmCell[row][col][i];\n") + file.write(" if (phys.strip < NUM_STRIPS && phys.idx < hwStrips[phys.strip].numPixels()) {\n") + file.write(" hwStrips[phys.strip].setPixelColor(phys.idx, color);\n") + file.write(" }\n") + file.write(" }\n") + file.write("}\n\n") + file.write("void initVirtualMatrix() {\n") + 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: + payload = { + "source": str(document.path) if document.path is not None else "LED Mapper GUI", + "unit": OUTPUT_UNIT, + "cell_size_mm": CELL_SIZE_MM, + "leds": [ + { + "id": point.generated_id, + "source_id": point.source_id, + "input_index": point.input_index, + "strip": point.strip, + "led_id": point.led_id, + "x": round(point.x_mm, 6), + "y": round(point.y_mm, 6), + "row": point.row, + "col": point.col, + "matrix_row": point.matrix_row, + "matrix_col": point.matrix_col, + } + for point in points + ], + } + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def collect_validation(points: list[PanelLedPoint]) -> list[str]: + lines = [] + distance_violations = [] + cell_violations = [] + for current, next_point in zip(points, points[1:]): + 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: + 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)) + + duplicate_ids = len({point.generated_id for point in points}) != len(points) + max_per_cell = max(len(cell_points) for cell_points in build_cells(points).values()) + 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 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}") + if distance_violations: + lines.append("") + lines.append("First distance violations:") + for current, next_point, gap in distance_violations[:20]: + lines.append(f" {current.generated_id}->{next_point.generated_id}: {gap:.2f} mm, cells ({current.row},{current.col})->({next_point.row},{next_point.col})") + if cell_violations: + lines.append("") + lines.append("First cell-neighbor violations:") + for current, next_point in cell_violations[:20]: + lines.append(f" {current.generated_id}->{next_point.generated_id}: cells ({current.row},{current.col})->({next_point.row},{next_point.col})") + return lines + + +def write_validation_report(path: Path, points: list[PanelLedPoint]) -> None: + lines = [ + "Panel mapping validation", + "========================", + *collect_validation(points), + "", + ] + path.write_text("\n".join(lines), encoding="utf-8") + + +def write_readme(path: Path) -> None: + path.write_text( + "\n".join( + [ + "# Panel Mapping Build", + "", + "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.", + "", + ] + ), + encoding="utf-8", + ) + + +def _coordinate_scale_to_mm(document: MappingDocument) -> float: + unit = _input_unit_for_document(document) + if unit == "m": + return 1000.0 + if unit == "cm": + return 10.0 + if unit == "mm": + return 1.0 + return coordinate_display_scale(document.points) + + +def _input_unit_for_document(document: MappingDocument) -> str: + normalized = str(document.coordinate_unit).lower() + if normalized in {"m", "cm", "mm"}: + return normalized + if hasattr(document, "_coordinate_unit_for_save"): + return document._coordinate_unit_for_save() + return "m" if coordinate_display_scale(document.points) == 1000.0 else "mm" diff --git a/qa_smoke.py b/qa_smoke.py new file mode 100644 index 0000000..8d043e6 --- /dev/null +++ b/qa_smoke.py @@ -0,0 +1,1740 @@ +from __future__ import annotations + +import json +import importlib +import sys +import tempfile +import time +from pathlib import Path +from typing import Callable + +if __package__ is None or __package__ == "": + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from app.models import ( + LedPoint, + MappingDocument, + build_route_adjacency, + is_valid_route_neighbor, + min_neighbor_distance_for_points, + order_points_like_grid_svg, +) +from app.panel_mapping_generator import generate_panel_mapping_from_document +from app.unfolding import MAX_LINES +try: + from app.canvas import LedCanvas + from app.icons import LUCIDE_PATHS + from app.main_window import MainWindow, SIDEBAR_COLLAPSED_WIDTH, SIDEBAR_MIN_WIDTH + from PySide6.QtCore import Qt + from PySide6.QtGui import QColor, QKeySequence + from PySide6.QtWidgets import QApplication, QGraphicsItem, QToolBar +except ImportError: + LedCanvas = None + LUCIDE_PATHS = {} + MainWindow = None + SIDEBAR_COLLAPSED_WIDTH = 34 + SIDEBAR_MIN_WIDTH = 180 + Qt = None + QColor = None + QKeySequence = None + QApplication = None + QGraphicsItem = None + QToolBar = None + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent + + +def main() -> None: + tests: list[Callable[[], None]] = [ + test_load_supported_json_files, + test_line_limit_rejects_fifth_line, + test_line_indexes_are_scoped_per_line, + test_remove_selected_leds_blocks_and_lines, + test_remove_block_preserves_line_numbers, + test_save_load_unfolding_roundtrip, + test_reassigning_leds_removes_old_memberships, + test_reassigning_leds_reindexes_affected_lines, + test_line_numbers_do_not_renumber_when_points_are_cleared, + test_auto_blocks_reindex_all_affected_lines, + test_legacy_rebuild_groups_by_line_and_block, + test_legacy_line_without_block_id_is_preserved, + test_points_without_grid_fields_load, + test_loaded_duplicate_memberships_are_deduplicated, + test_empty_blocks_are_preserved, + test_block_names_keep_increasing_in_line, + test_block_names_do_not_renumber_when_line_is_removed, + test_reindex_preserves_block_point_order, + test_add_block_preserves_start_point, + test_append_points_to_block_extends_existing_block, + test_clockwise_block_from_seed_starts_at_selected_point, + test_clockwise_block_from_seed_reverses_counterclockwise_corner, + test_clockwise_block_from_seed_uses_direction_priority, + test_far_neighbor_is_not_valid_route_neighbor, + test_diagonal_grid_neighbor_is_valid_route_neighbor, + test_rebuild_line_blocks_splits_far_segments, + test_saved_coordinate_unit_is_reused, + test_uid_point_id_mode_does_not_fallback_to_source_ids, + test_panel_mapping_generation_from_document, + test_canvas_uses_grid_svg_coordinates, + test_canvas_rebuild_can_preserve_user_zoom, + test_grid_toggle_adds_grid_items, + test_canvas_connects_new_block_to_previous_non_empty_block, + test_canvas_rebuild_drops_deleted_selection_items, + test_canvas_refresh_does_not_accumulate_connection_items, + test_main_window_has_classic_menu_bar, + test_classic_menu_contains_expected_actions, + test_side_panel_hides_removed_tools, + test_undo_redo_roundtrip_for_line_creation, + test_main_window_actions_use_lucide_icons, + test_main_window_action_shortcuts_are_assigned, + test_lucide_icon_names_are_declared, + test_table_hides_empty_line_index_columns, + test_sidebar_collapses_and_expands, + test_sidebar_action_toggle_updates_state_and_constraints, + test_sidebar_collapsed_tab_click_and_drag_expand, + test_canvas_select_points_skips_duplicate_selection_emit, + test_canvas_brush_cursor_survives_pan_cursor_reset, + test_canvas_brush_zone_is_visible_and_collects_points, + test_canvas_brush_zone_uses_viewport_radius_after_zoom, + test_canvas_brush_preview_highlights_only_actionable_points, + test_brush_can_create_new_block_without_preselected_block, + test_brush_continues_block_from_selected_led, + test_main_window_restores_table_state_after_selection_error, + test_main_window_restores_tree_state_after_refresh_error, + test_package_imports_work_from_project_root, + test_route_adjacency_large_grid_perf_guard, + test_route_prefers_up_left_down_right, + test_route_prefers_coordinate_up_when_grid_index_is_not_adjacent, + test_first_route_step_prefers_up_before_slightly_closer_right, + test_route_does_not_jump_over_intermediate_led, + test_route_does_not_use_grid_indexes_to_skip_nearer_physical_neighbor, + test_route_continues_after_curved_sparse_step, + test_auto_block_continues_past_point_neighboring_start, + test_auto_block_does_not_close_before_meaningful_continuation, + test_parallel_track_middle_edges_are_filtered, + test_route_keeps_close_point_on_smooth_bend, + test_new_block_does_not_inherit_previous_block_trajectory, + test_route_prefers_current_block_trajectory_after_first_step, + test_route_prefers_near_neighbor_before_far_trajectory, + test_route_continues_trajectory_across_sparse_gap, + test_clockwise_route_keeps_straight_track_at_branch, + test_clockwise_route_turns_right_when_straight_is_absent, + test_auto_block_continues_through_left_down_diagonal, + test_too_close_neighbor_has_lowest_priority, + test_meter_like_coordinates_use_34_mm_threshold, + ] + for test in tests: + test() + print(f"ok - {test.__name__}") + print(f"All {len(tests)} QA smoke checks passed.") + + +def test_load_supported_json_files() -> None: + fixtures = [ + PROJECT_ROOT / "circles.json", + PROJECT_ROOT / "circles_new.json", + PROJECT_ROOT / "panel_mapping_build" / "ordered_circles.json", + ] + for path in fixtures: + doc = MappingDocument() + doc.load(path) + assert doc.points, f"{path} did not load any points" + assert doc.collection_name in {"circles", "leds"} + assert len(doc.lines) <= MAX_LINES + + +def test_line_limit_rejects_fifth_line() -> None: + with temp_document_path() as path: + doc = MappingDocument() + doc.load(path) + for _ in range(MAX_LINES): + doc.add_line() + + assert len(doc.lines) == MAX_LINES + try: + doc.add_line() + except ValueError as exc: + assert str(MAX_LINES) in str(exc) + else: + raise AssertionError("Adding a fifth line should fail.") + + +def test_line_indexes_are_scoped_per_line() -> None: + with temp_document_path() as path: + doc = MappingDocument() + doc.load(path) + + first_line = doc.add_line() + doc.add_block(first_line.id, [0, 1]) + second_line = doc.add_line() + doc.add_block(second_line.id, [4, 5, 6]) + + indexes_by_line = { + line.id: sorted(point.line_index for point in doc.points if point.line_id == line.id) + for line in doc.lines + if line.blocks + } + assert sorted(indexes_by_line.values()) == [[0, 1], [0, 1, 2]] + + +def test_remove_selected_leds_blocks_and_lines() -> 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, 2]) + second_block = doc.add_block(first_line.id, [3, 4]) + second_line = doc.add_line() + doc.add_block(second_line.id, [8, 9]) + + removed_leds = doc.clear_point_assignments([0, 8]) + assert removed_leds == 2 + assert doc.points[0].line_id is None + assert doc.points[8].line_id is None + assert all(0 not in block.point_ids and 8 not in block.point_ids for line in doc.lines for block in line.blocks) + + removed_block_leds = doc.remove_block(first_line.id, first_block.id) + assert removed_block_leds == 2 + assert doc.points[1].line_id is None + assert doc.points[2].line_id is None + assert doc.points[3].line_id == first_line.id + assert doc.points[4].line_id == first_line.id + + removed_line_leds = doc.remove_line(first_line.id) + assert removed_line_leds == len(second_block.point_ids) + assert doc.points[3].line_id is None + assert doc.points[4].line_id is None + + +def test_remove_block_preserves_line_numbers() -> None: + with temp_document_path() as path: + doc = MappingDocument() + doc.load(path) + first_line = doc.add_line() + second_line = doc.add_line() + doc.add_block(first_line.id, [0, 1]) + second_block = doc.add_block(second_line.id, [4, 5]) + + removed = doc.remove_block(second_line.id, second_block.id) + + assert removed == 2 + assert [line.id for line in doc.lines] == [1, 2] + assert [line.name for line in doc.lines] == ["Линия 1", "Линия 2"] + assert doc.get_line(second_line.id).blocks == [] + + +def test_save_load_unfolding_roundtrip() -> None: + with temp_document_path() as path: + save_path = path.with_name("saved_mapping.json") + doc = MappingDocument() + doc.load(path) + + first_line = doc.add_line() + doc.add_block(first_line.id, [0, 1]) + second_line = doc.add_line() + doc.add_block(second_line.id, [4, 5, 6]) + doc.unfolding.block_size = 2 + doc.save(save_path) + + payload = json.loads(save_path.read_text(encoding="utf-8")) + assert payload["unfolding"]["max_lines"] == MAX_LINES + assert payload["unfolding"]["led_index_scope"] == "line" + assert len(payload["unfolding"]["block_counts"]) == 2 + + loaded = MappingDocument() + loaded.load(save_path) + assert len(loaded.lines) == 2 + assert loaded.unfolding.block_size == 2 + assert loaded.describe_unfolding().led_index_scope == "line" + assert sorted( + sorted(point.line_index for point in loaded.points if point.line_id == line.id) + for line in loaded.lines + ) == [[0, 1], [0, 1, 2]] + + +def test_reassigning_leds_removes_old_memberships() -> 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, 2]) + second_line = doc.add_line() + second_block = doc.add_block(second_line.id, [1, 3]) + + assert 1 not in first_block.point_ids + assert 1 in second_block.point_ids + memberships = [ + (line.id, block.id) + for line in doc.lines + for block in line.blocks + if 1 in block.point_ids + ] + assert memberships == [(second_line.id, second_block.id)] + + +def test_reassigning_leds_reindexes_affected_lines() -> None: + doc = MappingDocument() + doc.load(PROJECT_ROOT / "circles.json") + for _ in range(MAX_LINES): + doc.add_line() + + doc.add_block(1, list(range(0, 20))) + doc.add_block(2, list(range(10, 30))) + doc.add_block(3, list(range(25, 40))) + + for point_id in range(40): + memberships = sum(point_id in block.point_ids for line in doc.lines for block in line.blocks) + assert memberships <= 1, (point_id, memberships) + + for line in doc.lines: + ids = [point_id for block in line.blocks for point_id in block.point_ids] + indexes = [doc.points[point_id].line_index for point_id in ids] + assert indexes == list(range(len(ids))), (line.id, indexes) + + +def test_line_numbers_do_not_renumber_when_points_are_cleared() -> None: + with temp_document_path() as path: + doc = MappingDocument() + doc.load(path) + first_line = doc.add_line() + second_line = doc.add_line() + doc.add_block(first_line.id, [0, 1]) + doc.add_block(second_line.id, [4, 5]) + + doc.clear_point_assignments([0, 1]) + + assert [line.id for line in doc.lines] == [1, 2] + assert [line.name for line in doc.lines] == ["Линия 1", "Линия 2"] + + +def test_auto_blocks_reindex_all_affected_lines() -> None: + with temp_document_path() as path: + doc = MappingDocument() + doc.load(path) + first_line = doc.add_line() + second_line = doc.add_line() + doc.add_block(first_line.id, [0, 1, 2]) + + doc.auto_blocks_from_selection(second_line.id, [1, 4], [[1, 4]]) + + assert [doc.points[point_id].line_index for point_id in [0, 2]] == [0, 1] + assert [doc.points[point_id].line_index for point_id in [1, 4]] == [0, 1] + + +def test_legacy_rebuild_groups_by_line_and_block() -> None: + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "legacy_blocks.json" + points = fixture_points() + for point in points[:2]: + point["line_id"] = 1 + point["block_id"] = 1 + for point in points[4:6]: + point["line_id"] = 2 + point["block_id"] = 1 + path.write_text(json.dumps({"circles": points}), encoding="utf-8") + + doc = MappingDocument() + doc.load(path) + assert len(doc.lines) == 2 + assert [len(line.blocks) for line in doc.lines] == [1, 1] + assert sorted(doc.points[point_id].line_id for point_id in doc.lines[0].blocks[0].point_ids) == [1, 1] + assert sorted(doc.points[point_id].line_id for point_id in doc.lines[1].blocks[0].point_ids) == [2, 2] + + +def test_legacy_line_without_block_id_is_preserved() -> None: + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "legacy_line_only.json" + points = fixture_points() + for index, point in enumerate(points[:3]): + point["line_id"] = 1 + point["line_index"] = index + path.write_text(json.dumps({"circles": points}), encoding="utf-8") + + doc = MappingDocument() + doc.load(path) + assert len(doc.lines) == 1 + assert len(doc.lines[0].blocks) == 1 + ids = [point_id for block in doc.lines[0].blocks for point_id in block.point_ids] + assert len(ids) == 3 + assert [doc.points[point_id].line_index for point_id in ids] == [0, 1, 2] + + +def test_points_without_grid_fields_load() -> None: + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "xy_only.json" + path.write_text( + json.dumps({"leds": [{"id": 10, "x": 1.0, "y": 2.0}, {"id": 11, "x": 3.0, "y": 4.0}]}), + encoding="utf-8", + ) + doc = MappingDocument() + doc.load(path) + assert [(point.row, point.col) for point in doc.points] == [(0, 0), (2, 0)] + assert len(doc.auto_block_preview([0, 1])) == 2 + + +def test_loaded_duplicate_memberships_are_deduplicated() -> None: + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "dupes.json" + points = fixture_points() + payload = { + "circles": points, + "lines": [ + {"id": 1, "blocks": [{"id": 1, "point_ids": [0, 1, 2]}]}, + {"id": 2, "blocks": [{"id": 2, "point_ids": [1, 3]}]}, + ], + } + path.write_text(json.dumps(payload), encoding="utf-8") + + doc = MappingDocument() + doc.load(path) + memberships = sum(1 in block.point_ids for line in doc.lines for block in line.blocks) + assert memberships == 1 + + +def test_empty_blocks_are_preserved() -> None: + with temp_document_path() as path: + save_path = path.with_name("empty_block_saved.json") + doc = MappingDocument() + doc.load(path) + line = doc.add_line() + empty_block = doc.add_block(line.id, []) + filled_block = doc.add_block(line.id, [0]) + + assert empty_block.point_ids == [] + assert len(line.blocks) == 2 + + doc.clear_point_assignments([0]) + assert len(line.blocks) == 2 + assert filled_block.point_ids == [] + + doc.save(save_path) + loaded = MappingDocument() + loaded.load(save_path) + assert len(loaded.lines[0].blocks) == 2 + assert all(block.point_ids == [] for block in loaded.lines[0].blocks) + + +def test_block_names_keep_increasing_in_line() -> None: + with temp_document_path() as path: + doc = MappingDocument() + doc.load(path) + line = doc.add_line() + + first = doc.add_block(line.id, []) + second = doc.add_block(line.id, []) + third = doc.add_block(line.id, []) + assert [block.name for block in doc.get_line(line.id).blocks] == ["Блок 1", "Блок 2", "Блок 3"] + + doc.reindex_all() + assert [block.name for block in doc.get_line(line.id).blocks] == ["Блок 1", "Блок 2", "Блок 3"] + + doc.remove_block(line.id, second.id) + fourth = doc.add_block(line.id, []) + + assert fourth.name == "Блок 4" + assert [block.name for block in doc.get_line(line.id).blocks] == ["Блок 1", "Блок 3", "Блок 4"] + + +def test_block_names_do_not_renumber_when_line_is_removed() -> None: + with temp_document_path() as path: + doc = MappingDocument() + doc.load(path) + first_line = doc.add_line() + second_line = doc.add_line() + first = doc.add_block(first_line.id, []) + second = doc.add_block(first_line.id, []) + third = doc.add_block(first_line.id, []) + doc.add_block(second_line.id, [0]) + + doc.remove_block(first_line.id, second.id) + assert [block.name for block in doc.get_line(first_line.id).blocks] == ["Блок 1", "Блок 3"] + + doc.remove_line(second_line.id) + assert [block.name for block in doc.get_line(first_line.id).blocks] == ["Блок 1", "Блок 3"] + assert [first.name, third.name] == ["Блок 1", "Блок 3"] + + +def test_reindex_preserves_block_point_order() -> None: + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "manual_order.json" + points = [ + {"id": 0, "x": 0, "y": 34, "row": 0, "col": 0}, + {"id": 1, "x": 34, "y": 34, "row": 0, "col": 1}, + {"id": 2, "x": 34, "y": 0, "row": 1, "col": 1}, + {"id": 3, "x": 0, "y": 0, "row": 1, "col": 0}, + ] + path.write_text(json.dumps({"circles": points}), encoding="utf-8") + + doc = MappingDocument() + doc.load(path) + line = doc.add_line() + block = doc.add_block(line.id, [0, 3, 2, 1]) + block.point_ids = [0, 3, 2, 1] + doc.reindex_all() + + assert block.point_ids == [0, 3, 2, 1] + assert [doc.points[point_id].line_index for point_id in block.point_ids] == [0, 1, 2, 3] + + +def test_add_block_preserves_start_point() -> None: + with temp_document_path() as path: + doc = MappingDocument() + doc.load(path) + line = doc.add_line() + block = doc.add_block(line.id, [3, 1, 2]) + assert block.point_ids[0] == 3 + + +def test_append_points_to_block_extends_existing_block() -> None: + with temp_document_path() as path: + doc = MappingDocument() + doc.load(path) + line = doc.add_line() + first_block = doc.add_block(line.id, [0, 1]) + second_block = doc.add_block(line.id, [2, 3]) + + added = doc.append_points_to_block(line.id, first_block.id, [1, 2, 4]) + + assert added == 2 + assert first_block.point_ids == [0, 1, 2, 4] + assert 2 not in second_block.point_ids + assert [doc.points[point_id].block_index for point_id in first_block.point_ids] == [0, 1, 2, 3] + assert [doc.points[point_id].line_index for point_id in first_block.point_ids] == [0, 1, 2, 3] + + +def test_clockwise_block_from_seed_starts_at_selected_point() -> None: + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "ring.json" + points = [ + {"id": 0, "x": 0, "y": 34, "row": 0, "col": 1}, + {"id": 1, "x": 34, "y": 34, "row": 0, "col": 2}, + {"id": 2, "x": 34, "y": 0, "row": 1, "col": 2}, + {"id": 3, "x": 0, "y": 0, "row": 1, "col": 1}, + ] + path.write_text(json.dumps({"circles": points}), encoding="utf-8") + + doc = MappingDocument() + doc.load(path) + line = doc.add_line() + block = doc.add_clockwise_block_from_seed(line.id, 2) + save_path = path.with_name("ring_saved.json") + doc.save(save_path) + loaded = MappingDocument() + loaded.load(save_path) + loaded_block = loaded.lines[0].blocks[0] + + assert block.point_ids[0] == 2 + assert doc.points[2].line_index == 0 + assert [doc.points[point_id].block_index for point_id in block.point_ids] == list(range(4)) + assert loaded_block.point_ids[0] == 2 + assert loaded.points[2].line_index == 0 + + +def test_clockwise_block_from_seed_reverses_counterclockwise_corner() -> None: + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "counterclockwise_loop.json" + points = [ + {"id": 0, "x": 0.0, "y": 0.0, "row": 0, "col": 0}, + {"id": 1, "x": 34.0, "y": 0.0, "row": 0, "col": 1}, + {"id": 2, "x": 68.0, "y": 0.0, "row": 0, "col": 2}, + {"id": 3, "x": 68.0, "y": 34.0, "row": 1, "col": 2}, + {"id": 4, "x": 68.0, "y": 68.0, "row": 2, "col": 2}, + {"id": 5, "x": 34.0, "y": 68.0, "row": 2, "col": 1}, + {"id": 6, "x": 0.0, "y": 68.0, "row": 2, "col": 0}, + {"id": 7, "x": 0.0, "y": 34.0, "row": 1, "col": 0}, + ] + path.write_text(json.dumps({"circles": points}), encoding="utf-8") + + doc = MappingDocument() + doc.load(path) + line = doc.add_line() + block = doc.add_clockwise_block_from_seed(line.id, 0) + + assert block.point_ids == [0, 7, 6, 5, 4, 3, 2, 1] + + +def test_clockwise_block_from_seed_uses_direction_priority() -> None: + doc = MappingDocument() + doc.load(PROJECT_ROOT / "circles_new.json") + doc.clear_all_assignments() + line = doc.add_line() + block = doc.add_clockwise_block_from_seed(line.id, 492) + first_sources = [doc.points[point_id].source_id for point_id in block.point_ids[:12]] + assert first_sources == [493, 662, 787, 753, 560, 346, 347, 683, 684, 459, 460, 811] + threshold = min_neighbor_distance_for_points(doc.points) + assert is_valid_route_neighbor( + doc.points[block.point_ids[-1]], + doc.points[block.point_ids[0]], + threshold, + ) + assert doc.points[492].line_index == 0 + + +def test_far_neighbor_is_not_valid_route_neighbor() -> None: + current = LedPoint(0, 0, 0.0, 0.0, 0, 0) + far = LedPoint(1, 1, 100.0, 0.0, 1, 0) + near = LedPoint(2, 2, 34.0, 0.0, 1, 0) + tolerated = LedPoint(4, 4, 36.0, 0.0, 1, 0) + over_limit = LedPoint(3, 3, 34.1, 0.0, 1, 0) + assert is_valid_route_neighbor(current, near, 34.0) + assert is_valid_route_neighbor(current, tolerated, 34.0) + assert not is_valid_route_neighbor(current, far, 34.0) + assert is_valid_route_neighbor(current, over_limit, 34.0) + + +def test_diagonal_grid_neighbor_is_valid_route_neighbor() -> None: + current = LedPoint(0, 0, 34.0, 34.0, 0, 1) + diagonal = LedPoint(1, 1, 0.0, 0.0, 1, 0) + far_diagonal = LedPoint(2, 2, -10.0, -10.0, 1, 0) + + assert is_valid_route_neighbor(current, diagonal, 34.0) + assert not is_valid_route_neighbor(current, far_diagonal, 34.0) + + +def test_rebuild_line_blocks_splits_far_segments() -> None: + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "far_segments.json" + points = [ + {"id": 0, "x": 0, "y": 0, "row": 0, "col": 0}, + {"id": 1, "x": 34, "y": 0, "row": 1, "col": 0}, + {"id": 2, "x": 200, "y": 0, "row": 2, "col": 0}, + ] + path.write_text(json.dumps({"circles": points}), encoding="utf-8") + doc = MappingDocument() + doc.load(path) + line = doc.add_line() + doc.add_block(line.id, [0, 1, 2]) + block_count = doc.rebuild_line_blocks_by_route(line.id) + assert block_count == 2 + assert sorted(block.point_ids for block in doc.get_line(line.id).blocks) == [[0, 1], [2]] + + +def test_saved_coordinate_unit_is_reused() -> None: + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "meters.json" + save_path = Path(temp_dir) / "saved.json" + points = [ + {"id": 0, "x": 0.0, "y": 0.0, "row": 0, "col": 0}, + {"id": 1, "x": 0.034, "y": 0.0, "row": 1, "col": 0}, + ] + path.write_text(json.dumps({"circles": points, "coordinate_unit": "m"}), encoding="utf-8") + doc = MappingDocument() + doc.load(path) + doc.save(save_path) + payload = json.loads(save_path.read_text(encoding="utf-8")) + assert payload["coordinate_unit"] == "m" + loaded = MappingDocument() + loaded.load(save_path) + assert loaded.coordinate_unit == "m" + assert min_neighbor_distance_for_points(loaded.points) == 0.034 + + +def test_uid_point_id_mode_does_not_fallback_to_source_ids() -> None: + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "uid_mode.json" + points = fixture_points() + payload = { + "circles": points, + "lines": [ + { + "id": 1, + "point_id_mode": "uid", + "blocks": [{"id": 1, "point_ids": [0, 9999, 1]}], + } + ], + } + path.write_text(json.dumps(payload), encoding="utf-8") + doc = MappingDocument() + doc.load(path) + assert doc.lines[0].blocks[0].point_ids == [0, 1] + + +def test_panel_mapping_generation_from_document() -> None: + 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)))) + result = generate_panel_mapping_from_document(doc, Path(temp_dir)) + + assert result.led_count == len(doc.points) + assert result.block_count == 1 + assert result.assigned_count == len(doc.points) + assert result.unassigned_count == 0 + assert result.vm_rows > 0 + assert result.vm_cols > 0 + 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 "{0, 0}" in vm_map + ordered_payload = json.loads(result.generated_files["ordered_json"].read_text(encoding="utf-8")) + assert len(ordered_payload["leds"]) == len(doc.points) + assert ordered_payload["leds"][0]["strip"] == 0 + + +def test_canvas_uses_grid_svg_coordinates() -> 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.set_document(doc) + min_x = min(point.x for point in doc.points) + max_y = max(point.y for point in doc.points) + margin = 40 + for point in doc.points: + item = canvas.items_by_id[point.uid] + assert item.pos().x() == (point.x - min_x) + margin + assert item.pos().y() == (max_y - point.y) + margin + + +def test_canvas_rebuild_can_preserve_user_zoom() -> 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.set_document(doc) + canvas.resetTransform() + canvas.scale(2.5, 2.5) + before = canvas.transform() + + canvas.rebuild(reset_view=False) + app.processEvents() + + after = canvas.transform() + assert after.m11() == before.m11() + assert after.m22() == before.m22() + + +def test_grid_toggle_adds_grid_items() -> 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.set_document(doc) + assert not canvas.grid_items + canvas.set_grid_visible(True) + assert canvas.grid_items + + scene_rect = canvas.sceneRect() + vertical_lines = [item.line() for item in canvas.grid_items if item.line().x1() == item.line().x2()] + horizontal_lines = [item.line() for item in canvas.grid_items if item.line().y1() == item.line().y2()] + assert vertical_lines + assert horizontal_lines + assert all(line.y1() <= scene_rect.top() and line.y2() >= scene_rect.bottom() for line in vertical_lines) + assert all(line.x1() <= scene_rect.left() and line.x2() >= scene_rect.right() for line in horizontal_lines) + assert min(line.x1() for line in vertical_lines) <= scene_rect.left() + assert max(line.x1() for line in vertical_lines) >= scene_rect.right() + assert min(line.y1() for line in horizontal_lines) <= scene_rect.top() + assert max(line.y1() for line in horizontal_lines) >= scene_rect.bottom() + + +def test_canvas_connects_new_block_to_previous_non_empty_block() -> 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) + line = doc.add_line() + doc.add_block(line.id, [0, 1]) + doc.add_block(line.id, []) + doc.add_block(line.id, [2, 3]) + + canvas = LedCanvas() + canvas.set_document(doc) + canvas.refresh_styles() + + expected_start = canvas.items_by_id[1].pos() + expected_end = canvas.items_by_id[2].pos() + bridge_found = any( + path_has_segment(item.path(), expected_start.x(), expected_start.y(), expected_end.x(), expected_end.y()) + for item in canvas.connection_items + ) + assert bridge_found + + +def test_canvas_rebuild_drops_deleted_selection_items() -> 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.set_document(doc) + canvas.select_points([0, 1, 2]) + + canvas.rebuild() + canvas.rebuild() + app.processEvents() + + assert canvas.selected_point_ids() == [] + assert canvas._last_selected_ids == set() + + +def test_canvas_refresh_does_not_accumulate_connection_items() -> None: + if LedCanvas is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + doc = MappingDocument() + doc.load(PROJECT_ROOT / "circles_new.json") + doc.clear_all_assignments() + line = doc.add_line() + doc.add_block(line.id, list(range(120))) + canvas = LedCanvas() + canvas.set_document(doc) + canvas.set_active_line(line.id) + first_count = len(canvas.connection_items) + for _ in range(10): + canvas.refresh_styles() + assert len(canvas.connection_items) == first_count + assert len(canvas.items_by_id) == len(doc.points) + + +def test_main_window_has_classic_menu_bar() -> None: + if MainWindow is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + window = MainWindow() + app.processEvents() + + menu_titles = [action.text() for action in window.menuBar().actions()] + assert menu_titles[:5] == ["Файл", "Правка", "Вид", "Окно", "Инструменты"] + assert QToolBar is None or not window.findChildren(QToolBar) + window.close() + + +def test_classic_menu_contains_expected_actions() -> None: + if MainWindow is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + window = MainWindow() + app.processEvents() + + expected = { + "Файл": ["Открыть", "Сохранить", "Сохранить как", "Сгенерировать скрипт"], + "Правка": ["Отменить", "Повторить", "Удалить фиксацию", "Удалить все", "Очистить выбор"], + "Вид": ["Показать всё", "Сетка 34 мм"], + "Окно": ["Скрыть боковую панель"], + "Инструменты": ["Новая линия", "Сгенерировать скрипт", "Автоблоки", "Кисть"], + } + for top_action in list(window.menuBar().actions()): + menu = top_action.menu() + if menu is not None and top_action.text() in expected: + menu_texts = [action.text() for action in menu.actions() if not action.isSeparator()] + assert menu_texts == expected[top_action.text()], top_action.text() + window.close() + + +def test_side_panel_hides_removed_tools() -> None: + if MainWindow is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + window = MainWindow() + app.processEvents() + + assert set(window.side_buttons) == {"add_line", "auto_blocks"} + assert window.side_buttons["add_line"].text() == "Линия" + assert window.side_buttons["auto_blocks"].text() == "Автоблоки" + window.close() + + +def test_undo_redo_roundtrip_for_line_creation() -> 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) + window.document = doc + window.canvas.set_document(doc) + + assert not window.actions["undo"].isEnabled() + assert not window.actions["redo"].isEnabled() + + window.add_line() + app.processEvents() + + assert len(window.document.lines) == 1 + assert window.actions["undo"].isEnabled() + assert not window.actions["redo"].isEnabled() + + window.undo_last_change() + app.processEvents() + + assert len(window.document.lines) == 0 + assert not window.actions["undo"].isEnabled() + assert window.actions["redo"].isEnabled() + + window.redo_last_change() + app.processEvents() + + assert len(window.document.lines) == 1 + assert window.actions["undo"].isEnabled() + assert not window.actions["redo"].isEnabled() + + window.undo_last_change() + window.add_line() + app.processEvents() + + assert len(window.document.lines) == 1 + assert not window.actions["redo"].isEnabled() + window._dirty = False + window.close() + + +def test_main_window_actions_use_lucide_icons() -> None: + if MainWindow is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + window = MainWindow() + app.processEvents() + + expected_icon_actions = { + "open", + "save", + "save_as", + "generate_script", + "add_line", + "add_block", + "auto_blocks", + "clockwise", + "rebuild_route", + "brush", + "undo", + "redo", + "delete", + "clear_all", + "clear_selection", + "fit_view", + "grid", + "toggle_sidebar", + } + missing = [key for key in expected_icon_actions if window.actions[key].icon().isNull()] + assert not missing + window.close() + + +def test_main_window_action_shortcuts_are_assigned() -> None: + if MainWindow is None or QApplication is None or QKeySequence is None: + return + app = QApplication.instance() or QApplication([]) + window = MainWindow() + app.processEvents() + + expected = { + "generate_script": "Ctrl+G", + "clear_all": "Ctrl+Shift+Del", + "clear_selection": "Esc", + "fit_view": "Ctrl+0", + "grid": "G", + "toggle_sidebar": "F9", + "add_line": "Ctrl+L", + "auto_blocks": "Ctrl+Shift+B", + "brush": "B", + } + for key, shortcut in expected.items(): + assert window.actions[key].shortcut().matches(QKeySequence(shortcut)) == QKeySequence.SequenceMatch.ExactMatch + assert window.actions[key].shortcutContext() == Qt.ApplicationShortcut + window.close() + + +def test_lucide_icon_names_are_declared() -> None: + expected_names = { + "archive-restore", + "chevron-right", + "eraser", + "eye", + "file-plus", + "file-code", + "folder-open", + "grid-2x2", + "layers", + "paintbrush", + "plus", + "redo-2", + "rotate-cw", + "save", + "save-all", + "trash-2", + "undo-2", + } + assert expected_names.issubset(set(LUCIDE_PATHS)) + + +def test_table_hides_empty_line_index_columns() -> 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() + second_line = doc.add_line() + doc.add_block(first_line.id, [0, 1]) + doc.add_block(second_line.id, [4]) + window.document = doc + + window._on_selection_changed([0, 1]) + app.processEvents() + assert [window.table.isColumnHidden(column) for column in range(6, 10)] == [False, True, True, True] + + window._on_selection_changed([0, 4]) + app.processEvents() + assert [window.table.isColumnHidden(column) for column in range(6, 10)] == [False, False, True, True] + + window._on_selection_changed([2, 3]) + app.processEvents() + assert [window.table.isColumnHidden(column) for column in range(6, 10)] == [True, True, True, True] + window.close() + + +def test_sidebar_collapses_and_expands() -> None: + if MainWindow is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + window = MainWindow() + app.processEvents() + + assert window.sidebar_splitter is not None + assert window.sidebar_stack is not None + window.sidebar_splitter.setSizes([SIDEBAR_MIN_WIDTH - 20, 1000]) + window._on_sidebar_splitter_moved(SIDEBAR_MIN_WIDTH - 20, 1) + app.processEvents() + + assert window._sidebar_collapsed + assert window.sidebar_stack.currentIndex() == 1 + assert window.sidebar_splitter.sizes()[0] == SIDEBAR_COLLAPSED_WIDTH + + window.sidebar_splitter.moveSplitter(0, 1) + window._on_sidebar_splitter_moved(0, 1) + app.processEvents() + + assert window._sidebar_collapsed + assert window.sidebar_stack.currentIndex() == 1 + assert window.sidebar_tab is not None + assert not window.sidebar_tab.isHidden() + assert window.sidebar_splitter.sizes()[0] == SIDEBAR_COLLAPSED_WIDTH + + window.sidebar_splitter.setSizes([0, 1000]) + window._on_sidebar_splitter_moved(0, 1) + app.processEvents() + + assert window._sidebar_collapsed + assert window.sidebar_stack.currentIndex() == 1 + assert window.sidebar_splitter.sizes()[0] == SIDEBAR_COLLAPSED_WIDTH + + window.expand_sidebar() + app.processEvents() + + assert not window._sidebar_collapsed + assert window.sidebar_stack.currentIndex() == 0 + assert window.sidebar_splitter.sizes()[0] >= SIDEBAR_MIN_WIDTH + window.close() + + +def test_sidebar_action_toggle_updates_state_and_constraints() -> None: + if MainWindow is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + window = MainWindow() + app.processEvents() + + assert window.sidebar_splitter is not None + assert window.sidebar_stack is not None + window.sidebar_splitter.setSizes([240, 1000]) + window._on_sidebar_splitter_moved(240, 1) + + window.actions["toggle_sidebar"].trigger() + app.processEvents() + + assert window._sidebar_collapsed + assert window.actions["toggle_sidebar"].text() == "Показать боковую панель" + assert window.sidebar_stack.minimumWidth() == SIDEBAR_COLLAPSED_WIDTH + assert window.sidebar_stack.maximumWidth() > SIDEBAR_MIN_WIDTH + + window.actions["toggle_sidebar"].trigger() + app.processEvents() + + assert not window._sidebar_collapsed + assert window.actions["toggle_sidebar"].text() == "Скрыть боковую панель" + assert window.sidebar_stack.minimumWidth() == 0 + assert window.sidebar_stack.maximumWidth() > SIDEBAR_MIN_WIDTH + assert abs(window.sidebar_splitter.sizes()[0] - 240) <= 2 + window.close() + + +def test_sidebar_collapsed_tab_click_and_drag_expand() -> None: + if MainWindow is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + window = MainWindow() + app.processEvents() + + assert window.sidebar_splitter is not None + assert window.sidebar_tab_container is not None + + window.collapse_sidebar() + app.processEvents() + window.sidebar_tab_container._expand_handler() + app.processEvents() + assert not window._sidebar_collapsed + assert window.sidebar_stack is not None + assert window.sidebar_stack.currentIndex() == 0 + + window.collapse_sidebar() + app.processEvents() + intermediate_width = SIDEBAR_MIN_WIDTH - 20 + window.sidebar_splitter.setSizes([intermediate_width, 1000]) + window._on_sidebar_splitter_moved(intermediate_width, 1) + app.processEvents() + + assert window._sidebar_collapsed + assert window.sidebar_stack.currentIndex() == 1 + + window.sidebar_splitter.setSizes([SIDEBAR_MIN_WIDTH + 20, 1000]) + window._on_sidebar_splitter_moved(SIDEBAR_MIN_WIDTH + 20, 1) + app.processEvents() + + assert not window._sidebar_collapsed + assert window.sidebar_stack.currentIndex() == 0 + assert window.sidebar_splitter.sizes()[0] >= SIDEBAR_MIN_WIDTH + window.close() + + +def test_canvas_select_points_skips_duplicate_selection_emit() -> 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.set_document(doc) + emitted: list[list[int]] = [] + canvas.selectionChanged.connect(lambda ids: emitted.append(ids)) + + canvas.select_points([0, 1]) + canvas.select_points([0, 1]) + app.processEvents() + + assert emitted == [[0, 1]] + + +def test_canvas_brush_cursor_survives_pan_cursor_reset() -> None: + if LedCanvas is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + canvas = LedCanvas() + canvas.set_brush_enabled(True) + + assert canvas.cursor().shape() == Qt.CrossCursor + assert canvas.viewport().cursor().shape() == Qt.CrossCursor + + canvas.setCursor(Qt.ClosedHandCursor) + canvas.viewport().setCursor(Qt.ClosedHandCursor) + canvas._apply_default_cursor() + app.processEvents() + + assert canvas.cursor().shape() == Qt.CrossCursor + assert canvas.viewport().cursor().shape() == Qt.CrossCursor + + canvas.set_brush_enabled(False) + assert canvas.cursor().shape() == Qt.ArrowCursor + assert canvas.viewport().cursor().shape() == Qt.ArrowCursor + + +def test_canvas_brush_zone_is_visible_and_collects_points() -> 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.set_document(doc) + touched: list[int] = [] + canvas.brushPointTouched.connect(lambda point_id: touched.append(point_id)) + + canvas.set_brush_enabled(True) + viewport_pos = canvas.mapFromScene(canvas.items_by_id[0].pos()) + canvas._update_brush_zone(viewport_pos) + canvas._touch_brush_point(viewport_pos) + app.processEvents() + + assert canvas._brush_zone_item is not None + assert canvas._brush_zone_item.isVisible() + assert QGraphicsItem is not None + assert canvas._brush_zone_item.flags() & QGraphicsItem.ItemIgnoresTransformations + assert len(touched) > 1 + assert 0 in touched + + canvas.set_brush_enabled(False) + assert canvas._brush_zone_item is not None + assert not canvas._brush_zone_item.isVisible() + + +def test_canvas_brush_zone_uses_viewport_radius_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), + LedPoint(1, 1, 34.0, 0.0, 0, 1), + ] + canvas = LedCanvas() + canvas.set_document(doc) + canvas.resetTransform() + canvas.scale(0.5, 0.5) + touched: list[int] = [] + canvas.brushPointTouched.connect(lambda point_id: touched.append(point_id)) + + canvas.set_brush_enabled(True) + viewport_pos = canvas.mapFromScene(canvas.items_by_id[0].pos()) + canvas._update_brush_zone(viewport_pos) + canvas._touch_brush_point(viewport_pos) + app.processEvents() + + assert set(touched) == {0, 1} + + +def test_canvas_brush_preview_highlights_only_actionable_points() -> 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), + LedPoint(1, 1, 8.0, 0.0, 0, 1), + LedPoint(2, 2, 80.0, 0.0, 0, 2), + ] + canvas = LedCanvas() + canvas.set_document(doc) + canvas.resetTransform() + canvas.set_brush_enabled(True) + canvas.set_brush_excluded_point_ids({0}) + + viewport_pos = canvas.mapFromScene(canvas.items_by_id[0].pos()) + canvas._update_brush_zone(viewport_pos) + app.processEvents() + + assert canvas._brush_preview_ids == {1} + assert canvas.items_by_id[1].brush().color() == QColor("#fbbf24") + assert canvas.items_by_id[0].brush().color() != QColor("#fbbf24") + + canvas._touch_brush_point(viewport_pos) + assert canvas._brush_touched_ids == {1} + assert canvas.items_by_id[1].brush().color() == QColor("#f43f5e") + + canvas.set_brush_enabled(False) + assert canvas._brush_preview_ids == set() + assert canvas._brush_touched_ids == set() + assert canvas.items_by_id[1].brush().color() == QColor("#94a3b8") + + +def test_brush_can_create_new_block_without_preselected_block() -> 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) + window.document = doc + window.canvas.set_document(doc) + + window.actions["brush"].setChecked(True) + window.toggle_brush_mode() + window.add_brush_point_to_block(0) + window.add_brush_point_to_block(1) + window.finish_brush_stroke() + app.processEvents() + + assert len(window.document.lines) == 1 + assert len(window.document.lines[0].blocks) == 1 + assert window.document.lines[0].blocks[0].point_ids == [0, 1] + window._dirty = False + window.close() + + +def test_brush_continues_block_from_selected_led() -> 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) + line = doc.add_line() + block = doc.add_block(line.id, [0, 1]) + window.document = doc + window.active_line_id = line.id + window.canvas.set_document(doc) + window.canvas.select_points([0]) + + window.actions["brush"].setChecked(True) + window.toggle_brush_mode() + window.add_brush_point_to_block(2) + window.finish_brush_stroke() + app.processEvents() + + assert len(window.document.get_line(line.id).blocks) == 1 + assert window.document.get_line(line.id).blocks[0].id == block.id + assert window.document.get_line(line.id).blocks[0].point_ids == [0, 1, 2] + window._dirty = False + window.close() + + +def test_main_window_restores_table_state_after_selection_error() -> None: + if MainWindow is None or QApplication is None: + return + app = QApplication.instance() or QApplication([]) + window = MainWindow() + window.table.setSortingEnabled(True) + + def failing_get_point(_point_id: int): + raise RuntimeError("synthetic selection failure") + + window.document.get_point = failing_get_point + try: + window._on_selection_changed([0]) + except RuntimeError: + pass + else: + raise AssertionError("selection failure should propagate") + + assert not window._updating_table + assert not window.table.signalsBlocked() + assert window.table.updatesEnabled() + assert window.table.isSortingEnabled() + window.close() + + +def test_main_window_restores_tree_state_after_refresh_error() -> 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) + line = doc.add_line() + doc.add_block(line.id, [0]) + window.document = doc + + def failing_attach(*_args, **_kwargs): + raise RuntimeError("synthetic tree failure") + + window._attach_block_widget = failing_attach + try: + window.refresh_tree() + except RuntimeError: + pass + else: + raise AssertionError("tree refresh failure should propagate") + + assert not window.tree.signalsBlocked() + assert window.tree.updatesEnabled() + window.close() + + +def test_package_imports_work_from_project_root() -> None: + importlib.import_module("app.canvas") + importlib.import_module("app.main_window") + + +def path_has_segment(path, x1: float, y1: float, x2: float, y2: float) -> bool: # noqa: ANN001 + for index in range(path.elementCount() - 1): + first = path.elementAt(index) + second = path.elementAt(index + 1) + if first.x == x1 and first.y == y1 and second.x == x2 and second.y == y2: + return True + return False + + +def test_route_adjacency_large_grid_perf_guard() -> None: + points = [ + LedPoint(uid, uid, (uid % 30) * 34.0, (uid // 30) * 34.0, uid // 30, uid % 30) + for uid in range(900) + ] + started = time.perf_counter() + adjacency = build_route_adjacency(points, 34.0) + elapsed = time.perf_counter() - started + assert sum(len(neighbors) for neighbors in adjacency.values()) > 0 + assert elapsed < 1.0 + + +def test_route_prefers_up_left_down_right() -> None: + points = [ + LedPoint(0, 0, 0.0, 0.0, 1, 1), + LedPoint(1, 1, 0.0, 34.0, 0, 1), + LedPoint(2, 2, -34.0, 0.0, 1, 0), + LedPoint(3, 3, 0.0, -34.0, 2, 1), + LedPoint(4, 4, 34.0, 0.0, 1, 2), + ] + ordered = order_points_like_grid_svg(points, 0) + assert [point.uid for point in ordered[:2]] == [0, 1] + + +def test_route_prefers_coordinate_up_when_grid_index_is_not_adjacent() -> None: + points = [ + LedPoint(0, 0, 0.0, 0.0, 10, 10), + LedPoint(1, 1, 0.0, 34.0, 50, 10), + LedPoint(2, 2, 34.0, 0.0, 10, 11), + ] + doc = MappingDocument() + doc.points = points + line = doc.add_line() + block = doc.add_clockwise_block_from_seed(line.id, 0) + + assert block.point_ids[:2] == [0, 1] + + +def test_first_route_step_prefers_up_before_slightly_closer_right() -> None: + points = [ + LedPoint(0, 0, 0.0, 0.0, 10, 10), + LedPoint(1, 1, 0.0, 35.5, 11, 10), + LedPoint(2, 2, 34.0, 0.0, 10, 11), + ] + doc = MappingDocument() + doc.points = points + line = doc.add_line() + block = doc.add_clockwise_block_from_seed(line.id, 0) + + assert block.point_ids[:2] == [0, 1] + + +def test_route_does_not_jump_over_intermediate_led() -> None: + points = [ + LedPoint(0, 0, 0.0, 0.0, 0, 0), + LedPoint(1, 1, 0.0, 17.0, 1, 0), + LedPoint(2, 2, 0.0, 34.0, 2, 0), + LedPoint(3, 3, 34.0, 0.0, 0, 1), + ] + doc = MappingDocument() + doc.points = points + line = doc.add_line() + block = doc.add_clockwise_block_from_seed(line.id, 0) + + assert block.point_ids[:3] == [0, 1, 2] + + +def test_route_does_not_use_grid_indexes_to_skip_nearer_physical_neighbor() -> None: + points = [ + LedPoint(0, 0, 0.0, 0.0, 0, 0), + LedPoint(1, 1, 34.0, 0.0, 0, 1), + LedPoint(2, 2, 68.0, 0.0, 0, 2), + LedPoint(3, 3, 68.0, 28.0, 50, 50), + LedPoint(4, 4, 102.0, 0.0, 0, 3), + ] + doc = MappingDocument() + doc.points = points + line = doc.add_line() + block = doc.add_clockwise_block_from_seed(line.id, 0) + + assert block.point_ids[:4] == [0, 1, 2, 3] + + +def test_route_continues_after_curved_sparse_step() -> None: + points = [ + LedPoint(0, 0, 0.0, 0.0, 0, 0), + LedPoint(1, 1, 34.0, -34.0, 1, 1), + LedPoint(2, 2, 68.0, -48.0, 2, 2), + LedPoint(3, 3, 102.0, -56.0, 3, 3), + ] + doc = MappingDocument() + doc.points = points + line = doc.add_line() + block = doc.add_clockwise_block_from_seed(line.id, 0) + + assert block.point_ids == [0, 1, 2, 3] + + +def test_auto_block_continues_past_point_neighboring_start() -> None: + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "closed_with_tail.json" + points = [ + {"id": 0, "x": 0.0, "y": 0.0, "row": 1, "col": 1}, + {"id": 1, "x": 0.0, "y": 34.0, "row": 0, "col": 1}, + {"id": 2, "x": 34.0, "y": 34.0, "row": 0, "col": 2}, + {"id": 3, "x": 34.0, "y": 0.0, "row": 1, "col": 2}, + {"id": 4, "x": 68.0, "y": 0.0, "row": 1, "col": 3}, + ] + path.write_text(json.dumps({"circles": points}), encoding="utf-8") + + doc = MappingDocument() + doc.load(path) + line = doc.add_line() + block = doc.add_clockwise_block_from_seed(line.id, 0) + + assert sorted(block.point_ids) == [0, 1, 2, 3, 4] + assert doc.points[4].line_id == line.id + + +def test_auto_block_does_not_close_before_meaningful_continuation() -> None: + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "premature_close.json" + points = [ + {"id": 0, "x": 0.0, "y": 0.0, "row": 1, "col": 1}, + {"id": 1, "x": 0.0, "y": 34.0, "row": 0, "col": 1}, + {"id": 2, "x": 34.0, "y": 34.0, "row": 0, "col": 2}, + {"id": 3, "x": 34.0, "y": 0.0, "row": 1, "col": 2}, + {"id": 4, "x": 68.0, "y": 0.0, "row": 1, "col": 3}, + {"id": 5, "x": 68.0, "y": 34.0, "row": 0, "col": 3}, + ] + path.write_text(json.dumps({"circles": points}), encoding="utf-8") + + doc = MappingDocument() + doc.load(path) + line = doc.add_line() + block = doc.add_clockwise_block_from_seed(line.id, 0) + + assert block.point_ids[:4] != [0, 1, 2, 3] + assert 4 in block.point_ids + assert 5 in block.point_ids + + +def test_parallel_track_middle_edges_are_filtered() -> None: + points = [ + LedPoint(0, 0, 0.0, 0.0, 0, 0), + LedPoint(1, 1, 0.0, 34.0, 1, 0), + LedPoint(2, 2, 0.0, 68.0, 2, 0), + LedPoint(3, 3, 34.0, 0.0, 0, 1), + LedPoint(4, 4, 34.0, 34.0, 1, 1), + LedPoint(5, 5, 34.0, 68.0, 2, 1), + ] + adjacency = build_route_adjacency(points, 34.0) + assert 4 not in [point.uid for point in adjacency[1]] + assert 1 not in [point.uid for point in adjacency[4]] + + +def test_route_keeps_close_point_on_smooth_bend() -> None: + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "smooth_bend.json" + points = [ + {"id": 0, "x": 0.0, "y": 0.0, "row": 3, "col": 0}, + {"id": 1, "x": 0.0, "y": 34.0, "row": 2, "col": 0}, + {"id": 2, "x": 17.0, "y": 51.0, "row": 1, "col": 1}, + {"id": 3, "x": 34.0, "y": 68.0, "row": 1, "col": 2}, + {"id": 4, "x": 68.0, "y": 68.0, "row": 1, "col": 3}, + ] + path.write_text(json.dumps({"circles": points}), encoding="utf-8") + + doc = MappingDocument() + doc.load(path) + line = doc.add_line() + block = doc.add_clockwise_block_from_seed(line.id, 0) + + assert block.point_ids == [0, 1, 2, 3, 4] + + +def test_new_block_does_not_inherit_previous_block_trajectory() -> None: + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "independent_new_block.json" + points = [ + {"id": 0, "x": -34.0, "y": 0.0, "row": 0, "col": -1}, + {"id": 1, "x": 0.0, "y": 0.0, "row": 0, "col": 0}, + {"id": 2, "x": 0.0, "y": 34.0, "row": 1, "col": 0}, + {"id": 3, "x": 34.0, "y": 0.0, "row": 0, "col": 1}, + ] + path.write_text(json.dumps({"circles": points}), encoding="utf-8") + + doc = MappingDocument() + doc.load(path) + line = doc.add_line() + doc.add_block(line.id, [0]) + block = doc.add_clockwise_block_from_seed(line.id, 1) + + assert block.point_ids[:2] == [1, 2] + + +def test_route_prefers_current_block_trajectory_after_first_step() -> None: + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "current_block_trajectory.json" + points = [ + {"id": 0, "x": 0.0, "y": 0.0, "row": 0, "col": 0}, + {"id": 1, "x": 34.0, "y": 0.0, "row": 0, "col": 1}, + {"id": 2, "x": 68.0, "y": 0.0, "row": 0, "col": 2}, + {"id": 3, "x": 34.0, "y": -34.0, "row": 1, "col": 1}, + ] + path.write_text(json.dumps({"circles": points}), encoding="utf-8") + + doc = MappingDocument() + doc.load(path) + line = doc.add_line() + block = doc.add_clockwise_block_from_seed(line.id, 0) + + assert block.point_ids[:3] == [0, 1, 2] + + +def test_route_prefers_near_neighbor_before_far_trajectory() -> None: + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "near_before_far.json" + points = [ + {"id": 0, "x": 0.0, "y": 0.0, "row": 0, "col": 0}, + {"id": 1, "x": 34.0, "y": 0.0, "row": 0, "col": 1}, + {"id": 2, "x": 34.0, "y": -34.0, "row": 1, "col": 1}, + {"id": 3, "x": 102.0, "y": 0.0, "row": 0, "col": 3}, + ] + path.write_text(json.dumps({"circles": points}), encoding="utf-8") + + doc = MappingDocument() + doc.load(path) + line = doc.add_line() + block = doc.add_clockwise_block_from_seed(line.id, 0) + + assert block.point_ids[:3] == [0, 1, 2] + + +def test_route_continues_trajectory_across_sparse_gap() -> None: + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "sparse_gap.json" + points = [ + {"id": 0, "x": 0.0, "y": 0.0, "row": 0, "col": 0}, + {"id": 1, "x": 0.0, "y": 34.0, "row": 1, "col": 0}, + {"id": 2, "x": 0.0, "y": 102.0, "row": 3, "col": 0}, + {"id": 3, "x": 0.0, "y": 136.0, "row": 4, "col": 0}, + ] + path.write_text(json.dumps({"circles": points}), encoding="utf-8") + + doc = MappingDocument() + doc.load(path) + line = doc.add_line() + block = doc.add_clockwise_block_from_seed(line.id, 0) + + assert block.point_ids == [0, 1, 2, 3] + + +def test_clockwise_route_keeps_straight_track_at_branch() -> None: + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "straight_branch.json" + points = [ + {"id": 0, "x": 0.0, "y": 0.0, "row": 3, "col": 0}, + {"id": 1, "x": 0.0, "y": 34.0, "row": 2, "col": 0}, + {"id": 2, "x": 0.0, "y": 68.0, "row": 1, "col": 0}, + {"id": 3, "x": 0.0, "y": 102.0, "row": 0, "col": 0}, + {"id": 4, "x": 34.0, "y": 34.0, "row": 2, "col": 1}, + {"id": 5, "x": 68.0, "y": 34.0, "row": 2, "col": 2}, + ] + path.write_text(json.dumps({"circles": points}), encoding="utf-8") + + doc = MappingDocument() + doc.load(path) + line = doc.add_line() + block = doc.add_clockwise_block_from_seed(line.id, 0) + + assert block.point_ids[:4] == [0, 1, 2, 3] + + +def test_clockwise_route_turns_right_when_straight_is_absent() -> None: + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "right_turn.json" + points = [ + {"id": 0, "x": 0.0, "y": 34.0, "row": 0, "col": 0}, + {"id": 1, "x": 34.0, "y": 34.0, "row": 0, "col": 1}, + {"id": 2, "x": 34.0, "y": 0.0, "row": 1, "col": 1}, + ] + path.write_text(json.dumps({"circles": points}), encoding="utf-8") + + doc = MappingDocument() + doc.load(path) + line = doc.add_line() + block = doc.add_clockwise_block_from_seed(line.id, 0) + + assert block.point_ids[:3] == [0, 1, 2] + + +def test_auto_block_continues_through_left_down_diagonal() -> None: + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "left_down_diagonal.json" + points = [ + {"id": 0, "x": 68.0, "y": 68.0, "row": 0, "col": 2}, + {"id": 1, "x": 34.0, "y": 68.0, "row": 0, "col": 1}, + {"id": 2, "x": 0.0, "y": 34.0, "row": 1, "col": 0}, + {"id": 3, "x": 0.0, "y": 0.0, "row": 2, "col": 0}, + ] + path.write_text(json.dumps({"circles": points}), encoding="utf-8") + + doc = MappingDocument() + doc.load(path) + line = doc.add_line() + block = doc.add_clockwise_block_from_seed(line.id, 0) + + assert block.point_ids == [0, 1, 2, 3] + + +def test_too_close_neighbor_has_lowest_priority() -> None: + points = [ + LedPoint(0, 0, 0.0, 0.0, 0, 0), + LedPoint(1, 1, 10.0, 0.0, 1, 0), + LedPoint(2, 2, 34.0, 0.0, 1, 1), + ] + ordered = order_points_like_grid_svg(points, 0) + assert [point.uid for point in ordered[:2]] == [0, 2] + + +def test_meter_like_coordinates_use_34_mm_threshold() -> None: + points = [ + LedPoint(0, 0, 0.0, 0.0, 0, 0), + LedPoint(1, 1, 0.010, 0.0, 1, 0), + LedPoint(2, 2, 0.034, 0.0, 1, 1), + LedPoint(3, 3, 0.037, 0.0, 1, 2), + ] + assert min_neighbor_distance_for_points(points) == 0.034 + assert not is_valid_route_neighbor(points[0], points[3], 0.034) + ordered = order_points_like_grid_svg(points, 0) + assert [point.uid for point in ordered[:2]] == [0, 2] + + +def temp_document_path(): + temp_dir = tempfile.TemporaryDirectory() + path = Path(temp_dir.name) / "fixture.json" + path.write_text(json.dumps({"circles": fixture_points()}), encoding="utf-8") + + class TempPath: + def __enter__(self) -> Path: + return path + + def __exit__(self, exc_type, exc, traceback) -> None: + temp_dir.cleanup() + + return TempPath() + + +def fixture_points() -> list[dict[str, int]]: + points: list[dict[str, int]] = [] + uid = 0 + for base_x, base_y in [(0, 0), (100, 0), (0, 100), (100, 100)]: + for offset in range(4): + points.append( + { + "id": uid, + "x": base_x + offset, + "y": base_y, + "row": base_y + offset, + "col": base_x + offset, + } + ) + uid += 1 + return points + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..da33986 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +PySide6>=6.7 diff --git a/unfolding.py b/unfolding.py new file mode 100644 index 0000000..ce8939f --- /dev/null +++ b/unfolding.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +import math +from dataclasses import dataclass, field +from typing import Any, Callable, Protocol, TypeVar + + +MAX_LINES = 4 + + +class PointLike(Protocol): + uid: int + x: float + y: float + row: int + col: int + + +PointT = TypeVar("PointT", bound=PointLike) +TypeVarT = TypeVar("TypeVarT") + + +@dataclass(frozen=True) +class UnfoldingSpec: + max_lines: int = MAX_LINES + neighbor_mode: str = "grid-8" + direction: str = "clockwise" + + +@dataclass(frozen=True) +class UnfoldingLineSummary: + line_id: int + led_count: int + block_count: int + + +@dataclass +class UnfoldingDescription: + max_lines: int = MAX_LINES + line_order: str = "clockwise" + block_order: str = "clockwise" + led_index_scope: str = "line" + block_size: int | None = None + block_counts: list[UnfoldingLineSummary] = field(default_factory=list) + + @classmethod + def from_payload(cls, payload: Any) -> "UnfoldingDescription": + if not isinstance(payload, dict): + return cls() + + max_lines = max(1, min(int(payload.get("max_lines", MAX_LINES)), MAX_LINES)) + block_size = payload.get("block_size") + block_counts: list[UnfoldingLineSummary] = [] + for item in payload.get("block_counts", []): + if isinstance(item, dict): + block_counts.append( + UnfoldingLineSummary( + line_id=int(item.get("line_id", 0)), + led_count=int(item.get("led_count", 0)), + block_count=int(item.get("block_count", 0)), + ) + ) + + return cls( + max_lines=max_lines, + line_order=str(payload.get("line_order", "clockwise")), + block_order=str(payload.get("block_order", "clockwise")), + led_index_scope=str(payload.get("led_index_scope", "line")), + block_size=None if block_size is None else int(block_size), + block_counts=block_counts, + ) + + def to_payload(self) -> dict[str, Any]: + payload: dict[str, Any] = { + "max_lines": max(1, min(self.max_lines, MAX_LINES)), + "line_order": self.line_order, + "block_order": self.block_order, + "led_index_scope": self.led_index_scope, + "block_counts": [ + { + "line_id": item.line_id, + "led_count": item.led_count, + "block_count": item.block_count, + } + for item in self.block_counts + ], + } + if self.block_size is not None: + payload["block_size"] = self.block_size + return payload + + def expected_block_count(self, led_count: int) -> int: + if led_count <= 0: + return 0 + if self.block_size is None or self.block_size <= 0: + return 1 + return math.ceil(led_count / self.block_size) + + +@dataclass(frozen=True) +class BlockDescriptor: + id: int + name: str + led_count: int + start_index: int + end_index: int + + +@dataclass(frozen=True) +class LineDescriptor: + id: int + name: str + block_count: int + led_count: int + blocks: list[BlockDescriptor] = field(default_factory=list) + + +@dataclass(frozen=True) +class UnfoldingSummary: + spec: UnfoldingSpec + lines: list[LineDescriptor] + + +def connected_groups(points: list[PointT]) -> list[list[PointT]]: + remaining = set(point.uid for point in points) + by_id = {point.uid: point for point in points} + groups: list[list[PointT]] = [] + while remaining: + start = remaining.pop() + queue = [start] + group = [by_id[start]] + while queue: + current = by_id[queue.pop()] + for candidate_id in list(remaining): + candidate = by_id[candidate_id] + if are_neighbors(current, candidate): + remaining.remove(candidate_id) + queue.append(candidate_id) + group.append(candidate) + groups.append(group) + return sorted(groups, key=lambda group: min(point_sort_key(point) for point in group)) + + +def order_points_clockwise(points: list[PointT]) -> list[PointT]: + if len(points) <= 2: + return sorted(points, key=point_sort_key) + + center_x = sum(point.x for point in points) / len(points) + center_y = sum(point.y for point in points) / len(points) + ordered = sorted( + points, + key=lambda point: ( + clockwise_angle(point, center_x, center_y), + distance_to_center(point, center_x, center_y), + point_sort_key(point), + ), + ) + ordered = ensure_clockwise(ordered) + return rotate_to_stable_start(ordered) + + +def order_blocks_clockwise(block_point_ids: list[list[int]], points_by_id: dict[int, PointT]) -> list[list[int]]: + non_empty = [block for block in block_point_ids if block] + if len(non_empty) <= 1: + return non_empty + + line_points = [points_by_id[point_id] for block in non_empty for point_id in block] + center_x = sum(point.x for point in line_points) / len(line_points) + center_y = sum(point.y for point in line_points) / len(line_points) + + def block_key(block: list[int]) -> tuple[float, float, int]: + block_points = [points_by_id[point_id] for point_id in block] + block_x = sum(point.x for point in block_points) / len(block_points) + block_y = sum(point.y for point in block_points) / len(block_points) + angle = (-math.atan2(block_y - center_y, block_x - center_x)) % math.tau + radius = math.hypot(block_x - center_x, block_y - center_y) + return angle, radius, min(block) + + return sorted(non_empty, key=block_key) + + +def clockwise_sort(items: list[TypeVarT], points_for_item: Callable[[TypeVarT], list[PointLike]]) -> list[TypeVarT]: + if len(items) <= 1: + return items[:] + + items_with_points = [(item, points_for_item(item), index) for index, item in enumerate(items)] + non_empty = [(item, points, index) for item, points, index in items_with_points if points] + empty = [(item, index) for item, points, index in items_with_points if not points] + if len(non_empty) <= 1: + return [item for item, _, _ in non_empty] + [item for item, _ in empty] + + all_points = [point for _, points, _ in non_empty for point in points] + center_x = sum(point.x for point in all_points) / len(all_points) + center_y = sum(point.y for point in all_points) / len(all_points) + + def item_key(item_data: tuple[TypeVarT, list[PointLike], int]) -> tuple[float, float, int]: + _, points, index = item_data + item_x = sum(point.x for point in points) / len(points) + item_y = sum(point.y for point in points) / len(points) + angle = (-math.atan2(item_y - center_y, item_x - center_x)) % math.tau + radius = math.hypot(item_x - center_x, item_y - center_y) + return angle, radius, index + + return [item for item, _, _ in sorted(non_empty, key=item_key)] + [item for item, _ in empty] + + +def are_neighbors(a: PointLike, b: PointLike) -> bool: + return abs(a.row - b.row) <= 1 and abs(a.col - b.col) <= 1 + + +def point_sort_key(point: PointLike) -> tuple[int, int, float, float, int]: + return point.row, point.col, point.x, point.y, point.uid + + +def clockwise_angle(point: PointLike, center_x: float, center_y: float) -> float: + return (-math.atan2(point.y - center_y, point.x - center_x)) % math.tau + + +def distance_to_center(point: PointLike, center_x: float, center_y: float) -> float: + return math.hypot(point.x - center_x, point.y - center_y) + + +def signed_area(points: list[PointLike]) -> float: + area = 0.0 + for index, point in enumerate(points): + next_point = points[(index + 1) % len(points)] + area += point.x * next_point.y - next_point.x * point.y + return area / 2.0 + + +def ensure_clockwise(points: list[PointT]) -> list[PointT]: + if len(points) < 3 or signed_area(points) <= 0: + return points + return [points[0], *reversed(points[1:])] + + +def rotate_to_stable_start(points: list[PointT]) -> list[PointT]: + start = min(range(len(points)), key=lambda index: point_sort_key(points[index])) + return [*points[start:], *points[:start]]