Compare commits

...

7 Commits

Author SHA1 Message Date
30ac54f77f feat: Release v3.5.0 with free-form text comments
- Добавлены свободные многострочные комментарии на схеме: создание через меню, перемещение мышью, редактирование и удаление.
- Реализовано хранение комментариев в JSON с устойчивыми идентификаторами, координатами и сохранением неизвестных полей.
- Все операции с комментариями включены в Undo/Redo; поддержаны схемы только с заметками и сохранение удаления последней заметки.
- Исправлены границы общего вида, неограниченный масштаб схемы с одной заметкой и завершение перетаскивания при панорамировании пробелом.
- Добавлены семь smoke-сценариев, проверки совместимости генератора и обновлено руководство пользователя.
- Версия приложения обновлена до 3.5.0.
2026-11-05 08:50:36 +03:00
25ca2101a4 feat: improve LED Mapper settings and image support
- Добавлена сохраняемая настройка размера текста индексов LED с мгновенным предпросмотром и сбросом.
- Исправлено размещение быстрых кнопок в строке меню без перекрытия пунктов на узком окне.
- Усилена проверка OTA-загрузок: права записи, конечный URL, авторизация, Content-Length и отдельная группа updater-процесса.
- Добавлен единый загрузчик подложек с определением формата по содержимому, EXIF-ориентацией и преобразованием CMYK.
- Расширен список форматов подложки и добавлена безопасная обработка крупных изображений.
- Ошибки декодирования теперь показываются пользователю без замены текущей подложки.
- Обновлены smoke-тесты, документация, PyInstaller-конфигурация и версия приложения до 3.4.0.
2026-10-02 09:42:24 +03:00
37fbb2c545 fix: isolate Windows build dependencies
- Ограничен поиск DLL при сборке каталогами Python, окружения и Windows.
- Добавлена проверка источников бинарных библиотек перед упаковкой.
- Добавлен режим --verify-bundle и регрессионный smoke-тест.
- Обновлены документация и версия приложения до 3.3.1.
2026-09-18 14:32:19 +03:00
f8f6a216f5 feat: add OTA updates and zero-based mapping
- Добавлена проверка и безопасная установка OTA-обновлений Windows с откатом.
- Нумерация линий и блоков переведена на индексы с нуля.
- Добавлена миграция старых JSON-схем с сохранением физической адресации.
- Обновлены генератор, сборка, документация и smoke-проверки.
- Подготовлена версия 3.3.0.
2026-09-18 10:37:21 +03:00
0b24cbd83c feat: add LED copy and paste in edit mode
- добавить команды копирования и вставки выбранных светодиодов в режиме редактирования
- назначить на новые команды настраиваемые сочетания Ctrl+C и Ctrl+V
- создавать свободные копии с уникальными идентификаторами и каскадным смещением по шагу сетки
- сохранять взаимное расположение и пользовательские поля копируемых точек
- объединить вставку группы в одну операцию Undo с восстановлением выделения через Redo
- не перехватывать стандартное копирование и вставку при редактировании текста и координат
- добавить пункты меню, иконки, документацию и проверки, расширив QA до 164 сценариев
2026-09-09 08:30:30 +03:00
0d4552ef35 Embed background images in mapping files
- встраивать исходное изображение подложки в JSON вместе с метаданными и контрольной суммой
- сохранять геометрию и параметры встроенной подложки при повторном редактировании
- показывать значение «Встроенная» в диалоге подложки до выбора замены
- загружать встроенное изображение непосредственно из JSON на холст
- защищать существующий файл схемы при ошибке чтения подложки
- расширить автоматические проверки до 161 сценария
2026-09-08 15:21:33 +03:00
f2108e053f release: prepare LED Mapper 3.1.0
- исправить точное и атомарное сохранение схем
- добавить настройку шага сетки и абсолютного размера LED
- добавить пакетное удаление выбранных блоков
- исключить генерацию устаревшего INO-файла
- синхронизировать сетку обычного режима и режима редактирования
- устранить движение схемы при переключении режима редактирования
- расширить автоматические проверки до 158 сценариев
2026-09-08 14:13:00 +03:00
17 changed files with 3506 additions and 547 deletions

View File

