641 lines
27 KiB
Python
641 lines
27 KiB
Python
from __future__ import annotations
|
|
|
|
import math
|
|
|
|
from PySide6.QtCore import QPointF, QRectF, Qt, Signal
|
|
from PySide6.QtGui import QColor, QBrush, QPainter, QPainterPath, QPen
|
|
from PySide6.QtWidgets import (
|
|
QGraphicsEllipseItem,
|
|
QGraphicsItem,
|
|
QGraphicsLineItem,
|
|
QGraphicsPathItem,
|
|
QGraphicsScene,
|
|
QGraphicsView,
|
|
QMenu,
|
|
QScrollBar,
|
|
)
|
|
from shiboken6 import isValid
|
|
|
|
try:
|
|
from .models import LedLine, LedPoint, MappingDocument, coordinate_display_scale
|
|
except ImportError:
|
|
from models import LedLine, LedPoint, MappingDocument, coordinate_display_scale
|
|
|
|
|
|
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()
|
|
autoBlockFromPointRequested = Signal(int)
|
|
brushPointTouched = Signal(int)
|
|
brushStrokeFinished = Signal()
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.setScene(QGraphicsScene(self))
|
|
self.setRenderHint(QPainter.Antialiasing)
|
|
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._point_radius = 4.5
|
|
self._brush_radius = 18.0
|
|
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._display_scale = 1.0
|
|
self._margin = 40.0
|
|
self._is_middle_button_panning = False
|
|
self._last_pan_pos = None
|
|
self._brush_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().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()
|
|
|
|
def set_grid_visible(self, visible: bool) -> None:
|
|
self._grid_visible = visible
|
|
self._refresh_grid()
|
|
|
|
def set_brush_enabled(self, enabled: bool) -> None:
|
|
self._brush_enabled = enabled
|
|
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_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 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._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
|
|
if self.document is None or not self.document.points:
|
|
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.scene().setSceneRect(
|
|
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_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()
|
|
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)
|
|
self._refresh_brush_preview_styles(self._brush_preview_ids | self._brush_touched_ids)
|
|
|
|
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
|
|
scene = self.scene()
|
|
scene.blockSignals(True)
|
|
self.setUpdatesEnabled(False)
|
|
try:
|
|
for item in self.items_by_id.values():
|
|
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 fit_to_content(self) -> None:
|
|
if self.scene() is None or self.sceneRect().isEmpty():
|
|
return
|
|
self.fitInView(self.sceneRect(), Qt.KeepAspectRatio)
|
|
|
|
def wheelEvent(self, event) -> None: # noqa: ANN001
|
|
factor = 1.15 if event.angleDelta().y() > 0 else 1 / 1.15
|
|
self.scale(factor, factor)
|
|
if self._brush_enabled and self._last_brush_viewport_pos is not None:
|
|
self._update_brush_zone(self._last_brush_viewport_pos)
|
|
|
|
def keyPressEvent(self, event) -> None: # noqa: ANN001
|
|
if event.key() == Qt.Key_Delete:
|
|
self.deleteSelectionRequested.emit()
|
|
event.accept()
|
|
return
|
|
super().keyPressEvent(event)
|
|
|
|
def mousePressEvent(self, event) -> None: # noqa: ANN001
|
|
if event.button() == Qt.RightButton:
|
|
event.accept()
|
|
return
|
|
if event.button() == Qt.MiddleButton:
|
|
self._is_middle_button_panning = True
|
|
self._last_pan_pos = event.pos()
|
|
self.setCursor(Qt.ClosedHandCursor)
|
|
self.viewport().setCursor(Qt.ClosedHandCursor)
|
|
event.accept()
|
|
return
|
|
if self._brush_enabled and event.button() == Qt.LeftButton:
|
|
self._is_brush_stroke = True
|
|
self._brush_touched_ids.clear()
|
|
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
|
|
if self._is_middle_button_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._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
|
|
if event.button() == Qt.RightButton:
|
|
event.accept()
|
|
return
|
|
if event.button() == Qt.MiddleButton and self._is_middle_button_panning:
|
|
self._is_middle_button_panning = False
|
|
self._last_pan_pos = None
|
|
self._apply_default_cursor()
|
|
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 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:
|
|
cursor_shape = Qt.CrossCursor if self._brush_enabled else Qt.ArrowCursor
|
|
self.setCursor(cursor_shape)
|
|
self.viewport().setCursor(cursor_shape)
|
|
|
|
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())
|
|
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 == 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.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 _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
|
|
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)
|
|
pen = QPen(QColor("#334155"), 0.6)
|
|
pen.setCosmetic(True)
|
|
|
|
for col in range(first_col, last_col + 1):
|
|
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):
|
|
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:]):
|
|
self._append_connection(inner_path, first.pos(), second.pos())
|
|
self._append_arrow_marker(inner_arrows, first.pos(), second.pos())
|
|
self._add_connection_path(inner_path, line.color, inner_width, inner_alpha)
|
|
self._add_connection_path(inner_arrows, line.color, arrow_width, arrow_alpha, z_value=2)
|
|
|
|
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:
|
|
self._append_connection(bridge_path, previous_block_end.pos(), block_start.pos())
|
|
self._append_arrow_marker(bridge_arrows, previous_block_end.pos(), block_start.pos())
|
|
if block_end is not None:
|
|
previous_block_end = block_end
|
|
self._add_connection_path(bridge_path, line.color, bridge_width, bridge_alpha)
|
|
self._add_connection_path(bridge_arrows, line.color, arrow_width, bridge_alpha, z_value=2)
|
|
|
|
@staticmethod
|
|
def _append_connection(path: QPainterPath, start: QPointF, end: QPointF) -> None:
|
|
path.moveTo(start)
|
|
path.lineTo(end)
|
|
|
|
@staticmethod
|
|
def _append_arrow_marker(path: QPainterPath, start: QPointF, end: QPointF) -> None:
|
|
dx = end.x() - start.x()
|
|
dy = end.y() - start.y()
|
|
length = math.hypot(dx, dy)
|
|
if length < 8.0:
|
|
return
|
|
unit_x = dx / length
|
|
unit_y = dy / length
|
|
mid_x = start.x() + dx * 0.55
|
|
mid_y = start.y() + dy * 0.55
|
|
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 _add_connection_path(self, path: QPainterPath, color: str, width: float, alpha: float, *, z_value: float = 1) -> None:
|
|
if path.isEmpty():
|
|
return
|
|
pen_color = QColor(color)
|
|
pen_color.setAlphaF(alpha)
|
|
item = self.scene().addPath(path, QPen(pen_color, width))
|
|
item.setZValue(z_value)
|
|
self.connection_items.append(item)
|
|
|
|
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 _grid_origin_scene_units(self) -> tuple[float, float]:
|
|
if self.document is None or not self.document.points:
|
|
return 0.0, 0.0
|
|
points = 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)
|
|
|
|
@staticmethod
|
|
def _move_scrollbar(scrollbar: QScrollBar, delta: int) -> None:
|
|
scrollbar.setValue(scrollbar.value() - delta)
|