Initial LED Mapper application

This commit is contained in:
Данил Ярош 2026-05-21 08:16:32 +03:00
commit 59bbad8288
14 changed files with 6210 additions and 0 deletions

12
.gitattributes vendored Normal file
View File

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

25
.gitignore vendored Normal file
View File

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

38
LEDMapper.spec Normal file
View File

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

177
README.md Normal file
View File

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

1
__init__.py Normal file
View File

@ -0,0 +1 @@
"""LED Mapper Editor application package."""

608
canvas.py Normal file
View File

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

50
icons.py Normal file
View File

@ -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": '<rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h2"/><path d="M20 8v11a2 2 0 0 1-2 2h-2"/><path d="m9 15 3-3 3 3"/><path d="M12 12v9"/>',
"brush": '<path d="m9.06 11.9 8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.01 0 1.1-.89 2-2 2 1.4 1.4 3.67 1.4 5.07 0l2.94-2.94a2.93 2.93 0 0 0-4.14-4.14z"/>',
"chevron-right": '<path d="m9 18 6-6-6-6"/>',
"eraser": '<path d="m7 21-4.3-4.3c-.9-.9-.9-2.4 0-3.3L14 2l8 8-11 11Z"/><path d="M22 21H7"/><path d="m5 11 8 8"/>',
"eye": '<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>',
"file-plus": '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/><path d="M12 18v-6"/><path d="M9 15h6"/>',
"file-code": '<path d="M14 2v6h6"/><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="m10 13-2 2 2 2"/><path d="m14 17 2-2-2-2"/>',
"folder-open": '<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6A2 2 0 0 1 18.46 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4.9a2 2 0 0 1 1.69.9L12 6h8a2 2 0 0 1 2 2v2"/><path d="M2 13h4"/>',
"grid-2x2": '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 12h18"/><path d="M12 3v18"/>',
"layers": '<path d="m12.83 2.18 8.34 4.66a1 1 0 0 1 0 1.74l-8.34 4.66a1.7 1.7 0 0 1-1.66 0L2.83 8.58a1 1 0 0 1 0-1.74l8.34-4.66a1.7 1.7 0 0 1 1.66 0Z"/><path d="m22 12.5-9.17 5.12a1.7 1.7 0 0 1-1.66 0L2 12.5"/><path d="m22 17.5-9.17 5.12a1.7 1.7 0 0 1-1.66 0L2 17.5"/>',
"list-plus": '<path d="M11 12H3"/><path d="M16 6H3"/><path d="M16 18H3"/><path d="M18 9v6"/><path d="M21 12h-6"/>',
"paintbrush": '<path d="m14.62 17.9-1.48 1.48a2.12 2.12 0 1 1-3-3l1.48-1.48"/><path d="m16.1 16.42 5.15-5.15a4.95 4.95 0 0 0-7-7L9.1 9.42"/><path d="m7.5 11 5.5 5.5"/><path d="m9 9-6 6v4h4l6-6"/>',
"plus": '<path d="M5 12h14"/><path d="M12 5v14"/>',
"redo-2": '<path d="m15 14 5-5-5-5"/><path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5 5.5 5.5 0 0 0 9.5 20H13"/>',
"rotate-cw": '<path d="M21 12a9 9 0 1 1-2.64-6.36L21 8"/><path d="M21 3v5h-5"/>',
"save": '<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 0 1 1h7"/>',
"save-all": '<path d="M10 2v3a1 1 0 0 0 1 1h5"/><path d="M18 18v-6a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v6"/><path d="M18 22H6a2 2 0 0 1-2-2V6"/><path d="M8 2h8l4 4v12a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2"/>',
"trash-2": '<path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/>',
"undo-2": '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5A5.5 5.5 0 0 1 20 14.5 5.5 5.5 0 0 1 14.5 20H11"/>',
}
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 = (
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" '
'viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2" '
'stroke-linecap="round" stroke-linejoin="round">{path}</svg>'
).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)

18
main.py Normal file
View File

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

1467
main_window.py Normal file

File diff suppressed because it is too large Load Diff

1343
models.py Normal file

File diff suppressed because it is too large Load Diff

491
panel_mapping_generator.py Normal file
View File

@ -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 <stdint.h>\n")
file.write("#include <Arduino.h>\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 <initializer_list>\n\n")
file.write("#ifndef ARDUINO\n#include <Arduino.h>\n#endif\n\n")
file.write("#include <Adafruit_NeoPixel.h>\n")
file.write("#ifndef NUM_STRIPS\n")
file.write(f"#define NUM_STRIPS {NUM_STRIPS}\n")
file.write("#endif\n")
file.write("extern Adafruit_NeoPixel hwStrips[NUM_STRIPS];\n\n")
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<uint16_t> 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<uint16_t> ids) {\n")
file.write(" if (row >= MATRIX_ROWS || col >= MATRIX_COLS) return;\n")
file.write(" uint8_t strip = (strip1based == 0) ? 0 : (uint8_t)(strip1based - 1);\n")
file.write(" if (strip >= NUM_STRIPS) return;\n")
file.write(" for (uint16_t id : ids) {\n")
file.write(" if (id >= VM_MAX_PHYS_LEDS) continue;\n")
file.write(" uint8_t &count = vmCount[row][col];\n")
file.write(" if (count < VM_MAX_IDS_PER_CELL) {\n")
file.write(" vmCell[row][col][count].idx = id;\n")
file.write(" vmCell[row][col][count].strip = strip;\n")
file.write(" ++count;\n")
file.write(" }\n")
file.write(" vmRow[strip][id] = (int16_t)row;\n")
file.write(" vmCol[strip][id] = (int16_t)col;\n")
file.write(" vmUsed[strip][id] = 1;\n")
file.write(" if (id > vmMaxId[strip]) vmMaxId[strip] = id;\n")
file.write(" }\n")
file.write("}\n\n")
file.write("void vmSetByPhys(uint8_t strip, uint16_t led, uint32_t color) {\n")
file.write(" if (strip >= NUM_STRIPS || led >= VM_MAX_PHYS_LEDS) return;\n")
file.write(" int16_t row = vmRow[strip][led];\n")
file.write(" int16_t col = vmCol[strip][led];\n")
file.write(" if (row < 0 || col < 0) {\n")
file.write(" if (led < hwStrips[strip].numPixels()) hwStrips[strip].setPixelColor(led, color);\n")
file.write(" return;\n")
file.write(" }\n")
file.write(" uint8_t count = vmCount[row][col];\n")
file.write(" for (uint8_t i = 0; i < count; ++i) {\n")
file.write(" const VMPhys &phys = vmCell[row][col][i];\n")
file.write(" if (phys.strip < NUM_STRIPS && phys.idx < hwStrips[phys.strip].numPixels()) {\n")
file.write(" hwStrips[phys.strip].setPixelColor(phys.idx, color);\n")
file.write(" }\n")
file.write(" }\n")
file.write("}\n\n")
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"

1740
qa_smoke.py Normal file

File diff suppressed because it is too large Load Diff

1
requirements.txt Normal file
View File

@ -0,0 +1 @@
PySide6>=6.7

239
unfolding.py Normal file
View File

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