Connection points

This commit is contained in:
2026-08-17 12:16:59 +02:00
parent 0bb2141673
commit cb9e03a6bf
4 changed files with 197 additions and 11 deletions

View File

@@ -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)

View File

@@ -6,7 +6,7 @@ from PySide6.QtCore import QEvent, QObject, QPoint, QSize, Qt
from PySide6.QtGui import QMouseEvent from PySide6.QtGui import QMouseEvent
from PySide6.QtWidgets import QAbstractItemView, QDialog, QHeaderView, QMenu 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_core.models import Document as CoreDocument
from bedit_gui.documents import Document from bedit_gui.documents import Document
from bedit_gui.models import Graph, Icon from bedit_gui.models import Graph, Icon
@@ -63,11 +63,13 @@ class DocumentTreeController(QObject):
document.model_changed.connect(self._on_document_changed) document.model_changed.connect(self._on_document_changed)
document.icon_changed.connect(self._on_icon_changed) document.icon_changed.connect(self._on_icon_changed)
document.graph_component_position_changed.connect(self._on_graph_component_position_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) document.equation_text_changed.connect(self._on_equation_text_changed)
self.model.rename_document_requested.connect(self.document.rename) self.model.rename_document_requested.connect(self.document.rename)
self.model.rename_component_requested.connect(self.document.rename_component) 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_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.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 # Add deselection with esc to this widget
window.ui.actionEscape.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) 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: 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) 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: def _collect_components(self, components: dict[ComponentID, Component]) -> None:
for component_id, component in components.items(): for component_id, component in components.items():
self._components[component_id] = component self._components[component_id] = component

View File

@@ -6,18 +6,19 @@ from pathlib import Path
from PySide6.QtCore import QObject, Signal from PySide6.QtCore import QObject, Signal
from PySide6.QtGui import QUndoStack 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_core.models import Document as CoreDocument
from bedit_gui.commands.change_icon_command import ChangeIconCommand from bedit_gui.commands.change_icon_command import ChangeIconCommand
from bedit_gui.commands.equation_text_command import ChangeEquationTextCommand from bedit_gui.commands.equation_text_command import ChangeEquationTextCommand
from bedit_gui.commands.graph_position_command import MoveGraphComponentCommand 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.port_commands import AddPortCommand, ChangePortCommand, RemovePortCommand
from bedit_gui.commands.param_commands import AddParamCommand, ChangeParamCommand, RemoveParamCommand from bedit_gui.commands.param_commands import AddParamCommand, ChangeParamCommand, RemoveParamCommand
from bedit_gui.commands.rename_component_command import RenameComponentCommand from bedit_gui.commands.rename_component_command import RenameComponentCommand
from bedit_gui.commands.rename_document_command import RenameDocumentCommand from bedit_gui.commands.rename_document_command import RenameDocumentCommand
from bedit_gui.commands.simulation_database_command import ChangeSimulationDatabaseCommand from bedit_gui.commands.simulation_database_command import ChangeSimulationDatabaseCommand
from bedit_gui.commands.component_command import AddEmptyEquationComponent, AddEmptyGraphComponent, DeleteComponent, PasteComponents 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 from bedit_gui.services import document_files
@@ -31,6 +32,7 @@ class Document(QObject):
equation_text_changed = Signal(object, str) equation_text_changed = Signal(object, str)
simulation_database_changed = Signal(object) simulation_database_changed = Signal(object)
graph_component_position_changed = Signal(object, object, 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: def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent) super().__init__(parent)
@@ -163,6 +165,22 @@ class Document(QObject):
metadata["graph_database"] = database metadata["graph_database"] = database
return 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: def change_icon(self, component_id: ComponentID, icon: Icon) -> None:
self.undo_stack.push(ChangeIconCommand(self, component_id, icon)) self.undo_stack.push(ChangeIconCommand(self, component_id, icon))

View File

