Graph view shows graph
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user