Compare commits
No commits in common. "master" and "v1.0" have entirely different histories.
114
LEDMapper.spec
@ -1,114 +1,12 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
|
|
||||||
spec_dir = Path(SPECPATH)
|
|
||||||
|
|
||||||
|
|
||||||
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:
|
|
||||||
tag = subprocess.check_output(
|
|
||||||
['git', 'describe', '--tags', '--exact-match', 'HEAD'],
|
|
||||||
cwd=spec_dir,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
text=True,
|
|
||||||
).strip()
|
|
||||||
if tag:
|
|
||||||
return tag
|
|
||||||
except (OSError, subprocess.CalledProcessError):
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
commit = subprocess.check_output(
|
|
||||||
['git', 'rev-parse', '--short', 'HEAD'],
|
|
||||||
cwd=spec_dir,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
text=True,
|
|
||||||
).strip()
|
|
||||||
if commit:
|
|
||||||
return commit
|
|
||||||
except (OSError, subprocess.CalledProcessError):
|
|
||||||
pass
|
|
||||||
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()}'
|
|
||||||
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')],
|
['main.py'],
|
||||||
pathex=[str(spec_dir), str(spec_dir.parent)],
|
pathex=['.'],
|
||||||
binaries=[],
|
binaries=[],
|
||||||
datas=[(str(spec_dir / 'assets'), 'assets'), (str(build_version_path), '.')],
|
datas=[],
|
||||||
hiddenimports=[
|
hiddenimports=[],
|
||||||
'canvas',
|
|
||||||
'background_image_loader',
|
|
||||||
'background_dialog',
|
|
||||||
'icons',
|
|
||||||
'localization',
|
|
||||||
'main_window',
|
|
||||||
'mapping_renderer',
|
|
||||||
'models',
|
|
||||||
'panel_mapping_generator',
|
|
||||||
'settings',
|
|
||||||
'settings_dialog',
|
|
||||||
'theme',
|
|
||||||
'unfolding',
|
|
||||||
'update_service',
|
|
||||||
'version',
|
|
||||||
],
|
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
runtime_hooks=[],
|
runtime_hooks=[],
|
||||||
@ -116,7 +14,6 @@ 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(
|
||||||
@ -125,7 +22,7 @@ exe = EXE(
|
|||||||
a.binaries,
|
a.binaries,
|
||||||
a.datas,
|
a.datas,
|
||||||
[],
|
[],
|
||||||
name=app_name,
|
name='LEDMapper',
|
||||||
debug=False,
|
debug=False,
|
||||||
bootloader_ignore_signals=False,
|
bootloader_ignore_signals=False,
|
||||||
strip=False,
|
strip=False,
|
||||||
@ -138,5 +35,4 @@ exe = EXE(
|
|||||||
target_arch=None,
|
target_arch=None,
|
||||||
codesign_identity=None,
|
codesign_identity=None,
|
||||||
entitlements_file=None,
|
entitlements_file=None,
|
||||||
icon=str(spec_dir / 'assets' / 'ledmapper.ico'),
|
|
||||||
)
|
)
|
||||||
|
|||||||
54
README.md
@ -10,24 +10,19 @@ LED Mapper — настольное приложение на Python и PySide6
|
|||||||
- Отображение схемы в реальных координатах.
|
- Отображение схемы в реальных координатах.
|
||||||
- Выделение точек мышью и работа через таблицу выбранных LED.
|
- Выделение точек мышью и работа через таблицу выбранных LED.
|
||||||
- До 4 физических линий светодиодных лент.
|
- До 4 физических линий светодиодных лент.
|
||||||
- Нумерация линий и блоков с `0`.
|
|
||||||
- Блоки внутри линий, включая пустые блоки.
|
- Блоки внутри линий, включая пустые блоки.
|
||||||
- Индексация LED отдельно для каждой линии, начиная с `0`.
|
- Индексация LED отдельно для каждой линии, начиная с `0`.
|
||||||
- Индексация внутри каждого блока.
|
- Индексация внутри каждого блока.
|
||||||
- Добавление блоков вручную, из выделения, автоблоками и кистью.
|
- Добавление блоков вручную, из выделения, автоблоками и кистью.
|
||||||
- Продолжение существующего блока кистью от последней точки блока.
|
- Продолжение существующего блока кистью от последней точки блока.
|
||||||
- Предпросмотр зоны действия кисти и точек, которые будут добавлены.
|
- Предпросмотр зоны действия кисти и точек, которые будут добавлены.
|
||||||
- Сетка с настраиваемым шагом (по умолчанию 34 мм) во всех режимах редактирования и генерации.
|
- Сетка 34 мм.
|
||||||
- Настраиваемые абсолютные размеры LED-точек и текста их индексов: визуальный размер не меняется при приближении.
|
- Undo/redo для операций разметки.
|
||||||
- Свободные многострочные текстовые комментарии на схеме.
|
|
||||||
- Undo/redo для операций разметки и комментариев.
|
|
||||||
- Удаление назначений через `Del` и контекстные меню.
|
- Удаление назначений через `Del` и контекстные меню.
|
||||||
- Пакетное удаление выбранных блоков через дерево одной операцией с единым Undo.
|
|
||||||
- Сворачивание боковой панели.
|
- Сворачивание боковой панели.
|
||||||
- Сортировка таблицы выбранных точек по столбцам.
|
- Сортировка таблицы выбранных точек по столбцам.
|
||||||
- Генерация `vm_map.h`, `ordered_circles.json`, `validation_report.txt` и README для результата.
|
- Генерация `vm_map.h`, `virtual_matrix_legacy.ino.txt`, `ordered_circles.json`, `validation_report.txt` и README для результата.
|
||||||
- Сборка самостоятельного Windows EXE через PyInstaller.
|
- Сборка самостоятельного Windows EXE через PyInstaller.
|
||||||
- Проверка и установка Windows-обновлений из релизов Gitea.
|
|
||||||
|
|
||||||
## Структура приложения
|
## Структура приложения
|
||||||
|
|
||||||
@ -37,8 +32,6 @@ 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-тесты приложения
|
||||||
@ -90,28 +83,8 @@ 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 и отображаются в редакторе.
|
|
||||||
|
|
||||||
## Горячие клавиши
|
## Горячие клавиши
|
||||||
|
|
||||||
| Действие | Клавиши |
|
| Действие | Клавиши |
|
||||||
@ -122,15 +95,11 @@ 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` |
|
||||||
| Сетка | `G` |
|
| Сетка 34 мм | `G` |
|
||||||
| Стрелки направления | `Ctrl+Shift+A` |
|
|
||||||
| Скрыть/показать боковую панель | `F9` |
|
| Скрыть/показать боковую панель | `F9` |
|
||||||
| Новая линия | `Ctrl+L` |
|
| Новая линия | `Ctrl+L` |
|
||||||
| Автоблоки | `Ctrl+Shift+B` |
|
| Автоблоки | `Ctrl+Shift+B` |
|
||||||
@ -141,19 +110,14 @@ 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`
|
- `strip = line_id - 1`
|
||||||
- `led_id = line_index`
|
- `led_id = line_index`
|
||||||
|
|
||||||
Неназначенные точки не попадают в рабочий `VM_MAPPINGS`, но исходные точки могут оставаться в служебных метаданных.
|
Неназначенные точки не попадают в рабочий `VM_MAPPINGS`, но исходные точки могут оставаться в служебных метаданных.
|
||||||
@ -197,12 +161,10 @@ JSON схемы хранит `coordinate_unit`, `grid_step_mm` и `index_base: 0
|
|||||||
.\.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-vX.Y.Z.exe
|
dist/LEDMapper.exe
|
||||||
```
|
```
|
||||||
|
|
||||||
`build/` и `dist/` не коммитятся, потому что это воспроизводимые артефакты сборки.
|
`build/` и `dist/` не коммитятся, потому что это воспроизводимые артефакты сборки.
|
||||||
|
|||||||
@ -1,3 +1 @@
|
|||||||
"""LED Mapper Editor application package."""
|
"""LED Mapper Editor application package."""
|
||||||
|
|
||||||
from .version import __version__
|
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 130 B |
@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 130 B |
@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 130 B |
@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 130 B |
|
Before Width: | Height: | Size: 264 KiB |
@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 124 B |
@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 124 B |
@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 124 B |
@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 124 B |
@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 124 B |
@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 124 B |
@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 124 B |
@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 124 B |
@ -1,127 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,147 +0,0 @@
|
|||||||
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()
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
# Свободные текстовые комментарии — план
|
|
||||||
|
|
||||||
Цель: свободные многострочные заметки на схеме, согласованные пользователем 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. Работа ведётся поверх текущих файлов без отката.
|
|
||||||
15
icons.py
@ -7,39 +7,24 @@ from PySide6.QtSvg import QSvgRenderer
|
|||||||
|
|
||||||
LUCIDE_PATHS = {
|
LUCIDE_PATHS = {
|
||||||
"archive-restore": '<rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h2"/><path d="M20 8v11a2 2 0 0 1-2 2h-2"/><path d="m9 15 3-3 3 3"/><path d="M12 12v9"/>',
|
"archive-restore": '<rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h2"/><path d="M20 8v11a2 2 0 0 1-2 2h-2"/><path d="m9 15 3-3 3 3"/><path d="M12 12v9"/>',
|
||||||
"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"/>',
|
||||||
"file-code": '<path d="M14 2v6h6"/><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="m10 13-2 2 2 2"/><path d="m14 17 2-2-2-2"/>',
|
"file-code": '<path d="M14 2v6h6"/><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="m10 13-2 2 2 2"/><path d="m14 17 2-2-2-2"/>',
|
||||||
"folder-open": '<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6A2 2 0 0 1 18.46 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4.9a2 2 0 0 1 1.69.9L12 6h8a2 2 0 0 1 2 2v2"/><path d="M2 13h4"/>',
|
"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"/>',
|
|
||||||
"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"/>',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,98 +0,0 @@
|
|||||||
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,
|
|
||||||
)
|
|
||||||
29
main.py
@ -1,43 +1,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
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
|
from main_window import MainWindow
|
||||||
if str(APP_DIR) not in sys.path:
|
|
||||||
sys.path.insert(0, str(APP_DIR))
|
|
||||||
|
|
||||||
try:
|
|
||||||
from .icons import lucide_icon
|
|
||||||
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:
|
|
||||||
from icons import lucide_icon
|
|
||||||
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)
|
||||||
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)
|
|
||||||
window = MainWindow()
|
window = MainWindow()
|
||||||
window.setWindowIcon(app_icon)
|
|
||||||
window.show()
|
window.show()
|
||||||
QTimer.singleShot(500, signal_update_ready)
|
|
||||||
return app.exec()
|
return app.exec()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
2155
main_window.py
@ -1,503 +0,0 @@
|
|||||||
"""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))
|
|
||||||
@ -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,7 +36,6 @@ 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)
|
||||||
@ -70,72 +69,7 @@ class BuildResult:
|
|||||||
unassigned_count: int
|
unassigned_count: int
|
||||||
|
|
||||||
|
|
||||||
def remove_obsolete_legacy_output(output_dir: Path) -> None:
|
def generate_panel_mapping_from_document(document: MappingDocument, output_dir: str | Path) -> BuildResult:
|
||||||
"""Удалить только 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("Нет точек для генерации скрипта.")
|
||||||
|
|
||||||
@ -148,26 +82,20 @@ def generate_panel_mapping_from_document(
|
|||||||
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(
|
write_vm_map_header(generated_files["vm_map"], points, mapping_points, cells, block_count)
|
||||||
generated_files["vm_map"],
|
write_legacy_ino(generated_files["legacy_ino"], mapping_points, cells)
|
||||||
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, document.grid_step_mm)
|
write_validation_report(generated_files["validation_report"], points)
|
||||||
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(
|
||||||
@ -188,14 +116,10 @@ def generate_panel_mapping_from_document(
|
|||||||
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 = order_points_like_grid_svg(route_group)
|
||||||
route_group,
|
route = normalize_route_clockwise(route, min_neighbor_distance_for_points(route))
|
||||||
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)
|
||||||
@ -205,7 +129,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,
|
strip=(point.line_id - 1) if point.line_id is not None else None,
|
||||||
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,
|
||||||
@ -256,35 +180,12 @@ 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]],
|
||||||
document: MappingDocument,
|
block_count: int,
|
||||||
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")
|
||||||
@ -292,24 +193,21 @@ 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_NUM_STRIPS {NUM_STRIPS}\n")
|
|
||||||
file.write(f"#define LED_COUNT {len(mapping_points)}\n")
|
|
||||||
file.write(f"#define SOURCE_POINT_COUNT {len(all_points)}\n")
|
|
||||||
file.write(f"#define LED_BLOCK_COUNT {block_count}\n\n")
|
|
||||||
|
|
||||||
if generate_grid:
|
|
||||||
file.write(f"#define VM_ROWS {vm_rows}\n")
|
file.write(f"#define VM_ROWS {vm_rows}\n")
|
||||||
file.write(f"#define VM_COLS {vm_cols}\n")
|
file.write(f"#define VM_COLS {vm_cols}\n")
|
||||||
file.write("#define VM_CELLS (VM_ROWS * 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_MAX_IDS_PER_CELL {MAX_LEDS_PER_CELL}\n")
|
||||||
|
file.write(f"#define VM_NUM_STRIPS {NUM_STRIPS}\n")
|
||||||
|
file.write(f"#define LED_COUNT {len(mapping_points)}\n")
|
||||||
|
file.write(f"#define SOURCE_POINT_COUNT {len(all_points)}\n")
|
||||||
file.write(f"#define GRID_ROWS {grid_rows}\n")
|
file.write(f"#define GRID_ROWS {grid_rows}\n")
|
||||||
file.write(f"#define GRID_COLS {grid_cols}\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_MIN {min_row}\n")
|
||||||
file.write(f"#define GRID_ROW_MAX {max_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_MIN {min_col}\n")
|
||||||
file.write(f"#define GRID_COL_MAX {max_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(f"#define GRID_CELL_SIZE_MM {CELL_SIZE_MM:.1f}f\n")
|
||||||
|
file.write(f"#define LED_BLOCK_COUNT {block_count}\n\n")
|
||||||
file.write("// Source grid -> virtual matrix orientation used by this file.\n")
|
file.write("// 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 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("// VM column is the source row shifted to zero-based coordinates.\n")
|
||||||
@ -322,8 +220,6 @@ def write_vm_map_header(
|
|||||||
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")
|
||||||
|
|
||||||
if generate_grid:
|
|
||||||
file.write("struct GridCellRef {\n")
|
file.write("struct GridCellRef {\n")
|
||||||
file.write(" uint8_t row;\n")
|
file.write(" uint8_t row;\n")
|
||||||
file.write(" uint8_t col;\n")
|
file.write(" uint8_t col;\n")
|
||||||
@ -378,116 +274,123 @@ def write_vm_map_header(
|
|||||||
file.write(f" {', '.join(str(value) for value in values)}{suffix}\n")
|
file.write(f" {', '.join(str(value) for value in values)}{suffix}\n")
|
||||||
file.write("};\n\n")
|
file.write("};\n\n")
|
||||||
|
|
||||||
if generate_blocks:
|
|
||||||
# Generate compile-time block macros/constants
|
|
||||||
file.write("// Compile-time block indices\n")
|
|
||||||
for index, (offset, count, name) in enumerate(block_ranges):
|
|
||||||
identifier = sanitize_cpp_identifier(name)
|
|
||||||
file.write(f"#define BLOCK_{identifier} {index}\n")
|
|
||||||
file.write("\n")
|
|
||||||
|
|
||||||
file.write(f"#define TOTAL_BLOCK_LEDS {len(flat_block_leds)}\n\n")
|
def write_legacy_ino(
|
||||||
file.write("struct BlockRange {\n")
|
path: Path,
|
||||||
file.write(" uint16_t offset;\n")
|
points: list[PanelLedPoint],
|
||||||
file.write(" uint16_t count;\n")
|
cells: dict[tuple[int, int], list[PanelLedPoint]],
|
||||||
file.write("};\n\n")
|
) -> None:
|
||||||
|
vm_rows, vm_cols, _grid_rows, _grid_cols = get_dimensions(points)
|
||||||
file.write("// Flat list of all LEDs in blocks, ordered by block, and within block\n")
|
with path.open("w", encoding="utf-8", newline="\n") as file:
|
||||||
file.write("const LEDMap PROGMEM BLOCK_LEDS[TOTAL_BLOCK_LEDS] = {\n")
|
file.write("// ============================================================================\n")
|
||||||
for index, point in enumerate(flat_block_leds):
|
file.write("// ARCHIVE COPY - VIRTUAL MATRIX GENERATED BY LED Mapper GUI\n")
|
||||||
suffix = "," if index < len(flat_block_leds) - 1 else ""
|
file.write("// Saved as .ino.txt so Arduino IDE will not compile it automatically.\n")
|
||||||
file.write(f" {{{point.strip}, {point.led_id}}}{suffix} // block point\n")
|
file.write("// ============================================================================\n\n")
|
||||||
file.write("};\n\n")
|
file.write("#include <initializer_list>\n\n")
|
||||||
|
file.write("#ifndef ARDUINO\n#include <Arduino.h>\n#endif\n\n")
|
||||||
file.write("// Start offset and length for each block\n")
|
file.write("#include <Adafruit_NeoPixel.h>\n")
|
||||||
file.write("const BlockRange PROGMEM BLOCK_RANGES[LED_BLOCK_COUNT] = {\n")
|
file.write("#ifndef NUM_STRIPS\n")
|
||||||
for index, (offset, count, name) in enumerate(block_ranges):
|
file.write(f"#define NUM_STRIPS {NUM_STRIPS}\n")
|
||||||
suffix = "," if index < len(block_ranges) - 1 else ""
|
file.write("#endif\n")
|
||||||
file.write(f" {{{offset}, {count}}}{suffix} // {name}\n")
|
file.write("extern Adafruit_NeoPixel hwStrips[NUM_STRIPS];\n\n")
|
||||||
file.write("};\n\n")
|
file.write("#ifndef VM_MAX_PHYS_LEDS\n")
|
||||||
|
file.write(f"#define VM_MAX_PHYS_LEDS {max(point.led_id for point in points if point.led_id is not None) + 1}\n")
|
||||||
# String names array in PROGMEM
|
file.write("#endif\n")
|
||||||
file.write("// Original string names of blocks in flash memory\n")
|
file.write("#ifndef VM_MAX_IDS_PER_CELL\n")
|
||||||
for index, (offset, count, name) in enumerate(block_ranges):
|
file.write(f"#define VM_MAX_IDS_PER_CELL {MAX_LEDS_PER_CELL}\n")
|
||||||
escaped_name = name.replace('"', '\\"')
|
file.write("#endif\n")
|
||||||
file.write(f"const char BLOCK_NAME_{index}[] PROGMEM = \"{escaped_name}\";\n")
|
file.write("#ifndef MATRIX_ROWS\n")
|
||||||
file.write("\n")
|
file.write(f"#define MATRIX_ROWS {vm_rows}\n")
|
||||||
|
file.write("#endif\n")
|
||||||
file.write("const char* const BLOCK_NAMES[LED_BLOCK_COUNT] PROGMEM = {\n")
|
file.write("#ifndef MATRIX_COLS\n")
|
||||||
for index in range(len(block_ranges)):
|
file.write(f"#define MATRIX_COLS {vm_cols}\n")
|
||||||
suffix = "," if index < len(block_ranges) - 1 else ""
|
file.write("#endif\n\n")
|
||||||
file.write(f" BLOCK_NAME_{index}{suffix}\n")
|
file.write("struct VMPhys { uint16_t idx; uint8_t strip; };\n\n")
|
||||||
file.write("};\n\n")
|
file.write("static uint8_t vmCount[MATRIX_ROWS][MATRIX_COLS];\n")
|
||||||
|
file.write("static VMPhys vmCell[MATRIX_ROWS][MATRIX_COLS][VM_MAX_IDS_PER_CELL];\n")
|
||||||
# Inline helpers for blocks
|
file.write("static int16_t vmRow[NUM_STRIPS][VM_MAX_PHYS_LEDS];\n")
|
||||||
file.write("// Control block LEDs by count inside a block\n")
|
file.write("static int16_t vmCol[NUM_STRIPS][VM_MAX_PHYS_LEDS];\n")
|
||||||
file.write("inline void setBlockLEDsCount(uint16_t blockIdx, uint16_t count, uint32_t color) {\n")
|
file.write("static uint8_t vmUsed[NUM_STRIPS][VM_MAX_PHYS_LEDS];\n")
|
||||||
file.write(" if (blockIdx >= LED_BLOCK_COUNT) return;\n")
|
file.write("static uint16_t vmMaxId[NUM_STRIPS];\n\n")
|
||||||
file.write(" uint16_t offset = pgm_read_word(&BLOCK_RANGES[blockIdx].offset);\n")
|
file.write("void clearVirtualMatrix();\n")
|
||||||
file.write(" uint16_t total_count = pgm_read_word(&BLOCK_RANGES[blockIdx].count);\n")
|
file.write("void setLEDs(uint16_t row, uint16_t col, uint8_t strip1based, std::initializer_list<uint16_t> ids);\n")
|
||||||
file.write(" if (count > total_count) count = total_count;\n")
|
file.write("void vmSetByPhys(uint8_t strip, uint16_t led, uint32_t color);\n")
|
||||||
file.write(f" extern Adafruit_NeoPixel hwStrips[VM_NUM_STRIPS];\n")
|
file.write("void initVirtualMatrix();\n\n")
|
||||||
file.write(" for (uint16_t i = 0; i < count; i++) {\n")
|
file.write("void clearVirtualMatrix() {\n")
|
||||||
file.write(" uint8_t strip = pgm_read_byte(&BLOCK_LEDS[offset + i].strip);\n")
|
file.write(" for (int r = 0; r < MATRIX_ROWS; ++r)\n")
|
||||||
file.write(" uint16_t led_id = pgm_read_word(&BLOCK_LEDS[offset + i].led_id);\n")
|
file.write(" for (int c = 0; c < MATRIX_COLS; ++c) vmCount[r][c] = 0;\n")
|
||||||
file.write(" if (strip < VM_NUM_STRIPS) {\n")
|
file.write(" for (int s = 0; s < NUM_STRIPS; ++s) {\n")
|
||||||
file.write(" hwStrips[strip].setPixelColor(led_id, color);\n")
|
file.write(" vmMaxId[s] = 0;\n")
|
||||||
|
file.write(" for (int i = 0; i < VM_MAX_PHYS_LEDS; ++i) {\n")
|
||||||
|
file.write(" vmRow[s][i] = -1;\n")
|
||||||
|
file.write(" vmCol[s][i] = -1;\n")
|
||||||
|
file.write(" vmUsed[s][i] = 0;\n")
|
||||||
file.write(" }\n")
|
file.write(" }\n")
|
||||||
file.write(" }\n")
|
file.write(" }\n")
|
||||||
file.write("}\n\n")
|
file.write("}\n\n")
|
||||||
|
file.write("void setLEDs(uint16_t row, uint16_t col, uint8_t strip1based, std::initializer_list<uint16_t> ids) {\n")
|
||||||
file.write("// Control single block LED by 0-based index inside a block\n")
|
file.write(" if (row >= MATRIX_ROWS || col >= MATRIX_COLS) return;\n")
|
||||||
file.write("inline void setBlockLED(uint16_t blockIdx, uint16_t ledIdxInBlock, uint32_t color) {\n")
|
file.write(" uint8_t strip = (strip1based == 0) ? 0 : (uint8_t)(strip1based - 1);\n")
|
||||||
file.write(" if (blockIdx >= LED_BLOCK_COUNT) return;\n")
|
file.write(" if (strip >= NUM_STRIPS) return;\n")
|
||||||
file.write(" uint16_t offset = pgm_read_word(&BLOCK_RANGES[blockIdx].offset);\n")
|
file.write(" for (uint16_t id : ids) {\n")
|
||||||
file.write(" uint16_t count = pgm_read_word(&BLOCK_RANGES[blockIdx].count);\n")
|
file.write(" if (id >= VM_MAX_PHYS_LEDS) continue;\n")
|
||||||
file.write(" if (ledIdxInBlock >= count) return;\n")
|
file.write(" uint8_t &count = vmCount[row][col];\n")
|
||||||
file.write(" uint8_t strip = pgm_read_byte(&BLOCK_LEDS[offset + ledIdxInBlock].strip);\n")
|
file.write(" if (count < VM_MAX_IDS_PER_CELL) {\n")
|
||||||
file.write(" uint16_t led_id = pgm_read_word(&BLOCK_LEDS[offset + ledIdxInBlock].led_id);\n")
|
file.write(" vmCell[row][col][count].idx = id;\n")
|
||||||
file.write(f" extern Adafruit_NeoPixel hwStrips[VM_NUM_STRIPS];\n")
|
file.write(" vmCell[row][col][count].strip = strip;\n")
|
||||||
file.write(" if (strip < VM_NUM_STRIPS) {\n")
|
file.write(" ++count;\n")
|
||||||
file.write(" hwStrips[strip].setPixelColor(led_id, color);\n")
|
file.write(" }\n")
|
||||||
|
file.write(" vmRow[strip][id] = (int16_t)row;\n")
|
||||||
|
file.write(" vmCol[strip][id] = (int16_t)col;\n")
|
||||||
|
file.write(" vmUsed[strip][id] = 1;\n")
|
||||||
|
file.write(" if (id > vmMaxId[strip]) vmMaxId[strip] = id;\n")
|
||||||
file.write(" }\n")
|
file.write(" }\n")
|
||||||
file.write("}\n\n")
|
file.write("}\n\n")
|
||||||
|
file.write("void vmSetByPhys(uint8_t strip, uint16_t led, uint32_t color) {\n")
|
||||||
# Add getBlockIndexByName
|
file.write(" if (strip >= NUM_STRIPS || led >= VM_MAX_PHYS_LEDS) return;\n")
|
||||||
file.write("// Find block index by its string name (case-sensitive, original name)\n")
|
file.write(" int16_t row = vmRow[strip][led];\n")
|
||||||
file.write("inline int16_t getBlockIndexByName(const char* name) {\n")
|
file.write(" int16_t col = vmCol[strip][led];\n")
|
||||||
file.write(" for (uint16_t i = 0; i < LED_BLOCK_COUNT; i++) {\n")
|
file.write(" if (row < 0 || col < 0) {\n")
|
||||||
file.write(" const char* blockNamePtr = (const char*)pgm_read_word(&BLOCK_NAMES[i]);\n")
|
file.write(" if (led < hwStrips[strip].numPixels()) hwStrips[strip].setPixelColor(led, color);\n")
|
||||||
file.write(" if (strcmp_P(name, blockNamePtr) == 0) {\n")
|
file.write(" return;\n")
|
||||||
file.write(" return (int16_t)i;\n")
|
|
||||||
file.write(" }\n")
|
file.write(" }\n")
|
||||||
|
file.write(" uint8_t count = vmCount[row][col];\n")
|
||||||
|
file.write(" for (uint8_t i = 0; i < count; ++i) {\n")
|
||||||
|
file.write(" const VMPhys &phys = vmCell[row][col][i];\n")
|
||||||
|
file.write(" if (phys.strip < NUM_STRIPS && phys.idx < hwStrips[phys.strip].numPixels()) {\n")
|
||||||
|
file.write(" hwStrips[phys.strip].setPixelColor(phys.idx, color);\n")
|
||||||
file.write(" }\n")
|
file.write(" }\n")
|
||||||
file.write(" return -1;\n")
|
|
||||||
file.write("}\n\n")
|
|
||||||
|
|
||||||
# Add string-based control overloads
|
|
||||||
file.write("// Control block LEDs by string name and count\n")
|
|
||||||
file.write("inline void setBlockLEDsCount(const char* name, uint16_t count, uint32_t color) {\n")
|
|
||||||
file.write(" int16_t idx = getBlockIndexByName(name);\n")
|
|
||||||
file.write(" if (idx >= 0) {\n")
|
|
||||||
file.write(" setBlockLEDsCount((uint16_t)idx, count, color);\n")
|
|
||||||
file.write(" }\n")
|
file.write(" }\n")
|
||||||
file.write("}\n\n")
|
file.write("}\n\n")
|
||||||
|
file.write("void initVirtualMatrix() {\n")
|
||||||
file.write("// Control single block LED by block string name and 0-based index\n")
|
file.write(" clearVirtualMatrix();\n")
|
||||||
file.write("inline void setBlockLED(const char* name, uint16_t ledIdxInBlock, uint32_t color) {\n")
|
last_row = None
|
||||||
file.write(" int16_t idx = getBlockIndexByName(name);\n")
|
for row in range(vm_rows):
|
||||||
file.write(" if (idx >= 0) {\n")
|
row_cells = [(col, cells[(row, col)]) for col in range(vm_cols) if (row, col) in cells]
|
||||||
file.write(" setBlockLED((uint16_t)idx, ledIdxInBlock, color);\n")
|
if not row_cells:
|
||||||
file.write(" }\n")
|
continue
|
||||||
|
if last_row != row:
|
||||||
|
file.write(f"\n // Row {row}\n")
|
||||||
|
last_row = row
|
||||||
|
for col, cell_points in row_cells:
|
||||||
|
by_strip: dict[int, list[int]] = defaultdict(list)
|
||||||
|
for point in cell_points:
|
||||||
|
if point.strip is not None and point.led_id is not None:
|
||||||
|
by_strip[point.strip + 1].append(point.led_id)
|
||||||
|
for strip1based, led_ids_list in sorted(by_strip.items()):
|
||||||
|
led_ids = ", ".join(str(led_id) for led_id in led_ids_list)
|
||||||
|
file.write(f" setLEDs({row}, {col:2d}, {strip1based}, {{ {led_ids} }});\n")
|
||||||
file.write("}\n")
|
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:
|
||||||
point_id_map = {point.input_index: point.generated_id for point in points}
|
payload = {
|
||||||
point_payloads = []
|
"source": str(document.path) if document.path is not None else "LED Mapper GUI",
|
||||||
for point in points:
|
"unit": OUTPUT_UNIT,
|
||||||
source_point = document.points[point.input_index]
|
"cell_size_mm": CELL_SIZE_MM,
|
||||||
payload = document.point_to_payload(source_point, id_value=point.generated_id)
|
"leds": [
|
||||||
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,
|
||||||
@ -498,32 +401,14 @@ 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(
|
def collect_validation(points: list[PanelLedPoint]) -> list[str]:
|
||||||
points: list[PanelLedPoint],
|
|
||||||
cell_size_mm: float = CELL_SIZE_MM,
|
|
||||||
) -> list[str]:
|
|
||||||
lines = []
|
lines = []
|
||||||
distance_violations = []
|
distance_violations = []
|
||||||
cell_violations = []
|
cell_violations = []
|
||||||
@ -531,9 +416,7 @@ def collect_validation(
|
|||||||
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)
|
||||||
diagonal = abs(current.row - next_point.row) == 1 and abs(current.col - next_point.col) == 1
|
if gap > CELL_SIZE_MM + 1e-6:
|
||||||
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))
|
||||||
@ -543,7 +426,7 @@ def collect_validation(
|
|||||||
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 beyond grid step + tolerance: {len(distance_violations)}")
|
lines.append(f"Same-route distance violations > {CELL_SIZE_MM:g} mm: {len(distance_violations)}")
|
||||||
lines.append(f"Same-route 8-neighbor cell violations: {len(cell_violations)}")
|
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}")
|
||||||
@ -560,15 +443,11 @@ def collect_validation(
|
|||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def write_validation_report(
|
def write_validation_report(path: Path, points: list[PanelLedPoint]) -> None:
|
||||||
path: Path,
|
|
||||||
points: list[PanelLedPoint],
|
|
||||||
cell_size_mm: float = CELL_SIZE_MM,
|
|
||||||
) -> None:
|
|
||||||
lines = [
|
lines = [
|
||||||
"Panel mapping validation",
|
"Panel mapping validation",
|
||||||
"========================",
|
"========================",
|
||||||
*collect_validation(points, cell_size_mm),
|
*collect_validation(points),
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
path.write_text("\n".join(lines), encoding="utf-8")
|
path.write_text("\n".join(lines), encoding="utf-8")
|
||||||
@ -578,17 +457,13 @@ def write_readme(path: Path) -> None:
|
|||||||
path.write_text(
|
path.write_text(
|
||||||
"\n".join(
|
"\n".join(
|
||||||
[
|
[
|
||||||
"# Результат генерации Panel Mapping",
|
"# Panel Mapping Build",
|
||||||
"",
|
"",
|
||||||
"LED Mapper создал следующие файлы:",
|
"This folder contains files generated by the LED Mapper GUI:",
|
||||||
"- `vm_map.h` — маппинг виртуальной матрицы в формате PROGMEM.",
|
"- `vm_map.h` contains the PROGMEM virtual matrix mapping.",
|
||||||
"- `ordered_circles.json` — нормализованные LED и координаты матрицы.",
|
"- `virtual_matrix_legacy.ino.txt` keeps the readable `setLEDs(...)` mapping.",
|
||||||
"- `validation_report.txt` — результаты проверки маршрутов и ячеек.",
|
"- `ordered_circles.json` stores generated ids plus source row/col data.",
|
||||||
"- `README.md` — описание набора файлов.",
|
"- `validation_report.txt` records sequence and cell checks.",
|
||||||
"",
|
|
||||||
"Физический адрес ленты в `vm_map.h`: `strip = line_id`; нумерация начинается с `0`.",
|
|
||||||
"",
|
|
||||||
"Устаревший `virtual_matrix_legacy.ino.txt` больше не создаётся.",
|
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
|||||||
3529
qa_smoke.py
@ -1,2 +1 @@
|
|||||||
PySide6==6.11.1
|
PySide6>=6.7
|
||||||
pyinstaller==6.20.0
|
|
||||||
|
|||||||
187
settings.py
@ -1,187 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,306 +0,0 @@
|
|||||||
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
@ -1,599 +0,0 @@
|
|||||||
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"))
|
|
||||||
@ -1,348 +0,0 @@
|
|||||||
"""Безопасная проверка и установка 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
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
"""Текущая версия приложения; обновляется при публикации релиза."""
|
|
||||||
|
|
||||||
__version__ = "3.5.0"
|
|
||||||