@ -1,6 +1,8 @@
# -*- mode: python ; coding: utf-8 -*-
import os
import subprocess
import sys
from pathlib import Path
from datetime import datetime
@ -10,15 +12,15 @@ spec_dir = Path(SPECPATH)
def git_build_ref():
try:
dirty = subprocess.run(
['git', 'diff-index', '--quiet', 'HEAD', '--'],
dirty = subprocess.check_output(
['git', 'status', '--porcelain', '--untracked-files=normal'],
cwd=spec_dir,
stderr=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
).returncode != 0
if dirty:
text=True,
)
if dirty.strip():
return datetime.now().strftime('%H%M%S-%d%m%Y')
except OSError:
except (OSError, subprocess.CalledProcessError):
return datetime.now().strftime('%H%M%S-%d%m%Y')
try:
tag = subprocess.check_output(
@ -45,16 +47,54 @@ def git_build_ref():
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(
[str(spec_dir / 'main.py')],
pathex=[str(spec_dir), str(spec_dir.parent)],
binaries=[],
datas=[(str(spec_dir / 'assets'), 'assets')],
datas=[(str(spec_dir / 'assets'), 'assets'), (str(build_version_path), '.')],
hiddenimports=[
'canvas',
'background_image_loader',
'background_dialog',
'icons',
'localization',
@ -66,6 +106,8 @@ a = Analysis(
'settings_dialog',
'theme',
'unfolding',
'update_service',
'version',
],
hookspath=[],
hooksconfig={},
@ -74,6 +116,7 @@ a = Analysis(
noarchive=False,
optimize=0,
)
_check_binary_sources(a.binaries, library_roots)
pyz = PYZ(a.pure)
exe = EXE(

View File

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

View File

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

View File

@ -16,21 +16,31 @@ from PySide6.QtWidgets import (
)
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.path = QLineEdit(background.path)
self.path.setPlaceholderText("PNG или JPEG")
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()
@ -69,14 +79,19 @@ class BackgroundDialog(QDialog):
form.addRow(buttons)
def background(self) -> BackgroundImage:
embedded = self._source_background if self._keep_embedded_image else BackgroundImage()
return BackgroundImage(
path=self.path.text().strip(),
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:
@ -84,16 +99,22 @@ class BackgroundDialog(QDialog):
self.scale_y.setValue(100.0)
def _browse(self) -> None:
start = self.path.text().strip()
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),
"Изображения (*.png *.jpg *.jpeg);;Все файлы (*.*)",
supported_image_filter(),
)
if selected:
self.path.setText(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:

147
background_image_loader.py Normal file
View File

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

404
canvas.py
View File

@ -1,10 +1,9 @@
from __future__ import annotations
import math
from pathlib import Path
from PySide6.QtCore import QPointF, QRectF, QSize, Qt, Signal
from PySide6.QtGui import QColor, QBrush, QImage, QImageReader, QPainter, QPainterPath, QPen, QPixmap, QTransform
from PySide6.QtCore import QPointF, QRectF, Qt, QTimer, Signal
from PySide6.QtGui import QColor, QBrush, QFont, QPainter, QPainterPath, QPen, QPixmap, QTransform
from PySide6.QtWidgets import (
QApplication,
QGraphicsEllipseItem,
@ -15,6 +14,7 @@ from PySide6.QtWidgets import (
QGraphicsRectItem,
QGraphicsScene,
QGraphicsSimpleTextItem,
QGraphicsTextItem,
QGraphicsView,
QMenu,
QScrollBar,
@ -22,12 +22,14 @@ from PySide6.QtWidgets import (
from shiboken6 import isValid
try:
from .background_image_loader import background_cache_key, load_background_image
from .models import BackgroundImage, LedLine, LedPoint, MappingDocument, coordinate_display_scale
except ImportError:
from background_image_loader import background_cache_key, load_background_image
from models import BackgroundImage, LedLine, LedPoint, MappingDocument, coordinate_display_scale
MAX_BACKGROUND_PIXMAP_SIDE = 8192
MAX_GRID_LINES = 800
class LedItem(QGraphicsEllipseItem):
@ -35,6 +37,7 @@ class LedItem(QGraphicsEllipseItem):
super().__init__(-radius, -radius, radius * 2, radius * 2)
self.point_id = point.uid
self.setFlag(QGraphicsEllipseItem.ItemIsSelectable, True)
self.setFlag(QGraphicsItem.ItemIgnoresTransformations, True)
self.setToolTip(self._tooltip(point))
self.setZValue(10)
@ -53,6 +56,43 @@ class LedItem(QGraphicsEllipseItem):
)
class CommentItem(QGraphicsTextItem):
"""Свободная заметка; положение хранится в координатах документа."""
def __init__(self, comment, dark: bool) -> None: # noqa: ANN001
super().__init__()
self.comment_id = comment.id
self.dark = dark
self.setPlainText(comment.text)
font = QFont()
font.setPixelSize(14)
self.setFont(font)
self.document().setDocumentMargin(10)
self.setTextWidth(240)
self.setTextInteractionFlags(Qt.NoTextInteraction)
self.setFlag(QGraphicsItem.ItemIgnoresTransformations, True)
self.setZValue(55)
self.setCursor(Qt.SizeAllCursor)
self.setToolTip("Перетащите комментарий мышью. Двойной щелчок — изменить текст. ПКМ — меню.")
self.apply_theme(dark)
def apply_theme(self, dark: bool) -> None:
self.dark = dark
self.setDefaultTextColor(QColor("#fef3c7" if dark else "#451a03"))
self.update()
def paint(self, painter, option, widget=None) -> None: # noqa: ANN001
painter.save()
painter.setBrush(QColor("#483b20" if self.dark else "#fff4c2"))
pen = QPen(QColor("#b68c3d" if self.dark else "#d4a437"), 1)
pen.setCosmetic(True)
painter.setPen(pen)
painter.drawRoundedRect(self.boundingRect().adjusted(0.5, 0.5, -0.5, -0.5), 5, 5)
painter.restore()
super().paint(painter, option, widget)
class LedCanvas(QGraphicsView):
selectionChanged = Signal(list)
deleteSelectionRequested = Signal()
@ -66,6 +106,10 @@ class LedCanvas(QGraphicsView):
brushPointTouched = Signal(int)
brushStrokeFinished = Signal()
addLedRequested = Signal(float, float)
addCommentRequested = Signal(float, float)
editCommentRequested = Signal(str)
deleteCommentRequested = Signal(str)
commentMoveFinished = Signal(str, float, float)
backgroundEditStarted = Signal()
backgroundChanged = Signal()
backgroundEditFinished = Signal(bool)
@ -83,6 +127,8 @@ class LedCanvas(QGraphicsView):
self.document: MappingDocument | None = None
self.active_line_id: int | None = None
self.items_by_id: dict[int, LedItem] = {}
self.comment_items: dict[str, CommentItem] = {}
self._comment_drag: dict | None = None
self.grid_items: list[QGraphicsLineItem] = []
self.connection_items: list[QGraphicsPathItem] = []
self.line_index_label_items: dict[int, QGraphicsSimpleTextItem] = {}
@ -91,11 +137,13 @@ class LedCanvas(QGraphicsView):
self._background_handles: dict[str, QGraphicsRectItem] = {}
self._background_edit_mode = False
self._background_drag: dict | None = None
self._background_pixmap_cache_key: tuple[str, int | None] | None = None
self._background_pixmap_cache_key: tuple[object, ...] | None = None
self._background_pixmap_cache: QPixmap | None = None
self._background_load_error = ""
self._background_loaded = False
self._direction_arrows_visible = True
self._line_index_labels_visible = True
self._line_index_font_size_px = 12
self._point_radius = 4.5
self._brush_radius = 18.0
self._zoom_focus_pull = 0.35
@ -129,12 +177,19 @@ class LedCanvas(QGraphicsView):
self._last_brush_viewport_pos = None
self._last_selected_ids: set[int] = set()
self._edit_mode_enabled = False
self._pending_edit_view_state: tuple[QTransform, QPointF, QRectF] | None = None
self.scene().setBackgroundBrush(QBrush(QColor("#2b2b2b")))
self.scene().selectionChanged.connect(self._emit_selection)
self.horizontalScrollBar().valueChanged.connect(self._on_grid_view_changed)
self.verticalScrollBar().valueChanged.connect(self._on_grid_view_changed)
self.setToolTip("Выделение рамкой: перетащите мышью. Масштаб: колесо. Del: снять фиксацию выбранных LED.")
def set_document(self, document: MappingDocument, *, reset_view: bool = True) -> None:
if document is not self.document:
self._stable_display_scale = None
self._stable_edit_bounds = None
self.document = document
self._grid_step = min(1000.0, max(1.0, float(getattr(document, "grid_step_mm", 34.0))))
self.rebuild(reset_view=reset_view)
def set_edit_tool(self, tool: str) -> None:
@ -156,9 +211,28 @@ class LedCanvas(QGraphicsView):
def set_edit_mode_enabled(self, enabled: bool) -> None:
if self._edit_mode_enabled == enabled:
return
view_state = self._capture_view_state()
self._pending_edit_view_state = view_state
self._edit_mode_enabled = enabled
self._update_scene_rect()
self.rebuild(reset_view=False)
self._restore_view_state(view_state)
self._refresh_grid()
self._position_line_index_labels()
self._update_selection_bbox()
# Scroll bars are laid out by Qt on the next event-loop pass. Restore
# once more afterwards so their appearance/disappearance cannot move
# the scheme by several screen pixels.
QTimer.singleShot(0, self._restore_pending_edit_view_state)
def _restore_pending_edit_view_state(self) -> None:
state = self._pending_edit_view_state
if state is None:
return
self._pending_edit_view_state = None
self._restore_view_state(state)
self._refresh_grid()
self._position_line_index_labels()
self._update_selection_bbox()
def set_active_line(self, line_id: int | None) -> None:
if self.active_line_id == line_id:
@ -170,6 +244,28 @@ class LedCanvas(QGraphicsView):
self._grid_visible = visible
self._refresh_grid()
def set_grid_step_mm(self, step_mm: float) -> None:
self._grid_step = min(1000.0, max(1.0, float(step_mm)))
self._refresh_grid()
def _on_grid_view_changed(self, _value: int) -> None:
self._refresh_grid()
def set_point_diameter(self, diameter_px: int) -> None:
radius = max(0.5, min(32.0, float(diameter_px) / 2.0))
if abs(radius - self._point_radius) < 1e-9:
return
self._point_radius = radius
for item in self.items_by_id.values():
if item is not None and isValid(item):
item.setRect(-radius, -radius, radius * 2.0, radius * 2.0)
self._update_led_content_rect()
self._update_scene_rect()
self._update_selection_bbox()
self._position_line_index_labels()
if self._last_brush_viewport_pos is not None:
self._update_brush_preview(self._last_brush_viewport_pos)
def set_direction_arrows_visible(self, visible: bool) -> None:
if self._direction_arrows_visible == visible:
return
@ -188,6 +284,18 @@ class LedCanvas(QGraphicsView):
def line_index_labels_visible(self) -> bool:
return self._line_index_labels_visible
def set_line_index_font_size(self, font_size_px: int) -> None:
font_size = max(1, min(256, int(font_size_px)))
if self._line_index_font_size_px == font_size:
return
self._line_index_font_size_px = font_size
for label in self.line_index_label_items.values():
if label is not None and isValid(label):
font = label.font()
font.setPixelSize(font_size)
label.setFont(font)
self._position_line_index_labels()
def set_brush_radius(self, radius: int) -> None:
self._brush_radius = float(max(4, min(80, radius)))
if self._brush_zone_item is not None and isValid(self._brush_zone_item):
@ -268,6 +376,15 @@ class LedCanvas(QGraphicsView):
def background_loaded(self) -> bool:
return self._background_loaded
def background_load_error(self) -> str:
return self._background_load_error
def validate_background(self, background: BackgroundImage) -> bool:
if not background.is_enabled():
self._background_load_error = "Не выбран файл изображения подложки."
return False
return not self._load_background_pixmap(background).isNull()
def set_background(self, background: BackgroundImage) -> None:
if self.document is None:
return
@ -327,6 +444,8 @@ class LedCanvas(QGraphicsView):
self.setUpdatesEnabled(False)
try:
self.items_by_id.clear()
self.comment_items.clear()
self._comment_drag = None
self.grid_items.clear()
self.connection_items.clear()
self.line_index_label_items.clear()
@ -349,8 +468,15 @@ class LedCanvas(QGraphicsView):
self._raw_bounds = None
self._content_scene_rect = None
if self.document is None or not self.document.points:
self._display_scale = 1.0
self._refresh_background()
self._refresh_comments()
self._update_scene_rect()
if reset_view:
self.fit_to_content()
else:
self.setTransform(old_transform)
self.centerOn(old_center)
return
# Keep display scale completely stable to prevent grid and camera jumps
@ -379,13 +505,9 @@ class LedCanvas(QGraphicsView):
self.scene().addItem(item)
stable_min_x, stable_max_x, stable_min_y, stable_max_y = self._stable_edit_bounds
self._content_scene_rect = QRectF(
0,
0,
max(stable_max_x - stable_min_x, 1.0) + self._margin * 2,
max(stable_max_y - stable_min_y, 1.0) + self._margin * 2,
)
self._update_led_content_rect()
self._refresh_background()
self._refresh_comments()
self._update_scene_rect()
self._refresh_grid()
self.refresh_styles()
@ -408,6 +530,8 @@ class LedCanvas(QGraphicsView):
self.centerOn(old_center)
def refresh_styles(self, redraw_connections: bool = True) -> None:
for comment_item in self.comment_items.values():
comment_item.apply_theme(self._theme == "dark")
if self.document is None:
return
if redraw_connections:
@ -452,6 +576,16 @@ class LedCanvas(QGraphicsView):
if isinstance(item, LedItem) and isValid(item)
)
def document_point_positions(self) -> list[tuple[int, float, float]]:
"""Return every visible LED center converted back to document coordinates."""
positions: list[tuple[int, float, float]] = []
for point_id, item in sorted(self.items_by_id.items()):
if item is None or not isValid(item):
continue
document_point = self._document_point_from_scene(item.pos())
positions.append((point_id, document_point.x(), document_point.y()))
return positions
def select_points(self, point_ids: list[int]) -> None:
target_ids = set(point_ids)
if target_ids == self._last_selected_ids:
@ -477,10 +611,7 @@ class LedCanvas(QGraphicsView):
self._remove_background_items()
if self.document is None or not self.document.background.is_enabled():
return
image_path = Path(self.document.background.path)
if not image_path.is_absolute() and self.document.path is not None:
image_path = self.document.path.parent / image_path
pixmap = self._load_background_pixmap(image_path)
pixmap = self._load_background_pixmap(self.document.background)
if pixmap.isNull():
self._background_loaded = False
return
@ -500,17 +631,14 @@ class LedCanvas(QGraphicsView):
self._background_handles.clear()
self._background_loaded = False
def _load_background_pixmap(self, path: Path) -> QPixmap:
cache_key = (str(path), path.stat().st_mtime_ns if path.exists() else None)
def _load_background_pixmap(self, background: BackgroundImage) -> QPixmap:
document_path = self.document.path if self.document is not None else None
cache_key = background_cache_key(background, document_path)
if self._background_pixmap_cache_key == cache_key and self._background_pixmap_cache is not None:
return self._background_pixmap_cache
reader = QImageReader(str(path))
size = reader.size()
if size.isValid() and max(size.width(), size.height()) > MAX_BACKGROUND_PIXMAP_SIDE:
factor = MAX_BACKGROUND_PIXMAP_SIDE / max(size.width(), size.height())
reader.setScaledSize(QSize(max(1, int(size.width() * factor)), max(1, int(size.height() * factor))))
image = reader.read()
pixmap = QPixmap.fromImage(image) if not image.isNull() else QPixmap()
result = load_background_image(background, document_path)
self._background_load_error = result.error
pixmap = QPixmap.fromImage(result.image) if result.loaded else QPixmap()
self._background_pixmap_cache_key = cache_key
self._background_pixmap_cache = pixmap
return pixmap
@ -552,8 +680,47 @@ class LedCanvas(QGraphicsView):
return None
return self._background_item.sceneBoundingRect()
def _refresh_comments(self) -> None:
for item in self.comment_items.values():
if isValid(item):
self.scene().removeItem(item)
self.comment_items.clear()
if self.document is None:
return
for comment in self.document.comments:
item = CommentItem(comment, self._theme == "dark")
item.setPos(self._to_scene_point(comment.x, comment.y))
self.scene().addItem(item)
self.comment_items[comment.id] = item
def _comment_at(self, viewport_pos) -> CommentItem | None: # noqa: ANN001
for item in self.items(viewport_pos):
if isinstance(item, CommentItem):
return item
return None
def _rect_with_comments(self, rect: QRectF | None) -> QRectF | None:
scale = max(abs(self.transform().m11()), 1e-9)
for item in self.comment_items.values():
local = item.boundingRect().adjusted(-20, -20, 20, 20)
bounds = QRectF(
item.pos().x() + local.x() / scale, item.pos().y() + local.y() / scale,
local.width() / scale, local.height() / scale,
)
rect = bounds if rect is None else rect.united(bounds)
return rect
def mouseDoubleClickEvent(self, event) -> None: # noqa: ANN001
item = self._comment_at(event.pos())
if event.button() == Qt.LeftButton and item is not None:
self._comment_drag = None
self.editCommentRequested.emit(item.comment_id)
event.accept()
return
super().mouseDoubleClickEvent(event)
def _update_scene_rect(self) -> None:
rect = self._content_scene_rect
rect = self._rect_with_comments(self._content_scene_rect)
background_rect = self._background_scene_rect()
if rect is None:
rect = background_rect
@ -607,21 +774,33 @@ class LedCanvas(QGraphicsView):
handle.setPos(point)
def fit_to_content(self) -> None:
self._update_scene_rect()
if self.scene() is None or self.sceneRect().isEmpty():
if self.scene() is None:
return
# Focus camera on actual document content bounds, ignoring expanded edit sceneRect bounds
rect = self._content_scene_rect
background_rect = self._background_scene_rect()
if rect is None:
rect = background_rect
elif background_rect is not None:
rect = rect.united(background_rect)
if rect is None or rect.isEmpty():
rect = self.sceneRect()
self.fitInView(rect, Qt.KeepAspectRatio)
# Размер заметок в пикселях постоянен; их границы в сцене зависят от масштаба.
for _ in range(8 if self.comment_items else 1):
self._update_scene_rect()
rect = self._rect_with_comments(self._content_scene_rect)
background_rect = self._background_scene_rect()
if rect is None:
rect = background_rect
elif background_rect is not None:
rect = rect.united(background_rect)
if rect is None or rect.isEmpty():
rect = self.sceneRect()
if rect.isEmpty():
return
self.fitInView(rect, Qt.KeepAspectRatio)
if self.comment_items and self.transform().m11() > 1.0:
self.resetTransform()
self.centerOn(rect.center())
break
self._refresh_grid()
self._position_line_index_labels()
self._update_selection_bbox()
def resizeEvent(self, event) -> None: # noqa: ANN001
super().resizeEvent(event)
self._refresh_grid()
def _capture_view_state(self) -> tuple[QTransform, QPointF, QRectF] | None:
if self.viewport().rect().isEmpty():
@ -635,12 +814,17 @@ class LedCanvas(QGraphicsView):
def _restore_view_state(self, state: tuple[QTransform, QPointF, QRectF] | None) -> None:
if state is None:
return
transform, center, visible_rect = state
transform, _center, visible_rect = state
self.setTransform(transform)
current_visible_rect = self.mapToScene(self.viewport().rect()).boundingRect()
desired_visible_rect = QRectF(
visible_rect.topLeft(),
current_visible_rect.size(),
)
scene_rect = self.scene().sceneRect()
if not scene_rect.contains(visible_rect):
self.scene().setSceneRect(scene_rect.united(visible_rect))
self.centerOn(center)
if not scene_rect.contains(desired_visible_rect):
self.scene().setSceneRect(scene_rect.united(desired_visible_rect))
self.centerOn(desired_visible_rect.center())
def wheelEvent(self, event) -> None: # noqa: ANN001
if self._background_drag is not None:
@ -649,9 +833,11 @@ class LedCanvas(QGraphicsView):
factor = 1.15 if event.angleDelta().y() > 0 else 1 / 1.15
viewport_pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
self._zoom_at_viewport_pos(viewport_pos, factor)
self._refresh_grid()
if self._brush_enabled:
self._update_brush_zone(viewport_pos)
self._position_line_index_labels()
self._update_selection_bbox()
event.accept()
def _zoom_at_viewport_pos(self, viewport_pos, factor: float) -> None: # noqa: ANN001
@ -713,6 +899,16 @@ class LedCanvas(QGraphicsView):
self._start_pan(event.pos())
event.accept()
return
if event.button() == Qt.LeftButton:
comment = self._comment_at(event.pos())
if comment is not None:
self.setFocus(Qt.MouseFocusReason)
self._comment_drag = {
"item": comment, "start": self.mapToScene(event.pos()),
"origin": QPointF(comment.pos()),
}
event.accept()
return
is_move_tool = getattr(self, "_edit_tool", "select") == "move"
has_alt = bool(event.modifiers() & Qt.AltModifier)
if event.button() == Qt.LeftButton and (has_alt or is_move_tool) and self._start_point_drag(event.pos()):
@ -769,6 +965,11 @@ class LedCanvas(QGraphicsView):
self._move_scrollbar(self.verticalScrollBar(), delta.y())
event.accept()
return
if self._comment_drag is not None:
drag = self._comment_drag
drag["item"].setPos(drag["origin"] + self.mapToScene(event.pos()) - drag["start"])
event.accept()
return
if self._background_drag is not None:
self._move_background_edit(event.pos())
event.accept()
@ -783,8 +984,7 @@ class LedCanvas(QGraphicsView):
dx = scene_pos.x() - self._last_drawn_pos.x()
dy = scene_pos.y() - self._last_drawn_pos.y()
dist = math.hypot(dx, dy)
doc_dist = dist / (self._display_scale or 1.0)
if doc_dist >= 34.0:
if dist >= self._grid_step:
point = self._document_point_from_scene(scene_pos)
self.addLedRequested.emit(point.x(), point.y())
self._last_drawn_pos = scene_pos
@ -820,6 +1020,17 @@ class LedCanvas(QGraphicsView):
self._finish_pan()
event.accept()
return
if event.button() == Qt.LeftButton and self._comment_drag is not None:
drag = self._comment_drag
self._comment_drag = None
position = drag["origin"] + self.mapToScene(event.pos()) - drag["start"]
item = drag["item"]
item.setPos(position)
if position != drag["origin"]:
point = self._document_point_from_scene(position)
self.commentMoveFinished.emit(item.comment_id, point.x(), point.y())
event.accept()
return
if event.button() == Qt.LeftButton and self._background_drag is not None:
self._finish_background_edit()
event.accept()
@ -884,9 +1095,22 @@ class LedCanvas(QGraphicsView):
)
self._clear_rubber_band()
# Collect all LedItems inside the rubber band rectangle
items_in_rect = self.scene().items(rect, Qt.IntersectsItemBoundingRect)
hit_ids = [item.point_id for item in items_in_rect if isinstance(item, LedItem)]
# Test in viewport pixels: LED items have an absolute on-screen size.
viewport_transform = self.viewportTransform()
viewport_rect = QRectF(viewport_transform.map(origin), viewport_transform.map(scene_pos)).normalized()
hit_ids: list[int] = []
for point_id, item in self.items_by_id.items():
if item is None or not isValid(item):
continue
center = viewport_transform.map(item.pos())
item_rect = QRectF(
center.x() - self._point_radius,
center.y() - self._point_radius,
self._point_radius * 2.0,
self._point_radius * 2.0,
)
if viewport_rect.intersects(item_rect) or viewport_rect.contains(center):
hit_ids.append(point_id)
keep_shift = bool(modifiers & (Qt.ShiftModifier | Qt.ControlModifier))
if keep_shift:
@ -923,8 +1147,8 @@ class LedCanvas(QGraphicsView):
# Compute union bounding rect in scene coords (item pos is centre)
xs = [item.pos().x() for item in selected_items]
ys = [item.pos().y() for item in selected_items]
radius = selected_items[0].rect().width() / 2.0
padding = radius + 4.0
view_scale = max(abs(self.transform().m11()), 0.001)
padding = (self._point_radius + 4.0) / view_scale
x_min = min(xs) - padding
y_min = min(ys) - padding
x_max = max(xs) + padding
@ -984,7 +1208,8 @@ class LedCanvas(QGraphicsView):
})
self._point_drag = {
"items": drag_items
"items": drag_items,
"point_id": item.point_id if item is not None else (selected_ids[0] if len(selected_ids) == 1 else None),
}
self.setDragMode(QGraphicsView.NoDrag)
self.viewport().setCursor(Qt.ClosedHandCursor)
@ -1023,7 +1248,10 @@ class LedCanvas(QGraphicsView):
document_point = self._document_point_from_scene(item.pos())
moves.append((point_id, document_point.x(), document_point.y()))
if moves:
if len(moves) == 1:
point_id, x, y = moves[0]
self.pointMoveFinished.emit(point_id, x, y)
elif moves:
self.pointsMoveFinished.emit(moves)
def _start_background_edit(self, viewport_pos) -> bool: # noqa: ANN001
@ -1175,6 +1403,9 @@ class LedCanvas(QGraphicsView):
)
def _start_pan(self, pos) -> None: # noqa: ANN001
if self._comment_drag is not None:
self._comment_drag["item"].setPos(self._comment_drag["origin"])
self._comment_drag = None
self._is_panning = True
self._last_pan_pos = pos
self._apply_default_cursor()
@ -1185,10 +1416,24 @@ class LedCanvas(QGraphicsView):
self._apply_default_cursor()
def contextMenuEvent(self, event) -> None: # noqa: ANN001
comment = self._comment_at(event.pos())
if comment is not None:
menu = QMenu(self)
edit_comment = menu.addAction("Изменить комментарий…")
delete_comment = menu.addAction("Удалить комментарий")
chosen = menu.exec(event.globalPos())
if chosen == edit_comment:
self.editCommentRequested.emit(comment.comment_id)
elif chosen == delete_comment:
self.deleteCommentRequested.emit(comment.comment_id)
return
item = self._led_item_at(event.pos())
point_id = item.point_id if item is not None else self._single_selected_point_id()
menu = QMenu(self)
add_comment = menu.addAction("Добавить комментарий…")
comment_position = self._document_point_from_scene(self.mapToScene(event.pos()))
menu.addSeparator()
edit_action = menu.addAction("Редактировать координаты")
edit_action.setEnabled(point_id is not None)
delete_action = menu.addAction("Удалить фиксацию выбранных LED")
@ -1236,9 +1481,11 @@ class LedCanvas(QGraphicsView):
select_block_action = menu.addAction(f"Выделить блок '{block_name}'")
fit_action = menu.addAction("Показать все точки")
fit_action.setEnabled(self.document is not None and bool(self.document.points))
fit_action.setEnabled(self.document is not None and bool(self.document.points or self.document.comments))
chosen = menu.exec(event.globalPos())
if chosen == edit_action and point_id is not None:
if chosen == add_comment:
self.addCommentRequested.emit(comment_position.x(), comment_position.y())
elif chosen == edit_action and point_id is not None:
self.editPointRequested.emit(point_id)
elif chosen == delete_action:
self.deleteSelectionRequested.emit()
@ -1306,6 +1553,9 @@ class LedCanvas(QGraphicsView):
if item is None or not isValid(item):
continue
label = QGraphicsSimpleTextItem(str(point.line_index))
font = QFont(label.font())
font.setPixelSize(self._line_index_font_size_px)
label.setFont(font)
label.setBrush(QColor("#f8fafc" if self._theme == "dark" else "#111827"))
label.setZValue(40)
label.setFlag(QGraphicsItem.ItemIgnoresTransformations, True)
@ -1327,10 +1577,25 @@ class LedCanvas(QGraphicsView):
continue
label_rect = label.boundingRect()
label.setPos(
item.pos().x() - label_rect.width() / 2.0,
item.pos().x() - label_rect.width() / (2.0 * scale),
item.pos().y() - scene_radius - scene_gap,
)
def _update_led_content_rect(self) -> None:
valid_items = [item for item in self.items_by_id.values() if item is not None and isValid(item)]
if not valid_items:
self._content_scene_rect = None
return
padding = max(self._point_radius * 4.0, 40.0)
xs = [item.pos().x() for item in valid_items]
ys = [item.pos().y() for item in valid_items]
self._content_scene_rect = QRectF(
min(xs) - padding,
min(ys) - padding,
max(max(xs) - min(xs), 1.0) + padding * 2.0,
max(max(ys) - min(ys), 1.0) + padding * 2.0,
)
def _clear_line_index_labels(self) -> None:
for label in self.line_index_label_items.values():
if isValid(label):
@ -1462,23 +1727,26 @@ class LedCanvas(QGraphicsView):
if self.document is None or not self.document.points:
return
min_x, max_x, min_y, max_y = self._raw_bounds
min_x, _max_x, _min_y, max_y = self._raw_bounds
origin_x, origin_y = self._grid_origin_scene_units()
left_x = min_x - self._margin
right_x = max_x + self._margin
bottom_y = min_y - self._margin
top_y = max_y + self._margin
scene_rect = self.scene().sceneRect()
left_x = min(left_x, scene_rect.left() + min_x - self._margin)
right_x = max(right_x, scene_rect.right() + min_x - self._margin)
top_y = max(top_y, max_y + self._margin - scene_rect.top())
bottom_y = min(bottom_y, max_y + self._margin - scene_rect.bottom())
visible_scene_rect = self.mapToScene(self.viewport().rect()).boundingRect()
overscan_x = max(self._grid_step, visible_scene_rect.width() * 0.05)
overscan_y = max(self._grid_step, visible_scene_rect.height() * 0.05)
visible_scene_rect.adjust(-overscan_x, -overscan_y, overscan_x, overscan_y)
# Convert the visible scene rectangle back to display units. The
# resulting grid density depends only on the camera, never on the
# expanded sceneRect used by scheme editing mode.
left_x = visible_scene_rect.left() - self._margin + min_x
right_x = visible_scene_rect.right() - self._margin + min_x
bottom_y = max_y - visible_scene_rect.bottom() + self._margin
top_y = max_y - visible_scene_rect.top() + self._margin
first_col = math.floor((left_x - origin_x) / self._grid_step)
last_col = math.ceil((right_x - origin_x) / self._grid_step)
first_row = math.floor((bottom_y - origin_y) / self._grid_step)
last_row = math.ceil((top_y - origin_y) / self._grid_step)
grid_span = (last_col - first_col) + (last_row - first_row)
step_multiplier = 1
grid_line_count = max(0, last_col - first_col + 1) + max(0, last_row - first_row + 1)
step_multiplier = max(1, math.ceil(grid_line_count / MAX_GRID_LINES))
pen = QPen(QColor("#334155" if self._theme == "dark" else "#d1d5db"), 0.6)
pen.setCosmetic(True)

View File

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

View File

@ -10,6 +10,8 @@ LUCIDE_PATHS = {
"arrow-right": '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>',
"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"/>',
"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"/>',
"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"/>',

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import sys
from pathlib import Path
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication
APP_DIR = Path(__file__).resolve().parent
@ -14,15 +15,20 @@ try:
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:
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"
@ -31,6 +37,7 @@ def main() -> int:
window = MainWindow()
window.setWindowIcon(app_icon)
window.show()
QTimer.singleShot(500, signal_update_ready)
return app.exec()

View File

@ -2,9 +2,10 @@ from __future__ import annotations
from copy import deepcopy
from pathlib import Path
import sys
from threading import Event, Thread
from PySide6.QtCore import QEvent, QSignalBlocker, QSize, Qt, QTimer
from PySide6.QtGui import QAction, QColor, QImageReader, QKeySequence
from PySide6.QtCore import QEvent, QObject, QSignalBlocker, QSize, Qt, QTimer, Signal
from PySide6.QtGui import QAction, QColor, QKeySequence
from PySide6.QtWidgets import (
QButtonGroup,
QAbstractItemView,
@ -54,13 +55,17 @@ try:
show_information,
show_warning,
)
from .models import LedBlock, LedLine, MappingDocument
from .models import LedBlock, LedLine, LedPoint, MappingDocument
from .panel_mapping_generator import generate_panel_mapping_from_document
from .settings import DEFAULT_SHORTCUTS, AppSettings
from .settings_dialog import SettingsDialog
from .theme import apply_app_theme, effective_theme
from .unfolding import MAX_LINES
from .mapping_renderer import render_mapping_image
from .update_service import (
UpdateCancelled, UpdateError, UpdateInfo, check_for_update, current_executable, current_version,
download_update, launch_update,
)
except ImportError:
from background_dialog import BackgroundDialog
from canvas import LedCanvas
@ -73,13 +78,17 @@ except ImportError:
show_information,
show_warning,
)
from models import LedBlock, LedLine, MappingDocument
from models import LedBlock, LedLine, LedPoint, MappingDocument
from panel_mapping_generator import generate_panel_mapping_from_document
from settings import DEFAULT_SHORTCUTS, AppSettings
from settings_dialog import SettingsDialog
from theme import apply_app_theme, effective_theme
from unfolding import MAX_LINES
from mapping_renderer import render_mapping_image
from update_service import (
UpdateCancelled, UpdateError, UpdateInfo, check_for_update, current_executable, current_version,
download_update, launch_update,
)
ROOT_DIR = Path(__file__).resolve().parents[1]
@ -191,6 +200,13 @@ class GenerationStrategyDialog(QDialog):
self.ok_button.setEnabled(True)
class UpdateSignals(QObject):
checked = Signal(object)
downloaded = Signal(object)
progress = Signal(int)
failed = Signal(str)
class MainWindow(QMainWindow):
def __init__(self) -> None:
super().__init__()
@ -204,7 +220,10 @@ class MainWindow(QMainWindow):
self._undo_stack: list[tuple[MappingDocument, int | None, list[int], bool]] = []
self._redo_stack: list[tuple[MappingDocument, int | None, list[int], bool]] = []
self._pending_background_undo_state: tuple[MappingDocument, int | None, list[int], bool] | None = None
self._copied_led_points: list[LedPoint] = []
self._paste_sequence = 0
self.settings = AppSettings.load()
self.document.grid_step_mm = self.settings.grid_step_mm
apply_app_theme(self.settings.theme)
self.actions: dict[str, QAction] = {}
self.action_icon_names: dict[str, str] = {}
@ -228,6 +247,16 @@ class MainWindow(QMainWindow):
self.menu_buttons_widget: QWidget | None = None
self.right_panel: QFrame | None = None
self.right_panel_buttons: dict[str, tuple[QToolButton, str]] = {}
self._update_info: UpdateInfo | None = None
self._update_busy = False
self._update_manual_check = False
self._update_cancel = Event()
self._installing_update = False
self._update_signals = UpdateSignals(self)
self._update_signals.checked.connect(self._on_update_checked)
self._update_signals.downloaded.connect(self._on_update_downloaded)
self._update_signals.progress.connect(self._on_update_progress)
self._update_signals.failed.connect(self._on_update_failed)
self.canvas = LedCanvas()
self._apply_canvas_settings()
@ -240,6 +269,10 @@ class MainWindow(QMainWindow):
self.canvas.autoBlockFromPointRequested.connect(self.auto_block_from_point)
self.canvas.counterclockwiseBlockFromPointRequested.connect(self.auto_block_from_point_counterclockwise)
self.canvas.addLedRequested.connect(self.add_manual_led)
self.canvas.addCommentRequested.connect(self.add_comment_from_canvas)
self.canvas.editCommentRequested.connect(self.edit_comment)
self.canvas.deleteCommentRequested.connect(self.delete_comment)
self.canvas.commentMoveFinished.connect(self.move_comment_from_canvas)
self.canvas.brushStrokeStarted.connect(self.start_brush_stroke)
self.canvas.brushPointTouched.connect(self.add_brush_point_to_block)
self.canvas.brushStrokeFinished.connect(self.finish_brush_stroke)
@ -262,7 +295,6 @@ class MainWindow(QMainWindow):
self.actions["tree_rename"] = self.tree_rename_action
self.tree_delete_action = QAction(self)
self.tree_delete_action.setShortcut(QKeySequence("Ctrl+Del"))
self.tree_delete_action.setShortcutContext(Qt.WidgetShortcut)
self.tree_delete_action.triggered.connect(self.remove_selected_tree_item)
self.tree.addAction(self.tree_delete_action)
@ -319,6 +351,8 @@ class MainWindow(QMainWindow):
application.focusChanged.connect(self._on_application_focus_changed)
application.installEventFilter(self)
self._refresh_ui_state()
if getattr(sys, "frozen", False):
QTimer.singleShot(1200, self.check_updates)
def _build_actions(self) -> None:
def add_action(
@ -344,6 +378,7 @@ class MainWindow(QMainWindow):
self.actions[key] = action
return action
add_action("add_comment", "Добавить комментарий…", self.add_comment_at_center)
add_action("new", "Создать...", self.new_file, "file-plus", QKeySequence.New)
add_action("open", "Открыть", self.open_file, "folder-open", QKeySequence.Open)
add_action("save", "Сохранить", self.save_file, "save", QKeySequence.Save)
@ -357,6 +392,9 @@ class MainWindow(QMainWindow):
"Экспортировать текущую схему маппинга в изображение (PNG, JPEG)"
)
add_action("settings", "Параметры", self.open_settings, "settings", QKeySequence("Ctrl+,"))
add_action("check_updates", "Проверить обновления", self.check_updates)
add_action("install_update", "Установить обновление", self.install_update)
self.actions["install_update"].setEnabled(False)
add_action(
"generate_script",
"Сгенерировать скрипт",
@ -379,11 +417,27 @@ class MainWindow(QMainWindow):
None,
"Автоматически добавить блок от выбранной LED против часовой стрелки",
)
add_action("rebuild_route", "Пересобрать", self.rebuild_active_line_blocks, "redo-2", None, "Пересобрать блоки активной линии по правилу 34 мм")
add_action("rebuild_route", "Пересобрать", self.rebuild_active_line_blocks, "redo-2", None, "Пересобрать блоки активной линии с текущим шагом сетки")
brush_action = add_action("brush", "Кисть", self.toggle_brush_mode, "paintbrush", QKeySequence("B"), "Рисовать выбранный блок по точкам на схеме")
brush_action.setCheckable(True)
add_action("undo", "Отменить", self.undo_last_change, "undo-2", QKeySequence.Undo)
add_action("redo", "Повторить", self.redo_last_change, "redo-2", QKeySequence.Redo)
add_action(
"copy_leds",
"Копировать светодиоды",
self.copy_selected_leds,
"copy",
QKeySequence.Copy,
"Скопировать выбранные LED в режиме редактирования",
)
add_action(
"paste_leds",
"Вставить светодиоды",
self.paste_copied_leds,
"clipboard-paste",
QKeySequence.Paste,
"Вставить свободные копии LED со смещением на шаг сетки",
)
self.delete_action = add_action(
"delete",
"Удалить фиксацию",
@ -403,7 +457,7 @@ class MainWindow(QMainWindow):
add_action("clear_all", "Удалить все", self.clear_all_assignments, "eraser", QKeySequence("Shift+Del"))
add_action("clear_selection", "Очистить выбор", lambda: self.canvas.select_points([]), "archive-restore", QKeySequence("Esc"))
add_action("fit_view", "Показать всё", self.canvas.fit_to_content, "eye", QKeySequence("Ctrl+0"))
grid_action = add_action("grid", "Сетка 34 мм", self.toggle_grid, "grid-2x2", QKeySequence("G"), "Показать или скрыть сетку 34 мм")
grid_action = add_action("grid", "Сетка", self.toggle_grid, "grid-2x2", QKeySequence("G"), "Показать или скрыть сетку")
grid_action.setCheckable(True)
grid_action.setChecked(self.settings.show_grid_by_default)
add_action("background", "Подложка", self.open_background_dialog, "image", QKeySequence("Ctrl+B"), "Добавить или изменить изображение подложки")
@ -478,6 +532,9 @@ class MainWindow(QMainWindow):
edit_menu.addAction(self.actions["undo"])
edit_menu.addAction(self.actions["redo"])
edit_menu.addSeparator()
edit_menu.addAction(self.actions["copy_leds"])
edit_menu.addAction(self.actions["paste_leds"])
edit_menu.addSeparator()
edit_menu.addAction(self.actions["delete"])
edit_menu.addAction(self.actions["delete_points"])
edit_menu.addAction(self.actions["clear_all"])
@ -501,6 +558,7 @@ class MainWindow(QMainWindow):
tools_menu = menu_bar.addMenu("Инструменты")
tools_menu.addAction(self.actions["add_line"])
tools_menu.addAction(self.actions["add_led"])
tools_menu.addAction(self.actions["add_comment"])
tools_menu.addAction(self.actions["generate_script"])
tools_menu.addSeparator()
tools_menu.addAction(self.actions["auto_blocks"])
@ -508,6 +566,10 @@ class MainWindow(QMainWindow):
tools_menu.addSeparator()
tools_menu.addAction(self.actions["brush"])
help_menu = menu_bar.addMenu("Справка")
help_menu.addAction(self.actions["check_updates"])
help_menu.addAction(self.actions["install_update"])
# Delete old widget if recreating
if hasattr(self, "menu_buttons_widget") and self.menu_buttons_widget is not None:
self.menu_buttons_widget.deleteLater()
@ -547,15 +609,23 @@ class MainWindow(QMainWindow):
if not hasattr(self, "menu_buttons_widget") or self.menu_buttons_widget is None:
return
menu_bar = self.menuBar()
actions = menu_bar.actions()
if len(actions) < 4:
action_rects = [
menu_bar.actionGeometry(action)
for action in menu_bar.actions()
if action.isVisible()
]
action_rects = [rect for rect in action_rects if rect.isValid() and rect.right() > 0]
if not action_rects:
self.menu_buttons_widget.hide()
return
tools_action = actions[3]
rect = menu_bar.actionGeometry(tools_action)
if rect.isValid() and rect.right() > 0:
h = menu_bar.height()
w = self.menu_buttons_widget.sizeHint().width()
self.menu_buttons_widget.setGeometry(rect.right() + 10, (h - 20) // 2, w, 20)
left = max(rect.right() for rect in action_rects) + 10
height = menu_bar.height()
width = self.menu_buttons_widget.sizeHint().width()
if left + width > menu_bar.contentsRect().right() + 1:
self.menu_buttons_widget.hide()
return
self.menu_buttons_widget.setGeometry(left, max(0, (height - 20) // 2), width, 20)
self.menu_buttons_widget.show()
def _build_layout(self) -> None:
side = QWidget()
@ -832,8 +902,8 @@ class MainWindow(QMainWindow):
actions = [
("align_horiz", "align-horizontal", "Выровнять по горизонтали (вдоль средней линии Y)", self.align_selected_horizontally),
("align_vert", "align-vertical", "Выровнять по вертикали (вдоль средней линии X)", self.align_selected_vertically),
("grid_horiz", "grid-horizontal", "Расставить по горизонтали с шагом 34 мм", self.arrange_selected_horizontally_grid),
("grid_vert", "grid-vertical", "Расставить по вертикали с шагом 34 мм", self.arrange_selected_vertically_grid),
("grid_horiz", "grid-horizontal", "Расставить по горизонтали с заданным шагом сетки", self.arrange_selected_horizontally_grid),
("grid_vert", "grid-vertical", "Расставить по вертикали с заданным шагом сетки", self.arrange_selected_vertically_grid),
("align_grid", "grid", "Выровнять по сетке (по центру клеток)", self.align_selected_to_grid),
]
@ -892,11 +962,15 @@ class MainWindow(QMainWindow):
return
points = sorted([self.document.get_point(pid) for pid in selected_ids], key=lambda p: p.x)
first_p = points[0]
step = self.document.grid_step_in_document_units()
coords = {}
for i, p in enumerate(points):
new_x = first_p.x + i * 34.0
new_x = first_p.x + i * step
coords[p.uid] = (new_x, first_p.y)
self.update_multiple_points_coordinates(coords, "Светодиоды расставлены по горизонтальной сетке с шагом 34 мм.")
self.update_multiple_points_coordinates(
coords,
f"Светодиоды расставлены по горизонтальной сетке с шагом {self.document.grid_step_mm:g} мм.",
)
def arrange_selected_vertically_grid(self) -> None:
selected_ids = self.canvas.selected_point_ids()
if len(selected_ids) < 2:
@ -904,11 +978,15 @@ class MainWindow(QMainWindow):
return
points = sorted([self.document.get_point(pid) for pid in selected_ids], key=lambda p: p.y, reverse=True)
first_p = points[0]
step = self.document.grid_step_in_document_units()
coords = {}
for i, p in enumerate(points):
new_y = first_p.y - i * 34.0
new_y = first_p.y - i * step
coords[p.uid] = (first_p.x, new_y)
self.update_multiple_points_coordinates(coords, "Светодиоды расставлены по вертикальной сетке с шагом 34 мм.")
self.update_multiple_points_coordinates(
coords,
f"Светодиоды расставлены по вертикальной сетке с шагом {self.document.grid_step_mm:g} мм.",
)
def align_selected_to_grid(self) -> None:
selected_ids = self.canvas.selected_point_ids()
@ -928,9 +1006,11 @@ class MainWindow(QMainWindow):
self.status.setText("Нет опорных точек для расчета сетки.")
return
from models import coordinate_display_scale, infer_grid_coordinate_axes, MIN_NEIGHBOR_DISTANCE_MM
scale = coordinate_display_scale(anchors)
step = MIN_NEIGHBOR_DISTANCE_MM / scale
try:
from .models import infer_grid_coordinate_axes
except ImportError:
from models import infer_grid_coordinate_axes
step = self.document.grid_step_in_document_units()
row_axis, row_sign, col_axis, col_sign = infer_grid_coordinate_axes(anchors)
ref = anchors[0]
@ -958,14 +1038,11 @@ class MainWindow(QMainWindow):
if res != BackgroundDialog.Accepted:
return
background = dialog.background()
if not background.path:
if not background.is_enabled():
return
image_path = Path(background.path)
if not image_path.is_absolute() and self.document.path is not None:
image_path = self.document.path.parent / image_path
reader = QImageReader(str(image_path))
if not reader.canRead():
show_warning(self, "Подложка не загружена", "Не удалось загрузить изображение подложки. Выберите PNG или JPEG файл.")
if not self.canvas.validate_background(background):
details = self.canvas.background_load_error() or "Формат или содержимое изображения не поддерживается."
show_warning(self, "Подложка не загружена", details)
return
if background.to_payload() == self.document.background.to_payload():
self.status.setText("Подложка не изменилась.")
@ -1048,15 +1125,27 @@ class MainWindow(QMainWindow):
self._pending_background_undo_state = None
def open_settings(self) -> None:
dialog = SettingsDialog(self.settings, self)
dialog_settings = deepcopy(self.settings)
dialog_settings.grid_step_mm = self.document.grid_step_mm
dialog = SettingsDialog(dialog_settings, self)
previous_index_font_size = self.settings.led_index_font_size_px
dialog.led_index_font_size.valueChanged.connect(self.canvas.set_line_index_font_size)
self._disable_shortcuts()
try:
res = dialog.exec()
finally:
self._enable_shortcuts()
if res != SettingsDialog.Accepted:
self.canvas.set_line_index_font_size(previous_index_font_size)
return
self.settings = dialog.settings()
new_settings = dialog.settings()
grid_changed = abs(self.document.grid_step_mm - new_settings.grid_step_mm) > 1e-9
if grid_changed:
previous_state = self._capture_state()
self.document.grid_step_mm = new_settings.grid_step_mm
self._push_undo_state(previous_state)
self._dirty = True
self.settings = new_settings
self.settings.save()
apply_app_theme(self.settings.theme)
self._refresh_theme_icons()
@ -1092,17 +1181,35 @@ class MainWindow(QMainWindow):
self.canvas.set_grid_visible(self.settings.show_grid_by_default)
self.canvas.set_direction_arrows_visible(self.settings.show_direction_arrows_by_default)
self.canvas.set_line_index_labels_visible(self.settings.show_led_ids_by_default)
self.canvas.set_line_index_font_size(self.settings.led_index_font_size_px)
self.canvas.set_brush_radius(self.settings.brush_radius)
self.canvas.set_grid_step_mm(self.document.grid_step_mm)
self.canvas.set_point_diameter(self.settings.led_point_diameter_px)
self.canvas.set_zoom_focus_pull(self.settings.zoom_focus_pull)
self.canvas.set_pan_mode(self.settings.pan_mode)
self.canvas.set_instant_space_pan(self.settings.instant_space_pan)
def eventFilter(self, obj, event) -> bool: # noqa: ANN001, N802
if event.type() == QEvent.Type.ShortcutOverride:
if self._text_input_owns_copy_paste(event):
event.accept()
return True
if self._is_tree_bulk_delete_event(event):
event.accept()
return self.remove_selected_tree_item()
if event.type() == QEvent.Type.KeyPress and not event.isAutoRepeat():
if self._trigger_layout_independent_shortcut(event):
return True
return super().eventFilter(obj, event)
def _is_tree_bulk_delete_event(self, event) -> bool: # noqa: ANN001
focus = QApplication.focusWidget()
return (
self._physical_key_sequence(event) == "Ctrl+Del"
and focus is not None
and (focus is self.tree or self.tree.isAncestorOf(focus))
)
def _trigger_layout_independent_shortcut(self, event) -> bool: # noqa: ANN001
active_window = QApplication.activeWindow()
if active_window is not None and active_window is not self:
@ -1114,9 +1221,13 @@ class MainWindow(QMainWindow):
modifiers = event.modifiers()
if isinstance(focus, QKeySequenceEdit):
return False
if self._text_input_owns_copy_paste(event):
return False
text_input_has_focus = isinstance(focus, (QLineEdit, QTextEdit, QPlainTextEdit, QKeySequenceEdit))
if text_input_has_focus and not (modifiers & (Qt.ControlModifier | Qt.AltModifier)):
return False
if self._is_tree_bulk_delete_event(event):
return self.remove_selected_tree_item()
for key in self.shortcut_action_keys:
if self.settings.shortcut_for(key) != sequence:
continue
@ -1126,6 +1237,13 @@ class MainWindow(QMainWindow):
return True
return False
@staticmethod
def _text_input_owns_copy_paste(event) -> bool: # noqa: ANN001
focus = QApplication.focusWidget()
if not isinstance(focus, (QLineEdit, QTextEdit, QPlainTextEdit)):
return False
return event.matches(QKeySequence.StandardKey.Copy) or event.matches(QKeySequence.StandardKey.Paste)
def _physical_key_sequence(self, event) -> str: # noqa: ANN001
key_name = physical_key_name(event)
if not key_name:
@ -1294,17 +1412,13 @@ class MainWindow(QMainWindow):
p = Path(path)
if not p.suffix:
p = p.with_suffix(".json")
loaded = MappingDocument()
loaded.grid_step_mm = self.settings.grid_step_mm
try:
p.write_text('{"circles": []}', encoding="utf-8")
loaded.save(p)
except Exception as exc:
show_critical(self, "Ошибка создания файла", str(exc))
return
loaded = MappingDocument()
try:
loaded.load(p)
except Exception as exc:
show_critical(self, "Ошибка загрузки нового файла", str(exc))
return
self.document = loaded
self._dirty = False
self._undo_stack.clear()
@ -1560,6 +1674,7 @@ class MainWindow(QMainWindow):
return False
active_line = self._get_line_or_none(self.active_line_id)
try:
self._synchronize_canvas_point_positions()
self.document.save(path)
except Exception as exc:
self._restore_active_line(active_line)
@ -1580,6 +1695,17 @@ class MainWindow(QMainWindow):
self._refresh_ui_state()
return True
def _synchronize_canvas_point_positions(self) -> None:
"""Commit any active graphics-item positions before serializing the document."""
for point_id, x, y in self.canvas.document_point_positions():
if not 0 <= point_id < len(self.document.points):
continue
point = self.document.get_point(point_id)
if abs(point.x - x) <= 1e-9 and abs(point.y - y) <= 1e-9:
continue
self.document.update_point_position(point_id, x=x, y=y)
self._dirty = True
def add_line(self) -> None:
if len(self.document.lines) >= MAX_LINES:
show_information(
@ -1598,6 +1724,55 @@ class MainWindow(QMainWindow):
self.status.setText(f"Создана {line.name}. {self._line_summary(line)}")
self._refresh_ui_state()
def add_comment_at_center(self) -> None:
point = self.canvas._document_point_from_scene(
self.canvas.mapToScene(self.canvas.viewport().rect().center())
)
self.add_comment_from_canvas(point.x(), point.y())
def add_comment_from_canvas(self, x: float, y: float) -> None:
text, accepted = QInputDialog.getMultiLineText(self, "Новый комментарий", "Текст комментария:")
if not accepted or not text.strip():
return
self._push_undo()
self.document.add_comment(text, x, y)
self._after_comment_change("Комментарий добавлен.")
def edit_comment(self, comment_id: str) -> None:
comment = self.document.get_comment(comment_id)
text, accepted = QInputDialog.getMultiLineText(
self, "Изменить комментарий", "Текст комментария:", comment.text,
)
if not accepted or not text.strip() or text == comment.text:
return
self._push_undo()
self.document.update_comment(comment_id, text=text)
self._after_comment_change("Комментарий изменён.")
def move_comment_from_canvas(self, comment_id: str, x: float, y: float) -> None:
comment = self.document.get_comment(comment_id)
if (comment.x, comment.y) == (x, y):
return
self._push_undo()
self.document.update_comment(comment_id, x=x, y=y)
self._after_comment_change("Комментарий перемещён.")
def delete_comment(self, comment_id: str) -> None:
self.document.get_comment(comment_id)
self._push_undo()
self.document.remove_comment(comment_id)
self._after_comment_change("Комментарий удалён.")
def _after_comment_change(self, message: str) -> None:
self._dirty = True
if self.canvas.document is not self.document:
self.canvas.set_document(self.document, reset_view=False)
else:
self.canvas._refresh_comments()
self.canvas._update_scene_rect()
self.status.setText(message)
self._refresh_ui_state()
def add_manual_led(self, x: float, y: float) -> None:
self._push_undo()
point = self.document.add_manual_point(x, y)
@ -1609,6 +1784,56 @@ class MainWindow(QMainWindow):
self.status.setText(f"Добавлен свободный LED ID {point.source_id}: X={point.x:.3f}, Y={point.y:.3f}.")
self._refresh_ui_state()
def copy_selected_leds(self) -> bool:
if not self._edit_mode_is_enabled():
self.status.setText("Копирование LED доступно только в режиме редактирования.")
self._refresh_ui_state()
return False
point_ids = self.canvas.selected_point_ids()
if not point_ids:
self.status.setText("Выберите светодиоды для копирования.")
self._refresh_ui_state()
return False
self._copied_led_points = [deepcopy(self.document.get_point(point_id)) for point_id in point_ids]
self._paste_sequence = 0
self.status.setText(f"Скопировано светодиодов: {len(self._copied_led_points)}.")
self._refresh_ui_state(point_ids)
return True
def paste_copied_leds(self) -> bool:
if not self._edit_mode_is_enabled():
self.status.setText("Вставка LED доступна только в режиме редактирования.")
self._refresh_ui_state()
return False
if not self._copied_led_points:
self.status.setText("Сначала скопируйте выбранные светодиоды.")
self._refresh_ui_state()
return False
self._push_undo()
self._paste_sequence += 1
offset = float(self.document.grid_step_in_document_units()) * self._paste_sequence
pasted = self.document.add_point_copies(
self._copied_led_points,
offset_x=offset,
offset_y=-offset,
)
pasted_ids = [point.uid for point in pasted]
self._dirty = True
self.canvas.set_document(self.document, reset_view=False)
self.canvas.select_points(pasted_ids)
self.refresh_tree()
self._on_selection_changed(pasted_ids)
self.status.setText(
f"Вставлено свободных светодиодов: {len(pasted)}. "
f"Смещение: {self._paste_sequence} шаг(а) сетки вправо и вниз."
)
self._refresh_ui_state(pasted_ids)
return True
def _edit_mode_is_enabled(self) -> bool:
action = self.actions.get("toggle_edit_mode")
return bool(action is not None and action.isChecked())
def edit_point_coordinates(self, point_id: int) -> None:
if not 0 <= point_id < len(self.document.points):
return
@ -1688,7 +1913,7 @@ class MainWindow(QMainWindow):
def _coordinate_spin_box(self, value: float) -> QDoubleSpinBox:
spin = QDoubleSpinBox()
spin.setRange(-1_000_000_000.0, 1_000_000_000.0)
spin.setDecimals(6)
spin.setDecimals(15)
spin.setSingleStep(1.0)
spin.setValue(float(value))
spin.setKeyboardTracking(False)
@ -1803,7 +2028,7 @@ class MainWindow(QMainWindow):
answer = ask_question(
self,
"Пересобрать блоки",
"Пересобрать блоки активной линии по физическим соседям и правилу 34 мм?",
f"Пересобрать блоки активной линии по физическим соседям с шагом {self.document.grid_step_mm:g} мм?",
)
if answer != QMessageBox.StandardButton.Yes:
return
@ -1881,6 +2106,33 @@ class MainWindow(QMainWindow):
self._refresh_ui_state()
def remove_selected_tree_item(self) -> bool:
selected_blocks = self._selected_tree_blocks()
if selected_blocks:
blocks = [
self._find_block(line_id, block_id)
for line_id, block_id in selected_blocks
]
affected = len({point_id for block in blocks for point_id in block.point_ids})
if len(selected_blocks) == 1:
if not self._confirm_delete("блок", blocks[0].name, affected):
return False
elif not self._confirm_delete_blocks(len(selected_blocks), affected):
return False
active_line = self._get_line_or_none(self.active_line_id)
self._push_undo()
removed = self.document.remove_blocks(selected_blocks)
self._dirty = True
self._restore_active_line(active_line)
self.brush_target = None
self.canvas.select_points([])
if len(selected_blocks) == 1:
message = f"Удален блок и фиксация LED: {removed}"
else:
message = f"Удалено блоков: {len(selected_blocks)}, снята фиксация LED: {removed}"
self._after_mapping_change(message, selection_already_updated=True)
return True
item = self.tree.currentItem()
if item is None:
return False
@ -1902,23 +2154,6 @@ class MainWindow(QMainWindow):
self.canvas.select_points([])
self._after_mapping_change(f"Удалена линия и фиксация LED: {removed}", selection_already_updated=True)
return True
if data[0] == "block":
if not self._block_exists(data[1], data[2]):
self.refresh_tree()
return False
active_line = self._get_line_or_none(data[1])
block = self._find_block_or_none(data[1], data[2])
if block is None:
return False
if not self._confirm_delete("блок", block.name, len(block.point_ids)):
return False
self._push_undo()
removed = self.document.remove_block(data[1], data[2])
self._dirty = True
self._restore_active_line(active_line)
self.canvas.select_points([])
self._after_mapping_change(f"Удален блок и фиксация LED: {removed}", selection_already_updated=True)
return True
return False
def clear_all_assignments(self) -> None:
@ -2250,14 +2485,9 @@ class MainWindow(QMainWindow):
rename_action = menu.addAction("Переименовать блок\tF2")
elif data[0] == "line":
rename_action = menu.addAction("Переименовать линию\tF2")
label = "Удалить выбранную фиксацию\tCtrl+Del"
if item is not None and data is not None:
if data[0] == "line":
label = "Удалить линию\tCtrl+Del"
elif data[0] == "block":
label = "Удалить блок\tCtrl+Del"
label = self._tree_delete_label(data)
delete_action = menu.addAction(label)
delete_action.setEnabled(item is not None)
delete_action.setEnabled(bool(self._selected_tree_blocks()) or item is not None)
clear_all_action = menu.addAction("Удалить все фиксации\tShift+Del")
clear_all_action.setEnabled(bool(self.document.lines))
fit_action = menu.addAction("Показать все точки")
@ -2583,6 +2813,16 @@ class MainWindow(QMainWindow):
order[(line.id, block.id)] = len(order)
return sorted(set(blocks), key=lambda block_key: order.get(block_key, 10**9))
def _tree_delete_label(self, current_data) -> str: # noqa: ANN001
selected_block_count = len(self._selected_tree_blocks())
if selected_block_count > 1:
return "Удалить выбранные блоки\tCtrl+Del"
if selected_block_count == 1:
return "Удалить блок\tCtrl+Del"
if current_data and current_data[0] == "line":
return "Удалить линию\tCtrl+Del"
return "Удалить выбранную фиксацию\tCtrl+Del"
def _can_merge_selected_tree_blocks(self) -> bool:
selected_blocks = self._selected_tree_blocks()
return len(selected_blocks) >= 2 and len({line_id for line_id, _block_id in selected_blocks}) == 1
@ -2625,6 +2865,7 @@ class MainWindow(QMainWindow):
has_visible_background = has_loaded_background and self.document.background.visible
has_active_line = self._get_line_or_none(self.active_line_id) is not None
has_selection = bool(point_ids)
edit_mode_enabled = self._edit_mode_is_enabled()
selected_has_assignment = any(
self.document.get_point(point_id).line_id is not None
or self.document.get_point(point_id).block_id is not None
@ -2652,12 +2893,14 @@ class MainWindow(QMainWindow):
self.selection_label.setText(self._selection_summary(point_ids))
state = {
"save": has_points and self._dirty,
"save_as": has_points,
"save": bool(has_points or self.document.comments or self.document.path is not None) and self._dirty,
"save_as": bool(has_points or self.document.comments or self.document.path is not None),
"export_image": has_points,
"generate_script": has_points,
"undo": bool(self._undo_stack),
"redo": bool(self._redo_stack),
"copy_leds": edit_mode_enabled and has_selection,
"paste_leds": edit_mode_enabled and bool(self._copied_led_points),
"add_line": has_points and len(self.document.lines) < MAX_LINES,
"add_led": True,
"add_block": has_points and has_active_line,
@ -2670,7 +2913,7 @@ class MainWindow(QMainWindow):
"delete_points": has_selection,
"clear_all": has_lines,
"clear_selection": has_selection,
"fit_view": has_points or has_visible_background,
"fit_view": has_points or has_visible_background or bool(self.document.comments),
"grid": True,
"background": True,
"background_visible": has_loaded_background,
@ -2914,8 +3157,117 @@ class MainWindow(QMainWindow):
)
return answer == QMessageBox.StandardButton.Yes
def check_updates(self) -> None:
if self._update_busy:
return
self._update_manual_check = not bool(getattr(sys, "frozen", False)) or self.sender() is self.actions["check_updates"]
self._update_busy = True
self.actions["check_updates"].setEnabled(False)
self.actions["install_update"].setEnabled(False)
if self._update_manual_check:
self.status.setText("Проверка обновлений...")
def worker() -> None:
try:
self._update_signals.checked.emit(check_for_update(current_version()))
except (UpdateError, OSError) as exc:
self._update_signals.failed.emit(str(exc))
Thread(target=worker, name="ledmapper-update-check", daemon=True).start()
def _on_update_checked(self, info: UpdateInfo | None) -> None:
self._update_busy = False
self._update_info = info
self.actions["check_updates"].setEnabled(True)
self.actions["install_update"].setEnabled(info is not None and bool(getattr(sys, "frozen", False)))
if info is None:
if self._update_manual_check:
show_information(self, "Обновления", f"Установлена актуальная версия {current_version()}.")
return
self.actions["install_update"].setText(f"Установить обновление {info.tag}")
self.status.setText(f"Доступна версия {info.tag}. Меню «Справка» → «Установить обновление».")
if not getattr(sys, "frozen", False) and self._update_manual_check:
show_information(self, "Обновление", "Автоустановка доступна только в собранном EXE.")
def install_update(self) -> None:
info = self._update_info
if info is None or self._update_busy:
return
try:
executable = current_executable()
except UpdateError as exc:
show_warning(self, "Обновление", str(exc))
return
answer = ask_question(self, "Обновление LED Mapper", f"Скачать {info.asset_name} и перезапустить приложение?")
if answer != QMessageBox.StandardButton.Yes:
return
self._update_busy = True
self._update_cancel.clear()
self.actions["check_updates"].setEnabled(False)
self.actions["install_update"].setEnabled(False)
self.status.setText(f"Загрузка {info.asset_name}: 0 %")
def worker() -> None:
try:
downloaded = download_update(
info, executable.parent,
progress=self._update_signals.progress.emit,
cancelled=self._update_cancel.is_set,
)
if self._update_cancel.is_set():
downloaded.unlink(missing_ok=True)
return
self._update_signals.downloaded.emit(downloaded)
except UpdateCancelled:
return
except (UpdateError, OSError) as exc:
self._update_signals.failed.emit(str(exc))
Thread(target=worker, name="ledmapper-update-download", daemon=True).start()
def _on_update_progress(self, percent: int) -> None:
if self._update_info is not None:
self.status.setText(f"Загрузка {self._update_info.asset_name}: {percent} %")
def _on_update_downloaded(self, downloaded: Path) -> None:
self._update_busy = False
self.actions["check_updates"].setEnabled(True)
self.actions["install_update"].setEnabled(True)
info = self._update_info
if info is None:
downloaded.unlink(missing_ok=True)
return
if self._dirty:
answer = ask_question(self, "Сохранить схему", "Перед перезапуском нужно сохранить изменения схемы. Сохранить сейчас?")
if answer != QMessageBox.StandardButton.Yes:
downloaded.unlink(missing_ok=True)
return
self.save_file()
if self._dirty:
downloaded.unlink(missing_ok=True)
return
try:
launch_update(downloaded, info)
except UpdateError as exc:
downloaded.unlink(missing_ok=True)
show_warning(self, "Обновление", str(exc))
return
self._installing_update = True
self.close()
def _on_update_failed(self, message: str) -> None:
self._update_busy = False
self.actions["check_updates"].setEnabled(True)
self.actions["install_update"].setEnabled(
self._update_info is not None and bool(getattr(sys, "frozen", False))
)
self.status.setText(f"Ошибка обновления: {message}")
if self._update_manual_check or self._update_info is not None:
show_warning(self, "Обновление", message)
def closeEvent(self, event) -> None: # noqa: ANN001
if self._confirm_discard_changes("Закрыть приложение и потерять несохраненные изменения текущей карты?"):
if self._installing_update or self._confirm_discard_changes("Закрыть приложение и потерять несохраненные изменения текущей карты?"):
self._update_cancel.set()
event.accept()
else:
event.ignore()
@ -2942,6 +3294,14 @@ class MainWindow(QMainWindow):
)
return answer == QMessageBox.StandardButton.Yes
def _confirm_delete_blocks(self, count: int, affected: int) -> bool:
answer = ask_question(
self,
"Подтвердите удаление",
f"Удалить выбранные блоки: {count} и снять фиксацию с {affected} LED?",
)
return answer == QMessageBox.StandardButton.Yes
def _confirm_delete_points(self, count: int) -> bool:
answer = ask_question(
self,
@ -2981,8 +3341,7 @@ class MainWindow(QMainWindow):
@staticmethod
def _format_coordinate(value: float) -> str:
text = f"{value:.3f}".rstrip("0").rstrip(".")
return text if text else "0"
return repr(float(value))
def _line_summary(self, line: LedLine) -> str:
return f"блоков: {len(line.blocks)}, LED: {self._line_count(line)}"

421
models.py
View File

@ -1,10 +1,18 @@
from __future__ import annotations
import base64
import binascii
import hashlib
import json
import math
import mimetypes
import os
import re
import tempfile
import uuid
from collections import deque
from dataclasses import dataclass, field
from copy import deepcopy
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Any
@ -14,12 +22,16 @@ except ImportError: # pragma: no cover - supports package imports in tests/tool
from .unfolding import MAX_LINES, UnfoldingDescription, UnfoldingLineSummary, clockwise_sort, order_points_clockwise
MIN_NEIGHBOR_DISTANCE_MM = 34.0
DEFAULT_GRID_STEP_MM = MIN_NEIGHBOR_DISTANCE_MM
MAX_NEIGHBOR_TOLERANCE_MM = 2.0
METER_LIKE_MAX_SPAN = 10.0
_DOCUMENT_KNOWN_FIELDS = {
"background",
"comments",
"circles",
"coordinate_unit",
"grid_step_mm",
"index_base",
"leds",
"lines",
"unit",
@ -41,6 +53,15 @@ _LED_POINT_KNOWN_FIELDS = {
}
class NominalGridDistance(float):
"""Grid spacing carrying a tolerance expressed in the same coordinate unit."""
def __new__(cls, value: float, tolerance: float): # noqa: ANN204
instance = super().__new__(cls, value)
instance.tolerance = tolerance
return instance
def load_json_document(path: str | Path) -> dict[str, Any]:
path = Path(path)
if path.exists():
@ -187,11 +208,33 @@ class BackgroundImage:
scale_y_percent: float = 100.0
opacity_percent: float = 100.0
visible: bool = True
embedded_image_base64: str = ""
embedded_image_mime_type: str = ""
embedded_image_name: str = ""
embedded_image_sha256: str = ""
@classmethod
def from_payload(cls, payload) -> "BackgroundImage": # noqa: ANN001
if not isinstance(payload, dict):
return cls()
embedded = payload.get("embedded_image")
embedded_data = ""
embedded_mime_type = ""
embedded_name = ""
embedded_sha256 = ""
if isinstance(embedded, dict):
candidate_data = str(embedded.get("data") or "")
try:
decoded = base64.b64decode(candidate_data, validate=True) if candidate_data else b""
except (binascii.Error, ValueError, TypeError):
decoded = b""
candidate_sha256 = str(embedded.get("sha256") or "").strip().lower()
actual_sha256 = hashlib.sha256(decoded).hexdigest() if decoded else ""
if decoded and (not candidate_sha256 or candidate_sha256 == actual_sha256):
embedded_data = candidate_data
embedded_mime_type = str(embedded.get("mime_type") or "application/octet-stream")
embedded_name = Path(str(embedded.get("name") or "background")).name
embedded_sha256 = actual_sha256
scale = _optional_float(payload.get("scale_percent"))
scale_x = _optional_float(payload.get("scale_x_percent"))
scale_y = _optional_float(payload.get("scale_y_percent"))
@ -210,10 +253,14 @@ class BackgroundImage:
scale_y_percent=max(1.0, float(scale_y)),
opacity_percent=min(100.0, max(0.0, 100.0 if opacity is None else float(opacity))),
visible=bool(payload.get("visible", True)),
embedded_image_base64=embedded_data,
embedded_image_mime_type=embedded_mime_type,
embedded_image_name=embedded_name,
embedded_image_sha256=embedded_sha256,
)
def to_payload(self) -> dict[str, bool | float | str]:
return {
def to_payload(self) -> dict[str, Any]:
payload: dict[str, Any] = {
"path": self.path,
"offset_x": self.offset_x,
"offset_y": self.offset_y,
@ -222,9 +269,80 @@ class BackgroundImage:
"opacity_percent": self.opacity_percent,
"visible": self.visible,
}
if self.has_embedded_image():
payload["embedded_image"] = {
"encoding": "base64",
"mime_type": self.embedded_image_mime_type or "application/octet-stream",
"name": self.embedded_image_name or "background",
"sha256": self.embedded_image_sha256 or hashlib.sha256(self.embedded_image_bytes()).hexdigest(),
"data": self.embedded_image_base64,
}
return payload
def is_enabled(self) -> bool:
return bool(self.path)
return bool(self.path) or self.has_embedded_image()
def has_embedded_image(self) -> bool:
return bool(self.embedded_image_base64)
def embedded_image_bytes(self) -> bytes:
if not self.embedded_image_base64:
return b""
try:
return base64.b64decode(self.embedded_image_base64, validate=True)
except (binascii.Error, ValueError, TypeError):
return b""
def with_embedded_image(self, base_dir: Path | None = None) -> "BackgroundImage":
if self.has_embedded_image() or not self.path:
return self
image_path = Path(self.path)
if not image_path.is_absolute() and base_dir is not None:
image_path = base_dir / image_path
image_bytes = image_path.read_bytes()
if not image_bytes:
raise ValueError("Файл подложки пуст и не может быть встроен в схему.")
mime_type = mimetypes.guess_type(image_path.name)[0] or "application/octet-stream"
return replace(
self,
path="",
embedded_image_base64=base64.b64encode(image_bytes).decode("ascii"),
embedded_image_mime_type=mime_type,
embedded_image_name=image_path.name,
embedded_image_sha256=hashlib.sha256(image_bytes).hexdigest(),
)
@dataclass
class TextComment:
id: str
text: str
x: float
y: float
extra_payload: dict[str, Any] = field(default_factory=dict)
@classmethod
def from_payload(cls, payload: dict[str, Any]) -> "TextComment":
text, x, y = cls.validate(payload.get("text", ""), payload.get("x", 0), payload.get("y", 0))
return cls(
id=str(payload.get("id") or uuid.uuid4().hex), text=text, x=x, y=y,
extra_payload={key: deepcopy(value) for key, value in payload.items()
if key not in {"id", "text", "x", "y"}},
)
@staticmethod
def validate(text: str, x: float, y: float) -> tuple[str, float, float]:
if not isinstance(text, str) or not text.strip():
raise ValueError("Введите текст комментария.")
x, y = float(x), float(y)
if not math.isfinite(x) or not math.isfinite(y):
raise ValueError("Координаты комментария должны быть конечными числами.")
return text, x, y
def to_payload(self) -> dict[str, Any]:
text, x, y = self.validate(self.text, self.x, self.y)
return {**deepcopy(self.extra_payload), "id": self.id, "text": text, "x": x, "y": y}
class MappingDocument:
@ -233,24 +351,28 @@ class MappingDocument:
self.collection_name = "circles"
self.points: list[LedPoint] = []
self.lines: list[LedLine] = []
self.comments: list[TextComment] = []
self.background = BackgroundImage()
self.unfolding = UnfoldingDescription()
self.coordinate_unit = "auto"
self.coordinate_unit = "mm"
self.grid_step_mm = DEFAULT_GRID_STEP_MM
self.extra_payload: dict[str, Any] = {}
self._next_line_id = 1
self._next_block_id = 1
self._next_line_id = 0
self._next_block_id = 0
def clear(self) -> None:
self.path = None
self.collection_name = "circles"
self.points.clear()
self.lines.clear()
self.comments.clear()
self.background = BackgroundImage()
self.unfolding = UnfoldingDescription()
self.coordinate_unit = "auto"
self.coordinate_unit = "mm"
self.grid_step_mm = DEFAULT_GRID_STEP_MM
self.extra_payload.clear()
self._next_line_id = 1
self._next_block_id = 1
self._next_line_id = 0
self._next_block_id = 0
def load(self, path: str | Path) -> None:
self.clear()
@ -261,7 +383,8 @@ class MappingDocument:
for key, value in data.items()
if key not in _DOCUMENT_KNOWN_FIELDS
}
self.coordinate_unit = str(data.get("coordinate_unit", data.get("unit", "auto")))
self.coordinate_unit = str(data.get("coordinate_unit", data.get("unit", "auto"))).strip().lower()
self.grid_step_mm = _positive_finite_float(data.get("grid_step_mm"), DEFAULT_GRID_STEP_MM)
points_data = data.get("leds")
self.collection_name = "leds"
if not isinstance(points_data, list):
@ -297,7 +420,22 @@ class MappingDocument:
)
)
self._canonicalize_coordinate_unit()
comments_data = data.get("comments", [])
if not isinstance(comments_data, list):
raise ValueError("Поле comments должно содержать список комментариев.")
seen_comment_ids: set[str] = set()
for payload in comments_data:
if not isinstance(payload, dict):
raise ValueError("Комментарий должен быть объектом JSON.")
comment = TextComment.from_payload(payload)
if comment.id in seen_comment_ids:
comment.id = uuid.uuid4().hex
seen_comment_ids.add(comment.id)
self.comments.append(comment)
self._load_lines(data)
self._migrate_legacy_index_base(data)
self.background = BackgroundImage.from_payload(data.get("background"))
self.unfolding = UnfoldingDescription.from_payload(data.get("unfolding"))
self.unfolding.max_lines = MAX_LINES
@ -336,6 +474,41 @@ class MappingDocument:
self.lines.append(line)
self._next_line_id = max(self._next_line_id, line_id + 1)
def _migrate_legacy_index_base(self, data: dict[str, Any]) -> None:
"""Однократно переводит старые 1-based ID, сохраняя физические адреса лент."""
marker = data.get("index_base")
if marker == 0:
return
if marker not in (None, 1):
raise ValueError("Unsupported mapping index_base; expected 0 or 1.")
identifiers = [line.id for line in self.lines]
identifiers.extend(point.line_id for point in self.points if point.line_id is not None)
if marker is None and (not identifiers or min(identifiers) == 0):
return
if identifiers and min(identifiers) < 1:
return
for line in self.lines:
old_id = line.id
line.id -= 1
if re.fullmatch(r"(?:Линия|Line)\s+\d+", line.name):
line.name = f"Линия {line.id}"
if line.color == default_color(old_id):
line.color = default_color(line.id)
for block in line.blocks:
block.id -= 1
if re.fullmatch(r"(?:Блок|Block)\s+\d+", block.name):
block.name = f"Блок {block.id}"
for point in self.points:
if point.line_id is not None:
point.line_id -= 1
if point.block_id is not None:
point.block_id -= 1
self._next_line_id = max((line.id for line in self.lines), default=-1) + 1
self._next_block_id = max(
(block.id for line in self.lines for block in line.blocks),
default=-1,
) + 1
def _rebuild_lines_from_points(self) -> None:
for line_id in sorted({point.line_id for point in self.points if point.line_id is not None}):
line = LedLine(int(line_id), f"Line {line_id}", default_color(int(line_id)))
@ -357,7 +530,7 @@ class MappingDocument:
if point.line_id == line_id
]
if ids:
line.blocks.append(LedBlock(self._next_block_id, "Block 1", ids))
line.blocks.append(LedBlock(self._next_block_id, f"Блок {self._next_block_id}", ids))
self._next_block_id += 1
for block_id in block_ids:
ids = [
@ -379,9 +552,34 @@ class MappingDocument:
def save(self, path: str | Path) -> None:
path = Path(path)
self.reindex_all()
payload = self.to_payload()
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
source_base_dir = self.path.parent if self.path is not None else path.parent
background_for_save = self.background.with_embedded_image(source_base_dir)
payload = self.to_payload(background=background_for_save)
serialized = json.dumps(payload, ensure_ascii=False, indent=2, allow_nan=False)
if json.loads(serialized) != payload:
raise ValueError("Не удалось без потерь сериализовать схему.")
temporary_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
newline="\n",
dir=path.parent,
prefix=f".{path.name}.",
suffix=".tmp",
delete=False,
) as temporary_file:
temporary_file.write(serialized)
temporary_file.write("\n")
temporary_file.flush()
os.fsync(temporary_file.fileno())
temporary_path = Path(temporary_file.name)
os.replace(temporary_path, path)
finally:
if temporary_path is not None and temporary_path.exists():
temporary_path.unlink()
self.path = path
self.background = background_for_save
def to_payload(
self,
@ -389,6 +587,7 @@ class MappingDocument:
point_payloads: list[dict[str, Any]] | None = None,
lines_payload: list[dict[str, Any]] | None = None,
collection_name: str | None = None,
background: BackgroundImage | None = None,
) -> dict[str, Any]:
payload = dict(self.extra_payload)
payload[collection_name or self.collection_name] = (
@ -398,8 +597,11 @@ class MappingDocument:
)
payload["unfolding"] = self.describe_unfolding().to_payload()
payload["coordinate_unit"] = self._coordinate_unit_for_save()
payload["background"] = self.background.to_payload()
payload["grid_step_mm"] = self.grid_step_mm
payload["index_base"] = 0
payload["background"] = (self.background if background is None else background).to_payload()
payload["lines"] = self.lines_to_payload() if lines_payload is None else lines_payload
payload["comments"] = [comment.to_payload() for comment in self.comments]
return payload
def point_to_payload(self, point: LedPoint, *, id_value: int | None = None) -> dict[str, Any]:
@ -443,6 +645,32 @@ class MappingDocument:
for line in self.lines
]
def add_comment(self, text: str, x: float, y: float) -> TextComment:
text, x, y = TextComment.validate(text, x, y)
comment = TextComment(uuid.uuid4().hex, text, x, y)
self.comments.append(comment)
return comment
def get_comment(self, comment_id: str) -> TextComment:
for comment in self.comments:
if comment.id == comment_id:
return comment
raise KeyError(comment_id)
def update_comment(
self, comment_id: str, *, text: str | None = None,
x: float | None = None, y: float | None = None,
) -> None:
comment = self.get_comment(comment_id)
values = TextComment.validate(
comment.text if text is None else text,
comment.x if x is None else x, comment.y if y is None else y,
)
comment.text, comment.x, comment.y = values
def remove_comment(self, comment_id: str) -> None:
self.comments.remove(self.get_comment(comment_id))
def add_line(self) -> LedLine:
line_limit = self._line_limit()
if len(self.lines) >= line_limit:
@ -469,6 +697,37 @@ class MappingDocument:
self.points.append(point)
return point
def add_point_copies(
self,
templates: list[LedPoint],
*,
offset_x: float,
offset_y: float,
) -> list[LedPoint]:
if not templates:
return []
next_source_id = max((point.source_id for point in self.points), default=-1) + 1
copies: list[LedPoint] = []
for template in list(templates):
x = float(template.x) + float(offset_x)
y = float(template.y) + float(offset_y)
row, col = self._grid_cell_for_coordinate(x, y)
point = LedPoint(
uid=len(self.points),
source_id=next_source_id,
x=x,
y=y,
row=row,
col=col,
coordinate_unit=self._coordinate_unit_for_save(),
manual=True,
extra_payload=deepcopy(template.extra_payload),
)
self.points.append(point)
copies.append(point)
next_source_id += 1
return copies
def update_point_position(self, point_id: int, x: float | None = None, y: float | None = None) -> None:
point = self.get_point(point_id)
if x is not None:
@ -509,7 +768,7 @@ class MappingDocument:
point_id for point_id in point_ids if 0 <= point_id < len(self.points)
])
self._detach_points_from_blocks(clean_point_ids)
block = LedBlock(len(line.blocks) + 1, f"Блок {len(line.blocks) + 1}", clean_point_ids)
block = LedBlock(len(line.blocks), f"Блок {len(line.blocks)}", clean_point_ids)
insert_index = len(line.blocks)
if after_block_id is not None:
for index, existing_block in enumerate(line.blocks):
@ -603,18 +862,36 @@ class MappingDocument:
return removed
def remove_block(self, line_id: int, block_id: int) -> int:
line = self.get_line(line_id)
return self.remove_blocks([(line_id, block_id)])
def remove_blocks(self, block_keys: list[tuple[int, int]]) -> int:
target_keys = set(block_keys)
if not target_keys:
return 0
existing_keys = {
(line.id, block.id)
for line in self.lines
for block in line.blocks
}
missing_keys = target_keys - existing_keys
if missing_keys:
raise KeyError(f"Unknown block keys: {sorted(missing_keys)}")
removed_ids: list[int] = []
kept_blocks: list[LedBlock] = []
for block in line.blocks:
if block.id == block_id:
removed_ids.extend(block.point_ids)
else:
kept_blocks.append(block)
line.blocks = kept_blocks
self._clear_points(removed_ids)
for line in self.lines:
kept_blocks: list[LedBlock] = []
for block in line.blocks:
if (line.id, block.id) in target_keys:
removed_ids.extend(block.point_ids)
else:
kept_blocks.append(block)
line.blocks = kept_blocks
unique_removed_ids = unique_in_order(removed_ids)
self._clear_points(unique_removed_ids)
self.reindex_all()
return len(removed_ids)
return len(unique_removed_ids)
def remove_line(self, line_id: int) -> int:
removed_ids: list[int] = []
@ -635,14 +912,14 @@ class MappingDocument:
assigned = sum(1 for point in self.points if point.line_id is not None or point.block_id is not None)
self.lines.clear()
self._clear_points([point.uid for point in self.points])
self._next_line_id = 1
self._next_block_id = 1
self._next_line_id = 0
self._next_block_id = 0
return assigned
def renumber_lines(self) -> None:
self.order_lines_clockwise()
id_map: dict[int, int] = {}
for new_id, line in enumerate(self.lines, start=1):
for new_id, line in enumerate(self.lines):
old_id = line.id
id_map[old_id] = new_id
line.id = new_id
@ -654,7 +931,7 @@ class MappingDocument:
if point.line_id in id_map:
point.line_id = id_map[point.line_id]
self._next_line_id = len(self.lines) + 1
self._next_line_id = len(self.lines)
def _validate_line_limit(self) -> None:
line_limit = self._line_limit()
@ -669,7 +946,23 @@ class MappingDocument:
return "m"
if self.coordinate_unit in {"mm", "millimeter", "millimeters"}:
return "mm"
return "m" if coordinate_display_scale(self.points) == 1000.0 else "mm"
self._canonicalize_coordinate_unit()
return self.coordinate_unit
def _canonicalize_coordinate_unit(self) -> None:
if self.coordinate_unit in {"m", "meter", "meters"}:
unit = "m"
elif self.coordinate_unit in {"mm", "millimeter", "millimeters"}:
unit = "mm"
else:
unit = "m" if self.points and coordinate_display_scale(self.points) == 1000.0 else "mm"
self.coordinate_unit = unit
for point in self.points:
point.coordinate_unit = unit
def grid_step_in_document_units(self) -> float:
scale = 1000.0 if self._coordinate_unit_for_save() == "m" else 1.0
return NominalGridDistance(self.grid_step_mm / scale, MAX_NEIGHBOR_TOLERANCE_MM / scale)
def _grid_cell_for_coordinate(self, x: float, y: float, *, exclude_uid: int | None = None) -> tuple[int, int]:
anchors = [point for point in self.points if point.uid != exclude_uid and not point.manual]
@ -677,8 +970,7 @@ class MappingDocument:
anchors = [point for point in self.points if point.uid != exclude_uid]
if not anchors:
return 0, 0
scale = coordinate_display_scale(anchors)
step = MIN_NEIGHBOR_DISTANCE_MM / scale
step = self.grid_step_in_document_units()
row_axis, row_sign, col_axis, col_sign = infer_grid_coordinate_axes(anchors)
source_row_value = x if row_axis == "x" else y
source_col_value = x if col_axis == "x" else y
@ -698,7 +990,14 @@ class MappingDocument:
if reorder_leds:
for block in line.blocks:
start_point_id = block.point_ids[0] if block.point_ids else None
ordered_ids = [point.uid for point in order_points_like_grid_svg(self._points_for_block(block), start_point_id)]
ordered_ids = [
point.uid
for point in order_points_like_grid_svg(
self._points_for_block(block),
start_point_id,
min_neighbor_distance=self.grid_step_in_document_units(),
)
]
if start_point_id is not None:
ordered_ids = rotate_ids_to_start(ordered_ids, start_point_id)
block.point_ids = ordered_ids
@ -799,7 +1098,7 @@ class MappingDocument:
start_id = start_ids[0] if start_ids else point_ids[0]
groups = self.route_groups_from_points(point_ids, start_id)
line.blocks.clear()
for block_number, group in enumerate(groups, start=1):
for block_number, group in enumerate(groups):
line.blocks.append(LedBlock(block_number, f"Блок {block_number}", group))
self.reindex_all()
return len(groups)
@ -811,7 +1110,7 @@ class MappingDocument:
self._detach_points_from_blocks([point_id for group in groups for point_id in group])
line = self.get_line(line_id)
for group_ids in groups:
block_number = len(line.blocks) + 1
block_number = len(line.blocks)
block = LedBlock(block_number, f"Блок {block_number}", unique_in_order(group_ids))
line.blocks.append(block)
self.order_blocks_clockwise(line_id, reorder_leds=False)
@ -825,7 +1124,11 @@ class MappingDocument:
]
return [
[point.uid for point in route]
for route in trace_route_paths_like_grid_svg(points, start_point_id)
for route in trace_route_paths_like_grid_svg(
points,
start_point_id,
min_neighbor_distance=self.grid_step_in_document_units(),
)
]
def add_clockwise_block_from_seed(self, line_id: int, start_point_id: int) -> LedBlock:
@ -862,7 +1165,7 @@ class MappingDocument:
for point in self.points
if point.uid == start_point_id or (point.line_id is None and point.block_id is None)
]
threshold = min_neighbor_distance_for_points(candidate_points)
threshold = self.grid_step_in_document_units()
adjacency = build_route_adjacency(candidate_points, threshold)
route = trace_single_route_like_grid_svg(
candidate_points,
@ -887,7 +1190,10 @@ class MappingDocument:
]
return [
[point.uid for point in route]
for route in trace_route_paths_like_grid_svg(points)
for route in trace_route_paths_like_grid_svg(
points,
min_neighbor_distance=self.grid_step_in_document_units(),
)
]
def reindex_all(self) -> None:
@ -914,7 +1220,7 @@ class MappingDocument:
@staticmethod
def renumber_blocks(line: LedLine) -> None:
for block_number, block in enumerate(line.blocks, start=1):
for block_number, block in enumerate(line.blocks):
block.id = block_number
if not block.name or re.match(r"^(Блок|Block)\s+\d+$", block.name):
block.name = f"Блок {block_number}"
@ -980,6 +1286,13 @@ def _optional_float(value: Any) -> float | None:
return None
def _positive_finite_float(value: Any, fallback: float) -> float:
parsed = _optional_float(value)
if parsed is None or not math.isfinite(parsed) or parsed <= 0.0:
return fallback
return min(1000.0, max(1.0, parsed))
def infer_grid_coordinate_axes(points: list[LedPoint]) -> tuple[str, int, str, int]:
row_x = _axis_correlation(points, "row", "x")
row_y = _axis_correlation(points, "row", "y")
@ -1016,7 +1329,7 @@ def _correlation_sign(value: float) -> int:
def default_color(line_id: int) -> str:
colors = ["#e11d48", "#2563eb", "#16a34a", "#f59e0b", "#7c3aed", "#0891b2"]
return colors[(line_id - 1) % len(colors)]
return colors[line_id % len(colors)]
def distance(a: LedPoint, b: LedPoint) -> float:
@ -1080,7 +1393,10 @@ def contour_groups_like_grid_svg(points: list[LedPoint]) -> list[list[LedPoint]]
return sorted(groups, key=lambda group: min(point_sort_key_for_model(point) for point in group))
def route_groups_like_grid_svg(points: list[LedPoint]) -> list[list[LedPoint]]:
def route_groups_like_grid_svg(
points: list[LedPoint],
min_neighbor_distance: float | None = None,
) -> list[list[LedPoint]]:
points_by_cell: dict[tuple[int, int], list[LedPoint]] = {}
for point in points:
points_by_cell.setdefault((point.row, point.col), []).append(point)
@ -1089,7 +1405,7 @@ def route_groups_like_grid_svg(points: list[LedPoint]) -> list[list[LedPoint]]:
seen: set[tuple[int, int]] = set()
groups: list[list[LedPoint]] = []
neighbors = ((1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (-1, 1), (-1, -1), (1, -1))
threshold = min_neighbor_distance_for_points(points)
threshold = min_neighbor_distance or min_neighbor_distance_for_points(points)
for start_cell in sorted(cells):
if start_cell in seen:
@ -1143,10 +1459,15 @@ def order_group_nearest(points: list[LedPoint]) -> list[LedPoint]:
return ordered
def order_points_like_grid_svg(points: list[LedPoint], start_point_id: int | None = None) -> list[LedPoint]:
def order_points_like_grid_svg(
points: list[LedPoint],
start_point_id: int | None = None,
*,
min_neighbor_distance: float | None = None,
) -> list[LedPoint]:
if len(points) <= 1:
return points[:]
min_neighbor_distance = min_neighbor_distance_for_points(points)
min_neighbor_distance = min_neighbor_distance or min_neighbor_distance_for_points(points)
remaining = points[:]
if start_point_id is None:
start_point = min(remaining, key=point_sort_key_for_model)
@ -1186,12 +1507,14 @@ def order_points_like_grid_svg(points: list[LedPoint], start_point_id: int | Non
def trace_route_paths_like_grid_svg(
points: list[LedPoint],
start_point_id: int | None = None,
*,
min_neighbor_distance: float | None = None,
) -> list[list[LedPoint]]:
remaining = points[:]
remaining_ids = {point.uid for point in remaining}
routes: list[list[LedPoint]] = []
preferred_start_id = start_point_id
min_neighbor_distance = min_neighbor_distance_for_points(points)
min_neighbor_distance = min_neighbor_distance or min_neighbor_distance_for_points(points)
while remaining:
if preferred_start_id is not None:
start_point = next((point for point in remaining if point.uid == preferred_start_id), None)
@ -1695,11 +2018,15 @@ def max_neighbor_distance(nominal_distance: float) -> float:
def neighbor_tolerance(nominal_distance: float) -> float:
explicit_tolerance = getattr(nominal_distance, "tolerance", None)
if explicit_tolerance is not None:
return float(explicit_tolerance)
return MAX_NEIGHBOR_TOLERANCE_MM / (MIN_NEIGHBOR_DISTANCE_MM / nominal_distance)
def min_neighbor_distance_for_points(points: list[LedPoint]) -> float:
return MIN_NEIGHBOR_DISTANCE_MM / coordinate_display_scale(points)
scale = coordinate_display_scale(points)
return NominalGridDistance(MIN_NEIGHBOR_DISTANCE_MM / scale, MAX_NEIGHBOR_TOLERANCE_MM / scale)
def coordinate_display_scale(points: list[LedPoint]) -> float:

View File

@ -11,20 +11,20 @@ from typing import Iterable
try:
from .models import (
LedPoint,
MAX_NEIGHBOR_TOLERANCE_MM,
MappingDocument,
contour_groups_like_grid_svg as route_groups_like_grid_svg,
coordinate_display_scale,
min_neighbor_distance_for_points,
normalize_route_clockwise,
order_points_like_grid_svg,
)
except ImportError:
from models import (
LedPoint,
MAX_NEIGHBOR_TOLERANCE_MM,
MappingDocument,
contour_groups_like_grid_svg as route_groups_like_grid_svg,
coordinate_display_scale,
min_neighbor_distance_for_points,
normalize_route_clockwise,
order_points_like_grid_svg,
)
@ -36,6 +36,7 @@ MAX_LEDS_PER_CELL = 3
NUM_STRIPS = 4
EMPTY_STRIP = "0xFF"
EMPTY_LED = "0xFFFF"
OBSOLETE_LEGACY_FILENAME = "virtual_matrix_legacy.ino.txt"
@dataclass(frozen=True)
@ -69,6 +70,20 @@ class BuildResult:
unassigned_count: int
def remove_obsolete_legacy_output(output_dir: Path) -> None:
"""Удалить только legacy-файл, созданный прежними версиями генератора."""
legacy_path = output_dir / OBSOLETE_LEGACY_FILENAME
if legacy_path.is_file() or legacy_path.is_symlink():
legacy_path.unlink()
def cpp_float_literal(value: float) -> str:
text = format(float(value), ".9g")
if "." not in text and "e" not in text.lower():
text += ".0"
return f"{text}f"
def sanitize_cpp_identifier(name: str) -> str:
# Transliteration dictionary for Russian Cyrillic
cyrillic_translit = {
@ -133,10 +148,8 @@ def generate_panel_mapping_from_document(
raise ValueError("Нет назначенных LED: перед генерацией распределите точки по линиям.")
cells = build_cells(mapping_points)
block_count = sum(len(line.blocks) for line in document.lines)
generated_files = {
"vm_map": output_dir / "vm_map.h",
"legacy_ino": output_dir / "virtual_matrix_legacy.ino.txt",
"ordered_json": output_dir / "ordered_circles.json",
"validation_report": output_dir / "validation_report.txt",
"readme": output_dir / "README.md",
@ -151,17 +164,10 @@ def generate_panel_mapping_from_document(
generate_grid=generate_grid,
generate_blocks=generate_blocks,
)
write_legacy_ino(
generated_files["legacy_ino"],
mapping_points,
cells,
document,
generate_grid=generate_grid,
generate_blocks=generate_blocks,
)
write_ordered_json(generated_files["ordered_json"], points, document)
write_validation_report(generated_files["validation_report"], points)
write_validation_report(generated_files["validation_report"], points, document.grid_step_mm)
write_readme(generated_files["readme"])
remove_obsolete_legacy_output(output_dir)
vm_rows, vm_cols, grid_rows, grid_cols = get_dimensions(points)
return BuildResult(
@ -182,10 +188,14 @@ def generate_panel_mapping_from_document(
def normalize_document_points(document: MappingDocument) -> list[PanelLedPoint]:
scale = _coordinate_scale_to_mm(document)
route_groups = route_groups_like_grid_svg(document.points)
min_neighbor_distance = document.grid_step_in_document_units()
ordered: list[tuple[LedPoint, int]] = []
for route_group_index, route_group in enumerate(route_groups, start=1):
route = order_points_like_grid_svg(route_group)
route = normalize_route_clockwise(route, min_neighbor_distance_for_points(route))
route = order_points_like_grid_svg(
route_group,
min_neighbor_distance=min_neighbor_distance,
)
route = normalize_route_clockwise(route, min_neighbor_distance)
ordered.extend((point, route_group_index) for point in route)
min_row = min(point.row for point, _group in ordered)
@ -195,7 +205,7 @@ def normalize_document_points(document: MappingDocument) -> list[PanelLedPoint]:
generated_id=index,
source_id=point.source_id,
input_index=point.uid,
strip=(point.line_id - 1) if point.line_id is not None else None,
strip=point.line_id,
led_id=point.line_index,
x_mm=point.x * scale,
y_mm=point.y * scale,
@ -299,7 +309,7 @@ def write_vm_map_header(
file.write(f"#define GRID_ROW_MAX {max_row}\n")
file.write(f"#define GRID_COL_MIN {min_col}\n")
file.write(f"#define GRID_COL_MAX {max_col}\n")
file.write(f"#define GRID_CELL_SIZE_MM {CELL_SIZE_MM:.1f}f\n\n")
file.write(f"#define GRID_CELL_SIZE_MM {cpp_float_literal(document.grid_step_mm)}\n\n")
file.write("// Source grid -> virtual matrix orientation used by this file.\n")
file.write("// VM row is the source column flipped from right to left.\n")
file.write("// VM column is the source row shifted to zero-based coordinates.\n")
@ -470,242 +480,6 @@ def write_vm_map_header(
file.write("}\n")
def write_legacy_ino(
path: Path,
points: list[PanelLedPoint],
cells: dict[tuple[int, int], list[PanelLedPoint]],
document: MappingDocument,
generate_grid: bool = True,
generate_blocks: bool = False,
) -> None:
vm_rows, vm_cols, _grid_rows, _grid_cols = get_dimensions(points)
# Collect blocks info
all_blocks = []
for line in document.lines:
all_blocks.extend(line.blocks)
block_count = len(all_blocks)
point_by_input_index = {p.input_index: p for p in points}
flat_block_leds = []
block_ranges = []
for block in all_blocks:
block_points = []
for point_uid in block.point_ids:
p = point_by_input_index.get(point_uid)
if p is not None and p.strip is not None and p.led_id is not None:
block_points.append(p)
offset = len(flat_block_leds)
count = len(block_points)
flat_block_leds.extend(block_points)
block_ranges.append((offset, count, block.name))
with path.open("w", encoding="utf-8", newline="\n") as file:
file.write("// ============================================================================\n")
file.write("// ARCHIVE COPY - VIRTUAL MATRIX GENERATED BY LED Mapper GUI\n")
file.write("// Saved as .ino.txt so Arduino IDE will not compile it automatically.\n")
file.write("// ============================================================================\n\n")
file.write("#include <initializer_list>\n\n")
file.write("#ifndef ARDUINO\n#include <Arduino.h>\n#endif\n\n")
file.write("#include <Adafruit_NeoPixel.h>\n")
file.write("#ifndef NUM_STRIPS\n")
file.write(f"#define NUM_STRIPS {NUM_STRIPS}\n")
file.write("#endif\n")
file.write("extern Adafruit_NeoPixel hwStrips[NUM_STRIPS];\n\n")
if generate_grid:
file.write("#ifndef VM_MAX_PHYS_LEDS\n")
file.write(f"#define VM_MAX_PHYS_LEDS {max((point.led_id for point in points if point.led_id is not None), default=0) + 1}\n")
file.write("#endif\n")
file.write("#ifndef VM_MAX_IDS_PER_CELL\n")
file.write(f"#define VM_MAX_IDS_PER_CELL {MAX_LEDS_PER_CELL}\n")
file.write("#endif\n")
file.write("#ifndef MATRIX_ROWS\n")
file.write(f"#define MATRIX_ROWS {vm_rows}\n")
file.write("#endif\n")
file.write("#ifndef MATRIX_COLS\n")
file.write(f"#define MATRIX_COLS {vm_cols}\n")
file.write("#endif\n\n")
file.write("struct VMPhys { uint16_t idx; uint8_t strip; };\n\n")
file.write("static uint8_t vmCount[MATRIX_ROWS][MATRIX_COLS];\n")
file.write("static VMPhys vmCell[MATRIX_ROWS][MATRIX_COLS][VM_MAX_IDS_PER_CELL];\n")
file.write("static int16_t vmRow[NUM_STRIPS][VM_MAX_PHYS_LEDS];\n")
file.write("static int16_t vmCol[NUM_STRIPS][VM_MAX_PHYS_LEDS];\n")
file.write("static uint8_t vmUsed[NUM_STRIPS][VM_MAX_PHYS_LEDS];\n")
file.write("static uint16_t vmMaxId[NUM_STRIPS];\n\n")
file.write("void clearVirtualMatrix();\n")
file.write("void setLEDs(uint16_t row, uint16_t col, uint8_t strip1based, std::initializer_list<uint16_t> ids);\n")
file.write("void vmSetByPhys(uint8_t strip, uint16_t led, uint32_t color);\n")
file.write("void initVirtualMatrix();\n\n")
if generate_grid:
file.write("void clearVirtualMatrix() {\n")
file.write(" for (int r = 0; r < MATRIX_ROWS; ++r)\n")
file.write(" for (int c = 0; c < MATRIX_COLS; ++c) vmCount[r][c] = 0;\n")
file.write(" for (int s = 0; s < NUM_STRIPS; ++s) {\n")
file.write(" vmMaxId[s] = 0;\n")
file.write(" for (int i = 0; i < VM_MAX_PHYS_LEDS; ++i) {\n")
file.write(" vmRow[s][i] = -1;\n")
file.write(" vmCol[s][i] = -1;\n")
file.write(" vmUsed[s][i] = 0;\n")
file.write(" }\n")
file.write(" }\n")
file.write("}\n\n")
file.write("void setLEDs(uint16_t row, uint16_t col, uint8_t strip1based, std::initializer_list<uint16_t> ids) {\n")
file.write(" if (row >= MATRIX_ROWS || col >= MATRIX_COLS) return;\n")
file.write(" uint8_t strip = (strip1based == 0) ? 0 : (uint8_t)(strip1based - 1);\n")
file.write(" if (strip >= NUM_STRIPS) return;\n")
file.write(" for (uint16_t id : ids) {\n")
file.write(" if (id >= VM_MAX_PHYS_LEDS) continue;\n")
file.write(" uint8_t &count = vmCount[row][col];\n")
file.write(" if (count < VM_MAX_IDS_PER_CELL) {\n")
file.write(" vmCell[row][col][count].idx = id;\n")
file.write(" vmCell[row][col][count].strip = strip;\n")
file.write(" ++count;\n")
file.write(" }\n")
file.write(" vmRow[strip][id] = (int16_t)row;\n")
file.write(" vmCol[strip][id] = (int16_t)col;\n")
file.write(" vmUsed[strip][id] = 1;\n")
file.write(" if (id > vmMaxId[strip]) vmMaxId[strip] = id;\n")
file.write(" }\n")
file.write("}\n\n")
file.write("void vmSetByPhys(uint8_t strip, uint16_t led, uint32_t color) {\n")
file.write(" if (strip >= NUM_STRIPS || led >= VM_MAX_PHYS_LEDS) return;\n")
file.write(" int16_t row = vmRow[strip][led];\n")
file.write(" int16_t col = vmCol[strip][led];\n")
file.write(" if (row < 0 || col < 0) {\n")
file.write(" if (led < hwStrips[strip].numPixels()) hwStrips[strip].setPixelColor(led, color);\n")
file.write(" return;\n")
file.write(" }\n")
file.write(" uint8_t count = vmCount[row][col];\n")
file.write(" for (uint8_t i = 0; i < count; ++i) {\n")
file.write(" const VMPhys &phys = vmCell[row][col][i];\n")
file.write(" if (phys.strip < NUM_STRIPS && phys.idx < hwStrips[phys.strip].numPixels()) {\n")
file.write(" hwStrips[phys.strip].setPixelColor(phys.idx, color);\n")
file.write(" }\n")
file.write(" }\n")
file.write("}\n\n")
if generate_blocks:
file.write("// Compile-time block indices\n")
for index, (offset, count, name) in enumerate(block_ranges):
identifier = sanitize_cpp_identifier(name)
file.write(f"#define BLOCK_{identifier} {index}\n")
file.write("\n")
file.write("#ifndef LED_BLOCK_COUNT\n")
file.write(f"#define LED_BLOCK_COUNT {block_count}\n")
file.write("#endif\n")
file.write("#ifndef TOTAL_BLOCK_LEDS\n")
file.write(f"#define TOTAL_BLOCK_LEDS {len(flat_block_leds)}\n")
file.write("#endif\n\n")
if not generate_grid:
file.write("struct VMPhys { uint16_t idx; uint8_t strip; };\n\n")
file.write("struct BlockRange { uint16_t offset; uint16_t count; };\n\n")
file.write("static const VMPhys PROGMEM BLOCK_LEDS[TOTAL_BLOCK_LEDS] = {\n")
for index, point in enumerate(flat_block_leds):
suffix = "," if index < len(flat_block_leds) - 1 else ""
file.write(f" {{{point.led_id}, {point.strip}}}{suffix} // block point\n")
file.write("};\n\n")
file.write("static const BlockRange PROGMEM BLOCK_RANGES[LED_BLOCK_COUNT] = {\n")
for index, (offset, count, name) in enumerate(block_ranges):
suffix = "," if index < len(block_ranges) - 1 else ""
file.write(f" {{{offset}, {count}}}{suffix} // {name}\n")
file.write("};\n\n")
# String names array in PROGMEM
file.write("// Original string names of blocks in flash memory\n")
for index, (offset, count, name) in enumerate(block_ranges):
escaped_name = name.replace('"', '\\"')
file.write(f"static const char BLOCK_NAME_{index}[] PROGMEM = \"{escaped_name}\";\n")
file.write("\n")
file.write("static const char* const BLOCK_NAMES[LED_BLOCK_COUNT] PROGMEM = {\n")
for index in range(len(block_ranges)):
suffix = "," if index < len(block_ranges) - 1 else ""
file.write(f" BLOCK_NAME_{index}{suffix}\n")
file.write("};\n\n")
file.write("void setBlockLEDsCount(uint16_t blockIdx, uint16_t count, uint32_t color);\n")
file.write("void setBlockLED(uint16_t blockIdx, uint16_t ledIdxInBlock, uint32_t color);\n")
file.write("int16_t getBlockIndexByName(const char* name);\n")
file.write("void setBlockLEDsCount(const char* name, uint16_t count, uint32_t color);\n")
file.write("void setBlockLED(const char* name, uint16_t ledIdxInBlock, uint32_t color);\n\n")
file.write("void setBlockLEDsCount(uint16_t blockIdx, uint16_t count, uint32_t color) {\n")
file.write(" if (blockIdx >= LED_BLOCK_COUNT) return;\n")
file.write(" uint16_t offset = pgm_read_word(&BLOCK_RANGES[blockIdx].offset);\n")
file.write(" uint16_t total_count = pgm_read_word(&BLOCK_RANGES[blockIdx].count);\n")
file.write(" if (count > total_count) count = total_count;\n")
file.write(" for (uint16_t i = 0; i < count; i++) {\n")
file.write(" uint8_t strip = pgm_read_byte(&BLOCK_LEDS[offset + i].strip);\n")
file.write(" uint16_t led_id = pgm_read_word(&BLOCK_LEDS[offset + i].idx);\n")
file.write(" if (strip < NUM_STRIPS) {\n")
file.write(" hwStrips[strip].setPixelColor(led_id, color);\n")
file.write(" }\n")
file.write(" }\n")
file.write("}\n\n")
file.write("void setBlockLED(uint16_t blockIdx, uint16_t ledIdxInBlock, uint32_t color) {\n")
file.write(" if (blockIdx >= LED_BLOCK_COUNT) return;\n")
file.write(" uint16_t offset = pgm_read_word(&BLOCK_RANGES[blockIdx].offset);\n")
file.write(" uint16_t count = pgm_read_word(&BLOCK_RANGES[blockIdx].count);\n")
file.write(" if (ledIdxInBlock >= count) return;\n")
file.write(" uint8_t strip = pgm_read_byte(&BLOCK_LEDS[offset + ledIdxInBlock].strip);\n")
file.write(" uint16_t led_id = pgm_read_word(&BLOCK_LEDS[offset + ledIdxInBlock].idx);\n")
file.write(" if (strip < NUM_STRIPS) {\n")
file.write(" hwStrips[strip].setPixelColor(led_id, color);\n")
file.write(" }\n")
file.write("}\n\n")
file.write("int16_t getBlockIndexByName(const char* name) {\n")
file.write(" for (uint16_t i = 0; i < LED_BLOCK_COUNT; i++) {\n")
file.write(" const char* blockNamePtr = (const char*)pgm_read_word(&BLOCK_NAMES[i]);\n")
file.write(" if (strcmp_P(name, blockNamePtr) == 0) {\n")
file.write(" return (int16_t)i;\n")
file.write(" }\n")
file.write(" }\n")
file.write(" return -1;\n")
file.write("}\n\n")
file.write("void setBlockLEDsCount(const char* name, uint16_t count, uint32_t color) {\n")
file.write(" int16_t idx = getBlockIndexByName(name);\n")
file.write(" if (idx >= 0) {\n")
file.write(" setBlockLEDsCount((uint16_t)idx, count, color);\n")
file.write(" }\n")
file.write("}\n\n")
file.write("void setBlockLED(const char* name, uint16_t ledIdxInBlock, uint32_t color) {\n")
file.write(" int16_t idx = getBlockIndexByName(name);\n")
file.write(" if (idx >= 0) {\n")
file.write(" setBlockLED((uint16_t)idx, ledIdxInBlock, color);\n")
file.write(" }\n")
file.write("}\n\n")
file.write("void initVirtualMatrix() {\n")
if generate_grid:
file.write(" clearVirtualMatrix();\n")
last_row = None
for row in range(vm_rows):
row_cells = [(col, cells[(row, col)]) for col in range(vm_cols) if (row, col) in cells]
if not row_cells:
continue
if last_row != row:
file.write(f"\n // Row {row}\n")
last_row = row
for col, cell_points in row_cells:
by_strip: dict[int, list[int]] = defaultdict(list)
for point in cell_points:
if point.strip is not None and point.led_id is not None:
by_strip[point.strip + 1].append(point.led_id)
for strip1based, led_ids_list in sorted(by_strip.items()):
led_ids = ", ".join(str(led_id) for led_id in led_ids_list)
file.write(f" setLEDs({row}, {col:2d}, {strip1based}, {{ {led_ids} }});\n")
file.write("}\n")
def write_ordered_json(path: Path, points: list[PanelLedPoint], document: MappingDocument) -> None:
point_id_map = {point.input_index: point.generated_id for point in points}
point_payloads = []
@ -738,14 +512,18 @@ def write_ordered_json(path: Path, points: list[PanelLedPoint], document: Mappin
{
"source": str(document.path) if document.path is not None else "LED Mapper GUI",
"unit": OUTPUT_UNIT,
"cell_size_mm": CELL_SIZE_MM,
"coordinate_unit": OUTPUT_UNIT,
"cell_size_mm": document.grid_step_mm,
"ordered_id_mode": "generated_uid",
}
)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def collect_validation(points: list[PanelLedPoint]) -> list[str]:
def collect_validation(
points: list[PanelLedPoint],
cell_size_mm: float = CELL_SIZE_MM,
) -> list[str]:
lines = []
distance_violations = []
cell_violations = []
@ -753,7 +531,9 @@ def collect_validation(points: list[PanelLedPoint]) -> list[str]:
if current.route_group != next_point.route_group:
continue
gap = math.hypot(current.x_mm - next_point.x_mm, current.y_mm - next_point.y_mm)
if gap > CELL_SIZE_MM + 1e-6:
diagonal = abs(current.row - next_point.row) == 1 and abs(current.col - next_point.col) == 1
distance_limit = cell_size_mm * (math.sqrt(2.0) if diagonal else 1.0) + MAX_NEIGHBOR_TOLERANCE_MM
if gap > distance_limit + 1e-6:
distance_violations.append((current, next_point, gap))
if abs(current.row - next_point.row) > 1 or abs(current.col - next_point.col) > 1:
cell_violations.append((current, next_point))
@ -763,7 +543,7 @@ def collect_validation(points: list[PanelLedPoint]) -> list[str]:
lines.append(f"LED count: {len(points)}")
lines.append(f"Generated ids contiguous: {not duplicate_ids}")
lines.append(f"Max LEDs per cell: {max_per_cell}")
lines.append(f"Same-route distance violations > {CELL_SIZE_MM:g} mm: {len(distance_violations)}")
lines.append(f"Same-route distance violations beyond grid step + tolerance: {len(distance_violations)}")
lines.append(f"Same-route 8-neighbor cell violations: {len(cell_violations)}")
if max_per_cell > MAX_LEDS_PER_CELL:
lines.append(f"ERROR: max LEDs per cell exceeds {MAX_LEDS_PER_CELL}")
@ -780,11 +560,15 @@ def collect_validation(points: list[PanelLedPoint]) -> list[str]:
return lines
def write_validation_report(path: Path, points: list[PanelLedPoint]) -> None:
def write_validation_report(
path: Path,
points: list[PanelLedPoint],
cell_size_mm: float = CELL_SIZE_MM,
) -> None:
lines = [
"Panel mapping validation",
"========================",
*collect_validation(points),
*collect_validation(points, cell_size_mm),
"",
]
path.write_text("\n".join(lines), encoding="utf-8")
@ -794,13 +578,17 @@ def write_readme(path: Path) -> None:
path.write_text(
"\n".join(
[
"# Panel Mapping Build",
"# Результат генерации Panel Mapping",
"",
"This folder contains files generated by the LED Mapper GUI:",
"- `vm_map.h` contains the PROGMEM virtual matrix mapping.",
"- `virtual_matrix_legacy.ino.txt` keeps the readable `setLEDs(...)` mapping.",
"- `ordered_circles.json` stores generated ids plus source row/col data.",
"- `validation_report.txt` records sequence and cell checks.",
"LED Mapper создал следующие файлы:",
"- `vm_map.h` — маппинг виртуальной матрицы в формате PROGMEM.",
"- `ordered_circles.json` — нормализованные LED и координаты матрицы.",
"- `validation_report.txt` — результаты проверки маршрутов и ячеек.",
"- `README.md` — описание набора файлов.",
"",
"Физический адрес ленты в `vm_map.h`: `strip = line_id`; нумерация начинается с `0`.",
"",
"Устаревший `virtual_matrix_legacy.ino.txt` больше не создаётся.",
"",
]
),

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
from __future__ import annotations
import json
import math
from dataclasses import asdict, dataclass
from pathlib import Path
@ -9,6 +10,7 @@ from PySide6.QtGui import QKeySequence
SHORTCUT_DEFINITIONS: list[tuple[str, str, str]] = [
("new", "Создать", "Ctrl+N"),
("open", "Открыть", "Ctrl+O"),
("save", "Сохранить", "Ctrl+S"),
("save_as", "Сохранить как", "Ctrl+Shift+S"),
@ -16,12 +18,14 @@ SHORTCUT_DEFINITIONS: list[tuple[str, str, str]] = [
("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", "Сетка 34 мм", "G"),
("grid", "Сетка", "G"),
("direction_arrows", "Стрелки направления", "Ctrl+Shift+A"),
("led_ids", "Отображать id светодиодов", "I"),
("background", "Подложка", "Ctrl+B"),
@ -40,7 +44,17 @@ SHORTCUT_DEFINITIONS: list[tuple[str, str, str]] = [
("brush", "Кисть", "B"),
]
DEFAULT_SHORTCUTS = {key: shortcut for key, _title, shortcut in SHORTCUT_DEFINITIONS}
SETTINGS_VERSION = 2
SETTINGS_VERSION = 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
@ -50,6 +64,9 @@ class AppSettings:
show_grid_by_default: bool = False
show_direction_arrows_by_default: bool = True
show_led_ids_by_default: bool = True
grid_step_mm: float = DEFAULT_GRID_STEP_MM
led_point_diameter_px: int = DEFAULT_LED_POINT_DIAMETER_PX
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
@ -74,6 +91,24 @@ class AppSettings:
if hasattr(settings, key):
setattr(settings, key, value)
settings.version = SETTINGS_VERSION
settings.grid_step_mm = clamp_float(
settings.grid_step_mm,
MIN_GRID_STEP_MM,
MAX_GRID_STEP_MM,
DEFAULT_GRID_STEP_MM,
)
settings.led_point_diameter_px = clamp_int(
settings.led_point_diameter_px,
MIN_LED_POINT_DIAMETER_PX,
MAX_LED_POINT_DIAMETER_PX,
DEFAULT_LED_POINT_DIAMETER_PX,
)
settings.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"}:
@ -89,7 +124,7 @@ class AppSettings:
settings.sidebar_inner_sizes = normalize_splitter_sizes(settings.sidebar_inner_sizes, [360, 420])
settings.shortcuts = normalize_shortcuts(
settings.shortcuts,
fill_new_defaults=saved_version < SETTINGS_VERSION,
fill_new_defaults=saved_version < SHORTCUT_DEFAULTS_MIGRATION_VERSION,
)
return settings
@ -119,6 +154,16 @@ def clamp_int(value, minimum: int, maximum: int, fallback: int) -> int: # noqa:
return max(minimum, min(maximum, number))
def clamp_float(value, minimum: float, maximum: float, fallback: float) -> float: # noqa: ANN001
try:
number = float(value)
except (TypeError, ValueError):
return fallback
if not math.isfinite(number):
return fallback
return max(minimum, min(maximum, number))
def normalize_splitter_sizes(value, fallback: list[int]) -> list[int]: # noqa: ANN001
if not isinstance(value, list) or len(value) != len(fallback):
return fallback[:]

View File

@ -1,5 +1,6 @@
from __future__ import annotations
from dataclasses import replace
from pathlib import Path
from PySide6.QtGui import QKeySequence
@ -10,6 +11,7 @@ from PySide6.QtWidgets import (
QComboBox,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFileDialog,
QFormLayout,
QHBoxLayout,
@ -26,10 +28,38 @@ from PySide6.QtWidgets import (
try:
from .localization import localize_dialog_buttons, show_warning
from .settings import DEFAULT_SHORTCUTS, SHORTCUT_DEFINITIONS, AppSettings
from .settings import (
DEFAULT_GRID_STEP_MM,
DEFAULT_LED_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_SHORTCUTS, SHORTCUT_DEFINITIONS, AppSettings
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):
@ -44,12 +74,44 @@ class SettingsDialog(QDialog):
self.theme.addItem("Светлая", "light")
self.theme.addItem("Темная", "dark")
self.theme.setCurrentIndex(max(0, self.theme.findData(settings.theme)))
self.show_grid = QCheckBox("Показывать сетку 34 мм при запуске")
self.show_grid = QCheckBox("Показывать сетку при запуске")
self.show_grid.setChecked(settings.show_grid_by_default)
self.show_arrows = QCheckBox("Показывать стрелки направления при запуске")
self.show_arrows.setChecked(settings.show_direction_arrows_by_default)
self.show_led_ids = QCheckBox("Показывать id светодиодов при выборе")
self.show_led_ids.setChecked(settings.show_led_ids_by_default)
self.grid_step = QDoubleSpinBox()
self.grid_step.setRange(MIN_GRID_STEP_MM, MAX_GRID_STEP_MM)
self.grid_step.setDecimals(2)
self.grid_step.setSingleStep(1.0)
self.grid_step.setSuffix(" мм")
self.grid_step.setValue(settings.grid_step_mm)
self.grid_step_reset = QPushButton("Сбросить")
self.grid_step_reset.clicked.connect(
lambda: self.grid_step.setValue(DEFAULT_GRID_STEP_MM)
)
self.led_point_diameter = QSpinBox()
self.led_point_diameter.setRange(
MIN_LED_POINT_DIAMETER_PX,
MAX_LED_POINT_DIAMETER_PX,
)
self.led_point_diameter.setSuffix(" px")
self.led_point_diameter.setValue(settings.led_point_diameter_px)
self.led_point_diameter_reset = QPushButton("Сбросить")
self.led_point_diameter_reset.clicked.connect(
lambda: self.led_point_diameter.setValue(DEFAULT_LED_POINT_DIAMETER_PX)
)
self.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(" %")
@ -105,11 +167,16 @@ class SettingsDialog(QDialog):
self.section_stack.addWidget(widget)
def settings(self) -> AppSettings:
return AppSettings(
return replace(
self._settings,
version=SETTINGS_VERSION,
theme=str(self.theme.currentData()),
show_grid_by_default=self.show_grid.isChecked(),
show_direction_arrows_by_default=self.show_arrows.isChecked(),
show_led_ids_by_default=self.show_led_ids.isChecked(),
grid_step_mm=self.grid_step.value(),
led_point_diameter_px=self.led_point_diameter.value(),
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(),
@ -126,10 +193,28 @@ class SettingsDialog(QDialog):
layout.addRow(self.show_grid)
layout.addRow(self.show_arrows)
layout.addRow(self.show_led_ids)
layout.addRow("Шаг сетки", self._setting_with_reset(self.grid_step, self.grid_step_reset))
layout.addRow(
"Диаметр LED-точек",
self._setting_with_reset(self.led_point_diameter, self.led_point_diameter_reset),
)
layout.addRow(
"Размер текста индексов 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)

348
update_service.py Normal file
View File

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

3
version.py Normal file
View File

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