- Добавлено полное удаление выбранных LED-точек через контекстное меню холста, таблицы и горячую клавишу Ctrl+Del. - Разрешено редактирование координат X и Y для всех точек схемы, а не только для созданных вручную. - После удаления точек пересчитываются UID, связи блоков и индексы линий без повреждения маршрутов. - UID в таблице теперь показывает исходный id точки, сохраняя внутренний идентификатор для выбора и операций. - Добавлена настройка распределения высоты внутренних областей боковой панели. - Расширены smoke-тесты на удаление точек, редактирование координат, пересчет блоков и состояние интерфейса.
1415 lines
60 KiB
Python
1415 lines
60 KiB
Python
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, QImageReader, QPainter, QPainterPath, QPen, QPixmap, QTransform
|
|
from PySide6.QtWidgets import (
|
|
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()
|
|
autoBlockFromPointRequested = 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.RubberBandDrag)
|
|
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._brush_enabled = False
|
|
self._add_led_enabled = False
|
|
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.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_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 else QGraphicsView.RubberBandDrag)
|
|
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_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()
|
|
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
|
|
|
|
self._display_scale = coordinate_display_scale(self.document.points)
|
|
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, max_x = min(xs), max(xs)
|
|
min_y, max_y = min(ys), max(ys)
|
|
self._raw_bounds = (min_x, max_x, min_y, max_y)
|
|
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)
|
|
|
|
self._content_scene_rect = QRectF(
|
|
0,
|
|
0,
|
|
max(max_x - min_x, 1.0) + self._margin * 2,
|
|
max(max_y - 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)
|
|
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)
|
|
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
|
|
self.fitInView(self.sceneRect(), 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
|
|
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:
|
|
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
|
|
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._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
|
|
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 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 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
|
|
super().mouseReleaseEvent(event)
|
|
|
|
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._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.itemAt(event.pos())
|
|
point_id = item.point_id if isinstance(item, LedItem) and isValid(item) else self._single_selected_point_id()
|
|
|
|
menu = QMenu(self)
|
|
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)
|
|
fit_action = menu.addAction("Показать все точки")
|
|
fit_action.setEnabled(self.document is not None and bool(self.document.points))
|
|
chosen = menu.exec(event.globalPos())
|
|
if 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 == fit_action:
|
|
self.fit_to_content()
|
|
|
|
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.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)
|
|
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 = max(1, math.ceil(grid_span / 800))
|
|
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)
|