From cb9e03a6bfee562b6ebd01b4bb4935b5d4eeaa4c Mon Sep 17 00:00:00 2001 From: Joppe Blondel Date: Mon, 17 Aug 2026 12:16:59 +0200 Subject: [PATCH] Connection points --- .../graph_connection_points_command.py | 24 +++ .../controllers/document_tree_controller.py | 9 +- src/bedit_gui/documents/document.py | 22 ++- src/bedit_gui/views/graph_editor_widget.py | 153 +++++++++++++++++- 4 files changed, 197 insertions(+), 11 deletions(-) create mode 100644 src/bedit_gui/commands/graph_connection_points_command.py diff --git a/src/bedit_gui/commands/graph_connection_points_command.py b/src/bedit_gui/commands/graph_connection_points_command.py new file mode 100644 index 0000000..9b89235 --- /dev/null +++ b/src/bedit_gui/commands/graph_connection_points_command.py @@ -0,0 +1,24 @@ +from copy import deepcopy + +from PySide6.QtGui import QUndoCommand + +from bedit_core.models import ComponentID, ConnectionID + + +class ChangeGraphConnectionPointsCommand(QUndoCommand): + def __init__(self, document: object, graph_id: ComponentID, connection_id: ConnectionID, points: list[tuple[int, int]], text: str) -> None: + super().__init__(text) + self.document = document + self.graph_id = graph_id + self.connection_id = connection_id + database = document._graph_database(False) + graph = database.graphs.get(graph_id) if database is not None else None + connection = graph.connections.get(connection_id) if graph is not None else None + self.old_points = deepcopy(connection.points) if connection is not None else None + self.new_points = deepcopy(points) + + def redo(self) -> None: + self.document._set_graph_connection_points(self.graph_id, self.connection_id, self.new_points) + + def undo(self) -> None: + self.document._set_graph_connection_points(self.graph_id, self.connection_id, self.old_points) diff --git a/src/bedit_gui/controllers/document_tree_controller.py b/src/bedit_gui/controllers/document_tree_controller.py index b242194..93607b1 100644 --- a/src/bedit_gui/controllers/document_tree_controller.py +++ b/src/bedit_gui/controllers/document_tree_controller.py @@ -6,7 +6,7 @@ from PySide6.QtCore import QEvent, QObject, QPoint, QSize, Qt from PySide6.QtGui import QMouseEvent from PySide6.QtWidgets import QAbstractItemView, QDialog, QHeaderView, QMenu -from bedit_core.models import Component, ComponentID, EquationImplementation, GraphImplementation, Port, PortID, Parameter, ParameterID +from bedit_core.models import Component, ComponentID, ConnectionID, EquationImplementation, GraphImplementation, Port, PortID, Parameter, ParameterID from bedit_core.models import Document as CoreDocument from bedit_gui.documents import Document from bedit_gui.models import Graph, Icon @@ -63,11 +63,13 @@ class DocumentTreeController(QObject): document.model_changed.connect(self._on_document_changed) document.icon_changed.connect(self._on_icon_changed) document.graph_component_position_changed.connect(self._on_graph_component_position_changed) + document.graph_connection_points_changed.connect(self._on_graph_connection_points_changed) document.equation_text_changed.connect(self._on_equation_text_changed) self.model.rename_document_requested.connect(self.document.rename) self.model.rename_component_requested.connect(self.document.rename_component) window.graph_editor.component_move_requested.connect(self.document.move_graph_component) window.graph_editor.component_context_menu_requested.connect(self._show_graph_component_context_menu) + window.graph_editor.connection_points_change_requested.connect(self.document.change_graph_connection_points) # Add deselection with esc to this widget window.ui.actionEscape.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) @@ -150,6 +152,11 @@ class DocumentTreeController(QObject): if graph_component is not None and self.document.component_id(graph_component) == graph_id: self.window.graph_editor.set_component_position(component_id, position) + def _on_graph_connection_points_changed(self, graph_id: ComponentID, connection_id: ConnectionID, points: list[tuple[int, int]] | None) -> None: + graph_component = self.window.graph_editor.component() + if graph_component is not None and self.document.component_id(graph_component) == graph_id: + self.window.graph_editor.set_connection_points(connection_id, points) + def _collect_components(self, components: dict[ComponentID, Component]) -> None: for component_id, component in components.items(): self._components[component_id] = component diff --git a/src/bedit_gui/documents/document.py b/src/bedit_gui/documents/document.py index 38b8691..6e8fd31 100644 --- a/src/bedit_gui/documents/document.py +++ b/src/bedit_gui/documents/document.py @@ -6,18 +6,19 @@ from pathlib import Path from PySide6.QtCore import QObject, Signal from PySide6.QtGui import QUndoStack -from bedit_core.models import ID, Component, ComponentID, GraphImplementation, Port, PortID, Parameter, ParameterID +from bedit_core.models import ID, Component, ComponentID, ConnectionID, GraphImplementation, Port, PortID, Parameter, ParameterID from bedit_core.models import Document as CoreDocument from bedit_gui.commands.change_icon_command import ChangeIconCommand from bedit_gui.commands.equation_text_command import ChangeEquationTextCommand from bedit_gui.commands.graph_position_command import MoveGraphComponentCommand +from bedit_gui.commands.graph_connection_points_command import ChangeGraphConnectionPointsCommand from bedit_gui.commands.port_commands import AddPortCommand, ChangePortCommand, RemovePortCommand from bedit_gui.commands.param_commands import AddParamCommand, ChangeParamCommand, RemoveParamCommand from bedit_gui.commands.rename_component_command import RenameComponentCommand from bedit_gui.commands.rename_document_command import RenameDocumentCommand from bedit_gui.commands.simulation_database_command import ChangeSimulationDatabaseCommand from bedit_gui.commands.component_command import AddEmptyEquationComponent, AddEmptyGraphComponent, DeleteComponent, PasteComponents -from bedit_gui.models import Graph, GraphDatabase, Icon, IconDatabase, Simulation, SimulationDatabase +from bedit_gui.models import Graph, GraphConnection, GraphDatabase, Icon, IconDatabase, Simulation, SimulationDatabase from bedit_gui.services import document_files @@ -31,6 +32,7 @@ class Document(QObject): equation_text_changed = Signal(object, str) simulation_database_changed = Signal(object) graph_component_position_changed = Signal(object, object, object) + graph_connection_points_changed = Signal(object, object, object) def __init__(self, parent: QObject | None = None) -> None: super().__init__(parent) @@ -163,6 +165,22 @@ class Document(QObject): metadata["graph_database"] = database return database + def change_graph_connection_points(self, graph_component: Component, connection_id: ConnectionID, points: list[tuple[int, int]], text: str) -> None: + graph_id = self.component_id(graph_component) + self.undo_stack.push(ChangeGraphConnectionPointsCommand(self, graph_id, connection_id, points, text)) + + def _set_graph_connection_points(self, graph_id: ComponentID, connection_id: ConnectionID, points: list[tuple[int, int]] | None) -> None: + if points is None: + database = self._graph_database(False) + graph = database.graphs.get(graph_id) if database is not None else None + if graph is not None: + graph.connections.pop(connection_id, None) + else: + database = self._graph_database(True) + graph = database.graphs.setdefault(graph_id, Graph()) + graph.connections[connection_id] = GraphConnection(points=list(points)) + self.graph_connection_points_changed.emit(graph_id, connection_id, points) + def change_icon(self, component_id: ComponentID, icon: Icon) -> None: self.undo_stack.push(ChangeIconCommand(self, component_id, icon)) diff --git a/src/bedit_gui/views/graph_editor_widget.py b/src/bedit_gui/views/graph_editor_widget.py index 8cd31c8..64ca052 100644 --- a/src/bedit_gui/views/graph_editor_widget.py +++ b/src/bedit_gui/views/graph_editor_widget.py @@ -1,13 +1,14 @@ from __future__ import annotations +from itertools import pairwise from math import hypot from PySide6.QtCore import QEvent, QObject, QPointF, QRectF, QSize, QTimer, Qt, Signal -from PySide6.QtGui import QColor, QCursor, QPainter, QPainterPath, QPen, QWheelEvent -from PySide6.QtWidgets import QGraphicsItem, QGraphicsPathItem, QGraphicsPixmapItem, QGraphicsScene, QGraphicsSceneMouseEvent, QGraphicsView, QWidget +from PySide6.QtGui import QBrush, QColor, QCursor, QPainter, QPainterPath, QPen, QWheelEvent +from PySide6.QtWidgets import QGraphicsEllipseItem, QGraphicsItem, QGraphicsPathItem, QGraphicsPixmapItem, QGraphicsScene, QGraphicsSceneMouseEvent, QGraphicsView, QMenu, QWidget from bedit_core.models import BondCausality, BondConnection, Component, ComponentID, ConnectionID, GraphImplementation, SignalConnection -from bedit_gui.models import Graph, Icon +from bedit_gui.models import Graph, GraphConnection, Icon from bedit_gui.ui.generated.ui_graph_editor_widget import Ui_graphEditorWidget from bedit_gui.utils.icon import get_pixmap_bounding_box, render_icon @@ -51,13 +52,24 @@ class GraphGraphicsScene(QGraphicsScene): class GraphConnectionItem(QGraphicsPathItem): """A routed connection with a full signal arrow or half bond arrow.""" - def __init__(self, points: list[tuple[float, float]], *, half_arrow: bool, tick_at_source: bool | None = None) -> None: + def __init__(self, points: list[tuple[float, float]], *, half_arrow: bool, tick_at_source: bool | None = None, connection_id: ConnectionID | None = None, editor: GraphEditorWidget | None = None) -> None: super().__init__() + self.connection_id = connection_id + self.editor = editor self.setPath(self._connection_path(points, half_arrow, tick_at_source)) pen = QPen(QColor("#202020"), CONNECTION_WIDTH) self.setPen(pen) self.setZValue(-1) + def contextMenuEvent(self, event) -> None: + if self.editor is None or self.connection_id is None: + return + menu = QMenu(self.editor) + add_point = menu.addAction("Add Point") + if menu.exec(event.screenPos()) is add_point: + self.editor.add_connection_point(self.connection_id, event.scenePos()) + event.accept() + @staticmethod def _connection_path(points: list[tuple[float, float]], half_arrow: bool, tick_at_source: bool | None = None) -> QPainterPath: path = QPainterPath(QPointF(*points[0])) @@ -144,9 +156,56 @@ class GraphComponentItem(QGraphicsPixmapItem): event.accept() +class GraphConnectionPointItem(QGraphicsEllipseItem): + def __init__(self, connection_id: ConnectionID, index: int, position: tuple[int, int], editor: GraphEditorWidget) -> None: + radius = CONNECTION_WIDTH + super().__init__(-radius, -radius, radius * 2, radius * 2) + self.connection_id = connection_id + self.index = index + self.editor = editor + self._drag_start = QPointF() + self._dragging = False + self.setPos(*position) + self.setPen(QPen(Qt.PenStyle.NoPen)) + self.setBrush(QBrush(QColor("#202020"))) + self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable) + self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable) + self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges) + + def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: object) -> object: + if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange and self._dragging and isinstance(value, QPointF): + size = self.editor.snap_to_grid_size + value = QPointF(round(value.x() / size) * size, round(value.y() / size) * size) + result = super().itemChange(change, value) + if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged: + self.editor.refresh_connections() + return result + + def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: + self._drag_start = QPointF(self.pos()) + self._dragging = True + super().mousePressEvent(event) + + def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: + super().mouseReleaseEvent(event) + self._dragging = False + position = (round(self.pos().x()), round(self.pos().y())) + self.setPos(*position) + if self.pos() != self._drag_start: + self.editor.finish_connection_point_move(self.connection_id) + + def contextMenuEvent(self, event) -> None: + menu = QMenu(self.editor) + delete_point = menu.addAction("Delete Point") + if menu.exec(event.screenPos()) is delete_point: + self.editor.delete_connection_point(self.connection_id, self.index) + event.accept() + + class GraphEditorWidget(QWidget): component_move_requested = Signal(object, object, object) component_context_menu_requested = Signal(object, object) + connection_points_change_requested = Signal(object, object, object, str) def __init__(self, parent: QWidget | None = None, snap_to_grid_size: int = 4) -> None: super().__init__(parent) @@ -158,6 +217,7 @@ class GraphEditorWidget(QWidget): self._component_items: dict[ComponentID, GraphComponentItem] = {} self._component_bounds: dict[ComponentID, QRectF] = {} self._connection_items: dict[ConnectionID, GraphConnectionItem] = {} + self._connection_point_items: dict[ConnectionID, list[GraphConnectionPointItem]] = {} self.set_snap_to_grid_size(snap_to_grid_size) self.scene = GraphGraphicsScene(self) self.scene.setSceneRect(-SCENE_SIZE / 2, -SCENE_SIZE / 2, SCENE_SIZE, SCENE_SIZE) @@ -185,6 +245,7 @@ class GraphEditorWidget(QWidget): self._component_items = {} self._component_bounds = {} self._connection_items = {} + self._connection_point_items = {} self.scene.clear() if component is None: return @@ -213,10 +274,12 @@ class GraphEditorWidget(QWidget): tick_at_source = False elif connection.causality is BondCausality.FLOW_OUT: tick_at_source = True - connection_item = GraphConnectionItem([(0, 0), (1, 0)], half_arrow=isinstance(connection, BondConnection), tick_at_source=tick_at_source) + connection_item = GraphConnectionItem([(0, 0), (1, 0)], half_arrow=isinstance(connection, BondConnection), tick_at_source=tick_at_source, connection_id=connection_id, editor=self) connection_item.setData(0, str(connection_id)) self._connection_items[connection_id] = connection_item self.scene.addItem(connection_item) + visual_connection = graph.connections.get(connection_id) + self._create_connection_point_items(connection_id, visual_connection.points[1:-1] if visual_connection is not None and len(visual_connection.points) >= 2 else []) self.refresh_connections() if component_changed: QTimer.singleShot(0, self.ui.actionZoomToFit.trigger) @@ -234,9 +297,8 @@ class GraphEditorWidget(QWidget): continue source_position = self._item_position(source_component) target_position = self._item_position(target_component) - visual_connection = self._graph.connections.get(connection_id) - points = list(visual_connection.points) if visual_connection is not None else [] - points = [source_position, target_position] if len(points) < 2 else [source_position, *points[1:-1], target_position] + point_items = self._connection_point_items.get(connection_id, []) + points = [source_position, *((point.pos().x(), point.pos().y()) for point in point_items), target_position] points = self._straighten_direct_connection(points) source_bounds = self._component_bounds[source_component].translated(*source_position) target_bounds = self._component_bounds[target_component].translated(*target_position) @@ -246,6 +308,81 @@ class GraphEditorWidget(QWidget): tick_at_source = False if connection.causality is BondCausality.EFFORT_OUT else True if connection.causality is BondCausality.FLOW_OUT else None item.setPath(item._connection_path(points, isinstance(connection, BondConnection), tick_at_source)) + def add_connection_point(self, connection_id: ConnectionID, scene_position: QPointF) -> None: + points = self._connection_metadata_points(connection_id) + position = self._snap_position(scene_position) + index = self._nearest_segment_index(points, position) + 1 + points.insert(index, position) + self._request_connection_points_change(connection_id, points, "Add connection point") + + def delete_connection_point(self, connection_id: ConnectionID, index: int) -> None: + points = self._connection_metadata_points(connection_id) + if 0 < index < len(points) - 1: + points.pop(index) + self._request_connection_points_change(connection_id, points, "Delete connection point") + + def finish_connection_point_move(self, connection_id: ConnectionID) -> None: + self._request_connection_points_change(connection_id, self._connection_metadata_points(connection_id), "Move connection point") + + def set_connection_points(self, connection_id: ConnectionID, points: list[tuple[int, int]] | None) -> None: + if points is None: + self._graph.connections.pop(connection_id, None) + interior_points = [] + else: + self._graph.connections[connection_id] = GraphConnection(points=list(points)) + interior_points = points[1:-1] + items = self._connection_point_items.get(connection_id, []) + if len(items) == len(interior_points): + for item, position in zip(items, interior_points): + item.setPos(*position) + else: + for item in items: + self.scene.removeItem(item) + self._create_connection_point_items(connection_id, interior_points) + self.refresh_connections() + + def _create_connection_point_items(self, connection_id: ConnectionID, positions: list[tuple[int, int]]) -> None: + items = [GraphConnectionPointItem(connection_id, index, position, self) for index, position in enumerate(positions, 1)] + self._connection_point_items[connection_id] = items + for item in items: + self.scene.addItem(item) + + def _connection_metadata_points(self, connection_id: ConnectionID) -> list[tuple[int, int]]: + component = self._component + if component is None: + return [] + connection = component.implementation.graph.connections[connection_id] + port_owners = {port_id: component_id for component_id, child in component.implementation.graph.components.items() for port_id in child.interface.ports} + source = self._item_position(port_owners[connection.source]) + target = self._item_position(port_owners[connection.target]) + interior = [(round(item.pos().x()), round(item.pos().y())) for item in self._connection_point_items.get(connection_id, [])] + return [(round(source[0]), round(source[1])), *interior, (round(target[0]), round(target[1]))] + + def _request_connection_points_change(self, connection_id: ConnectionID, points: list[tuple[int, int]], text: str) -> None: + if self._component is not None: + self.connection_points_change_requested.emit(self._component, connection_id, points, text) + + def _snap_position(self, position: QPointF) -> tuple[int, int]: + size = self.snap_to_grid_size + return round(position.x() / size) * size, round(position.y() / size) * size + + @staticmethod + def _nearest_segment_index(points: list[tuple[int, int]], position: tuple[int, int]) -> int: + best_index = 0 + best_distance = float("inf") + for index, (start, end) in enumerate(pairwise(points)): + dx = end[0] - start[0] + dy = end[1] - start[1] + length_squared = dx * dx + dy * dy + ratio = 0.0 if not length_squared else max(0.0, min(1.0, ((position[0] - start[0]) * dx + (position[1] - start[1]) * dy) / length_squared)) + closest_x = start[0] + ratio * dx + closest_y = start[1] + ratio * dy + distance = (position[0] - closest_x) ** 2 + (position[1] - closest_y) ** 2 + if distance < best_distance: + best_index = index + best_distance = distance + return best_index + def finish_component_move(self, component_id: ComponentID, position: tuple[int, int]) -> None: if self._component is not None: self.component_move_requested.emit(self._component, component_id, position)