Graph view shows graph

This commit is contained in:
Joppe Blondel
2026-08-17 11:29:07 +02:00
parent 0c93338e39
commit 8e7d2d3efd
9 changed files with 482 additions and 27 deletions
+164 -5
View File
@@ -1,17 +1,29 @@
from __future__ import annotations
from PySide6.QtCore import QEvent, QObject, QRectF, Qt
from PySide6.QtGui import QColor, QPainter, QPen, QWheelEvent
from PySide6.QtWidgets import QGraphicsScene, QGraphicsView, QWidget
from math import hypot
from bedit_core.models import Component, GraphImplementation
from PySide6.QtCore import QEvent, QObject, QPointF, QRectF, QSize, QTimer, Qt
from PySide6.QtGui import QColor, QPainter, QPainterPath, QPen, QWheelEvent
from PySide6.QtWidgets import QGraphicsItem, QGraphicsPathItem, QGraphicsPixmapItem, QGraphicsScene, QGraphicsView, QWidget
from bedit_core.models import BondCausality, BondConnection, Component, ComponentID, GraphImplementation, SignalConnection
from bedit_gui.models import Graph, 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
GRID_SPACING = 32
SCENE_SIZE = 10000
MIN_ZOOM = 0.2
MAX_ZOOM = 4.0
ZOOM_STEP = 1.15
ZOOM_TO_FIT_PADDING = 32.0
COMPONENT_ICON_SIZE = QSize(96, 96)
FALLBACK_COMPONENT_SPACING = 128
CONNECTION_WIDTH = 2.0
CONNECTION_BOUNDING_BOX_SPACING = 8.0
ARROW_LENGTH = 14.0
ARROW_HALF_WIDTH = 7.0
CAUSALITY_TICK_HALF_LENGTH = 8.0
class GraphGraphicsScene(QGraphicsScene):
@@ -34,6 +46,62 @@ class GraphGraphicsScene(QGraphicsScene):
y += GRID_SPACING
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:
super().__init__()
self.setPath(self._connection_path(points, half_arrow, tick_at_source))
pen = QPen(QColor("#202020"), CONNECTION_WIDTH)
pen.setCosmetic(True)
self.setPen(pen)
self.setZValue(-1)
@staticmethod
def _connection_path(points: list[tuple[float, float]], half_arrow: bool, tick_at_source: bool | None = None) -> QPainterPath:
path = QPainterPath(QPointF(*points[0]))
for point in points[1:]:
path.lineTo(QPointF(*point))
target = QPointF(*points[-1])
previous = next((QPointF(*point) for point in reversed(points[:-1]) if QPointF(*point) != target), None)
if previous is None:
return path
dx = target.x() - previous.x()
dy = target.y() - previous.y()
length = hypot(dx, dy)
back_x = target.x() - ARROW_LENGTH * dx / length
back_y = target.y() - ARROW_LENGTH * dy / length
perpendicular_x = -ARROW_HALF_WIDTH * dy / length
perpendicular_y = ARROW_HALF_WIDTH * dx / length
path.moveTo(target)
path.lineTo(back_x + perpendicular_x, back_y + perpendicular_y)
if not half_arrow:
path.moveTo(target)
path.lineTo(back_x - perpendicular_x, back_y - perpendicular_y)
if tick_at_source is not None:
GraphConnectionItem._add_causality_tick(path, points, tick_at_source)
return path
@staticmethod
def _add_causality_tick(path: QPainterPath, points: list[tuple[float, float]], at_source: bool) -> None:
if at_source:
endpoint = QPointF(*points[0])
neighbor = next((QPointF(*point) for point in points[1:] if QPointF(*point) != endpoint), None)
else:
endpoint = QPointF(*points[-1])
neighbor = next((QPointF(*point) for point in reversed(points[:-1]) if QPointF(*point) != endpoint), None)
if neighbor is None:
return
dx = neighbor.x() - endpoint.x()
dy = neighbor.y() - endpoint.y()
length = hypot(dx, dy)
perpendicular_x = -CAUSALITY_TICK_HALF_LENGTH * dy / length
perpendicular_y = CAUSALITY_TICK_HALF_LENGTH * dx / length
path.moveTo(endpoint.x() - perpendicular_x, endpoint.y() - perpendicular_y)
path.lineTo(endpoint.x() + perpendicular_x, endpoint.y() + perpendicular_y)
class GraphEditorWidget(QWidget):
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
@@ -46,16 +114,107 @@ class GraphEditorWidget(QWidget):
self.ui.graphicsView.setScene(self.scene)
self.ui.graphicsView.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
self.ui.graphicsView.viewport().installEventFilter(self)
self.ui.actionZoomToFit.triggered.connect(self.zoom_to_fit)
self.ui.graphicsView.centerOn(0, 0)
def set_component(self, component: Component | None) -> None:
def set_component(self, component: Component | None, graph: Graph | None = None, icons: dict[ComponentID, Icon] | None = None) -> None:
if component is not None and not isinstance(component.implementation, GraphImplementation):
raise TypeError("GraphEditorWidget only supports components with a graph implementation")
component_changed = component is not self._component
self._component = component
self.scene.clear()
if component is None:
return
graph = graph or Graph()
icons = icons or {}
positions = {component_id: graph.component_positions.get(component_id, (index * FALLBACK_COMPONENT_SPACING, 0)) for index, component_id in enumerate(component.implementation.graph.components)}
port_owners = {port_id: component_id for component_id, child in component.implementation.graph.components.items() for port_id in child.interface.ports}
component_items: dict[ComponentID, QGraphicsPixmapItem] = {}
component_bounds: dict[ComponentID, QRectF] = {}
for component_id, child in component.implementation.graph.components.items():
icon = icons.get(component_id, Icon())
pixmap = render_icon(icon, child.interface.ports, COMPONENT_ICON_SIZE).pixmap(COMPONENT_ICON_SIZE)
item = QGraphicsPixmapItem(pixmap)
item.setOffset(-pixmap.width() / 2, -pixmap.height() / 2)
item.setPos(*positions[component_id])
item.setToolTip(child.name)
item.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
component_items[component_id] = item
bounds = get_pixmap_bounding_box(pixmap)
component_bounds[component_id] = bounds.translated(item.pos().x() - pixmap.width() / 2, item.pos().y() - pixmap.height() / 2)
for connection_id, connection in component.implementation.graph.connections.items():
if not isinstance(connection, (SignalConnection, BondConnection)):
continue
source_component = port_owners.get(connection.source)
target_component = port_owners.get(connection.target)
if source_component is None or target_component is None:
continue
visual_connection = graph.connections.get(connection_id)
points = list(visual_connection.points) if visual_connection is not None else []
if len(points) < 2:
points = [positions[source_component], positions[target_component]]
else:
points = [positions[source_component], *points[1:-1], positions[target_component]]
points = self._clip_connection(points, component_bounds[source_component], component_bounds[target_component])
tick_at_source = None
if isinstance(connection, BondConnection):
if connection.causality is BondCausality.EFFORT_OUT:
tick_at_source = False
elif connection.causality is BondCausality.FLOW_OUT:
tick_at_source = True
connection_item = GraphConnectionItem(points, half_arrow=isinstance(connection, BondConnection), tick_at_source=tick_at_source)
connection_item.setData(0, str(connection_id))
self.scene.addItem(connection_item)
for item in component_items.values():
self.scene.addItem(item)
if component_changed:
QTimer.singleShot(0, self.ui.actionZoomToFit.trigger)
@staticmethod
def _clip_connection(points: list[tuple[int, int]], source_bounds: QRectF, target_bounds: QRectF) -> list[tuple[float, float]]:
source = points[0]
target = points[-1]
source_direction = next((point for point in points[1:] if point != source), None)
target_direction = next((point for point in reversed(points[:-1]) if point != target), None)
if source_direction is None or target_direction is None:
return points
clipped = list(points)
clipped[0] = GraphEditorWidget._bounding_box_edge(source_bounds, source_direction)
clipped[-1] = GraphEditorWidget._bounding_box_edge(target_bounds, target_direction)
return clipped
@staticmethod
def _bounding_box_edge(bounds: QRectF, toward: tuple[int, int]) -> tuple[float, float]:
bounds = bounds.adjusted(-CONNECTION_BOUNDING_BOX_SPACING, -CONNECTION_BOUNDING_BOX_SPACING, CONNECTION_BOUNDING_BOX_SPACING, CONNECTION_BOUNDING_BOX_SPACING)
center = bounds.center()
dx = toward[0] - center.x()
dy = toward[1] - center.y()
horizontal_scale = bounds.width() / 2 / abs(dx) if dx else float("inf")
vertical_scale = bounds.height() / 2 / abs(dy) if dy else float("inf")
scale = min(horizontal_scale, vertical_scale)
return center.x() + dx * scale, center.y() + dy * scale
def component(self) -> Component | None:
return self._component
def zoom_to_fit(self) -> None:
bounds = self.scene.itemsBoundingRect()
if bounds.isEmpty():
self.ui.graphicsView.resetTransform()
self.ui.graphicsView.centerOn(0, 0)
return
bounds.adjust(-ZOOM_TO_FIT_PADDING, -ZOOM_TO_FIT_PADDING, ZOOM_TO_FIT_PADDING, ZOOM_TO_FIT_PADDING)
self.ui.graphicsView.fitInView(bounds, Qt.AspectRatioMode.KeepAspectRatio)
current_zoom = self.ui.graphicsView.transform().m11()
target_zoom = min(MAX_ZOOM, max(MIN_ZOOM, current_zoom))
if target_zoom != current_zoom:
self.ui.graphicsView.scale(target_zoom / current_zoom, target_zoom / current_zoom)
self.ui.graphicsView.centerOn(bounds.center())
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
if watched is self.ui.graphicsView.viewport() and event.type() == QEvent.Type.Wheel:
assert isinstance(event, QWheelEvent)