Compare commits

..

28 Commits
v1.2 ... master

Author SHA1 Message Date
30ac54f77f feat: Release v3.5.0 with free-form text comments
- Добавлены свободные многострочные комментарии на схеме: создание через меню, перемещение мышью, редактирование и удаление.
- Реализовано хранение комментариев в JSON с устойчивыми идентификаторами, координатами и сохранением неизвестных полей.
- Все операции с комментариями включены в Undo/Redo; поддержаны схемы только с заметками и сохранение удаления последней заметки.
- Исправлены границы общего вида, неограниченный масштаб схемы с одной заметкой и завершение перетаскивания при панорамировании пробелом.
- Добавлены семь smoke-сценариев, проверки совместимости генератора и обновлено руководство пользователя.
- Версия приложения обновлена до 3.5.0.
2026-11-05 08:50:36 +03:00
25ca2101a4 feat: improve LED Mapper settings and image support
- Добавлена сохраняемая настройка размера текста индексов LED с мгновенным предпросмотром и сбросом.
- Исправлено размещение быстрых кнопок в строке меню без перекрытия пунктов на узком окне.
- Усилена проверка OTA-загрузок: права записи, конечный URL, авторизация, Content-Length и отдельная группа updater-процесса.
- Добавлен единый загрузчик подложек с определением формата по содержимому, EXIF-ориентацией и преобразованием CMYK.
- Расширен список форматов подложки и добавлена безопасная обработка крупных изображений.
- Ошибки декодирования теперь показываются пользователю без замены текущей подложки.
- Обновлены smoke-тесты, документация, PyInstaller-конфигурация и версия приложения до 3.4.0.
2026-10-02 09:42:24 +03:00
37fbb2c545 fix: isolate Windows build dependencies
- Ограничен поиск DLL при сборке каталогами Python, окружения и Windows.
- Добавлена проверка источников бинарных библиотек перед упаковкой.
- Добавлен режим --verify-bundle и регрессионный smoke-тест.
- Обновлены документация и версия приложения до 3.3.1.
2026-09-18 14:32:19 +03:00
f8f6a216f5 feat: add OTA updates and zero-based mapping
- Добавлена проверка и безопасная установка OTA-обновлений Windows с откатом.
- Нумерация линий и блоков переведена на индексы с нуля.
- Добавлена миграция старых JSON-схем с сохранением физической адресации.
- Обновлены генератор, сборка, документация и smoke-проверки.
- Подготовлена версия 3.3.0.
2026-09-18 10:37:21 +03:00
0b24cbd83c feat: add LED copy and paste in edit mode
- добавить команды копирования и вставки выбранных светодиодов в режиме редактирования
- назначить на новые команды настраиваемые сочетания Ctrl+C и Ctrl+V
- создавать свободные копии с уникальными идентификаторами и каскадным смещением по шагу сетки
- сохранять взаимное расположение и пользовательские поля копируемых точек
- объединить вставку группы в одну операцию Undo с восстановлением выделения через Redo
- не перехватывать стандартное копирование и вставку при редактировании текста и координат
- добавить пункты меню, иконки, документацию и проверки, расширив QA до 164 сценариев
2026-09-09 08:30:30 +03:00
0d4552ef35 Embed background images in mapping files
- встраивать исходное изображение подложки в JSON вместе с метаданными и контрольной суммой
- сохранять геометрию и параметры встроенной подложки при повторном редактировании
- показывать значение «Встроенная» в диалоге подложки до выбора замены
- загружать встроенное изображение непосредственно из JSON на холст
- защищать существующий файл схемы при ошибке чтения подложки
- расширить автоматические проверки до 161 сценария
2026-09-08 15:21:33 +03:00
f2108e053f release: prepare LED Mapper 3.1.0
- исправить точное и атомарное сохранение схем
- добавить настройку шага сетки и абсолютного размера LED
- добавить пакетное удаление выбранных блоков
- исключить генерацию устаревшего INO-файла
- синхронизировать сетку обычного режима и режима редактирования
- устранить движение схемы при переключении режима редактирования
- расширить автоматические проверки до 158 сценариев
2026-09-08 14:13:00 +03:00
691bfee068 Implement LED layout editing tools and infinite grid mode
- Добавлена bbox-подсветка с синим пунктиром вокруг выделенных светодиодов
- Реализовано перетаскивание выделенной группы светодиодов за любую область внутри bbox-рамки
- Добавлен инструмент «Выровнять по сетке» (Snap to Grid) на правую панель
- Добавлена поддержка бесконечной сетки во все стороны в режиме редактирования
- Исправлено прореживание и масштабирование сетки в режиме редактирования
- Добавлены пункты выделения линий и блоков по правому клику мыши
- Стабилизировано положение камеры при добавлении точек (устранено смещение scrolling-вьюпорта)
2026-08-07 14:03:41 +03:00
3fe3afa3d7 Increase exported mapping image resolution to 4K and scale layout proportionally
- Значение MAPPING_MAX увеличено с 2200 до 4000 для повышения разрешения экспортируемого изображения до уровня 4K.
- Добавлено пропорциональное масштабирование всех констант разметки, размеров шрифтов, толщины линий, отступов и скруглений на основе коэффициента base_scale.
- Параметр масштабирования base_scale прокинут в функции отрисовки стрелок направления и компенсации пересечений.
2026-08-06 12:24:10 +03:00
5507d1c7c7 Fix QMenuBar tools buttons rendering using child widget overlay
- Исправлено отображение кнопок инструментов в QMenuBar: вместо QWidgetAction использован дочерний виджет QWidget с абсолютным позиционированием.
- Добавлено позиционирование виджета кнопок справа от заголовка Инструменты с автоматическим пересчетом координат в resizeEvent.
- Обновлен юнит-тест test_menu_bar_tools_buttons под новое представление.
2026-07-31 16:58:30 +03:00
3431ef6b50 Add Tools menu buttons directly to QMenuBar next to menu headers
- Инструменты добавлены непосредственно на панель меню QMenuBar в виде QToolButton.
- Действия инструментов отображаются справа от заголовков меню (Файл, Правка, Вид, Инструменты).
- Действия инструментов отделены от заголовков вертикальным разделителем.
- Добавлен и пройден тест test_menu_bar_tools_buttons в qa_smoke.py.
2026-07-31 16:32:24 +03:00
c3b98d7fe8 Add new empty scheme creation action and support empty JSON load
- Добавлено действие Создать... (Ctrl+N) в меню Файл для инициализации нового файла схемы.
- Реализована автоматическая запись дефолтной схемы circles при открытии пустого или содержащего только пробелы json-файла.
- Добавлены соответствующие автоматические тесты в qa_smoke.py.
2026-07-31 14:54:27 +03:00
fa1497bee7 Support theme selection on export with horizontal bottom legend and block connections
- Добавлена возможность выбора тёмной или светлой темы для экспортируемого изображения.
- Легенда перенесена вниз и отрисовывается слева направо в виде колонок одинаковой ширины.
- Реализованы пунктирные линии связи между соседними блоками одной ленты.
- На соединительных линиях рисуются стрелки направления соединения светодиодов.
- Добавлен механизм сдвига номеров светодиодов для предотвращения наложений друг на друга и их ухода за пределы холста.
2026-07-31 14:15:10 +03:00
82ce556847 Rewrite export to image using standalone renderer from document data
- Создан автономный модуль mapping_renderer.py, рисующий маппинг напрямую из MappingDocument.
- Изображение больше не зависит от Qt-сцены и не содержит метки интерфейса или лишних id.
- Разрешение выходного изображения 2400x2400+800 пикселей с боковой легендой.
- Плашки start/end блоков рисуются напрямую на изображении.
- Добавлено предупреждение при несохранённых изменениях (с возможностью сохранить и экспортировать сразу).
- canvas.py очищен от предыдущей неработающей реализации export_to_image.
2026-07-31 13:44:40 +03:00
59586baca1 Enhance export mapping to image with block badges and sidebar legend
- Добавлен детальный рендеринг маппинга при экспорте:
  - Рисуются информационные плашки (badges) для первого и последнего светодиода в каждом блоке, показывающие ID точки и её локальный индекс в линии.
  - Справа от маппинга выводится боковая панель с легендой линий и блоков.
  - В легенде под цветом каждой линии выводятся блоки и диапазоны индексов их светодиодов (например, Блок: 0-186).
2026-07-31 13:11:04 +03:00
31b9028fc2 Add export mapping scheme to image feature
- Реализован метод export_to_image в canvas.py для рендеринга всей сцены маппинга (с учетом кастомного фона, сетки и точек) в файл изображения с полями и масштабированием.
- Добавлено действие export_image (Экспортировать в изображение...) в меню Файл с хоткеем Ctrl+Shift+E и вызовом диалога сохранения файла.
- Добавлен интеграционный тест test_export_mapping_to_image в qa_smoke.py для проверки экспорта схемы в PNG.
2026-07-31 13:04:39 +03:00
91189d6d37 Fix block and line name resetting upon saving document
- Изменены методы renumber_lines и renumber_blocks в models.py: теперь они переименовывают только стандартные (дефолтные) названия блоков/линий.
- Кастомные названия блоков и линий теперь полностью сохраняются при переиндексации (на сохранении).
- Добавлен интеграционный тест на сохранение/загрузку кастомных имен линий и блоков в qa_smoke.py.
2026-07-31 12:42:05 +03:00
65d7580977 Add tree context menu hotkeys and support renaming lines in GUI
- Добавлены локальные горячие клавиши для действий в дереве: F2 для переименования, Ctrl+Del для удаления, Ctrl+M для объединения блоков.
- Изменен дефолтный хоткей для удаления всех фиксаций (clear_all) на Shift+Del.
- Реализована функция rename_line для переименования линий по аналогии с блоками.
- В контекстное меню дерева добавлено отображение горячих клавиш справа от названий команд.
- Добавлены интеграционные тесты для верификации переименования линий и регистрации хоткеев.
2026-07-31 12:35:15 +03:00
c15fe6e510 Fix block renaming display in tree widget and correct shortcut leakage during modal dialogs
- Добавлен вызов self.refresh_tree() в rename_block для незамедлительного отображения переименованного блока.
- Переработаны _disable_shortcuts и _enable_shortcuts для полного очищения/восстановления последовательностей QKeySequence.
- В eventFilter заменено self.focusWidget() на QApplication.focusWidget() для корректного отслеживания фокуса в диалогах.
- Добавлена проверка activeWindow() в _trigger_layout_independent_shortcut для блокировки хоткеев главного окна при активных диалогах.
2026-07-31 11:25:03 +03:00
030c327d3b Fix hotkeys interception in modal dialog text inputs
- Реализованы методы _disable_shortcuts и _enable_shortcuts для временного отключения ApplicationShortcut контекста.
- Устранена проблема с перехватом горячих клавиш (N, G, B, I) во время ввода текста в QInputDialog (переименование блока) и QDialog (редактирование координат, настройки, фоновое изображение).
- Все вызовы диалогов обёрнуты в блоки try-finally для гарантированного восстановления контекста хоткеев.
2026-07-31 11:12:36 +03:00
8f814bc1fc Add block renaming in GUI and support block name-based addressing in generated scripts
- Реализовано переименование блоков по двойному клику и в контекстном меню дерева.
- Добавлена функция транслитерации и санитарной очистки имён блоков для C++ идентификаторов.
- Реализован массив строк имён блоков в flash-памяти (PROGMEM) и метод поиска getBlockIndexByName.
- Добавлены перегрузки функций управления светодиодами, принимающие оригинальные строковые названия блоков.
- Добавлены интеграционные тесты для проверки санитации имён, генерации макросов и функций поиска.
2026-07-31 11:07:12 +03:00
228e4e4f0b Add support for script generation strategies and block-level LED functions
- Добавлен диалог выбора стратегий генерации (Сетка и Блоки) в GUI.
- Добавлен C++ код для управления светодиодами в блоках по их ID и по количеству в vm_map.h и virtual_matrix_legacy.ino.txt.
- Обновлены функции генератора, добавлена поддержка индивидуальных параметров генерации.
- Написаны интеграционные тесты для проверки генерации стратегий и функций управления блоками.
2026-07-31 09:42:54 +03:00
2832914343 feat: Add counterclockwise routing and LED editing
- Добавлено автоматическое построение блока против часовой стрелки.
- Добавлено редактирование координат LED через контекстное меню.
- Добавлено перемещение светодиодов через Alt + ЛКМ.
- Убран лишний столбец "Источник" из таблицы выбранных LED.
- Индексы LED сделаны визуальной подсказкой, которая не мешает кликам и перетаскиванию точек.
- Добавлены проверки для новых сценариев редактирования, маршрутизации, таблицы и сборочного имени.
2026-06-11 08:49:58 +03:00
207d6ca86c fix: Accept raw JSON encodings
- Добавлена мягкая загрузка сырых JSON-файлов с корневым массивом точек.

- Исправляются trailing commas перед закрывающими скобками и числа вида 1173. без дробной части.

- При открытии файлов в UTF-16, UTF-16 LE/BE, CP1251 или CP1252 содержимое автоматически конвертируется и сохраняется в UTF-8.

- Добавлены smoke-проверки для сырого circle_centers JSON, CP1251 и UTF-16 LE без BOM.
2026-06-10 14:05:07 +03:00
3699586cc2 feat: Allow editing and deleting LED points
- Добавлено полное удаление выбранных LED-точек через контекстное меню холста, таблицы и горячую клавишу Ctrl+Del.

- Разрешено редактирование координат X и Y для всех точек схемы, а не только для созданных вручную.

- После удаления точек пересчитываются UID, связи блоков и индексы линий без повреждения маршрутов.

- UID в таблице теперь показывает исходный id точки, сохраняя внутренний идентификатор для выбора и операций.

- Добавлена настройка распределения высоты внутренних областей боковой панели.

- Расширены smoke-тесты на удаление точек, редактирование координат, пересчет блоков и состояние интерфейса.
2026-06-03 15:46:05 +03:00
ffa7be203e feat: Preserve mappings during generation
- Перед генерацией скрипта текущая схема теперь обязательно сохраняется.

- Для несохраненной схемы перед генерацией открывается диалог сохранения, а отмена останавливает генерацию.

- ordered_circles.json теперь сохраняет полный документ с метаданными, линиями, блоками, подложкой и unfolding.

- UID точек и point_ids блоков в ordered_circles.json пересчитываются под новый порядок генерации, а исходный id сохраняется в source_id.

- Неизвестные поля исходного JSON сохраняются при обычном сохранении и при генерации ordered_circles.json.

- Диалог выбора папки генерации теперь по умолчанию открывается в папке приложения.

- Добавлены smoke-проверки на сохранение полей, ремап UID и стартовую папку генерации.
2026-05-26 14:34:40 +03:00
d8c95a15e2 feat: Improve editor overlays and stability
- Добавлена подложка с видимостью, прозрачностью, масштабом, перемещением и редактированием через отдельное меню.

- Добавлен инструмент создания свободных светодиодов и редактирование X/Y для вручную созданных точек в таблице.

- Упрощена таблица выбранных LED: единый ID, координаты X/Y и UID в конце.

- Добавлены настраиваемые горячие клавиши для всех действий и переключатель отображения id светодиодов.

- Улучшена отрисовка линий: стрелки направления, изгибы при полном и частичном визуальном наложении сегментов.

- Оптимизировано переключение активных линий, выбор точек и поиск наложений соединений для снижения подвисаний.

- Исправлена логика ручных LED: row/col рассчитываются по сетке 34 мм, а ручные точки не сдвигают якорь сетки.

- Обновлена сборка EXE: имя использует тег, короткий хэш или дату/время для dirty-репозитория.

- Расширен smoke-набор до 113 проверок для новых функций и регрессий.
2026-05-25 13:39:59 +03:00
9f297b326f Release settings and shortcut customization 2026-05-21 16:58:28 +03:00
32 changed files with 10727 additions and 566 deletions

View File

