Compare commits
No commits in common. "master" and "v3.0.0" have entirely different histories.
@ -1,8 +1,6 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@ -12,15 +10,15 @@ spec_dir = Path(SPECPATH)
|
|||||||
|
|
||||||
def git_build_ref():
|
def git_build_ref():
|
||||||
try:
|
try:
|
||||||
dirty = subprocess.check_output(
|
dirty = subprocess.run(
|
||||||
['git', 'status', '--porcelain', '--untracked-files=normal'],
|
['git', 'diff-index', '--quiet', 'HEAD', '--'],
|
||||||
cwd=spec_dir,
|
cwd=spec_dir,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
text=True,
|
stdout=subprocess.DEVNULL,
|
||||||
)
|
).returncode != 0
|
||||||
if dirty.strip():
|
if dirty:
|
||||||
return datetime.now().strftime('%H%M%S-%d%m%Y')
|
return datetime.now().strftime('%H%M%S-%d%m%Y')
|
||||||
except (OSError, subprocess.CalledProcessError):
|
except OSError:
|
||||||
return datetime.now().strftime('%H%M%S-%d%m%Y')
|
return datetime.now().strftime('%H%M%S-%d%m%Y')
|
||||||
try:
|
try:
|
||||||
tag = subprocess.check_output(
|
tag = subprocess.check_output(
|
||||||
@ -47,54 +45,16 @@ def git_build_ref():
|
|||||||
return 'untagged'
|
return 'untagged'
|
||||||
|
|
||||||
|
|
||||||
def _build_library_roots():
|
|
||||||
windows_root = Path(os.environ.get('SystemRoot', r'C:\Windows')).resolve()
|
|
||||||
return tuple(dict.fromkeys(
|
|
||||||
path.resolve() for path in (
|
|
||||||
Path(sys.executable).parent,
|
|
||||||
Path(sys.prefix),
|
|
||||||
Path(sys.base_prefix),
|
|
||||||
Path(sys.base_prefix) / 'DLLs',
|
|
||||||
windows_root / 'System32',
|
|
||||||
windows_root,
|
|
||||||
) if path.is_dir()
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|
||||||
def _set_isolated_build_path(roots):
|
|
||||||
# Analysis searches PATH for DLLs. Unrelated tools may provide incompatible
|
|
||||||
# copies of the Windows runtime or ICU libraries there.
|
|
||||||
os.environ['PATH'] = os.pathsep.join(str(root) for root in roots)
|
|
||||||
|
|
||||||
|
|
||||||
def _check_binary_sources(binaries, roots):
|
|
||||||
foreign = [
|
|
||||||
(name, source)
|
|
||||||
for name, source, kind in binaries
|
|
||||||
if kind == 'BINARY'
|
|
||||||
and not any(Path(source).resolve().is_relative_to(root) for root in roots)
|
|
||||||
]
|
|
||||||
if foreign:
|
|
||||||
details = '\n'.join(f'{name}: {source}' for name, source in foreign[:10])
|
|
||||||
raise RuntimeError(f'Build contains DLLs outside Python and Windows:\n{details}')
|
|
||||||
|
|
||||||
|
|
||||||
app_name = f'LEDMapper-{git_build_ref()}'
|
app_name = f'LEDMapper-{git_build_ref()}'
|
||||||
library_roots = _build_library_roots()
|
|
||||||
_set_isolated_build_path(library_roots)
|
|
||||||
build_version_path = spec_dir.parent / 'build' / 'ledmapper-build-version.txt'
|
|
||||||
build_version_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
build_version_path.write_text(app_name.removeprefix('LEDMapper-'), encoding='utf-8')
|
|
||||||
|
|
||||||
|
|
||||||
a = Analysis(
|
a = Analysis(
|
||||||
[str(spec_dir / 'main.py')],
|
[str(spec_dir / 'main.py')],
|
||||||
pathex=[str(spec_dir), str(spec_dir.parent)],
|
pathex=[str(spec_dir), str(spec_dir.parent)],
|
||||||
binaries=[],
|
binaries=[],
|
||||||
datas=[(str(spec_dir / 'assets'), 'assets'), (str(build_version_path), '.')],
|
datas=[(str(spec_dir / 'assets'), 'assets')],
|
||||||
hiddenimports=[
|
hiddenimports=[
|
||||||
'canvas',
|
'canvas',
|
||||||
'background_image_loader',
|
|
||||||
'background_dialog',
|
'background_dialog',
|
||||||
'icons',
|
'icons',
|
||||||
'localization',
|
'localization',
|
||||||
@ -106,8 +66,6 @@ a = Analysis(
|
|||||||
'settings_dialog',
|
'settings_dialog',
|
||||||
'theme',
|
'theme',
|
||||||
'unfolding',
|
'unfolding',
|
||||||
'update_service',
|
|
||||||
'version',
|
|
||||||
],
|
],
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
@ -116,7 +74,6 @@ a = Analysis(
|
|||||||
noarchive=False,
|
noarchive=False,
|
||||||
optimize=0,
|
optimize=0,
|
||||||
)
|
)
|
||||||
_check_binary_sources(a.binaries, library_roots)
|
|
||||||
pyz = PYZ(a.pure)
|
pyz = PYZ(a.pure)
|
||||||
|
|
||||||
exe = EXE(
|
exe = EXE(
|
||||||
|
|||||||
53
README.md
53
README.md
@ -10,24 +10,19 @@ LED Mapper — настольное приложение на Python и PySide6
|
|||||||
- Отображение схемы в реальных координатах.
|
- Отображение схемы в реальных координатах.
|
||||||
- Выделение точек мышью и работа через таблицу выбранных LED.
|
- Выделение точек мышью и работа через таблицу выбранных LED.
|
||||||
- До 4 физических линий светодиодных лент.
|
- До 4 физических линий светодиодных лент.
|
||||||
- Нумерация линий и блоков с `0`.
|
|
||||||
- Блоки внутри линий, включая пустые блоки.
|
- Блоки внутри линий, включая пустые блоки.
|
||||||
- Индексация LED отдельно для каждой линии, начиная с `0`.
|
- Индексация LED отдельно для каждой линии, начиная с `0`.
|
||||||
- Индексация внутри каждого блока.
|
- Индексация внутри каждого блока.
|
||||||
- Добавление блоков вручную, из выделения, автоблоками и кистью.
|
- Добавление блоков вручную, из выделения, автоблоками и кистью.
|
||||||
- Продолжение существующего блока кистью от последней точки блока.
|
- Продолжение существующего блока кистью от последней точки блока.
|
||||||
- Предпросмотр зоны действия кисти и точек, которые будут добавлены.
|
- Предпросмотр зоны действия кисти и точек, которые будут добавлены.
|
||||||
- Сетка с настраиваемым шагом (по умолчанию 34 мм) во всех режимах редактирования и генерации.
|
- Сетка 34 мм.
|
||||||
- Настраиваемые абсолютные размеры LED-точек и текста их индексов: визуальный размер не меняется при приближении.
|
- Undo/redo для операций разметки.
|
||||||
- Свободные многострочные текстовые комментарии на схеме.
|
|
||||||
- Undo/redo для операций разметки и комментариев.
|
|
||||||
- Удаление назначений через `Del` и контекстные меню.
|
- Удаление назначений через `Del` и контекстные меню.
|
||||||
- Пакетное удаление выбранных блоков через дерево одной операцией с единым Undo.
|
|
||||||
- Сворачивание боковой панели.
|
- Сворачивание боковой панели.
|
||||||
- Сортировка таблицы выбранных точек по столбцам.
|
- Сортировка таблицы выбранных точек по столбцам.
|
||||||
- Генерация `vm_map.h`, `ordered_circles.json`, `validation_report.txt` и README для результата.
|
- Генерация `vm_map.h`, `virtual_matrix_legacy.ino.txt`, `ordered_circles.json`, `validation_report.txt` и README для результата.
|
||||||
- Сборка самостоятельного Windows EXE через PyInstaller.
|
- Сборка самостоятельного Windows EXE через PyInstaller.
|
||||||
- Проверка и установка Windows-обновлений из релизов Gitea.
|
|
||||||
|
|
||||||
## Структура приложения
|
## Структура приложения
|
||||||
|
|
||||||
@ -37,8 +32,6 @@ main_window.py Главное окно, меню, действия,
|
|||||||
canvas.py Холст схемы, выделение, кисть, сетка и связи
|
canvas.py Холст схемы, выделение, кисть, сетка и связи
|
||||||
models.py Модель карты, линий, блоков и алгоритмы маршрутов
|
models.py Модель карты, линий, блоков и алгоритмы маршрутов
|
||||||
panel_mapping_generator.py Генерация файлов маппинга
|
panel_mapping_generator.py Генерация файлов маппинга
|
||||||
update_service.py Проверка релизов и безопасная установка Windows EXE
|
|
||||||
version.py Базовая версия приложения при запуске из исходников
|
|
||||||
icons.py Встроенные lucide-иконки
|
icons.py Встроенные lucide-иконки
|
||||||
unfolding.py Описание развертки линий и блоков
|
unfolding.py Описание развертки линий и блоков
|
||||||
qa_smoke.py Smoke-тесты приложения
|
qa_smoke.py Smoke-тесты приложения
|
||||||
@ -90,28 +83,8 @@ python -m venv .venv
|
|||||||
6. Проверьте счетчики в верхней части боковой панели: количество линий, блоков и назначенных LED.
|
6. Проверьте счетчики в верхней части боковой панели: количество линий, блоков и назначенных LED.
|
||||||
7. Сгенерируйте скрипт через `Файл -> Сгенерировать скрипт`.
|
7. Сгенерируйте скрипт через `Файл -> Сгенерировать скрипт`.
|
||||||
|
|
||||||
Шаг сетки, диаметр точек и размер текста индексов меняются в `Файл -> Параметры -> Внешний вид`. Для каждого значения рядом есть кнопка `Сбросить`. Размер текста индексов обновляется на схеме сразу во время настройки; при отмене возвращается прежнее значение. Шаг сетки сохраняется в JSON текущей схемы, а диаметр точек и размер текста индексов — в пользовательских параметрах приложения. Визуальные размеры точек и подписей не меняются при приближении схемы. Изменение шага не перемещает уже существующие LED автоматически.
|
|
||||||
|
|
||||||
Чтобы удалить несколько блоков, выделите их в дереве с `Ctrl` или `Shift`, затем нажмите `Ctrl+Del` или выберите удаление в контекстном меню. Приложение показывает одно подтверждение и создаёт одну операцию Undo.
|
|
||||||
|
|
||||||
В режиме редактирования выбранные светодиоды можно скопировать через `Ctrl+C` и вставить через `Ctrl+V`. Вставленные LED остаются свободными, сохраняют взаимное расположение и пользовательские поля исходных точек. Первая копия смещается на один шаг сетки вправо и вниз, а следующие вставки образуют каскад. Вся группа вставляется одной операцией Undo и сразу становится текущим выделением.
|
|
||||||
|
|
||||||
В собранном Windows EXE приложение проверяет наличие нового стабильного релиза после запуска. В меню `Справка` можно запустить проверку вручную и, если новая версия найдена, выбрать `Установить обновление`. Загружается только артефакт вида `LEDMapper-vX.Y.Z.exe` из репозитория `Robotail/LED-mapper` на `git.robotail.pro`. После проверки размера и формата EXE приложение предлагает сохранить изменённую схему, закрывается, а отдельный процесс заменяет EXE по прежнему пути и запускает новую версию — существующие ярлыки продолжают работать. Версия релизной сборки хранится внутри EXE, поэтому переименование файла не вызывает повторную установку того же обновления. При неудачном запуске старая версия восстанавливается; причина записывается рядом с EXE в `update-error.log`. Для обновления нужны доступ к серверу релизов и право записи в каталог приложения. Из исходников можно проверить наличие версии, но автоустановка недоступна.
|
|
||||||
|
|
||||||
Перед загрузкой проверяются права записи в каталог приложения. Перенаправления на страницу входа, другой сайт или неожиданный путь того же сервера отклоняются; объявленный сервером размер файла должен совпадать с размером артефакта в данных релиза.
|
|
||||||
|
|
||||||
Если не назначен ни один светодиод, генерация не запускается. Если назначены не все светодиоды, приложение показывает предупреждение и предлагает либо сгенерировать неполный маппинг, либо продолжить редактирование.
|
Если не назначен ни один светодиод, генерация не запускается. Если назначены не все светодиоды, приложение показывает предупреждение и предлагает либо сгенерировать неполный маппинг, либо продолжить редактирование.
|
||||||
|
|
||||||
## Текстовые комментарии на схеме
|
|
||||||
|
|
||||||
Чтобы разместить заметку, нажмите правой кнопкой мыши в нужном месте схемы и выберите `Добавить комментарий…`. Команда `Инструменты -> Добавить комментарий…` создаёт заметку в центре видимой области. Введите многострочный текст и подтвердите ввод.
|
|
||||||
|
|
||||||
Комментарий можно свободно перетаскивать левой кнопкой мыши в любом режиме. Двойной щелчок открывает редактирование текста; в контекстном меню комментария доступны изменение и удаление. Размер текста остаётся читаемым при изменении масштаба. Команда `Показать всё` учитывает комментарии, в том числе за пределами LED-схемы.
|
|
||||||
|
|
||||||
Добавление, изменение, перемещение и удаление поддерживают Undo/Redo. Отмена диалога и пустой текст не изменяют схему. Комментарии сохраняются в JSON в списке `comments`: `id`, `text`, `x`, `y`; координаты используют единицы схемы. Старые JSON без этого списка открываются без комментариев. Можно сохранить схему, содержащую только комментарии.
|
|
||||||
|
|
||||||
Комментарии не привязаны к линиям, блокам или LED и не влияют на физические адреса в `VM_MAPPINGS`. Экспорт изображения маппинга через меню `Файл` по-прежнему показывает LED и легенду; текстовые комментарии сохраняются в JSON и отображаются в редакторе.
|
|
||||||
|
|
||||||
## Горячие клавиши
|
## Горячие клавиши
|
||||||
|
|
||||||
| Действие | Клавиши |
|
| Действие | Клавиши |
|
||||||
@ -122,14 +95,11 @@ python -m venv .venv
|
|||||||
| Сгенерировать скрипт | `Ctrl+G` |
|
| Сгенерировать скрипт | `Ctrl+G` |
|
||||||
| Отменить | `Ctrl+Z` |
|
| Отменить | `Ctrl+Z` |
|
||||||
| Повторить | `Ctrl+Y` |
|
| Повторить | `Ctrl+Y` |
|
||||||
| Копировать выбранные LED | `Ctrl+C` |
|
|
||||||
| Вставить скопированные LED | `Ctrl+V` |
|
|
||||||
| Удалить фиксацию | `Del` |
|
| Удалить фиксацию | `Del` |
|
||||||
| Удалить выбранные точки или блоки | `Ctrl+Del` |
|
|
||||||
| Удалить все | `Ctrl+Shift+Del` |
|
| Удалить все | `Ctrl+Shift+Del` |
|
||||||
| Очистить выбор | `Esc` |
|
| Очистить выбор | `Esc` |
|
||||||
| Показать всю схему | `Ctrl+0` |
|
| Показать всю схему | `Ctrl+0` |
|
||||||
| Сетка | `G` |
|
| Сетка 34 мм | `G` |
|
||||||
| Стрелки направления | `Ctrl+Shift+A` |
|
| Стрелки направления | `Ctrl+Shift+A` |
|
||||||
| Скрыть/показать боковую панель | `F9` |
|
| Скрыть/показать боковую панель | `F9` |
|
||||||
| Новая линия | `Ctrl+L` |
|
| Новая линия | `Ctrl+L` |
|
||||||
@ -141,19 +111,14 @@ python -m venv .venv
|
|||||||
Генератор создает:
|
Генератор создает:
|
||||||
|
|
||||||
- `vm_map.h` — основной header для прошивки.
|
- `vm_map.h` — основной header для прошивки.
|
||||||
|
- `virtual_matrix_legacy.ino.txt` — читаемая legacy-версия с `setLEDs(...)`.
|
||||||
- `ordered_circles.json` — нормализованные точки и координаты виртуальной матрицы.
|
- `ordered_circles.json` — нормализованные точки и координаты виртуальной матрицы.
|
||||||
- `validation_report.txt` — отчет о проверках маршрутов и ячеек.
|
- `validation_report.txt` — отчет о проверках маршрутов и ячеек.
|
||||||
- `README.md` — краткое описание результата генерации.
|
- `README.md` — краткое описание результата генерации.
|
||||||
|
|
||||||
Устаревший `virtual_matrix_legacy.ino.txt` больше не создаётся. Если файл с этим точным именем остался в выходной папке от прежней версии, следующая успешная генерация удалит его. Другие `.ino`-файлы не затрагиваются.
|
|
||||||
|
|
||||||
JSON схемы хранит `coordinate_unit`, `grid_step_mm` и `index_base: 0`. При первом открытии старого файла без единицы координат приложение определяет её один раз и затем сохраняет явно. Старые схемы с 1-based ID линий и блоков при загрузке переводятся на 0-based; сохранение записывает маркер `index_base: 0`, чтобы повторная загрузка не сдвигала ID. Сохранение выполняется через временный файл и атомарную замену, поэтому исходный JSON не повреждается при ошибке записи.
|
|
||||||
|
|
||||||
Если у схемы есть подложка, при сохранении исходное изображение встраивается в секцию `background.embedded_image` внутри JSON вместе с именем, MIME-типом и контрольной суммой. Диалог выбора показывает все форматы, которые доступны в поставляемой версии Qt (включая PNG, JPEG/JFIF, BMP, GIF, WebP, TIFF и SVG), распознаёт содержимое независимо от регистра и расширения файла и учитывает EXIF-ориентацию. Большие изображения безопасно уменьшаются для отображения. Если изображение повреждено или его декодер недоступен, приложение показывает причину и сохраняет прежнюю подложку. После сохранения схема не зависит от внешнего файла, а размеры и параметры подложки сохраняются при редактировании и переносе JSON. Для уже встроенного и не заменённого изображения диалог `Подложка` показывает в поле пути значение `Встроенная`. Чтобы заменить его, нажмите `Выбрать` и укажите новый файл; новое изображение будет встроено при следующем сохранении схемы.
|
|
||||||
|
|
||||||
В `vm_map.h` используются физические адреса из GUI:
|
В `vm_map.h` используются физические адреса из GUI:
|
||||||
|
|
||||||
- `strip = line_id`
|
- `strip = line_id - 1`
|
||||||
- `led_id = line_index`
|
- `led_id = line_index`
|
||||||
|
|
||||||
Неназначенные точки не попадают в рабочий `VM_MAPPINGS`, но исходные точки могут оставаться в служебных метаданных.
|
Неназначенные точки не попадают в рабочий `VM_MAPPINGS`, но исходные точки могут оставаться в служебных метаданных.
|
||||||
@ -197,12 +162,10 @@ JSON схемы хранит `coordinate_unit`, `grid_step_mm` и `index_base: 0
|
|||||||
.\.venv\Scripts\python.exe -m PyInstaller --noconfirm --clean app\LEDMapper.spec
|
.\.venv\Scripts\python.exe -m PyInstaller --noconfirm --clean app\LEDMapper.spec
|
||||||
```
|
```
|
||||||
|
|
||||||
Сборочный `spec` ограничивает поиск DLL каталогами Python, виртуального окружения и Windows. Это защищает EXE от несовместимых системных библиотек сторонних программ, добавленных в `PATH`. После сборки проверяйте готовый файл командой `dist\LEDMapper-vX.Y.Z.exe --verify-bundle`: успешная проверка завершается с кодом `0` без открытия окна приложения.
|
Готовый файл появится здесь:
|
||||||
|
|
||||||
В чистой сборке из релизного тега файл будет называться:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
dist/LEDMapper-vX.Y.Z.exe
|
dist/LEDMapper.exe
|
||||||
```
|
```
|
||||||
|
|
||||||
`build/` и `dist/` не коммитятся, потому что это воспроизводимые артефакты сборки.
|
`build/` и `dist/` не коммитятся, потому что это воспроизводимые артефакты сборки.
|
||||||
|
|||||||
@ -1,3 +1 @@
|
|||||||
"""LED Mapper Editor application package."""
|
"""LED Mapper Editor application package."""
|
||||||
|
|
||||||
from .version import __version__
|
|
||||||
|
|||||||
@ -16,31 +16,21 @@ from PySide6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from .background_image_loader import supported_image_filter
|
|
||||||
from .localization import localize_dialog_buttons
|
from .localization import localize_dialog_buttons
|
||||||
from .models import BackgroundImage
|
from .models import BackgroundImage
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from background_image_loader import supported_image_filter
|
|
||||||
from localization import localize_dialog_buttons
|
from localization import localize_dialog_buttons
|
||||||
from models import BackgroundImage
|
from models import BackgroundImage
|
||||||
|
|
||||||
|
|
||||||
class BackgroundDialog(QDialog):
|
class BackgroundDialog(QDialog):
|
||||||
EMBEDDED_PATH_LABEL = "Встроенная"
|
|
||||||
|
|
||||||
def __init__(self, background: BackgroundImage, parent=None) -> None: # noqa: ANN001
|
def __init__(self, background: BackgroundImage, parent=None) -> None: # noqa: ANN001
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("Подложка")
|
self.setWindowTitle("Подложка")
|
||||||
self.setMinimumWidth(460)
|
self.setMinimumWidth(460)
|
||||||
|
|
||||||
self._source_background = background
|
self.path = QLineEdit(background.path)
|
||||||
self._keep_embedded_image = background.has_embedded_image()
|
self.path.setPlaceholderText("PNG или JPEG")
|
||||||
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 = QPushButton("Выбрать")
|
||||||
browse.clicked.connect(self._browse)
|
browse.clicked.connect(self._browse)
|
||||||
path_row = QWidget()
|
path_row = QWidget()
|
||||||
@ -79,19 +69,14 @@ class BackgroundDialog(QDialog):
|
|||||||
form.addRow(buttons)
|
form.addRow(buttons)
|
||||||
|
|
||||||
def background(self) -> BackgroundImage:
|
def background(self) -> BackgroundImage:
|
||||||
embedded = self._source_background if self._keep_embedded_image else BackgroundImage()
|
|
||||||
return BackgroundImage(
|
return BackgroundImage(
|
||||||
path="" if self._keep_embedded_image else self.path.text().strip(),
|
path=self.path.text().strip(),
|
||||||
offset_x=self.offset_x.value(),
|
offset_x=self.offset_x.value(),
|
||||||
offset_y=self.offset_y.value(),
|
offset_y=self.offset_y.value(),
|
||||||
scale_x_percent=self.scale_x.value(),
|
scale_x_percent=self.scale_x.value(),
|
||||||
scale_y_percent=self.scale_y.value(),
|
scale_y_percent=self.scale_y.value(),
|
||||||
opacity_percent=self.opacity.value(),
|
opacity_percent=self.opacity.value(),
|
||||||
visible=self.visible.isChecked(),
|
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:
|
def _reset_scale(self) -> None:
|
||||||
@ -99,22 +84,16 @@ class BackgroundDialog(QDialog):
|
|||||||
self.scale_y.setValue(100.0)
|
self.scale_y.setValue(100.0)
|
||||||
|
|
||||||
def _browse(self) -> None:
|
def _browse(self) -> None:
|
||||||
start = "" if self._keep_embedded_image else self.path.text().strip()
|
start = self.path.text().strip()
|
||||||
start_path = Path(start).parent if start else Path.home()
|
start_path = Path(start).parent if start else Path.home()
|
||||||
selected, _ = QFileDialog.getOpenFileName(
|
selected, _ = QFileDialog.getOpenFileName(
|
||||||
self,
|
self,
|
||||||
"Выбрать подложку",
|
"Выбрать подложку",
|
||||||
str(start_path),
|
str(start_path),
|
||||||
supported_image_filter(),
|
"Изображения (*.png *.jpg *.jpeg);;Все файлы (*.*)",
|
||||||
)
|
)
|
||||||
if selected:
|
if selected:
|
||||||
self._set_external_path(selected)
|
self.path.setText(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
|
@staticmethod
|
||||||
def _spin(minimum: float, maximum: float, value: float, suffix: str) -> QDoubleSpinBox:
|
def _spin(minimum: float, maximum: float, value: float, suffix: str) -> QDoubleSpinBox:
|
||||||
|
|||||||
@ -1,147 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import math
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PySide6.QtCore import QByteArray, QBuffer, QIODevice, QSize, Qt
|
|
||||||
from PySide6.QtGui import QImage, QImageIOHandler, QImageReader
|
|
||||||
|
|
||||||
try:
|
|
||||||
from .models import BackgroundImage
|
|
||||||
except ImportError:
|
|
||||||
from models import BackgroundImage
|
|
||||||
|
|
||||||
|
|
||||||
MAX_BACKGROUND_IMAGE_SIDE = 8192
|
|
||||||
MAX_BACKGROUND_IMAGE_PIXELS = 32_000_000
|
|
||||||
MAX_UNSCALED_SOURCE_PIXELS = 100_000_000
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class BackgroundImageLoadResult:
|
|
||||||
image: QImage
|
|
||||||
error: str = ""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def loaded(self) -> bool:
|
|
||||||
return not self.image.isNull()
|
|
||||||
|
|
||||||
|
|
||||||
def supported_image_filter() -> str:
|
|
||||||
formats = sorted(
|
|
||||||
{
|
|
||||||
bytes(image_format).decode("ascii", errors="ignore").lower()
|
|
||||||
for image_format in QImageReader.supportedImageFormats()
|
|
||||||
if image_format
|
|
||||||
}
|
|
||||||
)
|
|
||||||
wildcards: list[str] = []
|
|
||||||
for image_format in formats:
|
|
||||||
if not image_format or image_format == "pdf":
|
|
||||||
continue
|
|
||||||
wildcards.extend((f"*.{image_format}", f"*.{image_format.upper()}"))
|
|
||||||
patterns = " ".join(dict.fromkeys(wildcards))
|
|
||||||
return f"Изображения ({patterns});;Все файлы (*.*)"
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_background_path(background: BackgroundImage, document_path: Path | None = None) -> Path:
|
|
||||||
path = Path(background.path)
|
|
||||||
if not path.is_absolute() and document_path is not None:
|
|
||||||
path = document_path.parent / path
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def background_cache_key(background: BackgroundImage, document_path: Path | None = None) -> tuple[object, ...]:
|
|
||||||
if background.has_embedded_image():
|
|
||||||
image_bytes = background.embedded_image_bytes()
|
|
||||||
return ("embedded", background.embedded_image_sha256 or hash(image_bytes), len(image_bytes))
|
|
||||||
path = resolve_background_path(background, document_path)
|
|
||||||
try:
|
|
||||||
stat = path.stat()
|
|
||||||
except OSError:
|
|
||||||
return ("file", str(path), None, None)
|
|
||||||
return ("file", str(path), stat.st_mtime_ns, stat.st_size)
|
|
||||||
|
|
||||||
|
|
||||||
def _bounded_image_size(size: QSize, max_side: int) -> QSize:
|
|
||||||
if not size.isValid() or max_side <= 0:
|
|
||||||
return size
|
|
||||||
side_factor = min(1.0, max_side / max(size.width(), size.height()))
|
|
||||||
pixel_factor = min(1.0, math.sqrt(MAX_BACKGROUND_IMAGE_PIXELS / (size.width() * size.height())))
|
|
||||||
factor = min(side_factor, pixel_factor)
|
|
||||||
if factor >= 1.0:
|
|
||||||
return size
|
|
||||||
return QSize(max(1, math.floor(size.width() * factor)), max(1, math.floor(size.height() * factor)))
|
|
||||||
|
|
||||||
|
|
||||||
def load_background_image(
|
|
||||||
background: BackgroundImage,
|
|
||||||
document_path: Path | None = None,
|
|
||||||
*,
|
|
||||||
max_side: int = MAX_BACKGROUND_IMAGE_SIDE,
|
|
||||||
) -> BackgroundImageLoadResult:
|
|
||||||
buffer: QBuffer | None = None
|
|
||||||
if background.has_embedded_image():
|
|
||||||
image_bytes = background.embedded_image_bytes()
|
|
||||||
if not image_bytes:
|
|
||||||
return BackgroundImageLoadResult(QImage(), "Встроенные данные изображения повреждены или пусты.")
|
|
||||||
buffer = QBuffer()
|
|
||||||
buffer.setData(QByteArray(image_bytes))
|
|
||||||
if not buffer.open(QIODevice.OpenModeFlag.ReadOnly):
|
|
||||||
return BackgroundImageLoadResult(QImage(), "Не удалось открыть встроенные данные изображения.")
|
|
||||||
reader = QImageReader(buffer)
|
|
||||||
source_name = background.embedded_image_name or "встроенное изображение"
|
|
||||||
else:
|
|
||||||
path = resolve_background_path(background, document_path)
|
|
||||||
if not path.is_file():
|
|
||||||
return BackgroundImageLoadResult(QImage(), f"Файл не найден: {path}")
|
|
||||||
reader = QImageReader(str(path))
|
|
||||||
source_name = path.name
|
|
||||||
|
|
||||||
previous_allocation_limit: int | None = None
|
|
||||||
try:
|
|
||||||
reader.setAutoTransform(True)
|
|
||||||
reader.setDecideFormatFromContent(True)
|
|
||||||
size = reader.size()
|
|
||||||
bounded_size = _bounded_image_size(size, max_side)
|
|
||||||
if bounded_size != size:
|
|
||||||
if not reader.supportsOption(QImageIOHandler.ImageOption.ScaledSize):
|
|
||||||
source_pixels = size.width() * size.height()
|
|
||||||
if source_pixels > MAX_UNSCALED_SOURCE_PIXELS:
|
|
||||||
return BackgroundImageLoadResult(
|
|
||||||
QImage(),
|
|
||||||
f"Изображение «{source_name}» слишком большое для безопасного декодирования "
|
|
||||||
f"({size.width()}×{size.height()} пикселей).",
|
|
||||||
)
|
|
||||||
current_allocation_limit = QImageReader.allocationLimit()
|
|
||||||
required_megabytes = math.ceil(source_pixels * 8 / (1024 * 1024)) + 16
|
|
||||||
if current_allocation_limit > 0 and required_megabytes > current_allocation_limit:
|
|
||||||
previous_allocation_limit = current_allocation_limit
|
|
||||||
QImageReader.setAllocationLimit(required_megabytes)
|
|
||||||
reader.setScaledSize(bounded_size)
|
|
||||||
image = reader.read()
|
|
||||||
if image.isNull():
|
|
||||||
details = reader.errorString().strip()
|
|
||||||
message = f"Не удалось декодировать «{source_name}»"
|
|
||||||
if details:
|
|
||||||
message += f": {details}"
|
|
||||||
return BackgroundImageLoadResult(QImage(), message + ".")
|
|
||||||
|
|
||||||
bounded_size = _bounded_image_size(image.size(), max_side)
|
|
||||||
if bounded_size != image.size():
|
|
||||||
image = image.scaled(
|
|
||||||
bounded_size,
|
|
||||||
Qt.AspectRatioMode.KeepAspectRatio,
|
|
||||||
Qt.TransformationMode.SmoothTransformation,
|
|
||||||
)
|
|
||||||
if image.format() not in (QImage.Format.Format_ARGB32, QImage.Format.Format_ARGB32_Premultiplied):
|
|
||||||
image = image.convertToFormat(QImage.Format.Format_ARGB32_Premultiplied)
|
|
||||||
if image.isNull():
|
|
||||||
return BackgroundImageLoadResult(QImage(), f"Не удалось преобразовать «{source_name}» для отображения.")
|
|
||||||
return BackgroundImageLoadResult(image)
|
|
||||||
finally:
|
|
||||||
if previous_allocation_limit is not None:
|
|
||||||
QImageReader.setAllocationLimit(previous_allocation_limit)
|
|
||||||
if buffer is not None:
|
|
||||||
buffer.close()
|
|
||||||
388
canvas.py
388
canvas.py
@ -1,9 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtCore import QPointF, QRectF, Qt, QTimer, Signal
|
from PySide6.QtCore import QPointF, QRectF, QSize, Qt, Signal
|
||||||
from PySide6.QtGui import QColor, QBrush, QFont, QPainter, QPainterPath, QPen, QPixmap, QTransform
|
from PySide6.QtGui import QColor, QBrush, QImage, QImageReader, QPainter, QPainterPath, QPen, QPixmap, QTransform
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QApplication,
|
QApplication,
|
||||||
QGraphicsEllipseItem,
|
QGraphicsEllipseItem,
|
||||||
@ -14,7 +15,6 @@ from PySide6.QtWidgets import (
|
|||||||
QGraphicsRectItem,
|
QGraphicsRectItem,
|
||||||
QGraphicsScene,
|
QGraphicsScene,
|
||||||
QGraphicsSimpleTextItem,
|
QGraphicsSimpleTextItem,
|
||||||
QGraphicsTextItem,
|
|
||||||
QGraphicsView,
|
QGraphicsView,
|
||||||
QMenu,
|
QMenu,
|
||||||
QScrollBar,
|
QScrollBar,
|
||||||
@ -22,14 +22,12 @@ from PySide6.QtWidgets import (
|
|||||||
from shiboken6 import isValid
|
from shiboken6 import isValid
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from .background_image_loader import background_cache_key, load_background_image
|
|
||||||
from .models import BackgroundImage, LedLine, LedPoint, MappingDocument, coordinate_display_scale
|
from .models import BackgroundImage, LedLine, LedPoint, MappingDocument, coordinate_display_scale
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from background_image_loader import background_cache_key, load_background_image
|
|
||||||
from models import BackgroundImage, LedLine, LedPoint, MappingDocument, coordinate_display_scale
|
from models import BackgroundImage, LedLine, LedPoint, MappingDocument, coordinate_display_scale
|
||||||
|
|
||||||
|
|
||||||
MAX_GRID_LINES = 800
|
MAX_BACKGROUND_PIXMAP_SIDE = 8192
|
||||||
|
|
||||||
|
|
||||||
class LedItem(QGraphicsEllipseItem):
|
class LedItem(QGraphicsEllipseItem):
|
||||||
@ -37,7 +35,6 @@ class LedItem(QGraphicsEllipseItem):
|
|||||||
super().__init__(-radius, -radius, radius * 2, radius * 2)
|
super().__init__(-radius, -radius, radius * 2, radius * 2)
|
||||||
self.point_id = point.uid
|
self.point_id = point.uid
|
||||||
self.setFlag(QGraphicsEllipseItem.ItemIsSelectable, True)
|
self.setFlag(QGraphicsEllipseItem.ItemIsSelectable, True)
|
||||||
self.setFlag(QGraphicsItem.ItemIgnoresTransformations, True)
|
|
||||||
self.setToolTip(self._tooltip(point))
|
self.setToolTip(self._tooltip(point))
|
||||||
self.setZValue(10)
|
self.setZValue(10)
|
||||||
|
|
||||||
@ -56,43 +53,6 @@ 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):
|
class LedCanvas(QGraphicsView):
|
||||||
selectionChanged = Signal(list)
|
selectionChanged = Signal(list)
|
||||||
deleteSelectionRequested = Signal()
|
deleteSelectionRequested = Signal()
|
||||||
@ -106,10 +66,6 @@ class LedCanvas(QGraphicsView):
|
|||||||
brushPointTouched = Signal(int)
|
brushPointTouched = Signal(int)
|
||||||
brushStrokeFinished = Signal()
|
brushStrokeFinished = Signal()
|
||||||
addLedRequested = Signal(float, float)
|
addLedRequested = Signal(float, float)
|
||||||
addCommentRequested = Signal(float, float)
|
|
||||||
editCommentRequested = Signal(str)
|
|
||||||
deleteCommentRequested = Signal(str)
|
|
||||||
commentMoveFinished = Signal(str, float, float)
|
|
||||||
backgroundEditStarted = Signal()
|
backgroundEditStarted = Signal()
|
||||||
backgroundChanged = Signal()
|
backgroundChanged = Signal()
|
||||||
backgroundEditFinished = Signal(bool)
|
backgroundEditFinished = Signal(bool)
|
||||||
@ -127,8 +83,6 @@ class LedCanvas(QGraphicsView):
|
|||||||
self.document: MappingDocument | None = None
|
self.document: MappingDocument | None = None
|
||||||
self.active_line_id: int | None = None
|
self.active_line_id: int | None = None
|
||||||
self.items_by_id: dict[int, LedItem] = {}
|
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.grid_items: list[QGraphicsLineItem] = []
|
||||||
self.connection_items: list[QGraphicsPathItem] = []
|
self.connection_items: list[QGraphicsPathItem] = []
|
||||||
self.line_index_label_items: dict[int, QGraphicsSimpleTextItem] = {}
|
self.line_index_label_items: dict[int, QGraphicsSimpleTextItem] = {}
|
||||||
@ -137,13 +91,11 @@ class LedCanvas(QGraphicsView):
|
|||||||
self._background_handles: dict[str, QGraphicsRectItem] = {}
|
self._background_handles: dict[str, QGraphicsRectItem] = {}
|
||||||
self._background_edit_mode = False
|
self._background_edit_mode = False
|
||||||
self._background_drag: dict | None = None
|
self._background_drag: dict | None = None
|
||||||
self._background_pixmap_cache_key: tuple[object, ...] | None = None
|
self._background_pixmap_cache_key: tuple[str, int | None] | None = None
|
||||||
self._background_pixmap_cache: QPixmap | None = None
|
self._background_pixmap_cache: QPixmap | None = None
|
||||||
self._background_load_error = ""
|
|
||||||
self._background_loaded = False
|
self._background_loaded = False
|
||||||
self._direction_arrows_visible = True
|
self._direction_arrows_visible = True
|
||||||
self._line_index_labels_visible = True
|
self._line_index_labels_visible = True
|
||||||
self._line_index_font_size_px = 12
|
|
||||||
self._point_radius = 4.5
|
self._point_radius = 4.5
|
||||||
self._brush_radius = 18.0
|
self._brush_radius = 18.0
|
||||||
self._zoom_focus_pull = 0.35
|
self._zoom_focus_pull = 0.35
|
||||||
@ -177,19 +129,12 @@ class LedCanvas(QGraphicsView):
|
|||||||
self._last_brush_viewport_pos = None
|
self._last_brush_viewport_pos = None
|
||||||
self._last_selected_ids: set[int] = set()
|
self._last_selected_ids: set[int] = set()
|
||||||
self._edit_mode_enabled = False
|
self._edit_mode_enabled = False
|
||||||
self._pending_edit_view_state: tuple[QTransform, QPointF, QRectF] | None = None
|
|
||||||
self.scene().setBackgroundBrush(QBrush(QColor("#2b2b2b")))
|
self.scene().setBackgroundBrush(QBrush(QColor("#2b2b2b")))
|
||||||
self.scene().selectionChanged.connect(self._emit_selection)
|
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.")
|
self.setToolTip("Выделение рамкой: перетащите мышью. Масштаб: колесо. Del: снять фиксацию выбранных LED.")
|
||||||
|
|
||||||
def set_document(self, document: MappingDocument, *, reset_view: bool = True) -> None:
|
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.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)
|
self.rebuild(reset_view=reset_view)
|
||||||
|
|
||||||
def set_edit_tool(self, tool: str) -> None:
|
def set_edit_tool(self, tool: str) -> None:
|
||||||
@ -211,28 +156,9 @@ class LedCanvas(QGraphicsView):
|
|||||||
def set_edit_mode_enabled(self, enabled: bool) -> None:
|
def set_edit_mode_enabled(self, enabled: bool) -> None:
|
||||||
if self._edit_mode_enabled == enabled:
|
if self._edit_mode_enabled == enabled:
|
||||||
return
|
return
|
||||||
view_state = self._capture_view_state()
|
|
||||||
self._pending_edit_view_state = view_state
|
|
||||||
self._edit_mode_enabled = enabled
|
self._edit_mode_enabled = enabled
|
||||||
self._update_scene_rect()
|
self._update_scene_rect()
|
||||||
self._restore_view_state(view_state)
|
self.rebuild(reset_view=False)
|
||||||
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:
|
def set_active_line(self, line_id: int | None) -> None:
|
||||||
if self.active_line_id == line_id:
|
if self.active_line_id == line_id:
|
||||||
@ -244,28 +170,6 @@ class LedCanvas(QGraphicsView):
|
|||||||
self._grid_visible = visible
|
self._grid_visible = visible
|
||||||
self._refresh_grid()
|
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:
|
def set_direction_arrows_visible(self, visible: bool) -> None:
|
||||||
if self._direction_arrows_visible == visible:
|
if self._direction_arrows_visible == visible:
|
||||||
return
|
return
|
||||||
@ -284,18 +188,6 @@ class LedCanvas(QGraphicsView):
|
|||||||
def line_index_labels_visible(self) -> bool:
|
def line_index_labels_visible(self) -> bool:
|
||||||
return self._line_index_labels_visible
|
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:
|
def set_brush_radius(self, radius: int) -> None:
|
||||||
self._brush_radius = float(max(4, min(80, radius)))
|
self._brush_radius = float(max(4, min(80, radius)))
|
||||||
if self._brush_zone_item is not None and isValid(self._brush_zone_item):
|
if self._brush_zone_item is not None and isValid(self._brush_zone_item):
|
||||||
@ -376,15 +268,6 @@ class LedCanvas(QGraphicsView):
|
|||||||
def background_loaded(self) -> bool:
|
def background_loaded(self) -> bool:
|
||||||
return self._background_loaded
|
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:
|
def set_background(self, background: BackgroundImage) -> None:
|
||||||
if self.document is None:
|
if self.document is None:
|
||||||
return
|
return
|
||||||
@ -444,8 +327,6 @@ class LedCanvas(QGraphicsView):
|
|||||||
self.setUpdatesEnabled(False)
|
self.setUpdatesEnabled(False)
|
||||||
try:
|
try:
|
||||||
self.items_by_id.clear()
|
self.items_by_id.clear()
|
||||||
self.comment_items.clear()
|
|
||||||
self._comment_drag = None
|
|
||||||
self.grid_items.clear()
|
self.grid_items.clear()
|
||||||
self.connection_items.clear()
|
self.connection_items.clear()
|
||||||
self.line_index_label_items.clear()
|
self.line_index_label_items.clear()
|
||||||
@ -468,15 +349,8 @@ class LedCanvas(QGraphicsView):
|
|||||||
self._raw_bounds = None
|
self._raw_bounds = None
|
||||||
self._content_scene_rect = None
|
self._content_scene_rect = None
|
||||||
if self.document is None or not self.document.points:
|
if self.document is None or not self.document.points:
|
||||||
self._display_scale = 1.0
|
|
||||||
self._refresh_background()
|
self._refresh_background()
|
||||||
self._refresh_comments()
|
|
||||||
self._update_scene_rect()
|
self._update_scene_rect()
|
||||||
if reset_view:
|
|
||||||
self.fit_to_content()
|
|
||||||
else:
|
|
||||||
self.setTransform(old_transform)
|
|
||||||
self.centerOn(old_center)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Keep display scale completely stable to prevent grid and camera jumps
|
# Keep display scale completely stable to prevent grid and camera jumps
|
||||||
@ -505,9 +379,13 @@ class LedCanvas(QGraphicsView):
|
|||||||
self.scene().addItem(item)
|
self.scene().addItem(item)
|
||||||
|
|
||||||
stable_min_x, stable_max_x, stable_min_y, stable_max_y = self._stable_edit_bounds
|
stable_min_x, stable_max_x, stable_min_y, stable_max_y = self._stable_edit_bounds
|
||||||
self._update_led_content_rect()
|
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._refresh_background()
|
self._refresh_background()
|
||||||
self._refresh_comments()
|
|
||||||
self._update_scene_rect()
|
self._update_scene_rect()
|
||||||
self._refresh_grid()
|
self._refresh_grid()
|
||||||
self.refresh_styles()
|
self.refresh_styles()
|
||||||
@ -530,8 +408,6 @@ class LedCanvas(QGraphicsView):
|
|||||||
self.centerOn(old_center)
|
self.centerOn(old_center)
|
||||||
|
|
||||||
def refresh_styles(self, redraw_connections: bool = True) -> None:
|
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:
|
if self.document is None:
|
||||||
return
|
return
|
||||||
if redraw_connections:
|
if redraw_connections:
|
||||||
@ -576,16 +452,6 @@ class LedCanvas(QGraphicsView):
|
|||||||
if isinstance(item, LedItem) and isValid(item)
|
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:
|
def select_points(self, point_ids: list[int]) -> None:
|
||||||
target_ids = set(point_ids)
|
target_ids = set(point_ids)
|
||||||
if target_ids == self._last_selected_ids:
|
if target_ids == self._last_selected_ids:
|
||||||
@ -611,7 +477,10 @@ class LedCanvas(QGraphicsView):
|
|||||||
self._remove_background_items()
|
self._remove_background_items()
|
||||||
if self.document is None or not self.document.background.is_enabled():
|
if self.document is None or not self.document.background.is_enabled():
|
||||||
return
|
return
|
||||||
pixmap = self._load_background_pixmap(self.document.background)
|
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)
|
||||||
if pixmap.isNull():
|
if pixmap.isNull():
|
||||||
self._background_loaded = False
|
self._background_loaded = False
|
||||||
return
|
return
|
||||||
@ -631,14 +500,17 @@ class LedCanvas(QGraphicsView):
|
|||||||
self._background_handles.clear()
|
self._background_handles.clear()
|
||||||
self._background_loaded = False
|
self._background_loaded = False
|
||||||
|
|
||||||
def _load_background_pixmap(self, background: BackgroundImage) -> QPixmap:
|
def _load_background_pixmap(self, path: Path) -> QPixmap:
|
||||||
document_path = self.document.path if self.document is not None else None
|
cache_key = (str(path), path.stat().st_mtime_ns if path.exists() 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:
|
if self._background_pixmap_cache_key == cache_key and self._background_pixmap_cache is not None:
|
||||||
return self._background_pixmap_cache
|
return self._background_pixmap_cache
|
||||||
result = load_background_image(background, document_path)
|
reader = QImageReader(str(path))
|
||||||
self._background_load_error = result.error
|
size = reader.size()
|
||||||
pixmap = QPixmap.fromImage(result.image) if result.loaded else QPixmap()
|
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()
|
||||||
self._background_pixmap_cache_key = cache_key
|
self._background_pixmap_cache_key = cache_key
|
||||||
self._background_pixmap_cache = pixmap
|
self._background_pixmap_cache = pixmap
|
||||||
return pixmap
|
return pixmap
|
||||||
@ -680,47 +552,8 @@ class LedCanvas(QGraphicsView):
|
|||||||
return None
|
return None
|
||||||
return self._background_item.sceneBoundingRect()
|
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:
|
def _update_scene_rect(self) -> None:
|
||||||
rect = self._rect_with_comments(self._content_scene_rect)
|
rect = self._content_scene_rect
|
||||||
background_rect = self._background_scene_rect()
|
background_rect = self._background_scene_rect()
|
||||||
if rect is None:
|
if rect is None:
|
||||||
rect = background_rect
|
rect = background_rect
|
||||||
@ -774,12 +607,12 @@ class LedCanvas(QGraphicsView):
|
|||||||
handle.setPos(point)
|
handle.setPos(point)
|
||||||
|
|
||||||
def fit_to_content(self) -> None:
|
def fit_to_content(self) -> None:
|
||||||
if self.scene() is None:
|
|
||||||
return
|
|
||||||
# Размер заметок в пикселях постоянен; их границы в сцене зависят от масштаба.
|
|
||||||
for _ in range(8 if self.comment_items else 1):
|
|
||||||
self._update_scene_rect()
|
self._update_scene_rect()
|
||||||
rect = self._rect_with_comments(self._content_scene_rect)
|
if self.scene() is None or self.sceneRect().isEmpty():
|
||||||
|
return
|
||||||
|
|
||||||
|
# Focus camera on actual document content bounds, ignoring expanded edit sceneRect bounds
|
||||||
|
rect = self._content_scene_rect
|
||||||
background_rect = self._background_scene_rect()
|
background_rect = self._background_scene_rect()
|
||||||
if rect is None:
|
if rect is None:
|
||||||
rect = background_rect
|
rect = background_rect
|
||||||
@ -787,20 +620,8 @@ class LedCanvas(QGraphicsView):
|
|||||||
rect = rect.united(background_rect)
|
rect = rect.united(background_rect)
|
||||||
if rect is None or rect.isEmpty():
|
if rect is None or rect.isEmpty():
|
||||||
rect = self.sceneRect()
|
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
|
self.fitInView(rect, Qt.KeepAspectRatio)
|
||||||
super().resizeEvent(event)
|
|
||||||
self._refresh_grid()
|
|
||||||
|
|
||||||
def _capture_view_state(self) -> tuple[QTransform, QPointF, QRectF] | None:
|
def _capture_view_state(self) -> tuple[QTransform, QPointF, QRectF] | None:
|
||||||
if self.viewport().rect().isEmpty():
|
if self.viewport().rect().isEmpty():
|
||||||
@ -814,17 +635,12 @@ class LedCanvas(QGraphicsView):
|
|||||||
def _restore_view_state(self, state: tuple[QTransform, QPointF, QRectF] | None) -> None:
|
def _restore_view_state(self, state: tuple[QTransform, QPointF, QRectF] | None) -> None:
|
||||||
if state is None:
|
if state is None:
|
||||||
return
|
return
|
||||||
transform, _center, visible_rect = state
|
transform, center, visible_rect = state
|
||||||
self.setTransform(transform)
|
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()
|
scene_rect = self.scene().sceneRect()
|
||||||
if not scene_rect.contains(desired_visible_rect):
|
if not scene_rect.contains(visible_rect):
|
||||||
self.scene().setSceneRect(scene_rect.united(desired_visible_rect))
|
self.scene().setSceneRect(scene_rect.united(visible_rect))
|
||||||
self.centerOn(desired_visible_rect.center())
|
self.centerOn(center)
|
||||||
|
|
||||||
def wheelEvent(self, event) -> None: # noqa: ANN001
|
def wheelEvent(self, event) -> None: # noqa: ANN001
|
||||||
if self._background_drag is not None:
|
if self._background_drag is not None:
|
||||||
@ -833,11 +649,9 @@ class LedCanvas(QGraphicsView):
|
|||||||
factor = 1.15 if event.angleDelta().y() > 0 else 1 / 1.15
|
factor = 1.15 if event.angleDelta().y() > 0 else 1 / 1.15
|
||||||
viewport_pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
viewport_pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
||||||
self._zoom_at_viewport_pos(viewport_pos, factor)
|
self._zoom_at_viewport_pos(viewport_pos, factor)
|
||||||
self._refresh_grid()
|
|
||||||
if self._brush_enabled:
|
if self._brush_enabled:
|
||||||
self._update_brush_zone(viewport_pos)
|
self._update_brush_zone(viewport_pos)
|
||||||
self._position_line_index_labels()
|
self._position_line_index_labels()
|
||||||
self._update_selection_bbox()
|
|
||||||
event.accept()
|
event.accept()
|
||||||
|
|
||||||
def _zoom_at_viewport_pos(self, viewport_pos, factor: float) -> None: # noqa: ANN001
|
def _zoom_at_viewport_pos(self, viewport_pos, factor: float) -> None: # noqa: ANN001
|
||||||
@ -899,16 +713,6 @@ class LedCanvas(QGraphicsView):
|
|||||||
self._start_pan(event.pos())
|
self._start_pan(event.pos())
|
||||||
event.accept()
|
event.accept()
|
||||||
return
|
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"
|
is_move_tool = getattr(self, "_edit_tool", "select") == "move"
|
||||||
has_alt = bool(event.modifiers() & Qt.AltModifier)
|
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()):
|
if event.button() == Qt.LeftButton and (has_alt or is_move_tool) and self._start_point_drag(event.pos()):
|
||||||
@ -965,11 +769,6 @@ class LedCanvas(QGraphicsView):
|
|||||||
self._move_scrollbar(self.verticalScrollBar(), delta.y())
|
self._move_scrollbar(self.verticalScrollBar(), delta.y())
|
||||||
event.accept()
|
event.accept()
|
||||||
return
|
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:
|
if self._background_drag is not None:
|
||||||
self._move_background_edit(event.pos())
|
self._move_background_edit(event.pos())
|
||||||
event.accept()
|
event.accept()
|
||||||
@ -984,7 +783,8 @@ class LedCanvas(QGraphicsView):
|
|||||||
dx = scene_pos.x() - self._last_drawn_pos.x()
|
dx = scene_pos.x() - self._last_drawn_pos.x()
|
||||||
dy = scene_pos.y() - self._last_drawn_pos.y()
|
dy = scene_pos.y() - self._last_drawn_pos.y()
|
||||||
dist = math.hypot(dx, dy)
|
dist = math.hypot(dx, dy)
|
||||||
if dist >= self._grid_step:
|
doc_dist = dist / (self._display_scale or 1.0)
|
||||||
|
if doc_dist >= 34.0:
|
||||||
point = self._document_point_from_scene(scene_pos)
|
point = self._document_point_from_scene(scene_pos)
|
||||||
self.addLedRequested.emit(point.x(), point.y())
|
self.addLedRequested.emit(point.x(), point.y())
|
||||||
self._last_drawn_pos = scene_pos
|
self._last_drawn_pos = scene_pos
|
||||||
@ -1020,17 +820,6 @@ class LedCanvas(QGraphicsView):
|
|||||||
self._finish_pan()
|
self._finish_pan()
|
||||||
event.accept()
|
event.accept()
|
||||||
return
|
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:
|
if event.button() == Qt.LeftButton and self._background_drag is not None:
|
||||||
self._finish_background_edit()
|
self._finish_background_edit()
|
||||||
event.accept()
|
event.accept()
|
||||||
@ -1095,22 +884,9 @@ class LedCanvas(QGraphicsView):
|
|||||||
)
|
)
|
||||||
self._clear_rubber_band()
|
self._clear_rubber_band()
|
||||||
|
|
||||||
# Test in viewport pixels: LED items have an absolute on-screen size.
|
# Collect all LedItems inside the rubber band rectangle
|
||||||
viewport_transform = self.viewportTransform()
|
items_in_rect = self.scene().items(rect, Qt.IntersectsItemBoundingRect)
|
||||||
viewport_rect = QRectF(viewport_transform.map(origin), viewport_transform.map(scene_pos)).normalized()
|
hit_ids = [item.point_id for item in items_in_rect if isinstance(item, LedItem)]
|
||||||
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))
|
keep_shift = bool(modifiers & (Qt.ShiftModifier | Qt.ControlModifier))
|
||||||
if keep_shift:
|
if keep_shift:
|
||||||
@ -1147,8 +923,8 @@ class LedCanvas(QGraphicsView):
|
|||||||
# Compute union bounding rect in scene coords (item pos is centre)
|
# Compute union bounding rect in scene coords (item pos is centre)
|
||||||
xs = [item.pos().x() for item in selected_items]
|
xs = [item.pos().x() for item in selected_items]
|
||||||
ys = [item.pos().y() for item in selected_items]
|
ys = [item.pos().y() for item in selected_items]
|
||||||
view_scale = max(abs(self.transform().m11()), 0.001)
|
radius = selected_items[0].rect().width() / 2.0
|
||||||
padding = (self._point_radius + 4.0) / view_scale
|
padding = radius + 4.0
|
||||||
x_min = min(xs) - padding
|
x_min = min(xs) - padding
|
||||||
y_min = min(ys) - padding
|
y_min = min(ys) - padding
|
||||||
x_max = max(xs) + padding
|
x_max = max(xs) + padding
|
||||||
@ -1208,8 +984,7 @@ class LedCanvas(QGraphicsView):
|
|||||||
})
|
})
|
||||||
|
|
||||||
self._point_drag = {
|
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.setDragMode(QGraphicsView.NoDrag)
|
||||||
self.viewport().setCursor(Qt.ClosedHandCursor)
|
self.viewport().setCursor(Qt.ClosedHandCursor)
|
||||||
@ -1248,10 +1023,7 @@ class LedCanvas(QGraphicsView):
|
|||||||
document_point = self._document_point_from_scene(item.pos())
|
document_point = self._document_point_from_scene(item.pos())
|
||||||
moves.append((point_id, document_point.x(), document_point.y()))
|
moves.append((point_id, document_point.x(), document_point.y()))
|
||||||
|
|
||||||
if len(moves) == 1:
|
if moves:
|
||||||
point_id, x, y = moves[0]
|
|
||||||
self.pointMoveFinished.emit(point_id, x, y)
|
|
||||||
elif moves:
|
|
||||||
self.pointsMoveFinished.emit(moves)
|
self.pointsMoveFinished.emit(moves)
|
||||||
|
|
||||||
def _start_background_edit(self, viewport_pos) -> bool: # noqa: ANN001
|
def _start_background_edit(self, viewport_pos) -> bool: # noqa: ANN001
|
||||||
@ -1403,9 +1175,6 @@ class LedCanvas(QGraphicsView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _start_pan(self, pos) -> None: # noqa: ANN001
|
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._is_panning = True
|
||||||
self._last_pan_pos = pos
|
self._last_pan_pos = pos
|
||||||
self._apply_default_cursor()
|
self._apply_default_cursor()
|
||||||
@ -1416,24 +1185,10 @@ class LedCanvas(QGraphicsView):
|
|||||||
self._apply_default_cursor()
|
self._apply_default_cursor()
|
||||||
|
|
||||||
def contextMenuEvent(self, event) -> None: # noqa: ANN001
|
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())
|
item = self._led_item_at(event.pos())
|
||||||
point_id = item.point_id if item is not None else self._single_selected_point_id()
|
point_id = item.point_id if item is not None else self._single_selected_point_id()
|
||||||
|
|
||||||
menu = QMenu(self)
|
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 = menu.addAction("Редактировать координаты")
|
||||||
edit_action.setEnabled(point_id is not None)
|
edit_action.setEnabled(point_id is not None)
|
||||||
delete_action = menu.addAction("Удалить фиксацию выбранных LED")
|
delete_action = menu.addAction("Удалить фиксацию выбранных LED")
|
||||||
@ -1481,11 +1236,9 @@ class LedCanvas(QGraphicsView):
|
|||||||
select_block_action = menu.addAction(f"Выделить блок '{block_name}'")
|
select_block_action = menu.addAction(f"Выделить блок '{block_name}'")
|
||||||
|
|
||||||
fit_action = menu.addAction("Показать все точки")
|
fit_action = menu.addAction("Показать все точки")
|
||||||
fit_action.setEnabled(self.document is not None and bool(self.document.points or self.document.comments))
|
fit_action.setEnabled(self.document is not None and bool(self.document.points))
|
||||||
chosen = menu.exec(event.globalPos())
|
chosen = menu.exec(event.globalPos())
|
||||||
if chosen == add_comment:
|
if chosen == edit_action and point_id is not None:
|
||||||
self.addCommentRequested.emit(comment_position.x(), comment_position.y())
|
|
||||||
elif chosen == edit_action and point_id is not None:
|
|
||||||
self.editPointRequested.emit(point_id)
|
self.editPointRequested.emit(point_id)
|
||||||
elif chosen == delete_action:
|
elif chosen == delete_action:
|
||||||
self.deleteSelectionRequested.emit()
|
self.deleteSelectionRequested.emit()
|
||||||
@ -1553,9 +1306,6 @@ class LedCanvas(QGraphicsView):
|
|||||||
if item is None or not isValid(item):
|
if item is None or not isValid(item):
|
||||||
continue
|
continue
|
||||||
label = QGraphicsSimpleTextItem(str(point.line_index))
|
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.setBrush(QColor("#f8fafc" if self._theme == "dark" else "#111827"))
|
||||||
label.setZValue(40)
|
label.setZValue(40)
|
||||||
label.setFlag(QGraphicsItem.ItemIgnoresTransformations, True)
|
label.setFlag(QGraphicsItem.ItemIgnoresTransformations, True)
|
||||||
@ -1577,25 +1327,10 @@ class LedCanvas(QGraphicsView):
|
|||||||
continue
|
continue
|
||||||
label_rect = label.boundingRect()
|
label_rect = label.boundingRect()
|
||||||
label.setPos(
|
label.setPos(
|
||||||
item.pos().x() - label_rect.width() / (2.0 * scale),
|
item.pos().x() - label_rect.width() / 2.0,
|
||||||
item.pos().y() - scene_radius - scene_gap,
|
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:
|
def _clear_line_index_labels(self) -> None:
|
||||||
for label in self.line_index_label_items.values():
|
for label in self.line_index_label_items.values():
|
||||||
if isValid(label):
|
if isValid(label):
|
||||||
@ -1727,26 +1462,23 @@ class LedCanvas(QGraphicsView):
|
|||||||
|
|
||||||
if self.document is None or not self.document.points:
|
if self.document is None or not self.document.points:
|
||||||
return
|
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()
|
origin_x, origin_y = self._grid_origin_scene_units()
|
||||||
visible_scene_rect = self.mapToScene(self.viewport().rect()).boundingRect()
|
left_x = min_x - self._margin
|
||||||
overscan_x = max(self._grid_step, visible_scene_rect.width() * 0.05)
|
right_x = max_x + self._margin
|
||||||
overscan_y = max(self._grid_step, visible_scene_rect.height() * 0.05)
|
bottom_y = min_y - self._margin
|
||||||
visible_scene_rect.adjust(-overscan_x, -overscan_y, overscan_x, overscan_y)
|
top_y = max_y + self._margin
|
||||||
|
scene_rect = self.scene().sceneRect()
|
||||||
# Convert the visible scene rectangle back to display units. The
|
left_x = min(left_x, scene_rect.left() + min_x - self._margin)
|
||||||
# resulting grid density depends only on the camera, never on the
|
right_x = max(right_x, scene_rect.right() + min_x - self._margin)
|
||||||
# expanded sceneRect used by scheme editing mode.
|
top_y = max(top_y, max_y + self._margin - scene_rect.top())
|
||||||
left_x = visible_scene_rect.left() - self._margin + min_x
|
bottom_y = min(bottom_y, max_y + self._margin - scene_rect.bottom())
|
||||||
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)
|
first_col = math.floor((left_x - origin_x) / self._grid_step)
|
||||||
last_col = math.ceil((right_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)
|
first_row = math.floor((bottom_y - origin_y) / self._grid_step)
|
||||||
last_row = math.ceil((top_y - origin_y) / self._grid_step)
|
last_row = math.ceil((top_y - origin_y) / self._grid_step)
|
||||||
grid_line_count = max(0, last_col - first_col + 1) + max(0, last_row - first_row + 1)
|
grid_span = (last_col - first_col) + (last_row - first_row)
|
||||||
step_multiplier = max(1, math.ceil(grid_line_count / MAX_GRID_LINES))
|
step_multiplier = 1
|
||||||
pen = QPen(QColor("#334155" if self._theme == "dark" else "#d1d5db"), 0.6)
|
pen = QPen(QColor("#334155" if self._theme == "dark" else "#d1d5db"), 0.6)
|
||||||
pen.setCosmetic(True)
|
pen.setCosmetic(True)
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +0,0 @@
|
|||||||
# Свободные текстовые комментарии — план
|
|
||||||
|
|
||||||
Цель: свободные многострочные заметки на схеме, согласованные пользователем 05.11.2026.
|
|
||||||
|
|
||||||
Архитектура: отдельная коллекция `comments` документа с устойчивым ID, текстом и координатами в единицах документа. Холст отвечает за отображение и жесты, главное окно — за диалог и историю. Существующие файлы без comments загружаются с пустой коллекцией; неизвестные поля сохраняются. Физический маппинг не изменяется.
|
|
||||||
|
|
||||||
Варианты: привязка к LED/линиям отклонена пользователем; выбрано свободное расположение. Техническое решение: встроить объекты в существующий QGraphicsView и deepcopy-историю без новых зависимостей.
|
|
||||||
|
|
||||||
- [x] Модель: тест round-trip Unicode, отрицательных/дробных координат, неизвестных полей и старого JSON; добавить TextComment и CRUD.
|
|
||||||
- [x] Интерфейс: контекстное меню добавления/изменения/удаления, двойной щелчок, перетаскивание. Проверить Undo/Redo, отмену/пустой текст, пустую схему, масштаб и единицы m/mm.
|
|
||||||
- [x] Документация и проверка: README, полный smoke-набор, py_compile, интерактивные события Qt и рендер холста. Сохранить фактические результаты в AgentMemory.
|
|
||||||
|
|
||||||
Ограничения: использовать .venv; сохранить существующие пользовательские изменения; не создавать коммиты и релизы. Обнаружена грязная рабочая копия с HEAD 3699586 — это отличается от исторической записи памяти о релизе v3.4.0; version.py содержит 3.4.0. Работа ведётся поверх текущих файлов без отката.
|
|
||||||
2
icons.py
2
icons.py
@ -10,8 +10,6 @@ LUCIDE_PATHS = {
|
|||||||
"arrow-right": '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>',
|
"arrow-right": '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>',
|
||||||
"brush": '<path d="m9.06 11.9 8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.01 0 1.1-.89 2-2 2 1.4 1.4 3.67 1.4 5.07 0l2.94-2.94a2.93 2.93 0 0 0-4.14-4.14z"/>',
|
"brush": '<path d="m9.06 11.9 8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.01 0 1.1-.89 2-2 2 1.4 1.4 3.67 1.4 5.07 0l2.94-2.94a2.93 2.93 0 0 0-4.14-4.14z"/>',
|
||||||
"chevron-right": '<path d="m9 18 6-6-6-6"/>',
|
"chevron-right": '<path d="m9 18 6-6-6-6"/>',
|
||||||
"clipboard-paste": '<rect width="8" height="4" x="8" y="2" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v5M8 4H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h6"/><path d="m16 19 3-3-3-3"/><path d="M19 16h-7"/>',
|
|
||||||
"copy": '<rect width="14" height="14" x="8" y="8" rx="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
|
|
||||||
"eraser": '<path d="m7 21-4.3-4.3c-.9-.9-.9-2.4 0-3.3L14 2l8 8-11 11Z"/><path d="M22 21H7"/><path d="m5 11 8 8"/>',
|
"eraser": '<path d="m7 21-4.3-4.3c-.9-.9-.9-2.4 0-3.3L14 2l8 8-11 11Z"/><path d="M22 21H7"/><path d="m5 11 8 8"/>',
|
||||||
"eye": '<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>',
|
"eye": '<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>',
|
||||||
"file-plus": '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/><path d="M12 18v-6"/><path d="M9 15h6"/>',
|
"file-plus": '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/><path d="M12 18v-6"/><path d="M9 15h6"/>',
|
||||||
|
|||||||
7
main.py
7
main.py
@ -3,7 +3,6 @@ from __future__ import annotations
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtCore import QTimer
|
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
APP_DIR = Path(__file__).resolve().parent
|
APP_DIR = Path(__file__).resolve().parent
|
||||||
@ -15,20 +14,15 @@ try:
|
|||||||
from .main_window import MainWindow
|
from .main_window import MainWindow
|
||||||
from .settings import AppSettings
|
from .settings import AppSettings
|
||||||
from .theme import apply_app_theme, effective_theme
|
from .theme import apply_app_theme, effective_theme
|
||||||
from .update_service import signal_update_ready
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from icons import lucide_icon
|
from icons import lucide_icon
|
||||||
from main_window import MainWindow
|
from main_window import MainWindow
|
||||||
from settings import AppSettings
|
from settings import AppSettings
|
||||||
from theme import apply_app_theme, effective_theme
|
from theme import apply_app_theme, effective_theme
|
||||||
from update_service import signal_update_ready
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
if "--verify-bundle" in sys.argv:
|
|
||||||
app.processEvents()
|
|
||||||
return 0
|
|
||||||
settings = AppSettings.load()
|
settings = AppSettings.load()
|
||||||
apply_app_theme(settings.theme)
|
apply_app_theme(settings.theme)
|
||||||
icon_color = "#111827" if effective_theme(settings.theme) == "light" else "#e5e7eb"
|
icon_color = "#111827" if effective_theme(settings.theme) == "light" else "#e5e7eb"
|
||||||
@ -37,7 +31,6 @@ def main() -> int:
|
|||||||
window = MainWindow()
|
window = MainWindow()
|
||||||
window.setWindowIcon(app_icon)
|
window.setWindowIcon(app_icon)
|
||||||
window.show()
|
window.show()
|
||||||
QTimer.singleShot(500, signal_update_ready)
|
|
||||||
return app.exec()
|
return app.exec()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
503
main_window.py
503
main_window.py
@ -2,10 +2,9 @@ from __future__ import annotations
|
|||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
from threading import Event, Thread
|
|
||||||
|
|
||||||
from PySide6.QtCore import QEvent, QObject, QSignalBlocker, QSize, Qt, QTimer, Signal
|
from PySide6.QtCore import QEvent, QSignalBlocker, QSize, Qt, QTimer
|
||||||
from PySide6.QtGui import QAction, QColor, QKeySequence
|
from PySide6.QtGui import QAction, QColor, QImageReader, QKeySequence
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QButtonGroup,
|
QButtonGroup,
|
||||||
QAbstractItemView,
|
QAbstractItemView,
|
||||||
@ -55,17 +54,13 @@ try:
|
|||||||
show_information,
|
show_information,
|
||||||
show_warning,
|
show_warning,
|
||||||
)
|
)
|
||||||
from .models import LedBlock, LedLine, LedPoint, MappingDocument
|
from .models import LedBlock, LedLine, MappingDocument
|
||||||
from .panel_mapping_generator import generate_panel_mapping_from_document
|
from .panel_mapping_generator import generate_panel_mapping_from_document
|
||||||
from .settings import DEFAULT_SHORTCUTS, AppSettings
|
from .settings import DEFAULT_SHORTCUTS, AppSettings
|
||||||
from .settings_dialog import SettingsDialog
|
from .settings_dialog import SettingsDialog
|
||||||
from .theme import apply_app_theme, effective_theme
|
from .theme import apply_app_theme, effective_theme
|
||||||
from .unfolding import MAX_LINES
|
from .unfolding import MAX_LINES
|
||||||
from .mapping_renderer import render_mapping_image
|
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:
|
except ImportError:
|
||||||
from background_dialog import BackgroundDialog
|
from background_dialog import BackgroundDialog
|
||||||
from canvas import LedCanvas
|
from canvas import LedCanvas
|
||||||
@ -78,17 +73,13 @@ except ImportError:
|
|||||||
show_information,
|
show_information,
|
||||||
show_warning,
|
show_warning,
|
||||||
)
|
)
|
||||||
from models import LedBlock, LedLine, LedPoint, MappingDocument
|
from models import LedBlock, LedLine, MappingDocument
|
||||||
from panel_mapping_generator import generate_panel_mapping_from_document
|
from panel_mapping_generator import generate_panel_mapping_from_document
|
||||||
from settings import DEFAULT_SHORTCUTS, AppSettings
|
from settings import DEFAULT_SHORTCUTS, AppSettings
|
||||||
from settings_dialog import SettingsDialog
|
from settings_dialog import SettingsDialog
|
||||||
from theme import apply_app_theme, effective_theme
|
from theme import apply_app_theme, effective_theme
|
||||||
from unfolding import MAX_LINES
|
from unfolding import MAX_LINES
|
||||||
from mapping_renderer import render_mapping_image
|
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]
|
ROOT_DIR = Path(__file__).resolve().parents[1]
|
||||||
@ -200,13 +191,6 @@ class GenerationStrategyDialog(QDialog):
|
|||||||
self.ok_button.setEnabled(True)
|
self.ok_button.setEnabled(True)
|
||||||
|
|
||||||
|
|
||||||
class UpdateSignals(QObject):
|
|
||||||
checked = Signal(object)
|
|
||||||
downloaded = Signal(object)
|
|
||||||
progress = Signal(int)
|
|
||||||
failed = Signal(str)
|
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@ -220,10 +204,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._undo_stack: list[tuple[MappingDocument, int | None, list[int], bool]] = []
|
self._undo_stack: list[tuple[MappingDocument, int | None, list[int], bool]] = []
|
||||||
self._redo_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._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.settings = AppSettings.load()
|
||||||
self.document.grid_step_mm = self.settings.grid_step_mm
|
|
||||||
apply_app_theme(self.settings.theme)
|
apply_app_theme(self.settings.theme)
|
||||||
self.actions: dict[str, QAction] = {}
|
self.actions: dict[str, QAction] = {}
|
||||||
self.action_icon_names: dict[str, str] = {}
|
self.action_icon_names: dict[str, str] = {}
|
||||||
@ -247,16 +228,6 @@ class MainWindow(QMainWindow):
|
|||||||
self.menu_buttons_widget: QWidget | None = None
|
self.menu_buttons_widget: QWidget | None = None
|
||||||
self.right_panel: QFrame | None = None
|
self.right_panel: QFrame | None = None
|
||||||
self.right_panel_buttons: dict[str, tuple[QToolButton, str]] = {}
|
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.canvas = LedCanvas()
|
||||||
self._apply_canvas_settings()
|
self._apply_canvas_settings()
|
||||||
@ -269,10 +240,6 @@ class MainWindow(QMainWindow):
|
|||||||
self.canvas.autoBlockFromPointRequested.connect(self.auto_block_from_point)
|
self.canvas.autoBlockFromPointRequested.connect(self.auto_block_from_point)
|
||||||
self.canvas.counterclockwiseBlockFromPointRequested.connect(self.auto_block_from_point_counterclockwise)
|
self.canvas.counterclockwiseBlockFromPointRequested.connect(self.auto_block_from_point_counterclockwise)
|
||||||
self.canvas.addLedRequested.connect(self.add_manual_led)
|
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.brushStrokeStarted.connect(self.start_brush_stroke)
|
||||||
self.canvas.brushPointTouched.connect(self.add_brush_point_to_block)
|
self.canvas.brushPointTouched.connect(self.add_brush_point_to_block)
|
||||||
self.canvas.brushStrokeFinished.connect(self.finish_brush_stroke)
|
self.canvas.brushStrokeFinished.connect(self.finish_brush_stroke)
|
||||||
@ -295,6 +262,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.actions["tree_rename"] = self.tree_rename_action
|
self.actions["tree_rename"] = self.tree_rename_action
|
||||||
|
|
||||||
self.tree_delete_action = QAction(self)
|
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.setShortcutContext(Qt.WidgetShortcut)
|
||||||
self.tree_delete_action.triggered.connect(self.remove_selected_tree_item)
|
self.tree_delete_action.triggered.connect(self.remove_selected_tree_item)
|
||||||
self.tree.addAction(self.tree_delete_action)
|
self.tree.addAction(self.tree_delete_action)
|
||||||
@ -351,8 +319,6 @@ class MainWindow(QMainWindow):
|
|||||||
application.focusChanged.connect(self._on_application_focus_changed)
|
application.focusChanged.connect(self._on_application_focus_changed)
|
||||||
application.installEventFilter(self)
|
application.installEventFilter(self)
|
||||||
self._refresh_ui_state()
|
self._refresh_ui_state()
|
||||||
if getattr(sys, "frozen", False):
|
|
||||||
QTimer.singleShot(1200, self.check_updates)
|
|
||||||
|
|
||||||
def _build_actions(self) -> None:
|
def _build_actions(self) -> None:
|
||||||
def add_action(
|
def add_action(
|
||||||
@ -378,7 +344,6 @@ class MainWindow(QMainWindow):
|
|||||||
self.actions[key] = action
|
self.actions[key] = action
|
||||||
return action
|
return action
|
||||||
|
|
||||||
add_action("add_comment", "Добавить комментарий…", self.add_comment_at_center)
|
|
||||||
add_action("new", "Создать...", self.new_file, "file-plus", QKeySequence.New)
|
add_action("new", "Создать...", self.new_file, "file-plus", QKeySequence.New)
|
||||||
add_action("open", "Открыть", self.open_file, "folder-open", QKeySequence.Open)
|
add_action("open", "Открыть", self.open_file, "folder-open", QKeySequence.Open)
|
||||||
add_action("save", "Сохранить", self.save_file, "save", QKeySequence.Save)
|
add_action("save", "Сохранить", self.save_file, "save", QKeySequence.Save)
|
||||||
@ -392,9 +357,6 @@ class MainWindow(QMainWindow):
|
|||||||
"Экспортировать текущую схему маппинга в изображение (PNG, JPEG)"
|
"Экспортировать текущую схему маппинга в изображение (PNG, JPEG)"
|
||||||
)
|
)
|
||||||
add_action("settings", "Параметры", self.open_settings, "settings", QKeySequence("Ctrl+,"))
|
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(
|
add_action(
|
||||||
"generate_script",
|
"generate_script",
|
||||||
"Сгенерировать скрипт",
|
"Сгенерировать скрипт",
|
||||||
@ -417,27 +379,11 @@ class MainWindow(QMainWindow):
|
|||||||
None,
|
None,
|
||||||
"Автоматически добавить блок от выбранной LED против часовой стрелки",
|
"Автоматически добавить блок от выбранной LED против часовой стрелки",
|
||||||
)
|
)
|
||||||
add_action("rebuild_route", "Пересобрать", self.rebuild_active_line_blocks, "redo-2", None, "Пересобрать блоки активной линии с текущим шагом сетки")
|
add_action("rebuild_route", "Пересобрать", self.rebuild_active_line_blocks, "redo-2", None, "Пересобрать блоки активной линии по правилу 34 мм")
|
||||||
brush_action = add_action("brush", "Кисть", self.toggle_brush_mode, "paintbrush", QKeySequence("B"), "Рисовать выбранный блок по точкам на схеме")
|
brush_action = add_action("brush", "Кисть", self.toggle_brush_mode, "paintbrush", QKeySequence("B"), "Рисовать выбранный блок по точкам на схеме")
|
||||||
brush_action.setCheckable(True)
|
brush_action.setCheckable(True)
|
||||||
add_action("undo", "Отменить", self.undo_last_change, "undo-2", QKeySequence.Undo)
|
add_action("undo", "Отменить", self.undo_last_change, "undo-2", QKeySequence.Undo)
|
||||||
add_action("redo", "Повторить", self.redo_last_change, "redo-2", QKeySequence.Redo)
|
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(
|
self.delete_action = add_action(
|
||||||
"delete",
|
"delete",
|
||||||
"Удалить фиксацию",
|
"Удалить фиксацию",
|
||||||
@ -457,7 +403,7 @@ class MainWindow(QMainWindow):
|
|||||||
add_action("clear_all", "Удалить все", self.clear_all_assignments, "eraser", QKeySequence("Shift+Del"))
|
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("clear_selection", "Очистить выбор", lambda: self.canvas.select_points([]), "archive-restore", QKeySequence("Esc"))
|
||||||
add_action("fit_view", "Показать всё", self.canvas.fit_to_content, "eye", QKeySequence("Ctrl+0"))
|
add_action("fit_view", "Показать всё", self.canvas.fit_to_content, "eye", QKeySequence("Ctrl+0"))
|
||||||
grid_action = add_action("grid", "Сетка", self.toggle_grid, "grid-2x2", QKeySequence("G"), "Показать или скрыть сетку")
|
grid_action = add_action("grid", "Сетка 34 мм", self.toggle_grid, "grid-2x2", QKeySequence("G"), "Показать или скрыть сетку 34 мм")
|
||||||
grid_action.setCheckable(True)
|
grid_action.setCheckable(True)
|
||||||
grid_action.setChecked(self.settings.show_grid_by_default)
|
grid_action.setChecked(self.settings.show_grid_by_default)
|
||||||
add_action("background", "Подложка", self.open_background_dialog, "image", QKeySequence("Ctrl+B"), "Добавить или изменить изображение подложки")
|
add_action("background", "Подложка", self.open_background_dialog, "image", QKeySequence("Ctrl+B"), "Добавить или изменить изображение подложки")
|
||||||
@ -532,9 +478,6 @@ class MainWindow(QMainWindow):
|
|||||||
edit_menu.addAction(self.actions["undo"])
|
edit_menu.addAction(self.actions["undo"])
|
||||||
edit_menu.addAction(self.actions["redo"])
|
edit_menu.addAction(self.actions["redo"])
|
||||||
edit_menu.addSeparator()
|
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"])
|
||||||
edit_menu.addAction(self.actions["delete_points"])
|
edit_menu.addAction(self.actions["delete_points"])
|
||||||
edit_menu.addAction(self.actions["clear_all"])
|
edit_menu.addAction(self.actions["clear_all"])
|
||||||
@ -558,7 +501,6 @@ class MainWindow(QMainWindow):
|
|||||||
tools_menu = menu_bar.addMenu("Инструменты")
|
tools_menu = menu_bar.addMenu("Инструменты")
|
||||||
tools_menu.addAction(self.actions["add_line"])
|
tools_menu.addAction(self.actions["add_line"])
|
||||||
tools_menu.addAction(self.actions["add_led"])
|
tools_menu.addAction(self.actions["add_led"])
|
||||||
tools_menu.addAction(self.actions["add_comment"])
|
|
||||||
tools_menu.addAction(self.actions["generate_script"])
|
tools_menu.addAction(self.actions["generate_script"])
|
||||||
tools_menu.addSeparator()
|
tools_menu.addSeparator()
|
||||||
tools_menu.addAction(self.actions["auto_blocks"])
|
tools_menu.addAction(self.actions["auto_blocks"])
|
||||||
@ -566,10 +508,6 @@ class MainWindow(QMainWindow):
|
|||||||
tools_menu.addSeparator()
|
tools_menu.addSeparator()
|
||||||
tools_menu.addAction(self.actions["brush"])
|
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
|
# Delete old widget if recreating
|
||||||
if hasattr(self, "menu_buttons_widget") and self.menu_buttons_widget is not None:
|
if hasattr(self, "menu_buttons_widget") and self.menu_buttons_widget is not None:
|
||||||
self.menu_buttons_widget.deleteLater()
|
self.menu_buttons_widget.deleteLater()
|
||||||
@ -609,23 +547,15 @@ class MainWindow(QMainWindow):
|
|||||||
if not hasattr(self, "menu_buttons_widget") or self.menu_buttons_widget is None:
|
if not hasattr(self, "menu_buttons_widget") or self.menu_buttons_widget is None:
|
||||||
return
|
return
|
||||||
menu_bar = self.menuBar()
|
menu_bar = self.menuBar()
|
||||||
action_rects = [
|
actions = menu_bar.actions()
|
||||||
menu_bar.actionGeometry(action)
|
if len(actions) < 4:
|
||||||
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
|
return
|
||||||
left = max(rect.right() for rect in action_rects) + 10
|
tools_action = actions[3]
|
||||||
height = menu_bar.height()
|
rect = menu_bar.actionGeometry(tools_action)
|
||||||
width = self.menu_buttons_widget.sizeHint().width()
|
if rect.isValid() and rect.right() > 0:
|
||||||
if left + width > menu_bar.contentsRect().right() + 1:
|
h = menu_bar.height()
|
||||||
self.menu_buttons_widget.hide()
|
w = self.menu_buttons_widget.sizeHint().width()
|
||||||
return
|
self.menu_buttons_widget.setGeometry(rect.right() + 10, (h - 20) // 2, w, 20)
|
||||||
self.menu_buttons_widget.setGeometry(left, max(0, (height - 20) // 2), width, 20)
|
|
||||||
self.menu_buttons_widget.show()
|
|
||||||
|
|
||||||
def _build_layout(self) -> None:
|
def _build_layout(self) -> None:
|
||||||
side = QWidget()
|
side = QWidget()
|
||||||
@ -902,8 +832,8 @@ class MainWindow(QMainWindow):
|
|||||||
actions = [
|
actions = [
|
||||||
("align_horiz", "align-horizontal", "Выровнять по горизонтали (вдоль средней линии Y)", self.align_selected_horizontally),
|
("align_horiz", "align-horizontal", "Выровнять по горизонтали (вдоль средней линии Y)", self.align_selected_horizontally),
|
||||||
("align_vert", "align-vertical", "Выровнять по вертикали (вдоль средней линии X)", self.align_selected_vertically),
|
("align_vert", "align-vertical", "Выровнять по вертикали (вдоль средней линии X)", self.align_selected_vertically),
|
||||||
("grid_horiz", "grid-horizontal", "Расставить по горизонтали с заданным шагом сетки", self.arrange_selected_horizontally_grid),
|
("grid_horiz", "grid-horizontal", "Расставить по горизонтали с шагом 34 мм", self.arrange_selected_horizontally_grid),
|
||||||
("grid_vert", "grid-vertical", "Расставить по вертикали с заданным шагом сетки", self.arrange_selected_vertically_grid),
|
("grid_vert", "grid-vertical", "Расставить по вертикали с шагом 34 мм", self.arrange_selected_vertically_grid),
|
||||||
("align_grid", "grid", "Выровнять по сетке (по центру клеток)", self.align_selected_to_grid),
|
("align_grid", "grid", "Выровнять по сетке (по центру клеток)", self.align_selected_to_grid),
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -962,15 +892,11 @@ class MainWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
points = sorted([self.document.get_point(pid) for pid in selected_ids], key=lambda p: p.x)
|
points = sorted([self.document.get_point(pid) for pid in selected_ids], key=lambda p: p.x)
|
||||||
first_p = points[0]
|
first_p = points[0]
|
||||||
step = self.document.grid_step_in_document_units()
|
|
||||||
coords = {}
|
coords = {}
|
||||||
for i, p in enumerate(points):
|
for i, p in enumerate(points):
|
||||||
new_x = first_p.x + i * step
|
new_x = first_p.x + i * 34.0
|
||||||
coords[p.uid] = (new_x, first_p.y)
|
coords[p.uid] = (new_x, first_p.y)
|
||||||
self.update_multiple_points_coordinates(
|
self.update_multiple_points_coordinates(coords, "Светодиоды расставлены по горизонтальной сетке с шагом 34 мм.")
|
||||||
coords,
|
|
||||||
f"Светодиоды расставлены по горизонтальной сетке с шагом {self.document.grid_step_mm:g} мм.",
|
|
||||||
)
|
|
||||||
def arrange_selected_vertically_grid(self) -> None:
|
def arrange_selected_vertically_grid(self) -> None:
|
||||||
selected_ids = self.canvas.selected_point_ids()
|
selected_ids = self.canvas.selected_point_ids()
|
||||||
if len(selected_ids) < 2:
|
if len(selected_ids) < 2:
|
||||||
@ -978,15 +904,11 @@ class MainWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
points = sorted([self.document.get_point(pid) for pid in selected_ids], key=lambda p: p.y, reverse=True)
|
points = sorted([self.document.get_point(pid) for pid in selected_ids], key=lambda p: p.y, reverse=True)
|
||||||
first_p = points[0]
|
first_p = points[0]
|
||||||
step = self.document.grid_step_in_document_units()
|
|
||||||
coords = {}
|
coords = {}
|
||||||
for i, p in enumerate(points):
|
for i, p in enumerate(points):
|
||||||
new_y = first_p.y - i * step
|
new_y = first_p.y - i * 34.0
|
||||||
coords[p.uid] = (first_p.x, new_y)
|
coords[p.uid] = (first_p.x, new_y)
|
||||||
self.update_multiple_points_coordinates(
|
self.update_multiple_points_coordinates(coords, "Светодиоды расставлены по вертикальной сетке с шагом 34 мм.")
|
||||||
coords,
|
|
||||||
f"Светодиоды расставлены по вертикальной сетке с шагом {self.document.grid_step_mm:g} мм.",
|
|
||||||
)
|
|
||||||
|
|
||||||
def align_selected_to_grid(self) -> None:
|
def align_selected_to_grid(self) -> None:
|
||||||
selected_ids = self.canvas.selected_point_ids()
|
selected_ids = self.canvas.selected_point_ids()
|
||||||
@ -1006,11 +928,9 @@ class MainWindow(QMainWindow):
|
|||||||
self.status.setText("Нет опорных точек для расчета сетки.")
|
self.status.setText("Нет опорных точек для расчета сетки.")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
from models import coordinate_display_scale, infer_grid_coordinate_axes, MIN_NEIGHBOR_DISTANCE_MM
|
||||||
from .models import infer_grid_coordinate_axes
|
scale = coordinate_display_scale(anchors)
|
||||||
except ImportError:
|
step = MIN_NEIGHBOR_DISTANCE_MM / scale
|
||||||
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)
|
row_axis, row_sign, col_axis, col_sign = infer_grid_coordinate_axes(anchors)
|
||||||
|
|
||||||
ref = anchors[0]
|
ref = anchors[0]
|
||||||
@ -1038,11 +958,14 @@ class MainWindow(QMainWindow):
|
|||||||
if res != BackgroundDialog.Accepted:
|
if res != BackgroundDialog.Accepted:
|
||||||
return
|
return
|
||||||
background = dialog.background()
|
background = dialog.background()
|
||||||
if not background.is_enabled():
|
if not background.path:
|
||||||
return
|
return
|
||||||
if not self.canvas.validate_background(background):
|
image_path = Path(background.path)
|
||||||
details = self.canvas.background_load_error() or "Формат или содержимое изображения не поддерживается."
|
if not image_path.is_absolute() and self.document.path is not None:
|
||||||
show_warning(self, "Подложка не загружена", details)
|
image_path = self.document.path.parent / image_path
|
||||||
|
reader = QImageReader(str(image_path))
|
||||||
|
if not reader.canRead():
|
||||||
|
show_warning(self, "Подложка не загружена", "Не удалось загрузить изображение подложки. Выберите PNG или JPEG файл.")
|
||||||
return
|
return
|
||||||
if background.to_payload() == self.document.background.to_payload():
|
if background.to_payload() == self.document.background.to_payload():
|
||||||
self.status.setText("Подложка не изменилась.")
|
self.status.setText("Подложка не изменилась.")
|
||||||
@ -1125,27 +1048,15 @@ class MainWindow(QMainWindow):
|
|||||||
self._pending_background_undo_state = None
|
self._pending_background_undo_state = None
|
||||||
|
|
||||||
def open_settings(self) -> None:
|
def open_settings(self) -> None:
|
||||||
dialog_settings = deepcopy(self.settings)
|
dialog = SettingsDialog(self.settings, self)
|
||||||
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()
|
self._disable_shortcuts()
|
||||||
try:
|
try:
|
||||||
res = dialog.exec()
|
res = dialog.exec()
|
||||||
finally:
|
finally:
|
||||||
self._enable_shortcuts()
|
self._enable_shortcuts()
|
||||||
if res != SettingsDialog.Accepted:
|
if res != SettingsDialog.Accepted:
|
||||||
self.canvas.set_line_index_font_size(previous_index_font_size)
|
|
||||||
return
|
return
|
||||||
new_settings = dialog.settings()
|
self.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()
|
self.settings.save()
|
||||||
apply_app_theme(self.settings.theme)
|
apply_app_theme(self.settings.theme)
|
||||||
self._refresh_theme_icons()
|
self._refresh_theme_icons()
|
||||||
@ -1181,35 +1092,17 @@ class MainWindow(QMainWindow):
|
|||||||
self.canvas.set_grid_visible(self.settings.show_grid_by_default)
|
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_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_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_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_zoom_focus_pull(self.settings.zoom_focus_pull)
|
||||||
self.canvas.set_pan_mode(self.settings.pan_mode)
|
self.canvas.set_pan_mode(self.settings.pan_mode)
|
||||||
self.canvas.set_instant_space_pan(self.settings.instant_space_pan)
|
self.canvas.set_instant_space_pan(self.settings.instant_space_pan)
|
||||||
|
|
||||||
def eventFilter(self, obj, event) -> bool: # noqa: ANN001, N802
|
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 event.type() == QEvent.Type.KeyPress and not event.isAutoRepeat():
|
||||||
if self._trigger_layout_independent_shortcut(event):
|
if self._trigger_layout_independent_shortcut(event):
|
||||||
return True
|
return True
|
||||||
return super().eventFilter(obj, event)
|
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
|
def _trigger_layout_independent_shortcut(self, event) -> bool: # noqa: ANN001
|
||||||
active_window = QApplication.activeWindow()
|
active_window = QApplication.activeWindow()
|
||||||
if active_window is not None and active_window is not self:
|
if active_window is not None and active_window is not self:
|
||||||
@ -1221,13 +1114,9 @@ class MainWindow(QMainWindow):
|
|||||||
modifiers = event.modifiers()
|
modifiers = event.modifiers()
|
||||||
if isinstance(focus, QKeySequenceEdit):
|
if isinstance(focus, QKeySequenceEdit):
|
||||||
return False
|
return False
|
||||||
if self._text_input_owns_copy_paste(event):
|
|
||||||
return False
|
|
||||||
text_input_has_focus = isinstance(focus, (QLineEdit, QTextEdit, QPlainTextEdit, QKeySequenceEdit))
|
text_input_has_focus = isinstance(focus, (QLineEdit, QTextEdit, QPlainTextEdit, QKeySequenceEdit))
|
||||||
if text_input_has_focus and not (modifiers & (Qt.ControlModifier | Qt.AltModifier)):
|
if text_input_has_focus and not (modifiers & (Qt.ControlModifier | Qt.AltModifier)):
|
||||||
return False
|
return False
|
||||||
if self._is_tree_bulk_delete_event(event):
|
|
||||||
return self.remove_selected_tree_item()
|
|
||||||
for key in self.shortcut_action_keys:
|
for key in self.shortcut_action_keys:
|
||||||
if self.settings.shortcut_for(key) != sequence:
|
if self.settings.shortcut_for(key) != sequence:
|
||||||
continue
|
continue
|
||||||
@ -1237,13 +1126,6 @@ class MainWindow(QMainWindow):
|
|||||||
return True
|
return True
|
||||||
return False
|
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
|
def _physical_key_sequence(self, event) -> str: # noqa: ANN001
|
||||||
key_name = physical_key_name(event)
|
key_name = physical_key_name(event)
|
||||||
if not key_name:
|
if not key_name:
|
||||||
@ -1412,13 +1294,17 @@ class MainWindow(QMainWindow):
|
|||||||
p = Path(path)
|
p = Path(path)
|
||||||
if not p.suffix:
|
if not p.suffix:
|
||||||
p = p.with_suffix(".json")
|
p = p.with_suffix(".json")
|
||||||
loaded = MappingDocument()
|
|
||||||
loaded.grid_step_mm = self.settings.grid_step_mm
|
|
||||||
try:
|
try:
|
||||||
loaded.save(p)
|
p.write_text('{"circles": []}', encoding="utf-8")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
show_critical(self, "Ошибка создания файла", str(exc))
|
show_critical(self, "Ошибка создания файла", str(exc))
|
||||||
return
|
return
|
||||||
|
loaded = MappingDocument()
|
||||||
|
try:
|
||||||
|
loaded.load(p)
|
||||||
|
except Exception as exc:
|
||||||
|
show_critical(self, "Ошибка загрузки нового файла", str(exc))
|
||||||
|
return
|
||||||
self.document = loaded
|
self.document = loaded
|
||||||
self._dirty = False
|
self._dirty = False
|
||||||
self._undo_stack.clear()
|
self._undo_stack.clear()
|
||||||
@ -1674,7 +1560,6 @@ class MainWindow(QMainWindow):
|
|||||||
return False
|
return False
|
||||||
active_line = self._get_line_or_none(self.active_line_id)
|
active_line = self._get_line_or_none(self.active_line_id)
|
||||||
try:
|
try:
|
||||||
self._synchronize_canvas_point_positions()
|
|
||||||
self.document.save(path)
|
self.document.save(path)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._restore_active_line(active_line)
|
self._restore_active_line(active_line)
|
||||||
@ -1695,17 +1580,6 @@ class MainWindow(QMainWindow):
|
|||||||
self._refresh_ui_state()
|
self._refresh_ui_state()
|
||||||
return True
|
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:
|
def add_line(self) -> None:
|
||||||
if len(self.document.lines) >= MAX_LINES:
|
if len(self.document.lines) >= MAX_LINES:
|
||||||
show_information(
|
show_information(
|
||||||
@ -1724,55 +1598,6 @@ class MainWindow(QMainWindow):
|
|||||||
self.status.setText(f"Создана {line.name}. {self._line_summary(line)}")
|
self.status.setText(f"Создана {line.name}. {self._line_summary(line)}")
|
||||||
self._refresh_ui_state()
|
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:
|
def add_manual_led(self, x: float, y: float) -> None:
|
||||||
self._push_undo()
|
self._push_undo()
|
||||||
point = self.document.add_manual_point(x, y)
|
point = self.document.add_manual_point(x, y)
|
||||||
@ -1784,56 +1609,6 @@ class MainWindow(QMainWindow):
|
|||||||
self.status.setText(f"Добавлен свободный LED ID {point.source_id}: X={point.x:.3f}, Y={point.y:.3f}.")
|
self.status.setText(f"Добавлен свободный LED ID {point.source_id}: X={point.x:.3f}, Y={point.y:.3f}.")
|
||||||
self._refresh_ui_state()
|
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:
|
def edit_point_coordinates(self, point_id: int) -> None:
|
||||||
if not 0 <= point_id < len(self.document.points):
|
if not 0 <= point_id < len(self.document.points):
|
||||||
return
|
return
|
||||||
@ -1913,7 +1688,7 @@ class MainWindow(QMainWindow):
|
|||||||
def _coordinate_spin_box(self, value: float) -> QDoubleSpinBox:
|
def _coordinate_spin_box(self, value: float) -> QDoubleSpinBox:
|
||||||
spin = QDoubleSpinBox()
|
spin = QDoubleSpinBox()
|
||||||
spin.setRange(-1_000_000_000.0, 1_000_000_000.0)
|
spin.setRange(-1_000_000_000.0, 1_000_000_000.0)
|
||||||
spin.setDecimals(15)
|
spin.setDecimals(6)
|
||||||
spin.setSingleStep(1.0)
|
spin.setSingleStep(1.0)
|
||||||
spin.setValue(float(value))
|
spin.setValue(float(value))
|
||||||
spin.setKeyboardTracking(False)
|
spin.setKeyboardTracking(False)
|
||||||
@ -2028,7 +1803,7 @@ class MainWindow(QMainWindow):
|
|||||||
answer = ask_question(
|
answer = ask_question(
|
||||||
self,
|
self,
|
||||||
"Пересобрать блоки",
|
"Пересобрать блоки",
|
||||||
f"Пересобрать блоки активной линии по физическим соседям с шагом {self.document.grid_step_mm:g} мм?",
|
"Пересобрать блоки активной линии по физическим соседям и правилу 34 мм?",
|
||||||
)
|
)
|
||||||
if answer != QMessageBox.StandardButton.Yes:
|
if answer != QMessageBox.StandardButton.Yes:
|
||||||
return
|
return
|
||||||
@ -2106,33 +1881,6 @@ class MainWindow(QMainWindow):
|
|||||||
self._refresh_ui_state()
|
self._refresh_ui_state()
|
||||||
|
|
||||||
def remove_selected_tree_item(self) -> bool:
|
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()
|
item = self.tree.currentItem()
|
||||||
if item is None:
|
if item is None:
|
||||||
return False
|
return False
|
||||||
@ -2154,6 +1902,23 @@ class MainWindow(QMainWindow):
|
|||||||
self.canvas.select_points([])
|
self.canvas.select_points([])
|
||||||
self._after_mapping_change(f"Удалена линия и фиксация LED: {removed}", selection_already_updated=True)
|
self._after_mapping_change(f"Удалена линия и фиксация LED: {removed}", selection_already_updated=True)
|
||||||
return 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
|
return False
|
||||||
|
|
||||||
def clear_all_assignments(self) -> None:
|
def clear_all_assignments(self) -> None:
|
||||||
@ -2485,9 +2250,14 @@ class MainWindow(QMainWindow):
|
|||||||
rename_action = menu.addAction("Переименовать блок\tF2")
|
rename_action = menu.addAction("Переименовать блок\tF2")
|
||||||
elif data[0] == "line":
|
elif data[0] == "line":
|
||||||
rename_action = menu.addAction("Переименовать линию\tF2")
|
rename_action = menu.addAction("Переименовать линию\tF2")
|
||||||
label = self._tree_delete_label(data)
|
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"
|
||||||
delete_action = menu.addAction(label)
|
delete_action = menu.addAction(label)
|
||||||
delete_action.setEnabled(bool(self._selected_tree_blocks()) or item is not None)
|
delete_action.setEnabled(item is not None)
|
||||||
clear_all_action = menu.addAction("Удалить все фиксации\tShift+Del")
|
clear_all_action = menu.addAction("Удалить все фиксации\tShift+Del")
|
||||||
clear_all_action.setEnabled(bool(self.document.lines))
|
clear_all_action.setEnabled(bool(self.document.lines))
|
||||||
fit_action = menu.addAction("Показать все точки")
|
fit_action = menu.addAction("Показать все точки")
|
||||||
@ -2813,16 +2583,6 @@ class MainWindow(QMainWindow):
|
|||||||
order[(line.id, block.id)] = len(order)
|
order[(line.id, block.id)] = len(order)
|
||||||
return sorted(set(blocks), key=lambda block_key: order.get(block_key, 10**9))
|
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:
|
def _can_merge_selected_tree_blocks(self) -> bool:
|
||||||
selected_blocks = self._selected_tree_blocks()
|
selected_blocks = self._selected_tree_blocks()
|
||||||
return len(selected_blocks) >= 2 and len({line_id for line_id, _block_id in selected_blocks}) == 1
|
return len(selected_blocks) >= 2 and len({line_id for line_id, _block_id in selected_blocks}) == 1
|
||||||
@ -2865,7 +2625,6 @@ class MainWindow(QMainWindow):
|
|||||||
has_visible_background = has_loaded_background and self.document.background.visible
|
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_active_line = self._get_line_or_none(self.active_line_id) is not None
|
||||||
has_selection = bool(point_ids)
|
has_selection = bool(point_ids)
|
||||||
edit_mode_enabled = self._edit_mode_is_enabled()
|
|
||||||
selected_has_assignment = any(
|
selected_has_assignment = any(
|
||||||
self.document.get_point(point_id).line_id is not None
|
self.document.get_point(point_id).line_id is not None
|
||||||
or self.document.get_point(point_id).block_id is not None
|
or self.document.get_point(point_id).block_id is not None
|
||||||
@ -2893,14 +2652,12 @@ class MainWindow(QMainWindow):
|
|||||||
self.selection_label.setText(self._selection_summary(point_ids))
|
self.selection_label.setText(self._selection_summary(point_ids))
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
"save": bool(has_points or self.document.comments or self.document.path is not None) and self._dirty,
|
"save": has_points and self._dirty,
|
||||||
"save_as": bool(has_points or self.document.comments or self.document.path is not None),
|
"save_as": has_points,
|
||||||
"export_image": has_points,
|
"export_image": has_points,
|
||||||
"generate_script": has_points,
|
"generate_script": has_points,
|
||||||
"undo": bool(self._undo_stack),
|
"undo": bool(self._undo_stack),
|
||||||
"redo": bool(self._redo_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_line": has_points and len(self.document.lines) < MAX_LINES,
|
||||||
"add_led": True,
|
"add_led": True,
|
||||||
"add_block": has_points and has_active_line,
|
"add_block": has_points and has_active_line,
|
||||||
@ -2913,7 +2670,7 @@ class MainWindow(QMainWindow):
|
|||||||
"delete_points": has_selection,
|
"delete_points": has_selection,
|
||||||
"clear_all": has_lines,
|
"clear_all": has_lines,
|
||||||
"clear_selection": has_selection,
|
"clear_selection": has_selection,
|
||||||
"fit_view": has_points or has_visible_background or bool(self.document.comments),
|
"fit_view": has_points or has_visible_background,
|
||||||
"grid": True,
|
"grid": True,
|
||||||
"background": True,
|
"background": True,
|
||||||
"background_visible": has_loaded_background,
|
"background_visible": has_loaded_background,
|
||||||
@ -3157,117 +2914,8 @@ class MainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
return answer == QMessageBox.StandardButton.Yes
|
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
|
def closeEvent(self, event) -> None: # noqa: ANN001
|
||||||
if self._installing_update or self._confirm_discard_changes("Закрыть приложение и потерять несохраненные изменения текущей карты?"):
|
if self._confirm_discard_changes("Закрыть приложение и потерять несохраненные изменения текущей карты?"):
|
||||||
self._update_cancel.set()
|
|
||||||
event.accept()
|
event.accept()
|
||||||
else:
|
else:
|
||||||
event.ignore()
|
event.ignore()
|
||||||
@ -3294,14 +2942,6 @@ class MainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
return answer == QMessageBox.StandardButton.Yes
|
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:
|
def _confirm_delete_points(self, count: int) -> bool:
|
||||||
answer = ask_question(
|
answer = ask_question(
|
||||||
self,
|
self,
|
||||||
@ -3341,7 +2981,8 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_coordinate(value: float) -> str:
|
def _format_coordinate(value: float) -> str:
|
||||||
return repr(float(value))
|
text = f"{value:.3f}".rstrip("0").rstrip(".")
|
||||||
|
return text if text else "0"
|
||||||
|
|
||||||
def _line_summary(self, line: LedLine) -> str:
|
def _line_summary(self, line: LedLine) -> str:
|
||||||
return f"блоков: {len(line.blocks)}, LED: {self._line_count(line)}"
|
return f"блоков: {len(line.blocks)}, LED: {self._line_count(line)}"
|
||||||
|
|||||||
409
models.py
409
models.py
@ -1,18 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
|
||||||
import binascii
|
|
||||||
import hashlib
|
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import mimetypes
|
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import tempfile
|
|
||||||
import uuid
|
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from copy import deepcopy
|
from dataclasses import dataclass, field
|
||||||
from dataclasses import dataclass, field, replace
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@ -22,16 +14,12 @@ except ImportError: # pragma: no cover - supports package imports in tests/tool
|
|||||||
from .unfolding import MAX_LINES, UnfoldingDescription, UnfoldingLineSummary, clockwise_sort, order_points_clockwise
|
from .unfolding import MAX_LINES, UnfoldingDescription, UnfoldingLineSummary, clockwise_sort, order_points_clockwise
|
||||||
|
|
||||||
MIN_NEIGHBOR_DISTANCE_MM = 34.0
|
MIN_NEIGHBOR_DISTANCE_MM = 34.0
|
||||||
DEFAULT_GRID_STEP_MM = MIN_NEIGHBOR_DISTANCE_MM
|
|
||||||
MAX_NEIGHBOR_TOLERANCE_MM = 2.0
|
MAX_NEIGHBOR_TOLERANCE_MM = 2.0
|
||||||
METER_LIKE_MAX_SPAN = 10.0
|
METER_LIKE_MAX_SPAN = 10.0
|
||||||
_DOCUMENT_KNOWN_FIELDS = {
|
_DOCUMENT_KNOWN_FIELDS = {
|
||||||
"background",
|
"background",
|
||||||
"comments",
|
|
||||||
"circles",
|
"circles",
|
||||||
"coordinate_unit",
|
"coordinate_unit",
|
||||||
"grid_step_mm",
|
|
||||||
"index_base",
|
|
||||||
"leds",
|
"leds",
|
||||||
"lines",
|
"lines",
|
||||||
"unit",
|
"unit",
|
||||||
@ -53,15 +41,6 @@ _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]:
|
def load_json_document(path: str | Path) -> dict[str, Any]:
|
||||||
path = Path(path)
|
path = Path(path)
|
||||||
if path.exists():
|
if path.exists():
|
||||||
@ -208,33 +187,11 @@ class BackgroundImage:
|
|||||||
scale_y_percent: float = 100.0
|
scale_y_percent: float = 100.0
|
||||||
opacity_percent: float = 100.0
|
opacity_percent: float = 100.0
|
||||||
visible: bool = True
|
visible: bool = True
|
||||||
embedded_image_base64: str = ""
|
|
||||||
embedded_image_mime_type: str = ""
|
|
||||||
embedded_image_name: str = ""
|
|
||||||
embedded_image_sha256: str = ""
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_payload(cls, payload) -> "BackgroundImage": # noqa: ANN001
|
def from_payload(cls, payload) -> "BackgroundImage": # noqa: ANN001
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
return cls()
|
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 = _optional_float(payload.get("scale_percent"))
|
||||||
scale_x = _optional_float(payload.get("scale_x_percent"))
|
scale_x = _optional_float(payload.get("scale_x_percent"))
|
||||||
scale_y = _optional_float(payload.get("scale_y_percent"))
|
scale_y = _optional_float(payload.get("scale_y_percent"))
|
||||||
@ -253,14 +210,10 @@ class BackgroundImage:
|
|||||||
scale_y_percent=max(1.0, float(scale_y)),
|
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))),
|
opacity_percent=min(100.0, max(0.0, 100.0 if opacity is None else float(opacity))),
|
||||||
visible=bool(payload.get("visible", True)),
|
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, Any]:
|
def to_payload(self) -> dict[str, bool | float | str]:
|
||||||
payload: dict[str, Any] = {
|
return {
|
||||||
"path": self.path,
|
"path": self.path,
|
||||||
"offset_x": self.offset_x,
|
"offset_x": self.offset_x,
|
||||||
"offset_y": self.offset_y,
|
"offset_y": self.offset_y,
|
||||||
@ -269,80 +222,9 @@ class BackgroundImage:
|
|||||||
"opacity_percent": self.opacity_percent,
|
"opacity_percent": self.opacity_percent,
|
||||||
"visible": self.visible,
|
"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:
|
def is_enabled(self) -> bool:
|
||||||
return bool(self.path) or self.has_embedded_image()
|
return bool(self.path)
|
||||||
|
|
||||||
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:
|
class MappingDocument:
|
||||||
@ -351,28 +233,24 @@ class MappingDocument:
|
|||||||
self.collection_name = "circles"
|
self.collection_name = "circles"
|
||||||
self.points: list[LedPoint] = []
|
self.points: list[LedPoint] = []
|
||||||
self.lines: list[LedLine] = []
|
self.lines: list[LedLine] = []
|
||||||
self.comments: list[TextComment] = []
|
|
||||||
self.background = BackgroundImage()
|
self.background = BackgroundImage()
|
||||||
self.unfolding = UnfoldingDescription()
|
self.unfolding = UnfoldingDescription()
|
||||||
self.coordinate_unit = "mm"
|
self.coordinate_unit = "auto"
|
||||||
self.grid_step_mm = DEFAULT_GRID_STEP_MM
|
|
||||||
self.extra_payload: dict[str, Any] = {}
|
self.extra_payload: dict[str, Any] = {}
|
||||||
self._next_line_id = 0
|
self._next_line_id = 1
|
||||||
self._next_block_id = 0
|
self._next_block_id = 1
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
self.path = None
|
self.path = None
|
||||||
self.collection_name = "circles"
|
self.collection_name = "circles"
|
||||||
self.points.clear()
|
self.points.clear()
|
||||||
self.lines.clear()
|
self.lines.clear()
|
||||||
self.comments.clear()
|
|
||||||
self.background = BackgroundImage()
|
self.background = BackgroundImage()
|
||||||
self.unfolding = UnfoldingDescription()
|
self.unfolding = UnfoldingDescription()
|
||||||
self.coordinate_unit = "mm"
|
self.coordinate_unit = "auto"
|
||||||
self.grid_step_mm = DEFAULT_GRID_STEP_MM
|
|
||||||
self.extra_payload.clear()
|
self.extra_payload.clear()
|
||||||
self._next_line_id = 0
|
self._next_line_id = 1
|
||||||
self._next_block_id = 0
|
self._next_block_id = 1
|
||||||
|
|
||||||
def load(self, path: str | Path) -> None:
|
def load(self, path: str | Path) -> None:
|
||||||
self.clear()
|
self.clear()
|
||||||
@ -383,8 +261,7 @@ class MappingDocument:
|
|||||||
for key, value in data.items()
|
for key, value in data.items()
|
||||||
if key not in _DOCUMENT_KNOWN_FIELDS
|
if key not in _DOCUMENT_KNOWN_FIELDS
|
||||||
}
|
}
|
||||||
self.coordinate_unit = str(data.get("coordinate_unit", data.get("unit", "auto"))).strip().lower()
|
self.coordinate_unit = str(data.get("coordinate_unit", data.get("unit", "auto")))
|
||||||
self.grid_step_mm = _positive_finite_float(data.get("grid_step_mm"), DEFAULT_GRID_STEP_MM)
|
|
||||||
points_data = data.get("leds")
|
points_data = data.get("leds")
|
||||||
self.collection_name = "leds"
|
self.collection_name = "leds"
|
||||||
if not isinstance(points_data, list):
|
if not isinstance(points_data, list):
|
||||||
@ -420,22 +297,7 @@ 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._load_lines(data)
|
||||||
self._migrate_legacy_index_base(data)
|
|
||||||
self.background = BackgroundImage.from_payload(data.get("background"))
|
self.background = BackgroundImage.from_payload(data.get("background"))
|
||||||
self.unfolding = UnfoldingDescription.from_payload(data.get("unfolding"))
|
self.unfolding = UnfoldingDescription.from_payload(data.get("unfolding"))
|
||||||
self.unfolding.max_lines = MAX_LINES
|
self.unfolding.max_lines = MAX_LINES
|
||||||
@ -474,41 +336,6 @@ class MappingDocument:
|
|||||||
self.lines.append(line)
|
self.lines.append(line)
|
||||||
self._next_line_id = max(self._next_line_id, line_id + 1)
|
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:
|
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}):
|
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)))
|
line = LedLine(int(line_id), f"Line {line_id}", default_color(int(line_id)))
|
||||||
@ -530,7 +357,7 @@ class MappingDocument:
|
|||||||
if point.line_id == line_id
|
if point.line_id == line_id
|
||||||
]
|
]
|
||||||
if ids:
|
if ids:
|
||||||
line.blocks.append(LedBlock(self._next_block_id, f"Блок {self._next_block_id}", ids))
|
line.blocks.append(LedBlock(self._next_block_id, "Block 1", ids))
|
||||||
self._next_block_id += 1
|
self._next_block_id += 1
|
||||||
for block_id in block_ids:
|
for block_id in block_ids:
|
||||||
ids = [
|
ids = [
|
||||||
@ -552,34 +379,9 @@ class MappingDocument:
|
|||||||
def save(self, path: str | Path) -> None:
|
def save(self, path: str | Path) -> None:
|
||||||
path = Path(path)
|
path = Path(path)
|
||||||
self.reindex_all()
|
self.reindex_all()
|
||||||
source_base_dir = self.path.parent if self.path is not None else path.parent
|
payload = self.to_payload()
|
||||||
background_for_save = self.background.with_embedded_image(source_base_dir)
|
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
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.path = path
|
||||||
self.background = background_for_save
|
|
||||||
|
|
||||||
def to_payload(
|
def to_payload(
|
||||||
self,
|
self,
|
||||||
@ -587,7 +389,6 @@ class MappingDocument:
|
|||||||
point_payloads: list[dict[str, Any]] | None = None,
|
point_payloads: list[dict[str, Any]] | None = None,
|
||||||
lines_payload: list[dict[str, Any]] | None = None,
|
lines_payload: list[dict[str, Any]] | None = None,
|
||||||
collection_name: str | None = None,
|
collection_name: str | None = None,
|
||||||
background: BackgroundImage | None = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
payload = dict(self.extra_payload)
|
payload = dict(self.extra_payload)
|
||||||
payload[collection_name or self.collection_name] = (
|
payload[collection_name or self.collection_name] = (
|
||||||
@ -597,11 +398,8 @@ class MappingDocument:
|
|||||||
)
|
)
|
||||||
payload["unfolding"] = self.describe_unfolding().to_payload()
|
payload["unfolding"] = self.describe_unfolding().to_payload()
|
||||||
payload["coordinate_unit"] = self._coordinate_unit_for_save()
|
payload["coordinate_unit"] = self._coordinate_unit_for_save()
|
||||||
payload["grid_step_mm"] = self.grid_step_mm
|
payload["background"] = self.background.to_payload()
|
||||||
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["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
|
return payload
|
||||||
|
|
||||||
def point_to_payload(self, point: LedPoint, *, id_value: int | None = None) -> dict[str, Any]:
|
def point_to_payload(self, point: LedPoint, *, id_value: int | None = None) -> dict[str, Any]:
|
||||||
@ -645,32 +443,6 @@ class MappingDocument:
|
|||||||
for line in self.lines
|
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:
|
def add_line(self) -> LedLine:
|
||||||
line_limit = self._line_limit()
|
line_limit = self._line_limit()
|
||||||
if len(self.lines) >= line_limit:
|
if len(self.lines) >= line_limit:
|
||||||
@ -697,37 +469,6 @@ class MappingDocument:
|
|||||||
self.points.append(point)
|
self.points.append(point)
|
||||||
return 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:
|
def update_point_position(self, point_id: int, x: float | None = None, y: float | None = None) -> None:
|
||||||
point = self.get_point(point_id)
|
point = self.get_point(point_id)
|
||||||
if x is not None:
|
if x is not None:
|
||||||
@ -768,7 +509,7 @@ class MappingDocument:
|
|||||||
point_id for point_id in point_ids if 0 <= point_id < len(self.points)
|
point_id for point_id in point_ids if 0 <= point_id < len(self.points)
|
||||||
])
|
])
|
||||||
self._detach_points_from_blocks(clean_point_ids)
|
self._detach_points_from_blocks(clean_point_ids)
|
||||||
block = LedBlock(len(line.blocks), f"Блок {len(line.blocks)}", clean_point_ids)
|
block = LedBlock(len(line.blocks) + 1, f"Блок {len(line.blocks) + 1}", clean_point_ids)
|
||||||
insert_index = len(line.blocks)
|
insert_index = len(line.blocks)
|
||||||
if after_block_id is not None:
|
if after_block_id is not None:
|
||||||
for index, existing_block in enumerate(line.blocks):
|
for index, existing_block in enumerate(line.blocks):
|
||||||
@ -862,36 +603,18 @@ class MappingDocument:
|
|||||||
return removed
|
return removed
|
||||||
|
|
||||||
def remove_block(self, line_id: int, block_id: int) -> int:
|
def remove_block(self, line_id: int, block_id: int) -> int:
|
||||||
return self.remove_blocks([(line_id, block_id)])
|
line = self.get_line(line_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] = []
|
removed_ids: list[int] = []
|
||||||
for line in self.lines:
|
|
||||||
kept_blocks: list[LedBlock] = []
|
kept_blocks: list[LedBlock] = []
|
||||||
for block in line.blocks:
|
for block in line.blocks:
|
||||||
if (line.id, block.id) in target_keys:
|
if block.id == block_id:
|
||||||
removed_ids.extend(block.point_ids)
|
removed_ids.extend(block.point_ids)
|
||||||
else:
|
else:
|
||||||
kept_blocks.append(block)
|
kept_blocks.append(block)
|
||||||
line.blocks = kept_blocks
|
line.blocks = kept_blocks
|
||||||
|
self._clear_points(removed_ids)
|
||||||
unique_removed_ids = unique_in_order(removed_ids)
|
|
||||||
self._clear_points(unique_removed_ids)
|
|
||||||
self.reindex_all()
|
self.reindex_all()
|
||||||
return len(unique_removed_ids)
|
return len(removed_ids)
|
||||||
|
|
||||||
def remove_line(self, line_id: int) -> int:
|
def remove_line(self, line_id: int) -> int:
|
||||||
removed_ids: list[int] = []
|
removed_ids: list[int] = []
|
||||||
@ -912,14 +635,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)
|
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.lines.clear()
|
||||||
self._clear_points([point.uid for point in self.points])
|
self._clear_points([point.uid for point in self.points])
|
||||||
self._next_line_id = 0
|
self._next_line_id = 1
|
||||||
self._next_block_id = 0
|
self._next_block_id = 1
|
||||||
return assigned
|
return assigned
|
||||||
|
|
||||||
def renumber_lines(self) -> None:
|
def renumber_lines(self) -> None:
|
||||||
self.order_lines_clockwise()
|
self.order_lines_clockwise()
|
||||||
id_map: dict[int, int] = {}
|
id_map: dict[int, int] = {}
|
||||||
for new_id, line in enumerate(self.lines):
|
for new_id, line in enumerate(self.lines, start=1):
|
||||||
old_id = line.id
|
old_id = line.id
|
||||||
id_map[old_id] = new_id
|
id_map[old_id] = new_id
|
||||||
line.id = new_id
|
line.id = new_id
|
||||||
@ -931,7 +654,7 @@ class MappingDocument:
|
|||||||
if point.line_id in id_map:
|
if point.line_id in id_map:
|
||||||
point.line_id = id_map[point.line_id]
|
point.line_id = id_map[point.line_id]
|
||||||
|
|
||||||
self._next_line_id = len(self.lines)
|
self._next_line_id = len(self.lines) + 1
|
||||||
|
|
||||||
def _validate_line_limit(self) -> None:
|
def _validate_line_limit(self) -> None:
|
||||||
line_limit = self._line_limit()
|
line_limit = self._line_limit()
|
||||||
@ -946,23 +669,7 @@ class MappingDocument:
|
|||||||
return "m"
|
return "m"
|
||||||
if self.coordinate_unit in {"mm", "millimeter", "millimeters"}:
|
if self.coordinate_unit in {"mm", "millimeter", "millimeters"}:
|
||||||
return "mm"
|
return "mm"
|
||||||
self._canonicalize_coordinate_unit()
|
return "m" if coordinate_display_scale(self.points) == 1000.0 else "mm"
|
||||||
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]:
|
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]
|
anchors = [point for point in self.points if point.uid != exclude_uid and not point.manual]
|
||||||
@ -970,7 +677,8 @@ class MappingDocument:
|
|||||||
anchors = [point for point in self.points if point.uid != exclude_uid]
|
anchors = [point for point in self.points if point.uid != exclude_uid]
|
||||||
if not anchors:
|
if not anchors:
|
||||||
return 0, 0
|
return 0, 0
|
||||||
step = self.grid_step_in_document_units()
|
scale = coordinate_display_scale(anchors)
|
||||||
|
step = MIN_NEIGHBOR_DISTANCE_MM / scale
|
||||||
row_axis, row_sign, col_axis, col_sign = infer_grid_coordinate_axes(anchors)
|
row_axis, row_sign, col_axis, col_sign = infer_grid_coordinate_axes(anchors)
|
||||||
source_row_value = x if row_axis == "x" else y
|
source_row_value = x if row_axis == "x" else y
|
||||||
source_col_value = x if col_axis == "x" else y
|
source_col_value = x if col_axis == "x" else y
|
||||||
@ -990,14 +698,7 @@ class MappingDocument:
|
|||||||
if reorder_leds:
|
if reorder_leds:
|
||||||
for block in line.blocks:
|
for block in line.blocks:
|
||||||
start_point_id = block.point_ids[0] if block.point_ids else None
|
start_point_id = block.point_ids[0] if block.point_ids else None
|
||||||
ordered_ids = [
|
ordered_ids = [point.uid for point in order_points_like_grid_svg(self._points_for_block(block), start_point_id)]
|
||||||
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:
|
if start_point_id is not None:
|
||||||
ordered_ids = rotate_ids_to_start(ordered_ids, start_point_id)
|
ordered_ids = rotate_ids_to_start(ordered_ids, start_point_id)
|
||||||
block.point_ids = ordered_ids
|
block.point_ids = ordered_ids
|
||||||
@ -1098,7 +799,7 @@ class MappingDocument:
|
|||||||
start_id = start_ids[0] if start_ids else point_ids[0]
|
start_id = start_ids[0] if start_ids else point_ids[0]
|
||||||
groups = self.route_groups_from_points(point_ids, start_id)
|
groups = self.route_groups_from_points(point_ids, start_id)
|
||||||
line.blocks.clear()
|
line.blocks.clear()
|
||||||
for block_number, group in enumerate(groups):
|
for block_number, group in enumerate(groups, start=1):
|
||||||
line.blocks.append(LedBlock(block_number, f"Блок {block_number}", group))
|
line.blocks.append(LedBlock(block_number, f"Блок {block_number}", group))
|
||||||
self.reindex_all()
|
self.reindex_all()
|
||||||
return len(groups)
|
return len(groups)
|
||||||
@ -1110,7 +811,7 @@ class MappingDocument:
|
|||||||
self._detach_points_from_blocks([point_id for group in groups for point_id in group])
|
self._detach_points_from_blocks([point_id for group in groups for point_id in group])
|
||||||
line = self.get_line(line_id)
|
line = self.get_line(line_id)
|
||||||
for group_ids in groups:
|
for group_ids in groups:
|
||||||
block_number = len(line.blocks)
|
block_number = len(line.blocks) + 1
|
||||||
block = LedBlock(block_number, f"Блок {block_number}", unique_in_order(group_ids))
|
block = LedBlock(block_number, f"Блок {block_number}", unique_in_order(group_ids))
|
||||||
line.blocks.append(block)
|
line.blocks.append(block)
|
||||||
self.order_blocks_clockwise(line_id, reorder_leds=False)
|
self.order_blocks_clockwise(line_id, reorder_leds=False)
|
||||||
@ -1124,11 +825,7 @@ class MappingDocument:
|
|||||||
]
|
]
|
||||||
return [
|
return [
|
||||||
[point.uid for point in route]
|
[point.uid for point in route]
|
||||||
for route in trace_route_paths_like_grid_svg(
|
for route in trace_route_paths_like_grid_svg(points, start_point_id)
|
||||||
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:
|
def add_clockwise_block_from_seed(self, line_id: int, start_point_id: int) -> LedBlock:
|
||||||
@ -1165,7 +862,7 @@ class MappingDocument:
|
|||||||
for point in self.points
|
for point in self.points
|
||||||
if point.uid == start_point_id or (point.line_id is None and point.block_id is None)
|
if point.uid == start_point_id or (point.line_id is None and point.block_id is None)
|
||||||
]
|
]
|
||||||
threshold = self.grid_step_in_document_units()
|
threshold = min_neighbor_distance_for_points(candidate_points)
|
||||||
adjacency = build_route_adjacency(candidate_points, threshold)
|
adjacency = build_route_adjacency(candidate_points, threshold)
|
||||||
route = trace_single_route_like_grid_svg(
|
route = trace_single_route_like_grid_svg(
|
||||||
candidate_points,
|
candidate_points,
|
||||||
@ -1190,10 +887,7 @@ class MappingDocument:
|
|||||||
]
|
]
|
||||||
return [
|
return [
|
||||||
[point.uid for point in route]
|
[point.uid for point in route]
|
||||||
for route in trace_route_paths_like_grid_svg(
|
for route in trace_route_paths_like_grid_svg(points)
|
||||||
points,
|
|
||||||
min_neighbor_distance=self.grid_step_in_document_units(),
|
|
||||||
)
|
|
||||||
]
|
]
|
||||||
|
|
||||||
def reindex_all(self) -> None:
|
def reindex_all(self) -> None:
|
||||||
@ -1220,7 +914,7 @@ class MappingDocument:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def renumber_blocks(line: LedLine) -> None:
|
def renumber_blocks(line: LedLine) -> None:
|
||||||
for block_number, block in enumerate(line.blocks):
|
for block_number, block in enumerate(line.blocks, start=1):
|
||||||
block.id = block_number
|
block.id = block_number
|
||||||
if not block.name or re.match(r"^(Блок|Block)\s+\d+$", block.name):
|
if not block.name or re.match(r"^(Блок|Block)\s+\d+$", block.name):
|
||||||
block.name = f"Блок {block_number}"
|
block.name = f"Блок {block_number}"
|
||||||
@ -1286,13 +980,6 @@ def _optional_float(value: Any) -> float | None:
|
|||||||
return 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]:
|
def infer_grid_coordinate_axes(points: list[LedPoint]) -> tuple[str, int, str, int]:
|
||||||
row_x = _axis_correlation(points, "row", "x")
|
row_x = _axis_correlation(points, "row", "x")
|
||||||
row_y = _axis_correlation(points, "row", "y")
|
row_y = _axis_correlation(points, "row", "y")
|
||||||
@ -1329,7 +1016,7 @@ def _correlation_sign(value: float) -> int:
|
|||||||
|
|
||||||
def default_color(line_id: int) -> str:
|
def default_color(line_id: int) -> str:
|
||||||
colors = ["#e11d48", "#2563eb", "#16a34a", "#f59e0b", "#7c3aed", "#0891b2"]
|
colors = ["#e11d48", "#2563eb", "#16a34a", "#f59e0b", "#7c3aed", "#0891b2"]
|
||||||
return colors[line_id % len(colors)]
|
return colors[(line_id - 1) % len(colors)]
|
||||||
|
|
||||||
|
|
||||||
def distance(a: LedPoint, b: LedPoint) -> float:
|
def distance(a: LedPoint, b: LedPoint) -> float:
|
||||||
@ -1393,10 +1080,7 @@ 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))
|
return sorted(groups, key=lambda group: min(point_sort_key_for_model(point) for point in group))
|
||||||
|
|
||||||
|
|
||||||
def route_groups_like_grid_svg(
|
def route_groups_like_grid_svg(points: list[LedPoint]) -> list[list[LedPoint]]:
|
||||||
points: list[LedPoint],
|
|
||||||
min_neighbor_distance: float | None = None,
|
|
||||||
) -> list[list[LedPoint]]:
|
|
||||||
points_by_cell: dict[tuple[int, int], list[LedPoint]] = {}
|
points_by_cell: dict[tuple[int, int], list[LedPoint]] = {}
|
||||||
for point in points:
|
for point in points:
|
||||||
points_by_cell.setdefault((point.row, point.col), []).append(point)
|
points_by_cell.setdefault((point.row, point.col), []).append(point)
|
||||||
@ -1405,7 +1089,7 @@ def route_groups_like_grid_svg(
|
|||||||
seen: set[tuple[int, int]] = set()
|
seen: set[tuple[int, int]] = set()
|
||||||
groups: list[list[LedPoint]] = []
|
groups: list[list[LedPoint]] = []
|
||||||
neighbors = ((1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (-1, 1), (-1, -1), (1, -1))
|
neighbors = ((1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (-1, 1), (-1, -1), (1, -1))
|
||||||
threshold = min_neighbor_distance or min_neighbor_distance_for_points(points)
|
threshold = min_neighbor_distance_for_points(points)
|
||||||
|
|
||||||
for start_cell in sorted(cells):
|
for start_cell in sorted(cells):
|
||||||
if start_cell in seen:
|
if start_cell in seen:
|
||||||
@ -1459,15 +1143,10 @@ def order_group_nearest(points: list[LedPoint]) -> list[LedPoint]:
|
|||||||
return ordered
|
return ordered
|
||||||
|
|
||||||
|
|
||||||
def order_points_like_grid_svg(
|
def order_points_like_grid_svg(points: list[LedPoint], start_point_id: int | None = None) -> list[LedPoint]:
|
||||||
points: list[LedPoint],
|
|
||||||
start_point_id: int | None = None,
|
|
||||||
*,
|
|
||||||
min_neighbor_distance: float | None = None,
|
|
||||||
) -> list[LedPoint]:
|
|
||||||
if len(points) <= 1:
|
if len(points) <= 1:
|
||||||
return points[:]
|
return points[:]
|
||||||
min_neighbor_distance = min_neighbor_distance or min_neighbor_distance_for_points(points)
|
min_neighbor_distance = min_neighbor_distance_for_points(points)
|
||||||
remaining = points[:]
|
remaining = points[:]
|
||||||
if start_point_id is None:
|
if start_point_id is None:
|
||||||
start_point = min(remaining, key=point_sort_key_for_model)
|
start_point = min(remaining, key=point_sort_key_for_model)
|
||||||
@ -1507,14 +1186,12 @@ def order_points_like_grid_svg(
|
|||||||
def trace_route_paths_like_grid_svg(
|
def trace_route_paths_like_grid_svg(
|
||||||
points: list[LedPoint],
|
points: list[LedPoint],
|
||||||
start_point_id: int | None = None,
|
start_point_id: int | None = None,
|
||||||
*,
|
|
||||||
min_neighbor_distance: float | None = None,
|
|
||||||
) -> list[list[LedPoint]]:
|
) -> list[list[LedPoint]]:
|
||||||
remaining = points[:]
|
remaining = points[:]
|
||||||
remaining_ids = {point.uid for point in remaining}
|
remaining_ids = {point.uid for point in remaining}
|
||||||
routes: list[list[LedPoint]] = []
|
routes: list[list[LedPoint]] = []
|
||||||
preferred_start_id = start_point_id
|
preferred_start_id = start_point_id
|
||||||
min_neighbor_distance = min_neighbor_distance or min_neighbor_distance_for_points(points)
|
min_neighbor_distance = min_neighbor_distance_for_points(points)
|
||||||
while remaining:
|
while remaining:
|
||||||
if preferred_start_id is not None:
|
if preferred_start_id is not None:
|
||||||
start_point = next((point for point in remaining if point.uid == preferred_start_id), None)
|
start_point = next((point for point in remaining if point.uid == preferred_start_id), None)
|
||||||
@ -2018,15 +1695,11 @@ def max_neighbor_distance(nominal_distance: float) -> float:
|
|||||||
|
|
||||||
|
|
||||||
def neighbor_tolerance(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)
|
return MAX_NEIGHBOR_TOLERANCE_MM / (MIN_NEIGHBOR_DISTANCE_MM / nominal_distance)
|
||||||
|
|
||||||
|
|
||||||
def min_neighbor_distance_for_points(points: list[LedPoint]) -> float:
|
def min_neighbor_distance_for_points(points: list[LedPoint]) -> float:
|
||||||
scale = coordinate_display_scale(points)
|
return MIN_NEIGHBOR_DISTANCE_MM / coordinate_display_scale(points)
|
||||||
return NominalGridDistance(MIN_NEIGHBOR_DISTANCE_MM / scale, MAX_NEIGHBOR_TOLERANCE_MM / scale)
|
|
||||||
|
|
||||||
|
|
||||||
def coordinate_display_scale(points: list[LedPoint]) -> float:
|
def coordinate_display_scale(points: list[LedPoint]) -> float:
|
||||||
|
|||||||
@ -11,20 +11,20 @@ from typing import Iterable
|
|||||||
try:
|
try:
|
||||||
from .models import (
|
from .models import (
|
||||||
LedPoint,
|
LedPoint,
|
||||||
MAX_NEIGHBOR_TOLERANCE_MM,
|
|
||||||
MappingDocument,
|
MappingDocument,
|
||||||
contour_groups_like_grid_svg as route_groups_like_grid_svg,
|
contour_groups_like_grid_svg as route_groups_like_grid_svg,
|
||||||
coordinate_display_scale,
|
coordinate_display_scale,
|
||||||
|
min_neighbor_distance_for_points,
|
||||||
normalize_route_clockwise,
|
normalize_route_clockwise,
|
||||||
order_points_like_grid_svg,
|
order_points_like_grid_svg,
|
||||||
)
|
)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from models import (
|
from models import (
|
||||||
LedPoint,
|
LedPoint,
|
||||||
MAX_NEIGHBOR_TOLERANCE_MM,
|
|
||||||
MappingDocument,
|
MappingDocument,
|
||||||
contour_groups_like_grid_svg as route_groups_like_grid_svg,
|
contour_groups_like_grid_svg as route_groups_like_grid_svg,
|
||||||
coordinate_display_scale,
|
coordinate_display_scale,
|
||||||
|
min_neighbor_distance_for_points,
|
||||||
normalize_route_clockwise,
|
normalize_route_clockwise,
|
||||||
order_points_like_grid_svg,
|
order_points_like_grid_svg,
|
||||||
)
|
)
|
||||||
@ -36,7 +36,6 @@ MAX_LEDS_PER_CELL = 3
|
|||||||
NUM_STRIPS = 4
|
NUM_STRIPS = 4
|
||||||
EMPTY_STRIP = "0xFF"
|
EMPTY_STRIP = "0xFF"
|
||||||
EMPTY_LED = "0xFFFF"
|
EMPTY_LED = "0xFFFF"
|
||||||
OBSOLETE_LEGACY_FILENAME = "virtual_matrix_legacy.ino.txt"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@ -70,20 +69,6 @@ class BuildResult:
|
|||||||
unassigned_count: int
|
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:
|
def sanitize_cpp_identifier(name: str) -> str:
|
||||||
# Transliteration dictionary for Russian Cyrillic
|
# Transliteration dictionary for Russian Cyrillic
|
||||||
cyrillic_translit = {
|
cyrillic_translit = {
|
||||||
@ -148,8 +133,10 @@ def generate_panel_mapping_from_document(
|
|||||||
raise ValueError("Нет назначенных LED: перед генерацией распределите точки по линиям.")
|
raise ValueError("Нет назначенных LED: перед генерацией распределите точки по линиям.")
|
||||||
cells = build_cells(mapping_points)
|
cells = build_cells(mapping_points)
|
||||||
block_count = sum(len(line.blocks) for line in document.lines)
|
block_count = sum(len(line.blocks) for line in document.lines)
|
||||||
|
|
||||||
generated_files = {
|
generated_files = {
|
||||||
"vm_map": output_dir / "vm_map.h",
|
"vm_map": output_dir / "vm_map.h",
|
||||||
|
"legacy_ino": output_dir / "virtual_matrix_legacy.ino.txt",
|
||||||
"ordered_json": output_dir / "ordered_circles.json",
|
"ordered_json": output_dir / "ordered_circles.json",
|
||||||
"validation_report": output_dir / "validation_report.txt",
|
"validation_report": output_dir / "validation_report.txt",
|
||||||
"readme": output_dir / "README.md",
|
"readme": output_dir / "README.md",
|
||||||
@ -164,10 +151,17 @@ def generate_panel_mapping_from_document(
|
|||||||
generate_grid=generate_grid,
|
generate_grid=generate_grid,
|
||||||
generate_blocks=generate_blocks,
|
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_ordered_json(generated_files["ordered_json"], points, document)
|
||||||
write_validation_report(generated_files["validation_report"], points, document.grid_step_mm)
|
write_validation_report(generated_files["validation_report"], points)
|
||||||
write_readme(generated_files["readme"])
|
write_readme(generated_files["readme"])
|
||||||
remove_obsolete_legacy_output(output_dir)
|
|
||||||
|
|
||||||
vm_rows, vm_cols, grid_rows, grid_cols = get_dimensions(points)
|
vm_rows, vm_cols, grid_rows, grid_cols = get_dimensions(points)
|
||||||
return BuildResult(
|
return BuildResult(
|
||||||
@ -188,14 +182,10 @@ def generate_panel_mapping_from_document(
|
|||||||
def normalize_document_points(document: MappingDocument) -> list[PanelLedPoint]:
|
def normalize_document_points(document: MappingDocument) -> list[PanelLedPoint]:
|
||||||
scale = _coordinate_scale_to_mm(document)
|
scale = _coordinate_scale_to_mm(document)
|
||||||
route_groups = route_groups_like_grid_svg(document.points)
|
route_groups = route_groups_like_grid_svg(document.points)
|
||||||
min_neighbor_distance = document.grid_step_in_document_units()
|
|
||||||
ordered: list[tuple[LedPoint, int]] = []
|
ordered: list[tuple[LedPoint, int]] = []
|
||||||
for route_group_index, route_group in enumerate(route_groups, start=1):
|
for route_group_index, route_group in enumerate(route_groups, start=1):
|
||||||
route = order_points_like_grid_svg(
|
route = order_points_like_grid_svg(route_group)
|
||||||
route_group,
|
route = normalize_route_clockwise(route, min_neighbor_distance_for_points(route))
|
||||||
min_neighbor_distance=min_neighbor_distance,
|
|
||||||
)
|
|
||||||
route = normalize_route_clockwise(route, min_neighbor_distance)
|
|
||||||
ordered.extend((point, route_group_index) for point in route)
|
ordered.extend((point, route_group_index) for point in route)
|
||||||
|
|
||||||
min_row = min(point.row for point, _group in ordered)
|
min_row = min(point.row for point, _group in ordered)
|
||||||
@ -205,7 +195,7 @@ def normalize_document_points(document: MappingDocument) -> list[PanelLedPoint]:
|
|||||||
generated_id=index,
|
generated_id=index,
|
||||||
source_id=point.source_id,
|
source_id=point.source_id,
|
||||||
input_index=point.uid,
|
input_index=point.uid,
|
||||||
strip=point.line_id,
|
strip=(point.line_id - 1) if point.line_id is not None else None,
|
||||||
led_id=point.line_index,
|
led_id=point.line_index,
|
||||||
x_mm=point.x * scale,
|
x_mm=point.x * scale,
|
||||||
y_mm=point.y * scale,
|
y_mm=point.y * scale,
|
||||||
@ -309,7 +299,7 @@ def write_vm_map_header(
|
|||||||
file.write(f"#define GRID_ROW_MAX {max_row}\n")
|
file.write(f"#define GRID_ROW_MAX {max_row}\n")
|
||||||
file.write(f"#define GRID_COL_MIN {min_col}\n")
|
file.write(f"#define GRID_COL_MIN {min_col}\n")
|
||||||
file.write(f"#define GRID_COL_MAX {max_col}\n")
|
file.write(f"#define GRID_COL_MAX {max_col}\n")
|
||||||
file.write(f"#define GRID_CELL_SIZE_MM {cpp_float_literal(document.grid_step_mm)}\n\n")
|
file.write(f"#define GRID_CELL_SIZE_MM {CELL_SIZE_MM:.1f}f\n\n")
|
||||||
file.write("// Source grid -> virtual matrix orientation used by this file.\n")
|
file.write("// Source grid -> virtual matrix orientation used by this file.\n")
|
||||||
file.write("// VM row is the source column flipped from right to left.\n")
|
file.write("// VM row is the source column flipped from right to left.\n")
|
||||||
file.write("// VM column is the source row shifted to zero-based coordinates.\n")
|
file.write("// VM column is the source row shifted to zero-based coordinates.\n")
|
||||||
@ -480,6 +470,242 @@ def write_vm_map_header(
|
|||||||
file.write("}\n")
|
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:
|
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_id_map = {point.input_index: point.generated_id for point in points}
|
||||||
point_payloads = []
|
point_payloads = []
|
||||||
@ -512,18 +738,14 @@ 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",
|
"source": str(document.path) if document.path is not None else "LED Mapper GUI",
|
||||||
"unit": OUTPUT_UNIT,
|
"unit": OUTPUT_UNIT,
|
||||||
"coordinate_unit": OUTPUT_UNIT,
|
"cell_size_mm": CELL_SIZE_MM,
|
||||||
"cell_size_mm": document.grid_step_mm,
|
|
||||||
"ordered_id_mode": "generated_uid",
|
"ordered_id_mode": "generated_uid",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def collect_validation(
|
def collect_validation(points: list[PanelLedPoint]) -> list[str]:
|
||||||
points: list[PanelLedPoint],
|
|
||||||
cell_size_mm: float = CELL_SIZE_MM,
|
|
||||||
) -> list[str]:
|
|
||||||
lines = []
|
lines = []
|
||||||
distance_violations = []
|
distance_violations = []
|
||||||
cell_violations = []
|
cell_violations = []
|
||||||
@ -531,9 +753,7 @@ def collect_validation(
|
|||||||
if current.route_group != next_point.route_group:
|
if current.route_group != next_point.route_group:
|
||||||
continue
|
continue
|
||||||
gap = math.hypot(current.x_mm - next_point.x_mm, current.y_mm - next_point.y_mm)
|
gap = math.hypot(current.x_mm - next_point.x_mm, current.y_mm - next_point.y_mm)
|
||||||
diagonal = abs(current.row - next_point.row) == 1 and abs(current.col - next_point.col) == 1
|
if gap > CELL_SIZE_MM + 1e-6:
|
||||||
distance_limit = cell_size_mm * (math.sqrt(2.0) if diagonal else 1.0) + MAX_NEIGHBOR_TOLERANCE_MM
|
|
||||||
if gap > distance_limit + 1e-6:
|
|
||||||
distance_violations.append((current, next_point, gap))
|
distance_violations.append((current, next_point, gap))
|
||||||
if abs(current.row - next_point.row) > 1 or abs(current.col - next_point.col) > 1:
|
if abs(current.row - next_point.row) > 1 or abs(current.col - next_point.col) > 1:
|
||||||
cell_violations.append((current, next_point))
|
cell_violations.append((current, next_point))
|
||||||
@ -543,7 +763,7 @@ def collect_validation(
|
|||||||
lines.append(f"LED count: {len(points)}")
|
lines.append(f"LED count: {len(points)}")
|
||||||
lines.append(f"Generated ids contiguous: {not duplicate_ids}")
|
lines.append(f"Generated ids contiguous: {not duplicate_ids}")
|
||||||
lines.append(f"Max LEDs per cell: {max_per_cell}")
|
lines.append(f"Max LEDs per cell: {max_per_cell}")
|
||||||
lines.append(f"Same-route distance violations beyond grid step + tolerance: {len(distance_violations)}")
|
lines.append(f"Same-route distance violations > {CELL_SIZE_MM:g} mm: {len(distance_violations)}")
|
||||||
lines.append(f"Same-route 8-neighbor cell violations: {len(cell_violations)}")
|
lines.append(f"Same-route 8-neighbor cell violations: {len(cell_violations)}")
|
||||||
if max_per_cell > MAX_LEDS_PER_CELL:
|
if max_per_cell > MAX_LEDS_PER_CELL:
|
||||||
lines.append(f"ERROR: max LEDs per cell exceeds {MAX_LEDS_PER_CELL}")
|
lines.append(f"ERROR: max LEDs per cell exceeds {MAX_LEDS_PER_CELL}")
|
||||||
@ -560,15 +780,11 @@ def collect_validation(
|
|||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def write_validation_report(
|
def write_validation_report(path: Path, points: list[PanelLedPoint]) -> None:
|
||||||
path: Path,
|
|
||||||
points: list[PanelLedPoint],
|
|
||||||
cell_size_mm: float = CELL_SIZE_MM,
|
|
||||||
) -> None:
|
|
||||||
lines = [
|
lines = [
|
||||||
"Panel mapping validation",
|
"Panel mapping validation",
|
||||||
"========================",
|
"========================",
|
||||||
*collect_validation(points, cell_size_mm),
|
*collect_validation(points),
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
path.write_text("\n".join(lines), encoding="utf-8")
|
path.write_text("\n".join(lines), encoding="utf-8")
|
||||||
@ -578,17 +794,13 @@ def write_readme(path: Path) -> None:
|
|||||||
path.write_text(
|
path.write_text(
|
||||||
"\n".join(
|
"\n".join(
|
||||||
[
|
[
|
||||||
"# Результат генерации Panel Mapping",
|
"# Panel Mapping Build",
|
||||||
"",
|
"",
|
||||||
"LED Mapper создал следующие файлы:",
|
"This folder contains files generated by the LED Mapper GUI:",
|
||||||
"- `vm_map.h` — маппинг виртуальной матрицы в формате PROGMEM.",
|
"- `vm_map.h` contains the PROGMEM virtual matrix mapping.",
|
||||||
"- `ordered_circles.json` — нормализованные LED и координаты матрицы.",
|
"- `virtual_matrix_legacy.ino.txt` keeps the readable `setLEDs(...)` mapping.",
|
||||||
"- `validation_report.txt` — результаты проверки маршрутов и ячеек.",
|
"- `ordered_circles.json` stores generated ids plus source row/col data.",
|
||||||
"- `README.md` — описание набора файлов.",
|
"- `validation_report.txt` records sequence and cell checks.",
|
||||||
"",
|
|
||||||
"Физический адрес ленты в `vm_map.h`: `strip = line_id`; нумерация начинается с `0`.",
|
|
||||||
"",
|
|
||||||
"Устаревший `virtual_matrix_legacy.ino.txt` больше не создаётся.",
|
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
|||||||
1598
qa_smoke.py
1598
qa_smoke.py
File diff suppressed because it is too large
Load Diff
51
settings.py
51
settings.py
@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import math
|
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@ -10,7 +9,6 @@ from PySide6.QtGui import QKeySequence
|
|||||||
|
|
||||||
|
|
||||||
SHORTCUT_DEFINITIONS: list[tuple[str, str, str]] = [
|
SHORTCUT_DEFINITIONS: list[tuple[str, str, str]] = [
|
||||||
("new", "Создать", "Ctrl+N"),
|
|
||||||
("open", "Открыть", "Ctrl+O"),
|
("open", "Открыть", "Ctrl+O"),
|
||||||
("save", "Сохранить", "Ctrl+S"),
|
("save", "Сохранить", "Ctrl+S"),
|
||||||
("save_as", "Сохранить как", "Ctrl+Shift+S"),
|
("save_as", "Сохранить как", "Ctrl+Shift+S"),
|
||||||
@ -18,14 +16,12 @@ SHORTCUT_DEFINITIONS: list[tuple[str, str, str]] = [
|
|||||||
("settings", "Параметры", "Ctrl+,"),
|
("settings", "Параметры", "Ctrl+,"),
|
||||||
("undo", "Отменить", "Ctrl+Z"),
|
("undo", "Отменить", "Ctrl+Z"),
|
||||||
("redo", "Повторить", "Ctrl+Y"),
|
("redo", "Повторить", "Ctrl+Y"),
|
||||||
("copy_leds", "Копировать светодиоды", "Ctrl+C"),
|
|
||||||
("paste_leds", "Вставить светодиоды", "Ctrl+V"),
|
|
||||||
("delete", "Удалить фиксацию", "Del"),
|
("delete", "Удалить фиксацию", "Del"),
|
||||||
("delete_points", "Удалить точки", "Ctrl+Del"),
|
("delete_points", "Удалить точки", "Ctrl+Del"),
|
||||||
("clear_all", "Удалить все", "Shift+Del"),
|
("clear_all", "Удалить все", "Shift+Del"),
|
||||||
("clear_selection", "Очистить выбор", "Esc"),
|
("clear_selection", "Очистить выбор", "Esc"),
|
||||||
("fit_view", "Показать всё", "Ctrl+0"),
|
("fit_view", "Показать всё", "Ctrl+0"),
|
||||||
("grid", "Сетка", "G"),
|
("grid", "Сетка 34 мм", "G"),
|
||||||
("direction_arrows", "Стрелки направления", "Ctrl+Shift+A"),
|
("direction_arrows", "Стрелки направления", "Ctrl+Shift+A"),
|
||||||
("led_ids", "Отображать id светодиодов", "I"),
|
("led_ids", "Отображать id светодиодов", "I"),
|
||||||
("background", "Подложка", "Ctrl+B"),
|
("background", "Подложка", "Ctrl+B"),
|
||||||
@ -44,17 +40,7 @@ SHORTCUT_DEFINITIONS: list[tuple[str, str, str]] = [
|
|||||||
("brush", "Кисть", "B"),
|
("brush", "Кисть", "B"),
|
||||||
]
|
]
|
||||||
DEFAULT_SHORTCUTS = {key: shortcut for key, _title, shortcut in SHORTCUT_DEFINITIONS}
|
DEFAULT_SHORTCUTS = {key: shortcut for key, _title, shortcut in SHORTCUT_DEFINITIONS}
|
||||||
SETTINGS_VERSION = 4
|
SETTINGS_VERSION = 2
|
||||||
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
|
@dataclass
|
||||||
@ -64,9 +50,6 @@ class AppSettings:
|
|||||||
show_grid_by_default: bool = False
|
show_grid_by_default: bool = False
|
||||||
show_direction_arrows_by_default: bool = True
|
show_direction_arrows_by_default: bool = True
|
||||||
show_led_ids_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
|
zoom_focus_pull: int = 35
|
||||||
pan_mode: str = "middle_space"
|
pan_mode: str = "middle_space"
|
||||||
instant_space_pan: bool = False
|
instant_space_pan: bool = False
|
||||||
@ -91,24 +74,6 @@ class AppSettings:
|
|||||||
if hasattr(settings, key):
|
if hasattr(settings, key):
|
||||||
setattr(settings, key, value)
|
setattr(settings, key, value)
|
||||||
settings.version = SETTINGS_VERSION
|
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.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)
|
settings.brush_radius = clamp_int(settings.brush_radius, 4, 80, cls.brush_radius)
|
||||||
if settings.theme not in {"system", "light", "dark"}:
|
if settings.theme not in {"system", "light", "dark"}:
|
||||||
@ -124,7 +89,7 @@ class AppSettings:
|
|||||||
settings.sidebar_inner_sizes = normalize_splitter_sizes(settings.sidebar_inner_sizes, [360, 420])
|
settings.sidebar_inner_sizes = normalize_splitter_sizes(settings.sidebar_inner_sizes, [360, 420])
|
||||||
settings.shortcuts = normalize_shortcuts(
|
settings.shortcuts = normalize_shortcuts(
|
||||||
settings.shortcuts,
|
settings.shortcuts,
|
||||||
fill_new_defaults=saved_version < SHORTCUT_DEFAULTS_MIGRATION_VERSION,
|
fill_new_defaults=saved_version < SETTINGS_VERSION,
|
||||||
)
|
)
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
@ -154,16 +119,6 @@ def clamp_int(value, minimum: int, maximum: int, fallback: int) -> int: # noqa:
|
|||||||
return max(minimum, min(maximum, number))
|
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
|
def normalize_splitter_sizes(value, fallback: list[int]) -> list[int]: # noqa: ANN001
|
||||||
if not isinstance(value, list) or len(value) != len(fallback):
|
if not isinstance(value, list) or len(value) != len(fallback):
|
||||||
return fallback[:]
|
return fallback[:]
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import replace
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtGui import QKeySequence
|
from PySide6.QtGui import QKeySequence
|
||||||
@ -11,7 +10,6 @@ from PySide6.QtWidgets import (
|
|||||||
QComboBox,
|
QComboBox,
|
||||||
QDialog,
|
QDialog,
|
||||||
QDialogButtonBox,
|
QDialogButtonBox,
|
||||||
QDoubleSpinBox,
|
|
||||||
QFileDialog,
|
QFileDialog,
|
||||||
QFormLayout,
|
QFormLayout,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
@ -28,38 +26,10 @@ from PySide6.QtWidgets import (
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from .localization import localize_dialog_buttons, show_warning
|
from .localization import localize_dialog_buttons, show_warning
|
||||||
from .settings import (
|
from .settings import DEFAULT_SHORTCUTS, SHORTCUT_DEFINITIONS, AppSettings
|
||||||
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:
|
except ImportError:
|
||||||
from localization import localize_dialog_buttons, show_warning
|
from localization import localize_dialog_buttons, show_warning
|
||||||
from settings import (
|
from settings import DEFAULT_SHORTCUTS, SHORTCUT_DEFINITIONS, AppSettings
|
||||||
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):
|
class SettingsDialog(QDialog):
|
||||||
@ -74,44 +44,12 @@ class SettingsDialog(QDialog):
|
|||||||
self.theme.addItem("Светлая", "light")
|
self.theme.addItem("Светлая", "light")
|
||||||
self.theme.addItem("Темная", "dark")
|
self.theme.addItem("Темная", "dark")
|
||||||
self.theme.setCurrentIndex(max(0, self.theme.findData(settings.theme)))
|
self.theme.setCurrentIndex(max(0, self.theme.findData(settings.theme)))
|
||||||
self.show_grid = QCheckBox("Показывать сетку при запуске")
|
self.show_grid = QCheckBox("Показывать сетку 34 мм при запуске")
|
||||||
self.show_grid.setChecked(settings.show_grid_by_default)
|
self.show_grid.setChecked(settings.show_grid_by_default)
|
||||||
self.show_arrows = QCheckBox("Показывать стрелки направления при запуске")
|
self.show_arrows = QCheckBox("Показывать стрелки направления при запуске")
|
||||||
self.show_arrows.setChecked(settings.show_direction_arrows_by_default)
|
self.show_arrows.setChecked(settings.show_direction_arrows_by_default)
|
||||||
self.show_led_ids = QCheckBox("Показывать id светодиодов при выборе")
|
self.show_led_ids = QCheckBox("Показывать id светодиодов при выборе")
|
||||||
self.show_led_ids.setChecked(settings.show_led_ids_by_default)
|
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 = QSpinBox()
|
||||||
self.zoom_pull.setRange(0, 100)
|
self.zoom_pull.setRange(0, 100)
|
||||||
self.zoom_pull.setSuffix(" %")
|
self.zoom_pull.setSuffix(" %")
|
||||||
@ -167,16 +105,11 @@ class SettingsDialog(QDialog):
|
|||||||
self.section_stack.addWidget(widget)
|
self.section_stack.addWidget(widget)
|
||||||
|
|
||||||
def settings(self) -> AppSettings:
|
def settings(self) -> AppSettings:
|
||||||
return replace(
|
return AppSettings(
|
||||||
self._settings,
|
|
||||||
version=SETTINGS_VERSION,
|
|
||||||
theme=str(self.theme.currentData()),
|
theme=str(self.theme.currentData()),
|
||||||
show_grid_by_default=self.show_grid.isChecked(),
|
show_grid_by_default=self.show_grid.isChecked(),
|
||||||
show_direction_arrows_by_default=self.show_arrows.isChecked(),
|
show_direction_arrows_by_default=self.show_arrows.isChecked(),
|
||||||
show_led_ids_by_default=self.show_led_ids.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(),
|
zoom_focus_pull=self.zoom_pull.value(),
|
||||||
pan_mode=str(self.pan_mode.currentData()),
|
pan_mode=str(self.pan_mode.currentData()),
|
||||||
instant_space_pan=self.instant_space_pan.isChecked(),
|
instant_space_pan=self.instant_space_pan.isChecked(),
|
||||||
@ -193,28 +126,10 @@ class SettingsDialog(QDialog):
|
|||||||
layout.addRow(self.show_grid)
|
layout.addRow(self.show_grid)
|
||||||
layout.addRow(self.show_arrows)
|
layout.addRow(self.show_arrows)
|
||||||
layout.addRow(self.show_led_ids)
|
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("Притяжение зума к курсору", self.zoom_pull)
|
||||||
layout.addRow(QLabel("0% оставляет центр спокойнее, 100% сильнее тянет схему к курсору."))
|
layout.addRow(QLabel("0% оставляет центр спокойнее, 100% сильнее тянет схему к курсору."))
|
||||||
return page
|
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:
|
def _build_behavior_tab(self) -> QWidget:
|
||||||
page = QWidget()
|
page = QWidget()
|
||||||
layout = QFormLayout(page)
|
layout = QFormLayout(page)
|
||||||
|
|||||||
@ -1,348 +0,0 @@
|
|||||||
"""Безопасная проверка и установка Windows-релизов LED Mapper."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import platform
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import uuid
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import BinaryIO, Callable
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
from urllib.request import Request, urlopen
|
|
||||||
|
|
||||||
try:
|
|
||||||
from .version import __version__
|
|
||||||
except ImportError:
|
|
||||||
from version import __version__
|
|
||||||
|
|
||||||
RELEASE_API_URL = "https://git.robotail.pro/api/v1/repos/Robotail/LED-mapper/releases/latest"
|
|
||||||
RELEASE_PAGE_URL = "https://git.robotail.pro/Robotail/LED-mapper/releases"
|
|
||||||
RELEASE_HOST = "git.robotail.pro"
|
|
||||||
READY_ENV = "LEDMAPPER_UPDATE_READY_FILE"
|
|
||||||
MAX_METADATA_BYTES = 1024 * 1024
|
|
||||||
MAX_DOWNLOAD_BYTES = 200 * 1024 * 1024
|
|
||||||
CHUNK_BYTES = 1024 * 1024
|
|
||||||
VERSION_RE = re.compile(r"v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)\Z")
|
|
||||||
EXECUTABLE_RE = re.compile(r"LEDMapper-(v\d+\.\d+\.\d+)\.exe\Z", re.IGNORECASE)
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateError(RuntimeError):
|
|
||||||
"""Проверка, загрузка или установка обновления не выполнена."""
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateCancelled(UpdateError):
|
|
||||||
"""Загрузка отменена пользователем."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class UpdateInfo:
|
|
||||||
tag: str
|
|
||||||
version: str
|
|
||||||
asset_name: str
|
|
||||||
download_url: str
|
|
||||||
size: int
|
|
||||||
|
|
||||||
|
|
||||||
def parse_version(value: str) -> tuple[int, int, int]:
|
|
||||||
match = VERSION_RE.fullmatch(value.strip())
|
|
||||||
if match is None:
|
|
||||||
raise UpdateError("Версия релиза должна иметь формат vX.Y.Z")
|
|
||||||
return tuple(int(part) for part in match.groups())
|
|
||||||
|
|
||||||
|
|
||||||
def current_version() -> str:
|
|
||||||
if getattr(sys, "frozen", False):
|
|
||||||
bundle_dir = getattr(sys, "_MEIPASS", None)
|
|
||||||
if bundle_dir is not None:
|
|
||||||
try:
|
|
||||||
bundled_version = (Path(bundle_dir) / "ledmapper-build-version.txt").read_text(encoding="utf-8").strip()
|
|
||||||
if VERSION_RE.fullmatch(bundled_version):
|
|
||||||
return bundled_version
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
match = EXECUTABLE_RE.fullmatch(Path(sys.executable).name)
|
|
||||||
if match is not None:
|
|
||||||
return match.group(1)
|
|
||||||
return __version__
|
|
||||||
|
|
||||||
|
|
||||||
def _trusted_url(value: str) -> bool:
|
|
||||||
parsed = urlparse(value)
|
|
||||||
return (
|
|
||||||
parsed.scheme == "https"
|
|
||||||
and parsed.hostname == RELEASE_HOST
|
|
||||||
and parsed.port in (None, 443)
|
|
||||||
and not parsed.username
|
|
||||||
and not parsed.password
|
|
||||||
and not parsed.fragment
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def check_for_update(
|
|
||||||
installed_version: str,
|
|
||||||
*,
|
|
||||||
opener: Callable[..., BinaryIO] | None = None,
|
|
||||||
timeout: float = 10.0,
|
|
||||||
) -> UpdateInfo | None:
|
|
||||||
"""Возвращает только новый релиз с ровно одним подходящим Windows EXE."""
|
|
||||||
installed = parse_version(installed_version)
|
|
||||||
if platform.system() != "Windows":
|
|
||||||
raise UpdateError("Автообновление доступно только в Windows")
|
|
||||||
request = Request(
|
|
||||||
RELEASE_API_URL,
|
|
||||||
headers={"Accept": "application/json", "User-Agent": "LEDMapper-Updater"},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with (opener or urlopen)(request, timeout=timeout) as response:
|
|
||||||
if not _trusted_url(response.geturl()):
|
|
||||||
raise UpdateError("Сервер релизов перенаправил запрос на другой сайт")
|
|
||||||
payload = response.read(MAX_METADATA_BYTES + 1)
|
|
||||||
if len(payload) > MAX_METADATA_BYTES:
|
|
||||||
raise UpdateError("Ответ сервера релизов слишком велик")
|
|
||||||
release = json.loads(payload)
|
|
||||||
except UpdateError:
|
|
||||||
raise
|
|
||||||
except (OSError, ValueError, TypeError) as exc:
|
|
||||||
raise UpdateError(f"Не удалось проверить обновления: {exc}") from exc
|
|
||||||
if not isinstance(release, dict) or release.get("draft") or release.get("prerelease"):
|
|
||||||
raise UpdateError("Сервер не вернул опубликованный стабильный релиз")
|
|
||||||
tag = release.get("tag_name")
|
|
||||||
if not isinstance(tag, str) or not tag.startswith("v"):
|
|
||||||
raise UpdateError("У релиза отсутствует корректный тег")
|
|
||||||
remote = parse_version(tag)
|
|
||||||
if remote <= installed:
|
|
||||||
return None
|
|
||||||
expected_name = f"LEDMapper-{tag}.exe"
|
|
||||||
assets = release.get("assets")
|
|
||||||
if not isinstance(assets, list):
|
|
||||||
raise UpdateError("У релиза отсутствует список артефактов")
|
|
||||||
matching = [asset for asset in assets if isinstance(asset, dict) and asset.get("name") == expected_name]
|
|
||||||
if len(matching) != 1:
|
|
||||||
raise UpdateError(f"Не найден Windows-артефакт {expected_name}")
|
|
||||||
asset = matching[0]
|
|
||||||
download_url = asset.get("browser_download_url")
|
|
||||||
expected_path = f"/Robotail/LED-mapper/releases/download/{tag}/{expected_name}"
|
|
||||||
if not isinstance(download_url, str) or not _trusted_url(download_url) or urlparse(download_url).path != expected_path:
|
|
||||||
raise UpdateError("Релиз содержит неожиданный адрес артефакта")
|
|
||||||
size = asset.get("size")
|
|
||||||
if isinstance(size, bool) or not isinstance(size, int) or not 0 < size <= MAX_DOWNLOAD_BYTES:
|
|
||||||
raise UpdateError("Релиз содержит недопустимый размер артефакта")
|
|
||||||
return UpdateInfo(tag, tag.removeprefix("v"), expected_name, download_url, size)
|
|
||||||
|
|
||||||
|
|
||||||
def _check_executable(path: Path) -> None:
|
|
||||||
with path.open("rb") as source:
|
|
||||||
header = source.read(64)
|
|
||||||
if len(header) < 64 or header[:2] != b"MZ":
|
|
||||||
raise UpdateError("Скачанный файл не является Windows EXE")
|
|
||||||
pe_offset = int.from_bytes(header[0x3C:0x40], "little")
|
|
||||||
if not 64 <= pe_offset <= 1024 * 1024:
|
|
||||||
raise UpdateError("Некорректный заголовок Windows EXE")
|
|
||||||
source.seek(pe_offset)
|
|
||||||
if source.read(4) != b"PE\x00\x00":
|
|
||||||
raise UpdateError("Некорректный заголовок Windows EXE")
|
|
||||||
if source.read(2) != b"\x64\x86":
|
|
||||||
raise UpdateError("Обновление не предназначено для Windows x64")
|
|
||||||
|
|
||||||
|
|
||||||
def download_update(
|
|
||||||
info: UpdateInfo,
|
|
||||||
directory: Path,
|
|
||||||
*,
|
|
||||||
opener: Callable[..., BinaryIO] | None = None,
|
|
||||||
progress: Callable[[int], None] | None = None,
|
|
||||||
cancelled: Callable[[], bool] | None = None,
|
|
||||||
timeout: float = 30.0,
|
|
||||||
) -> Path:
|
|
||||||
"""Скачивает артефакт в отдельный временный файл и проверяет размер/PE."""
|
|
||||||
target_dir = directory.resolve()
|
|
||||||
if not target_dir.is_dir():
|
|
||||||
raise UpdateError("Каталог приложения не найден")
|
|
||||||
if not os.access(target_dir, os.W_OK):
|
|
||||||
raise UpdateError("Нет прав на запись в каталог приложения")
|
|
||||||
if not _trusted_url(info.download_url) or urlparse(info.download_url).path != (
|
|
||||||
f"/Robotail/LED-mapper/releases/download/{info.tag}/{info.asset_name}"
|
|
||||||
):
|
|
||||||
raise UpdateError("Недопустимый адрес загрузки обновления")
|
|
||||||
expected_path = f"/Robotail/LED-mapper/releases/download/{info.tag}/{info.asset_name}"
|
|
||||||
path: Path | None = None
|
|
||||||
received = 0
|
|
||||||
request = Request(info.download_url, headers={"User-Agent": "LEDMapper-Updater"})
|
|
||||||
try:
|
|
||||||
with (opener or urlopen)(request, timeout=timeout) as response:
|
|
||||||
if not _trusted_url(response.geturl()):
|
|
||||||
raise UpdateError("Загрузка перенаправлена на другой сайт")
|
|
||||||
final_path = urlparse(response.geturl()).path
|
|
||||||
if final_path == "/user/login":
|
|
||||||
raise UpdateError("Release-артефакты недоступны без авторизации")
|
|
||||||
if final_path != expected_path:
|
|
||||||
raise UpdateError("Загрузка перенаправлена на неожиданный адрес")
|
|
||||||
content_length = getattr(response, "headers", {}).get("Content-Length")
|
|
||||||
if content_length is not None:
|
|
||||||
try:
|
|
||||||
declared_size = int(content_length)
|
|
||||||
except (TypeError, ValueError) as exc:
|
|
||||||
raise UpdateError("Сервер вернул некорректный размер обновления") from exc
|
|
||||||
if declared_size != info.size:
|
|
||||||
raise UpdateError("Размер обновления не совпадает с данными релиза")
|
|
||||||
with tempfile.NamedTemporaryFile(
|
|
||||||
mode="wb", dir=target_dir, prefix=".ledmapper-update-", suffix=".download", delete=False
|
|
||||||
) as output:
|
|
||||||
path = Path(output.name)
|
|
||||||
while True:
|
|
||||||
if cancelled is not None and cancelled():
|
|
||||||
raise UpdateCancelled("Загрузка обновления отменена")
|
|
||||||
chunk = response.read(CHUNK_BYTES)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
received += len(chunk)
|
|
||||||
if received > MAX_DOWNLOAD_BYTES or received > info.size:
|
|
||||||
raise UpdateError("Артефакт превышает объявленный размер")
|
|
||||||
output.write(chunk)
|
|
||||||
if progress is not None:
|
|
||||||
progress(min(received * 100 // info.size, 100))
|
|
||||||
output.flush()
|
|
||||||
os.fsync(output.fileno())
|
|
||||||
if received != info.size:
|
|
||||||
raise UpdateError("Артефакт скачан не полностью")
|
|
||||||
_check_executable(path)
|
|
||||||
if progress is not None:
|
|
||||||
progress(100)
|
|
||||||
return path
|
|
||||||
except (UpdateError, OSError) as exc:
|
|
||||||
if path is not None:
|
|
||||||
path.unlink(missing_ok=True)
|
|
||||||
if isinstance(exc, UpdateError):
|
|
||||||
raise
|
|
||||||
raise UpdateError(f"Не удалось скачать обновление: {exc}") from exc
|
|
||||||
|
|
||||||
|
|
||||||
def current_executable() -> Path:
|
|
||||||
if not getattr(sys, "frozen", False):
|
|
||||||
raise UpdateError("Установка обновления доступна только в собранном EXE")
|
|
||||||
return Path(sys.executable).resolve()
|
|
||||||
|
|
||||||
|
|
||||||
def signal_update_ready() -> None:
|
|
||||||
marker = os.environ.pop(READY_ENV, None)
|
|
||||||
if marker is None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
path = Path(marker).resolve()
|
|
||||||
if path.parent != current_executable().parent or not path.name.startswith(".ledmapper-ready-"):
|
|
||||||
return
|
|
||||||
path.write_text(str(os.getpid()), encoding="ascii")
|
|
||||||
except (OSError, UpdateError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _write_windows_updater(path: Path) -> None:
|
|
||||||
script = r'''param(
|
|
||||||
[int]$ApplicationProcessId,
|
|
||||||
[string]$Source,
|
|
||||||
[string]$OldTarget,
|
|
||||||
[string]$NewTarget,
|
|
||||||
[string]$Backup,
|
|
||||||
[string]$ReadyFile
|
|
||||||
)
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
$applicationDirectory = Split-Path -Parent $OldTarget
|
|
||||||
$errorLog = Join-Path $applicationDirectory "update-error.log"
|
|
||||||
$restarted = $null
|
|
||||||
$oldMoved = $false
|
|
||||||
$newMoved = $false
|
|
||||||
try {
|
|
||||||
Wait-Process -Id $ApplicationProcessId -ErrorAction SilentlyContinue
|
|
||||||
# Родительский процесс PyInstaller может ещё удерживать EXE при очистке.
|
|
||||||
$moveDeadline = (Get-Date).AddSeconds(30)
|
|
||||||
while (-not $oldMoved) {
|
|
||||||
try {
|
|
||||||
Move-Item -LiteralPath $OldTarget -Destination $Backup -ErrorAction Stop
|
|
||||||
$oldMoved = $true
|
|
||||||
} catch {
|
|
||||||
if ((Get-Date) -ge $moveDeadline) { throw }
|
|
||||||
Start-Sleep -Milliseconds 250
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Test-Path -LiteralPath $NewTarget) { throw "Целевой EXE уже существует" }
|
|
||||||
Move-Item -LiteralPath $Source -Destination $NewTarget -ErrorAction Stop
|
|
||||||
$newMoved = $true
|
|
||||||
$env:LEDMAPPER_UPDATE_READY_FILE = $ReadyFile
|
|
||||||
$env:PYINSTALLER_RESET_ENVIRONMENT = "1"
|
|
||||||
$restarted = Start-Process -FilePath $NewTarget -WorkingDirectory $applicationDirectory -PassThru
|
|
||||||
$deadline = (Get-Date).AddSeconds(60)
|
|
||||||
while (-not (Test-Path -LiteralPath $ReadyFile -PathType Leaf)) {
|
|
||||||
if ($restarted.HasExited) { throw "Новая версия завершилась до открытия окна" }
|
|
||||||
if ((Get-Date) -ge $deadline) { throw "Новая версия не подтвердила запуск" }
|
|
||||||
Start-Sleep -Milliseconds 250
|
|
||||||
}
|
|
||||||
if ($restarted.HasExited) { throw "Новая версия завершилась сразу после запуска" }
|
|
||||||
Remove-Item -LiteralPath $ReadyFile -Force -ErrorAction SilentlyContinue
|
|
||||||
Remove-Item -LiteralPath $Backup -Force -ErrorAction SilentlyContinue
|
|
||||||
Remove-Item -LiteralPath $MyInvocation.MyCommand.Path -Force -ErrorAction SilentlyContinue
|
|
||||||
} catch {
|
|
||||||
$failure = $_.ToString()
|
|
||||||
if ($restarted -and -not $restarted.HasExited) {
|
|
||||||
& taskkill.exe /PID $restarted.Id /T /F *> $null
|
|
||||||
}
|
|
||||||
if ($newMoved -and (Test-Path -LiteralPath $NewTarget)) {
|
|
||||||
Remove-Item -LiteralPath $NewTarget -Force -ErrorAction SilentlyContinue
|
|
||||||
}
|
|
||||||
if ($oldMoved -and (Test-Path -LiteralPath $Backup)) {
|
|
||||||
Move-Item -LiteralPath $Backup -Destination $OldTarget -Force -ErrorAction SilentlyContinue
|
|
||||||
}
|
|
||||||
Remove-Item -LiteralPath $ReadyFile -Force -ErrorAction SilentlyContinue
|
|
||||||
$failure | Out-File -LiteralPath $errorLog -Encoding utf8
|
|
||||||
Remove-Item -LiteralPath $MyInvocation.MyCommand.Path -Force -ErrorAction SilentlyContinue
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
'''
|
|
||||||
path.write_text(script, encoding="utf-8-sig", newline="\r\n")
|
|
||||||
|
|
||||||
|
|
||||||
def launch_update(downloaded: Path, info: UpdateInfo, *, process_id: int | None = None) -> None:
|
|
||||||
"""Запускает отдельный helper; замену EXE он делает после выхода приложения."""
|
|
||||||
old_target = current_executable()
|
|
||||||
source = downloaded.resolve()
|
|
||||||
if platform.system() != "Windows":
|
|
||||||
raise UpdateError("Установка обновления доступна только в Windows")
|
|
||||||
if not os.access(old_target.parent, os.W_OK):
|
|
||||||
raise UpdateError("Нет прав на замену приложения")
|
|
||||||
if source.parent != old_target.parent or not source.is_file() or not source.name.startswith(".ledmapper-update-"):
|
|
||||||
raise UpdateError("Скачанный файл должен находиться рядом с приложением")
|
|
||||||
if not EXECUTABLE_RE.fullmatch(info.asset_name):
|
|
||||||
raise UpdateError("Недопустимое имя Windows-артефакта")
|
|
||||||
new_target = old_target
|
|
||||||
if source.stat().st_size != info.size:
|
|
||||||
raise UpdateError("Размер скачанного артефакта изменился")
|
|
||||||
_check_executable(source)
|
|
||||||
token = uuid.uuid4().hex
|
|
||||||
script_path = old_target.parent / f".ledmapper-updater-{token}.ps1"
|
|
||||||
backup_path = old_target.parent / f".ledmapper-backup-{token}.exe"
|
|
||||||
ready_path = old_target.parent / f".ledmapper-ready-{token}"
|
|
||||||
try:
|
|
||||||
_write_windows_updater(script_path)
|
|
||||||
creation_flags = (
|
|
||||||
getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
||||||
| getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
||||||
)
|
|
||||||
subprocess.Popen(
|
|
||||||
[
|
|
||||||
"powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File",
|
|
||||||
str(script_path), str(process_id or os.getpid()), str(source), str(old_target),
|
|
||||||
str(new_target), str(backup_path), str(ready_path),
|
|
||||||
],
|
|
||||||
close_fds=True,
|
|
||||||
creationflags=creation_flags,
|
|
||||||
)
|
|
||||||
except (OSError, UpdateError) as exc:
|
|
||||||
script_path.unlink(missing_ok=True)
|
|
||||||
if isinstance(exc, UpdateError):
|
|
||||||
raise
|
|
||||||
raise UpdateError(f"Не удалось запустить установщик: {exc}") from exc
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
"""Текущая версия приложения; обновляется при публикации релиза."""
|
|
||||||
|
|
||||||
__version__ = "3.5.0"
|
|
||||||
Loading…
x
Reference in New Issue
Block a user