Connection points

This commit is contained in:
Joppe Blondel
2026-08-17 12:16:59 +02:00
parent 0bb2141673
commit cb9e03a6bf
4 changed files with 197 additions and 11 deletions
+145 -8
View File
@@ -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)