@ -1,13 +1,27 @@
# -*- mode: python ; coding: utf-8 -*- # -*- mode: python ; coding: utf-8 -*-
import os
import subprocess import subprocess
import sys
from pathlib import Path from pathlib import Path
from datetime import datetime
spec_dir = Path(SPECPATH) spec_dir = Path(SPECPATH)
def git_build_ref(): def git_build_ref():
try:
dirty = subprocess.check_output(
['git', 'status', '--porcelain', '--untracked-files=normal'],
cwd=spec_dir,
stderr=subprocess.DEVNULL,
text=True,
)
if dirty.strip():
return datetime.now().strftime('%H%M%S-%d%m%Y')
except (OSError, subprocess.CalledProcessError):
return datetime.now().strftime('%H%M%S-%d%m%Y')
try: try:
tag = subprocess.check_output( tag = subprocess.check_output(
['git', 'describe', '--tags', '--exact-match', 'HEAD'], ['git', 'describe', '--tags', '--exact-match', 'HEAD'],
@ -33,21 +47,67 @@ def git_build_ref():
return 'untagged' return 'untagged'
def _build_library_roots():
windows_root = Path(os.environ.get('SystemRoot', r'C:\Windows')).resolve()
return tuple(dict.fromkeys(
path.resolve() for path in (
Path(sys.executable).parent,
Path(sys.prefix),
Path(sys.base_prefix),
Path(sys.base_prefix) / 'DLLs',
windows_root / 'System32',
windows_root,
) if path.is_dir()
))
def _set_isolated_build_path(roots):
# Analysis searches PATH for DLLs. Unrelated tools may provide incompatible
# copies of the Windows runtime or ICU libraries there.
os.environ['PATH'] = os.pathsep.join(str(root) for root in roots)
def _check_binary_sources(binaries, roots):
foreign = [
(name, source)
for name, source, kind in binaries
if kind == 'BINARY'
and not any(Path(source).resolve().is_relative_to(root) for root in roots)
]
if foreign:
details = '\n'.join(f'{name}: {source}' for name, source in foreign[:10])
raise RuntimeError(f'Build contains DLLs outside Python and Windows:\n{details}')
app_name = f'LEDMapper-{git_build_ref()}' app_name = f'LEDMapper-{git_build_ref()}'
library_roots = _build_library_roots()
_set_isolated_build_path(library_roots)
build_version_path = spec_dir.parent / 'build' / 'ledmapper-build-version.txt'
build_version_path.parent.mkdir(parents=True, exist_ok=True)
build_version_path.write_text(app_name.removeprefix('LEDMapper-'), encoding='utf-8')
a = Analysis( a = Analysis(
[str(spec_dir / 'main.py')], [str(spec_dir / 'main.py')],
pathex=[str(spec_dir), str(spec_dir.parent)], pathex=[str(spec_dir), str(spec_dir.parent)],
binaries=[], binaries=[],
datas=[], datas=[(str(spec_dir / 'assets'), 'assets'), (str(build_version_path), '.')],
hiddenimports=[ hiddenimports=[
'canvas', 'canvas',
'background_image_loader',
'background_dialog',
'icons', 'icons',
'localization',
'main_window', 'main_window',
'mapping_renderer',
'models', 'models',
'panel_mapping_generator', 'panel_mapping_generator',
'settings',
'settings_dialog',
'theme',
'unfolding', 'unfolding',
'update_service',
'version',
], ],
hookspath=[], hookspath=[],
hooksconfig={}, hooksconfig={},
@ -56,6 +116,7 @@ a = Analysis(
noarchive=False, noarchive=False,
optimize=0, optimize=0,
) )
_check_binary_sources(a.binaries, library_roots)
pyz = PYZ(a.pure) pyz = PYZ(a.pure)
exe = EXE( exe = EXE(

View File

@ -10,19 +10,24 @@ LED Mapper — настольное приложение на Python и PySide6
- Отображение схемы в реальных координатах. - Отображение схемы в реальных координатах.
- Выделение точек мышью и работа через таблицу выбранных LED. - Выделение точек мышью и работа через таблицу выбранных LED.
- До 4 физических линий светодиодных лент. - До 4 физических линий светодиодных лент.
- Нумерация линий и блоков с `0`.
- Блоки внутри линий, включая пустые блоки. - Блоки внутри линий, включая пустые блоки.
- Индексация LED отдельно для каждой линии, начиная с `0`. - Индексация LED отдельно для каждой линии, начиная с `0`.
- Индексация внутри каждого блока. - Индексация внутри каждого блока.
- Добавление блоков вручную, из выделения, автоблоками и кистью. - Добавление блоков вручную, из выделения, автоблоками и кистью.
- Продолжение существующего блока кистью от последней точки блока. - Продолжение существующего блока кистью от последней точки блока.
- Предпросмотр зоны действия кисти и точек, которые будут добавлены. - Предпросмотр зоны действия кисти и точек, которые будут добавлены.
- Сетка 34 мм. - Сетка с настраиваемым шагом (по умолчанию 34 мм) во всех режимах редактирования и генерации.
- Undo/redo для операций разметки. - Настраиваемые абсолютные размеры LED-точек и текста их индексов: визуальный размер не меняется при приближении.
- Свободные многострочные текстовые комментарии на схеме.
- Undo/redo для операций разметки и комментариев.
- Удаление назначений через `Del` и контекстные меню. - Удаление назначений через `Del` и контекстные меню.
- Пакетное удаление выбранных блоков через дерево одной операцией с единым Undo.
- Сворачивание боковой панели. - Сворачивание боковой панели.
- Сортировка таблицы выбранных точек по столбцам. - Сортировка таблицы выбранных точек по столбцам.
- Генерация `vm_map.h`, `virtual_matrix_legacy.ino.txt`, `ordered_circles.json`, `validation_report.txt` и README для результата. - Генерация `vm_map.h`, `ordered_circles.json`, `validation_report.txt` и README для результата.
- Сборка самостоятельного Windows EXE через PyInstaller. - Сборка самостоятельного Windows EXE через PyInstaller.
- Проверка и установка Windows-обновлений из релизов Gitea.
## Структура приложения ## Структура приложения
@ -32,6 +37,8 @@ main_window.py Главное окно, меню, действия,
canvas.py Холст схемы, выделение, кисть, сетка и связи canvas.py Холст схемы, выделение, кисть, сетка и связи
models.py Модель карты, линий, блоков и алгоритмы маршрутов models.py Модель карты, линий, блоков и алгоритмы маршрутов
panel_mapping_generator.py Генерация файлов маппинга panel_mapping_generator.py Генерация файлов маппинга
update_service.py Проверка релизов и безопасная установка Windows EXE
version.py Базовая версия приложения при запуске из исходников
icons.py Встроенные lucide-иконки icons.py Встроенные lucide-иконки
unfolding.py Описание развертки линий и блоков unfolding.py Описание развертки линий и блоков
qa_smoke.py Smoke-тесты приложения qa_smoke.py Smoke-тесты приложения
@ -83,8 +90,28 @@ python -m venv .venv
6. Проверьте счетчики в верхней части боковой панели: количество линий, блоков и назначенных LED. 6. Проверьте счетчики в верхней части боковой панели: количество линий, блоков и назначенных LED.
7. Сгенерируйте скрипт через `Файл -> Сгенерировать скрипт`. 7. Сгенерируйте скрипт через `Файл -> Сгенерировать скрипт`.
Шаг сетки, диаметр точек и размер текста индексов меняются в `Файл -> Параметры -> Внешний вид`. Для каждого значения рядом есть кнопка `Сбросить`. Размер текста индексов обновляется на схеме сразу во время настройки; при отмене возвращается прежнее значение. Шаг сетки сохраняется в JSON текущей схемы, а диаметр точек и размер текста индексов — в пользовательских параметрах приложения. Визуальные размеры точек и подписей не меняются при приближении схемы. Изменение шага не перемещает уже существующие LED автоматически.
Чтобы удалить несколько блоков, выделите их в дереве с `Ctrl` или `Shift`, затем нажмите `Ctrl+Del` или выберите удаление в контекстном меню. Приложение показывает одно подтверждение и создаёт одну операцию Undo.
В режиме редактирования выбранные светодиоды можно скопировать через `Ctrl+C` и вставить через `Ctrl+V`. Вставленные LED остаются свободными, сохраняют взаимное расположение и пользовательские поля исходных точек. Первая копия смещается на один шаг сетки вправо и вниз, а следующие вставки образуют каскад. Вся группа вставляется одной операцией Undo и сразу становится текущим выделением.
В собранном Windows EXE приложение проверяет наличие нового стабильного релиза после запуска. В меню `Справка` можно запустить проверку вручную и, если новая версия найдена, выбрать `Установить обновление`. Загружается только артефакт вида `LEDMapper-vX.Y.Z.exe` из репозитория `Robotail/LED-mapper` на `git.robotail.pro`. После проверки размера и формата EXE приложение предлагает сохранить изменённую схему, закрывается, а отдельный процесс заменяет EXE по прежнему пути и запускает новую версию — существующие ярлыки продолжают работать. Версия релизной сборки хранится внутри EXE, поэтому переименование файла не вызывает повторную установку того же обновления. При неудачном запуске старая версия восстанавливается; причина записывается рядом с EXE в `update-error.log`. Для обновления нужны доступ к серверу релизов и право записи в каталог приложения. Из исходников можно проверить наличие версии, но автоустановка недоступна.
Перед загрузкой проверяются права записи в каталог приложения. Перенаправления на страницу входа, другой сайт или неожиданный путь того же сервера отклоняются; объявленный сервером размер файла должен совпадать с размером артефакта в данных релиза.
Если не назначен ни один светодиод, генерация не запускается. Если назначены не все светодиоды, приложение показывает предупреждение и предлагает либо сгенерировать неполный маппинг, либо продолжить редактирование. Если не назначен ни один светодиод, генерация не запускается. Если назначены не все светодиоды, приложение показывает предупреждение и предлагает либо сгенерировать неполный маппинг, либо продолжить редактирование.
## Текстовые комментарии на схеме
Чтобы разместить заметку, нажмите правой кнопкой мыши в нужном месте схемы и выберите `Добавить комментарий…`. Команда `Инструменты -> Добавить комментарий…` создаёт заметку в центре видимой области. Введите многострочный текст и подтвердите ввод.
Комментарий можно свободно перетаскивать левой кнопкой мыши в любом режиме. Двойной щелчок открывает редактирование текста; в контекстном меню комментария доступны изменение и удаление. Размер текста остаётся читаемым при изменении масштаба. Команда `Показать всё` учитывает комментарии, в том числе за пределами LED-схемы.
Добавление, изменение, перемещение и удаление поддерживают Undo/Redo. Отмена диалога и пустой текст не изменяют схему. Комментарии сохраняются в JSON в списке `comments`: `id`, `text`, `x`, `y`; координаты используют единицы схемы. Старые JSON без этого списка открываются без комментариев. Можно сохранить схему, содержащую только комментарии.
Комментарии не привязаны к линиям, блокам или LED и не влияют на физические адреса в `VM_MAPPINGS`. Экспорт изображения маппинга через меню `Файл` по-прежнему показывает LED и легенду; текстовые комментарии сохраняются в JSON и отображаются в редакторе.
## Горячие клавиши ## Горячие клавиши
| Действие | Клавиши | | Действие | Клавиши |
@ -95,11 +122,14 @@ python -m venv .venv
| Сгенерировать скрипт | `Ctrl+G` | | Сгенерировать скрипт | `Ctrl+G` |
| Отменить | `Ctrl+Z` | | Отменить | `Ctrl+Z` |
| Повторить | `Ctrl+Y` | | Повторить | `Ctrl+Y` |
| Копировать выбранные LED | `Ctrl+C` |
| Вставить скопированные LED | `Ctrl+V` |
| Удалить фиксацию | `Del` | | Удалить фиксацию | `Del` |
| Удалить выбранные точки или блоки | `Ctrl+Del` |
| Удалить все | `Ctrl+Shift+Del` | | Удалить все | `Ctrl+Shift+Del` |
| Очистить выбор | `Esc` | | Очистить выбор | `Esc` |
| Показать всю схему | `Ctrl+0` | | Показать всю схему | `Ctrl+0` |
| Сетка 34 мм | `G` | | Сетка | `G` |
| Стрелки направления | `Ctrl+Shift+A` | | Стрелки направления | `Ctrl+Shift+A` |
| Скрыть/показать боковую панель | `F9` | | Скрыть/показать боковую панель | `F9` |
| Новая линия | `Ctrl+L` | | Новая линия | `Ctrl+L` |
@ -111,14 +141,19 @@ python -m venv .venv
Генератор создает: Генератор создает:
- `vm_map.h` — основной header для прошивки. - `vm_map.h` — основной header для прошивки.
- `virtual_matrix_legacy.ino.txt` — читаемая legacy-версия с `setLEDs(...)`.
- `ordered_circles.json` — нормализованные точки и координаты виртуальной матрицы. - `ordered_circles.json` — нормализованные точки и координаты виртуальной матрицы.
- `validation_report.txt` — отчет о проверках маршрутов и ячеек. - `validation_report.txt` — отчет о проверках маршрутов и ячеек.
- `README.md` — краткое описание результата генерации. - `README.md` — краткое описание результата генерации.
Устаревший `virtual_matrix_legacy.ino.txt` больше не создаётся. Если файл с этим точным именем остался в выходной папке от прежней версии, следующая успешная генерация удалит его. Другие `.ino`-файлы не затрагиваются.
JSON схемы хранит `coordinate_unit`, `grid_step_mm` и `index_base: 0`. При первом открытии старого файла без единицы координат приложение определяет её один раз и затем сохраняет явно. Старые схемы с 1-based ID линий и блоков при загрузке переводятся на 0-based; сохранение записывает маркер `index_base: 0`, чтобы повторная загрузка не сдвигала ID. Сохранение выполняется через временный файл и атомарную замену, поэтому исходный JSON не повреждается при ошибке записи.
Если у схемы есть подложка, при сохранении исходное изображение встраивается в секцию `background.embedded_image` внутри JSON вместе с именем, MIME-типом и контрольной суммой. Диалог выбора показывает все форматы, которые доступны в поставляемой версии Qt (включая PNG, JPEG/JFIF, BMP, GIF, WebP, TIFF и SVG), распознаёт содержимое независимо от регистра и расширения файла и учитывает EXIF-ориентацию. Большие изображения безопасно уменьшаются для отображения. Если изображение повреждено или его декодер недоступен, приложение показывает причину и сохраняет прежнюю подложку. После сохранения схема не зависит от внешнего файла, а размеры и параметры подложки сохраняются при редактировании и переносе JSON. Для уже встроенного и не заменённого изображения диалог `Подложка` показывает в поле пути значение `Встроенная`. Чтобы заменить его, нажмите `Выбрать` и укажите новый файл; новое изображение будет встроено при следующем сохранении схемы.
В `vm_map.h` используются физические адреса из GUI: В `vm_map.h` используются физические адреса из GUI:
- `strip = line_id - 1` - `strip = line_id`
- `led_id = line_index` - `led_id = line_index`
Неназначенные точки не попадают в рабочий `VM_MAPPINGS`, но исходные точки могут оставаться в служебных метаданных. Неназначенные точки не попадают в рабочий `VM_MAPPINGS`, но исходные точки могут оставаться в служебных метаданных.
@ -162,10 +197,12 @@ python -m venv .venv
.\.venv\Scripts\python.exe -m PyInstaller --noconfirm --clean app\LEDMapper.spec .\.venv\Scripts\python.exe -m PyInstaller --noconfirm --clean app\LEDMapper.spec
``` ```
Готовый файл появится здесь: Сборочный `spec` ограничивает поиск DLL каталогами Python, виртуального окружения и Windows. Это защищает EXE от несовместимых системных библиотек сторонних программ, добавленных в `PATH`. После сборки проверяйте готовый файл командой `dist\LEDMapper-vX.Y.Z.exe --verify-bundle`: успешная проверка завершается с кодом `0` без открытия окна приложения.
В чистой сборке из релизного тега файл будет называться:
```text ```text
dist/LEDMapper.exe dist/LEDMapper-vX.Y.Z.exe
``` ```
`build/` и `dist/` не коммитятся, потому что это воспроизводимые артефакты сборки. `build/` и `dist/` не коммитятся, потому что это воспроизводимые артефакты сборки.

View File

@ -1 +1,3 @@
"""LED Mapper Editor application package.""" """LED Mapper Editor application package."""
from .version import __version__

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 10 10"><path fill="#d4d4d4" d="M2 3.5h6L5 7z"/></svg>

After

Width:  |  Height:  |  Size: 130 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 10 10"><path fill="#475569" d="M2 3.5h6L5 7z"/></svg>

After

Width:  |  Height:  |  Size: 130 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 10 10"><path fill="#d4d4d4" d="M5 3 8 6.5H2z"/></svg>

After

Width:  |  Height:  |  Size: 130 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 10 10"><path fill="#475569" d="M5 3 8 6.5H2z"/></svg>

After

Width:  |  Height:  |  Size: 130 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8"><path fill="#a3a3a3" d="M4 7 1 2h6z"/></svg>

After

Width:  |  Height:  |  Size: 124 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8"><path fill="#64748b" d="M4 7 1 2h6z"/></svg>

After

Width:  |  Height:  |  Size: 124 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8"><path fill="#a3a3a3" d="M1 4 6 1v6z"/></svg>

After

Width:  |  Height:  |  Size: 124 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8"><path fill="#64748b" d="M1 4 6 1v6z"/></svg>

After

Width:  |  Height:  |  Size: 124 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8"><path fill="#a3a3a3" d="M7 4 2 7V1z"/></svg>

After

Width:  |  Height:  |  Size: 124 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8"><path fill="#64748b" d="M7 4 2 7V1z"/></svg>

After

Width:  |  Height:  |  Size: 124 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8"><path fill="#a3a3a3" d="M4 1 7 6H1z"/></svg>

After

Width:  |  Height:  |  Size: 124 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8"><path fill="#64748b" d="M4 1 7 6H1z"/></svg>

After

Width:  |  Height:  |  Size: 124 B

127
background_dialog.py Normal file
View File

@ -0,0 +1,127 @@
from __future__ import annotations
from pathlib import Path
from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QCheckBox,
QDoubleSpinBox,
QFileDialog,
QFormLayout,
QHBoxLayout,
QLineEdit,
QPushButton,
QWidget,
)
try:
from .background_image_loader import supported_image_filter
from .localization import localize_dialog_buttons
from .models import BackgroundImage
except ImportError:
from background_image_loader import supported_image_filter
from localization import localize_dialog_buttons
from models import BackgroundImage
class BackgroundDialog(QDialog):
EMBEDDED_PATH_LABEL = "Встроенная"
def __init__(self, background: BackgroundImage, parent=None) -> None: # noqa: ANN001
super().__init__(parent)
self.setWindowTitle("Подложка")
self.setMinimumWidth(460)
self._source_background = background
self._keep_embedded_image = background.has_embedded_image()
self.path = QLineEdit(self.EMBEDDED_PATH_LABEL if self._keep_embedded_image else background.path)
self.path.setPlaceholderText("Файл изображения")
self.path.setReadOnly(self._keep_embedded_image)
if self._keep_embedded_image:
source_name = background.embedded_image_name or "изображение"
self.path.setToolTip(f"{source_name} встроено в JSON схемы")
browse = QPushButton("Выбрать")
browse.clicked.connect(self._browse)
path_row = QWidget()
path_layout = QHBoxLayout(path_row)
path_layout.setContentsMargins(0, 0, 0, 0)
path_layout.addWidget(self.path, 1)
path_layout.addWidget(browse)
self.offset_x = self._spin(-100000.0, 100000.0, background.offset_x, "")
self.offset_y = self._spin(-100000.0, 100000.0, background.offset_y, "")
self.scale_x = self._spin(1.0, 10000.0, background.scale_x_percent, " %")
self.scale_y = self._spin(1.0, 10000.0, background.scale_y_percent, " %")
self.opacity = self._spin(0.0, 100.0, background.opacity_percent, " %")
self.visible = QCheckBox("Показывать подложку")
self.visible.setChecked(background.visible)
reset_scale = QPushButton("100%")
reset_scale.clicked.connect(self._reset_scale)
scale_row = QWidget()
scale_layout = QHBoxLayout(scale_row)
scale_layout.setContentsMargins(0, 0, 0, 0)
scale_layout.addWidget(self.scale_x, 1)
scale_layout.addWidget(self.scale_y, 1)
scale_layout.addWidget(reset_scale)
form = QFormLayout(self)
form.addRow("Изображение", path_row)
form.addRow("Смещение центра X", self.offset_x)
form.addRow("Смещение центра Y", self.offset_y)
form.addRow("Масштаб X / Y", scale_row)
form.addRow("Прозрачность", self.opacity)
form.addRow("", self.visible)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
localize_dialog_buttons(buttons)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
form.addRow(buttons)
def background(self) -> BackgroundImage:
embedded = self._source_background if self._keep_embedded_image else BackgroundImage()
return BackgroundImage(
path="" if self._keep_embedded_image else self.path.text().strip(),
offset_x=self.offset_x.value(),
offset_y=self.offset_y.value(),
scale_x_percent=self.scale_x.value(),
scale_y_percent=self.scale_y.value(),
opacity_percent=self.opacity.value(),
visible=self.visible.isChecked(),
embedded_image_base64=embedded.embedded_image_base64,
embedded_image_mime_type=embedded.embedded_image_mime_type,
embedded_image_name=embedded.embedded_image_name,
embedded_image_sha256=embedded.embedded_image_sha256,
)
def _reset_scale(self) -> None:
self.scale_x.setValue(100.0)
self.scale_y.setValue(100.0)
def _browse(self) -> None:
start = "" if self._keep_embedded_image else self.path.text().strip()
start_path = Path(start).parent if start else Path.home()
selected, _ = QFileDialog.getOpenFileName(
self,
"Выбрать подложку",
str(start_path),
supported_image_filter(),
)
if selected:
self._set_external_path(selected)
def _set_external_path(self, path: str) -> None:
self._keep_embedded_image = False
self.path.setReadOnly(False)
self.path.setToolTip("")
self.path.setText(path)
@staticmethod
def _spin(minimum: float, maximum: float, value: float, suffix: str) -> QDoubleSpinBox:
spin = QDoubleSpinBox()
spin.setRange(minimum, maximum)
spin.setDecimals(2)
spin.setSingleStep(1.0)
spin.setValue(value)
spin.setSuffix(suffix)
return spin

147
background_image_loader.py Normal file
View File

@ -0,0 +1,147 @@
from __future__ import annotations
from dataclasses import dataclass
import math
from pathlib import Path
from PySide6.QtCore import QByteArray, QBuffer, QIODevice, QSize, Qt
from PySide6.QtGui import QImage, QImageIOHandler, QImageReader
try:
from .models import BackgroundImage
except ImportError:
from models import BackgroundImage
MAX_BACKGROUND_IMAGE_SIDE = 8192
MAX_BACKGROUND_IMAGE_PIXELS = 32_000_000
MAX_UNSCALED_SOURCE_PIXELS = 100_000_000
@dataclass(frozen=True)
class BackgroundImageLoadResult:
image: QImage
error: str = ""
@property
def loaded(self) -> bool:
return not self.image.isNull()
def supported_image_filter() -> str:
formats = sorted(
{
bytes(image_format).decode("ascii", errors="ignore").lower()
for image_format in QImageReader.supportedImageFormats()
if image_format
}
)
wildcards: list[str] = []
for image_format in formats:
if not image_format or image_format == "pdf":
continue
wildcards.extend((f"*.{image_format}", f"*.{image_format.upper()}"))
patterns = " ".join(dict.fromkeys(wildcards))
return f"Изображения ({patterns});;Все файлы (*.*)"
def resolve_background_path(background: BackgroundImage, document_path: Path | None = None) -> Path:
path = Path(background.path)
if not path.is_absolute() and document_path is not None:
path = document_path.parent / path
return path
def background_cache_key(background: BackgroundImage, document_path: Path | None = None) -> tuple[object, ...]:
if background.has_embedded_image():
image_bytes = background.embedded_image_bytes()
return ("embedded", background.embedded_image_sha256 or hash(image_bytes), len(image_bytes))
path = resolve_background_path(background, document_path)
try:
stat = path.stat()
except OSError:
return ("file", str(path), None, None)
return ("file", str(path), stat.st_mtime_ns, stat.st_size)
def _bounded_image_size(size: QSize, max_side: int) -> QSize:
if not size.isValid() or max_side <= 0:
return size
side_factor = min(1.0, max_side / max(size.width(), size.height()))
pixel_factor = min(1.0, math.sqrt(MAX_BACKGROUND_IMAGE_PIXELS / (size.width() * size.height())))
factor = min(side_factor, pixel_factor)
if factor >= 1.0:
return size
return QSize(max(1, math.floor(size.width() * factor)), max(1, math.floor(size.height() * factor)))
def load_background_image(
background: BackgroundImage,
document_path: Path | None = None,
*,
max_side: int = MAX_BACKGROUND_IMAGE_SIDE,
) -> BackgroundImageLoadResult:
buffer: QBuffer | None = None
if background.has_embedded_image():
image_bytes = background.embedded_image_bytes()
if not image_bytes:
return BackgroundImageLoadResult(QImage(), "Встроенные данные изображения повреждены или пусты.")
buffer = QBuffer()
buffer.setData(QByteArray(image_bytes))
if not buffer.open(QIODevice.OpenModeFlag.ReadOnly):
return BackgroundImageLoadResult(QImage(), "Не удалось открыть встроенные данные изображения.")
reader = QImageReader(buffer)
source_name = background.embedded_image_name or "встроенное изображение"
else:
path = resolve_background_path(background, document_path)
if not path.is_file():
return BackgroundImageLoadResult(QImage(), f"Файл не найден: {path}")
reader = QImageReader(str(path))
source_name = path.name
previous_allocation_limit: int | None = None
try:
reader.setAutoTransform(True)
reader.setDecideFormatFromContent(True)
size = reader.size()
bounded_size = _bounded_image_size(size, max_side)
if bounded_size != size:
if not reader.supportsOption(QImageIOHandler.ImageOption.ScaledSize):
source_pixels = size.width() * size.height()
if source_pixels > MAX_UNSCALED_SOURCE_PIXELS:
return BackgroundImageLoadResult(
QImage(),
f"Изображение «{source_name}» слишком большое для безопасного декодирования "
f"({size.width()}×{size.height()} пикселей).",
)
current_allocation_limit = QImageReader.allocationLimit()
required_megabytes = math.ceil(source_pixels * 8 / (1024 * 1024)) + 16
if current_allocation_limit > 0 and required_megabytes > current_allocation_limit:
previous_allocation_limit = current_allocation_limit
QImageReader.setAllocationLimit(required_megabytes)
reader.setScaledSize(bounded_size)
image = reader.read()
if image.isNull():
details = reader.errorString().strip()
message = f"Не удалось декодировать «{source_name}»"
if details:
message += f": {details}"
return BackgroundImageLoadResult(QImage(), message + ".")
bounded_size = _bounded_image_size(image.size(), max_side)
if bounded_size != image.size():
image = image.scaled(
bounded_size,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
if image.format() not in (QImage.Format.Format_ARGB32, QImage.Format.Format_ARGB32_Premultiplied):
image = image.convertToFormat(QImage.Format.Format_ARGB32_Premultiplied)
if image.isNull():
return BackgroundImageLoadResult(QImage(), f"Не удалось преобразовать «{source_name}» для отображения.")
return BackgroundImageLoadResult(image)
finally:
if previous_allocation_limit is not None:
QImageReader.setAllocationLimit(previous_allocation_limit)
if buffer is not None:
buffer.close()

1733
canvas.py

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,13 @@
# Свободные текстовые комментарии — план
Цель: свободные многострочные заметки на схеме, согласованные пользователем 05.11.2026.
Архитектура: отдельная коллекция `comments` документа с устойчивым ID, текстом и координатами в единицах документа. Холст отвечает за отображение и жесты, главное окно — за диалог и историю. Существующие файлы без comments загружаются с пустой коллекцией; неизвестные поля сохраняются. Физический маппинг не изменяется.
Варианты: привязка к LED/линиям отклонена пользователем; выбрано свободное расположение. Техническое решение: встроить объекты в существующий QGraphicsView и deepcopy-историю без новых зависимостей.
- [x] Модель: тест round-trip Unicode, отрицательных/дробных координат, неизвестных полей и старого JSON; добавить TextComment и CRUD.
- [x] Интерфейс: контекстное меню добавления/изменения/удаления, двойной щелчок, перетаскивание. Проверить Undo/Redo, отмену/пустой текст, пустую схему, масштаб и единицы m/mm.
- [x] Документация и проверка: README, полный smoke-набор, py_compile, интерактивные события Qt и рендер холста. Сохранить фактические результаты в AgentMemory.
Ограничения: использовать .venv; сохранить существующие пользовательские изменения; не создавать коммиты и релизы. Обнаружена грязная рабочая копия с HEAD 3699586 — это отличается от исторической записи памяти о релизе v3.4.0; version.py содержит 3.4.0. Работа ведётся поверх текущих файлов без отката.

View File

@ -10,6 +10,8 @@ LUCIDE_PATHS = {
"arrow-right": '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>', "arrow-right": '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>',
"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"/>', "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"/>', "chevron-right": '<path d="m9 18 6-6-6-6"/>',
"clipboard-paste": '<rect width="8" height="4" x="8" y="2" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v5M8 4H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h6"/><path d="m16 19 3-3-3-3"/><path d="M19 16h-7"/>',
"copy": '<rect width="14" height="14" x="8" y="8" rx="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
"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"/>', "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"/>', "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-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"/>',
@ -17,16 +19,27 @@ LUCIDE_PATHS = {
"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"/>', "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"/>',
"git-branch": '<line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>', "git-branch": '<line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>',
"grid-2x2": '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 12h18"/><path d="M12 3v18"/>', "grid-2x2": '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 12h18"/><path d="M12 3v18"/>',
"image": '<rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/>',
"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"/>', "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"/>', "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"/>', "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"/>', "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"/>', "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-ccw": '<path d="M3 12a9 9 0 1 0 2.64-6.36L3 8"/><path d="M3 3v5h5"/>',
"rotate-cw": '<path d="M21 12a9 9 0 1 1-2.64-6.36L21 8"/><path d="M21 3v5h-5"/>', "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": '<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"/>', "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"/>',
"settings": '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.09a2 2 0 0 1-1-1.74v-.51a2 2 0 0 1 1-1.72l.15-.1a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
"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"/>', "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"/>', "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"/>',
"pencil": '<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
"mouse-pointer": '<path d="m3 3 7.07 16.97 2.51-7.39 7.39-2.51L3 3z"/><path d="m13 13 6 6"/>',
"move": '<path d="m5 9-3 3 3 3M9 5l3-3 3 3M15 19l-3 3-3-3M19 9l3 3-3 3M2 12h20M12 2v20"/>',
"plus-circle": '<circle cx="12" cy="12" r="10"/><path d="M8 12h8M12 8v8"/>',
"align-horizontal": '<line x1="2" x2="22" y1="12" y2="12"/><rect width="4" height="6" x="5" y="9" rx="1"/><rect width="4" height="6" x="15" y="9" rx="1"/>',
"align-vertical": '<line x1="12" x2="12" y1="2" y2="22"/><rect width="6" height="4" x="9" y="5" rx="1"/><rect width="6" height="4" x="9" y="15" rx="1"/>',
"grid-horizontal": '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18M15 3v18"/>',
"grid-vertical": '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18M3 15h18"/>',
} }

98
localization.py Normal file
View File

@ -0,0 +1,98 @@
from __future__ import annotations
from PySide6.QtWidgets import QDialogButtonBox, QMessageBox, QWidget
_DIALOG_BUTTON_TEXTS = {
QDialogButtonBox.StandardButton.Ok: "ОК",
QDialogButtonBox.StandardButton.Cancel: "Отмена",
QDialogButtonBox.StandardButton.Yes: "Да",
QDialogButtonBox.StandardButton.No: "Нет",
QDialogButtonBox.StandardButton.Save: "Сохранить",
QDialogButtonBox.StandardButton.SaveAll: "Сохранить все",
QDialogButtonBox.StandardButton.Open: "Открыть",
QDialogButtonBox.StandardButton.Close: "Закрыть",
QDialogButtonBox.StandardButton.Apply: "Применить",
QDialogButtonBox.StandardButton.Reset: "Сбросить",
QDialogButtonBox.StandardButton.RestoreDefaults: "По умолчанию",
QDialogButtonBox.StandardButton.Discard: "Не сохранять",
QDialogButtonBox.StandardButton.Abort: "Прервать",
QDialogButtonBox.StandardButton.Retry: "Повторить",
QDialogButtonBox.StandardButton.Ignore: "Игнорировать",
}
_MESSAGE_BUTTON_TEXTS = {
QMessageBox.StandardButton.Ok: "ОК",
QMessageBox.StandardButton.Cancel: "Отмена",
QMessageBox.StandardButton.Yes: "Да",
QMessageBox.StandardButton.No: "Нет",
QMessageBox.StandardButton.Save: "Сохранить",
QMessageBox.StandardButton.SaveAll: "Сохранить все",
QMessageBox.StandardButton.Open: "Открыть",
QMessageBox.StandardButton.Close: "Закрыть",
QMessageBox.StandardButton.Apply: "Применить",
QMessageBox.StandardButton.Reset: "Сбросить",
QMessageBox.StandardButton.RestoreDefaults: "По умолчанию",
QMessageBox.StandardButton.Discard: "Не сохранять",
QMessageBox.StandardButton.Abort: "Прервать",
QMessageBox.StandardButton.Retry: "Повторить",
QMessageBox.StandardButton.Ignore: "Игнорировать",
}
def localize_dialog_buttons(button_box: QDialogButtonBox) -> None:
for button in button_box.buttons():
standard_button = button_box.standardButton(button)
text = _DIALOG_BUTTON_TEXTS.get(standard_button)
if text:
button.setText(text)
def localize_message_box(message_box: QMessageBox) -> None:
for button in message_box.buttons():
standard_button = message_box.standardButton(button)
text = _MESSAGE_BUTTON_TEXTS.get(standard_button)
if text:
button.setText(text)
def _show_message(
parent: QWidget | None,
icon: QMessageBox.Icon,
title: str,
text: str,
buttons: QMessageBox.StandardButton = QMessageBox.StandardButton.Ok,
default_button: QMessageBox.StandardButton = QMessageBox.StandardButton.NoButton,
) -> QMessageBox.StandardButton:
message_box = QMessageBox(parent)
message_box.setIcon(icon)
message_box.setWindowTitle(title)
message_box.setText(text)
message_box.setStandardButtons(buttons)
if default_button != QMessageBox.StandardButton.NoButton:
message_box.setDefaultButton(default_button)
localize_message_box(message_box)
return QMessageBox.StandardButton(message_box.exec())
def show_information(parent: QWidget | None, title: str, text: str) -> QMessageBox.StandardButton:
return _show_message(parent, QMessageBox.Icon.Information, title, text)
def show_warning(parent: QWidget | None, title: str, text: str) -> QMessageBox.StandardButton:
return _show_message(parent, QMessageBox.Icon.Warning, title, text)
def show_critical(parent: QWidget | None, title: str, text: str) -> QMessageBox.StandardButton:
return _show_message(parent, QMessageBox.Icon.Critical, title, text)
def ask_question(parent: QWidget | None, title: str, text: str) -> QMessageBox.StandardButton:
return _show_message(
parent,
QMessageBox.Icon.Question,
title,
text,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)

16
main.py
View File

@ -3,6 +3,7 @@ from __future__ import annotations
import sys import sys
from pathlib import Path from pathlib import Path
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
APP_DIR = Path(__file__).resolve().parent APP_DIR = Path(__file__).resolve().parent
@ -12,18 +13,31 @@ if str(APP_DIR) not in sys.path:
try: try:
from .icons import lucide_icon from .icons import lucide_icon
from .main_window import MainWindow from .main_window import MainWindow
from .settings import AppSettings
from .theme import apply_app_theme, effective_theme
from .update_service import signal_update_ready
except ImportError: except ImportError:
from icons import lucide_icon from icons import lucide_icon
from main_window import MainWindow from main_window import MainWindow
from settings import AppSettings
from theme import apply_app_theme, effective_theme
from update_service import signal_update_ready
def main() -> int: def main() -> int:
app = QApplication(sys.argv) app = QApplication(sys.argv)
app_icon = lucide_icon("git-branch", "#e5e7eb", 256) if "--verify-bundle" in sys.argv:
app.processEvents()
return 0
settings = AppSettings.load()
apply_app_theme(settings.theme)
icon_color = "#111827" if effective_theme(settings.theme) == "light" else "#e5e7eb"
app_icon = lucide_icon("git-branch", icon_color, 256)
app.setWindowIcon(app_icon) app.setWindowIcon(app_icon)
window = MainWindow() window = MainWindow()
window.setWindowIcon(app_icon) window.setWindowIcon(app_icon)
window.show() window.show()
QTimer.singleShot(500, signal_update_ready)
return app.exec() return app.exec()

File diff suppressed because it is too large Load Diff

503
mapping_renderer.py Normal file
View File

@ -0,0 +1,503 @@
"""Standalone high-quality mapping image renderer.
Draws the LED mapping directly from MappingDocument data, completely
independent of the Qt scene/canvas so no UI labels or zoom state leak in.
Layout
------
* Top part : LED mapping (square canvas)
* Bottom part: colour legend arranged left-to-right for lines,
with blocks listed top-to-bottom inside each column.
Connections
-----------
* Inner connections (within a block): solid line, full opacity.
* Bridge connections (between consecutive blocks of the same line): dashed
line, slightly thinner, same colour.
* Overlapping segments are deflected with a quadratic Bezier (same
algorithm as the Qt canvas).
* Mid-segment arrow heads indicate direction.
Badges
------
* Start badge (green, above dot) : first LED of a block.
* End badge (red, below dot) : last LED of a block.
* Single badge (blue, above dot) : block with exactly one LED.
* An anti-overlap pass shifts badges that would otherwise overlap.
"""
from __future__ import annotations
import math
from pathlib import Path
from typing import TYPE_CHECKING
from PySide6.QtCore import QPointF, QRectF, Qt
from PySide6.QtGui import (
QBrush,
QColor,
QFont,
QFontMetricsF,
QImage,
QPainter,
QPainterPath,
QPen,
)
if TYPE_CHECKING:
try:
from models import MappingDocument
except ImportError:
from .models import MappingDocument # type: ignore[no-redef]
# ---------------------------------------------------------------------------
# Layout constants
# ---------------------------------------------------------------------------
MAPPING_MAX = 4000 # longest side of mapping area (pixels)
LEGEND_HEIGHT = 480 # legend strip at the bottom
PADDING_FRAC = 0.04 # % padding inside mapping area
# Legend layout
LEGEND_H_PAD = 60 # horizontal padding of legend area
LEGEND_V_PAD = 28 # vertical padding of legend area
LEGEND_COL_GAP = 24 # gap between line columns
LEGEND_SWATCH_W = 22
LEGEND_SWATCH_H = 22
LEGEND_LINE_ROW = 38 # height of each line header row
LEGEND_BLOCK_ROW = 26 # height of each block row
# Colours
DARK_BG = "#0f172a"
DARK_LEGEND_BG = "#111827"
DIVIDER = "#1e3a5f"
DARK_TITLE = "#f8fafc"
DARK_LINE_TEXT = "#f1f5f9"
DARK_BLOCK_TEXT = "#94a3b8"
UNASSIGNED = "#3b4f6b"
BADGE_START = "#10b981"
BADGE_END = "#ef4444"
BADGE_SINGLE = "#3b82f6"
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _overlap_control_point(
start: QPointF,
end: QPointF,
segments: list[tuple[QPointF, QPointF]],
base_scale: float = 1.0,
) -> QPointF | None:
"""Return a quadratic Bezier control point if *start-end* overlaps an
existing segment, otherwise None (straight line)."""
dx = end.x() - start.x()
dy = end.y() - start.y()
length = math.hypot(dx, dy)
if length < 0.001:
return None
unit_ax = dx / length
unit_ay = dy / length
previous_count = 0
for prev_s, prev_e in segments:
# Parallel check
pdx = prev_e.x() - prev_s.x()
pdy = prev_e.y() - prev_s.y()
plen = math.hypot(pdx, pdy)
if plen < 0.001:
continue
unit_bx = pdx / plen
unit_by = pdy / plen
if abs(unit_ax * unit_bx + unit_ay * unit_by) < 0.985:
continue
# Perpendicular distance
d0 = abs((prev_s.x() - start.x()) * unit_ay - (prev_s.y() - start.y()) * unit_ax)
d1 = abs((prev_e.x() - start.x()) * unit_ay - (prev_e.y() - start.y()) * unit_ax)
if max(d0, d1) > 5.0 * base_scale:
continue
# Overlap projection
b0 = (prev_s.x() - start.x()) * unit_ax + (prev_s.y() - start.y()) * unit_ay
b1 = (prev_e.x() - start.x()) * unit_ax + (prev_e.y() - start.y()) * unit_ay
ov_s = max(0.0, min(b0, b1))
ov_e = min(length, max(b0, b1))
if (ov_e - ov_s) >= min(10.0 * base_scale, min(length, plen) * 0.35):
previous_count += 1
segments.append((QPointF(start), QPointF(end)))
if previous_count == 0:
return None
bend = min(16.0 * base_scale, max(7.0 * base_scale, length * 0.24))
side = 1.0 if previous_count % 2 == 1 else -1.0
layer = 1.0 + (previous_count - 1) // 2
offset = side * bend * layer
perp_x = -dy / length
perp_y = dx / length
mid_x = (start.x() + end.x()) / 2
mid_y = (start.y() + end.y()) / 2
return QPointF(mid_x + perp_x * offset, mid_y + perp_y * offset)
def _append_connection(
path: QPainterPath,
start: QPointF,
end: QPointF,
segments: list[tuple[QPointF, QPointF]],
base_scale: float = 1.0,
) -> QPointF | None:
path.moveTo(start)
control = _overlap_control_point(start, end, segments, base_scale)
if control is None:
path.lineTo(end)
else:
path.quadTo(control, end)
return control
def _append_arrow(
path: QPainterPath,
start: QPointF,
end: QPointF,
control: QPointF | None,
base_scale: float = 1.0,
) -> None:
dx = end.x() - start.x()
dy = end.y() - start.y()
length = math.hypot(dx, dy)
if length < 8.0 * base_scale:
return
t = 0.55
if control is None:
unit_x = dx / length
unit_y = dy / length
mid_x = start.x() + dx * t
mid_y = start.y() + dy * t
else:
inv_t = 1.0 - t
mid_x = inv_t*inv_t*start.x() + 2*inv_t*t*control.x() + t*t*end.x()
mid_y = inv_t*inv_t*start.y() + 2*inv_t*t*control.y() + t*t*end.y()
tan_x = 2*inv_t*(control.x()-start.x()) + 2*t*(end.x()-control.x())
tan_y = 2*inv_t*(control.y()-start.y()) + 2*t*(end.y()-control.y())
tl = math.hypot(tan_x, tan_y)
if tl < 0.001:
return
unit_x = tan_x / tl
unit_y = tan_y / tl
alen = min(7.0 * base_scale, length * 0.28)
awid = alen * 0.55
base_x = mid_x - unit_x * alen
base_y = mid_y - unit_y * alen
perp_x = -unit_y
perp_y = unit_x
path.moveTo(QPointF(base_x + perp_x*awid, base_y + perp_y*awid))
path.lineTo(QPointF(mid_x, mid_y))
path.lineTo(QPointF(base_x - perp_x*awid, base_y - perp_y*awid))
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def render_mapping_image(doc: "MappingDocument", file_path: str | Path, theme: str = "dark") -> bool:
"""Render *doc* to *file_path* (PNG or JPEG). Returns True on success."""
if not doc.points:
return False
is_dark = theme == "dark"
bg_color = QColor(DARK_BG if is_dark else "#ffffff")
legend_bg_color = QColor(DARK_LEGEND_BG if is_dark else "#f8fafc")
divider_color = QColor(DIVIDER if is_dark else "#e2e8f0")
line_text_color = QColor(DARK_LINE_TEXT if is_dark else "#0f172a")
block_text_color = QColor(DARK_BLOCK_TEXT if is_dark else "#475569")
unassigned_color = QColor(UNASSIGNED if is_dark else "#cbd5e1")
# Scale factor based on resolution
base_scale = MAPPING_MAX / 2200.0
# ── 1. World bounds ─────────────────────────────────────────────────────
xs = [p.x for p in doc.points]
ys = [p.y for p in doc.points]
min_x, max_x = min(xs), max(xs)
min_y, max_y = min(ys), max(ys)
world_w = max(max_x - min_x, 1.0)
world_h = max(max_y - min_y, 1.0)
pad_x = world_w * PADDING_FRAC
pad_y = world_h * PADDING_FRAC
draw_x0 = min_x - pad_x
draw_y0 = min_y - pad_y
draw_w = world_w + 2 * pad_x
draw_h = world_h + 2 * pad_y
# Compute actual pixel map dimensions (preserve aspect ratio)
aspect = draw_w / draw_h
if aspect >= 1.0:
map_px_w = MAPPING_MAX
map_px_h = max(200, round(MAPPING_MAX / aspect))
else:
map_px_h = MAPPING_MAX
map_px_w = max(200, round(MAPPING_MAX * aspect))
scale = map_px_w / draw_w # == map_px_h / draw_h
def to_px(x: float, y: float) -> QPointF:
px = (x - draw_x0) * scale
py = ((max_y + pad_y) - y) * scale # flip y
return QPointF(px, py)
dot_r = max(5.0 * base_scale, min(14.0 * base_scale, scale * 1.6))
# ── 2. Lookups ───────────────────────────────────────────────────────────
point_by_uid: dict[int, object] = {p.uid: p for p in doc.points}
point_line: dict[int, object] = {}
badge_info: dict[int, tuple[str, str]] = {} # uid → (text, badge_type)
for line in doc.lines:
cur = 0
for block in line.blocks:
n = len(block.point_ids)
if n == 0:
continue
s_uid = block.point_ids[0]
e_uid = block.point_ids[-1]
for uid in block.point_ids:
point_line[uid] = line
if n == 1:
badge_info[s_uid] = (f"#{cur}", "single")
else:
badge_info[s_uid] = (f"#{cur}", "start")
badge_info[e_uid] = (f"#{cur + n - 1}", "end")
cur += n
# 3. Pre-compute legend height so image is created at the right size
legend_line_row = LEGEND_LINE_ROW * base_scale
legend_block_row = LEGEND_BLOCK_ROW * base_scale
legend_v_pad = LEGEND_V_PAD * base_scale
legend_h_pad = LEGEND_H_PAD * base_scale
legend_col_gap = LEGEND_COL_GAP * base_scale
legend_swatch_w = LEGEND_SWATCH_W * base_scale
legend_swatch_h = LEGEND_SWATCH_H * base_scale
max_col_h_pre = max(
(legend_line_row + len(ln.blocks) * legend_block_row for ln in doc.lines),
default=0,
)
legend_height_pre = legend_v_pad * 2 + max(max_col_h_pre, 60.0 * base_scale)
img_w = map_px_w
img_h = map_px_h + round(legend_height_pre)
image = QImage(img_w, img_h, QImage.Format.Format_ARGB32)
image.fill(bg_color)
painter = QPainter(image)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setRenderHint(QPainter.RenderHint.TextAntialiasing)
# ── 4. Connections ───────────────────────────────────────────────────────
segments: list[tuple[QPointF, QPointF]] = []
line_w_inner = max(1.2 * base_scale, dot_r * 0.30)
line_w_bridge = max(1.0 * base_scale, dot_r * 0.20)
line_w_arrow = max(1.0 * base_scale, dot_r * 0.22)
for line in doc.lines:
lc = QColor(line.color)
# Inner paths
inner_path = QPainterPath()
arrow_path = QPainterPath()
for block in line.blocks:
pts = [point_by_uid.get(uid) for uid in block.point_ids]
pts = [p for p in pts if p is not None]
for a, b in zip(pts, pts[1:]):
s = to_px(a.x, a.y) # type: ignore[union-attr]
e = to_px(b.x, b.y) # type: ignore[union-attr]
ctrl = _append_connection(inner_path, s, e, segments, base_scale)
_append_arrow(arrow_path, s, e, ctrl, base_scale)
c_inner = QColor(lc)
c_inner.setAlphaF(0.85)
pen = QPen(c_inner, line_w_inner)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawPath(inner_path)
c_arrow = QColor(lc)
c_arrow.setAlphaF(0.95)
pen_a = QPen(c_arrow, line_w_arrow)
pen_a.setCapStyle(Qt.PenCapStyle.RoundCap)
pen_a.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
painter.setPen(pen_a)
painter.drawPath(arrow_path)
# Bridge paths (between blocks)
bridge_path = QPainterPath()
b_arrow_path = QPainterPath()
prev_end_pt = None
for block in line.blocks:
if not block.point_ids:
continue
first_pt = point_by_uid.get(block.point_ids[0])
last_pt = point_by_uid.get(block.point_ids[-1])
if first_pt is not None and prev_end_pt is not None:
s = to_px(prev_end_pt.x, prev_end_pt.y) # type: ignore[union-attr]
e = to_px(first_pt.x, first_pt.y) # type: ignore[union-attr]
ctrl = _append_connection(bridge_path, s, e, segments, base_scale)
_append_arrow(b_arrow_path, s, e, ctrl, base_scale)
if last_pt is not None:
prev_end_pt = last_pt
c_bridge = QColor(lc)
c_bridge.setAlphaF(0.5)
pen_b = QPen(c_bridge, line_w_bridge)
pen_b.setCapStyle(Qt.PenCapStyle.RoundCap)
pen_b.setStyle(Qt.PenStyle.DashLine)
painter.setPen(pen_b)
painter.drawPath(bridge_path)
c_ba = QColor(lc)
c_ba.setAlphaF(0.75)
pen_ba = QPen(c_ba, line_w_arrow)
pen_ba.setCapStyle(Qt.PenCapStyle.RoundCap)
pen_ba.setStyle(Qt.PenStyle.SolidLine)
painter.setPen(pen_ba)
painter.drawPath(b_arrow_path)
# ── 5. LED dots ──────────────────────────────────────────────────────────
for point in doc.points:
px = to_px(point.x, point.y)
line = point_line.get(point.uid)
color = QColor(line.color if line else unassigned_color)
painter.setPen(QPen(color.darker(160), max(1.0, dot_r * 0.10)))
painter.setBrush(QBrush(color))
painter.drawEllipse(px, dot_r, dot_r)
# ── 6. Badges (anti-overlap) ─────────────────────────────────────────────
badge_font = QFont("Segoe UI", max(7, int(dot_r * 0.82)))
badge_font.setBold(True)
painter.setFont(badge_font)
fm = QFontMetricsF(badge_font)
# First pass: compute proposed rect for each badge
badge_rects: list[tuple[str, str, QRectF, QColor]] = [] # (text, badge_type, rect, bg)
def proposed_rect(uid: int, text: str, btype: str) -> tuple[QRectF, QColor]:
p = point_by_uid.get(uid)
if p is None:
return QRectF(), QColor()
px = to_px(p.x, p.y) # type: ignore[union-attr]
tw = fm.horizontalAdvance(text)
th = fm.height()
bx = px.x() - tw / 2 - 5 * base_scale
bw = tw + 10 * base_scale
bh = th + 4 * base_scale
if btype in ("start", "single"):
by = px.y() - dot_r - bh - 4 * base_scale
bg = QColor(BADGE_START if btype == "start" else BADGE_SINGLE)
else:
by = px.y() + dot_r + 4 * base_scale
bg = QColor(BADGE_END)
return QRectF(bx, by, bw, bh), bg
# Collect all badges
raw_badges: list[tuple[int, str, str]] = []
for uid, (text, btype) in badge_info.items():
raw_badges.append((uid, text, btype))
# Build placed rects with nudge to avoid overlap
placed: list[QRectF] = []
NUDGE_STEP = fm.height() + 6 * base_scale
# Maximum y for badge bottom must stay within mapping area
badge_max_y = map_px_h - 4 * base_scale
for uid, text, btype in raw_badges:
rect, bg = proposed_rect(uid, text, btype)
if rect.isEmpty():
continue
# Try shifting vertically until no overlap
direction = -1 if btype in ("start", "single") else 1
for _ in range(12):
overlaps = any(rect.intersects(pr) for pr in placed)
if not overlaps:
break
rect = rect.translated(0, direction * NUDGE_STEP)
# Clamp: don't let badge go outside mapping area vertically
if rect.bottom() > badge_max_y:
rect = rect.translated(0, badge_max_y - rect.bottom())
if rect.top() < 0:
rect = rect.translated(0, -rect.top())
placed.append(rect)
badge_rects.append((text, btype, rect, bg))
# Second pass: draw
for text, btype, rect, bg in badge_rects:
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QBrush(bg))
painter.drawRoundedRect(rect, 3 * base_scale, 3 * base_scale)
painter.setPen(QColor(Qt.GlobalColor.white))
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, text)
# ── 7. Legend strip ───────────────────────────────────────────────────────
title_font = QFont("Segoe UI", int(16 * base_scale))
title_font.setBold(True)
block_font = QFont("Segoe UI", int(13 * base_scale))
legend_y0 = map_px_h
legend_h = round(legend_height_pre)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QBrush(legend_bg_color))
painter.drawRect(0, legend_y0, img_w, legend_h)
# divider
painter.setPen(QPen(divider_color, int(2 * base_scale)))
painter.drawLine(0, legend_y0, img_w, legend_y0)
if doc.lines:
n_lines = len(doc.lines)
avail_w = img_w - 2 * legend_h_pad
col_w = (avail_w - legend_col_gap * (n_lines - 1)) / n_lines
for i, line in enumerate(doc.lines):
cx = float(legend_h_pad) + i * (col_w + legend_col_gap)
cy = float(legend_y0 + legend_v_pad)
# Colour swatch + line name
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QBrush(QColor(line.color)))
painter.drawRoundedRect(
QRectF(cx, cy + 4 * base_scale, legend_swatch_w, legend_swatch_h), 3 * base_scale, 3 * base_scale
)
painter.setFont(title_font)
painter.setPen(line_text_color)
painter.drawText(
QRectF(cx + legend_swatch_w + 10 * base_scale, cy, col_w - legend_swatch_w - 10 * base_scale, legend_line_row),
Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft,
line.name,
)
cy += legend_line_row
# Blocks (top-to-bottom within column)
cur = 0
painter.setFont(block_font)
for block in line.blocks:
n = len(block.point_ids)
rng = f"{cur}-{cur + n - 1}" if n else "пусто"
cur += n
# Thin colour bar on the left
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QBrush(QColor(line.color)))
painter.drawRect(QRectF(cx + 4 * base_scale, cy + 4 * base_scale, 3 * base_scale, legend_block_row - 8 * base_scale))
painter.setPen(block_text_color)
painter.drawText(
QRectF(cx + 16 * base_scale, cy, col_w - 16 * base_scale, legend_block_row),
Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft,
f"{block.name}: {rng}",
)
cy += legend_block_row
painter.end()
return image.save(str(file_path))

