LED-mapper/canvas.py
Danil Iarosh 691bfee068 Implement LED layout editing tools and infinite grid mode
- Добавлена bbox-подсветка с синим пунктиром вокруг выделенных светодиодов
- Реализовано перетаскивание выделенной группы светодиодов за любую область внутри bbox-рамки
- Добавлен инструмент «Выровнять по сетке» (Snap to Grid) на правую панель
- Добавлена поддержка бесконечной сетки во все стороны в режиме редактирования
- Исправлено прореживание и масштабирование сетки в режиме редактирования
- Добавлены пункты выделения линий и блоков по правому клику мыши
- Стабилизировано положение камеры при добавлении точек (устранено смещение scrolling-вьюпорта)
2026-08-07 14:03:41 +03:00

1978 lines
85 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import math
from pathlib import Path
from PySide6.QtCore import QPointF, QRectF, QSize, Qt, Signal
from PySide6.QtGui import QColor, QBrush, QImage, QImageReader, QPainter, QPainterPath, QPen, QPixmap, QTransform
from PySide6.QtWidgets import (
QApplication,
QGraphicsEllipseItem,
QGraphicsItem,
QGraphicsLineItem,
QGraphicsPathItem,
QGraphicsPixmapItem,
QGraphicsRectItem,
QGraphicsScene,
QGraphicsSimpleTextItem,
QGraphicsView,
QMenu,
QScrollBar,
)
from shiboken6 import isValid
try:
from .models import BackgroundImage, LedLine, LedPoint, MappingDocument, coordinate_display_scale
except ImportError:
from models import BackgroundImage, LedLine, LedPoint, MappingDocument, coordinate_display_scale
MAX_BACKGROUND_PIXMAP_SIDE = 8192
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.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[str, int | None] | None = None
self._background_pixmap_cache: QPixmap | None = None
self._background_loaded = False
self._direction_arrows_visible = True
self._line_index_labels_visible = True
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.scene().setBackgroundBrush(QBrush(QColor("#2b2b2b")))
self.scene().selectionChanged.connect(self._emit_selection)
self.setToolTip("Выделение рамкой: перетащите мышью. Масштаб: колесо. Del: снять фиксацию выбранных LED.")
def set_document(self, document: MappingDocument, *, reset_view: bool = True) -> None:
self.document = document
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
self._edit_mode_enabled = enabled
self._update_scene_rect()
self.rebuild(reset_view=False)
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_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_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 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._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._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 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
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():
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, path: Path) -> QPixmap:
cache_key = (str(path), path.stat().st_mtime_ns if path.exists() else None)
if self._background_pixmap_cache_key == cache_key and self._background_pixmap_cache is not None:
return self._background_pixmap_cache
reader = QImageReader(str(path))
size = reader.size()
if size.isValid() and max(size.width(), size.height()) > MAX_BACKGROUND_PIXMAP_SIDE:
factor = MAX_BACKGROUND_PIXMAP_SIDE / max(size.width(), size.height())
reader.setScaledSize(QSize(max(1, int(size.width() * factor)), max(1, int(size.height() * factor))))
image = reader.read()
pixmap = QPixmap.fromImage(image) if not image.isNull() else QPixmap()
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)
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)
scene_rect = self.scene().sceneRect()
if not scene_rect.contains(visible_rect):
self.scene().setSceneRect(scene_rect.united(visible_rect))
self.centerOn(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)
if self._brush_enabled:
self._update_brush_zone(viewport_pos)
self._position_line_index_labels()
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)
doc_dist = dist / (self._display_scale or 1.0)
if doc_dist >= 34.0:
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()
# Collect all LedItems inside the rubber band rectangle
items_in_rect = self.scene().items(rect, Qt.IntersectsItemBoundingRect)
hit_ids = [item.point_id for item in items_in_rect if isinstance(item, LedItem)]
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]
radius = selected_items[0].rect().width() / 2.0
padding = radius + 4.0
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
}
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 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))
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,
item.pos().y() - scene_radius - scene_gap,
)
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()
left_x = min_x - self._margin
right_x = max_x + self._margin
bottom_y = min_y - self._margin
top_y = max_y + self._margin
scene_rect = self.scene().sceneRect()
left_x = min(left_x, scene_rect.left() + min_x - self._margin)
right_x = max(right_x, scene_rect.right() + min_x - self._margin)
top_y = max(top_y, max_y + self._margin - scene_rect.top())
bottom_y = min(bottom_y, max_y + self._margin - scene_rect.bottom())
first_col = math.floor((left_x - origin_x) / self._grid_step)
last_col = math.ceil((right_x - origin_x) / self._grid_step)
first_row = math.floor((bottom_y - origin_y) / self._grid_step)
last_row = math.ceil((top_y - origin_y) / self._grid_step)
grid_span = (last_col - first_col) + (last_row - first_row)
step_multiplier = 1
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)