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)