- Добавлена сохраняемая настройка размера текста индексов LED с мгновенным предпросмотром и сбросом. - Исправлено размещение быстрых кнопок в строке меню без перекрытия пунктов на узком окне. - Усилена проверка OTA-загрузок: права записи, конечный URL, авторизация, Content-Length и отдельная группа updater-процесса. - Добавлен единый загрузчик подложек с определением формата по содержимому, EXIF-ориентацией и преобразованием CMYK. - Расширен список форматов подложки и добавлена безопасная обработка крупных изображений. - Ошибки декодирования теперь показываются пользователю без замены текущей подложки. - Обновлены smoke-тесты, документация, PyInstaller-конфигурация и версия приложения до 3.4.0.
2101 lines
90 KiB
Python
2101 lines
90 KiB
Python
from __future__ import annotations
|
||
|
||
import math
|
||
|
||
from PySide6.QtCore import QPointF, QRectF, Qt, QTimer, Signal
|
||
from PySide6.QtGui import QColor, QBrush, QFont, QPainter, QPainterPath, QPen, QPixmap, QTransform
|
||
from PySide6.QtWidgets import (
|
||
QApplication,
|
||
QGraphicsEllipseItem,
|
||
QGraphicsItem,
|
||
QGraphicsLineItem,
|
||
QGraphicsPathItem,
|
||
QGraphicsPixmapItem,
|
||
QGraphicsRectItem,
|
||
QGraphicsScene,
|
||
QGraphicsSimpleTextItem,
|
||
QGraphicsView,
|
||
QMenu,
|
||
QScrollBar,
|
||
)
|
||
from shiboken6 import isValid
|
||
|
||
try:
|
||
from .background_image_loader import background_cache_key, load_background_image
|
||
from .models import BackgroundImage, LedLine, LedPoint, MappingDocument, coordinate_display_scale
|
||
except ImportError:
|
||
from background_image_loader import background_cache_key, load_background_image
|
||
from models import BackgroundImage, LedLine, LedPoint, MappingDocument, coordinate_display_scale
|
||
|
||
|
||
MAX_GRID_LINES = 800
|
||
|
||
|
||
class LedItem(QGraphicsEllipseItem):
|
||
def __init__(self, point: LedPoint, radius: float) -> None:
|
||
super().__init__(-radius, -radius, radius * 2, radius * 2)
|
||
self.point_id = point.uid
|
||
self.setFlag(QGraphicsEllipseItem.ItemIsSelectable, True)
|
||
self.setFlag(QGraphicsItem.ItemIgnoresTransformations, True)
|
||
self.setToolTip(self._tooltip(point))
|
||
self.setZValue(10)
|
||
|
||
@staticmethod
|
||
def _tooltip(point: LedPoint) -> str:
|
||
line = "-" if point.line_id is None else str(point.line_id)
|
||
block = "-" if point.block_id is None else str(point.block_id)
|
||
line_index = "-" if point.line_id is None or point.line_index is None else f"Л{point.line_id}: {point.line_index}"
|
||
block_index = "-" if point.block_index is None else str(point.block_index)
|
||
return (
|
||
f"Источник: {point.source_id}\n"
|
||
f"Сетка: ряд {point.row}, колонка {point.col}\n"
|
||
f"Линия: {line}, блок: {block}\n"
|
||
f"Индекс в линии: {line_index}\n"
|
||
f"Индекс в блоке: {block_index}"
|
||
)
|
||
|
||
|
||
class LedCanvas(QGraphicsView):
|
||
selectionChanged = Signal(list)
|
||
deleteSelectionRequested = Signal()
|
||
deletePointsRequested = Signal()
|
||
editPointRequested = Signal(int)
|
||
pointMoveFinished = Signal(int, float, float)
|
||
pointsMoveFinished = Signal(list)
|
||
autoBlockFromPointRequested = Signal(int)
|
||
counterclockwiseBlockFromPointRequested = Signal(int)
|
||
brushStrokeStarted = Signal(bool)
|
||
brushPointTouched = Signal(int)
|
||
brushStrokeFinished = Signal()
|
||
addLedRequested = Signal(float, float)
|
||
backgroundEditStarted = Signal()
|
||
backgroundChanged = Signal()
|
||
backgroundEditFinished = Signal(bool)
|
||
|
||
def __init__(self) -> None:
|
||
super().__init__()
|
||
self.setScene(QGraphicsScene(self))
|
||
self.setRenderHint(QPainter.Antialiasing)
|
||
self.setRenderHint(QPainter.SmoothPixmapTransform)
|
||
self.setMouseTracking(True)
|
||
self.viewport().setMouseTracking(True)
|
||
self.setDragMode(QGraphicsView.NoDrag)
|
||
self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
|
||
self.setResizeAnchor(QGraphicsView.AnchorUnderMouse)
|
||
self.document: MappingDocument | None = None
|
||
self.active_line_id: int | None = None
|
||
self.items_by_id: dict[int, LedItem] = {}
|
||
self.grid_items: list[QGraphicsLineItem] = []
|
||
self.connection_items: list[QGraphicsPathItem] = []
|
||
self.line_index_label_items: dict[int, QGraphicsSimpleTextItem] = {}
|
||
self._background_item: QGraphicsPixmapItem | None = None
|
||
self._background_outline: QGraphicsRectItem | None = None
|
||
self._background_handles: dict[str, QGraphicsRectItem] = {}
|
||
self._background_edit_mode = False
|
||
self._background_drag: dict | None = None
|
||
self._background_pixmap_cache_key: tuple[object, ...] | None = None
|
||
self._background_pixmap_cache: QPixmap | None = None
|
||
self._background_load_error = ""
|
||
self._background_loaded = False
|
||
self._direction_arrows_visible = True
|
||
self._line_index_labels_visible = True
|
||
self._line_index_font_size_px = 12
|
||
self._point_radius = 4.5
|
||
self._brush_radius = 18.0
|
||
self._zoom_focus_pull = 0.35
|
||
self._brush_zone_item: QGraphicsEllipseItem | None = None
|
||
self._grid_visible = False
|
||
self._grid_step = 34.0
|
||
self._raw_bounds: tuple[float, float, float, float] | None = None
|
||
self._content_scene_rect: QRectF | None = None
|
||
self._display_scale = 1.0
|
||
self._margin = 40.0
|
||
self._theme = "dark"
|
||
self._pan_mode = "middle_space"
|
||
self._instant_space_pan = False
|
||
self._space_pressed = False
|
||
self._is_panning = False
|
||
self._last_pan_pos = None
|
||
self._last_mouse_pos = None
|
||
self._point_drag: dict | None = None
|
||
self._rubber_origin: QPointF | None = None
|
||
self._rubber_item: QGraphicsRectItem | None = None
|
||
self._selection_bbox_item: QGraphicsRectItem | None = None
|
||
self._brush_enabled = False
|
||
self._add_led_enabled = False
|
||
self._edit_tool = "select"
|
||
self._is_drawing_leds = False
|
||
self._last_drawn_pos = None
|
||
self._is_brush_stroke = False
|
||
self._brush_touched_ids: set[int] = set()
|
||
self._brush_preview_ids: set[int] = set()
|
||
self._brush_excluded_point_ids: set[int] = set()
|
||
self._last_brush_viewport_pos = None
|
||
self._last_selected_ids: set[int] = set()
|
||
self._edit_mode_enabled = False
|
||
self._pending_edit_view_state: tuple[QTransform, QPointF, QRectF] | None = None
|
||
self.scene().setBackgroundBrush(QBrush(QColor("#2b2b2b")))
|
||
self.scene().selectionChanged.connect(self._emit_selection)
|
||
self.horizontalScrollBar().valueChanged.connect(self._on_grid_view_changed)
|
||
self.verticalScrollBar().valueChanged.connect(self._on_grid_view_changed)
|
||
self.setToolTip("Выделение рамкой: перетащите мышью. Масштаб: колесо. Del: снять фиксацию выбранных LED.")
|
||
|
||
def set_document(self, document: MappingDocument, *, reset_view: bool = True) -> None:
|
||
if document is not self.document:
|
||
self._stable_display_scale = None
|
||
self._stable_edit_bounds = None
|
||
self.document = document
|
||
self._grid_step = min(1000.0, max(1.0, float(getattr(document, "grid_step_mm", 34.0))))
|
||
self.rebuild(reset_view=reset_view)
|
||
|
||
def set_edit_tool(self, tool: str) -> None:
|
||
if tool not in ("select", "move", "draw"):
|
||
return
|
||
self._clear_rubber_band()
|
||
self._edit_tool = tool
|
||
if tool == "select":
|
||
self.setDragMode(QGraphicsView.NoDrag)
|
||
self._add_led_enabled = False
|
||
elif tool == "move":
|
||
self.setDragMode(QGraphicsView.NoDrag)
|
||
self._add_led_enabled = False
|
||
elif tool == "draw":
|
||
self.setDragMode(QGraphicsView.NoDrag)
|
||
self._add_led_enabled = True
|
||
self._apply_default_cursor()
|
||
|
||
def set_edit_mode_enabled(self, enabled: bool) -> None:
|
||
if self._edit_mode_enabled == enabled:
|
||
return
|
||
view_state = self._capture_view_state()
|
||
self._pending_edit_view_state = view_state
|
||
self._edit_mode_enabled = enabled
|
||
self._update_scene_rect()
|
||
self._restore_view_state(view_state)
|
||
self._refresh_grid()
|
||
self._position_line_index_labels()
|
||
self._update_selection_bbox()
|
||
# Scroll bars are laid out by Qt on the next event-loop pass. Restore
|
||
# once more afterwards so their appearance/disappearance cannot move
|
||
# the scheme by several screen pixels.
|
||
QTimer.singleShot(0, self._restore_pending_edit_view_state)
|
||
|
||
def _restore_pending_edit_view_state(self) -> None:
|
||
state = self._pending_edit_view_state
|
||
if state is None:
|
||
return
|
||
self._pending_edit_view_state = None
|
||
self._restore_view_state(state)
|
||
self._refresh_grid()
|
||
self._position_line_index_labels()
|
||
self._update_selection_bbox()
|
||
|
||
def set_active_line(self, line_id: int | None) -> None:
|
||
if self.active_line_id == line_id:
|
||
return
|
||
self.active_line_id = line_id
|
||
self.refresh_styles(redraw_connections=False)
|
||
|
||
def set_grid_visible(self, visible: bool) -> None:
|
||
self._grid_visible = visible
|
||
self._refresh_grid()
|
||
|
||
def set_grid_step_mm(self, step_mm: float) -> None:
|
||
self._grid_step = min(1000.0, max(1.0, float(step_mm)))
|
||
self._refresh_grid()
|
||
|
||
def _on_grid_view_changed(self, _value: int) -> None:
|
||
self._refresh_grid()
|
||
|
||
def set_point_diameter(self, diameter_px: int) -> None:
|
||
radius = max(0.5, min(32.0, float(diameter_px) / 2.0))
|
||
if abs(radius - self._point_radius) < 1e-9:
|
||
return
|
||
self._point_radius = radius
|
||
for item in self.items_by_id.values():
|
||
if item is not None and isValid(item):
|
||
item.setRect(-radius, -radius, radius * 2.0, radius * 2.0)
|
||
self._update_led_content_rect()
|
||
self._update_scene_rect()
|
||
self._update_selection_bbox()
|
||
self._position_line_index_labels()
|
||
if self._last_brush_viewport_pos is not None:
|
||
self._update_brush_preview(self._last_brush_viewport_pos)
|
||
|
||
def set_direction_arrows_visible(self, visible: bool) -> None:
|
||
if self._direction_arrows_visible == visible:
|
||
return
|
||
self._direction_arrows_visible = visible
|
||
self.refresh_styles(redraw_connections=True)
|
||
|
||
def direction_arrows_visible(self) -> bool:
|
||
return self._direction_arrows_visible
|
||
|
||
def set_line_index_labels_visible(self, visible: bool) -> None:
|
||
if self._line_index_labels_visible == visible:
|
||
return
|
||
self._line_index_labels_visible = visible
|
||
self._refresh_line_index_labels()
|
||
|
||
def line_index_labels_visible(self) -> bool:
|
||
return self._line_index_labels_visible
|
||
|
||
def set_line_index_font_size(self, font_size_px: int) -> None:
|
||
font_size = max(1, min(256, int(font_size_px)))
|
||
if self._line_index_font_size_px == font_size:
|
||
return
|
||
self._line_index_font_size_px = font_size
|
||
for label in self.line_index_label_items.values():
|
||
if label is not None and isValid(label):
|
||
font = label.font()
|
||
font.setPixelSize(font_size)
|
||
label.setFont(font)
|
||
self._position_line_index_labels()
|
||
|
||
def set_brush_radius(self, radius: int) -> None:
|
||
self._brush_radius = float(max(4, min(80, radius)))
|
||
if self._brush_zone_item is not None and isValid(self._brush_zone_item):
|
||
self.scene().removeItem(self._brush_zone_item)
|
||
self._brush_zone_item = None
|
||
if self._brush_enabled:
|
||
self._ensure_brush_zone()
|
||
if self._last_brush_viewport_pos is not None:
|
||
self._update_brush_zone(self._last_brush_viewport_pos)
|
||
|
||
def set_zoom_focus_pull(self, percent: int) -> None:
|
||
self._zoom_focus_pull = max(0.0, min(1.0, percent / 100.0))
|
||
|
||
def set_pan_mode(self, mode: str) -> None:
|
||
self._pan_mode = mode if mode in {"middle", "space", "middle_space"} else "middle_space"
|
||
if self._pan_mode == "middle" and self._space_pressed:
|
||
self._space_pressed = False
|
||
self._apply_default_cursor()
|
||
|
||
def set_instant_space_pan(self, enabled: bool) -> None:
|
||
self._instant_space_pan = enabled
|
||
if not enabled and self._is_panning and self._space_pressed:
|
||
self._finish_pan()
|
||
|
||
def set_theme(self, theme: str) -> None:
|
||
next_theme = "dark" if theme == "dark" else "light"
|
||
if self._theme == next_theme:
|
||
return
|
||
self._theme = next_theme
|
||
background = "#2b2b2b" if self._theme == "dark" else "#ffffff"
|
||
self.scene().setBackgroundBrush(QBrush(QColor(background)))
|
||
self._refresh_grid()
|
||
self.refresh_styles(redraw_connections=True)
|
||
|
||
def set_brush_enabled(self, enabled: bool) -> None:
|
||
self._brush_enabled = enabled
|
||
if enabled:
|
||
self._add_led_enabled = False
|
||
self._is_brush_stroke = False
|
||
self._clear_brush_state()
|
||
self.setDragMode(QGraphicsView.NoDrag)
|
||
if enabled:
|
||
self._ensure_brush_zone()
|
||
else:
|
||
self._hide_brush_zone()
|
||
self._apply_default_cursor()
|
||
|
||
def set_add_led_enabled(self, enabled: bool) -> None:
|
||
self._add_led_enabled = enabled
|
||
if enabled:
|
||
self.set_brush_enabled(False)
|
||
self.set_background_edit_mode(False)
|
||
self._apply_default_cursor()
|
||
|
||
def add_led_enabled(self) -> bool:
|
||
return self._add_led_enabled
|
||
|
||
def set_brush_excluded_point_ids(self, point_ids: set[int] | list[int]) -> None:
|
||
self._brush_excluded_point_ids = set(point_ids)
|
||
changed_ids = self._brush_preview_ids | self._brush_touched_ids
|
||
self._brush_preview_ids.difference_update(self._brush_excluded_point_ids)
|
||
self._refresh_brush_preview_styles(changed_ids)
|
||
|
||
def set_background_edit_mode(self, enabled: bool) -> None:
|
||
self._background_edit_mode = enabled
|
||
if enabled:
|
||
self._add_led_enabled = False
|
||
if enabled and self._brush_enabled:
|
||
self.set_brush_enabled(False)
|
||
if not enabled:
|
||
self._background_drag = None
|
||
self._update_background_handles()
|
||
self._apply_default_cursor()
|
||
|
||
def background_edit_mode(self) -> bool:
|
||
return self._background_edit_mode
|
||
|
||
def background_loaded(self) -> bool:
|
||
return self._background_loaded
|
||
|
||
def background_load_error(self) -> str:
|
||
return self._background_load_error
|
||
|
||
def validate_background(self, background: BackgroundImage) -> bool:
|
||
if not background.is_enabled():
|
||
self._background_load_error = "Не выбран файл изображения подложки."
|
||
return False
|
||
return not self._load_background_pixmap(background).isNull()
|
||
|
||
def set_background(self, background: BackgroundImage) -> None:
|
||
if self.document is None:
|
||
return
|
||
view_state = self._capture_view_state()
|
||
self.document.background = background
|
||
self._refresh_background()
|
||
self._update_scene_rect()
|
||
self._restore_view_state(view_state)
|
||
|
||
def clear_background(self) -> None:
|
||
if self.document is None:
|
||
return
|
||
view_state = self._capture_view_state()
|
||
self.document.background = BackgroundImage()
|
||
self._refresh_background()
|
||
self._update_scene_rect()
|
||
self._restore_view_state(view_state)
|
||
|
||
def reset_background_scale(self) -> None:
|
||
if self.document is None:
|
||
return
|
||
view_state = self._capture_view_state()
|
||
self.document.background.scale_x_percent = 100.0
|
||
self.document.background.scale_y_percent = 100.0
|
||
self._apply_background_geometry()
|
||
self._restore_view_state(view_state)
|
||
self.backgroundChanged.emit()
|
||
|
||
def set_background_visible(self, visible: bool) -> None:
|
||
if self.document is None or not self.document.background.is_enabled():
|
||
return
|
||
view_state = self._capture_view_state()
|
||
self.document.background.visible = visible
|
||
self._apply_background_geometry()
|
||
self._restore_view_state(view_state)
|
||
self.backgroundChanged.emit()
|
||
|
||
def rebuild(self, *, reset_view: bool = True) -> None:
|
||
old_transform = self.transform()
|
||
old_h_val = self.horizontalScrollBar().value()
|
||
old_v_val = self.verticalScrollBar().value()
|
||
old_stable_bounds = getattr(self, "_stable_edit_bounds", None)
|
||
|
||
# Anchor the camera center to a stable point to prevent viewport jump when bounds change
|
||
ref_uid = None
|
||
ref_offset = None
|
||
if self.document is not None and self.document.points:
|
||
ref_uid = self.document.points[0].uid
|
||
ref_item = self.items_by_id.get(ref_uid)
|
||
if ref_item is not None and isValid(ref_item):
|
||
old_center = self.mapToScene(self.viewport().rect().center())
|
||
ref_offset = old_center - ref_item.pos()
|
||
|
||
old_center = self.mapToScene(self.viewport().rect().center())
|
||
scene = self.scene()
|
||
scene.blockSignals(True)
|
||
self.setUpdatesEnabled(False)
|
||
try:
|
||
self.items_by_id.clear()
|
||
self.grid_items.clear()
|
||
self.connection_items.clear()
|
||
self.line_index_label_items.clear()
|
||
self._background_item = None
|
||
self._background_outline = None
|
||
self._background_handles.clear()
|
||
self._background_drag = None
|
||
self._brush_zone_item = None
|
||
self._brush_preview_ids.clear()
|
||
self._brush_touched_ids.clear()
|
||
self._last_selected_ids.clear()
|
||
scene.clear()
|
||
# Reset items invalidated by scene.clear()
|
||
self._rubber_item = None
|
||
self._rubber_origin = None
|
||
self._selection_bbox_item = None
|
||
finally:
|
||
scene.blockSignals(False)
|
||
self.setUpdatesEnabled(True)
|
||
self._raw_bounds = None
|
||
self._content_scene_rect = None
|
||
if self.document is None or not self.document.points:
|
||
self._refresh_background()
|
||
self._update_scene_rect()
|
||
return
|
||
|
||
# Keep display scale completely stable to prevent grid and camera jumps
|
||
if reset_view or getattr(self, "_stable_display_scale", None) is None:
|
||
self._stable_display_scale = coordinate_display_scale(self.document.points)
|
||
|
||
self._display_scale = self._stable_display_scale
|
||
|
||
xs = [point.x * self._display_scale for point in self.document.points]
|
||
ys = [point.y * self._display_scale for point in self.document.points]
|
||
min_x = min(xs) if xs else 0.0
|
||
max_x = max(xs) if xs else 100.0
|
||
min_y = min(ys) if ys else 0.0
|
||
max_y = max(ys) if ys else 100.0
|
||
|
||
if reset_view or getattr(self, "_stable_edit_bounds", None) is None:
|
||
self._stable_edit_bounds = (min_x, max_x, min_y, max_y)
|
||
|
||
self._raw_bounds = self._stable_edit_bounds
|
||
self._margin = max(self._point_radius * 4, 40)
|
||
|
||
for point in self.document.points:
|
||
item = LedItem(point, self._point_radius)
|
||
item.setPos(self._to_scene_point(point.x, point.y))
|
||
self.items_by_id[point.uid] = item
|
||
self.scene().addItem(item)
|
||
|
||
stable_min_x, stable_max_x, stable_min_y, stable_max_y = self._stable_edit_bounds
|
||
self._update_led_content_rect()
|
||
self._refresh_background()
|
||
self._update_scene_rect()
|
||
self._refresh_grid()
|
||
self.refresh_styles()
|
||
if reset_view:
|
||
self.fit_to_content()
|
||
else:
|
||
self.setTransform(old_transform)
|
||
# If bounds did not change, restore scrollbars directly to avoid floating point drift
|
||
if old_stable_bounds is not None and getattr(self, "_stable_edit_bounds", None) == old_stable_bounds:
|
||
self.horizontalScrollBar().setValue(old_h_val)
|
||
self.verticalScrollBar().setValue(old_v_val)
|
||
else:
|
||
if ref_uid is not None and ref_offset is not None:
|
||
new_item = self.items_by_id.get(ref_uid)
|
||
if new_item is not None and isValid(new_item):
|
||
self.centerOn(new_item.pos() + ref_offset)
|
||
else:
|
||
self.centerOn(old_center)
|
||
else:
|
||
self.centerOn(old_center)
|
||
|
||
def refresh_styles(self, redraw_connections: bool = True) -> None:
|
||
if self.document is None:
|
||
return
|
||
if redraw_connections:
|
||
self._clear_connections()
|
||
self._connection_segments: list[tuple[QPointF, QPointF]] = []
|
||
self._connection_segment_buckets: dict[tuple[int, int], list[tuple[QPointF, QPointF]]] = {}
|
||
line_by_id = {line.id: line for line in self.document.lines}
|
||
for point in self.document.points:
|
||
item = self.items_by_id.get(point.uid)
|
||
if item is None or not isValid(item):
|
||
continue
|
||
color = QColor("#94a3b8")
|
||
if point.line_id in line_by_id:
|
||
color = QColor(line_by_id[point.line_id].color)
|
||
if self.active_line_id is not None and point.line_id != self.active_line_id:
|
||
color.setAlphaF(0.45)
|
||
item.setBrush(color)
|
||
is_active_line_point = self.active_line_id is not None and point.line_id == self.active_line_id
|
||
try:
|
||
pen_color = QColor("#111827") if item.isSelected() else QColor("#ffffff")
|
||
item.setPen(QPen(pen_color, 2.0 if is_active_line_point else 1.2))
|
||
item.setToolTip(LedItem._tooltip(point))
|
||
except RuntimeError:
|
||
continue
|
||
self._last_selected_ids = {
|
||
item.point_id
|
||
for item in self.scene().selectedItems()
|
||
if isinstance(item, LedItem) and isValid(item)
|
||
}
|
||
if redraw_connections:
|
||
for line in self.document.lines:
|
||
self._draw_line_connections(line)
|
||
else:
|
||
self._restyle_connection_items()
|
||
self._refresh_brush_preview_styles(self._brush_preview_ids | self._brush_touched_ids)
|
||
self._refresh_line_index_labels()
|
||
|
||
def selected_point_ids(self) -> list[int]:
|
||
return sorted(
|
||
item.point_id
|
||
for item in self.scene().selectedItems()
|
||
if isinstance(item, LedItem) and isValid(item)
|
||
)
|
||
|
||
def document_point_positions(self) -> list[tuple[int, float, float]]:
|
||
"""Return every visible LED center converted back to document coordinates."""
|
||
positions: list[tuple[int, float, float]] = []
|
||
for point_id, item in sorted(self.items_by_id.items()):
|
||
if item is None or not isValid(item):
|
||
continue
|
||
document_point = self._document_point_from_scene(item.pos())
|
||
positions.append((point_id, document_point.x(), document_point.y()))
|
||
return positions
|
||
|
||
def select_points(self, point_ids: list[int]) -> None:
|
||
target_ids = set(point_ids)
|
||
if target_ids == self._last_selected_ids:
|
||
return
|
||
changed_ids = target_ids ^ self._last_selected_ids
|
||
scene = self.scene()
|
||
scene.blockSignals(True)
|
||
self.setUpdatesEnabled(False)
|
||
try:
|
||
for point_id in changed_ids:
|
||
item = self.items_by_id.get(point_id)
|
||
if not isValid(item):
|
||
continue
|
||
should_select = item.point_id in target_ids
|
||
if item.isSelected() != should_select:
|
||
item.setSelected(should_select)
|
||
finally:
|
||
self.setUpdatesEnabled(True)
|
||
scene.blockSignals(False)
|
||
self._emit_selection()
|
||
|
||
def _refresh_background(self) -> None:
|
||
self._remove_background_items()
|
||
if self.document is None or not self.document.background.is_enabled():
|
||
return
|
||
pixmap = self._load_background_pixmap(self.document.background)
|
||
if pixmap.isNull():
|
||
self._background_loaded = False
|
||
return
|
||
self._background_loaded = True
|
||
self._background_item = self.scene().addPixmap(pixmap)
|
||
self._background_item.setTransformationMode(Qt.SmoothTransformation)
|
||
self._background_item.setZValue(-30)
|
||
self._background_item.setAcceptedMouseButtons(Qt.NoButton)
|
||
self._apply_background_geometry()
|
||
|
||
def _remove_background_items(self) -> None:
|
||
for item in [self._background_item, self._background_outline, *self._background_handles.values()]:
|
||
if item is not None and isValid(item):
|
||
self.scene().removeItem(item)
|
||
self._background_item = None
|
||
self._background_outline = None
|
||
self._background_handles.clear()
|
||
self._background_loaded = False
|
||
|
||
def _load_background_pixmap(self, background: BackgroundImage) -> QPixmap:
|
||
document_path = self.document.path if self.document is not None else None
|
||
cache_key = background_cache_key(background, document_path)
|
||
if self._background_pixmap_cache_key == cache_key and self._background_pixmap_cache is not None:
|
||
return self._background_pixmap_cache
|
||
result = load_background_image(background, document_path)
|
||
self._background_load_error = result.error
|
||
pixmap = QPixmap.fromImage(result.image) if result.loaded else QPixmap()
|
||
self._background_pixmap_cache_key = cache_key
|
||
self._background_pixmap_cache = pixmap
|
||
return pixmap
|
||
|
||
def _apply_background_geometry(self) -> None:
|
||
if self.document is None or self._background_item is None or not isValid(self._background_item):
|
||
self._update_background_handles()
|
||
return
|
||
view_state = self._capture_view_state()
|
||
background = self.document.background
|
||
origin = self._background_origin_scene_point()
|
||
scale_x = max(1.0, background.scale_x_percent) / 100.0
|
||
scale_y = max(1.0, background.scale_y_percent) / 100.0
|
||
source_rect = self._background_item.boundingRect()
|
||
center_x = origin.x() + background.offset_x
|
||
center_y = origin.y() + background.offset_y
|
||
self._background_item.setVisible(background.visible)
|
||
self._background_item.setOpacity(max(0.0, min(1.0, background.opacity_percent / 100.0)))
|
||
self._background_item.setPos(
|
||
center_x - source_rect.width() * scale_x / 2.0,
|
||
center_y - source_rect.height() * scale_y / 2.0,
|
||
)
|
||
self._background_item.setScale(1.0)
|
||
self._background_item.setTransformOriginPoint(0, 0)
|
||
self._background_item.setTransform(
|
||
QTransform.fromScale(
|
||
scale_x,
|
||
scale_y,
|
||
)
|
||
)
|
||
self._update_background_handles()
|
||
self._update_scene_rect()
|
||
self._restore_view_state(view_state)
|
||
|
||
def _background_scene_rect(self) -> QRectF | None:
|
||
if self._background_item is None or not isValid(self._background_item):
|
||
return None
|
||
if self.document is not None and not self.document.background.visible:
|
||
return None
|
||
return self._background_item.sceneBoundingRect()
|
||
|
||
def _update_scene_rect(self) -> None:
|
||
rect = self._content_scene_rect
|
||
background_rect = self._background_scene_rect()
|
||
if rect is None:
|
||
rect = background_rect
|
||
elif background_rect is not None:
|
||
rect = rect.united(background_rect)
|
||
if rect is None:
|
||
rect = QRectF(0, 0, 1, 1)
|
||
if self._edit_mode_enabled:
|
||
# Expand the scene viewport by 8000 units in edit mode so users can draw and pan without bounds
|
||
rect = rect.adjusted(-8000, -8000, 8000, 8000)
|
||
self.scene().setSceneRect(rect)
|
||
|
||
def _update_background_handles(self) -> None:
|
||
rect = self._background_scene_rect()
|
||
if not self._background_edit_mode or rect is None:
|
||
for item in [self._background_outline, *self._background_handles.values()]:
|
||
if item is not None and isValid(item):
|
||
self.scene().removeItem(item)
|
||
self._background_outline = None
|
||
self._background_handles.clear()
|
||
return
|
||
pen = QPen(QColor("#fbbf24"), 1.2)
|
||
pen.setCosmetic(True)
|
||
if self._background_outline is None or not isValid(self._background_outline):
|
||
self._background_outline = self.scene().addRect(rect, pen)
|
||
self._background_outline.setZValue(60)
|
||
else:
|
||
self._background_outline.setRect(rect)
|
||
self._background_outline.setPen(pen)
|
||
handle_positions = {
|
||
"nw": rect.topLeft(),
|
||
"n": QPointF(rect.center().x(), rect.top()),
|
||
"ne": rect.topRight(),
|
||
"e": QPointF(rect.right(), rect.center().y()),
|
||
"se": rect.bottomRight(),
|
||
"s": QPointF(rect.center().x(), rect.bottom()),
|
||
"sw": rect.bottomLeft(),
|
||
"w": QPointF(rect.left(), rect.center().y()),
|
||
}
|
||
for name, point in handle_positions.items():
|
||
handle = self._background_handles.get(name)
|
||
if handle is None or not isValid(handle):
|
||
handle = QGraphicsRectItem(-4, -4, 8, 8)
|
||
handle.setBrush(QColor("#fbbf24"))
|
||
handle.setPen(QPen(QColor("#111827"), 1.0))
|
||
handle.setFlag(QGraphicsItem.ItemIgnoresTransformations, True)
|
||
handle.setZValue(61)
|
||
handle.setAcceptedMouseButtons(Qt.NoButton)
|
||
self.scene().addItem(handle)
|
||
self._background_handles[name] = handle
|
||
handle.setPos(point)
|
||
|
||
def fit_to_content(self) -> None:
|
||
self._update_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()
|
||
if rect is None:
|
||
rect = background_rect
|
||
elif background_rect is not None:
|
||
rect = rect.united(background_rect)
|
||
if rect is None or rect.isEmpty():
|
||
rect = self.sceneRect()
|
||
|
||
self.fitInView(rect, Qt.KeepAspectRatio)
|
||
self._refresh_grid()
|
||
self._position_line_index_labels()
|
||
self._update_selection_bbox()
|
||
|
||
def resizeEvent(self, event) -> None: # noqa: ANN001
|
||
super().resizeEvent(event)
|
||
self._refresh_grid()
|
||
|
||
def _capture_view_state(self) -> tuple[QTransform, QPointF, QRectF] | None:
|
||
if self.viewport().rect().isEmpty():
|
||
return None
|
||
return (
|
||
self.transform(),
|
||
self.mapToScene(self.viewport().rect().center()),
|
||
self.mapToScene(self.viewport().rect()).boundingRect(),
|
||
)
|
||
|
||
def _restore_view_state(self, state: tuple[QTransform, QPointF, QRectF] | None) -> None:
|
||
if state is None:
|
||
return
|
||
transform, _center, visible_rect = state
|
||
self.setTransform(transform)
|
||
current_visible_rect = self.mapToScene(self.viewport().rect()).boundingRect()
|
||
desired_visible_rect = QRectF(
|
||
visible_rect.topLeft(),
|
||
current_visible_rect.size(),
|
||
)
|
||
scene_rect = self.scene().sceneRect()
|
||
if not scene_rect.contains(desired_visible_rect):
|
||
self.scene().setSceneRect(scene_rect.united(desired_visible_rect))
|
||
self.centerOn(desired_visible_rect.center())
|
||
|
||
def wheelEvent(self, event) -> None: # noqa: ANN001
|
||
if self._background_drag is not None:
|
||
event.ignore()
|
||
return
|
||
factor = 1.15 if event.angleDelta().y() > 0 else 1 / 1.15
|
||
viewport_pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
||
self._zoom_at_viewport_pos(viewport_pos, factor)
|
||
self._refresh_grid()
|
||
if self._brush_enabled:
|
||
self._update_brush_zone(viewport_pos)
|
||
self._position_line_index_labels()
|
||
self._update_selection_bbox()
|
||
event.accept()
|
||
|
||
def _zoom_at_viewport_pos(self, viewport_pos, factor: float) -> None: # noqa: ANN001
|
||
old_anchor = self.transformationAnchor()
|
||
self.setTransformationAnchor(QGraphicsView.NoAnchor)
|
||
focus_scene_pos = self.mapToScene(viewport_pos)
|
||
current_center = self.mapToScene(self.viewport().rect().center())
|
||
self.scale(factor, factor)
|
||
self._ensure_zoom_padding()
|
||
pull_strength = self._zoom_focus_pull
|
||
target_center = QPointF(
|
||
current_center.x() + (focus_scene_pos.x() - current_center.x()) * pull_strength,
|
||
current_center.y() + (focus_scene_pos.y() - current_center.y()) * pull_strength,
|
||
)
|
||
self.centerOn(target_center)
|
||
self.setTransformationAnchor(old_anchor)
|
||
|
||
def _ensure_zoom_padding(self) -> None:
|
||
if self.viewport().rect().isEmpty():
|
||
return
|
||
self._update_scene_rect()
|
||
base_rect = self.scene().sceneRect()
|
||
visible_scene_rect = self.mapToScene(self.viewport().rect()).boundingRect()
|
||
pad_x = visible_scene_rect.width() * 0.5
|
||
pad_y = visible_scene_rect.height() * 0.5
|
||
self.scene().setSceneRect(base_rect.adjusted(-pad_x, -pad_y, pad_x, pad_y))
|
||
|
||
def keyPressEvent(self, event) -> None: # noqa: ANN001
|
||
if event.key() == Qt.Key_Delete:
|
||
self.deleteSelectionRequested.emit()
|
||
event.accept()
|
||
return
|
||
if event.key() == Qt.Key_Space and not event.isAutoRepeat() and self._can_pan_with_space():
|
||
self._space_pressed = True
|
||
if self._instant_space_pan and self._last_mouse_pos is not None:
|
||
self._start_pan(self._last_mouse_pos)
|
||
self._apply_default_cursor()
|
||
event.accept()
|
||
return
|
||
super().keyPressEvent(event)
|
||
|
||
def keyReleaseEvent(self, event) -> None: # noqa: ANN001
|
||
if event.key() == Qt.Key_Space and not event.isAutoRepeat():
|
||
self._space_pressed = False
|
||
if self._is_panning:
|
||
self._is_panning = False
|
||
self._last_pan_pos = None
|
||
self._apply_default_cursor()
|
||
event.accept()
|
||
return
|
||
super().keyReleaseEvent(event)
|
||
|
||
def mousePressEvent(self, event) -> None: # noqa: ANN001
|
||
self._last_mouse_pos = event.pos()
|
||
if event.button() == Qt.RightButton:
|
||
event.accept()
|
||
return
|
||
if self._should_start_pan(event.button()):
|
||
self._start_pan(event.pos())
|
||
event.accept()
|
||
return
|
||
is_move_tool = getattr(self, "_edit_tool", "select") == "move"
|
||
has_alt = bool(event.modifiers() & Qt.AltModifier)
|
||
if event.button() == Qt.LeftButton and (has_alt or is_move_tool) and self._start_point_drag(event.pos()):
|
||
event.accept()
|
||
return
|
||
if self._background_edit_mode and event.button() == Qt.LeftButton and self._start_background_edit(event.pos()):
|
||
event.accept()
|
||
return
|
||
if self._add_led_enabled and event.button() == Qt.LeftButton:
|
||
if getattr(self, "_edit_tool", "select") == "draw":
|
||
self._is_drawing_leds = True
|
||
scene_pos = self.mapToScene(event.pos())
|
||
self._last_drawn_pos = scene_pos
|
||
point = self._document_point_from_scene(scene_pos)
|
||
self.addLedRequested.emit(point.x(), point.y())
|
||
else:
|
||
point = self._document_point_from_scene(self.mapToScene(event.pos()))
|
||
self.addLedRequested.emit(point.x(), point.y())
|
||
event.accept()
|
||
return
|
||
if self._brush_enabled and event.button() == Qt.LeftButton:
|
||
self._is_brush_stroke = True
|
||
self._brush_touched_ids.clear()
|
||
self.brushStrokeStarted.emit(bool(event.modifiers() & Qt.ShiftModifier))
|
||
self._update_brush_zone(event.pos())
|
||
self._touch_brush_point(event.pos())
|
||
event.accept()
|
||
return
|
||
# Custom rubber band selection in select mode
|
||
if event.button() == Qt.LeftButton and self._edit_tool == "select" and not self._brush_enabled:
|
||
item = self._led_item_at(event.pos())
|
||
if item is None:
|
||
# Click on empty space — start rubber band
|
||
self._start_rubber_band(self.mapToScene(event.pos()))
|
||
event.accept()
|
||
return
|
||
super().mousePressEvent(event)
|
||
|
||
def mouseMoveEvent(self, event) -> None: # noqa: ANN001
|
||
previous_mouse_pos = self._last_mouse_pos
|
||
self._last_mouse_pos = event.pos()
|
||
if (
|
||
self._instant_space_pan
|
||
and self._space_pressed
|
||
and self._can_pan_with_space()
|
||
and not self._is_panning
|
||
and previous_mouse_pos is not None
|
||
):
|
||
self._start_pan(previous_mouse_pos)
|
||
if self._is_panning and self._last_pan_pos is not None:
|
||
delta = event.pos() - self._last_pan_pos
|
||
self._last_pan_pos = event.pos()
|
||
self._move_scrollbar(self.horizontalScrollBar(), delta.x())
|
||
self._move_scrollbar(self.verticalScrollBar(), delta.y())
|
||
event.accept()
|
||
return
|
||
if self._background_drag is not None:
|
||
self._move_background_edit(event.pos())
|
||
event.accept()
|
||
return
|
||
if self._point_drag is not None:
|
||
self._move_point_drag(event.pos())
|
||
event.accept()
|
||
return
|
||
if getattr(self, "_is_drawing_leds", False) and event.buttons() & Qt.LeftButton:
|
||
scene_pos = self.mapToScene(event.pos())
|
||
if getattr(self, "_last_drawn_pos", None) is not None:
|
||
dx = scene_pos.x() - self._last_drawn_pos.x()
|
||
dy = scene_pos.y() - self._last_drawn_pos.y()
|
||
dist = math.hypot(dx, dy)
|
||
if dist >= self._grid_step:
|
||
point = self._document_point_from_scene(scene_pos)
|
||
self.addLedRequested.emit(point.x(), point.y())
|
||
self._last_drawn_pos = scene_pos
|
||
event.accept()
|
||
return
|
||
if self._brush_enabled and self._is_brush_stroke:
|
||
self._update_brush_zone(event.pos())
|
||
self._touch_brush_point(event.pos())
|
||
event.accept()
|
||
return
|
||
if self._brush_enabled:
|
||
self._update_brush_zone(event.pos())
|
||
event.accept()
|
||
return
|
||
# Update custom rubber band
|
||
if self._rubber_origin is not None and event.buttons() & Qt.LeftButton:
|
||
self._update_rubber_band(self.mapToScene(event.pos()))
|
||
event.accept()
|
||
return
|
||
super().mouseMoveEvent(event)
|
||
|
||
def mouseReleaseEvent(self, event) -> None: # noqa: ANN001
|
||
self._last_mouse_pos = event.pos()
|
||
if event.button() == Qt.RightButton:
|
||
event.accept()
|
||
return
|
||
if event.button() == Qt.LeftButton and getattr(self, "_is_drawing_leds", False):
|
||
self._is_drawing_leds = False
|
||
self._last_drawn_pos = None
|
||
event.accept()
|
||
return
|
||
if self._should_finish_pan(event.button()):
|
||
self._finish_pan()
|
||
event.accept()
|
||
return
|
||
if event.button() == Qt.LeftButton and self._background_drag is not None:
|
||
self._finish_background_edit()
|
||
event.accept()
|
||
return
|
||
if event.button() == Qt.LeftButton and self._point_drag is not None:
|
||
self._finish_point_drag(event.pos())
|
||
event.accept()
|
||
return
|
||
if self._brush_enabled and event.button() == Qt.LeftButton and self._is_brush_stroke:
|
||
self._update_brush_zone(event.pos())
|
||
self._touch_brush_point(event.pos())
|
||
self._is_brush_stroke = False
|
||
self._clear_brush_state()
|
||
self.brushStrokeFinished.emit()
|
||
event.accept()
|
||
return
|
||
# Finish custom rubber band selection
|
||
if event.button() == Qt.LeftButton and self._rubber_origin is not None:
|
||
self._finish_rubber_band(self.mapToScene(event.pos()), event.modifiers())
|
||
event.accept()
|
||
return
|
||
super().mouseReleaseEvent(event)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Custom rubber-band selection (replaces Qt's built-in RubberBandDrag
|
||
# which cannot be reliably styled in Qt6/PySide6).
|
||
# ------------------------------------------------------------------
|
||
|
||
def _start_rubber_band(self, scene_pos: QPointF) -> None:
|
||
self._clear_rubber_band()
|
||
self._rubber_origin = scene_pos # ← сохраняем origin здесь, после clear
|
||
item = QGraphicsRectItem(QRectF(scene_pos, scene_pos))
|
||
item.setZValue(1000)
|
||
item.setPen(QPen(QColor(59, 130, 246, 220), 1.5, Qt.SolidLine))
|
||
item.setBrush(QBrush(QColor(59, 130, 246, 60)))
|
||
item.setFlag(QGraphicsRectItem.ItemIgnoresTransformations, False)
|
||
self.scene().addItem(item)
|
||
self._rubber_item = item
|
||
|
||
def _update_rubber_band(self, scene_pos: QPointF) -> None:
|
||
if self._rubber_item is None or self._rubber_origin is None:
|
||
return
|
||
origin = self._rubber_origin
|
||
rect = QRectF(
|
||
min(origin.x(), scene_pos.x()),
|
||
min(origin.y(), scene_pos.y()),
|
||
abs(scene_pos.x() - origin.x()),
|
||
abs(scene_pos.y() - origin.y()),
|
||
)
|
||
self._rubber_item.setRect(rect)
|
||
|
||
def _finish_rubber_band(self, scene_pos: QPointF, modifiers: Qt.KeyboardModifiers) -> None:
|
||
if self._rubber_origin is None:
|
||
self._clear_rubber_band()
|
||
return
|
||
origin = self._rubber_origin
|
||
rect = QRectF(
|
||
min(origin.x(), scene_pos.x()),
|
||
min(origin.y(), scene_pos.y()),
|
||
abs(scene_pos.x() - origin.x()),
|
||
abs(scene_pos.y() - origin.y()),
|
||
)
|
||
self._clear_rubber_band()
|
||
|
||
# Test in viewport pixels: LED items have an absolute on-screen size.
|
||
viewport_transform = self.viewportTransform()
|
||
viewport_rect = QRectF(viewport_transform.map(origin), viewport_transform.map(scene_pos)).normalized()
|
||
hit_ids: list[int] = []
|
||
for point_id, item in self.items_by_id.items():
|
||
if item is None or not isValid(item):
|
||
continue
|
||
center = viewport_transform.map(item.pos())
|
||
item_rect = QRectF(
|
||
center.x() - self._point_radius,
|
||
center.y() - self._point_radius,
|
||
self._point_radius * 2.0,
|
||
self._point_radius * 2.0,
|
||
)
|
||
if viewport_rect.intersects(item_rect) or viewport_rect.contains(center):
|
||
hit_ids.append(point_id)
|
||
|
||
keep_shift = bool(modifiers & (Qt.ShiftModifier | Qt.ControlModifier))
|
||
if keep_shift:
|
||
current = list(self.selected_point_ids())
|
||
merged = list(dict.fromkeys(current + hit_ids))
|
||
self.select_points(merged)
|
||
else:
|
||
self.select_points(hit_ids)
|
||
|
||
|
||
def _clear_rubber_band(self) -> None:
|
||
if self._rubber_item is not None and isValid(self._rubber_item):
|
||
self.scene().removeItem(self._rubber_item)
|
||
self._rubber_item = None
|
||
self._rubber_origin = None # сбрасываем оба — вызывается только при финале или реинициализации
|
||
|
||
# ------------------------------------------------------------------
|
||
# Selection bounding box highlight
|
||
# ------------------------------------------------------------------
|
||
|
||
def _update_selection_bbox(self) -> None:
|
||
"""Draw / update a dashed bounding rectangle around all selected LEDs."""
|
||
selected_items = [
|
||
item for item in self.scene().selectedItems()
|
||
if isinstance(item, LedItem) and isValid(item)
|
||
]
|
||
|
||
if not selected_items:
|
||
# Hide the bbox rect when nothing is selected
|
||
if self._selection_bbox_item is not None and isValid(self._selection_bbox_item):
|
||
self._selection_bbox_item.setVisible(False)
|
||
return
|
||
|
||
# Compute union bounding rect in scene coords (item pos is centre)
|
||
xs = [item.pos().x() for item in selected_items]
|
||
ys = [item.pos().y() for item in selected_items]
|
||
view_scale = max(abs(self.transform().m11()), 0.001)
|
||
padding = (self._point_radius + 4.0) / view_scale
|
||
x_min = min(xs) - padding
|
||
y_min = min(ys) - padding
|
||
x_max = max(xs) + padding
|
||
y_max = max(ys) + padding
|
||
bbox = QRectF(x_min, y_min, x_max - x_min, y_max - y_min)
|
||
|
||
if self._selection_bbox_item is None or not isValid(self._selection_bbox_item):
|
||
item = QGraphicsRectItem(bbox)
|
||
item.setZValue(999)
|
||
pen = QPen(QColor(99, 179, 255, 200), 1.2, Qt.DashLine)
|
||
pen.setDashPattern([6, 4])
|
||
item.setPen(pen)
|
||
item.setBrush(QBrush(Qt.NoBrush))
|
||
item.setFlag(QGraphicsRectItem.ItemIsSelectable, False)
|
||
item.setFlag(QGraphicsRectItem.ItemIsFocusable, False)
|
||
self.scene().addItem(item)
|
||
self._selection_bbox_item = item
|
||
else:
|
||
self._selection_bbox_item.setRect(bbox)
|
||
self._selection_bbox_item.setVisible(True)
|
||
|
||
def _start_point_drag(self, viewport_pos) -> bool: # noqa: ANN001
|
||
if self.document is None:
|
||
return False
|
||
item = self._led_item_at(viewport_pos)
|
||
|
||
# Check if click is inside the active selection bounding box
|
||
clicked_inside_bbox = False
|
||
if self._selection_bbox_item is not None and isValid(self._selection_bbox_item) and self._selection_bbox_item.isVisible():
|
||
scene_pos = self.mapToScene(viewport_pos)
|
||
if self._selection_bbox_item.rect().contains(scene_pos):
|
||
clicked_inside_bbox = True
|
||
|
||
if item is None and not clicked_inside_bbox:
|
||
return False
|
||
|
||
# Check selection and modifiers
|
||
selected_ids = list(self.selected_point_ids())
|
||
if item is not None and item.point_id not in selected_ids:
|
||
modifiers = QApplication.keyboardModifiers()
|
||
if modifiers & (Qt.ShiftModifier | Qt.ControlModifier):
|
||
selected_ids.append(item.point_id)
|
||
else:
|
||
selected_ids = [item.point_id]
|
||
self.select_points(selected_ids)
|
||
|
||
scene_pos = self.mapToScene(viewport_pos)
|
||
drag_items = []
|
||
for pid in selected_ids:
|
||
it = self.items_by_id.get(pid)
|
||
if it is not None and isValid(it):
|
||
drag_items.append({
|
||
"point_id": pid,
|
||
"item": it,
|
||
"offset": it.pos() - scene_pos,
|
||
"start_pos": it.pos(),
|
||
})
|
||
|
||
self._point_drag = {
|
||
"items": drag_items,
|
||
"point_id": item.point_id if item is not None else (selected_ids[0] if len(selected_ids) == 1 else None),
|
||
}
|
||
self.setDragMode(QGraphicsView.NoDrag)
|
||
self.viewport().setCursor(Qt.ClosedHandCursor)
|
||
return True
|
||
|
||
def _move_point_drag(self, viewport_pos) -> None: # noqa: ANN001
|
||
if self._point_drag is None:
|
||
return
|
||
scene_pos = self.mapToScene(viewport_pos)
|
||
for d in self._point_drag.get("items", []):
|
||
item = d.get("item")
|
||
if item is not None and isValid(item):
|
||
offset = d.get("offset", QPointF(0.0, 0.0))
|
||
item.setPos(scene_pos + offset)
|
||
self._position_line_index_labels()
|
||
self._update_selection_bbox()
|
||
|
||
def _finish_point_drag(self, viewport_pos) -> None: # noqa: ANN001
|
||
if self._point_drag is None:
|
||
return
|
||
self._move_point_drag(viewport_pos)
|
||
drag_items = self._point_drag.get("items", [])
|
||
self._point_drag = None
|
||
|
||
is_move_tool = getattr(self, "_edit_tool", "select") == "move"
|
||
self.setDragMode(QGraphicsView.NoDrag)
|
||
self._apply_default_cursor()
|
||
|
||
moves = []
|
||
for d in drag_items:
|
||
item = d.get("item")
|
||
point_id = d.get("point_id")
|
||
start_pos = d.get("start_pos")
|
||
if item is not None and isValid(item) and isinstance(start_pos, QPointF):
|
||
if (item.pos() - start_pos).manhattanLength() > 0.001:
|
||
document_point = self._document_point_from_scene(item.pos())
|
||
moves.append((point_id, document_point.x(), document_point.y()))
|
||
|
||
if len(moves) == 1:
|
||
point_id, x, y = moves[0]
|
||
self.pointMoveFinished.emit(point_id, x, y)
|
||
elif moves:
|
||
self.pointsMoveFinished.emit(moves)
|
||
|
||
def _start_background_edit(self, viewport_pos) -> bool: # noqa: ANN001
|
||
if self.document is None or self._background_item is None or not isValid(self._background_item):
|
||
return False
|
||
if not self.document.background.visible:
|
||
return False
|
||
scene_pos = self.mapToScene(viewport_pos)
|
||
rect = self._background_scene_rect()
|
||
if rect is None:
|
||
return False
|
||
handle_name = self._background_handle_at(viewport_pos)
|
||
if handle_name is None and not rect.contains(scene_pos):
|
||
return False
|
||
start_payload = self.document.background.to_payload()
|
||
self._background_drag = {
|
||
"mode": "resize" if handle_name else "move",
|
||
"handle": handle_name,
|
||
"start_pos": scene_pos,
|
||
"start_rect": QRectF(rect),
|
||
"start_background": BackgroundImage(
|
||
path=self.document.background.path,
|
||
offset_x=self.document.background.offset_x,
|
||
offset_y=self.document.background.offset_y,
|
||
scale_x_percent=self.document.background.scale_x_percent,
|
||
scale_y_percent=self.document.background.scale_y_percent,
|
||
opacity_percent=self.document.background.opacity_percent,
|
||
visible=self.document.background.visible,
|
||
),
|
||
"start_payload": start_payload,
|
||
"undo_emitted": False,
|
||
"changed": False,
|
||
}
|
||
self._apply_default_cursor()
|
||
return True
|
||
|
||
def _move_background_edit(self, viewport_pos) -> None: # noqa: ANN001
|
||
if self.document is None or self._background_drag is None:
|
||
return
|
||
scene_pos = self.mapToScene(viewport_pos)
|
||
start_pos: QPointF = self._background_drag["start_pos"]
|
||
delta = scene_pos - start_pos
|
||
if abs(delta.x()) < 0.001 and abs(delta.y()) < 0.001:
|
||
return
|
||
if not self._background_drag.get("undo_emitted"):
|
||
self.backgroundEditStarted.emit()
|
||
self._background_drag["undo_emitted"] = True
|
||
self._background_drag["changed"] = True
|
||
start_rect: QRectF = self._background_drag["start_rect"]
|
||
start_background: BackgroundImage = self._background_drag["start_background"]
|
||
if self._background_drag["mode"] == "move":
|
||
self.document.background.offset_x = start_background.offset_x + delta.x()
|
||
self.document.background.offset_y = start_background.offset_y + delta.y()
|
||
else:
|
||
handle = str(self._background_drag["handle"])
|
||
rect = QRectF(start_rect)
|
||
if "w" in handle:
|
||
rect.setLeft(min(rect.right() - 8.0, rect.left() + delta.x()))
|
||
if "e" in handle:
|
||
rect.setRight(max(rect.left() + 8.0, rect.right() + delta.x()))
|
||
if "n" in handle:
|
||
rect.setTop(min(rect.bottom() - 8.0, rect.top() + delta.y()))
|
||
if "s" in handle:
|
||
rect.setBottom(max(rect.top() + 8.0, rect.bottom() + delta.y()))
|
||
self._apply_background_rect(rect, start_background, start_rect)
|
||
self._apply_background_geometry()
|
||
|
||
def _finish_background_edit(self) -> None:
|
||
changed = bool(self._background_drag and self._background_drag.get("changed"))
|
||
start_payload = self._background_drag.get("start_payload") if self._background_drag else None
|
||
self._background_drag = None
|
||
self._apply_default_cursor()
|
||
payload_changed = changed and self.document is not None and self.document.background.to_payload() != start_payload
|
||
if payload_changed:
|
||
self._update_scene_rect()
|
||
self.backgroundChanged.emit()
|
||
self.backgroundEditFinished.emit(bool(payload_changed))
|
||
|
||
def _background_handle_at(self, viewport_pos) -> str | None: # noqa: ANN001
|
||
for name, handle in self._background_handles.items():
|
||
if handle is not None and isValid(handle):
|
||
handle_pos = self.mapFromScene(handle.scenePos())
|
||
if abs(handle_pos.x() - viewport_pos.x()) <= 8 and abs(handle_pos.y() - viewport_pos.y()) <= 8:
|
||
return name
|
||
return None
|
||
|
||
def _apply_background_rect(
|
||
self,
|
||
rect: QRectF,
|
||
start_background: BackgroundImage,
|
||
start_rect: QRectF,
|
||
) -> None:
|
||
if self.document is None:
|
||
return
|
||
origin = self._background_origin_scene_point()
|
||
self.document.background.offset_x = rect.center().x() - origin.x()
|
||
self.document.background.offset_y = rect.center().y() - origin.y()
|
||
if start_rect.width() > 0:
|
||
self.document.background.scale_x_percent = max(
|
||
1.0,
|
||
start_background.scale_x_percent * rect.width() / start_rect.width(),
|
||
)
|
||
if start_rect.height() > 0:
|
||
self.document.background.scale_y_percent = max(
|
||
1.0,
|
||
start_background.scale_y_percent * rect.height() / start_rect.height(),
|
||
)
|
||
|
||
def enterEvent(self, event) -> None: # noqa: ANN001
|
||
self._apply_default_cursor()
|
||
super().enterEvent(event)
|
||
|
||
def leaveEvent(self, event) -> None: # noqa: ANN001
|
||
self._hide_brush_zone()
|
||
self._clear_brush_preview()
|
||
super().leaveEvent(event)
|
||
|
||
def _apply_default_cursor(self) -> None:
|
||
if self._is_panning:
|
||
cursor_shape = Qt.ClosedHandCursor
|
||
elif self._background_drag is not None:
|
||
cursor_shape = Qt.ClosedHandCursor if self._background_drag.get("mode") == "move" else Qt.SizeAllCursor
|
||
elif self._point_drag is not None:
|
||
cursor_shape = Qt.ClosedHandCursor
|
||
elif self._space_pressed and self._can_pan_with_space():
|
||
cursor_shape = Qt.OpenHandCursor
|
||
elif self._brush_enabled or self._add_led_enabled:
|
||
cursor_shape = Qt.CrossCursor
|
||
else:
|
||
cursor_shape = Qt.ArrowCursor
|
||
self.setCursor(cursor_shape)
|
||
self.viewport().setCursor(cursor_shape)
|
||
|
||
def _can_pan_with_middle(self) -> bool:
|
||
return self._pan_mode in {"middle", "middle_space"}
|
||
|
||
def _can_pan_with_space(self) -> bool:
|
||
return self._pan_mode in {"space", "middle_space"}
|
||
|
||
def _should_start_pan(self, button: Qt.MouseButton) -> bool:
|
||
return (button == Qt.MiddleButton and self._can_pan_with_middle()) or (
|
||
button == Qt.LeftButton and self._space_pressed and self._can_pan_with_space()
|
||
)
|
||
|
||
def _should_finish_pan(self, button: Qt.MouseButton) -> bool:
|
||
return self._is_panning and (
|
||
(button == Qt.MiddleButton and self._can_pan_with_middle())
|
||
or (button == Qt.LeftButton and self._space_pressed and self._can_pan_with_space())
|
||
)
|
||
|
||
def _start_pan(self, pos) -> None: # noqa: ANN001
|
||
self._is_panning = True
|
||
self._last_pan_pos = pos
|
||
self._apply_default_cursor()
|
||
|
||
def _finish_pan(self) -> None:
|
||
self._is_panning = False
|
||
self._last_pan_pos = None
|
||
self._apply_default_cursor()
|
||
|
||
def contextMenuEvent(self, event) -> None: # noqa: ANN001
|
||
item = self._led_item_at(event.pos())
|
||
point_id = item.point_id if item is not None else self._single_selected_point_id()
|
||
|
||
menu = QMenu(self)
|
||
edit_action = menu.addAction("Редактировать координаты")
|
||
edit_action.setEnabled(point_id is not None)
|
||
delete_action = menu.addAction("Удалить фиксацию выбранных LED")
|
||
delete_action.setEnabled(self._selection_has_assignment())
|
||
delete_points_action = menu.addAction("Удалить выбранные точки")
|
||
delete_points_action.setEnabled(bool(self.selected_point_ids()))
|
||
clear_selection_action = menu.addAction("Очистить выбор")
|
||
clear_selection_action.setEnabled(bool(self.selected_point_ids()))
|
||
auto_block_action = menu.addAction("Автоматически добавить блок")
|
||
auto_block_action.setEnabled(point_id is not None)
|
||
counterclockwise_block_action = menu.addAction("Автоматически добавить блок против часовой")
|
||
counterclockwise_block_action.setEnabled(point_id is not None)
|
||
|
||
select_block_action = None
|
||
select_line_action = None
|
||
point = None
|
||
if point_id is not None and self.document is not None:
|
||
try:
|
||
point = self.document.get_point(point_id)
|
||
except (IndexError, KeyError):
|
||
pass
|
||
|
||
if self._edit_mode_enabled and point is not None:
|
||
line_id = point.line_id
|
||
block_id = point.block_id
|
||
|
||
if line_id is not None:
|
||
line_name = f"Линия {line_id}"
|
||
try:
|
||
line = self.document.get_line(line_id)
|
||
line_name = line.name
|
||
except KeyError:
|
||
pass
|
||
|
||
menu.addSeparator()
|
||
select_line_action = menu.addAction(f"Выделить линию '{line_name}'")
|
||
|
||
if block_id is not None:
|
||
block_name = f"Блок {block_id}"
|
||
try:
|
||
block = self.document._get_block(line_id, block_id)
|
||
block_name = block.name
|
||
except KeyError:
|
||
pass
|
||
select_block_action = menu.addAction(f"Выделить блок '{block_name}'")
|
||
|
||
fit_action = menu.addAction("Показать все точки")
|
||
fit_action.setEnabled(self.document is not None and bool(self.document.points))
|
||
chosen = menu.exec(event.globalPos())
|
||
if chosen == edit_action and point_id is not None:
|
||
self.editPointRequested.emit(point_id)
|
||
elif chosen == delete_action:
|
||
self.deleteSelectionRequested.emit()
|
||
elif chosen == delete_points_action:
|
||
self.deletePointsRequested.emit()
|
||
elif chosen == clear_selection_action:
|
||
self.select_points([])
|
||
elif chosen == auto_block_action and point_id is not None:
|
||
self.autoBlockFromPointRequested.emit(point_id)
|
||
elif chosen == counterclockwise_block_action and point_id is not None:
|
||
self.counterclockwiseBlockFromPointRequested.emit(point_id)
|
||
elif select_line_action is not None and chosen == select_line_action and point is not None and point.line_id is not None:
|
||
line_point_ids = [p.uid for p in self.document.points if p.line_id == point.line_id]
|
||
self.select_points(line_point_ids)
|
||
elif select_block_action is not None and chosen == select_block_action and point is not None and point.line_id is not None and point.block_id is not None:
|
||
block_point_ids = [p.uid for p in self.document.points if p.line_id == point.line_id and p.block_id == point.block_id]
|
||
self.select_points(block_point_ids)
|
||
elif chosen == fit_action:
|
||
self.fit_to_content()
|
||
|
||
def _led_item_at(self, viewport_pos) -> LedItem | None: # noqa: ANN001
|
||
for item in self.items(viewport_pos):
|
||
if isinstance(item, LedItem) and isValid(item):
|
||
return item
|
||
return None
|
||
|
||
def _emit_selection(self) -> None:
|
||
selected_ids = {
|
||
item.point_id
|
||
for item in self.scene().selectedItems()
|
||
if isinstance(item, LedItem) and isValid(item)
|
||
}
|
||
if selected_ids == self._last_selected_ids:
|
||
return
|
||
self._refresh_selection_pens(selected_ids ^ self._last_selected_ids)
|
||
self._last_selected_ids = selected_ids
|
||
self._refresh_line_index_labels()
|
||
self._update_selection_bbox()
|
||
self.selectionChanged.emit(sorted(selected_ids))
|
||
|
||
def _refresh_selection_pens(self, point_ids: set[int]) -> None:
|
||
if self.document is None:
|
||
return
|
||
for point_id in point_ids:
|
||
item = self.items_by_id.get(point_id)
|
||
if item is None or not isValid(item):
|
||
continue
|
||
point = self.document.get_point(point_id)
|
||
is_active_line_point = self.active_line_id is not None and point.line_id == self.active_line_id
|
||
try:
|
||
pen_color = QColor("#111827") if item.isSelected() else QColor("#ffffff")
|
||
item.setPen(QPen(pen_color, 2.0 if is_active_line_point else 1.2))
|
||
except RuntimeError:
|
||
continue
|
||
|
||
def _refresh_line_index_labels(self) -> None:
|
||
self._clear_line_index_labels()
|
||
if self.document is None or not self._line_index_labels_visible:
|
||
return
|
||
for point_id in sorted(self._last_selected_ids):
|
||
point = self.document.get_point(point_id)
|
||
if point.line_id is None or point.line_index is None:
|
||
continue
|
||
item = self.items_by_id.get(point_id)
|
||
if item is None or not isValid(item):
|
||
continue
|
||
label = QGraphicsSimpleTextItem(str(point.line_index))
|
||
font = QFont(label.font())
|
||
font.setPixelSize(self._line_index_font_size_px)
|
||
label.setFont(font)
|
||
label.setBrush(QColor("#f8fafc" if self._theme == "dark" else "#111827"))
|
||
label.setZValue(40)
|
||
label.setFlag(QGraphicsItem.ItemIgnoresTransformations, True)
|
||
label.setAcceptedMouseButtons(Qt.NoButton)
|
||
label.setEnabled(False)
|
||
self.scene().addItem(label)
|
||
self.line_index_label_items[point_id] = label
|
||
self._position_line_index_labels()
|
||
|
||
def _position_line_index_labels(self) -> None:
|
||
if not self.line_index_label_items:
|
||
return
|
||
scale = max(abs(self.transform().m11()), 0.001)
|
||
scene_gap = 6.0 / scale
|
||
scene_radius = self._point_radius / scale
|
||
for point_id, label in list(self.line_index_label_items.items()):
|
||
item = self.items_by_id.get(point_id)
|
||
if item is None or not isValid(item) or not isValid(label):
|
||
continue
|
||
label_rect = label.boundingRect()
|
||
label.setPos(
|
||
item.pos().x() - label_rect.width() / (2.0 * scale),
|
||
item.pos().y() - scene_radius - scene_gap,
|
||
)
|
||
|
||
def _update_led_content_rect(self) -> None:
|
||
valid_items = [item for item in self.items_by_id.values() if item is not None and isValid(item)]
|
||
if not valid_items:
|
||
self._content_scene_rect = None
|
||
return
|
||
padding = max(self._point_radius * 4.0, 40.0)
|
||
xs = [item.pos().x() for item in valid_items]
|
||
ys = [item.pos().y() for item in valid_items]
|
||
self._content_scene_rect = QRectF(
|
||
min(xs) - padding,
|
||
min(ys) - padding,
|
||
max(max(xs) - min(xs), 1.0) + padding * 2.0,
|
||
max(max(ys) - min(ys), 1.0) + padding * 2.0,
|
||
)
|
||
|
||
def _clear_line_index_labels(self) -> None:
|
||
for label in self.line_index_label_items.values():
|
||
if isValid(label):
|
||
self.scene().removeItem(label)
|
||
self.line_index_label_items.clear()
|
||
|
||
def _touch_brush_point(self, viewport_pos) -> None: # noqa: ANN001
|
||
touched = self._brush_candidate_ids(viewport_pos)
|
||
for point_id in sorted(touched):
|
||
self._brush_touched_ids.add(point_id)
|
||
self.brushPointTouched.emit(point_id)
|
||
self._update_brush_preview(viewport_pos)
|
||
|
||
def _brush_candidate_ids(self, viewport_pos) -> list[int]: # noqa: ANN001
|
||
return [
|
||
item.point_id
|
||
for item in self.items_by_id.values()
|
||
if isValid(item)
|
||
and item.point_id not in self._brush_touched_ids
|
||
and item.point_id not in self._brush_excluded_point_ids
|
||
and self._is_inside_brush_zone(viewport_pos, item)
|
||
]
|
||
|
||
def _is_inside_brush_zone(self, viewport_center, item: LedItem) -> bool: # noqa: ANN001
|
||
point_viewport = self.mapFromScene(item.pos())
|
||
radius = self._brush_radius + self._point_radius
|
||
dx = viewport_center.x() - point_viewport.x()
|
||
dy = viewport_center.y() - point_viewport.y()
|
||
return dx * dx + dy * dy <= radius * radius
|
||
|
||
def _ensure_brush_zone(self) -> QGraphicsEllipseItem | None:
|
||
if self.scene() is None:
|
||
return None
|
||
if self._brush_zone_item is not None and isValid(self._brush_zone_item):
|
||
return self._brush_zone_item
|
||
item = QGraphicsEllipseItem(-self._brush_radius, -self._brush_radius, self._brush_radius * 2, self._brush_radius * 2)
|
||
pen = QPen(QColor("#f43f5e"), 1.4)
|
||
pen.setCosmetic(True)
|
||
item.setPen(pen)
|
||
item.setBrush(QBrush(QColor(244, 63, 94, 32)))
|
||
item.setZValue(40)
|
||
item.setFlag(QGraphicsItem.ItemIgnoresTransformations, True)
|
||
item.setAcceptedMouseButtons(Qt.NoButton)
|
||
item.setVisible(False)
|
||
self.scene().addItem(item)
|
||
self._brush_zone_item = item
|
||
return item
|
||
|
||
def _update_brush_zone(self, viewport_pos) -> None: # noqa: ANN001
|
||
if not self._brush_enabled:
|
||
return
|
||
item = self._ensure_brush_zone()
|
||
if item is None:
|
||
return
|
||
self._last_brush_viewport_pos = viewport_pos
|
||
item.setPos(self.mapToScene(viewport_pos))
|
||
item.setVisible(True)
|
||
self._update_brush_preview(viewport_pos)
|
||
|
||
def _hide_brush_zone(self) -> None:
|
||
if self._brush_zone_item is not None and isValid(self._brush_zone_item):
|
||
self._brush_zone_item.setVisible(False)
|
||
|
||
def _update_brush_preview(self, viewport_pos) -> None: # noqa: ANN001
|
||
if not self._brush_enabled:
|
||
self._clear_brush_preview()
|
||
return
|
||
next_preview_ids = set(self._brush_candidate_ids(viewport_pos))
|
||
changed_ids = self._brush_preview_ids ^ next_preview_ids
|
||
self._brush_preview_ids = next_preview_ids
|
||
self._refresh_brush_preview_styles(changed_ids | self._brush_touched_ids)
|
||
|
||
def _clear_brush_preview(self) -> None:
|
||
changed_ids = self._brush_preview_ids | self._brush_touched_ids
|
||
self._brush_preview_ids.clear()
|
||
self._refresh_brush_preview_styles(changed_ids)
|
||
|
||
def _clear_brush_state(self) -> None:
|
||
changed_ids = self._brush_preview_ids | self._brush_touched_ids
|
||
self._brush_preview_ids.clear()
|
||
self._brush_touched_ids.clear()
|
||
self._refresh_brush_preview_styles(changed_ids)
|
||
|
||
def _refresh_brush_preview_styles(self, point_ids: set[int]) -> None:
|
||
if self.document is None:
|
||
return
|
||
line_by_id = {line.id: line for line in self.document.lines}
|
||
for point_id in point_ids:
|
||
item = self.items_by_id.get(point_id)
|
||
if item is None or not isValid(item):
|
||
continue
|
||
try:
|
||
if point_id in self._brush_touched_ids:
|
||
item.setBrush(QColor("#f43f5e"))
|
||
item.setPen(QPen(QColor("#ffffff"), 2.4))
|
||
continue
|
||
if point_id in self._brush_preview_ids:
|
||
item.setBrush(QColor("#fbbf24"))
|
||
item.setPen(QPen(QColor("#111827"), 2.4))
|
||
continue
|
||
point = self.document.get_point(point_id)
|
||
color = QColor("#94a3b8")
|
||
if point.line_id in line_by_id:
|
||
color = QColor(line_by_id[point.line_id].color)
|
||
if self.active_line_id is not None and point.line_id != self.active_line_id:
|
||
color.setAlphaF(0.45)
|
||
is_active_line_point = self.active_line_id is not None and point.line_id == self.active_line_id
|
||
pen_color = QColor("#111827") if item.isSelected() else QColor("#ffffff")
|
||
item.setBrush(color)
|
||
item.setPen(QPen(pen_color, 2.0 if is_active_line_point else 1.2))
|
||
except RuntimeError:
|
||
continue
|
||
|
||
def _clear_connections(self) -> None:
|
||
for item in self.connection_items:
|
||
if not isValid(item):
|
||
continue
|
||
self.scene().removeItem(item)
|
||
self.connection_items.clear()
|
||
|
||
def _refresh_grid(self) -> None:
|
||
for item in self.grid_items:
|
||
if not isValid(item):
|
||
continue
|
||
self.scene().removeItem(item)
|
||
self.grid_items.clear()
|
||
if not self._grid_visible or self._raw_bounds is None:
|
||
return
|
||
|
||
if self.document is None or not self.document.points:
|
||
return
|
||
min_x, _max_x, _min_y, max_y = self._raw_bounds
|
||
origin_x, origin_y = self._grid_origin_scene_units()
|
||
visible_scene_rect = self.mapToScene(self.viewport().rect()).boundingRect()
|
||
overscan_x = max(self._grid_step, visible_scene_rect.width() * 0.05)
|
||
overscan_y = max(self._grid_step, visible_scene_rect.height() * 0.05)
|
||
visible_scene_rect.adjust(-overscan_x, -overscan_y, overscan_x, overscan_y)
|
||
|
||
# Convert the visible scene rectangle back to display units. The
|
||
# resulting grid density depends only on the camera, never on the
|
||
# expanded sceneRect used by scheme editing mode.
|
||
left_x = visible_scene_rect.left() - self._margin + min_x
|
||
right_x = visible_scene_rect.right() - self._margin + min_x
|
||
bottom_y = max_y - visible_scene_rect.bottom() + self._margin
|
||
top_y = max_y - visible_scene_rect.top() + self._margin
|
||
first_col = math.floor((left_x - origin_x) / self._grid_step)
|
||
last_col = math.ceil((right_x - origin_x) / self._grid_step)
|
||
first_row = math.floor((bottom_y - origin_y) / self._grid_step)
|
||
last_row = math.ceil((top_y - origin_y) / self._grid_step)
|
||
grid_line_count = max(0, last_col - first_col + 1) + max(0, last_row - first_row + 1)
|
||
step_multiplier = max(1, math.ceil(grid_line_count / MAX_GRID_LINES))
|
||
pen = QPen(QColor("#334155" if self._theme == "dark" else "#d1d5db"), 0.6)
|
||
pen.setCosmetic(True)
|
||
|
||
for col in range(first_col, last_col + 1, step_multiplier):
|
||
x = origin_x + col * self._grid_step
|
||
top = self._scene_point_from_display_units(x, top_y)
|
||
bottom = self._scene_point_from_display_units(x, bottom_y)
|
||
item = self.scene().addLine(top.x(), top.y(), bottom.x(), bottom.y(), pen)
|
||
item.setZValue(-20)
|
||
self.grid_items.append(item)
|
||
|
||
for row in range(first_row, last_row + 1, step_multiplier):
|
||
y = origin_y + row * self._grid_step
|
||
left = self._scene_point_from_display_units(left_x, y)
|
||
right = self._scene_point_from_display_units(right_x, y)
|
||
item = self.scene().addLine(left.x(), left.y(), right.x(), right.y(), pen)
|
||
item.setZValue(-20)
|
||
self.grid_items.append(item)
|
||
|
||
def _draw_line_connections(self, line: LedLine) -> None:
|
||
is_active = self.active_line_id is None or line.id == self.active_line_id
|
||
inner_width = 2.8 if is_active else 1.2
|
||
inner_alpha = 0.9 if is_active else 0.25
|
||
bridge_width = 1.9 if is_active else 1.0
|
||
bridge_alpha = 0.65 if is_active else 0.18
|
||
arrow_width = 1.8 if is_active else 1.0
|
||
arrow_alpha = 0.95 if is_active else 0.28
|
||
inner_path = QPainterPath()
|
||
inner_arrows = QPainterPath()
|
||
for block in line.blocks:
|
||
ordered_items = [self.items_by_id[point_id] for point_id in block.point_ids if point_id in self.items_by_id]
|
||
for first, second in zip(ordered_items, ordered_items[1:]):
|
||
start = first.pos()
|
||
end = second.pos()
|
||
control = self._append_connection(inner_path, start, end)
|
||
if self._direction_arrows_visible:
|
||
self._append_arrow_marker(inner_arrows, start, end, control)
|
||
self._add_connection_path(inner_path, line.color, inner_width, inner_alpha, line_id=line.id, connection_kind="inner")
|
||
if self._direction_arrows_visible:
|
||
self._add_connection_path(
|
||
inner_arrows,
|
||
line.color,
|
||
arrow_width,
|
||
arrow_alpha,
|
||
z_value=2,
|
||
line_id=line.id,
|
||
connection_kind="arrow",
|
||
)
|
||
|
||
previous_block_end = None
|
||
bridge_path = QPainterPath()
|
||
bridge_arrows = QPainterPath()
|
||
for block in line.blocks:
|
||
if not block.point_ids:
|
||
continue
|
||
block_start = self.items_by_id.get(block.point_ids[0])
|
||
block_end = self.items_by_id.get(block.point_ids[-1])
|
||
if block_start is not None and previous_block_end is not None:
|
||
start = previous_block_end.pos()
|
||
end = block_start.pos()
|
||
control = self._append_connection(bridge_path, start, end)
|
||
if self._direction_arrows_visible:
|
||
self._append_arrow_marker(bridge_arrows, start, end, control)
|
||
if block_end is not None:
|
||
previous_block_end = block_end
|
||
self._add_connection_path(bridge_path, line.color, bridge_width, bridge_alpha, line_id=line.id, connection_kind="bridge")
|
||
if self._direction_arrows_visible:
|
||
self._add_connection_path(
|
||
bridge_arrows,
|
||
line.color,
|
||
arrow_width,
|
||
bridge_alpha,
|
||
z_value=2,
|
||
line_id=line.id,
|
||
connection_kind="bridge_arrow",
|
||
)
|
||
|
||
def _append_connection(self, path: QPainterPath, start: QPointF, end: QPointF) -> QPointF | None:
|
||
path.moveTo(start)
|
||
control = self._overlap_control_point(start, end)
|
||
if control is None:
|
||
path.lineTo(end)
|
||
else:
|
||
path.quadTo(control, end)
|
||
return control
|
||
|
||
@staticmethod
|
||
def _append_arrow_marker(path: QPainterPath, start: QPointF, end: QPointF, control: QPointF | None = None) -> None:
|
||
dx = end.x() - start.x()
|
||
dy = end.y() - start.y()
|
||
length = math.hypot(dx, dy)
|
||
if length < 8.0:
|
||
return
|
||
if control is None:
|
||
unit_x = dx / length
|
||
unit_y = dy / length
|
||
mid_x = start.x() + dx * 0.55
|
||
mid_y = start.y() + dy * 0.55
|
||
else:
|
||
t = 0.55
|
||
inv_t = 1.0 - t
|
||
mid_x = inv_t * inv_t * start.x() + 2.0 * inv_t * t * control.x() + t * t * end.x()
|
||
mid_y = inv_t * inv_t * start.y() + 2.0 * inv_t * t * control.y() + t * t * end.y()
|
||
tangent_x = 2.0 * inv_t * (control.x() - start.x()) + 2.0 * t * (end.x() - control.x())
|
||
tangent_y = 2.0 * inv_t * (control.y() - start.y()) + 2.0 * t * (end.y() - control.y())
|
||
tangent_length = math.hypot(tangent_x, tangent_y)
|
||
if tangent_length < 0.001:
|
||
return
|
||
unit_x = tangent_x / tangent_length
|
||
unit_y = tangent_y / tangent_length
|
||
arrow_length = min(7.0, length * 0.28)
|
||
arrow_width = arrow_length * 0.55
|
||
base_x = mid_x - unit_x * arrow_length
|
||
base_y = mid_y - unit_y * arrow_length
|
||
perp_x = -unit_y
|
||
perp_y = unit_x
|
||
left = QPointF(base_x + perp_x * arrow_width, base_y + perp_y * arrow_width)
|
||
right = QPointF(base_x - perp_x * arrow_width, base_y - perp_y * arrow_width)
|
||
tip = QPointF(mid_x, mid_y)
|
||
path.moveTo(left)
|
||
path.lineTo(tip)
|
||
path.lineTo(right)
|
||
|
||
def _overlap_control_point(self, start: QPointF, end: QPointF) -> QPointF | None:
|
||
segments = getattr(self, "_connection_segments", None)
|
||
if segments is None:
|
||
return None
|
||
candidates = self._overlap_candidates(start, end)
|
||
previous_count = sum(
|
||
1
|
||
for previous_start, previous_end in candidates
|
||
if self._segments_visually_overlap(start, end, previous_start, previous_end)
|
||
)
|
||
segments.append((QPointF(start), QPointF(end)))
|
||
buckets = getattr(self, "_connection_segment_buckets", None)
|
||
if buckets is not None:
|
||
segment = (QPointF(start), QPointF(end))
|
||
for bucket in self._segment_buckets(start, end):
|
||
buckets.setdefault(bucket, []).append(segment)
|
||
if previous_count == 0:
|
||
return None
|
||
dx = end.x() - start.x()
|
||
dy = end.y() - start.y()
|
||
length = math.hypot(dx, dy)
|
||
if length < 0.001:
|
||
return None
|
||
bend = min(16.0, max(7.0, length * 0.24))
|
||
side = 1.0 if previous_count % 2 == 1 else -1.0
|
||
layer = 1.0 + (previous_count - 1) // 2
|
||
offset = side * bend * layer
|
||
perp_x = -dy / length
|
||
perp_y = dx / length
|
||
return QPointF(
|
||
(start.x() + end.x()) / 2.0 + perp_x * offset,
|
||
(start.y() + end.y()) / 2.0 + perp_y * offset,
|
||
)
|
||
|
||
def _overlap_candidates(self, start: QPointF, end: QPointF) -> list[tuple[QPointF, QPointF]]:
|
||
buckets = getattr(self, "_connection_segment_buckets", None)
|
||
if not buckets:
|
||
return getattr(self, "_connection_segments", [])
|
||
candidates: list[tuple[QPointF, QPointF]] = []
|
||
seen: set[int] = set()
|
||
for bucket in self._segment_buckets(start, end):
|
||
for segment in buckets.get(bucket, []):
|
||
marker = id(segment)
|
||
if marker not in seen:
|
||
seen.add(marker)
|
||
candidates.append(segment)
|
||
return candidates
|
||
|
||
@staticmethod
|
||
def _segment_buckets(start: QPointF, end: QPointF) -> set[tuple[int, int]]:
|
||
bucket_size = 96.0
|
||
left = math.floor(min(start.x(), end.x()) / bucket_size)
|
||
right = math.floor(max(start.x(), end.x()) / bucket_size)
|
||
top = math.floor(min(start.y(), end.y()) / bucket_size)
|
||
bottom = math.floor(max(start.y(), end.y()) / bucket_size)
|
||
return {
|
||
(x_bucket, y_bucket)
|
||
for x_bucket in range(left, right + 1)
|
||
for y_bucket in range(top, bottom + 1)
|
||
}
|
||
|
||
@staticmethod
|
||
def _segments_visually_overlap(a_start: QPointF, a_end: QPointF, b_start: QPointF, b_end: QPointF) -> bool:
|
||
ax = a_end.x() - a_start.x()
|
||
ay = a_end.y() - a_start.y()
|
||
bx = b_end.x() - b_start.x()
|
||
by = b_end.y() - b_start.y()
|
||
a_length = math.hypot(ax, ay)
|
||
b_length = math.hypot(bx, by)
|
||
if a_length < 0.001 or b_length < 0.001:
|
||
return False
|
||
|
||
unit_ax = ax / a_length
|
||
unit_ay = ay / a_length
|
||
unit_bx = bx / b_length
|
||
unit_by = by / b_length
|
||
parallel_score = abs(unit_ax * unit_bx + unit_ay * unit_by)
|
||
if parallel_score < 0.985:
|
||
return False
|
||
|
||
def distance_to_a_line(point: QPointF) -> float:
|
||
return abs((point.x() - a_start.x()) * unit_ay - (point.y() - a_start.y()) * unit_ax)
|
||
|
||
if max(distance_to_a_line(b_start), distance_to_a_line(b_end)) > 5.0:
|
||
return False
|
||
|
||
b0 = (b_start.x() - a_start.x()) * unit_ax + (b_start.y() - a_start.y()) * unit_ay
|
||
b1 = (b_end.x() - a_start.x()) * unit_ax + (b_end.y() - a_start.y()) * unit_ay
|
||
overlap_start = max(0.0, min(b0, b1))
|
||
overlap_end = min(a_length, max(b0, b1))
|
||
return (overlap_end - overlap_start) >= min(10.0, min(a_length, b_length) * 0.35)
|
||
|
||
def _add_connection_path(
|
||
self,
|
||
path: QPainterPath,
|
||
color: str,
|
||
width: float,
|
||
alpha: float,
|
||
*,
|
||
z_value: float = 1,
|
||
line_id: int | None = None,
|
||
connection_kind: str = "inner",
|
||
) -> None:
|
||
if path.isEmpty():
|
||
return
|
||
item = self.scene().addPath(path)
|
||
item.setData(0, line_id)
|
||
item.setData(1, connection_kind)
|
||
item.setData(2, color)
|
||
item.setZValue(z_value)
|
||
self._apply_connection_item_style(item)
|
||
self.connection_items.append(item)
|
||
|
||
def _restyle_connection_items(self) -> None:
|
||
for item in self.connection_items:
|
||
if isValid(item):
|
||
self._apply_connection_item_style(item)
|
||
|
||
def _apply_connection_item_style(self, item: QGraphicsPathItem) -> None:
|
||
line_id = item.data(0)
|
||
kind = str(item.data(1) or "inner")
|
||
color = str(item.data(2) or "#e11d48")
|
||
is_active = self.active_line_id is None or line_id == self.active_line_id
|
||
if kind == "inner":
|
||
width = 2.8 if is_active else 1.2
|
||
alpha = 0.9 if is_active else 0.25
|
||
elif kind == "bridge":
|
||
width = 1.9 if is_active else 1.0
|
||
alpha = 0.65 if is_active else 0.18
|
||
elif kind == "arrow":
|
||
width = 1.8 if is_active else 1.0
|
||
alpha = 0.95 if is_active else 0.28
|
||
else:
|
||
width = 1.8 if is_active else 1.0
|
||
alpha = 0.65 if is_active else 0.18
|
||
pen_color = QColor(color)
|
||
pen_color.setAlphaF(alpha)
|
||
item.setPen(QPen(pen_color, width))
|
||
|
||
def _selection_has_assignment(self) -> bool:
|
||
if self.document is None:
|
||
return False
|
||
for point_id in self.selected_point_ids():
|
||
point = self.document.get_point(point_id)
|
||
if point.line_id is not None or point.block_id is not None:
|
||
return True
|
||
return False
|
||
|
||
def _single_selected_point_id(self) -> int | None:
|
||
point_ids = self.selected_point_ids()
|
||
return point_ids[0] if len(point_ids) == 1 else None
|
||
|
||
def _to_scene_point(self, x: float, y: float) -> QPointF:
|
||
if self._raw_bounds is None:
|
||
return QPointF(x, y)
|
||
x *= self._display_scale
|
||
y *= self._display_scale
|
||
min_x, _max_x, _min_y, max_y = self._raw_bounds
|
||
return QPointF((x - min_x) + self._margin, (max_y - y) + self._margin)
|
||
|
||
def _background_origin_scene_point(self) -> QPointF:
|
||
if self._raw_bounds is not None:
|
||
min_x, max_x, min_y, max_y = self._raw_bounds
|
||
return self._scene_point_from_display_units((min_x + max_x) / 2.0, (min_y + max_y) / 2.0)
|
||
if self._content_scene_rect is not None:
|
||
return self._content_scene_rect.center()
|
||
return QPointF(0.0, 0.0)
|
||
|
||
def _grid_origin_scene_units(self) -> tuple[float, float]:
|
||
if self.document is None or not self.document.points:
|
||
return 0.0, 0.0
|
||
points = [point for point in self.document.points if not point.manual] or self.document.points
|
||
min_row = min(point.row for point in points)
|
||
min_col = min(point.col for point in points)
|
||
anchor = min(points, key=lambda point: (point.y * self._display_scale, point.x * self._display_scale, point.uid))
|
||
anchor_x = anchor.x * self._display_scale
|
||
anchor_y = anchor.y * self._display_scale
|
||
origin_x = anchor_x - (anchor.col - min_col + 0.5) * self._grid_step
|
||
origin_y = anchor_y - (anchor.row - min_row + 0.5) * self._grid_step
|
||
return origin_x, origin_y
|
||
|
||
def _scene_point_from_display_units(self, x: float, y: float) -> QPointF:
|
||
if self._raw_bounds is None:
|
||
return QPointF(x, y)
|
||
min_x, _max_x, _min_y, max_y = self._raw_bounds
|
||
return QPointF((x - min_x) + self._margin, (max_y - y) + self._margin)
|
||
|
||
def _document_point_from_scene(self, scene_pos: QPointF) -> QPointF:
|
||
if self._raw_bounds is None:
|
||
return QPointF(scene_pos.x(), scene_pos.y())
|
||
min_x, _max_x, _min_y, max_y = self._raw_bounds
|
||
display_x = scene_pos.x() - self._margin + min_x
|
||
display_y = max_y - (scene_pos.y() - self._margin)
|
||
scale = self._display_scale or 1.0
|
||
return QPointF(display_x / scale, display_y / scale)
|
||
|
||
@staticmethod
|
||
def _move_scrollbar(scrollbar: QScrollBar, delta: int) -> None:
|
||
scrollbar.setValue(scrollbar.value() - delta)
|
||
|
||
def export_to_image(self, file_path: str) -> bool:
|
||
scene = self.scene()
|
||
if scene is None:
|
||
return False
|
||
|
||
rect = scene.itemsBoundingRect()
|
||
if rect.isEmpty():
|
||
return False
|
||
|
||
# 1. Add temporary badges for start/end points of each block
|
||
from PySide6.QtGui import QFont, QPen, QBrush, QColor
|
||
from PySide6.QtWidgets import QGraphicsRectItem, QGraphicsSimpleTextItem
|
||
|
||
def create_badge(text: str, bg_color: QColor, pos: QPointF, offset_y: float) -> QGraphicsRectItem:
|
||
text_item = QGraphicsSimpleTextItem(text)
|
||
font = QFont("Outfit", 8)
|
||
font.setBold(True)
|
||
text_item.setFont(font)
|
||
text_item.setBrush(QBrush(Qt.white))
|
||
|
||
rect = text_item.boundingRect()
|
||
px, py = 6, 4
|
||
w = rect.width() + px * 2
|
||
h = rect.height() + py * 2
|
||
|
||
rect_item = QGraphicsRectItem(-w/2, -h/2, w, h)
|
||
rect_item.setBrush(QBrush(bg_color))
|
||
rect_item.setPen(QPen(Qt.white, 1.2))
|
||
rect_item.setZValue(100)
|
||
|
||
text_item.setParentItem(rect_item)
|
||
text_item.setPos(-rect.width()/2 - rect.left(), -rect.height()/2 - rect.top())
|
||
|
||
rect_item.setPos(pos.x(), pos.y() + offset_y)
|
||
return rect_item
|
||
|
||
temp_items = []
|
||
for line in self.document.lines:
|
||
current_offset = 0
|
||
for block in line.blocks:
|
||
if not block.point_ids:
|
||
continue
|
||
block_len = len(block.point_ids)
|
||
first_pid = block.point_ids[0]
|
||
last_pid = block.point_ids[-1]
|
||
|
||
start_idx = current_offset
|
||
end_idx = current_offset + block_len - 1
|
||
current_offset += block_len
|
||
|
||
# Get scene positions
|
||
if first_pid in self.items_by_id:
|
||
pos_start = self.items_by_id[first_pid].pos()
|
||
point_start = self.document.points[first_pid]
|
||
if last_pid == first_pid:
|
||
text_start = f"S=E: ID {point_start.source_id} (#{start_idx})"
|
||
badge_start = create_badge(text_start, QColor("#3b82f6"), pos_start, offset_y=-18) # Blue badge
|
||
else:
|
||
text_start = f"S: ID {point_start.source_id} (#{start_idx})"
|
||
badge_start = create_badge(text_start, QColor("#10b981"), pos_start, offset_y=-18) # Green badge
|
||
scene.addItem(badge_start)
|
||
temp_items.append(badge_start)
|
||
|
||
if last_pid in self.items_by_id and last_pid != first_pid:
|
||
pos_end = self.items_by_id[last_pid].pos()
|
||
point_end = self.document.points[last_pid]
|
||
text_end = f"E: ID {point_end.source_id} (#{end_idx})"
|
||
badge_end = create_badge(text_end, QColor("#ef4444"), pos_end, offset_y=18) # Red badge
|
||
scene.addItem(badge_end)
|
||
temp_items.append(badge_end)
|
||
|
||
# 2. Render setup
|
||
target_height = 2000
|
||
mapping_width = 2000
|
||
sidebar_width = 800
|
||
total_width = mapping_width + sidebar_width
|
||
|
||
image = QImage(total_width, target_height, QImage.Format.Format_ARGB32)
|
||
|
||
# Draw background for the mapping area
|
||
bg_brush = scene.backgroundBrush()
|
||
mapping_bg = bg_brush.color() if bg_brush.style() != Qt.NoBrush else QColor("#2b2b2b" if self._theme == "dark" else "#ffffff")
|
||
|
||
painter = QPainter(image)
|
||
painter.setRenderHint(QPainter.Antialiasing)
|
||
painter.setRenderHint(QPainter.TextAntialiasing)
|
||
|
||
# Fill the entire image with mapping background initially
|
||
image.fill(mapping_bg)
|
||
|
||
# Adjust scene rect with padding for mapping render
|
||
padding = max(rect.width(), rect.height()) * 0.05
|
||
rect.adjust(-padding, -padding, padding, padding)
|
||
|
||
# Render the scene mapping area on the left
|
||
scene.render(painter, QRectF(0, 0, mapping_width, target_height), rect)
|
||
|
||
# 3. Draw sidebar legend on the right
|
||
sidebar_rect = QRectF(mapping_width, 0, sidebar_width, target_height)
|
||
|
||
# Fill sidebar background
|
||
painter.setPen(Qt.NoPen)
|
||
sidebar_bg = QColor("#1e293b" if self._theme == "dark" else "#f8fafc")
|
||
painter.setBrush(QBrush(sidebar_bg))
|
||
painter.drawRect(sidebar_rect)
|
||
|
||
# Draw vertical divider line
|
||
painter.setPen(QPen(QColor("#475569" if self._theme == "dark" else "#cbd5e1"), 2))
|
||
painter.drawLine(mapping_width, 0, mapping_width, target_height)
|
||
|
||
# Fonts
|
||
font_title = QFont("Outfit", 20)
|
||
font_title.setBold(True)
|
||
font_line = QFont("Outfit", 14)
|
||
font_line.setBold(True)
|
||
font_block = QFont("Outfit", 11)
|
||
|
||
# Title
|
||
painter.setFont(font_title)
|
||
painter.setPen(QColor("#ffffff" if self._theme == "dark" else "#0f172a"))
|
||
title_rect = QRectF(mapping_width + 40, 40, sidebar_width - 80, 50)
|
||
painter.drawText(title_rect, Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, "ЛЕГЕНДА МАППИНГА")
|
||
|
||
# Draw lines and blocks
|
||
y = 120
|
||
for line in self.document.lines:
|
||
if y > target_height - 60:
|
||
break
|
||
|
||
# Line swatch
|
||
swatch_rect = QRectF(mapping_width + 40, y + 4, 24, 24)
|
||
painter.setPen(Qt.NoPen)
|
||
painter.setBrush(QBrush(QColor(line.color)))
|
||
painter.drawRoundedRect(swatch_rect, 4, 4)
|
||
|
||
# Line name
|
||
painter.setFont(font_line)
|
||
painter.setPen(QColor("#f8fafc" if self._theme == "dark" else "#1e293b"))
|
||
line_text_rect = QRectF(mapping_width + 80, y, sidebar_width - 120, 32)
|
||
painter.drawText(line_text_rect, Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, line.name)
|
||
y += 40
|
||
|
||
# Blocks under line
|
||
painter.setFont(font_block)
|
||
painter.setPen(QColor("#94a3b8" if self._theme == "dark" else "#64748b"))
|
||
|
||
current_offset = 0
|
||
for block in line.blocks:
|
||
if y > target_height - 40:
|
||
break
|
||
block_len = len(block.point_ids)
|
||
if block_len == 0:
|
||
block_range_str = "empty"
|
||
else:
|
||
start_idx = current_offset
|
||
end_idx = current_offset + block_len - 1
|
||
block_range_str = f"{start_idx}-{end_idx}"
|
||
current_offset += block_len
|
||
|
||
block_text = f"• {block.name}: {block_range_str}"
|
||
block_text_rect = QRectF(mapping_width + 100, y, sidebar_width - 140, 24)
|
||
painter.drawText(block_text_rect, Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, block_text)
|
||
y += 28
|
||
|
||
y += 12
|
||
|
||
painter.end()
|
||
|
||
# 4. Remove temporary badges from the scene
|
||
for item in temp_items:
|
||
scene.removeItem(item)
|
||
|
||
return image.save(file_path)
|