Connection points
This commit is contained in:
24
src/bedit_gui/commands/graph_connection_points_command.py
Normal file
24
src/bedit_gui/commands/graph_connection_points_command.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user