@@ -1,13 +1,14 @@
from __future__ import annotations from __future__ import annotations
from itertools import pairwise
from math import hypot from math import hypot
from PySide6.QtCore import QEvent, QObject, QPointF, QRectF, QSize, QTimer, Qt, Signal from PySide6.QtCore import QEvent, QObject, QPointF, QRectF, QSize, QTimer, Qt, Signal
from PySide6.QtGui import QColor, QCursor, QPainter, QPainterPath, QPen, QWheelEvent from PySide6.QtGui import QBrush, QColor, QCursor, QPainter, QPainterPath, QPen, QWheelEvent
from PySide6.QtWidgets import QGraphicsItem, QGraphicsPathItem, QGraphicsPixmapItem, QGraphicsScene, QGraphicsSceneMouseEvent, QGraphicsView, QWidget 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_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.ui.generated.ui_graph_editor_widget import Ui_graphEditorWidget
from bedit_gui.utils.icon import get_pixmap_bounding_box, render_icon from bedit_gui.utils.icon import get_pixmap_bounding_box, render_icon
@@ -51,13 +52,24 @@ class GraphGraphicsScene(QGraphicsScene):
class GraphConnectionItem(QGraphicsPathItem): class GraphConnectionItem(QGraphicsPathItem):
"""A routed connection with a full signal arrow or half bond arrow.""" """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__() super().__init__()
self.connection_id = connection_id
self.editor = editor
self.setPath(self._connection_path(points, half_arrow, tick_at_source)) self.setPath(self._connection_path(points, half_arrow, tick_at_source))
pen = QPen(QColor("#202020"), CONNECTION_WIDTH) pen = QPen(QColor("#202020"), CONNECTION_WIDTH)
self.setPen(pen) self.setPen(pen)
self.setZValue(-1) 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 @staticmethod
def _connection_path(points: list[tuple[float, float]], half_arrow: bool, tick_at_source: bool | None = None) -> QPainterPath: def _connection_path(points: list[tuple[float, float]], half_arrow: bool, tick_at_source: bool | None = None) -> QPainterPath:
path = QPainterPath(QPointF(*points[0])) path = QPainterPath(QPointF(*points[0]))
@@ -144,9 +156,56 @@ class GraphComponentItem(QGraphicsPixmapItem):
event.accept() 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): class GraphEditorWidget(QWidget):
component_move_requested = Signal(object, object, object) component_move_requested = Signal(object, object, object)
component_context_menu_requested = Signal(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: def __init__(self, parent: QWidget | None = None, snap_to_grid_size: int = 4) -> None:
super().__init__(parent) super().__init__(parent)
@@ -158,6 +217,7 @@ class GraphEditorWidget(QWidget):
self._component_items: dict[ComponentID, GraphComponentItem] = {} self._component_items: dict[ComponentID, GraphComponentItem] = {}
self._component_bounds: dict[ComponentID, QRectF] = {} self._component_bounds: dict[ComponentID, QRectF] = {}
self._connection_items: dict[ConnectionID, GraphConnectionItem] = {} 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.set_snap_to_grid_size(snap_to_grid_size)
self.scene = GraphGraphicsScene(self) self.scene = GraphGraphicsScene(self)
self.scene.setSceneRect(-SCENE_SIZE / 2, -SCENE_SIZE / 2, SCENE_SIZE, SCENE_SIZE) 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_items = {}
self._component_bounds = {} self._component_bounds = {}
self._connection_items = {} self._connection_items = {}
self._connection_point_items = {}
self.scene.clear() self.scene.clear()
if component is None: if component is None:
return return
@@ -213,10 +274,12 @@ class GraphEditorWidget(QWidget):
tick_at_source = False tick_at_source = False
elif connection.causality is BondCausality.FLOW_OUT: elif connection.causality is BondCausality.FLOW_OUT:
tick_at_source = True 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)) connection_item.setData(0, str(connection_id))
self._connection_items[connection_id] = connection_item self._connection_items[connection_id] = connection_item
self.scene.addItem(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() self.refresh_connections()
if component_changed: if component_changed:
QTimer.singleShot(0, self.ui.actionZoomToFit.trigger) QTimer.singleShot(0, self.ui.actionZoomToFit.trigger)
@@ -234,9 +297,8 @@ class GraphEditorWidget(QWidget):
continue continue
source_position = self._item_position(source_component) source_position = self._item_position(source_component)
target_position = self._item_position(target_component) target_position = self._item_position(target_component)
visual_connection = self._graph.connections.get(connection_id) point_items = self._connection_point_items.get(connection_id, [])
points = list(visual_connection.points) if visual_connection is not None else [] points = [source_position, *((point.pos().x(), point.pos().y()) for point in point_items), target_position]
points = [source_position, target_position] if len(points) < 2 else [source_position, *points[1:-1], target_position]
points = self._straighten_direct_connection(points) points = self._straighten_direct_connection(points)
source_bounds = self._component_bounds[source_component].translated(*source_position) source_bounds = self._component_bounds[source_component].translated(*source_position)
target_bounds = self._component_bounds[target_component].translated(*target_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 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)) 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: def finish_component_move(self, component_id: ComponentID, position: tuple[int, int]) -> None:
if self._component is not None: if self._component is not None:
self.component_move_requested.emit(self._component, component_id, position) self.component_move_requested.emit(self._component, component_id, position)