from __future__ import annotations import math from PySide6.QtCore import QPointF, QRectF, Qt, QTimer, Signal from PySide6.QtGui import QColor, QBrush, QFont, QPainter, QPainterPath, QPen, QPixmap, QTransform from PySide6.QtWidgets import ( QApplication, QGraphicsEllipseItem, QGraphicsItem, QGraphicsLineItem, QGraphicsPathItem, QGraphicsPixmapItem, QGraphicsRectItem, QGraphicsScene, QGraphicsSimpleTextItem, QGraphicsView, QMenu, QScrollBar, ) from shiboken6 import isValid try: from .background_image_loader import background_cache_key, load_background_image from .models import BackgroundImage, LedLine, LedPoint, MappingDocument, coordinate_display_scale except ImportError: from background_image_loader import background_cache_key, load_background_image from models import BackgroundImage, LedLine, LedPoint, MappingDocument, coordinate_display_scale MAX_GRID_LINES = 800 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.setFlag(QGraphicsItem.ItemIgnoresTransformations, True) self.setToolTip(self._tooltip(point)) self.setZValue(10) @staticmethod def _tooltip(point: LedPoint) -> str: line = "-" if point.line_id is None else str(point.line_id) block = "-" if point.block_id is None else str(point.block_id) line_index = "-" if point.line_id is None or point.line_index is None else f"Л{point.line_id}: {point.line_index}" block_index = "-" if point.block_index is None else str(point.block_index) return ( f"Источник: {point.source_id}\n" f"Сетка: ряд {point.row}, колонка {point.col}\n" f"Линия: {line}, блок: {block}\n" f"Индекс в линии: {line_index}\n" f"Индекс в блоке: {block_index}" ) class LedCanvas(QGraphicsView): selectionChanged = Signal(list) deleteSelectionRequested = Signal() deletePointsRequested = Signal() editPointRequested = Signal(int) pointMoveFinished = Signal(int, float, float) pointsMoveFinished = Signal(list) autoBlockFromPointRequested = Signal(int) counterclockwiseBlockFromPointRequested = Signal(int) brushStrokeStarted = Signal(bool) brushPointTouched = Signal(int) brushStrokeFinished = Signal() addLedRequested = Signal(float, float) backgroundEditStarted = Signal() backgroundChanged = Signal() backgroundEditFinished = Signal(bool) def __init__(self) -> None: super().__init__() self.setScene(QGraphicsScene(self)) self.setRenderHint(QPainter.Antialiasing) self.setRenderHint(QPainter.SmoothPixmapTransform) self.setMouseTracking(True) self.viewport().setMouseTracking(True) self.setDragMode(QGraphicsView.NoDrag) self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) self.setResizeAnchor(QGraphicsView.AnchorUnderMouse) self.document: MappingDocument | None = None self.active_line_id: int | None = None self.items_by_id: dict[int, LedItem] = {} self.grid_items: list[QGraphicsLineItem] = [] self.connection_items: list[QGraphicsPathItem] = [] self.line_index_label_items: dict[int, QGraphicsSimpleTextItem] = {} self._background_item: QGraphicsPixmapItem | None = None self._background_outline: QGraphicsRectItem | None = None self._background_handles: dict[str, QGraphicsRectItem] = {} self._background_edit_mode = False self._background_drag: dict | None = None self._background_pixmap_cache_key: tuple[object, ...] | None = None self._background_pixmap_cache: QPixmap | None = None self._background_load_error = "" self._background_loaded = False self._direction_arrows_visible = True self._line_index_labels_visible = True self._line_index_font_size_px = 12 self._point_radius = 4.5 self._brush_radius = 18.0 self._zoom_focus_pull = 0.35 self._brush_zone_item: QGraphicsEllipseItem | None = None self._grid_visible = False self._grid_step = 34.0 self._raw_bounds: tuple[float, float, float, float] | None = None self._content_scene_rect: QRectF | None = None self._display_scale = 1.0 self._margin = 40.0 self._theme = "dark" self._pan_mode = "middle_space" self._instant_space_pan = False self._space_pressed = False self._is_panning = False self._last_pan_pos = None self._last_mouse_pos = None self._point_drag: dict | None = None self._rubber_origin: QPointF | None = None self._rubber_item: QGraphicsRectItem | None = None self._selection_bbox_item: QGraphicsRectItem | None = None self._brush_enabled = False self._add_led_enabled = False self._edit_tool = "select" self._is_drawing_leds = False self._last_drawn_pos = None self._is_brush_stroke = False self._brush_touched_ids: set[int] = set() self._brush_preview_ids: set[int] = set() self._brush_excluded_point_ids: set[int] = set() self._last_brush_viewport_pos = None self._last_selected_ids: set[int] = set() self._edit_mode_enabled = False self._pending_edit_view_state: tuple[QTransform, QPointF, QRectF] | None = None self.scene().setBackgroundBrush(QBrush(QColor("#2b2b2b"))) self.scene().selectionChanged.connect(self._emit_selection) self.horizontalScrollBar().valueChanged.connect(self._on_grid_view_changed) self.verticalScrollBar().valueChanged.connect(self._on_grid_view_changed) self.setToolTip("Выделение рамкой: перетащите мышью. Масштаб: колесо. Del: снять фиксацию выбранных LED.") def set_document(self, document: MappingDocument, *, reset_view: bool = True) -> None: if document is not self.document: self._stable_display_scale = None self._stable_edit_bounds = None self.document = document self._grid_step = min(1000.0, max(1.0, float(getattr(document, "grid_step_mm", 34.0)))) self.rebuild(reset_view=reset_view) def set_edit_tool(self, tool: str) -> None: if tool not in ("select", "move", "draw"): return self._clear_rubber_band() self._edit_tool = tool if tool == "select": self.setDragMode(QGraphicsView.NoDrag) self._add_led_enabled = False elif tool == "move": self.setDragMode(QGraphicsView.NoDrag) self._add_led_enabled = False elif tool == "draw": self.setDragMode(QGraphicsView.NoDrag) self._add_led_enabled = True self._apply_default_cursor() def set_edit_mode_enabled(self, enabled: bool) -> None: if self._edit_mode_enabled == enabled: return view_state = self._capture_view_state() self._pending_edit_view_state = view_state self._edit_mode_enabled = enabled self._update_scene_rect() self._restore_view_state(view_state) self._refresh_grid() self._position_line_index_labels() self._update_selection_bbox() # Scroll bars are laid out by Qt on the next event-loop pass. Restore # once more afterwards so their appearance/disappearance cannot move # the scheme by several screen pixels. QTimer.singleShot(0, self._restore_pending_edit_view_state) def _restore_pending_edit_view_state(self) -> None: state = self._pending_edit_view_state if state is None: return self._pending_edit_view_state = None self._restore_view_state(state) self._refresh_grid() self._position_line_index_labels() self._update_selection_bbox() 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_grid_step_mm(self, step_mm: float) -> None: self._grid_step = min(1000.0, max(1.0, float(step_mm))) self._refresh_grid() def _on_grid_view_changed(self, _value: int) -> None: self._refresh_grid() def set_point_diameter(self, diameter_px: int) -> None: radius = max(0.5, min(32.0, float(diameter_px) / 2.0)) if abs(radius - self._point_radius) < 1e-9: return self._point_radius = radius for item in self.items_by_id.values(): if item is not None and isValid(item): item.setRect(-radius, -radius, radius * 2.0, radius * 2.0) self._update_led_content_rect() self._update_scene_rect() self._update_selection_bbox() self._position_line_index_labels() if self._last_brush_viewport_pos is not None: self._update_brush_preview(self._last_brush_viewport_pos) 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_line_index_font_size(self, font_size_px: int) -> None: font_size = max(1, min(256, int(font_size_px))) if self._line_index_font_size_px == font_size: return self._line_index_font_size_px = font_size for label in self.line_index_label_items.values(): if label is not None and isValid(label): font = label.font() font.setPixelSize(font_size) label.setFont(font) self._position_line_index_labels() def set_brush_radius(self, radius: int) -> None: self._brush_radius = float(max(4, min(80, radius))) if self._brush_zone_item is not None and isValid(self._brush_zone_item): self.scene().removeItem(self._brush_zone_item) self._brush_zone_item = None if self._brush_enabled: self._ensure_brush_zone() if self._last_brush_viewport_pos is not None: self._update_brush_zone(self._last_brush_viewport_pos) def set_zoom_focus_pull(self, percent: int) -> None: self._zoom_focus_pull = max(0.0, min(1.0, percent / 100.0)) def set_pan_mode(self, mode: str) -> None: self._pan_mode = mode if mode in {"middle", "space", "middle_space"} else "middle_space" if self._pan_mode == "middle" and self._space_pressed: self._space_pressed = False self._apply_default_cursor() def set_instant_space_pan(self, enabled: bool) -> None: self._instant_space_pan = enabled if not enabled and self._is_panning and self._space_pressed: self._finish_pan() def set_theme(self, theme: str) -> None: next_theme = "dark" if theme == "dark" else "light" if self._theme == next_theme: return self._theme = next_theme background = "#2b2b2b" if self._theme == "dark" else "#ffffff" self.scene().setBackgroundBrush(QBrush(QColor(background))) self._refresh_grid() self.refresh_styles(redraw_connections=True) def set_brush_enabled(self, enabled: bool) -> None: self._brush_enabled = enabled if enabled: self._add_led_enabled = False self._is_brush_stroke = False self._clear_brush_state() self.setDragMode(QGraphicsView.NoDrag) if enabled: self._ensure_brush_zone() else: self._hide_brush_zone() self._apply_default_cursor() def set_add_led_enabled(self, enabled: bool) -> None: self._add_led_enabled = enabled if enabled: self.set_brush_enabled(False) self.set_background_edit_mode(False) self._apply_default_cursor() def add_led_enabled(self) -> bool: return self._add_led_enabled def set_brush_excluded_point_ids(self, point_ids: set[int] | list[int]) -> None: self._brush_excluded_point_ids = set(point_ids) changed_ids = self._brush_preview_ids | self._brush_touched_ids self._brush_preview_ids.difference_update(self._brush_excluded_point_ids) self._refresh_brush_preview_styles(changed_ids) def set_background_edit_mode(self, enabled: bool) -> None: self._background_edit_mode = enabled if enabled: self._add_led_enabled = False if enabled and self._brush_enabled: self.set_brush_enabled(False) if not enabled: self._background_drag = None self._update_background_handles() self._apply_default_cursor() def background_edit_mode(self) -> bool: return self._background_edit_mode def background_loaded(self) -> bool: return self._background_loaded def background_load_error(self) -> str: return self._background_load_error def validate_background(self, background: BackgroundImage) -> bool: if not background.is_enabled(): self._background_load_error = "Не выбран файл изображения подложки." return False return not self._load_background_pixmap(background).isNull() def set_background(self, background: BackgroundImage) -> None: if self.document is None: return view_state = self._capture_view_state() self.document.background = background self._refresh_background() self._update_scene_rect() self._restore_view_state(view_state) def clear_background(self) -> None: if self.document is None: return view_state = self._capture_view_state() self.document.background = BackgroundImage() self._refresh_background() self._update_scene_rect() self._restore_view_state(view_state) def reset_background_scale(self) -> None: if self.document is None: return view_state = self._capture_view_state() self.document.background.scale_x_percent = 100.0 self.document.background.scale_y_percent = 100.0 self._apply_background_geometry() self._restore_view_state(view_state) self.backgroundChanged.emit() def set_background_visible(self, visible: bool) -> None: if self.document is None or not self.document.background.is_enabled(): return view_state = self._capture_view_state() self.document.background.visible = visible self._apply_background_geometry() self._restore_view_state(view_state) self.backgroundChanged.emit() def rebuild(self, *, reset_view: bool = True) -> None: old_transform = self.transform() old_h_val = self.horizontalScrollBar().value() old_v_val = self.verticalScrollBar().value() old_stable_bounds = getattr(self, "_stable_edit_bounds", None) # Anchor the camera center to a stable point to prevent viewport jump when bounds change ref_uid = None ref_offset = None if self.document is not None and self.document.points: ref_uid = self.document.points[0].uid ref_item = self.items_by_id.get(ref_uid) if ref_item is not None and isValid(ref_item): old_center = self.mapToScene(self.viewport().rect().center()) ref_offset = old_center - ref_item.pos() old_center = self.mapToScene(self.viewport().rect().center()) scene = self.scene() scene.blockSignals(True) self.setUpdatesEnabled(False) try: self.items_by_id.clear() self.grid_items.clear() self.connection_items.clear() self.line_index_label_items.clear() self._background_item = None self._background_outline = None self._background_handles.clear() self._background_drag = None self._brush_zone_item = None self._brush_preview_ids.clear() self._brush_touched_ids.clear() self._last_selected_ids.clear() scene.clear() # Reset items invalidated by scene.clear() self._rubber_item = None self._rubber_origin = None self._selection_bbox_item = None finally: scene.blockSignals(False) self.setUpdatesEnabled(True) self._raw_bounds = None self._content_scene_rect = None if self.document is None or not self.document.points: self._refresh_background() self._update_scene_rect() return # Keep display scale completely stable to prevent grid and camera jumps if reset_view or getattr(self, "_stable_display_scale", None) is None: self._stable_display_scale = coordinate_display_scale(self.document.points) self._display_scale = self._stable_display_scale xs = [point.x * self._display_scale for point in self.document.points] ys = [point.y * self._display_scale for point in self.document.points] min_x = min(xs) if xs else 0.0 max_x = max(xs) if xs else 100.0 min_y = min(ys) if ys else 0.0 max_y = max(ys) if ys else 100.0 if reset_view or getattr(self, "_stable_edit_bounds", None) is None: self._stable_edit_bounds = (min_x, max_x, min_y, max_y) self._raw_bounds = self._stable_edit_bounds self._margin = max(self._point_radius * 4, 40) for point in self.document.points: item = LedItem(point, self._point_radius) item.setPos(self._to_scene_point(point.x, point.y)) self.items_by_id[point.uid] = item self.scene().addItem(item) stable_min_x, stable_max_x, stable_min_y, stable_max_y = self._stable_edit_bounds self._update_led_content_rect() self._refresh_background() self._update_scene_rect() self._refresh_grid() self.refresh_styles() if reset_view: self.fit_to_content() else: self.setTransform(old_transform) # If bounds did not change, restore scrollbars directly to avoid floating point drift if old_stable_bounds is not None and getattr(self, "_stable_edit_bounds", None) == old_stable_bounds: self.horizontalScrollBar().setValue(old_h_val) self.verticalScrollBar().setValue(old_v_val) else: if ref_uid is not None and ref_offset is not None: new_item = self.items_by_id.get(ref_uid) if new_item is not None and isValid(new_item): self.centerOn(new_item.pos() + ref_offset) else: self.centerOn(old_center) else: self.centerOn(old_center) def refresh_styles(self, redraw_connections: bool = True) -> None: if self.document is None: return if redraw_connections: self._clear_connections() self._connection_segments: list[tuple[QPointF, QPointF]] = [] self._connection_segment_buckets: dict[tuple[int, int], list[tuple[QPointF, QPointF]]] = {} line_by_id = {line.id: line for line in self.document.lines} for point in self.document.points: item = self.items_by_id.get(point.uid) if item is None or not isValid(item): continue color = QColor("#94a3b8") if point.line_id in line_by_id: color = QColor(line_by_id[point.line_id].color) if self.active_line_id is not None and point.line_id != self.active_line_id: color.setAlphaF(0.45) item.setBrush(color) is_active_line_point = self.active_line_id is not None and point.line_id == self.active_line_id try: pen_color = QColor("#111827") if item.isSelected() else QColor("#ffffff") item.setPen(QPen(pen_color, 2.0 if is_active_line_point else 1.2)) item.setToolTip(LedItem._tooltip(point)) except RuntimeError: continue self._last_selected_ids = { item.point_id for item in self.scene().selectedItems() if isinstance(item, LedItem) and isValid(item) } if redraw_connections: for line in self.document.lines: self._draw_line_connections(line) else: self._restyle_connection_items() self._refresh_brush_preview_styles(self._brush_preview_ids | self._brush_touched_ids) self._refresh_line_index_labels() def selected_point_ids(self) -> list[int]: return sorted( item.point_id for item in self.scene().selectedItems() if isinstance(item, LedItem) and isValid(item) ) def document_point_positions(self) -> list[tuple[int, float, float]]: """Return every visible LED center converted back to document coordinates.""" positions: list[tuple[int, float, float]] = [] for point_id, item in sorted(self.items_by_id.items()): if item is None or not isValid(item): continue document_point = self._document_point_from_scene(item.pos()) positions.append((point_id, document_point.x(), document_point.y())) return positions 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 pixmap = self._load_background_pixmap(self.document.background) 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, background: BackgroundImage) -> QPixmap: document_path = self.document.path if self.document is not None else None cache_key = background_cache_key(background, document_path) if self._background_pixmap_cache_key == cache_key and self._background_pixmap_cache is not None: return self._background_pixmap_cache result = load_background_image(background, document_path) self._background_load_error = result.error pixmap = QPixmap.fromImage(result.image) if result.loaded else QPixmap() self._background_pixmap_cache_key = cache_key self._background_pixmap_cache = pixmap return pixmap def _apply_background_geometry(self) -> None: if self.document is None or self._background_item is None or not isValid(self._background_item): self._update_background_handles() return view_state = self._capture_view_state() background = self.document.background origin = self._background_origin_scene_point() scale_x = max(1.0, background.scale_x_percent) / 100.0 scale_y = max(1.0, background.scale_y_percent) / 100.0 source_rect = self._background_item.boundingRect() center_x = origin.x() + background.offset_x center_y = origin.y() + background.offset_y self._background_item.setVisible(background.visible) self._background_item.setOpacity(max(0.0, min(1.0, background.opacity_percent / 100.0))) self._background_item.setPos( center_x - source_rect.width() * scale_x / 2.0, center_y - source_rect.height() * scale_y / 2.0, ) self._background_item.setScale(1.0) self._background_item.setTransformOriginPoint(0, 0) self._background_item.setTransform( QTransform.fromScale( scale_x, scale_y, ) ) self._update_background_handles() self._update_scene_rect() self._restore_view_state(view_state) def _background_scene_rect(self) -> QRectF | None: if self._background_item is None or not isValid(self._background_item): return None if self.document is not None and not self.document.background.visible: return None return self._background_item.sceneBoundingRect() def _update_scene_rect(self) -> None: rect = self._content_scene_rect background_rect = self._background_scene_rect() if rect is None: rect = background_rect elif background_rect is not None: rect = rect.united(background_rect) if rect is None: rect = QRectF(0, 0, 1, 1) if self._edit_mode_enabled: # Expand the scene viewport by 8000 units in edit mode so users can draw and pan without bounds rect = rect.adjusted(-8000, -8000, 8000, 8000) self.scene().setSceneRect(rect) def _update_background_handles(self) -> None: rect = self._background_scene_rect() if not self._background_edit_mode or rect is None: for item in [self._background_outline, *self._background_handles.values()]: if item is not None and isValid(item): self.scene().removeItem(item) self._background_outline = None self._background_handles.clear() return pen = QPen(QColor("#fbbf24"), 1.2) pen.setCosmetic(True) if self._background_outline is None or not isValid(self._background_outline): self._background_outline = self.scene().addRect(rect, pen) self._background_outline.setZValue(60) else: self._background_outline.setRect(rect) self._background_outline.setPen(pen) handle_positions = { "nw": rect.topLeft(), "n": QPointF(rect.center().x(), rect.top()), "ne": rect.topRight(), "e": QPointF(rect.right(), rect.center().y()), "se": rect.bottomRight(), "s": QPointF(rect.center().x(), rect.bottom()), "sw": rect.bottomLeft(), "w": QPointF(rect.left(), rect.center().y()), } for name, point in handle_positions.items(): handle = self._background_handles.get(name) if handle is None or not isValid(handle): handle = QGraphicsRectItem(-4, -4, 8, 8) handle.setBrush(QColor("#fbbf24")) handle.setPen(QPen(QColor("#111827"), 1.0)) handle.setFlag(QGraphicsItem.ItemIgnoresTransformations, True) handle.setZValue(61) handle.setAcceptedMouseButtons(Qt.NoButton) self.scene().addItem(handle) self._background_handles[name] = handle handle.setPos(point) def fit_to_content(self) -> None: self._update_scene_rect() if self.scene() is None or self.sceneRect().isEmpty(): return # Focus camera on actual document content bounds, ignoring expanded edit sceneRect bounds rect = self._content_scene_rect background_rect = self._background_scene_rect() if rect is None: rect = background_rect elif background_rect is not None: rect = rect.united(background_rect) if rect is None or rect.isEmpty(): rect = self.sceneRect() self.fitInView(rect, Qt.KeepAspectRatio) self._refresh_grid() self._position_line_index_labels() self._update_selection_bbox() def resizeEvent(self, event) -> None: # noqa: ANN001 super().resizeEvent(event) self._refresh_grid() 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) current_visible_rect = self.mapToScene(self.viewport().rect()).boundingRect() desired_visible_rect = QRectF( visible_rect.topLeft(), current_visible_rect.size(), ) scene_rect = self.scene().sceneRect() if not scene_rect.contains(desired_visible_rect): self.scene().setSceneRect(scene_rect.united(desired_visible_rect)) self.centerOn(desired_visible_rect.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) self._refresh_grid() if self._brush_enabled: self._update_brush_zone(viewport_pos) self._position_line_index_labels() self._update_selection_bbox() event.accept() def _zoom_at_viewport_pos(self, viewport_pos, factor: float) -> None: # noqa: ANN001 old_anchor = self.transformationAnchor() self.setTransformationAnchor(QGraphicsView.NoAnchor) focus_scene_pos = self.mapToScene(viewport_pos) current_center = self.mapToScene(self.viewport().rect().center()) self.scale(factor, factor) self._ensure_zoom_padding() pull_strength = self._zoom_focus_pull target_center = QPointF( current_center.x() + (focus_scene_pos.x() - current_center.x()) * pull_strength, current_center.y() + (focus_scene_pos.y() - current_center.y()) * pull_strength, ) self.centerOn(target_center) self.setTransformationAnchor(old_anchor) def _ensure_zoom_padding(self) -> None: if self.viewport().rect().isEmpty(): return self._update_scene_rect() base_rect = self.scene().sceneRect() visible_scene_rect = self.mapToScene(self.viewport().rect()).boundingRect() pad_x = visible_scene_rect.width() * 0.5 pad_y = visible_scene_rect.height() * 0.5 self.scene().setSceneRect(base_rect.adjusted(-pad_x, -pad_y, pad_x, pad_y)) def keyPressEvent(self, event) -> None: # noqa: ANN001 if event.key() == Qt.Key_Delete: self.deleteSelectionRequested.emit() event.accept() return if event.key() == Qt.Key_Space and not event.isAutoRepeat() and self._can_pan_with_space(): self._space_pressed = True if self._instant_space_pan and self._last_mouse_pos is not None: self._start_pan(self._last_mouse_pos) self._apply_default_cursor() event.accept() return super().keyPressEvent(event) def keyReleaseEvent(self, event) -> None: # noqa: ANN001 if event.key() == Qt.Key_Space and not event.isAutoRepeat(): self._space_pressed = False if self._is_panning: self._is_panning = False self._last_pan_pos = None self._apply_default_cursor() event.accept() return super().keyReleaseEvent(event) def mousePressEvent(self, event) -> None: # noqa: ANN001 self._last_mouse_pos = event.pos() if event.button() == Qt.RightButton: event.accept() return if self._should_start_pan(event.button()): self._start_pan(event.pos()) event.accept() return is_move_tool = getattr(self, "_edit_tool", "select") == "move" has_alt = bool(event.modifiers() & Qt.AltModifier) if event.button() == Qt.LeftButton and (has_alt or is_move_tool) and self._start_point_drag(event.pos()): event.accept() return if self._background_edit_mode and event.button() == Qt.LeftButton and self._start_background_edit(event.pos()): event.accept() return if self._add_led_enabled and event.button() == Qt.LeftButton: if getattr(self, "_edit_tool", "select") == "draw": self._is_drawing_leds = True scene_pos = self.mapToScene(event.pos()) self._last_drawn_pos = scene_pos point = self._document_point_from_scene(scene_pos) self.addLedRequested.emit(point.x(), point.y()) else: point = self._document_point_from_scene(self.mapToScene(event.pos())) self.addLedRequested.emit(point.x(), point.y()) event.accept() return if self._brush_enabled and event.button() == Qt.LeftButton: self._is_brush_stroke = True self._brush_touched_ids.clear() self.brushStrokeStarted.emit(bool(event.modifiers() & Qt.ShiftModifier)) self._update_brush_zone(event.pos()) self._touch_brush_point(event.pos()) event.accept() return # Custom rubber band selection in select mode if event.button() == Qt.LeftButton and self._edit_tool == "select" and not self._brush_enabled: item = self._led_item_at(event.pos()) if item is None: # Click on empty space — start rubber band self._start_rubber_band(self.mapToScene(event.pos())) event.accept() return super().mousePressEvent(event) def mouseMoveEvent(self, event) -> None: # noqa: ANN001 previous_mouse_pos = self._last_mouse_pos self._last_mouse_pos = event.pos() if ( self._instant_space_pan and self._space_pressed and self._can_pan_with_space() and not self._is_panning and previous_mouse_pos is not None ): self._start_pan(previous_mouse_pos) if self._is_panning and self._last_pan_pos is not None: delta = event.pos() - self._last_pan_pos self._last_pan_pos = event.pos() self._move_scrollbar(self.horizontalScrollBar(), delta.x()) self._move_scrollbar(self.verticalScrollBar(), delta.y()) event.accept() return if self._background_drag is not None: self._move_background_edit(event.pos()) event.accept() return if self._point_drag is not None: self._move_point_drag(event.pos()) event.accept() return if getattr(self, "_is_drawing_leds", False) and event.buttons() & Qt.LeftButton: scene_pos = self.mapToScene(event.pos()) if getattr(self, "_last_drawn_pos", None) is not None: dx = scene_pos.x() - self._last_drawn_pos.x() dy = scene_pos.y() - self._last_drawn_pos.y() dist = math.hypot(dx, dy) if dist >= self._grid_step: point = self._document_point_from_scene(scene_pos) self.addLedRequested.emit(point.x(), point.y()) self._last_drawn_pos = scene_pos event.accept() return if self._brush_enabled and self._is_brush_stroke: self._update_brush_zone(event.pos()) self._touch_brush_point(event.pos()) event.accept() return if self._brush_enabled: self._update_brush_zone(event.pos()) event.accept() return # Update custom rubber band if self._rubber_origin is not None and event.buttons() & Qt.LeftButton: self._update_rubber_band(self.mapToScene(event.pos())) event.accept() return super().mouseMoveEvent(event) def mouseReleaseEvent(self, event) -> None: # noqa: ANN001 self._last_mouse_pos = event.pos() if event.button() == Qt.RightButton: event.accept() return if event.button() == Qt.LeftButton and getattr(self, "_is_drawing_leds", False): self._is_drawing_leds = False self._last_drawn_pos = None event.accept() return if self._should_finish_pan(event.button()): self._finish_pan() event.accept() return if event.button() == Qt.LeftButton and self._background_drag is not None: self._finish_background_edit() event.accept() return if event.button() == Qt.LeftButton and self._point_drag is not None: self._finish_point_drag(event.pos()) event.accept() return if self._brush_enabled and event.button() == Qt.LeftButton and self._is_brush_stroke: self._update_brush_zone(event.pos()) self._touch_brush_point(event.pos()) self._is_brush_stroke = False self._clear_brush_state() self.brushStrokeFinished.emit() event.accept() return # Finish custom rubber band selection if event.button() == Qt.LeftButton and self._rubber_origin is not None: self._finish_rubber_band(self.mapToScene(event.pos()), event.modifiers()) event.accept() return super().mouseReleaseEvent(event) # ------------------------------------------------------------------ # Custom rubber-band selection (replaces Qt's built-in RubberBandDrag # which cannot be reliably styled in Qt6/PySide6). # ------------------------------------------------------------------ def _start_rubber_band(self, scene_pos: QPointF) -> None: self._clear_rubber_band() self._rubber_origin = scene_pos # ← сохраняем origin здесь, после clear item = QGraphicsRectItem(QRectF(scene_pos, scene_pos)) item.setZValue(1000) item.setPen(QPen(QColor(59, 130, 246, 220), 1.5, Qt.SolidLine)) item.setBrush(QBrush(QColor(59, 130, 246, 60))) item.setFlag(QGraphicsRectItem.ItemIgnoresTransformations, False) self.scene().addItem(item) self._rubber_item = item def _update_rubber_band(self, scene_pos: QPointF) -> None: if self._rubber_item is None or self._rubber_origin is None: return origin = self._rubber_origin rect = QRectF( min(origin.x(), scene_pos.x()), min(origin.y(), scene_pos.y()), abs(scene_pos.x() - origin.x()), abs(scene_pos.y() - origin.y()), ) self._rubber_item.setRect(rect) def _finish_rubber_band(self, scene_pos: QPointF, modifiers: Qt.KeyboardModifiers) -> None: if self._rubber_origin is None: self._clear_rubber_band() return origin = self._rubber_origin rect = QRectF( min(origin.x(), scene_pos.x()), min(origin.y(), scene_pos.y()), abs(scene_pos.x() - origin.x()), abs(scene_pos.y() - origin.y()), ) self._clear_rubber_band() # Test in viewport pixels: LED items have an absolute on-screen size. viewport_transform = self.viewportTransform() viewport_rect = QRectF(viewport_transform.map(origin), viewport_transform.map(scene_pos)).normalized() hit_ids: list[int] = [] for point_id, item in self.items_by_id.items(): if item is None or not isValid(item): continue center = viewport_transform.map(item.pos()) item_rect = QRectF( center.x() - self._point_radius, center.y() - self._point_radius, self._point_radius * 2.0, self._point_radius * 2.0, ) if viewport_rect.intersects(item_rect) or viewport_rect.contains(center): hit_ids.append(point_id) keep_shift = bool(modifiers & (Qt.ShiftModifier | Qt.ControlModifier)) if keep_shift: current = list(self.selected_point_ids()) merged = list(dict.fromkeys(current + hit_ids)) self.select_points(merged) else: self.select_points(hit_ids) def _clear_rubber_band(self) -> None: if self._rubber_item is not None and isValid(self._rubber_item): self.scene().removeItem(self._rubber_item) self._rubber_item = None self._rubber_origin = None # сбрасываем оба — вызывается только при финале или реинициализации # ------------------------------------------------------------------ # Selection bounding box highlight # ------------------------------------------------------------------ def _update_selection_bbox(self) -> None: """Draw / update a dashed bounding rectangle around all selected LEDs.""" selected_items = [ item for item in self.scene().selectedItems() if isinstance(item, LedItem) and isValid(item) ] if not selected_items: # Hide the bbox rect when nothing is selected if self._selection_bbox_item is not None and isValid(self._selection_bbox_item): self._selection_bbox_item.setVisible(False) return # Compute union bounding rect in scene coords (item pos is centre) xs = [item.pos().x() for item in selected_items] ys = [item.pos().y() for item in selected_items] view_scale = max(abs(self.transform().m11()), 0.001) padding = (self._point_radius + 4.0) / view_scale x_min = min(xs) - padding y_min = min(ys) - padding x_max = max(xs) + padding y_max = max(ys) + padding bbox = QRectF(x_min, y_min, x_max - x_min, y_max - y_min) if self._selection_bbox_item is None or not isValid(self._selection_bbox_item): item = QGraphicsRectItem(bbox) item.setZValue(999) pen = QPen(QColor(99, 179, 255, 200), 1.2, Qt.DashLine) pen.setDashPattern([6, 4]) item.setPen(pen) item.setBrush(QBrush(Qt.NoBrush)) item.setFlag(QGraphicsRectItem.ItemIsSelectable, False) item.setFlag(QGraphicsRectItem.ItemIsFocusable, False) self.scene().addItem(item) self._selection_bbox_item = item else: self._selection_bbox_item.setRect(bbox) self._selection_bbox_item.setVisible(True) def _start_point_drag(self, viewport_pos) -> bool: # noqa: ANN001 if self.document is None: return False item = self._led_item_at(viewport_pos) # Check if click is inside the active selection bounding box clicked_inside_bbox = False if self._selection_bbox_item is not None and isValid(self._selection_bbox_item) and self._selection_bbox_item.isVisible(): scene_pos = self.mapToScene(viewport_pos) if self._selection_bbox_item.rect().contains(scene_pos): clicked_inside_bbox = True if item is None and not clicked_inside_bbox: return False # Check selection and modifiers selected_ids = list(self.selected_point_ids()) if item is not None and item.point_id not in selected_ids: modifiers = QApplication.keyboardModifiers() if modifiers & (Qt.ShiftModifier | Qt.ControlModifier): selected_ids.append(item.point_id) else: selected_ids = [item.point_id] self.select_points(selected_ids) scene_pos = self.mapToScene(viewport_pos) drag_items = [] for pid in selected_ids: it = self.items_by_id.get(pid) if it is not None and isValid(it): drag_items.append({ "point_id": pid, "item": it, "offset": it.pos() - scene_pos, "start_pos": it.pos(), }) self._point_drag = { "items": drag_items, "point_id": item.point_id if item is not None else (selected_ids[0] if len(selected_ids) == 1 else None), } self.setDragMode(QGraphicsView.NoDrag) self.viewport().setCursor(Qt.ClosedHandCursor) return True def _move_point_drag(self, viewport_pos) -> None: # noqa: ANN001 if self._point_drag is None: return scene_pos = self.mapToScene(viewport_pos) for d in self._point_drag.get("items", []): item = d.get("item") if item is not None and isValid(item): offset = d.get("offset", QPointF(0.0, 0.0)) item.setPos(scene_pos + offset) self._position_line_index_labels() self._update_selection_bbox() def _finish_point_drag(self, viewport_pos) -> None: # noqa: ANN001 if self._point_drag is None: return self._move_point_drag(viewport_pos) drag_items = self._point_drag.get("items", []) self._point_drag = None is_move_tool = getattr(self, "_edit_tool", "select") == "move" self.setDragMode(QGraphicsView.NoDrag) self._apply_default_cursor() moves = [] for d in drag_items: item = d.get("item") point_id = d.get("point_id") start_pos = d.get("start_pos") if item is not None and isValid(item) and isinstance(start_pos, QPointF): if (item.pos() - start_pos).manhattanLength() > 0.001: document_point = self._document_point_from_scene(item.pos()) moves.append((point_id, document_point.x(), document_point.y())) if len(moves) == 1: point_id, x, y = moves[0] self.pointMoveFinished.emit(point_id, x, y) elif moves: self.pointsMoveFinished.emit(moves) def _start_background_edit(self, viewport_pos) -> bool: # noqa: ANN001 if self.document is None or self._background_item is None or not isValid(self._background_item): return False if not self.document.background.visible: return False scene_pos = self.mapToScene(viewport_pos) rect = self._background_scene_rect() if rect is None: return False handle_name = self._background_handle_at(viewport_pos) if handle_name is None and not rect.contains(scene_pos): return False start_payload = self.document.background.to_payload() self._background_drag = { "mode": "resize" if handle_name else "move", "handle": handle_name, "start_pos": scene_pos, "start_rect": QRectF(rect), "start_background": BackgroundImage( path=self.document.background.path, offset_x=self.document.background.offset_x, offset_y=self.document.background.offset_y, scale_x_percent=self.document.background.scale_x_percent, scale_y_percent=self.document.background.scale_y_percent, opacity_percent=self.document.background.opacity_percent, visible=self.document.background.visible, ), "start_payload": start_payload, "undo_emitted": False, "changed": False, } self._apply_default_cursor() return True def _move_background_edit(self, viewport_pos) -> None: # noqa: ANN001 if self.document is None or self._background_drag is None: return scene_pos = self.mapToScene(viewport_pos) start_pos: QPointF = self._background_drag["start_pos"] delta = scene_pos - start_pos if abs(delta.x()) < 0.001 and abs(delta.y()) < 0.001: return if not self._background_drag.get("undo_emitted"): self.backgroundEditStarted.emit() self._background_drag["undo_emitted"] = True self._background_drag["changed"] = True start_rect: QRectF = self._background_drag["start_rect"] start_background: BackgroundImage = self._background_drag["start_background"] if self._background_drag["mode"] == "move": self.document.background.offset_x = start_background.offset_x + delta.x() self.document.background.offset_y = start_background.offset_y + delta.y() else: handle = str(self._background_drag["handle"]) rect = QRectF(start_rect) if "w" in handle: rect.setLeft(min(rect.right() - 8.0, rect.left() + delta.x())) if "e" in handle: rect.setRight(max(rect.left() + 8.0, rect.right() + delta.x())) if "n" in handle: rect.setTop(min(rect.bottom() - 8.0, rect.top() + delta.y())) if "s" in handle: rect.setBottom(max(rect.top() + 8.0, rect.bottom() + delta.y())) self._apply_background_rect(rect, start_background, start_rect) self._apply_background_geometry() def _finish_background_edit(self) -> None: changed = bool(self._background_drag and self._background_drag.get("changed")) start_payload = self._background_drag.get("start_payload") if self._background_drag else None self._background_drag = None self._apply_default_cursor() payload_changed = changed and self.document is not None and self.document.background.to_payload() != start_payload if payload_changed: self._update_scene_rect() self.backgroundChanged.emit() self.backgroundEditFinished.emit(bool(payload_changed)) def _background_handle_at(self, viewport_pos) -> str | None: # noqa: ANN001 for name, handle in self._background_handles.items(): if handle is not None and isValid(handle): handle_pos = self.mapFromScene(handle.scenePos()) if abs(handle_pos.x() - viewport_pos.x()) <= 8 and abs(handle_pos.y() - viewport_pos.y()) <= 8: return name return None def _apply_background_rect( self, rect: QRectF, start_background: BackgroundImage, start_rect: QRectF, ) -> None: if self.document is None: return origin = self._background_origin_scene_point() self.document.background.offset_x = rect.center().x() - origin.x() self.document.background.offset_y = rect.center().y() - origin.y() if start_rect.width() > 0: self.document.background.scale_x_percent = max( 1.0, start_background.scale_x_percent * rect.width() / start_rect.width(), ) if start_rect.height() > 0: self.document.background.scale_y_percent = max( 1.0, start_background.scale_y_percent * rect.height() / start_rect.height(), ) def enterEvent(self, event) -> None: # noqa: ANN001 self._apply_default_cursor() super().enterEvent(event) def leaveEvent(self, event) -> None: # noqa: ANN001 self._hide_brush_zone() self._clear_brush_preview() super().leaveEvent(event) def _apply_default_cursor(self) -> None: if self._is_panning: cursor_shape = Qt.ClosedHandCursor elif self._background_drag is not None: cursor_shape = Qt.ClosedHandCursor if self._background_drag.get("mode") == "move" else Qt.SizeAllCursor elif self._point_drag is not None: cursor_shape = Qt.ClosedHandCursor elif self._space_pressed and self._can_pan_with_space(): cursor_shape = Qt.OpenHandCursor elif self._brush_enabled or self._add_led_enabled: cursor_shape = Qt.CrossCursor else: cursor_shape = Qt.ArrowCursor self.setCursor(cursor_shape) self.viewport().setCursor(cursor_shape) def _can_pan_with_middle(self) -> bool: return self._pan_mode in {"middle", "middle_space"} def _can_pan_with_space(self) -> bool: return self._pan_mode in {"space", "middle_space"} def _should_start_pan(self, button: Qt.MouseButton) -> bool: return (button == Qt.MiddleButton and self._can_pan_with_middle()) or ( button == Qt.LeftButton and self._space_pressed and self._can_pan_with_space() ) def _should_finish_pan(self, button: Qt.MouseButton) -> bool: return self._is_panning and ( (button == Qt.MiddleButton and self._can_pan_with_middle()) or (button == Qt.LeftButton and self._space_pressed and self._can_pan_with_space()) ) def _start_pan(self, pos) -> None: # noqa: ANN001 self._is_panning = True self._last_pan_pos = pos self._apply_default_cursor() def _finish_pan(self) -> None: self._is_panning = False self._last_pan_pos = None self._apply_default_cursor() def contextMenuEvent(self, event) -> None: # noqa: ANN001 item = self._led_item_at(event.pos()) point_id = item.point_id if item is not None else self._single_selected_point_id() menu = QMenu(self) edit_action = menu.addAction("Редактировать координаты") edit_action.setEnabled(point_id is not None) delete_action = menu.addAction("Удалить фиксацию выбранных LED") delete_action.setEnabled(self._selection_has_assignment()) delete_points_action = menu.addAction("Удалить выбранные точки") delete_points_action.setEnabled(bool(self.selected_point_ids())) clear_selection_action = menu.addAction("Очистить выбор") clear_selection_action.setEnabled(bool(self.selected_point_ids())) auto_block_action = menu.addAction("Автоматически добавить блок") auto_block_action.setEnabled(point_id is not None) counterclockwise_block_action = menu.addAction("Автоматически добавить блок против часовой") counterclockwise_block_action.setEnabled(point_id is not None) select_block_action = None select_line_action = None point = None if point_id is not None and self.document is not None: try: point = self.document.get_point(point_id) except (IndexError, KeyError): pass if self._edit_mode_enabled and point is not None: line_id = point.line_id block_id = point.block_id if line_id is not None: line_name = f"Линия {line_id}" try: line = self.document.get_line(line_id) line_name = line.name except KeyError: pass menu.addSeparator() select_line_action = menu.addAction(f"Выделить линию '{line_name}'") if block_id is not None: block_name = f"Блок {block_id}" try: block = self.document._get_block(line_id, block_id) block_name = block.name except KeyError: pass select_block_action = menu.addAction(f"Выделить блок '{block_name}'") fit_action = menu.addAction("Показать все точки") fit_action.setEnabled(self.document is not None and bool(self.document.points)) chosen = menu.exec(event.globalPos()) if chosen == edit_action and point_id is not None: self.editPointRequested.emit(point_id) elif chosen == delete_action: self.deleteSelectionRequested.emit() elif chosen == delete_points_action: self.deletePointsRequested.emit() elif chosen == clear_selection_action: self.select_points([]) elif chosen == auto_block_action and point_id is not None: self.autoBlockFromPointRequested.emit(point_id) elif chosen == counterclockwise_block_action and point_id is not None: self.counterclockwiseBlockFromPointRequested.emit(point_id) elif select_line_action is not None and chosen == select_line_action and point is not None and point.line_id is not None: line_point_ids = [p.uid for p in self.document.points if p.line_id == point.line_id] self.select_points(line_point_ids) elif select_block_action is not None and chosen == select_block_action and point is not None and point.line_id is not None and point.block_id is not None: block_point_ids = [p.uid for p in self.document.points if p.line_id == point.line_id and p.block_id == point.block_id] self.select_points(block_point_ids) elif chosen == fit_action: self.fit_to_content() def _led_item_at(self, viewport_pos) -> LedItem | None: # noqa: ANN001 for item in self.items(viewport_pos): if isinstance(item, LedItem) and isValid(item): return item return None def _emit_selection(self) -> None: selected_ids = { item.point_id for item in self.scene().selectedItems() if isinstance(item, LedItem) and isValid(item) } if selected_ids == self._last_selected_ids: return self._refresh_selection_pens(selected_ids ^ self._last_selected_ids) self._last_selected_ids = selected_ids self._refresh_line_index_labels() self._update_selection_bbox() self.selectionChanged.emit(sorted(selected_ids)) def _refresh_selection_pens(self, point_ids: set[int]) -> None: if self.document is None: return for point_id in point_ids: item = self.items_by_id.get(point_id) if item is None or not isValid(item): continue point = self.document.get_point(point_id) is_active_line_point = self.active_line_id is not None and point.line_id == self.active_line_id try: pen_color = QColor("#111827") if item.isSelected() else QColor("#ffffff") item.setPen(QPen(pen_color, 2.0 if is_active_line_point else 1.2)) except RuntimeError: continue def _refresh_line_index_labels(self) -> None: self._clear_line_index_labels() if self.document is None or not self._line_index_labels_visible: return for point_id in sorted(self._last_selected_ids): point = self.document.get_point(point_id) if point.line_id is None or point.line_index is None: continue item = self.items_by_id.get(point_id) if item is None or not isValid(item): continue label = QGraphicsSimpleTextItem(str(point.line_index)) font = QFont(label.font()) font.setPixelSize(self._line_index_font_size_px) label.setFont(font) label.setBrush(QColor("#f8fafc" if self._theme == "dark" else "#111827")) label.setZValue(40) label.setFlag(QGraphicsItem.ItemIgnoresTransformations, True) label.setAcceptedMouseButtons(Qt.NoButton) label.setEnabled(False) self.scene().addItem(label) self.line_index_label_items[point_id] = label self._position_line_index_labels() def _position_line_index_labels(self) -> None: if not self.line_index_label_items: return scale = max(abs(self.transform().m11()), 0.001) scene_gap = 6.0 / scale scene_radius = self._point_radius / scale for point_id, label in list(self.line_index_label_items.items()): item = self.items_by_id.get(point_id) if item is None or not isValid(item) or not isValid(label): continue label_rect = label.boundingRect() label.setPos( item.pos().x() - label_rect.width() / (2.0 * scale), item.pos().y() - scene_radius - scene_gap, ) def _update_led_content_rect(self) -> None: valid_items = [item for item in self.items_by_id.values() if item is not None and isValid(item)] if not valid_items: self._content_scene_rect = None return padding = max(self._point_radius * 4.0, 40.0) xs = [item.pos().x() for item in valid_items] ys = [item.pos().y() for item in valid_items] self._content_scene_rect = QRectF( min(xs) - padding, min(ys) - padding, max(max(xs) - min(xs), 1.0) + padding * 2.0, max(max(ys) - min(ys), 1.0) + padding * 2.0, ) 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() visible_scene_rect = self.mapToScene(self.viewport().rect()).boundingRect() overscan_x = max(self._grid_step, visible_scene_rect.width() * 0.05) overscan_y = max(self._grid_step, visible_scene_rect.height() * 0.05) visible_scene_rect.adjust(-overscan_x, -overscan_y, overscan_x, overscan_y) # Convert the visible scene rectangle back to display units. The # resulting grid density depends only on the camera, never on the # expanded sceneRect used by scheme editing mode. left_x = visible_scene_rect.left() - self._margin + min_x right_x = visible_scene_rect.right() - self._margin + min_x bottom_y = max_y - visible_scene_rect.bottom() + self._margin top_y = max_y - visible_scene_rect.top() + 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) grid_line_count = max(0, last_col - first_col + 1) + max(0, last_row - first_row + 1) step_multiplier = max(1, math.ceil(grid_line_count / MAX_GRID_LINES)) pen = QPen(QColor("#334155" if self._theme == "dark" else "#d1d5db"), 0.6) pen.setCosmetic(True) for col in range(first_col, last_col + 1, step_multiplier): x = origin_x + col * self._grid_step top = self._scene_point_from_display_units(x, top_y) bottom = self._scene_point_from_display_units(x, bottom_y) item = self.scene().addLine(top.x(), top.y(), bottom.x(), bottom.y(), pen) item.setZValue(-20) self.grid_items.append(item) for row in range(first_row, last_row + 1, step_multiplier): y = origin_y + row * self._grid_step left = self._scene_point_from_display_units(left_x, y) right = self._scene_point_from_display_units(right_x, y) item = self.scene().addLine(left.x(), left.y(), right.x(), right.y(), pen) item.setZValue(-20) self.grid_items.append(item) def _draw_line_connections(self, line: LedLine) -> None: is_active = self.active_line_id is None or line.id == self.active_line_id inner_width = 2.8 if is_active else 1.2 inner_alpha = 0.9 if is_active else 0.25 bridge_width = 1.9 if is_active else 1.0 bridge_alpha = 0.65 if is_active else 0.18 arrow_width = 1.8 if is_active else 1.0 arrow_alpha = 0.95 if is_active else 0.28 inner_path = QPainterPath() inner_arrows = QPainterPath() for block in line.blocks: ordered_items = [self.items_by_id[point_id] for point_id in block.point_ids if point_id in self.items_by_id] for first, second in zip(ordered_items, ordered_items[1:]): start = first.pos() end = second.pos() control = self._append_connection(inner_path, start, end) if self._direction_arrows_visible: self._append_arrow_marker(inner_arrows, start, end, control) self._add_connection_path(inner_path, line.color, inner_width, inner_alpha, line_id=line.id, connection_kind="inner") if self._direction_arrows_visible: self._add_connection_path( inner_arrows, line.color, arrow_width, arrow_alpha, z_value=2, line_id=line.id, connection_kind="arrow", ) previous_block_end = None bridge_path = QPainterPath() bridge_arrows = QPainterPath() for block in line.blocks: if not block.point_ids: continue block_start = self.items_by_id.get(block.point_ids[0]) block_end = self.items_by_id.get(block.point_ids[-1]) if block_start is not None and previous_block_end is not None: start = previous_block_end.pos() end = block_start.pos() control = self._append_connection(bridge_path, start, end) if self._direction_arrows_visible: self._append_arrow_marker(bridge_arrows, start, end, control) if block_end is not None: previous_block_end = block_end self._add_connection_path(bridge_path, line.color, bridge_width, bridge_alpha, line_id=line.id, connection_kind="bridge") if self._direction_arrows_visible: self._add_connection_path( bridge_arrows, line.color, arrow_width, bridge_alpha, z_value=2, line_id=line.id, connection_kind="bridge_arrow", ) def _append_connection(self, path: QPainterPath, start: QPointF, end: QPointF) -> QPointF | None: path.moveTo(start) control = self._overlap_control_point(start, end) if control is None: path.lineTo(end) else: path.quadTo(control, end) return control @staticmethod def _append_arrow_marker(path: QPainterPath, start: QPointF, end: QPointF, control: QPointF | None = None) -> None: dx = end.x() - start.x() dy = end.y() - start.y() length = math.hypot(dx, dy) if length < 8.0: return if control is None: unit_x = dx / length unit_y = dy / length mid_x = start.x() + dx * 0.55 mid_y = start.y() + dy * 0.55 else: t = 0.55 inv_t = 1.0 - t mid_x = inv_t * inv_t * start.x() + 2.0 * inv_t * t * control.x() + t * t * end.x() mid_y = inv_t * inv_t * start.y() + 2.0 * inv_t * t * control.y() + t * t * end.y() tangent_x = 2.0 * inv_t * (control.x() - start.x()) + 2.0 * t * (end.x() - control.x()) tangent_y = 2.0 * inv_t * (control.y() - start.y()) + 2.0 * t * (end.y() - control.y()) tangent_length = math.hypot(tangent_x, tangent_y) if tangent_length < 0.001: return unit_x = tangent_x / tangent_length unit_y = tangent_y / tangent_length arrow_length = min(7.0, length * 0.28) arrow_width = arrow_length * 0.55 base_x = mid_x - unit_x * arrow_length base_y = mid_y - unit_y * arrow_length perp_x = -unit_y perp_y = unit_x left = QPointF(base_x + perp_x * arrow_width, base_y + perp_y * arrow_width) right = QPointF(base_x - perp_x * arrow_width, base_y - perp_y * arrow_width) tip = QPointF(mid_x, mid_y) path.moveTo(left) path.lineTo(tip) path.lineTo(right) def _overlap_control_point(self, start: QPointF, end: QPointF) -> QPointF | None: segments = getattr(self, "_connection_segments", None) if segments is None: return None candidates = self._overlap_candidates(start, end) previous_count = sum( 1 for previous_start, previous_end in candidates if self._segments_visually_overlap(start, end, previous_start, previous_end) ) segments.append((QPointF(start), QPointF(end))) buckets = getattr(self, "_connection_segment_buckets", None) if buckets is not None: segment = (QPointF(start), QPointF(end)) for bucket in self._segment_buckets(start, end): buckets.setdefault(bucket, []).append(segment) if previous_count == 0: return None dx = end.x() - start.x() dy = end.y() - start.y() length = math.hypot(dx, dy) if length < 0.001: return None bend = min(16.0, max(7.0, length * 0.24)) side = 1.0 if previous_count % 2 == 1 else -1.0 layer = 1.0 + (previous_count - 1) // 2 offset = side * bend * layer perp_x = -dy / length perp_y = dx / length return QPointF( (start.x() + end.x()) / 2.0 + perp_x * offset, (start.y() + end.y()) / 2.0 + perp_y * offset, ) def _overlap_candidates(self, start: QPointF, end: QPointF) -> list[tuple[QPointF, QPointF]]: buckets = getattr(self, "_connection_segment_buckets", None) if not buckets: return getattr(self, "_connection_segments", []) candidates: list[tuple[QPointF, QPointF]] = [] seen: set[int] = set() for bucket in self._segment_buckets(start, end): for segment in buckets.get(bucket, []): marker = id(segment) if marker not in seen: seen.add(marker) candidates.append(segment) return candidates @staticmethod def _segment_buckets(start: QPointF, end: QPointF) -> set[tuple[int, int]]: bucket_size = 96.0 left = math.floor(min(start.x(), end.x()) / bucket_size) right = math.floor(max(start.x(), end.x()) / bucket_size) top = math.floor(min(start.y(), end.y()) / bucket_size) bottom = math.floor(max(start.y(), end.y()) / bucket_size) return { (x_bucket, y_bucket) for x_bucket in range(left, right + 1) for y_bucket in range(top, bottom + 1) } @staticmethod def _segments_visually_overlap(a_start: QPointF, a_end: QPointF, b_start: QPointF, b_end: QPointF) -> bool: ax = a_end.x() - a_start.x() ay = a_end.y() - a_start.y() bx = b_end.x() - b_start.x() by = b_end.y() - b_start.y() a_length = math.hypot(ax, ay) b_length = math.hypot(bx, by) if a_length < 0.001 or b_length < 0.001: return False unit_ax = ax / a_length unit_ay = ay / a_length unit_bx = bx / b_length unit_by = by / b_length parallel_score = abs(unit_ax * unit_bx + unit_ay * unit_by) if parallel_score < 0.985: return False def distance_to_a_line(point: QPointF) -> float: return abs((point.x() - a_start.x()) * unit_ay - (point.y() - a_start.y()) * unit_ax) if max(distance_to_a_line(b_start), distance_to_a_line(b_end)) > 5.0: return False b0 = (b_start.x() - a_start.x()) * unit_ax + (b_start.y() - a_start.y()) * unit_ay b1 = (b_end.x() - a_start.x()) * unit_ax + (b_end.y() - a_start.y()) * unit_ay overlap_start = max(0.0, min(b0, b1)) overlap_end = min(a_length, max(b0, b1)) return (overlap_end - overlap_start) >= min(10.0, min(a_length, b_length) * 0.35) def _add_connection_path( self, path: QPainterPath, color: str, width: float, alpha: float, *, z_value: float = 1, line_id: int | None = None, connection_kind: str = "inner", ) -> None: if path.isEmpty(): return item = self.scene().addPath(path) item.setData(0, line_id) item.setData(1, connection_kind) item.setData(2, color) item.setZValue(z_value) self._apply_connection_item_style(item) self.connection_items.append(item) def _restyle_connection_items(self) -> None: for item in self.connection_items: if isValid(item): self._apply_connection_item_style(item) def _apply_connection_item_style(self, item: QGraphicsPathItem) -> None: line_id = item.data(0) kind = str(item.data(1) or "inner") color = str(item.data(2) or "#e11d48") is_active = self.active_line_id is None or line_id == self.active_line_id if kind == "inner": width = 2.8 if is_active else 1.2 alpha = 0.9 if is_active else 0.25 elif kind == "bridge": width = 1.9 if is_active else 1.0 alpha = 0.65 if is_active else 0.18 elif kind == "arrow": width = 1.8 if is_active else 1.0 alpha = 0.95 if is_active else 0.28 else: width = 1.8 if is_active else 1.0 alpha = 0.65 if is_active else 0.18 pen_color = QColor(color) pen_color.setAlphaF(alpha) item.setPen(QPen(pen_color, width)) def _selection_has_assignment(self) -> bool: if self.document is None: return False for point_id in self.selected_point_ids(): point = self.document.get_point(point_id) if point.line_id is not None or point.block_id is not None: return True return False def _single_selected_point_id(self) -> int | None: point_ids = self.selected_point_ids() return point_ids[0] if len(point_ids) == 1 else None def _to_scene_point(self, x: float, y: float) -> QPointF: if self._raw_bounds is None: return QPointF(x, y) x *= self._display_scale y *= self._display_scale min_x, _max_x, _min_y, max_y = self._raw_bounds return QPointF((x - min_x) + self._margin, (max_y - y) + self._margin) def _background_origin_scene_point(self) -> QPointF: if self._raw_bounds is not None: min_x, max_x, min_y, max_y = self._raw_bounds return self._scene_point_from_display_units((min_x + max_x) / 2.0, (min_y + max_y) / 2.0) if self._content_scene_rect is not None: return self._content_scene_rect.center() return QPointF(0.0, 0.0) def _grid_origin_scene_units(self) -> tuple[float, float]: if self.document is None or not self.document.points: return 0.0, 0.0 points = [point for point in self.document.points if not point.manual] or self.document.points min_row = min(point.row for point in points) min_col = min(point.col for point in points) anchor = min(points, key=lambda point: (point.y * self._display_scale, point.x * self._display_scale, point.uid)) anchor_x = anchor.x * self._display_scale anchor_y = anchor.y * self._display_scale origin_x = anchor_x - (anchor.col - min_col + 0.5) * self._grid_step origin_y = anchor_y - (anchor.row - min_row + 0.5) * self._grid_step return origin_x, origin_y def _scene_point_from_display_units(self, x: float, y: float) -> QPointF: if self._raw_bounds is None: return QPointF(x, y) min_x, _max_x, _min_y, max_y = self._raw_bounds return QPointF((x - min_x) + self._margin, (max_y - y) + self._margin) def _document_point_from_scene(self, scene_pos: QPointF) -> QPointF: if self._raw_bounds is None: return QPointF(scene_pos.x(), scene_pos.y()) min_x, _max_x, _min_y, max_y = self._raw_bounds display_x = scene_pos.x() - self._margin + min_x display_y = max_y - (scene_pos.y() - self._margin) scale = self._display_scale or 1.0 return QPointF(display_x / scale, display_y / scale) @staticmethod def _move_scrollbar(scrollbar: QScrollBar, delta: int) -> None: scrollbar.setValue(scrollbar.value() - delta) def export_to_image(self, file_path: str) -> bool: scene = self.scene() if scene is None: return False rect = scene.itemsBoundingRect() if rect.isEmpty(): return False # 1. Add temporary badges for start/end points of each block from PySide6.QtGui import QFont, QPen, QBrush, QColor from PySide6.QtWidgets import QGraphicsRectItem, QGraphicsSimpleTextItem def create_badge(text: str, bg_color: QColor, pos: QPointF, offset_y: float) -> QGraphicsRectItem: text_item = QGraphicsSimpleTextItem(text) font = QFont("Outfit", 8) font.setBold(True) text_item.setFont(font) text_item.setBrush(QBrush(Qt.white)) rect = text_item.boundingRect() px, py = 6, 4 w = rect.width() + px * 2 h = rect.height() + py * 2 rect_item = QGraphicsRectItem(-w/2, -h/2, w, h) rect_item.setBrush(QBrush(bg_color)) rect_item.setPen(QPen(Qt.white, 1.2)) rect_item.setZValue(100) text_item.setParentItem(rect_item) text_item.setPos(-rect.width()/2 - rect.left(), -rect.height()/2 - rect.top()) rect_item.setPos(pos.x(), pos.y() + offset_y) return rect_item temp_items = [] for line in self.document.lines: current_offset = 0 for block in line.blocks: if not block.point_ids: continue block_len = len(block.point_ids) first_pid = block.point_ids[0] last_pid = block.point_ids[-1] start_idx = current_offset end_idx = current_offset + block_len - 1 current_offset += block_len # Get scene positions if first_pid in self.items_by_id: pos_start = self.items_by_id[first_pid].pos() point_start = self.document.points[first_pid] if last_pid == first_pid: text_start = f"S=E: ID {point_start.source_id} (#{start_idx})" badge_start = create_badge(text_start, QColor("#3b82f6"), pos_start, offset_y=-18) # Blue badge else: text_start = f"S: ID {point_start.source_id} (#{start_idx})" badge_start = create_badge(text_start, QColor("#10b981"), pos_start, offset_y=-18) # Green badge scene.addItem(badge_start) temp_items.append(badge_start) if last_pid in self.items_by_id and last_pid != first_pid: pos_end = self.items_by_id[last_pid].pos() point_end = self.document.points[last_pid] text_end = f"E: ID {point_end.source_id} (#{end_idx})" badge_end = create_badge(text_end, QColor("#ef4444"), pos_end, offset_y=18) # Red badge scene.addItem(badge_end) temp_items.append(badge_end) # 2. Render setup target_height = 2000 mapping_width = 2000 sidebar_width = 800 total_width = mapping_width + sidebar_width image = QImage(total_width, target_height, QImage.Format.Format_ARGB32) # Draw background for the mapping area bg_brush = scene.backgroundBrush() mapping_bg = bg_brush.color() if bg_brush.style() != Qt.NoBrush else QColor("#2b2b2b" if self._theme == "dark" else "#ffffff") painter = QPainter(image) painter.setRenderHint(QPainter.Antialiasing) painter.setRenderHint(QPainter.TextAntialiasing) # Fill the entire image with mapping background initially image.fill(mapping_bg) # Adjust scene rect with padding for mapping render padding = max(rect.width(), rect.height()) * 0.05 rect.adjust(-padding, -padding, padding, padding) # Render the scene mapping area on the left scene.render(painter, QRectF(0, 0, mapping_width, target_height), rect) # 3. Draw sidebar legend on the right sidebar_rect = QRectF(mapping_width, 0, sidebar_width, target_height) # Fill sidebar background painter.setPen(Qt.NoPen) sidebar_bg = QColor("#1e293b" if self._theme == "dark" else "#f8fafc") painter.setBrush(QBrush(sidebar_bg)) painter.drawRect(sidebar_rect) # Draw vertical divider line painter.setPen(QPen(QColor("#475569" if self._theme == "dark" else "#cbd5e1"), 2)) painter.drawLine(mapping_width, 0, mapping_width, target_height) # Fonts font_title = QFont("Outfit", 20) font_title.setBold(True) font_line = QFont("Outfit", 14) font_line.setBold(True) font_block = QFont("Outfit", 11) # Title painter.setFont(font_title) painter.setPen(QColor("#ffffff" if self._theme == "dark" else "#0f172a")) title_rect = QRectF(mapping_width + 40, 40, sidebar_width - 80, 50) painter.drawText(title_rect, Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, "ЛЕГЕНДА МАППИНГА") # Draw lines and blocks y = 120 for line in self.document.lines: if y > target_height - 60: break # Line swatch swatch_rect = QRectF(mapping_width + 40, y + 4, 24, 24) painter.setPen(Qt.NoPen) painter.setBrush(QBrush(QColor(line.color))) painter.drawRoundedRect(swatch_rect, 4, 4) # Line name painter.setFont(font_line) painter.setPen(QColor("#f8fafc" if self._theme == "dark" else "#1e293b")) line_text_rect = QRectF(mapping_width + 80, y, sidebar_width - 120, 32) painter.drawText(line_text_rect, Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, line.name) y += 40 # Blocks under line painter.setFont(font_block) painter.setPen(QColor("#94a3b8" if self._theme == "dark" else "#64748b")) current_offset = 0 for block in line.blocks: if y > target_height - 40: break block_len = len(block.point_ids) if block_len == 0: block_range_str = "empty" else: start_idx = current_offset end_idx = current_offset + block_len - 1 block_range_str = f"{start_idx}-{end_idx}" current_offset += block_len block_text = f"• {block.name}: {block_range_str}" block_text_rect = QRectF(mapping_width + 100, y, sidebar_width - 140, 24) painter.drawText(block_text_rect, Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, block_text) y += 28 y += 12 painter.end() # 4. Remove temporary badges from the scene for item in temp_items: scene.removeItem(item) return image.save(file_path)