LED-mapper/canvas.py

609 lines
25 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
inner_path = 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._add_connection_path(inner_path, line.color, inner_width, inner_alpha)
previous_block_end = None
bridge_path = 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())
if block_end is not None:
previous_block_end = block_end
self._add_connection_path(bridge_path, line.color, bridge_width, bridge_alpha)
@staticmethod
def _append_connection(path: QPainterPath, start: QPointF, end: QPointF) -> None:
path.moveTo(start)
path.lineTo(end)
def _add_connection_path(self, path: QPainterPath, color: str, width: float, alpha: float) -> None:
if path.isEmpty():
return
pen_color = QColor(color)
pen_color.setAlphaF(alpha)
item = self.scene().addPath(path, QPen(pen_color, width))
item.setZValue(1)
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)