from __future__ import annotations import math from pathlib import Path from PySide6.QtCore import QPointF, QRectF, QSize, Qt, Signal from PySide6.QtGui import QColor, QBrush, QImageReader, QPainter, QPainterPath, QPen, QPixmap, QTransform from PySide6.QtWidgets import ( QGraphicsEllipseItem, QGraphicsItem, QGraphicsLineItem, QGraphicsPathItem, QGraphicsPixmapItem, QGraphicsRectItem, QGraphicsScene, QGraphicsSimpleTextItem, QGraphicsView, QMenu, QScrollBar, ) from shiboken6 import isValid try: from .models import BackgroundImage, LedLine, LedPoint, MappingDocument, coordinate_display_scale except ImportError: from models import BackgroundImage, LedLine, LedPoint, MappingDocument, coordinate_display_scale MAX_BACKGROUND_PIXMAP_SIDE = 8192 class LedItem(QGraphicsEllipseItem): def __init__(self, point: LedPoint, radius: float) -> None: super().__init__(-radius, -radius, radius * 2, radius * 2) self.point_id = point.uid self.setFlag(QGraphicsEllipseItem.ItemIsSelectable, True) self.setToolTip(self._tooltip(point)) self.setZValue(10) @staticmethod def _tooltip(point: LedPoint) -> str: line = "-" if point.line_id is None else str(point.line_id) block = "-" if point.block_id is None else str(point.block_id) line_index = "-" if point.line_id is None or point.line_index is None else f"Л{point.line_id}: {point.line_index}" block_index = "-" if point.block_index is None else str(point.block_index) return ( f"Источник: {point.source_id}\n" f"Сетка: ряд {point.row}, колонка {point.col}\n" f"Линия: {line}, блок: {block}\n" f"Индекс в линии: {line_index}\n" f"Индекс в блоке: {block_index}" ) class LedCanvas(QGraphicsView): selectionChanged = Signal(list) deleteSelectionRequested = Signal() deletePointsRequested = Signal() editPointRequested = Signal(int) pointMoveFinished = Signal(int, float, float) 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.RubberBandDrag) self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) self.setResizeAnchor(QGraphicsView.AnchorUnderMouse) self.document: MappingDocument | None = None self.active_line_id: int | None = None self.items_by_id: dict[int, LedItem] = {} self.grid_items: list[QGraphicsLineItem] = [] self.connection_items: list[QGraphicsPathItem] = [] self.line_index_label_items: dict[int, QGraphicsSimpleTextItem] = {} self._background_item: QGraphicsPixmapItem | None = None self._background_outline: QGraphicsRectItem | None = None self._background_handles: dict[str, QGraphicsRectItem] = {} self._background_edit_mode = False self._background_drag: dict | None = None self._background_pixmap_cache_key: tuple[str, int | None] | None = None self._background_pixmap_cache: QPixmap | None = None self._background_loaded = False self._direction_arrows_visible = True self._line_index_labels_visible = True self._point_radius = 4.5 self._brush_radius = 18.0 self._zoom_focus_pull = 0.35 self._brush_zone_item: QGraphicsEllipseItem | None = None self._grid_visible = False self._grid_step = 34.0 self._raw_bounds: tuple[float, float, float, float] | None = None self._content_scene_rect: QRectF | None = None self._display_scale = 1.0 self._margin = 40.0 self._theme = "dark" self._pan_mode = "middle_space" self._instant_space_pan = False self._space_pressed = False self._is_panning = False self._last_pan_pos = None self._last_mouse_pos = None self._point_drag: dict | None = None self._brush_enabled = False self._add_led_enabled = False self._is_brush_stroke = False self._brush_touched_ids: set[int] = set() self._brush_preview_ids: set[int] = set() self._brush_excluded_point_ids: set[int] = set() self._last_brush_viewport_pos = None self._last_selected_ids: set[int] = set() self.scene().setBackgroundBrush(QBrush(QColor("#2b2b2b"))) self.scene().selectionChanged.connect(self._emit_selection) self.setToolTip("Выделение рамкой: перетащите мышью. Масштаб: колесо. Del: снять фиксацию выбранных LED.") def set_document(self, document: MappingDocument, *, reset_view: bool = True) -> None: self.document = document self.rebuild(reset_view=reset_view) def set_active_line(self, line_id: int | None) -> None: if self.active_line_id == line_id: return self.active_line_id = line_id self.refresh_styles(redraw_connections=False) def set_grid_visible(self, visible: bool) -> None: self._grid_visible = visible self._refresh_grid() def set_direction_arrows_visible(self, visible: bool) -> None: if self._direction_arrows_visible == visible: return self._direction_arrows_visible = visible self.refresh_styles(redraw_connections=True) def direction_arrows_visible(self) -> bool: return self._direction_arrows_visible def set_line_index_labels_visible(self, visible: bool) -> None: if self._line_index_labels_visible == visible: return self._line_index_labels_visible = visible self._refresh_line_index_labels() def line_index_labels_visible(self) -> bool: return self._line_index_labels_visible def set_brush_radius(self, radius: int) -> None: self._brush_radius = float(max(4, min(80, radius))) if self._brush_zone_item is not None and isValid(self._brush_zone_item): self.scene().removeItem(self._brush_zone_item) self._brush_zone_item = None if self._brush_enabled: self._ensure_brush_zone() if self._last_brush_viewport_pos is not None: self._update_brush_zone(self._last_brush_viewport_pos) def set_zoom_focus_pull(self, percent: int) -> None: self._zoom_focus_pull = max(0.0, min(1.0, percent / 100.0)) def set_pan_mode(self, mode: str) -> None: self._pan_mode = mode if mode in {"middle", "space", "middle_space"} else "middle_space" if self._pan_mode == "middle" and self._space_pressed: self._space_pressed = False self._apply_default_cursor() def set_instant_space_pan(self, enabled: bool) -> None: self._instant_space_pan = enabled if not enabled and self._is_panning and self._space_pressed: self._finish_pan() def set_theme(self, theme: str) -> None: next_theme = "dark" if theme == "dark" else "light" if self._theme == next_theme: return self._theme = next_theme background = "#2b2b2b" if self._theme == "dark" else "#ffffff" self.scene().setBackgroundBrush(QBrush(QColor(background))) self._refresh_grid() self.refresh_styles(redraw_connections=True) def set_brush_enabled(self, enabled: bool) -> None: self._brush_enabled = enabled if enabled: self._add_led_enabled = False self._is_brush_stroke = False self._clear_brush_state() self.setDragMode(QGraphicsView.NoDrag if enabled else QGraphicsView.RubberBandDrag) if enabled: self._ensure_brush_zone() else: self._hide_brush_zone() self._apply_default_cursor() def set_add_led_enabled(self, enabled: bool) -> None: self._add_led_enabled = enabled if enabled: self.set_brush_enabled(False) self.set_background_edit_mode(False) self._apply_default_cursor() def add_led_enabled(self) -> bool: return self._add_led_enabled def set_brush_excluded_point_ids(self, point_ids: set[int] | list[int]) -> None: self._brush_excluded_point_ids = set(point_ids) changed_ids = self._brush_preview_ids | self._brush_touched_ids self._brush_preview_ids.difference_update(self._brush_excluded_point_ids) self._refresh_brush_preview_styles(changed_ids) def set_background_edit_mode(self, enabled: bool) -> None: self._background_edit_mode = enabled if enabled: self._add_led_enabled = False if enabled and self._brush_enabled: self.set_brush_enabled(False) if not enabled: self._background_drag = None self._update_background_handles() self._apply_default_cursor() def background_edit_mode(self) -> bool: return self._background_edit_mode def background_loaded(self) -> bool: return self._background_loaded def set_background(self, background: BackgroundImage) -> None: if self.document is None: return view_state = self._capture_view_state() self.document.background = background self._refresh_background() self._update_scene_rect() self._restore_view_state(view_state) def clear_background(self) -> None: if self.document is None: return view_state = self._capture_view_state() self.document.background = BackgroundImage() self._refresh_background() self._update_scene_rect() self._restore_view_state(view_state) def reset_background_scale(self) -> None: if self.document is None: return view_state = self._capture_view_state() self.document.background.scale_x_percent = 100.0 self.document.background.scale_y_percent = 100.0 self._apply_background_geometry() self._restore_view_state(view_state) self.backgroundChanged.emit() def set_background_visible(self, visible: bool) -> None: if self.document is None or not self.document.background.is_enabled(): return view_state = self._capture_view_state() self.document.background.visible = visible self._apply_background_geometry() self._restore_view_state(view_state) self.backgroundChanged.emit() def rebuild(self, *, reset_view: bool = True) -> None: old_transform = self.transform() old_center = self.mapToScene(self.viewport().rect().center()) scene = self.scene() scene.blockSignals(True) self.setUpdatesEnabled(False) try: self.items_by_id.clear() self.grid_items.clear() self.connection_items.clear() self.line_index_label_items.clear() self._background_item = None self._background_outline = None self._background_handles.clear() self._background_drag = None self._brush_zone_item = None self._brush_preview_ids.clear() self._brush_touched_ids.clear() self._last_selected_ids.clear() scene.clear() finally: scene.blockSignals(False) self.setUpdatesEnabled(True) self._raw_bounds = None self._content_scene_rect = None if self.document is None or not self.document.points: self._refresh_background() self._update_scene_rect() return self._display_scale = coordinate_display_scale(self.document.points) xs = [point.x * self._display_scale for point in self.document.points] ys = [point.y * self._display_scale for point in self.document.points] min_x, max_x = min(xs), max(xs) min_y, max_y = min(ys), max(ys) self._raw_bounds = (min_x, max_x, min_y, max_y) self._margin = max(self._point_radius * 4, 40) for point in self.document.points: item = LedItem(point, self._point_radius) item.setPos(self._to_scene_point(point.x, point.y)) self.items_by_id[point.uid] = item self.scene().addItem(item) self._content_scene_rect = QRectF( 0, 0, max(max_x - min_x, 1.0) + self._margin * 2, max(max_y - min_y, 1.0) + self._margin * 2, ) self._refresh_background() self._update_scene_rect() self._refresh_grid() self.refresh_styles() if reset_view: self.fit_to_content() else: self.setTransform(old_transform) self.centerOn(old_center) def refresh_styles(self, redraw_connections: bool = True) -> None: if self.document is None: return if redraw_connections: self._clear_connections() self._connection_segments: list[tuple[QPointF, QPointF]] = [] self._connection_segment_buckets: dict[tuple[int, int], list[tuple[QPointF, QPointF]]] = {} line_by_id = {line.id: line for line in self.document.lines} for point in self.document.points: item = self.items_by_id.get(point.uid) if item is None or not isValid(item): continue color = QColor("#94a3b8") if point.line_id in line_by_id: color = QColor(line_by_id[point.line_id].color) if self.active_line_id is not None and point.line_id != self.active_line_id: color.setAlphaF(0.45) item.setBrush(color) is_active_line_point = self.active_line_id is not None and point.line_id == self.active_line_id try: pen_color = QColor("#111827") if item.isSelected() else QColor("#ffffff") item.setPen(QPen(pen_color, 2.0 if is_active_line_point else 1.2)) item.setToolTip(LedItem._tooltip(point)) except RuntimeError: continue self._last_selected_ids = { item.point_id for item in self.scene().selectedItems() if isinstance(item, LedItem) and isValid(item) } if redraw_connections: for line in self.document.lines: self._draw_line_connections(line) else: self._restyle_connection_items() self._refresh_brush_preview_styles(self._brush_preview_ids | self._brush_touched_ids) self._refresh_line_index_labels() def selected_point_ids(self) -> list[int]: return sorted( item.point_id for item in self.scene().selectedItems() if isinstance(item, LedItem) and isValid(item) ) def select_points(self, point_ids: list[int]) -> None: target_ids = set(point_ids) if target_ids == self._last_selected_ids: return changed_ids = target_ids ^ self._last_selected_ids scene = self.scene() scene.blockSignals(True) self.setUpdatesEnabled(False) try: for point_id in changed_ids: item = self.items_by_id.get(point_id) if not isValid(item): continue should_select = item.point_id in target_ids if item.isSelected() != should_select: item.setSelected(should_select) finally: self.setUpdatesEnabled(True) scene.blockSignals(False) self._emit_selection() def _refresh_background(self) -> None: self._remove_background_items() if self.document is None or not self.document.background.is_enabled(): return image_path = Path(self.document.background.path) if not image_path.is_absolute() and self.document.path is not None: image_path = self.document.path.parent / image_path pixmap = self._load_background_pixmap(image_path) if pixmap.isNull(): self._background_loaded = False return self._background_loaded = True self._background_item = self.scene().addPixmap(pixmap) self._background_item.setTransformationMode(Qt.SmoothTransformation) self._background_item.setZValue(-30) self._background_item.setAcceptedMouseButtons(Qt.NoButton) self._apply_background_geometry() def _remove_background_items(self) -> None: for item in [self._background_item, self._background_outline, *self._background_handles.values()]: if item is not None and isValid(item): self.scene().removeItem(item) self._background_item = None self._background_outline = None self._background_handles.clear() self._background_loaded = False def _load_background_pixmap(self, path: Path) -> QPixmap: cache_key = (str(path), path.stat().st_mtime_ns if path.exists() else None) if self._background_pixmap_cache_key == cache_key and self._background_pixmap_cache is not None: return self._background_pixmap_cache reader = QImageReader(str(path)) size = reader.size() if size.isValid() and max(size.width(), size.height()) > MAX_BACKGROUND_PIXMAP_SIDE: factor = MAX_BACKGROUND_PIXMAP_SIDE / max(size.width(), size.height()) reader.setScaledSize(QSize(max(1, int(size.width() * factor)), max(1, int(size.height() * factor)))) image = reader.read() pixmap = QPixmap.fromImage(image) if not image.isNull() else QPixmap() self._background_pixmap_cache_key = cache_key self._background_pixmap_cache = pixmap return pixmap def _apply_background_geometry(self) -> None: if self.document is None or self._background_item is None or not isValid(self._background_item): self._update_background_handles() return view_state = self._capture_view_state() background = self.document.background origin = self._background_origin_scene_point() scale_x = max(1.0, background.scale_x_percent) / 100.0 scale_y = max(1.0, background.scale_y_percent) / 100.0 source_rect = self._background_item.boundingRect() center_x = origin.x() + background.offset_x center_y = origin.y() + background.offset_y self._background_item.setVisible(background.visible) self._background_item.setOpacity(max(0.0, min(1.0, background.opacity_percent / 100.0))) self._background_item.setPos( center_x - source_rect.width() * scale_x / 2.0, center_y - source_rect.height() * scale_y / 2.0, ) self._background_item.setScale(1.0) self._background_item.setTransformOriginPoint(0, 0) self._background_item.setTransform( QTransform.fromScale( scale_x, scale_y, ) ) self._update_background_handles() self._update_scene_rect() self._restore_view_state(view_state) def _background_scene_rect(self) -> QRectF | None: if self._background_item is None or not isValid(self._background_item): return None if self.document is not None and not self.document.background.visible: return None return self._background_item.sceneBoundingRect() def _update_scene_rect(self) -> None: rect = self._content_scene_rect background_rect = self._background_scene_rect() if rect is None: rect = background_rect elif background_rect is not None: rect = rect.united(background_rect) if rect is None: rect = QRectF(0, 0, 1, 1) self.scene().setSceneRect(rect) def _update_background_handles(self) -> None: rect = self._background_scene_rect() if not self._background_edit_mode or rect is None: for item in [self._background_outline, *self._background_handles.values()]: if item is not None and isValid(item): self.scene().removeItem(item) self._background_outline = None self._background_handles.clear() return pen = QPen(QColor("#fbbf24"), 1.2) pen.setCosmetic(True) if self._background_outline is None or not isValid(self._background_outline): self._background_outline = self.scene().addRect(rect, pen) self._background_outline.setZValue(60) else: self._background_outline.setRect(rect) self._background_outline.setPen(pen) handle_positions = { "nw": rect.topLeft(), "n": QPointF(rect.center().x(), rect.top()), "ne": rect.topRight(), "e": QPointF(rect.right(), rect.center().y()), "se": rect.bottomRight(), "s": QPointF(rect.center().x(), rect.bottom()), "sw": rect.bottomLeft(), "w": QPointF(rect.left(), rect.center().y()), } for name, point in handle_positions.items(): handle = self._background_handles.get(name) if handle is None or not isValid(handle): handle = QGraphicsRectItem(-4, -4, 8, 8) handle.setBrush(QColor("#fbbf24")) handle.setPen(QPen(QColor("#111827"), 1.0)) handle.setFlag(QGraphicsItem.ItemIgnoresTransformations, True) handle.setZValue(61) handle.setAcceptedMouseButtons(Qt.NoButton) self.scene().addItem(handle) self._background_handles[name] = handle handle.setPos(point) def fit_to_content(self) -> None: self._update_scene_rect() if self.scene() is None or self.sceneRect().isEmpty(): return self.fitInView(self.sceneRect(), Qt.KeepAspectRatio) def _capture_view_state(self) -> tuple[QTransform, QPointF, QRectF] | None: if self.viewport().rect().isEmpty(): return None return ( self.transform(), self.mapToScene(self.viewport().rect().center()), self.mapToScene(self.viewport().rect()).boundingRect(), ) def _restore_view_state(self, state: tuple[QTransform, QPointF, QRectF] | None) -> None: if state is None: return transform, center, visible_rect = state self.setTransform(transform) scene_rect = self.scene().sceneRect() if not scene_rect.contains(visible_rect): self.scene().setSceneRect(scene_rect.united(visible_rect)) self.centerOn(center) def wheelEvent(self, event) -> None: # noqa: ANN001 if self._background_drag is not None: event.ignore() return factor = 1.15 if event.angleDelta().y() > 0 else 1 / 1.15 viewport_pos = event.position().toPoint() if hasattr(event, "position") else event.pos() self._zoom_at_viewport_pos(viewport_pos, factor) if self._brush_enabled: self._update_brush_zone(viewport_pos) self._position_line_index_labels() event.accept() def _zoom_at_viewport_pos(self, viewport_pos, factor: float) -> None: # noqa: ANN001 old_anchor = self.transformationAnchor() self.setTransformationAnchor(QGraphicsView.NoAnchor) focus_scene_pos = self.mapToScene(viewport_pos) current_center = self.mapToScene(self.viewport().rect().center()) self.scale(factor, factor) self._ensure_zoom_padding() pull_strength = self._zoom_focus_pull target_center = QPointF( current_center.x() + (focus_scene_pos.x() - current_center.x()) * pull_strength, current_center.y() + (focus_scene_pos.y() - current_center.y()) * pull_strength, ) self.centerOn(target_center) self.setTransformationAnchor(old_anchor) def _ensure_zoom_padding(self) -> None: if self.viewport().rect().isEmpty(): return self._update_scene_rect() base_rect = self.scene().sceneRect() visible_scene_rect = self.mapToScene(self.viewport().rect()).boundingRect() pad_x = visible_scene_rect.width() * 0.5 pad_y = visible_scene_rect.height() * 0.5 self.scene().setSceneRect(base_rect.adjusted(-pad_x, -pad_y, pad_x, pad_y)) def keyPressEvent(self, event) -> None: # noqa: ANN001 if event.key() == Qt.Key_Delete: self.deleteSelectionRequested.emit() event.accept() return if event.key() == Qt.Key_Space and not event.isAutoRepeat() and self._can_pan_with_space(): self._space_pressed = True if self._instant_space_pan and self._last_mouse_pos is not None: self._start_pan(self._last_mouse_pos) self._apply_default_cursor() event.accept() return super().keyPressEvent(event) def keyReleaseEvent(self, event) -> None: # noqa: ANN001 if event.key() == Qt.Key_Space and not event.isAutoRepeat(): self._space_pressed = False if self._is_panning: self._is_panning = False self._last_pan_pos = None self._apply_default_cursor() event.accept() return super().keyReleaseEvent(event) def mousePressEvent(self, event) -> None: # noqa: ANN001 self._last_mouse_pos = event.pos() if event.button() == Qt.RightButton: event.accept() return if self._should_start_pan(event.button()): self._start_pan(event.pos()) event.accept() return if event.button() == Qt.LeftButton and event.modifiers() & Qt.AltModifier 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: point = self._document_point_from_scene(self.mapToScene(event.pos())) self.addLedRequested.emit(point.x(), point.y()) event.accept() return if self._brush_enabled and event.button() == Qt.LeftButton: self._is_brush_stroke = True self._brush_touched_ids.clear() self.brushStrokeStarted.emit(bool(event.modifiers() & Qt.ShiftModifier)) self._update_brush_zone(event.pos()) self._touch_brush_point(event.pos()) event.accept() return super().mousePressEvent(event) def mouseMoveEvent(self, event) -> None: # noqa: ANN001 previous_mouse_pos = self._last_mouse_pos self._last_mouse_pos = event.pos() if ( self._instant_space_pan and self._space_pressed and self._can_pan_with_space() and not self._is_panning and previous_mouse_pos is not None ): self._start_pan(previous_mouse_pos) if self._is_panning and self._last_pan_pos is not None: delta = event.pos() - self._last_pan_pos self._last_pan_pos = event.pos() self._move_scrollbar(self.horizontalScrollBar(), delta.x()) self._move_scrollbar(self.verticalScrollBar(), delta.y()) event.accept() return if self._background_drag is not None: self._move_background_edit(event.pos()) event.accept() return if self._point_drag is not None: self._move_point_drag(event.pos()) event.accept() return if self._brush_enabled and self._is_brush_stroke: self._update_brush_zone(event.pos()) self._touch_brush_point(event.pos()) event.accept() return if self._brush_enabled: self._update_brush_zone(event.pos()) event.accept() return super().mouseMoveEvent(event) def mouseReleaseEvent(self, event) -> None: # noqa: ANN001 self._last_mouse_pos = event.pos() if event.button() == Qt.RightButton: event.accept() return if self._should_finish_pan(event.button()): self._finish_pan() event.accept() return if event.button() == Qt.LeftButton and self._background_drag is not None: self._finish_background_edit() event.accept() return if 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 super().mouseReleaseEvent(event) def _start_point_drag(self, viewport_pos) -> bool: # noqa: ANN001 if self.document is None: return False item = self._led_item_at(viewport_pos) if item is None: return False scene_pos = self.mapToScene(viewport_pos) self._point_drag = { "point_id": item.point_id, "item": item, "offset": item.pos() - scene_pos, "start_pos": item.pos(), } self.select_points([item.point_id]) 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 item = self._point_drag.get("item") if item is None or not isValid(item): return offset = self._point_drag.get("offset", QPointF(0.0, 0.0)) item.setPos(self.mapToScene(viewport_pos) + offset) self._position_line_index_labels() def _finish_point_drag(self, viewport_pos) -> None: # noqa: ANN001 if self._point_drag is None: return self._move_point_drag(viewport_pos) point_id = int(self._point_drag.get("point_id", -1)) item = self._point_drag.get("item") start_pos = self._point_drag.get("start_pos") self._point_drag = None self.setDragMode(QGraphicsView.NoDrag if self._brush_enabled else QGraphicsView.RubberBandDrag) self._apply_default_cursor() if item is None or not isValid(item) or point_id < 0: return if isinstance(start_pos, QPointF) and (item.pos() - start_pos).manhattanLength() < 0.001: return document_point = self._document_point_from_scene(item.pos()) self.pointMoveFinished.emit(point_id, document_point.x(), document_point.y()) 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) 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 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.selectionChanged.emit(sorted(selected_ids)) def _refresh_selection_pens(self, point_ids: set[int]) -> None: if self.document is None: return for point_id in point_ids: item = self.items_by_id.get(point_id) if item is None or not isValid(item): continue point = self.document.get_point(point_id) is_active_line_point = self.active_line_id is not None and point.line_id == self.active_line_id try: pen_color = QColor("#111827") if item.isSelected() else QColor("#ffffff") item.setPen(QPen(pen_color, 2.0 if is_active_line_point else 1.2)) except RuntimeError: continue def _refresh_line_index_labels(self) -> None: self._clear_line_index_labels() if self.document is None or not self._line_index_labels_visible: return for point_id in sorted(self._last_selected_ids): point = self.document.get_point(point_id) if point.line_id is None or point.line_index is None: continue item = self.items_by_id.get(point_id) if item is None or not isValid(item): continue label = QGraphicsSimpleTextItem(str(point.line_index)) label.setBrush(QColor("#f8fafc" if self._theme == "dark" else "#111827")) label.setZValue(40) label.setFlag(QGraphicsItem.ItemIgnoresTransformations, True) label.setAcceptedMouseButtons(Qt.NoButton) 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, item.pos().y() - scene_radius - scene_gap, ) def _clear_line_index_labels(self) -> None: for label in self.line_index_label_items.values(): if isValid(label): self.scene().removeItem(label) self.line_index_label_items.clear() def _touch_brush_point(self, viewport_pos) -> None: # noqa: ANN001 touched = self._brush_candidate_ids(viewport_pos) for point_id in sorted(touched): self._brush_touched_ids.add(point_id) self.brushPointTouched.emit(point_id) self._update_brush_preview(viewport_pos) def _brush_candidate_ids(self, viewport_pos) -> list[int]: # noqa: ANN001 return [ item.point_id for item in self.items_by_id.values() if isValid(item) and item.point_id not in self._brush_touched_ids and item.point_id not in self._brush_excluded_point_ids and self._is_inside_brush_zone(viewport_pos, item) ] def _is_inside_brush_zone(self, viewport_center, item: LedItem) -> bool: # noqa: ANN001 point_viewport = self.mapFromScene(item.pos()) radius = self._brush_radius + self._point_radius dx = viewport_center.x() - point_viewport.x() dy = viewport_center.y() - point_viewport.y() return dx * dx + dy * dy <= radius * radius def _ensure_brush_zone(self) -> QGraphicsEllipseItem | None: if self.scene() is None: return None if self._brush_zone_item is not None and isValid(self._brush_zone_item): return self._brush_zone_item item = QGraphicsEllipseItem(-self._brush_radius, -self._brush_radius, self._brush_radius * 2, self._brush_radius * 2) pen = QPen(QColor("#f43f5e"), 1.4) pen.setCosmetic(True) item.setPen(pen) item.setBrush(QBrush(QColor(244, 63, 94, 32))) item.setZValue(40) item.setFlag(QGraphicsItem.ItemIgnoresTransformations, True) item.setAcceptedMouseButtons(Qt.NoButton) item.setVisible(False) self.scene().addItem(item) self._brush_zone_item = item return item def _update_brush_zone(self, viewport_pos) -> None: # noqa: ANN001 if not self._brush_enabled: return item = self._ensure_brush_zone() if item is None: return self._last_brush_viewport_pos = viewport_pos item.setPos(self.mapToScene(viewport_pos)) item.setVisible(True) self._update_brush_preview(viewport_pos) def _hide_brush_zone(self) -> None: if self._brush_zone_item is not None and isValid(self._brush_zone_item): self._brush_zone_item.setVisible(False) def _update_brush_preview(self, viewport_pos) -> None: # noqa: ANN001 if not self._brush_enabled: self._clear_brush_preview() return next_preview_ids = set(self._brush_candidate_ids(viewport_pos)) changed_ids = self._brush_preview_ids ^ next_preview_ids self._brush_preview_ids = next_preview_ids self._refresh_brush_preview_styles(changed_ids | self._brush_touched_ids) def _clear_brush_preview(self) -> None: changed_ids = self._brush_preview_ids | self._brush_touched_ids self._brush_preview_ids.clear() self._refresh_brush_preview_styles(changed_ids) def _clear_brush_state(self) -> None: changed_ids = self._brush_preview_ids | self._brush_touched_ids self._brush_preview_ids.clear() self._brush_touched_ids.clear() self._refresh_brush_preview_styles(changed_ids) def _refresh_brush_preview_styles(self, point_ids: set[int]) -> None: if self.document is None: return line_by_id = {line.id: line for line in self.document.lines} for point_id in point_ids: item = self.items_by_id.get(point_id) if item is None or not isValid(item): continue try: if point_id in self._brush_touched_ids: item.setBrush(QColor("#f43f5e")) item.setPen(QPen(QColor("#ffffff"), 2.4)) continue if point_id in self._brush_preview_ids: item.setBrush(QColor("#fbbf24")) item.setPen(QPen(QColor("#111827"), 2.4)) continue point = self.document.get_point(point_id) color = QColor("#94a3b8") if point.line_id in line_by_id: color = QColor(line_by_id[point.line_id].color) if self.active_line_id is not None and point.line_id != self.active_line_id: color.setAlphaF(0.45) is_active_line_point = self.active_line_id is not None and point.line_id == self.active_line_id pen_color = QColor("#111827") if item.isSelected() else QColor("#ffffff") item.setBrush(color) item.setPen(QPen(pen_color, 2.0 if is_active_line_point else 1.2)) except RuntimeError: continue def _clear_connections(self) -> None: for item in self.connection_items: if not isValid(item): continue self.scene().removeItem(item) self.connection_items.clear() def _refresh_grid(self) -> None: for item in self.grid_items: if not isValid(item): continue self.scene().removeItem(item) self.grid_items.clear() if not self._grid_visible or self._raw_bounds is None: return if self.document is None or not self.document.points: return min_x, max_x, min_y, max_y = self._raw_bounds origin_x, origin_y = self._grid_origin_scene_units() left_x = min_x - self._margin right_x = max_x + self._margin bottom_y = min_y - self._margin top_y = max_y + self._margin scene_rect = self.scene().sceneRect() left_x = min(left_x, scene_rect.left() + min_x - self._margin) right_x = max(right_x, scene_rect.right() + min_x - self._margin) top_y = max(top_y, max_y + self._margin - scene_rect.top()) bottom_y = min(bottom_y, max_y + self._margin - scene_rect.bottom()) first_col = math.floor((left_x - origin_x) / self._grid_step) last_col = math.ceil((right_x - origin_x) / self._grid_step) first_row = math.floor((bottom_y - origin_y) / self._grid_step) last_row = math.ceil((top_y - origin_y) / self._grid_step) grid_span = (last_col - first_col) + (last_row - first_row) step_multiplier = max(1, math.ceil(grid_span / 800)) pen = QPen(QColor("#334155" if self._theme == "dark" else "#d1d5db"), 0.6) pen.setCosmetic(True) for col in range(first_col, last_col + 1, step_multiplier): x = origin_x + col * self._grid_step top = self._scene_point_from_display_units(x, top_y) bottom = self._scene_point_from_display_units(x, bottom_y) item = self.scene().addLine(top.x(), top.y(), bottom.x(), bottom.y(), pen) item.setZValue(-20) self.grid_items.append(item) for row in range(first_row, last_row + 1, step_multiplier): y = origin_y + row * self._grid_step left = self._scene_point_from_display_units(left_x, y) right = self._scene_point_from_display_units(right_x, y) item = self.scene().addLine(left.x(), left.y(), right.x(), right.y(), pen) item.setZValue(-20) self.grid_items.append(item) def _draw_line_connections(self, line: LedLine) -> None: is_active = self.active_line_id is None or line.id == self.active_line_id inner_width = 2.8 if is_active else 1.2 inner_alpha = 0.9 if is_active else 0.25 bridge_width = 1.9 if is_active else 1.0 bridge_alpha = 0.65 if is_active else 0.18 arrow_width = 1.8 if is_active else 1.0 arrow_alpha = 0.95 if is_active else 0.28 inner_path = QPainterPath() inner_arrows = QPainterPath() for block in line.blocks: ordered_items = [self.items_by_id[point_id] for point_id in block.point_ids if point_id in self.items_by_id] for first, second in zip(ordered_items, ordered_items[1:]): start = first.pos() end = second.pos() control = self._append_connection(inner_path, start, end) if self._direction_arrows_visible: self._append_arrow_marker(inner_arrows, start, end, control) self._add_connection_path(inner_path, line.color, inner_width, inner_alpha, line_id=line.id, connection_kind="inner") if self._direction_arrows_visible: self._add_connection_path( inner_arrows, line.color, arrow_width, arrow_alpha, z_value=2, line_id=line.id, connection_kind="arrow", ) previous_block_end = None bridge_path = QPainterPath() bridge_arrows = QPainterPath() for block in line.blocks: if not block.point_ids: continue block_start = self.items_by_id.get(block.point_ids[0]) block_end = self.items_by_id.get(block.point_ids[-1]) if block_start is not None and previous_block_end is not None: start = previous_block_end.pos() end = block_start.pos() control = self._append_connection(bridge_path, start, end) if self._direction_arrows_visible: self._append_arrow_marker(bridge_arrows, start, end, control) if block_end is not None: previous_block_end = block_end self._add_connection_path(bridge_path, line.color, bridge_width, bridge_alpha, line_id=line.id, connection_kind="bridge") if self._direction_arrows_visible: self._add_connection_path( bridge_arrows, line.color, arrow_width, bridge_alpha, z_value=2, line_id=line.id, connection_kind="bridge_arrow", ) def _append_connection(self, path: QPainterPath, start: QPointF, end: QPointF) -> QPointF | None: path.moveTo(start) control = self._overlap_control_point(start, end) if control is None: path.lineTo(end) else: path.quadTo(control, end) return control @staticmethod def _append_arrow_marker(path: QPainterPath, start: QPointF, end: QPointF, control: QPointF | None = None) -> None: dx = end.x() - start.x() dy = end.y() - start.y() length = math.hypot(dx, dy) if length < 8.0: return if control is None: unit_x = dx / length unit_y = dy / length mid_x = start.x() + dx * 0.55 mid_y = start.y() + dy * 0.55 else: t = 0.55 inv_t = 1.0 - t mid_x = inv_t * inv_t * start.x() + 2.0 * inv_t * t * control.x() + t * t * end.x() mid_y = inv_t * inv_t * start.y() + 2.0 * inv_t * t * control.y() + t * t * end.y() tangent_x = 2.0 * inv_t * (control.x() - start.x()) + 2.0 * t * (end.x() - control.x()) tangent_y = 2.0 * inv_t * (control.y() - start.y()) + 2.0 * t * (end.y() - control.y()) tangent_length = math.hypot(tangent_x, tangent_y) if tangent_length < 0.001: return unit_x = tangent_x / tangent_length unit_y = tangent_y / tangent_length arrow_length = min(7.0, length * 0.28) arrow_width = arrow_length * 0.55 base_x = mid_x - unit_x * arrow_length base_y = mid_y - unit_y * arrow_length perp_x = -unit_y perp_y = unit_x left = QPointF(base_x + perp_x * arrow_width, base_y + perp_y * arrow_width) right = QPointF(base_x - perp_x * arrow_width, base_y - perp_y * arrow_width) tip = QPointF(mid_x, mid_y) path.moveTo(left) path.lineTo(tip) path.lineTo(right) def _overlap_control_point(self, start: QPointF, end: QPointF) -> QPointF | None: segments = getattr(self, "_connection_segments", None) if segments is None: return None candidates = self._overlap_candidates(start, end) previous_count = sum( 1 for previous_start, previous_end in candidates if self._segments_visually_overlap(start, end, previous_start, previous_end) ) segments.append((QPointF(start), QPointF(end))) buckets = getattr(self, "_connection_segment_buckets", None) if buckets is not None: segment = (QPointF(start), QPointF(end)) for bucket in self._segment_buckets(start, end): buckets.setdefault(bucket, []).append(segment) if previous_count == 0: return None dx = end.x() - start.x() dy = end.y() - start.y() length = math.hypot(dx, dy) if length < 0.001: return None bend = min(16.0, max(7.0, length * 0.24)) side = 1.0 if previous_count % 2 == 1 else -1.0 layer = 1.0 + (previous_count - 1) // 2 offset = side * bend * layer perp_x = -dy / length perp_y = dx / length return QPointF( (start.x() + end.x()) / 2.0 + perp_x * offset, (start.y() + end.y()) / 2.0 + perp_y * offset, ) def _overlap_candidates(self, start: QPointF, end: QPointF) -> list[tuple[QPointF, QPointF]]: buckets = getattr(self, "_connection_segment_buckets", None) if not buckets: return getattr(self, "_connection_segments", []) candidates: list[tuple[QPointF, QPointF]] = [] seen: set[int] = set() for bucket in self._segment_buckets(start, end): for segment in buckets.get(bucket, []): marker = id(segment) if marker not in seen: seen.add(marker) candidates.append(segment) return candidates @staticmethod def _segment_buckets(start: QPointF, end: QPointF) -> set[tuple[int, int]]: bucket_size = 96.0 left = math.floor(min(start.x(), end.x()) / bucket_size) right = math.floor(max(start.x(), end.x()) / bucket_size) top = math.floor(min(start.y(), end.y()) / bucket_size) bottom = math.floor(max(start.y(), end.y()) / bucket_size) return { (x_bucket, y_bucket) for x_bucket in range(left, right + 1) for y_bucket in range(top, bottom + 1) } @staticmethod def _segments_visually_overlap(a_start: QPointF, a_end: QPointF, b_start: QPointF, b_end: QPointF) -> bool: ax = a_end.x() - a_start.x() ay = a_end.y() - a_start.y() bx = b_end.x() - b_start.x() by = b_end.y() - b_start.y() a_length = math.hypot(ax, ay) b_length = math.hypot(bx, by) if a_length < 0.001 or b_length < 0.001: return False unit_ax = ax / a_length unit_ay = ay / a_length unit_bx = bx / b_length unit_by = by / b_length parallel_score = abs(unit_ax * unit_bx + unit_ay * unit_by) if parallel_score < 0.985: return False def distance_to_a_line(point: QPointF) -> float: return abs((point.x() - a_start.x()) * unit_ay - (point.y() - a_start.y()) * unit_ax) if max(distance_to_a_line(b_start), distance_to_a_line(b_end)) > 5.0: return False b0 = (b_start.x() - a_start.x()) * unit_ax + (b_start.y() - a_start.y()) * unit_ay b1 = (b_end.x() - a_start.x()) * unit_ax + (b_end.y() - a_start.y()) * unit_ay overlap_start = max(0.0, min(b0, b1)) overlap_end = min(a_length, max(b0, b1)) return (overlap_end - overlap_start) >= min(10.0, min(a_length, b_length) * 0.35) def _add_connection_path( self, path: QPainterPath, color: str, width: float, alpha: float, *, z_value: float = 1, line_id: int | None = None, connection_kind: str = "inner", ) -> None: if path.isEmpty(): return item = self.scene().addPath(path) item.setData(0, line_id) item.setData(1, connection_kind) item.setData(2, color) item.setZValue(z_value) self._apply_connection_item_style(item) self.connection_items.append(item) def _restyle_connection_items(self) -> None: for item in self.connection_items: if isValid(item): self._apply_connection_item_style(item) def _apply_connection_item_style(self, item: QGraphicsPathItem) -> None: line_id = item.data(0) kind = str(item.data(1) or "inner") color = str(item.data(2) or "#e11d48") is_active = self.active_line_id is None or line_id == self.active_line_id if kind == "inner": width = 2.8 if is_active else 1.2 alpha = 0.9 if is_active else 0.25 elif kind == "bridge": width = 1.9 if is_active else 1.0 alpha = 0.65 if is_active else 0.18 elif kind == "arrow": width = 1.8 if is_active else 1.0 alpha = 0.95 if is_active else 0.28 else: width = 1.8 if is_active else 1.0 alpha = 0.65 if is_active else 0.18 pen_color = QColor(color) pen_color.setAlphaF(alpha) item.setPen(QPen(pen_color, width)) def _selection_has_assignment(self) -> bool: if self.document is None: return False for point_id in self.selected_point_ids(): point = self.document.get_point(point_id) if point.line_id is not None or point.block_id is not None: return True return False def _single_selected_point_id(self) -> int | None: point_ids = self.selected_point_ids() return point_ids[0] if len(point_ids) == 1 else None def _to_scene_point(self, x: float, y: float) -> QPointF: if self._raw_bounds is None: return QPointF(x, y) x *= self._display_scale y *= self._display_scale min_x, _max_x, _min_y, max_y = self._raw_bounds return QPointF((x - min_x) + self._margin, (max_y - y) + self._margin) def _background_origin_scene_point(self) -> QPointF: if self._raw_bounds is not None: min_x, max_x, min_y, max_y = self._raw_bounds return self._scene_point_from_display_units((min_x + max_x) / 2.0, (min_y + max_y) / 2.0) if self._content_scene_rect is not None: return self._content_scene_rect.center() return QPointF(0.0, 0.0) def _grid_origin_scene_units(self) -> tuple[float, float]: if self.document is None or not self.document.points: return 0.0, 0.0 points = [point for point in self.document.points if not point.manual] or self.document.points min_row = min(point.row for point in points) min_col = min(point.col for point in points) anchor = min(points, key=lambda point: (point.y * self._display_scale, point.x * self._display_scale, point.uid)) anchor_x = anchor.x * self._display_scale anchor_y = anchor.y * self._display_scale origin_x = anchor_x - (anchor.col - min_col + 0.5) * self._grid_step origin_y = anchor_y - (anchor.row - min_row + 0.5) * self._grid_step return origin_x, origin_y def _scene_point_from_display_units(self, x: float, y: float) -> QPointF: if self._raw_bounds is None: return QPointF(x, y) min_x, _max_x, _min_y, max_y = self._raw_bounds return QPointF((x - min_x) + self._margin, (max_y - y) + self._margin) def _document_point_from_scene(self, scene_pos: QPointF) -> QPointF: if self._raw_bounds is None: return QPointF(scene_pos.x(), scene_pos.y()) min_x, _max_x, _min_y, max_y = self._raw_bounds display_x = scene_pos.x() - self._margin + min_x display_y = max_y - (scene_pos.y() - self._margin) scale = self._display_scale or 1.0 return QPointF(display_x / scale, display_y / scale) @staticmethod def _move_scrollbar(scrollbar: QScrollBar, delta: int) -> None: scrollbar.setValue(scrollbar.value() - delta)