935
models.py

File diff suppressed because it is too large Load Diff

View File

@ -11,20 +11,20 @@ from typing import Iterable
try: try:
from .models import ( from .models import (
LedPoint, LedPoint,
MAX_NEIGHBOR_TOLERANCE_MM,
MappingDocument, MappingDocument,
contour_groups_like_grid_svg as route_groups_like_grid_svg, contour_groups_like_grid_svg as route_groups_like_grid_svg,
coordinate_display_scale, coordinate_display_scale,
min_neighbor_distance_for_points,
normalize_route_clockwise, normalize_route_clockwise,
order_points_like_grid_svg, order_points_like_grid_svg,
) )
except ImportError: except ImportError:
from models import ( from models import (
LedPoint, LedPoint,
MAX_NEIGHBOR_TOLERANCE_MM,
MappingDocument, MappingDocument,
contour_groups_like_grid_svg as route_groups_like_grid_svg, contour_groups_like_grid_svg as route_groups_like_grid_svg,
coordinate_display_scale, coordinate_display_scale,
min_neighbor_distance_for_points,
normalize_route_clockwise, normalize_route_clockwise,
order_points_like_grid_svg, order_points_like_grid_svg,
) )
@ -36,6 +36,7 @@ MAX_LEDS_PER_CELL = 3
NUM_STRIPS = 4 NUM_STRIPS = 4
EMPTY_STRIP = "0xFF" EMPTY_STRIP = "0xFF"
EMPTY_LED = "0xFFFF" EMPTY_LED = "0xFFFF"
OBSOLETE_LEGACY_FILENAME = "virtual_matrix_legacy.ino.txt"
@dataclass(frozen=True) @dataclass(frozen=True)
@ -69,7 +70,72 @@ class BuildResult:
unassigned_count: int unassigned_count: int
def generate_panel_mapping_from_document(document: MappingDocument, output_dir: str | Path) -> BuildResult: def remove_obsolete_legacy_output(output_dir: Path) -> None:
"""Удалить только legacy-файл, созданный прежними версиями генератора."""
legacy_path = output_dir / OBSOLETE_LEGACY_FILENAME
if legacy_path.is_file() or legacy_path.is_symlink():
legacy_path.unlink()
def cpp_float_literal(value: float) -> str:
text = format(float(value), ".9g")
if "." not in text and "e" not in text.lower():
text += ".0"
return f"{text}f"
def sanitize_cpp_identifier(name: str) -> str:
# Transliteration dictionary for Russian Cyrillic
cyrillic_translit = {
'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo',
'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm',
'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u',
'ф': 'f', 'х': 'kh', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'shch',
'ы': 'y', 'э': 'e', 'ю': 'yu', 'я': 'ya',
'А': 'A', 'Б': 'B', 'В': 'V', 'Г': 'G', 'Д': 'D', 'Е': 'E', 'Ё': 'Yo',
'Ж': 'Zh', 'З': 'Z', 'И': 'I', 'Й': 'Y', 'К': 'K', 'Л': 'L', 'М': 'M',
'Н': 'N', 'О': 'O', 'П': 'P', 'Р': 'R', 'С': 'S', 'Т': 'T', 'У': 'U',
'Ф': 'F', 'Х': 'Kh', 'Ц': 'Ts', 'Ч': 'Ch', 'Ш': 'Sh', 'Щ': 'Shch',
'Ы': 'Y', 'Э': 'E', 'Ю': 'Yu', 'Я': 'Ya',
'ь': '', 'Ь': '', 'ъ': '', 'Ъ': ''
}
# Transliterate Cyrillic to Latin
transliterated = "".join(cyrillic_translit.get(c, c) for c in name)
# Replace non-alphanumeric chars with underscore
sanitized = ""
for c in transliterated:
if c.isalnum() or c == '_':
sanitized += c
else:
sanitized += '_'
# Remove multiple consecutive underscores
while "__" in sanitized:
sanitized = sanitized.replace("__", "_")
# Strip leading/trailing underscores
sanitized = sanitized.strip("_")
# Prepend underscore if starts with a digit
if sanitized and sanitized[0].isdigit():
sanitized = "_" + sanitized
# If empty, return a default prefix
if not sanitized:
sanitized = "BLOCK"
return sanitized
def generate_panel_mapping_from_document(
document: MappingDocument,
output_dir: str | Path,
*,
generate_grid: bool = True,
generate_blocks: bool = False,
) -> BuildResult:
if not document.points: if not document.points:
raise ValueError("Нет точек для генерации скрипта.") raise ValueError("Нет точек для генерации скрипта.")
@ -82,20 +148,26 @@ def generate_panel_mapping_from_document(document: MappingDocument, output_dir:
raise ValueError("Нет назначенных LED: перед генерацией распределите точки по линиям.") raise ValueError("Нет назначенных LED: перед генерацией распределите точки по линиям.")
cells = build_cells(mapping_points) cells = build_cells(mapping_points)
block_count = sum(len(line.blocks) for line in document.lines) block_count = sum(len(line.blocks) for line in document.lines)
generated_files = { generated_files = {
"vm_map": output_dir / "vm_map.h", "vm_map": output_dir / "vm_map.h",
"legacy_ino": output_dir / "virtual_matrix_legacy.ino.txt",
"ordered_json": output_dir / "ordered_circles.json", "ordered_json": output_dir / "ordered_circles.json",
"validation_report": output_dir / "validation_report.txt", "validation_report": output_dir / "validation_report.txt",
"readme": output_dir / "README.md", "readme": output_dir / "README.md",
} }
write_vm_map_header(generated_files["vm_map"], points, mapping_points, cells, block_count) write_vm_map_header(
write_legacy_ino(generated_files["legacy_ino"], mapping_points, cells) generated_files["vm_map"],
points,
mapping_points,
cells,
document,
generate_grid=generate_grid,
generate_blocks=generate_blocks,
)
write_ordered_json(generated_files["ordered_json"], points, document) write_ordered_json(generated_files["ordered_json"], points, document)
write_validation_report(generated_files["validation_report"], points) write_validation_report(generated_files["validation_report"], points, document.grid_step_mm)
write_readme(generated_files["readme"]) write_readme(generated_files["readme"])
remove_obsolete_legacy_output(output_dir)
vm_rows, vm_cols, grid_rows, grid_cols = get_dimensions(points) vm_rows, vm_cols, grid_rows, grid_cols = get_dimensions(points)
return BuildResult( return BuildResult(
@ -116,10 +188,14 @@ def generate_panel_mapping_from_document(document: MappingDocument, output_dir:
def normalize_document_points(document: MappingDocument) -> list[PanelLedPoint]: def normalize_document_points(document: MappingDocument) -> list[PanelLedPoint]:
scale = _coordinate_scale_to_mm(document) scale = _coordinate_scale_to_mm(document)
route_groups = route_groups_like_grid_svg(document.points) route_groups = route_groups_like_grid_svg(document.points)
min_neighbor_distance = document.grid_step_in_document_units()
ordered: list[tuple[LedPoint, int]] = [] ordered: list[tuple[LedPoint, int]] = []
for route_group_index, route_group in enumerate(route_groups, start=1): for route_group_index, route_group in enumerate(route_groups, start=1):
route = order_points_like_grid_svg(route_group) route = order_points_like_grid_svg(
route = normalize_route_clockwise(route, min_neighbor_distance_for_points(route)) route_group,
min_neighbor_distance=min_neighbor_distance,
)
route = normalize_route_clockwise(route, min_neighbor_distance)
ordered.extend((point, route_group_index) for point in route) ordered.extend((point, route_group_index) for point in route)
min_row = min(point.row for point, _group in ordered) min_row = min(point.row for point, _group in ordered)
@ -129,7 +205,7 @@ def normalize_document_points(document: MappingDocument) -> list[PanelLedPoint]:
generated_id=index, generated_id=index,
source_id=point.source_id, source_id=point.source_id,
input_index=point.uid, input_index=point.uid,
strip=(point.line_id - 1) if point.line_id is not None else None, strip=point.line_id,
led_id=point.line_index, led_id=point.line_index,
x_mm=point.x * scale, x_mm=point.x * scale,
y_mm=point.y * scale, y_mm=point.y * scale,
@ -180,12 +256,35 @@ def write_vm_map_header(
all_points: list[PanelLedPoint], all_points: list[PanelLedPoint],
mapping_points: list[PanelLedPoint], mapping_points: list[PanelLedPoint],
cells: dict[tuple[int, int], list[PanelLedPoint]], cells: dict[tuple[int, int], list[PanelLedPoint]],
block_count: int, document: MappingDocument,
generate_grid: bool = True,
generate_blocks: bool = False,
) -> None: ) -> None:
vm_rows, vm_cols, grid_rows, grid_cols = get_dimensions(all_points) 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) min_row, max_row, min_col, max_col = get_source_bounds(all_points)
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# Collect blocks info
all_blocks = []
for line in document.lines:
all_blocks.extend(line.blocks)
block_count = len(all_blocks)
point_by_input_index = {p.input_index: p for p in all_points}
flat_block_leds = []
block_ranges = []
for block in all_blocks:
block_points = []
for point_uid in block.point_ids:
p = point_by_input_index.get(point_uid)
if p is not None and p.strip is not None and p.led_id is not None:
block_points.append(p)
offset = len(flat_block_leds)
count = len(block_points)
flat_block_leds.extend(block_points)
block_ranges.append((offset, count, block.name))
with path.open("w", encoding="utf-8", newline="\n") as file: with path.open("w", encoding="utf-8", newline="\n") as file:
file.write("#pragma once\n") file.write("#pragma once\n")
file.write("// Auto-generated by LED Mapper GUI\n") file.write("// Auto-generated by LED Mapper GUI\n")
@ -193,204 +292,202 @@ def write_vm_map_header(
file.write("// Virtual matrix mapping plus physical LED grid-cell metadata.\n\n") file.write("// Virtual matrix mapping plus physical LED grid-cell metadata.\n\n")
file.write("#include <stdint.h>\n") file.write("#include <stdint.h>\n")
file.write("#include <Arduino.h>\n\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 VM_NUM_STRIPS {NUM_STRIPS}\n")
file.write(f"#define LED_COUNT {len(mapping_points)}\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 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(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") if generate_grid:
file.write("// VM column is the source row shifted to zero-based coordinates.\n") file.write(f"#define VM_ROWS {vm_rows}\n")
file.write("#define VM_ROW_FROM_GRID_COL(col) (GRID_COL_MAX - (col))\n") file.write(f"#define VM_COLS {vm_cols}\n")
file.write("#define VM_COL_FROM_GRID_ROW(row) ((row) - GRID_ROW_MIN)\n") file.write("#define VM_CELLS (VM_ROWS * VM_COLS)\n")
file.write("#define VM_GRID_ROW_FROM_VM_COL(col) ((col) + GRID_ROW_MIN)\n") file.write(f"#define VM_MAX_IDS_PER_CELL {MAX_LEDS_PER_CELL}\n")
file.write("#define VM_GRID_COL_FROM_VM_ROW(row) (GRID_COL_MAX - (row))\n\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 {cpp_float_literal(document.grid_step_mm)}\n\n")
file.write("// Source grid -> virtual matrix orientation used by this file.\n")
file.write("// VM row is the source column flipped from right to left.\n")
file.write("// VM column is the source row shifted to zero-based coordinates.\n")
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("struct LEDMap {\n")
file.write(" uint8_t strip;\n") file.write(" uint8_t strip;\n")
file.write(" uint16_t led_id;\n") file.write(" uint16_t led_id;\n")
file.write("};\n\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") if generate_grid:
file.write("const uint8_t PROGMEM VM_LED_COUNT[VM_ROWS][VM_COLS] = {\n") file.write("struct GridCellRef {\n")
for row in range(vm_rows): file.write(" uint8_t row;\n")
counts = [len(cells.get((row, col), [])) for col in range(vm_cols)] file.write(" uint8_t col;\n")
suffix = "," if row < vm_rows - 1 else "" file.write("};\n\n")
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("// Number of LEDs in each virtual cell VM[row][col].\n")
file.write("const LEDMap PROGMEM VM_MAPPINGS[VM_ROWS][VM_COLS][3] = {\n") file.write("const uint8_t PROGMEM VM_LED_COUNT[VM_ROWS][VM_COLS] = {\n")
for row in range(vm_rows): for row in range(vm_rows):
file.write(" {\n") counts = [len(cells.get((row, col), [])) for col in range(vm_cols)]
for col in range(vm_cols): suffix = "," if row < vm_rows - 1 else ""
cell_points = cells.get((row, col), []) file.write(f" {format_int_row(counts)}{suffix}\n")
entries = [] file.write("};\n\n")
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("// LED mappings for all virtual cells. Empty slots use {0xFF, 0xFFFF}.\n")
file.write("const GridCellRef PROGMEM LED_GRID_CELL_1BASED[SOURCE_POINT_COUNT] = {\n") file.write("const LEDMap PROGMEM VM_MAPPINGS[VM_ROWS][VM_COLS][3] = {\n")
for index, point in enumerate(all_points): for row in range(vm_rows):
suffix = "," if index < len(all_points) - 1 else "" file.write(" {\n")
file.write(f" {{{point.row}, {point.col}}}{suffix} // id {point.generated_id}\n") for col in range(vm_cols):
file.write("};\n\n") 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("// Zero-based virtual matrix cell for each source point id.\n") file.write("// Original 1-based grid cell for each source point id.\n")
file.write("const GridCellRef PROGMEM LED_GRID_CELL_ZERO_BASED[SOURCE_POINT_COUNT] = {\n") file.write("const GridCellRef PROGMEM LED_GRID_CELL_1BASED[SOURCE_POINT_COUNT] = {\n")
for index, point in enumerate(all_points): for index, point in enumerate(all_points):
suffix = "," if index < len(all_points) - 1 else "" 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(f" {{{point.row}, {point.col}}}{suffix} // id {point.generated_id}\n")
file.write("};\n\n") file.write("};\n\n")
file.write("// Source ids from the input JSON, indexed by source point id.\n") file.write("// Zero-based virtual matrix cell for each source point id.\n")
file.write("const uint16_t PROGMEM LED_SOURCE_ID[SOURCE_POINT_COUNT] = {\n") file.write("const GridCellRef PROGMEM LED_GRID_CELL_ZERO_BASED[SOURCE_POINT_COUNT] = {\n")
for start in range(0, len(all_points), 16): for index, point in enumerate(all_points):
values = [point.source_id for point in all_points[start : start + 16]] suffix = "," if index < len(all_points) - 1 else ""
suffix = "," if start + 16 < len(all_points) else "" file.write(f" {{{point.matrix_row}, {point.matrix_col}}}{suffix} // id {point.generated_id}\n")
file.write(f" {', '.join(str(value) for value in values)}{suffix}\n") file.write("};\n\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( if generate_blocks:
path: Path, # Generate compile-time block macros/constants
points: list[PanelLedPoint], file.write("// Compile-time block indices\n")
cells: dict[tuple[int, int], list[PanelLedPoint]], for index, (offset, count, name) in enumerate(block_ranges):
) -> None: identifier = sanitize_cpp_identifier(name)
vm_rows, vm_cols, _grid_rows, _grid_cols = get_dimensions(points) file.write(f"#define BLOCK_{identifier} {index}\n")
with path.open("w", encoding="utf-8", newline="\n") as file: file.write("\n")
file.write("// ============================================================================\n")
file.write("// ARCHIVE COPY - VIRTUAL MATRIX GENERATED BY LED Mapper GUI\n") file.write(f"#define TOTAL_BLOCK_LEDS {len(flat_block_leds)}\n\n")
file.write("// Saved as .ino.txt so Arduino IDE will not compile it automatically.\n") file.write("struct BlockRange {\n")
file.write("// ============================================================================\n\n") file.write(" uint16_t offset;\n")
file.write("#include <initializer_list>\n\n") file.write(" uint16_t count;\n")
file.write("#ifndef ARDUINO\n#include <Arduino.h>\n#endif\n\n") file.write("};\n\n")
file.write("#include <Adafruit_NeoPixel.h>\n")
file.write("#ifndef NUM_STRIPS\n") file.write("// Flat list of all LEDs in blocks, ordered by block, and within block\n")
file.write(f"#define NUM_STRIPS {NUM_STRIPS}\n") file.write("const LEDMap PROGMEM BLOCK_LEDS[TOTAL_BLOCK_LEDS] = {\n")
file.write("#endif\n") for index, point in enumerate(flat_block_leds):
file.write("extern Adafruit_NeoPixel hwStrips[NUM_STRIPS];\n\n") suffix = "," if index < len(flat_block_leds) - 1 else ""
file.write("#ifndef VM_MAX_PHYS_LEDS\n") file.write(f" {{{point.strip}, {point.led_id}}}{suffix} // block point\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("};\n\n")
file.write("#endif\n")
file.write("#ifndef VM_MAX_IDS_PER_CELL\n") file.write("// Start offset and length for each block\n")
file.write(f"#define VM_MAX_IDS_PER_CELL {MAX_LEDS_PER_CELL}\n") file.write("const BlockRange PROGMEM BLOCK_RANGES[LED_BLOCK_COUNT] = {\n")
file.write("#endif\n") for index, (offset, count, name) in enumerate(block_ranges):
file.write("#ifndef MATRIX_ROWS\n") suffix = "," if index < len(block_ranges) - 1 else ""
file.write(f"#define MATRIX_ROWS {vm_rows}\n") file.write(f" {{{offset}, {count}}}{suffix} // {name}\n")
file.write("#endif\n") file.write("};\n\n")
file.write("#ifndef MATRIX_COLS\n")
file.write(f"#define MATRIX_COLS {vm_cols}\n") # String names array in PROGMEM
file.write("#endif\n\n") file.write("// Original string names of blocks in flash memory\n")
file.write("struct VMPhys { uint16_t idx; uint8_t strip; };\n\n") for index, (offset, count, name) in enumerate(block_ranges):
file.write("static uint8_t vmCount[MATRIX_ROWS][MATRIX_COLS];\n") escaped_name = name.replace('"', '\\"')
file.write("static VMPhys vmCell[MATRIX_ROWS][MATRIX_COLS][VM_MAX_IDS_PER_CELL];\n") file.write(f"const char BLOCK_NAME_{index}[] PROGMEM = \"{escaped_name}\";\n")
file.write("static int16_t vmRow[NUM_STRIPS][VM_MAX_PHYS_LEDS];\n") file.write("\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("const char* const BLOCK_NAMES[LED_BLOCK_COUNT] PROGMEM = {\n")
file.write("static uint16_t vmMaxId[NUM_STRIPS];\n\n") for index in range(len(block_ranges)):
file.write("void clearVirtualMatrix();\n") suffix = "," if index < len(block_ranges) - 1 else ""
file.write("void setLEDs(uint16_t row, uint16_t col, uint8_t strip1based, std::initializer_list<uint16_t> ids);\n") file.write(f" BLOCK_NAME_{index}{suffix}\n")
file.write("void vmSetByPhys(uint8_t strip, uint16_t led, uint32_t color);\n") file.write("};\n\n")
file.write("void initVirtualMatrix();\n\n")
file.write("void clearVirtualMatrix() {\n") # Inline helpers for blocks
file.write(" for (int r = 0; r < MATRIX_ROWS; ++r)\n") file.write("// Control block LEDs by count inside a block\n")
file.write(" for (int c = 0; c < MATRIX_COLS; ++c) vmCount[r][c] = 0;\n") file.write("inline void setBlockLEDsCount(uint16_t blockIdx, uint16_t count, uint32_t color) {\n")
file.write(" for (int s = 0; s < NUM_STRIPS; ++s) {\n") file.write(" if (blockIdx >= LED_BLOCK_COUNT) return;\n")
file.write(" vmMaxId[s] = 0;\n") file.write(" uint16_t offset = pgm_read_word(&BLOCK_RANGES[blockIdx].offset);\n")
file.write(" for (int i = 0; i < VM_MAX_PHYS_LEDS; ++i) {\n") file.write(" uint16_t total_count = pgm_read_word(&BLOCK_RANGES[blockIdx].count);\n")
file.write(" vmRow[s][i] = -1;\n") file.write(" if (count > total_count) count = total_count;\n")
file.write(" vmCol[s][i] = -1;\n") file.write(f" extern Adafruit_NeoPixel hwStrips[VM_NUM_STRIPS];\n")
file.write(" vmUsed[s][i] = 0;\n") file.write(" for (uint16_t i = 0; i < count; i++) {\n")
file.write(" }\n") file.write(" uint8_t strip = pgm_read_byte(&BLOCK_LEDS[offset + i].strip);\n")
file.write(" }\n") file.write(" uint16_t led_id = pgm_read_word(&BLOCK_LEDS[offset + i].led_id);\n")
file.write("}\n\n") file.write(" if (strip < VM_NUM_STRIPS) {\n")
file.write("void setLEDs(uint16_t row, uint16_t col, uint8_t strip1based, std::initializer_list<uint16_t> ids) {\n") file.write(" hwStrips[strip].setPixelColor(led_id, color);\n")
file.write(" if (row >= MATRIX_ROWS || col >= MATRIX_COLS) return;\n") file.write(" }\n")
file.write(" uint8_t strip = (strip1based == 0) ? 0 : (uint8_t)(strip1based - 1);\n") file.write(" }\n")
file.write(" if (strip >= NUM_STRIPS) return;\n") file.write("}\n\n")
file.write(" for (uint16_t id : ids) {\n")
file.write(" if (id >= VM_MAX_PHYS_LEDS) continue;\n") file.write("// Control single block LED by 0-based index inside a block\n")
file.write(" uint8_t &count = vmCount[row][col];\n") file.write("inline void setBlockLED(uint16_t blockIdx, uint16_t ledIdxInBlock, uint32_t color) {\n")
file.write(" if (count < VM_MAX_IDS_PER_CELL) {\n") file.write(" if (blockIdx >= LED_BLOCK_COUNT) return;\n")
file.write(" vmCell[row][col][count].idx = id;\n") file.write(" uint16_t offset = pgm_read_word(&BLOCK_RANGES[blockIdx].offset);\n")
file.write(" vmCell[row][col][count].strip = strip;\n") file.write(" uint16_t count = pgm_read_word(&BLOCK_RANGES[blockIdx].count);\n")
file.write(" ++count;\n") file.write(" if (ledIdxInBlock >= count) return;\n")
file.write(" }\n") file.write(" uint8_t strip = pgm_read_byte(&BLOCK_LEDS[offset + ledIdxInBlock].strip);\n")
file.write(" vmRow[strip][id] = (int16_t)row;\n") file.write(" uint16_t led_id = pgm_read_word(&BLOCK_LEDS[offset + ledIdxInBlock].led_id);\n")
file.write(" vmCol[strip][id] = (int16_t)col;\n") file.write(f" extern Adafruit_NeoPixel hwStrips[VM_NUM_STRIPS];\n")
file.write(" vmUsed[strip][id] = 1;\n") file.write(" if (strip < VM_NUM_STRIPS) {\n")
file.write(" if (id > vmMaxId[strip]) vmMaxId[strip] = id;\n") file.write(" hwStrips[strip].setPixelColor(led_id, color);\n")
file.write(" }\n") file.write(" }\n")
file.write("}\n\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") # Add getBlockIndexByName
file.write(" int16_t row = vmRow[strip][led];\n") file.write("// Find block index by its string name (case-sensitive, original name)\n")
file.write(" int16_t col = vmCol[strip][led];\n") file.write("inline int16_t getBlockIndexByName(const char* name) {\n")
file.write(" if (row < 0 || col < 0) {\n") file.write(" for (uint16_t i = 0; i < LED_BLOCK_COUNT; i++) {\n")
file.write(" if (led < hwStrips[strip].numPixels()) hwStrips[strip].setPixelColor(led, color);\n") file.write(" const char* blockNamePtr = (const char*)pgm_read_word(&BLOCK_NAMES[i]);\n")
file.write(" return;\n") file.write(" if (strcmp_P(name, blockNamePtr) == 0) {\n")
file.write(" }\n") file.write(" return (int16_t)i;\n")
file.write(" uint8_t count = vmCount[row][col];\n") file.write(" }\n")
file.write(" for (uint8_t i = 0; i < count; ++i) {\n") file.write(" }\n")
file.write(" const VMPhys &phys = vmCell[row][col][i];\n") file.write(" return -1;\n")
file.write(" if (phys.strip < NUM_STRIPS && phys.idx < hwStrips[phys.strip].numPixels()) {\n") file.write("}\n\n")
file.write(" hwStrips[phys.strip].setPixelColor(phys.idx, color);\n")
file.write(" }\n") # Add string-based control overloads
file.write(" }\n") file.write("// Control block LEDs by string name and count\n")
file.write("}\n\n") file.write("inline void setBlockLEDsCount(const char* name, uint16_t count, uint32_t color) {\n")
file.write("void initVirtualMatrix() {\n") file.write(" int16_t idx = getBlockIndexByName(name);\n")
file.write(" clearVirtualMatrix();\n") file.write(" if (idx >= 0) {\n")
last_row = None file.write(" setBlockLEDsCount((uint16_t)idx, count, color);\n")
for row in range(vm_rows): file.write(" }\n")
row_cells = [(col, cells[(row, col)]) for col in range(vm_cols) if (row, col) in cells] file.write("}\n\n")
if not row_cells:
continue file.write("// Control single block LED by block string name and 0-based index\n")
if last_row != row: file.write("inline void setBlockLED(const char* name, uint16_t ledIdxInBlock, uint32_t color) {\n")
file.write(f"\n // Row {row}\n") file.write(" int16_t idx = getBlockIndexByName(name);\n")
last_row = row file.write(" if (idx >= 0) {\n")
for col, cell_points in row_cells: file.write(" setBlockLED((uint16_t)idx, ledIdxInBlock, color);\n")
by_strip: dict[int, list[int]] = defaultdict(list) file.write(" }\n")
for point in cell_points: file.write("}\n")
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: def write_ordered_json(path: Path, points: list[PanelLedPoint], document: MappingDocument) -> None:
payload = { point_id_map = {point.input_index: point.generated_id for point in points}
"source": str(document.path) if document.path is not None else "LED Mapper GUI", point_payloads = []
"unit": OUTPUT_UNIT, for point in points:
"cell_size_mm": CELL_SIZE_MM, source_point = document.points[point.input_index]
"leds": [ payload = document.point_to_payload(source_point, id_value=point.generated_id)
payload.update(
{ {
"id": point.generated_id,
"source_id": point.source_id, "source_id": point.source_id,
"input_index": point.input_index, "input_index": point.input_index,
"strip": point.strip, "strip": point.strip,
@ -401,14 +498,32 @@ def write_ordered_json(path: Path, points: list[PanelLedPoint], document: Mappin
"col": point.col, "col": point.col,
"matrix_row": point.matrix_row, "matrix_row": point.matrix_row,
"matrix_col": point.matrix_col, "matrix_col": point.matrix_col,
"route_group": point.route_group,
} }
for point in points )
], point_payloads.append(payload)
}
payload = document.to_payload(
point_payloads=point_payloads,
lines_payload=document.lines_to_payload(point_id_map=point_id_map),
collection_name=document.collection_name,
)
payload.update(
{
"source": str(document.path) if document.path is not None else "LED Mapper GUI",
"unit": OUTPUT_UNIT,
"coordinate_unit": OUTPUT_UNIT,
"cell_size_mm": document.grid_step_mm,
"ordered_id_mode": "generated_uid",
}
)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def collect_validation(points: list[PanelLedPoint]) -> list[str]: def collect_validation(
points: list[PanelLedPoint],
cell_size_mm: float = CELL_SIZE_MM,
) -> list[str]:
lines = [] lines = []
distance_violations = [] distance_violations = []
cell_violations = [] cell_violations = []
@ -416,7 +531,9 @@ def collect_validation(points: list[PanelLedPoint]) -> list[str]:
if current.route_group != next_point.route_group: if current.route_group != next_point.route_group:
continue continue
gap = math.hypot(current.x_mm - next_point.x_mm, current.y_mm - next_point.y_mm) gap = math.hypot(current.x_mm - next_point.x_mm, current.y_mm - next_point.y_mm)
if gap > CELL_SIZE_MM + 1e-6: diagonal = abs(current.row - next_point.row) == 1 and abs(current.col - next_point.col) == 1
distance_limit = cell_size_mm * (math.sqrt(2.0) if diagonal else 1.0) + MAX_NEIGHBOR_TOLERANCE_MM
if gap > distance_limit + 1e-6:
distance_violations.append((current, next_point, gap)) distance_violations.append((current, next_point, gap))
if abs(current.row - next_point.row) > 1 or abs(current.col - next_point.col) > 1: if abs(current.row - next_point.row) > 1 or abs(current.col - next_point.col) > 1:
cell_violations.append((current, next_point)) cell_violations.append((current, next_point))
@ -426,7 +543,7 @@ def collect_validation(points: list[PanelLedPoint]) -> list[str]:
lines.append(f"LED count: {len(points)}") lines.append(f"LED count: {len(points)}")
lines.append(f"Generated ids contiguous: {not duplicate_ids}") lines.append(f"Generated ids contiguous: {not duplicate_ids}")
lines.append(f"Max LEDs per cell: {max_per_cell}") lines.append(f"Max LEDs per cell: {max_per_cell}")
lines.append(f"Same-route distance violations > {CELL_SIZE_MM:g} mm: {len(distance_violations)}") lines.append(f"Same-route distance violations beyond grid step + tolerance: {len(distance_violations)}")
lines.append(f"Same-route 8-neighbor cell violations: {len(cell_violations)}") lines.append(f"Same-route 8-neighbor cell violations: {len(cell_violations)}")
if max_per_cell > MAX_LEDS_PER_CELL: if max_per_cell > MAX_LEDS_PER_CELL:
lines.append(f"ERROR: max LEDs per cell exceeds {MAX_LEDS_PER_CELL}") lines.append(f"ERROR: max LEDs per cell exceeds {MAX_LEDS_PER_CELL}")
@ -443,11 +560,15 @@ def collect_validation(points: list[PanelLedPoint]) -> list[str]:
return lines return lines
def write_validation_report(path: Path, points: list[PanelLedPoint]) -> None: def write_validation_report(
path: Path,
points: list[PanelLedPoint],
cell_size_mm: float = CELL_SIZE_MM,
) -> None:
lines = [ lines = [
"Panel mapping validation", "Panel mapping validation",
"========================", "========================",
*collect_validation(points), *collect_validation(points, cell_size_mm),
"", "",
] ]
path.write_text("\n".join(lines), encoding="utf-8") path.write_text("\n".join(lines), encoding="utf-8")
@ -457,13 +578,17 @@ def write_readme(path: Path) -> None:
path.write_text( path.write_text(
"\n".join( "\n".join(
[ [
"# Panel Mapping Build", "# Результат генерации Panel Mapping",
"", "",
"This folder contains files generated by the LED Mapper GUI:", "LED Mapper создал следующие файлы:",
"- `vm_map.h` contains the PROGMEM virtual matrix mapping.", "- `vm_map.h` — маппинг виртуальной матрицы в формате PROGMEM.",
"- `virtual_matrix_legacy.ino.txt` keeps the readable `setLEDs(...)` mapping.", "- `ordered_circles.json` — нормализованные LED и координаты матрицы.",
"- `ordered_circles.json` stores generated ids plus source row/col data.", "- `validation_report.txt` — результаты проверки маршрутов и ячеек.",
"- `validation_report.txt` records sequence and cell checks.", "- `README.md` — описание набора файлов.",
"",
"Физический адрес ленты в `vm_map.h`: `strip = line_id`; нумерация начинается с `0`.",
"",
"Устаревший `virtual_matrix_legacy.ino.txt` больше не создаётся.",
"", "",
] ]
), ),

File diff suppressed because it is too large Load Diff

187
settings.py Normal file
View File

@ -0,0 +1,187 @@
from __future__ import annotations
import json
import math
from dataclasses import asdict, dataclass
from pathlib import Path
from PySide6.QtCore import QStandardPaths
from PySide6.QtGui import QKeySequence
SHORTCUT_DEFINITIONS: list[tuple[str, str, str]] = [
("new", "Создать", "Ctrl+N"),
("open", "Открыть", "Ctrl+O"),
("save", "Сохранить", "Ctrl+S"),
("save_as", "Сохранить как", "Ctrl+Shift+S"),
("generate_script", "Сгенерировать скрипт", "Ctrl+G"),
("settings", "Параметры", "Ctrl+,"),
("undo", "Отменить", "Ctrl+Z"),
("redo", "Повторить", "Ctrl+Y"),
("copy_leds", "Копировать светодиоды", "Ctrl+C"),
("paste_leds", "Вставить светодиоды", "Ctrl+V"),
("delete", "Удалить фиксацию", "Del"),
("delete_points", "Удалить точки", "Ctrl+Del"),
("clear_all", "Удалить все", "Shift+Del"),
("clear_selection", "Очистить выбор", "Esc"),
("fit_view", "Показать всё", "Ctrl+0"),
("grid", "Сетка", "G"),
("direction_arrows", "Стрелки направления", "Ctrl+Shift+A"),
("led_ids", "Отображать id светодиодов", "I"),
("background", "Подложка", "Ctrl+B"),
("background_visible", "Показать подложку", "Ctrl+Alt+B"),
("background_edit", "Редактировать подложку", "Ctrl+Shift+I"),
("background_reset_scale", "Сбросить масштаб подложки", "Ctrl+Alt+0"),
("background_clear", "Удалить подложку", "Ctrl+Alt+Backspace"),
("toggle_sidebar", "Скрыть боковую панель", "F9"),
("add_line", "Новая линия", "Ctrl+L"),
("add_led", "Новый светодиод", "N"),
("add_block", "Блок из выбора", "Ctrl+Shift+L"),
("auto_blocks", "Автоблоки", "Ctrl+Shift+B"),
("clockwise", "По часовой", "Ctrl+Alt+R"),
("counterclockwise", "Против часовой", "Ctrl+Alt+Shift+R"),
("rebuild_route", "Пересобрать", "Ctrl+Shift+R"),
("brush", "Кисть", "B"),
]
DEFAULT_SHORTCUTS = {key: shortcut for key, _title, shortcut in SHORTCUT_DEFINITIONS}
SETTINGS_VERSION = 4
SHORTCUT_DEFAULTS_MIGRATION_VERSION = 2
DEFAULT_GRID_STEP_MM = 34.0
MIN_GRID_STEP_MM = 1.0
MAX_GRID_STEP_MM = 1000.0
DEFAULT_LED_POINT_DIAMETER_PX = 9
MIN_LED_POINT_DIAMETER_PX = 1
MAX_LED_POINT_DIAMETER_PX = 64
DEFAULT_LED_INDEX_FONT_SIZE_PX = 12
MIN_LED_INDEX_FONT_SIZE_PX = 6
MAX_LED_INDEX_FONT_SIZE_PX = 64
@dataclass
class AppSettings:
version: int = SETTINGS_VERSION
theme: str = "system"
show_grid_by_default: bool = False
show_direction_arrows_by_default: bool = True
show_led_ids_by_default: bool = True
grid_step_mm: float = DEFAULT_GRID_STEP_MM
led_point_diameter_px: int = DEFAULT_LED_POINT_DIAMETER_PX
led_index_font_size_px: int = DEFAULT_LED_INDEX_FONT_SIZE_PX
zoom_focus_pull: int = 35
pan_mode: str = "middle_space"
instant_space_pan: bool = False
brush_radius: int = 18
default_output_dir: str = ""
confirm_partial_generation: bool = True
sidebar_inner_sizes: list[int] | None = None
shortcuts: dict[str, str] | None = None
@classmethod
def load(cls) -> "AppSettings":
path = settings_path()
if not path.exists():
return cls()
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return cls()
settings = cls()
saved_version = clamp_int(data.get("version", 1), 1, SETTINGS_VERSION, 1)
for key, value in data.items():
if hasattr(settings, key):
setattr(settings, key, value)
settings.version = SETTINGS_VERSION
settings.grid_step_mm = clamp_float(
settings.grid_step_mm,
MIN_GRID_STEP_MM,
MAX_GRID_STEP_MM,
DEFAULT_GRID_STEP_MM,
)
settings.led_point_diameter_px = clamp_int(
settings.led_point_diameter_px,
MIN_LED_POINT_DIAMETER_PX,
MAX_LED_POINT_DIAMETER_PX,
DEFAULT_LED_POINT_DIAMETER_PX,
)
settings.led_index_font_size_px = clamp_int(
settings.led_index_font_size_px,
MIN_LED_INDEX_FONT_SIZE_PX,
MAX_LED_INDEX_FONT_SIZE_PX,
DEFAULT_LED_INDEX_FONT_SIZE_PX,
)
settings.zoom_focus_pull = clamp_int(settings.zoom_focus_pull, 0, 100, cls.zoom_focus_pull)
settings.brush_radius = clamp_int(settings.brush_radius, 4, 80, cls.brush_radius)
if settings.theme not in {"system", "light", "dark"}:
settings.theme = cls.theme
if settings.pan_mode not in {"middle", "space", "middle_space"}:
settings.pan_mode = cls.pan_mode
settings.instant_space_pan = bool(settings.instant_space_pan)
settings.show_grid_by_default = bool(settings.show_grid_by_default)
settings.show_direction_arrows_by_default = bool(settings.show_direction_arrows_by_default)
settings.show_led_ids_by_default = bool(settings.show_led_ids_by_default)
settings.confirm_partial_generation = bool(settings.confirm_partial_generation)
settings.default_output_dir = str(settings.default_output_dir or "")
settings.sidebar_inner_sizes = normalize_splitter_sizes(settings.sidebar_inner_sizes, [360, 420])
settings.shortcuts = normalize_shortcuts(
settings.shortcuts,
fill_new_defaults=saved_version < SHORTCUT_DEFAULTS_MIGRATION_VERSION,
)
return settings
def save(self) -> None:
path = settings_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(asdict(self), ensure_ascii=False, indent=2), encoding="utf-8")
def shortcut_for(self, key: str) -> str:
if self.shortcuts is None:
return DEFAULT_SHORTCUTS.get(key, "")
return self.shortcuts.get(key, DEFAULT_SHORTCUTS.get(key, ""))
def settings_path() -> Path:
config_dir = QStandardPaths.writableLocation(QStandardPaths.AppConfigLocation)
if not config_dir:
config_dir = str(Path.home() / ".led_mapper")
return Path(config_dir) / "settings.json"
def clamp_int(value, minimum: int, maximum: int, fallback: int) -> int: # noqa: ANN001
try:
number = int(value)
except (TypeError, ValueError):
return fallback
return max(minimum, min(maximum, number))
def clamp_float(value, minimum: float, maximum: float, fallback: float) -> float: # noqa: ANN001
try:
number = float(value)
except (TypeError, ValueError):
return fallback
if not math.isfinite(number):
return fallback
return max(minimum, min(maximum, number))
def normalize_splitter_sizes(value, fallback: list[int]) -> list[int]: # noqa: ANN001
if not isinstance(value, list) or len(value) != len(fallback):
return fallback[:]
sizes = [clamp_int(size, 80, 10000, fallback[index]) for index, size in enumerate(value)]
if sum(sizes) <= 0:
return fallback[:]
return sizes
def normalize_shortcuts(value, *, fill_new_defaults: bool = False) -> dict[str, str]: # noqa: ANN001
source = value if isinstance(value, dict) else {}
shortcuts: dict[str, str] = {}
for key, default in DEFAULT_SHORTCUTS.items():
raw = source.get(key, default)
if raw is None or (fill_new_defaults and raw == "" and default):
raw = ""
if fill_new_defaults and default:
raw = default
sequence = QKeySequence(str(raw))
shortcuts[key] = sequence.toString(QKeySequence.SequenceFormat.PortableText)
return shortcuts

306
settings_dialog.py Normal file
View File

@ -0,0 +1,306 @@
from __future__ import annotations
from dataclasses import replace
from pathlib import Path
from PySide6.QtGui import QKeySequence
from PySide6.QtWidgets import (
QListWidget,
QListWidgetItem,
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFileDialog,
QFormLayout,
QHBoxLayout,
QKeySequenceEdit,
QLabel,
QLineEdit,
QPushButton,
QScrollArea,
QSpinBox,
QStackedWidget,
QVBoxLayout,
QWidget,
)
try:
from .localization import localize_dialog_buttons, show_warning
from .settings import (
DEFAULT_GRID_STEP_MM,
DEFAULT_LED_INDEX_FONT_SIZE_PX,
DEFAULT_LED_POINT_DIAMETER_PX,
DEFAULT_SHORTCUTS,
MAX_GRID_STEP_MM,
MAX_LED_INDEX_FONT_SIZE_PX,
MAX_LED_POINT_DIAMETER_PX,
MIN_GRID_STEP_MM,
MIN_LED_INDEX_FONT_SIZE_PX,
MIN_LED_POINT_DIAMETER_PX,
SETTINGS_VERSION,
SHORTCUT_DEFINITIONS,
AppSettings,
)
except ImportError:
from localization import localize_dialog_buttons, show_warning
from settings import (
DEFAULT_GRID_STEP_MM,
DEFAULT_LED_INDEX_FONT_SIZE_PX,
DEFAULT_LED_POINT_DIAMETER_PX,
DEFAULT_SHORTCUTS,
MAX_GRID_STEP_MM,
MAX_LED_INDEX_FONT_SIZE_PX,
MAX_LED_POINT_DIAMETER_PX,
MIN_GRID_STEP_MM,
MIN_LED_INDEX_FONT_SIZE_PX,
MIN_LED_POINT_DIAMETER_PX,
SETTINGS_VERSION,
SHORTCUT_DEFINITIONS,
AppSettings,
)
class SettingsDialog(QDialog):
def __init__(self, settings: AppSettings, parent=None) -> None: # noqa: ANN001
super().__init__(parent)
self.setWindowTitle("Параметры")
self.setMinimumSize(520, 360)
self._settings = settings
self.theme = QComboBox()
self.theme.addItem("Системная", "system")
self.theme.addItem("Светлая", "light")
self.theme.addItem("Темная", "dark")
self.theme.setCurrentIndex(max(0, self.theme.findData(settings.theme)))
self.show_grid = QCheckBox("Показывать сетку при запуске")
self.show_grid.setChecked(settings.show_grid_by_default)
self.show_arrows = QCheckBox("Показывать стрелки направления при запуске")
self.show_arrows.setChecked(settings.show_direction_arrows_by_default)
self.show_led_ids = QCheckBox("Показывать id светодиодов при выборе")
self.show_led_ids.setChecked(settings.show_led_ids_by_default)
self.grid_step = QDoubleSpinBox()
self.grid_step.setRange(MIN_GRID_STEP_MM, MAX_GRID_STEP_MM)
self.grid_step.setDecimals(2)
self.grid_step.setSingleStep(1.0)
self.grid_step.setSuffix(" мм")
self.grid_step.setValue(settings.grid_step_mm)
self.grid_step_reset = QPushButton("Сбросить")
self.grid_step_reset.clicked.connect(
lambda: self.grid_step.setValue(DEFAULT_GRID_STEP_MM)
)
self.led_point_diameter = QSpinBox()
self.led_point_diameter.setRange(
MIN_LED_POINT_DIAMETER_PX,
MAX_LED_POINT_DIAMETER_PX,
)
self.led_point_diameter.setSuffix(" px")
self.led_point_diameter.setValue(settings.led_point_diameter_px)
self.led_point_diameter_reset = QPushButton("Сбросить")
self.led_point_diameter_reset.clicked.connect(
lambda: self.led_point_diameter.setValue(DEFAULT_LED_POINT_DIAMETER_PX)
)
self.led_index_font_size = QSpinBox()
self.led_index_font_size.setRange(
MIN_LED_INDEX_FONT_SIZE_PX,
MAX_LED_INDEX_FONT_SIZE_PX,
)
self.led_index_font_size.setSuffix(" px")
self.led_index_font_size.setValue(settings.led_index_font_size_px)
self.led_index_font_size_reset = QPushButton("Сбросить")
self.led_index_font_size_reset.clicked.connect(
lambda: self.led_index_font_size.setValue(DEFAULT_LED_INDEX_FONT_SIZE_PX)
)
self.zoom_pull = QSpinBox()
self.zoom_pull.setRange(0, 100)
self.zoom_pull.setSuffix(" %")
self.zoom_pull.setValue(settings.zoom_focus_pull)
self.pan_mode = QComboBox()
self.pan_mode.addItem("СКМ", "middle")
self.pan_mode.addItem("Пробел", "space")
self.pan_mode.addItem("СКМ + Пробел", "middle_space")
self.pan_mode.setCurrentIndex(max(0, self.pan_mode.findData(settings.pan_mode)))
self.instant_space_pan = QCheckBox("Сразу перетаскивать на пробел")
self.instant_space_pan.setChecked(settings.instant_space_pan)
self.brush_radius = QSpinBox()
self.brush_radius.setRange(4, 80)
self.brush_radius.setSuffix(" px")
self.brush_radius.setValue(settings.brush_radius)
self.output_dir = QLineEdit(settings.default_output_dir)
self.output_dir.setPlaceholderText("Выбирать папку при генерации")
self.confirm_partial = QCheckBox("Предупреждать, если назначены не все LED")
self.confirm_partial.setChecked(settings.confirm_partial_generation)
self.shortcut_edits: dict[str, QKeySequenceEdit] = {}
self.section_list = QListWidget()
self.section_list.setFixedWidth(170)
self.section_stack = QStackedWidget()
self._add_section("Внешний вид", self._build_appearance_tab())
self._add_section("Поведение", self._build_behavior_tab())
self._add_section("Инструменты", self._build_tools_tab())
self._add_section("Файлы и генерация", self._build_files_tab())
self._add_section("Комбинации клавиш", self._build_shortcuts_tab())
self.section_list.setCurrentRow(0)
self.section_list.currentRowChanged.connect(self.section_stack.setCurrentIndex)
content = QWidget()
content_layout = QHBoxLayout(content)
content_layout.setContentsMargins(0, 0, 0, 0)
content_layout.addWidget(self.section_list)
content_layout.addWidget(self.section_stack, 1)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
localize_dialog_buttons(buttons)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout = QVBoxLayout(self)
layout.addWidget(content)
layout.addWidget(buttons)
def _add_section(self, title: str, widget: QWidget) -> None:
item = QListWidgetItem(title)
self.section_list.addItem(item)
self.section_stack.addWidget(widget)
def settings(self) -> AppSettings:
return replace(
self._settings,
version=SETTINGS_VERSION,
theme=str(self.theme.currentData()),
show_grid_by_default=self.show_grid.isChecked(),
show_direction_arrows_by_default=self.show_arrows.isChecked(),
show_led_ids_by_default=self.show_led_ids.isChecked(),
grid_step_mm=self.grid_step.value(),
led_point_diameter_px=self.led_point_diameter.value(),
led_index_font_size_px=self.led_index_font_size.value(),
zoom_focus_pull=self.zoom_pull.value(),
pan_mode=str(self.pan_mode.currentData()),
instant_space_pan=self.instant_space_pan.isChecked(),
brush_radius=self.brush_radius.value(),
default_output_dir=self.output_dir.text().strip(),
confirm_partial_generation=self.confirm_partial.isChecked(),
shortcuts=self._shortcuts(),
)
def _build_appearance_tab(self) -> QWidget:
page = QWidget()
layout = QFormLayout(page)
layout.addRow("Тема приложения", self.theme)
layout.addRow(self.show_grid)
layout.addRow(self.show_arrows)
layout.addRow(self.show_led_ids)
layout.addRow("Шаг сетки", self._setting_with_reset(self.grid_step, self.grid_step_reset))
layout.addRow(
"Диаметр LED-точек",
self._setting_with_reset(self.led_point_diameter, self.led_point_diameter_reset),
)
layout.addRow(
"Размер текста индексов LED",
self._setting_with_reset(self.led_index_font_size, self.led_index_font_size_reset),
)
layout.addRow("Притяжение зума к курсору", self.zoom_pull)
layout.addRow(QLabel("0% оставляет центр спокойнее, 100% сильнее тянет схему к курсору."))
return page
@staticmethod
def _setting_with_reset(editor: QWidget, reset: QPushButton) -> QWidget:
row = QWidget()
row_layout = QHBoxLayout(row)
row_layout.setContentsMargins(0, 0, 0, 0)
row_layout.addWidget(editor, 1)
row_layout.addWidget(reset)
return row
def _build_behavior_tab(self) -> QWidget:
page = QWidget()
layout = QFormLayout(page)
layout.addRow("Перемещение", self.pan_mode)
layout.addRow(self.instant_space_pan)
layout.addRow(QLabel("Пробел означает: удерживайте Пробел и перетаскивайте схему ЛКМ."))
return page
def _build_tools_tab(self) -> QWidget:
page = QWidget()
layout = QFormLayout(page)
layout.addRow("Радиус кисти", self.brush_radius)
layout.addRow(QLabel("Обычный мазок создает новый блок. Shift + ЛКМ добавляет точки к выбранному блоку."))
return page
def _build_files_tab(self) -> QWidget:
page = QWidget()
form = QFormLayout(page)
row = QWidget()
row_layout = QHBoxLayout(row)
row_layout.setContentsMargins(0, 0, 0, 0)
browse = QPushButton("Выбрать")
browse.clicked.connect(self._browse_output_dir)
row_layout.addWidget(self.output_dir, 1)
row_layout.addWidget(browse)
form.addRow("Папка вывода", row)
form.addRow(self.confirm_partial)
return page
def _build_shortcuts_tab(self) -> QWidget:
page = QWidget()
layout = QVBoxLayout(page)
layout.setContentsMargins(0, 0, 0, 0)
scroll_content = QWidget()
form = QFormLayout()
form.setContentsMargins(0, 0, 0, 0)
for key, title, _default_shortcut in SHORTCUT_DEFINITIONS:
edit = QKeySequenceEdit(QKeySequence(self._settings.shortcut_for(key)))
edit.setClearButtonEnabled(True)
edit.setMinimumWidth(190)
self.shortcut_edits[key] = edit
form.addRow(title, edit)
scroll_content.setLayout(form)
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setFrameShape(QScrollArea.NoFrame)
scroll_area.setWidget(scroll_content)
reset = QPushButton("Сбросить по умолчанию")
reset.clicked.connect(self._reset_shortcuts)
layout.addWidget(scroll_area, 1)
layout.addWidget(reset)
layout.addWidget(QLabel("Сочетания работают по физическим клавишам и не зависят от раскладки клавиатуры."))
return page
def accept(self) -> None:
duplicates: dict[str, list[str]] = {}
for key, sequence in self._shortcuts().items():
if not sequence:
continue
duplicates.setdefault(sequence, []).append(key)
conflict = next((keys for keys in duplicates.values() if len(keys) > 1), None)
if conflict:
names = {key: title for key, title, _shortcut in SHORTCUT_DEFINITIONS}
show_warning(
self,
"Конфликт сочетаний",
"Одно сочетание назначено нескольким действиям:\n"
+ "\n".join(names.get(key, key) for key in conflict),
)
return
super().accept()
def _shortcuts(self) -> dict[str, str]:
shortcuts: dict[str, str] = {}
for key, edit in self.shortcut_edits.items():
shortcuts[key] = edit.keySequence().toString(QKeySequence.SequenceFormat.PortableText)
return shortcuts
def _reset_shortcuts(self) -> None:
for key, edit in self.shortcut_edits.items():
edit.setKeySequence(QKeySequence(DEFAULT_SHORTCUTS.get(key, "")))
def _browse_output_dir(self) -> None:
start = self.output_dir.text().strip()
if not start or not Path(start).exists():
start = str(Path.home())
selected = QFileDialog.getExistingDirectory(self, "Папка вывода", start)
if selected:
self.output_dir.setText(selected)

599
theme.py Normal file
View File

@ -0,0 +1,599 @@
from __future__ import annotations
import sys
from pathlib import Path
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QPalette
from PySide6.QtWidgets import QApplication
ASSET_DIR = Path(__file__).resolve().parent / "assets"
def asset_url(name: str) -> str:
return ASSET_DIR.joinpath(name).as_posix()
def apply_app_theme(theme: str) -> None:
app = QApplication.instance()
if app is None:
return
effective = effective_theme(theme)
app.setStyle("Fusion")
if effective == "dark":
app.setPalette(dark_palette())
app.setStyleSheet(dark_stylesheet())
else:
app.setPalette(light_palette())
app.setStyleSheet(light_stylesheet())
def effective_theme(theme: str) -> str:
if theme == "dark":
return "dark"
if theme == "light":
return "light"
return system_theme()
def system_theme() -> str:
app = QApplication.instance()
if app is not None:
try:
color_scheme = app.styleHints().colorScheme()
if color_scheme == Qt.ColorScheme.Dark:
return "dark"
if color_scheme == Qt.ColorScheme.Light:
return "light"
except (AttributeError, TypeError):
pass
if sys.platform.startswith("win"):
try:
import winreg
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
) as key:
apps_use_light_theme, _ = winreg.QueryValueEx(key, "AppsUseLightTheme")
return "light" if int(apps_use_light_theme) else "dark"
except OSError:
pass
if app is not None:
return "dark" if app.style().standardPalette().color(QPalette.Window).lightness() < 128 else "light"
return "light"
def light_palette() -> QPalette:
palette = QPalette()
palette.setColor(QPalette.Window, QColor("#f8fafc"))
palette.setColor(QPalette.WindowText, QColor("#111827"))
palette.setColor(QPalette.Base, QColor("#ffffff"))
palette.setColor(QPalette.AlternateBase, QColor("#f1f5f9"))
palette.setColor(QPalette.ToolTipBase, QColor("#ffffff"))
palette.setColor(QPalette.ToolTipText, QColor("#111827"))
palette.setColor(QPalette.Text, QColor("#111827"))
palette.setColor(QPalette.Button, QColor("#e5e7eb"))
palette.setColor(QPalette.ButtonText, QColor("#111827"))
palette.setColor(QPalette.Highlight, QColor("#c084fc"))
palette.setColor(QPalette.HighlightedText, QColor("#111827"))
return palette
def dark_palette() -> QPalette:
palette = QPalette()
palette.setColor(QPalette.Window, QColor("#1f1f1f"))
palette.setColor(QPalette.WindowText, QColor("#f8fafc"))
palette.setColor(QPalette.Base, QColor("#2b2b2b"))
palette.setColor(QPalette.AlternateBase, QColor("#353535"))
palette.setColor(QPalette.ToolTipBase, QColor("#111827"))
palette.setColor(QPalette.ToolTipText, QColor("#f8fafc"))
palette.setColor(QPalette.Text, QColor("#f8fafc"))
palette.setColor(QPalette.Button, QColor("#2f2f2f"))
palette.setColor(QPalette.ButtonText, QColor("#f8fafc"))
palette.setColor(QPalette.Highlight, QColor("#c084fc"))
palette.setColor(QPalette.HighlightedText, QColor("#111827"))
return palette
def light_stylesheet() -> str:
return """
QWidget {
background-color: #f8fafc;
color: #111827;
selection-background-color: #93c5fd;
selection-color: #111827;
}
QGraphicsView {
background-color: #ffffff;
border: none;
}
QMenuBar {
background-color: #f1f5f9;
color: #111827;
}
QMenuBar::item {
background: transparent;
padding: 5px 10px;
}
QMenuBar::item:selected {
background-color: #e2e8f0;
}
QMenu {
background-color: #ffffff;
color: #111827;
border: 1px solid #cbd5e1;
}
QMenu::item {
padding: 5px 24px 5px 24px;
}
QMenu::item:selected {
background-color: #dbeafe;
}
QMenu::item:disabled {
color: #94a3b8;
background-color: transparent;
}
QMenu::item:selected:disabled {
color: #94a3b8;
background-color: transparent;
}
QPushButton, QToolButton {
background-color: #e5e7eb;
color: #111827;
border: 1px solid #cbd5e1;
border-radius: 4px;
padding: 4px 8px;
}
QPushButton:hover, QToolButton:hover {
background-color: #dbeafe;
}
QPushButton:disabled, QToolButton:disabled {
background-color: #f1f5f9;
color: #94a3b8;
border-color: #e2e8f0;
}
QTreeWidget, QTableWidget, QListWidget, QLineEdit, QComboBox, QSpinBox {
background-color: #ffffff;
color: #111827;
alternate-background-color: #f1f5f9;
border: 1px solid #cbd5e1;
border-radius: 4px;
min-height: 24px;
selection-background-color: #bfdbfe;
selection-color: #111827;
}
QLineEdit, QComboBox, QSpinBox {
padding: 2px 8px;
}
QComboBox {
padding-right: 28px;
}
QComboBox:hover, QSpinBox:hover, QLineEdit:hover {
border-color: #94a3b8;
}
QComboBox:focus, QSpinBox:focus, QLineEdit:focus {
border-color: #60a5fa;
}
QComboBox::drop-down {
subcontrol-origin: padding;
subcontrol-position: top right;
width: 24px;
border-left: 1px solid #cbd5e1;
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
background-color: #f1f5f9;
}
QComboBox::drop-down:hover {
background-color: #e2e8f0;
}
QComboBox::down-arrow {
image: url("__INPUT_DOWN_ARROW__");
width: 10px;
height: 10px;
}
QSpinBox {
padding-right: 28px;
}
QSpinBox::up-button, QSpinBox::down-button {
subcontrol-origin: border;
width: 24px;
border-left: 1px solid #cbd5e1;
background-color: #f1f5f9;
}
QSpinBox::up-button {
subcontrol-position: top right;
border-top-right-radius: 4px;
}
QSpinBox::down-button {
subcontrol-position: bottom right;
border-bottom-right-radius: 4px;
}
QSpinBox::up-button:hover, QSpinBox::down-button:hover {
background-color: #e2e8f0;
}
QSpinBox::up-arrow {
image: url("__INPUT_UP_ARROW__");
width: 10px;
height: 10px;
}
QSpinBox::down-arrow {
image: url("__INPUT_DOWN_ARROW__");
width: 10px;
height: 10px;
}
QComboBox QAbstractItemView {
background-color: #ffffff;
color: #111827;
border: 1px solid #cbd5e1;
selection-background-color: #bfdbfe;
selection-color: #111827;
}
QHeaderView::section {
background-color: #e5e7eb;
color: #111827;
border: 1px solid #cbd5e1;
padding: 4px;
}
QSplitter::handle {
background-color: #e2e8f0;
}
QStatusBar {
background-color: #f1f5f9;
color: #111827;
}
QToolTip {
background-color: #ffffff;
color: #111827;
border: 1px solid #cbd5e1;
}
QScrollBar:vertical {
background: #f1f5f9;
width: 12px;
margin: 12px 0 12px 0;
border: none;
}
QScrollBar::handle:vertical {
background: #94a3b8;
min-height: 28px;
margin: 0 4px;
border-radius: 2px;
}
QScrollBar::handle:vertical:hover {
background: #64748b;
margin: 0 2px;
border-radius: 4px;
}
QScrollBar::add-page:vertical,
QScrollBar::sub-page:vertical {
background: transparent;
border: none;
height: 0;
}
QScrollBar::sub-line:vertical,
QScrollBar::add-line:vertical {
background: #f1f5f9;
border: none;
height: 12px;
subcontrol-origin: margin;
}
QScrollBar::sub-line:vertical {
subcontrol-position: top;
}
QScrollBar::add-line:vertical {
subcontrol-position: bottom;
}
QScrollBar::up-arrow:vertical {
image: url("__UP_ARROW__");
width: 8px;
height: 8px;
}
QScrollBar::down-arrow:vertical {
image: url("__DOWN_ARROW__");
width: 8px;
height: 8px;
}
QScrollBar:horizontal {
background: #f1f5f9;
height: 12px;
margin: 0 12px 0 12px;
border: none;
}
QScrollBar::handle:horizontal {
background: #94a3b8;
min-width: 28px;
margin: 4px 0;
border-radius: 2px;
}
QScrollBar::handle:horizontal:hover {
background: #64748b;
margin: 2px 0;
border-radius: 4px;
}
QScrollBar::add-page:horizontal,
QScrollBar::sub-page:horizontal {
background: transparent;
border: none;
width: 0;
}
QScrollBar::sub-line:horizontal,
QScrollBar::add-line:horizontal {
background: #f1f5f9;
border: none;
width: 12px;
subcontrol-origin: margin;
}
QScrollBar::sub-line:horizontal {
subcontrol-position: left;
}
QScrollBar::add-line:horizontal {
subcontrol-position: right;
}
QScrollBar::left-arrow:horizontal {
image: url("__LEFT_ARROW__");
width: 8px;
height: 8px;
}
QScrollBar::right-arrow:horizontal {
image: url("__RIGHT_ARROW__");
width: 8px;
height: 8px;
}
""".replace("__INPUT_UP_ARROW__", asset_url("input-arrow-up-light.svg")) \
.replace("__INPUT_DOWN_ARROW__", asset_url("input-arrow-down-light.svg")) \
.replace("__UP_ARROW__", asset_url("scrollbar-arrow-up-light.svg")) \
.replace("__DOWN_ARROW__", asset_url("scrollbar-arrow-down-light.svg")) \
.replace("__LEFT_ARROW__", asset_url("scrollbar-arrow-left-light.svg")) \
.replace("__RIGHT_ARROW__", asset_url("scrollbar-arrow-right-light.svg"))
def dark_stylesheet() -> str:
return """
QWidget {
background-color: #1f1f1f;
color: #f8fafc;
selection-background-color: #3b82f6;
selection-color: #f8fafc;
}
QGraphicsView {
background-color: #2b2b2b;
border: none;
}
QMenuBar {
background-color: #242424;
color: #f8fafc;
}
QMenuBar::item {
background: transparent;
padding: 5px 10px;
}
QMenuBar::item:selected {
background-color: #343434;
}
QMenu {
background-color: #242424;
color: #f8fafc;
border: 1px solid #444444;
}
QMenu::item {
padding: 5px 24px 5px 24px;
}
QMenu::item:selected {
background-color: #3a3a3a;
}
QMenu::item:disabled {
color: #737373;
background-color: transparent;
}
QMenu::item:selected:disabled {
color: #737373;
background-color: transparent;
}
QPushButton, QToolButton {
background-color: #2f2f2f;
color: #f8fafc;
border: 1px solid #4b5563;
border-radius: 4px;
padding: 4px 8px;
}
QPushButton:hover, QToolButton:hover {
background-color: #3f3f46;
}
QPushButton:disabled, QToolButton:disabled {
background-color: #262626;
color: #737373;
border-color: #3a3a3a;
}
QTreeWidget, QTableWidget, QListWidget, QLineEdit, QComboBox, QSpinBox {
background-color: #2b2b2b;
color: #f8fafc;
alternate-background-color: #353535;
border: 1px solid #444444;
border-radius: 4px;
min-height: 24px;
selection-background-color: #3b82f6;
selection-color: #f8fafc;
}
QLineEdit, QComboBox, QSpinBox {
padding: 2px 8px;
}
QComboBox {
padding-right: 28px;
}
QComboBox:hover, QSpinBox:hover, QLineEdit:hover {
border-color: #737373;
}
QComboBox:focus, QSpinBox:focus, QLineEdit:focus {
border-color: #60a5fa;
}
QComboBox::drop-down {
subcontrol-origin: padding;
subcontrol-position: top right;
width: 24px;
border-left: 1px solid #444444;
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
background-color: #262626;
}
QComboBox::drop-down:hover {
background-color: #3f3f46;
}
QComboBox::down-arrow {
image: url("__INPUT_DOWN_ARROW__");
width: 10px;
height: 10px;
}
QSpinBox {
padding-right: 28px;
}
QSpinBox::up-button, QSpinBox::down-button {
subcontrol-origin: border;
width: 24px;
border-left: 1px solid #444444;
background-color: #262626;
}
QSpinBox::up-button {
subcontrol-position: top right;
border-top-right-radius: 4px;
}
QSpinBox::down-button {
subcontrol-position: bottom right;
border-bottom-right-radius: 4px;
}
QSpinBox::up-button:hover, QSpinBox::down-button:hover {
background-color: #3f3f46;
}
QSpinBox::up-arrow {
image: url("__INPUT_UP_ARROW__");
width: 10px;
height: 10px;
}
QSpinBox::down-arrow {
image: url("__INPUT_DOWN_ARROW__");
width: 10px;
height: 10px;
}
QComboBox QAbstractItemView {
background-color: #2b2b2b;
color: #f8fafc;
border: 1px solid #444444;
selection-background-color: #3b82f6;
selection-color: #f8fafc;
}
QHeaderView::section {
background-color: #3a3a3a;
color: #f8fafc;
border: 1px solid #444444;
padding: 4px;
}
QSplitter::handle {
background-color: #111827;
}
QStatusBar {
background-color: #1f1f1f;
color: #f8fafc;
}
QToolTip {
background-color: #111827;
color: #f8fafc;
border: 1px solid #4b5563;
}
QScrollBar:vertical {
background: #1f1f1f;
width: 12px;
margin: 12px 0 12px 0;
border: none;
}
QScrollBar::handle:vertical {
background: #737373;
min-height: 28px;
margin: 0 4px;
border-radius: 2px;
}
QScrollBar::handle:vertical:hover {
background: #a3a3a3;
margin: 0 2px;
border-radius: 4px;
}
QScrollBar::add-page:vertical,
QScrollBar::sub-page:vertical {
background: transparent;
border: none;
height: 0;
}
QScrollBar::sub-line:vertical,
QScrollBar::add-line:vertical {
background: #1f1f1f;
border: none;
height: 12px;
subcontrol-origin: margin;
}
QScrollBar::sub-line:vertical {
subcontrol-position: top;
}
QScrollBar::add-line:vertical {
subcontrol-position: bottom;
}
QScrollBar::up-arrow:vertical {
image: url("__UP_ARROW__");
width: 8px;
height: 8px;
}
QScrollBar::down-arrow:vertical {
image: url("__DOWN_ARROW__");
width: 8px;
height: 8px;
}
QScrollBar:horizontal {
background: #1f1f1f;
height: 12px;
margin: 0 12px 0 12px;
border: none;
}
QScrollBar::handle:horizontal {
background: #737373;
min-width: 28px;
margin: 4px 0;
border-radius: 2px;
}
QScrollBar::handle:horizontal:hover {
background: #a3a3a3;
margin: 2px 0;
border-radius: 4px;
}
QScrollBar::add-page:horizontal,
QScrollBar::sub-page:horizontal {
background: transparent;
border: none;
width: 0;
}
QScrollBar::sub-line:horizontal,
QScrollBar::add-line:horizontal {
background: #1f1f1f;
border: none;
width: 12px;
subcontrol-origin: margin;
}
QScrollBar::sub-line:horizontal {
subcontrol-position: left;
}
QScrollBar::add-line:horizontal {
subcontrol-position: right;
}
QScrollBar::left-arrow:horizontal {
image: url("__LEFT_ARROW__");
width: 8px;
height: 8px;
}
QScrollBar::right-arrow:horizontal {
image: url("__RIGHT_ARROW__");
width: 8px;
height: 8px;
}
""".replace("__INPUT_UP_ARROW__", asset_url("input-arrow-up-dark.svg")) \
.replace("__INPUT_DOWN_ARROW__", asset_url("input-arrow-down-dark.svg")) \
.replace("__UP_ARROW__", asset_url("scrollbar-arrow-up-dark.svg")) \
.replace("__DOWN_ARROW__", asset_url("scrollbar-arrow-down-dark.svg")) \
.replace("__LEFT_ARROW__", asset_url("scrollbar-arrow-left-dark.svg")) \
.replace("__RIGHT_ARROW__", asset_url("scrollbar-arrow-right-dark.svg"))

348
update_service.py Normal file
View File

@ -0,0 +1,348 @@
"""Безопасная проверка и установка Windows-релизов LED Mapper."""
from __future__ import annotations
import json
import os
import platform
import re
import subprocess
import sys
import tempfile
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import BinaryIO, Callable
from urllib.parse import urlparse
from urllib.request import Request, urlopen
try:
from .version import __version__
except ImportError:
from version import __version__
RELEASE_API_URL = "https://git.robotail.pro/api/v1/repos/Robotail/LED-mapper/releases/latest"
RELEASE_PAGE_URL = "https://git.robotail.pro/Robotail/LED-mapper/releases"
RELEASE_HOST = "git.robotail.pro"
READY_ENV = "LEDMAPPER_UPDATE_READY_FILE"
MAX_METADATA_BYTES = 1024 * 1024
MAX_DOWNLOAD_BYTES = 200 * 1024 * 1024
CHUNK_BYTES = 1024 * 1024
VERSION_RE = re.compile(r"v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)\Z")
EXECUTABLE_RE = re.compile(r"LEDMapper-(v\d+\.\d+\.\d+)\.exe\Z", re.IGNORECASE)
class UpdateError(RuntimeError):
"""Проверка, загрузка или установка обновления не выполнена."""
class UpdateCancelled(UpdateError):
"""Загрузка отменена пользователем."""
@dataclass(frozen=True)
class UpdateInfo:
tag: str
version: str
asset_name: str
download_url: str
size: int
def parse_version(value: str) -> tuple[int, int, int]:
match = VERSION_RE.fullmatch(value.strip())
if match is None:
raise UpdateError("Версия релиза должна иметь формат vX.Y.Z")
return tuple(int(part) for part in match.groups())
def current_version() -> str:
if getattr(sys, "frozen", False):
bundle_dir = getattr(sys, "_MEIPASS", None)
if bundle_dir is not None:
try:
bundled_version = (Path(bundle_dir) / "ledmapper-build-version.txt").read_text(encoding="utf-8").strip()
if VERSION_RE.fullmatch(bundled_version):
return bundled_version
except OSError:
pass
match = EXECUTABLE_RE.fullmatch(Path(sys.executable).name)
if match is not None:
return match.group(1)
return __version__
def _trusted_url(value: str) -> bool:
parsed = urlparse(value)
return (
parsed.scheme == "https"
and parsed.hostname == RELEASE_HOST
and parsed.port in (None, 443)
and not parsed.username
and not parsed.password
and not parsed.fragment
)
def check_for_update(
installed_version: str,
*,
opener: Callable[..., BinaryIO] | None = None,
timeout: float = 10.0,
) -> UpdateInfo | None:
"""Возвращает только новый релиз с ровно одним подходящим Windows EXE."""
installed = parse_version(installed_version)
if platform.system() != "Windows":
raise UpdateError("Автообновление доступно только в Windows")
request = Request(
RELEASE_API_URL,
headers={"Accept": "application/json", "User-Agent": "LEDMapper-Updater"},
)
try:
with (opener or urlopen)(request, timeout=timeout) as response:
if not _trusted_url(response.geturl()):
raise UpdateError("Сервер релизов перенаправил запрос на другой сайт")
payload = response.read(MAX_METADATA_BYTES + 1)
if len(payload) > MAX_METADATA_BYTES:
raise UpdateError("Ответ сервера релизов слишком велик")
release = json.loads(payload)
except UpdateError:
raise
except (OSError, ValueError, TypeError) as exc:
raise UpdateError(f"Не удалось проверить обновления: {exc}") from exc
if not isinstance(release, dict) or release.get("draft") or release.get("prerelease"):
raise UpdateError("Сервер не вернул опубликованный стабильный релиз")
tag = release.get("tag_name")
if not isinstance(tag, str) or not tag.startswith("v"):
raise UpdateError("У релиза отсутствует корректный тег")
remote = parse_version(tag)
if remote <= installed:
return None
expected_name = f"LEDMapper-{tag}.exe"
assets = release.get("assets")
if not isinstance(assets, list):
raise UpdateError("У релиза отсутствует список артефактов")
matching = [asset for asset in assets if isinstance(asset, dict) and asset.get("name") == expected_name]
if len(matching) != 1:
raise UpdateError(f"Не найден Windows-артефакт {expected_name}")
asset = matching[0]
download_url = asset.get("browser_download_url")
expected_path = f"/Robotail/LED-mapper/releases/download/{tag}/{expected_name}"
if not isinstance(download_url, str) or not _trusted_url(download_url) or urlparse(download_url).path != expected_path:
raise UpdateError("Релиз содержит неожиданный адрес артефакта")
size = asset.get("size")
if isinstance(size, bool) or not isinstance(size, int) or not 0 < size <= MAX_DOWNLOAD_BYTES:
raise UpdateError("Релиз содержит недопустимый размер артефакта")
return UpdateInfo(tag, tag.removeprefix("v"), expected_name, download_url, size)
def _check_executable(path: Path) -> None:
with path.open("rb") as source:
header = source.read(64)
if len(header) < 64 or header[:2] != b"MZ":
raise UpdateError("Скачанный файл не является Windows EXE")
pe_offset = int.from_bytes(header[0x3C:0x40], "little")
if not 64 <= pe_offset <= 1024 * 1024:
raise UpdateError("Некорректный заголовок Windows EXE")
source.seek(pe_offset)
if source.read(4) != b"PE\x00\x00":
raise UpdateError("Некорректный заголовок Windows EXE")
if source.read(2) != b"\x64\x86":
raise UpdateError("Обновление не предназначено для Windows x64")
def download_update(
info: UpdateInfo,
directory: Path,
*,
opener: Callable[..., BinaryIO] | None = None,
progress: Callable[[int], None] | None = None,
cancelled: Callable[[], bool] | None = None,
timeout: float = 30.0,
) -> Path:
"""Скачивает артефакт в отдельный временный файл и проверяет размер/PE."""
target_dir = directory.resolve()
if not target_dir.is_dir():
raise UpdateError("Каталог приложения не найден")
if not os.access(target_dir, os.W_OK):
raise UpdateError("Нет прав на запись в каталог приложения")
if not _trusted_url(info.download_url) or urlparse(info.download_url).path != (
f"/Robotail/LED-mapper/releases/download/{info.tag}/{info.asset_name}"
):
raise UpdateError("Недопустимый адрес загрузки обновления")
expected_path = f"/Robotail/LED-mapper/releases/download/{info.tag}/{info.asset_name}"
path: Path | None = None
received = 0
request = Request(info.download_url, headers={"User-Agent": "LEDMapper-Updater"})
try:
with (opener or urlopen)(request, timeout=timeout) as response:
if not _trusted_url(response.geturl()):
raise UpdateError("Загрузка перенаправлена на другой сайт")
final_path = urlparse(response.geturl()).path
if final_path == "/user/login":
raise UpdateError("Release-артефакты недоступны без авторизации")
if final_path != expected_path:
raise UpdateError("Загрузка перенаправлена на неожиданный адрес")
content_length = getattr(response, "headers", {}).get("Content-Length")
if content_length is not None:
try:
declared_size = int(content_length)
except (TypeError, ValueError) as exc:
raise UpdateError("Сервер вернул некорректный размер обновления") from exc
if declared_size != info.size:
raise UpdateError("Размер обновления не совпадает с данными релиза")
with tempfile.NamedTemporaryFile(
mode="wb", dir=target_dir, prefix=".ledmapper-update-", suffix=".download", delete=False
) as output:
path = Path(output.name)
while True:
if cancelled is not None and cancelled():
raise UpdateCancelled("Загрузка обновления отменена")
chunk = response.read(CHUNK_BYTES)
if not chunk:
break
received += len(chunk)
if received > MAX_DOWNLOAD_BYTES or received > info.size:
raise UpdateError("Артефакт превышает объявленный размер")
output.write(chunk)
if progress is not None:
progress(min(received * 100 // info.size, 100))
output.flush()
os.fsync(output.fileno())
if received != info.size:
raise UpdateError("Артефакт скачан не полностью")
_check_executable(path)
if progress is not None:
progress(100)
return path
except (UpdateError, OSError) as exc:
if path is not None:
path.unlink(missing_ok=True)
if isinstance(exc, UpdateError):
raise
raise UpdateError(f"Не удалось скачать обновление: {exc}") from exc
def current_executable() -> Path:
if not getattr(sys, "frozen", False):
raise UpdateError("Установка обновления доступна только в собранном EXE")
return Path(sys.executable).resolve()
def signal_update_ready() -> None:
marker = os.environ.pop(READY_ENV, None)
if marker is None:
return
try:
path = Path(marker).resolve()
if path.parent != current_executable().parent or not path.name.startswith(".ledmapper-ready-"):
return
path.write_text(str(os.getpid()), encoding="ascii")
except (OSError, UpdateError):
pass
def _write_windows_updater(path: Path) -> None:
script = r'''param(
[int]$ApplicationProcessId,
[string]$Source,
[string]$OldTarget,
[string]$NewTarget,
[string]$Backup,
[string]$ReadyFile
)
$ErrorActionPreference = "Stop"
$applicationDirectory = Split-Path -Parent $OldTarget
$errorLog = Join-Path $applicationDirectory "update-error.log"
$restarted = $null
$oldMoved = $false
$newMoved = $false
try {
Wait-Process -Id $ApplicationProcessId -ErrorAction SilentlyContinue
# Родительский процесс PyInstaller может ещё удерживать EXE при очистке.
$moveDeadline = (Get-Date).AddSeconds(30)
while (-not $oldMoved) {
try {
Move-Item -LiteralPath $OldTarget -Destination $Backup -ErrorAction Stop
$oldMoved = $true
} catch {
if ((Get-Date) -ge $moveDeadline) { throw }
Start-Sleep -Milliseconds 250
}
}
if (Test-Path -LiteralPath $NewTarget) { throw "Целевой EXE уже существует" }
Move-Item -LiteralPath $Source -Destination $NewTarget -ErrorAction Stop
$newMoved = $true
$env:LEDMAPPER_UPDATE_READY_FILE = $ReadyFile
$env:PYINSTALLER_RESET_ENVIRONMENT = "1"
$restarted = Start-Process -FilePath $NewTarget -WorkingDirectory $applicationDirectory -PassThru
$deadline = (Get-Date).AddSeconds(60)
while (-not (Test-Path -LiteralPath $ReadyFile -PathType Leaf)) {
if ($restarted.HasExited) { throw "Новая версия завершилась до открытия окна" }
if ((Get-Date) -ge $deadline) { throw "Новая версия не подтвердила запуск" }
Start-Sleep -Milliseconds 250
}
if ($restarted.HasExited) { throw "Новая версия завершилась сразу после запуска" }
Remove-Item -LiteralPath $ReadyFile -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $Backup -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $MyInvocation.MyCommand.Path -Force -ErrorAction SilentlyContinue
} catch {
$failure = $_.ToString()
if ($restarted -and -not $restarted.HasExited) {
& taskkill.exe /PID $restarted.Id /T /F *> $null
}
if ($newMoved -and (Test-Path -LiteralPath $NewTarget)) {
Remove-Item -LiteralPath $NewTarget -Force -ErrorAction SilentlyContinue
}
if ($oldMoved -and (Test-Path -LiteralPath $Backup)) {
Move-Item -LiteralPath $Backup -Destination $OldTarget -Force -ErrorAction SilentlyContinue
}
Remove-Item -LiteralPath $ReadyFile -Force -ErrorAction SilentlyContinue
$failure | Out-File -LiteralPath $errorLog -Encoding utf8
Remove-Item -LiteralPath $MyInvocation.MyCommand.Path -Force -ErrorAction SilentlyContinue
exit 1
}
'''
path.write_text(script, encoding="utf-8-sig", newline="\r\n")
def launch_update(downloaded: Path, info: UpdateInfo, *, process_id: int | None = None) -> None:
"""Запускает отдельный helper; замену EXE он делает после выхода приложения."""
old_target = current_executable()
source = downloaded.resolve()
if platform.system() != "Windows":
raise UpdateError("Установка обновления доступна только в Windows")
if not os.access(old_target.parent, os.W_OK):
raise UpdateError("Нет прав на замену приложения")
if source.parent != old_target.parent or not source.is_file() or not source.name.startswith(".ledmapper-update-"):
raise UpdateError("Скачанный файл должен находиться рядом с приложением")
if not EXECUTABLE_RE.fullmatch(info.asset_name):
raise UpdateError("Недопустимое имя Windows-артефакта")
new_target = old_target
if source.stat().st_size != info.size:
raise UpdateError("Размер скачанного артефакта изменился")
_check_executable(source)
token = uuid.uuid4().hex
script_path = old_target.parent / f".ledmapper-updater-{token}.ps1"
backup_path = old_target.parent / f".ledmapper-backup-{token}.exe"
ready_path = old_target.parent / f".ledmapper-ready-{token}"
try:
_write_windows_updater(script_path)
creation_flags = (
getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
| getattr(subprocess, "CREATE_NO_WINDOW", 0)
)
subprocess.Popen(
[
"powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File",
str(script_path), str(process_id or os.getpid()), str(source), str(old_target),
str(new_target), str(backup_path), str(ready_path),
],
close_fds=True,
creationflags=creation_flags,
)
except (OSError, UpdateError) as exc:
script_path.unlink(missing_ok=True)
if isinstance(exc, UpdateError):
raise
raise UpdateError(f"Не удалось запустить установщик: {exc}") from exc

3
version.py Normal file
View File

@ -0,0 +1,3 @@
"""Текущая версия приложения; обновляется при публикации релиза."""
__version__ = "3.5.0"