diff --git a/src/bedit_gui/controllers/document_tree_controller.py b/src/bedit_gui/controllers/document_tree_controller.py
index 3dbb05a..ba47a63 100644
--- a/src/bedit_gui/controllers/document_tree_controller.py
+++ b/src/bedit_gui/controllers/document_tree_controller.py
@@ -9,13 +9,13 @@ 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 Document as CoreDocument
from bedit_gui.documents import Document
-from bedit_gui.models import Icon
+from bedit_gui.models import Graph, Icon
from bedit_gui.views.dialogs.interface_editor_dialog import InterfaceEditorDialog
from bedit_gui.views.dialogs.param_editor_dialog import ParamEditorDialog
from bedit_gui.views.icon_editor_window import IconEditorWindow
from bedit_gui.views.main_window import MainWindow
from bedit_gui.views.models.document_tree_model import DocumentTreeModel
-from bedit_gui.utils.icon import render_icon
+from bedit_gui.utils.icon import render_fitted_icon
ICON_SIZE = QSize(32, 32)
@@ -102,7 +102,7 @@ class DocumentTreeController(QObject):
self._collect_components(model.root)
for component_id, component in self._components.items():
icon = self.document.component_icon(component_id)
- self.model.set_component_icon(component_id, render_icon(icon, component.interface.ports, ICON_SIZE))
+ self.model.set_component_icon(component_id, render_fitted_icon(icon, component.interface.ports, ICON_SIZE))
# Optional presentation behavior. Later, you could instead remember
# expanded component IDs and restore only those nodes.
@@ -121,7 +121,9 @@ class DocumentTreeController(QObject):
self.window.equation_editor.set_component(None)
self.window.equation_editor.hide()
if component is not None and isinstance(component.implementation, GraphImplementation):
- self.window.graph_editor.set_component(component)
+ graph = self.document.graph_database().graphs.get(self.document.component_id(component), Graph())
+ icons = {component_id: self.document.component_icon(component_id) for component_id in component.implementation.graph.components}
+ self.window.graph_editor.set_component(component, graph, icons)
self.window.graph_editor.show()
else:
self.window.graph_editor.set_component(None)
@@ -135,7 +137,10 @@ class DocumentTreeController(QObject):
component = self._components.get(component_id)
if component is None:
return
- self.model.set_component_icon(component_id, render_icon(icon if isinstance(icon, Icon) else Icon(), component.interface.ports, ICON_SIZE))
+ self.model.set_component_icon(component_id, render_fitted_icon(icon if isinstance(icon, Icon) else Icon(), component.interface.ports, ICON_SIZE))
+ graph_component = self.window.graph_editor.component()
+ if graph_component is not None and isinstance(graph_component.implementation, GraphImplementation) and component_id in graph_component.implementation.graph.components:
+ self._show_component(graph_component)
def _collect_components(self, components: dict[ComponentID, Component]) -> None:
for component_id, component in components.items():
diff --git a/src/bedit_gui/documents/document.py b/src/bedit_gui/documents/document.py
index 152e494..0f83b6a 100644
--- a/src/bedit_gui/documents/document.py
+++ b/src/bedit_gui/documents/document.py
@@ -16,7 +16,7 @@ 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 Icon, IconDatabase, Simulation, SimulationDatabase
+from bedit_gui.models import GraphDatabase, Icon, IconDatabase, Simulation, SimulationDatabase
from bedit_gui.services import document_files
@@ -121,6 +121,14 @@ class Document(QObject):
def component_icon(self, component_id: ComponentID) -> Icon:
return self.stored_component_icon(component_id) or Icon()
+ def graph_database(self) -> GraphDatabase:
+ metadata = self.model.metadata
+ value = metadata.get("graph_database") if metadata is not None else None
+ if isinstance(value, dict):
+ value = GraphDatabase.from_data(value)
+ metadata["graph_database"] = value
+ return deepcopy(value) if isinstance(value, GraphDatabase) else GraphDatabase()
+
def change_icon(self, component_id: ComponentID, icon: Icon) -> None:
self.undo_stack.push(ChangeIconCommand(self, component_id, icon))
diff --git a/src/bedit_gui/models.py b/src/bedit_gui/models.py
index 1a4fa0f..69ab87c 100644
--- a/src/bedit_gui/models.py
+++ b/src/bedit_gui/models.py
@@ -5,7 +5,7 @@ from dataclasses import dataclass, field
from enum import Enum
from typing import Any
-from bedit_core.models import ComponentID, ID, PortID
+from bedit_core.models import ComponentID, ConnectionID, ID, PortID
class ShapeID(ID):
@@ -123,6 +123,50 @@ class IconDatabase:
def to_data(self) -> dict[str, Any]:
return {"format_version": self.format_version, "icons": {str(key): icon.to_data() for key, icon in self.icons.items()}}
+@dataclass
+class GraphConnection:
+ points: list[tuple[int, int]] = field(default_factory=list)
+
+ @classmethod
+ def from_data(cls, data: Mapping[str, Any]) -> GraphConnection:
+ return cls(points=[(int(point[0]), int(point[1])) for point in data.get("points", [])])
+
+ def to_data(self) -> dict[str, Any]:
+ return {"points": [list(point) for point in self.points]}
+
+@dataclass
+class Graph:
+ shapes: dict[ShapeID, Shape] = field(default_factory=dict)
+ component_positions: dict[ComponentID, tuple[int, int]] = field(default_factory=dict)
+ connections: dict[ConnectionID, GraphConnection] = field(default_factory=dict)
+
+ @classmethod
+ def from_data(cls, data: Mapping[str, Any]) -> Graph:
+ shapes = {ShapeID(key): Shape.from_data(value) for key, value in data.get("shapes", {}).items()}
+ component_positions = {ComponentID(key): (int(value[0]), int(value[1])) for key, value in data.get("component_positions", {}).items()}
+ connections = {ConnectionID(key): GraphConnection.from_data(value) for key, value in data.get("connections", {}).items()}
+ return cls(shapes=shapes, component_positions=component_positions, connections=connections)
+
+ def to_data(self) -> dict[str, Any]:
+ return {
+ "shapes": {str(key): shape.to_data() for key, shape in self.shapes.items()},
+ "component_positions": {str(key): list(position) for key, position in self.component_positions.items()},
+ "connections": {str(key): connection.to_data() for key, connection in self.connections.items()},
+ }
+
+@dataclass
+class GraphDatabase:
+ format_version: int = 1
+ graphs: dict[ComponentID, Graph] = field(default_factory=dict)
+
+ @classmethod
+ def from_data(cls, data: Mapping[str, Any]) -> GraphDatabase:
+ graphs = {ComponentID(key): Graph.from_data(value) for key, value in data.get("graphs", {}).items()}
+ return cls(format_version=int(data.get("format_version", 1)), graphs=graphs)
+
+ def to_data(self) -> dict[str, Any]:
+ return {"format_version": self.format_version, "graphs": {str(key): graph.to_data() for key, graph in self.graphs.items()}}
+
class SimulationMethod(Enum):
DASSL = "dassl"
diff --git a/src/bedit_gui/services/document_files.py b/src/bedit_gui/services/document_files.py
index ded0ce1..8bdc676 100644
--- a/src/bedit_gui/services/document_files.py
+++ b/src/bedit_gui/services/document_files.py
@@ -6,7 +6,7 @@ from pathlib import Path
from bedit_core.models import Document
from bedit_core.serialization import load as load_document
from bedit_core.serialization import save as save_document
-from bedit_gui.models import IconDatabase, SimulationDatabase
+from bedit_gui.models import GraphDatabase, IconDatabase, SimulationDatabase
def load(path: str | Path) -> Document:
@@ -14,6 +14,8 @@ def load(path: str | Path) -> Document:
document = load_document(path)
if document.metadata is not None and isinstance(document.metadata.get("icon_database"), dict):
document.metadata["icon_database"] = IconDatabase.from_data(document.metadata["icon_database"])
+ if document.metadata is not None and isinstance(document.metadata.get("graph_database"), dict):
+ document.metadata["graph_database"] = GraphDatabase.from_data(document.metadata["graph_database"])
if document.metadata is not None and isinstance(document.metadata.get("simulation_database"), dict):
document.metadata["simulation_database"] = SimulationDatabase.from_data(document.metadata["simulation_database"])
return document
@@ -24,6 +26,8 @@ def save(document: Document, path: str | Path) -> None:
saved_document = deepcopy(document)
if saved_document.metadata is not None and isinstance(saved_document.metadata.get("icon_database"), IconDatabase):
saved_document.metadata["icon_database"] = saved_document.metadata["icon_database"].to_data()
+ if saved_document.metadata is not None and isinstance(saved_document.metadata.get("graph_database"), GraphDatabase):
+ saved_document.metadata["graph_database"] = saved_document.metadata["graph_database"].to_data()
if saved_document.metadata is not None and isinstance(saved_document.metadata.get("simulation_database"), SimulationDatabase):
saved_document.metadata["simulation_database"] = saved_document.metadata["simulation_database"].to_data()
save_document(saved_document, path)
diff --git a/src/bedit_gui/ui/forms/graph_editor_widget.ui b/src/bedit_gui/ui/forms/graph_editor_widget.ui
index 6ff968d..f1ecac3 100644
--- a/src/bedit_gui/ui/forms/graph_editor_widget.ui
+++ b/src/bedit_gui/ui/forms/graph_editor_widget.ui
@@ -40,7 +40,7 @@
false
-
+
@@ -51,21 +51,25 @@
QFrame::Shape::NoFrame
-
- QGraphicsView::DragMode::RubberBandDrag
-
QPainter::RenderHint::Antialiasing
+
+ QGraphicsView::DragMode::RubberBandDrag
+
-
+
+
+
+ :/icons/icons/zoom-original.png:/icons/icons/zoom-original.png
+
- Select
+ Zoom to Fit
- Select graph items
+ Zoom canvas to fit
@@ -93,6 +97,8 @@
-
+
+
+
diff --git a/src/bedit_gui/ui/forms/graph_editor_widget_ui.py b/src/bedit_gui/ui/forms/graph_editor_widget_ui.py
new file mode 100644
index 0000000..1450483
--- /dev/null
+++ b/src/bedit_gui/ui/forms/graph_editor_widget_ui.py
@@ -0,0 +1,89 @@
+# -*- coding: utf-8 -*-
+
+################################################################################
+## Form generated from reading UI file 'graph_editor_widget.ui'
+##
+## Created by: Qt User Interface Compiler version 6.11.1
+##
+## WARNING! All changes made in this file will be lost when recompiling UI file!
+################################################################################
+
+from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
+ QMetaObject, QObject, QPoint, QRect,
+ QSize, QTime, QUrl, Qt)
+from PySide6.QtGui import (QAction, QBrush, QColor, QConicalGradient,
+ QCursor, QFont, QFontDatabase, QGradient,
+ QIcon, QImage, QKeySequence, QLinearGradient,
+ QPainter, QPalette, QPixmap, QRadialGradient,
+ QTransform)
+from PySide6.QtWidgets import (QApplication, QFrame, QGraphicsView, QSizePolicy,
+ QToolBar, QVBoxLayout, QWidget)
+import resources_rc
+
+class Ui_graphEditorWidget(object):
+ def setupUi(self, graphEditorWidget):
+ if not graphEditorWidget.objectName():
+ graphEditorWidget.setObjectName(u"graphEditorWidget")
+ graphEditorWidget.resize(1079, 730)
+ self.actionZoomToFit = QAction(graphEditorWidget)
+ self.actionZoomToFit.setObjectName(u"actionZoomToFit")
+ icon = QIcon()
+ icon.addFile(u":/icons/icons/zoom-original.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
+ self.actionZoomToFit.setIcon(icon)
+ self.actionAddComponent = QAction(graphEditorWidget)
+ self.actionAddComponent.setObjectName(u"actionAddComponent")
+ self.actionAddConnection = QAction(graphEditorWidget)
+ self.actionAddConnection.setObjectName(u"actionAddConnection")
+ self.actionDelete = QAction(graphEditorWidget)
+ self.actionDelete.setObjectName(u"actionDelete")
+ self.verticalLayout = QVBoxLayout(graphEditorWidget)
+ self.verticalLayout.setSpacing(0)
+ self.verticalLayout.setObjectName(u"verticalLayout")
+ self.verticalLayout.setContentsMargins(0, 0, 0, 0)
+ self.graphToolBar = QToolBar(graphEditorWidget)
+ self.graphToolBar.setObjectName(u"graphToolBar")
+ self.graphToolBar.setMovable(False)
+ self.graphToolBar.setFloatable(False)
+
+ self.verticalLayout.addWidget(self.graphToolBar)
+
+ self.graphicsView = QGraphicsView(graphEditorWidget)
+ self.graphicsView.setObjectName(u"graphicsView")
+ self.graphicsView.setFrameShape(QFrame.Shape.NoFrame)
+ self.graphicsView.setRenderHints(QPainter.RenderHint.Antialiasing)
+ self.graphicsView.setDragMode(QGraphicsView.DragMode.RubberBandDrag)
+
+ self.verticalLayout.addWidget(self.graphicsView)
+
+
+ self.graphToolBar.addAction(self.actionZoomToFit)
+ self.graphToolBar.addAction(self.actionAddComponent)
+ self.graphToolBar.addAction(self.actionAddConnection)
+ self.graphToolBar.addAction(self.actionDelete)
+
+ self.retranslateUi(graphEditorWidget)
+
+ QMetaObject.connectSlotsByName(graphEditorWidget)
+ # setupUi
+
+ def retranslateUi(self, graphEditorWidget):
+ graphEditorWidget.setWindowTitle(QCoreApplication.translate("graphEditorWidget", u"Graph Editor", None))
+ self.actionZoomToFit.setText(QCoreApplication.translate("graphEditorWidget", u"Zoom to Fit", None))
+#if QT_CONFIG(tooltip)
+ self.actionZoomToFit.setToolTip(QCoreApplication.translate("graphEditorWidget", u"Zoom canvas to fit", None))
+#endif // QT_CONFIG(tooltip)
+ self.actionAddComponent.setText(QCoreApplication.translate("graphEditorWidget", u"Add Component", None))
+#if QT_CONFIG(tooltip)
+ self.actionAddComponent.setToolTip(QCoreApplication.translate("graphEditorWidget", u"Add a component", None))
+#endif // QT_CONFIG(tooltip)
+ self.actionAddConnection.setText(QCoreApplication.translate("graphEditorWidget", u"Add Connection", None))
+#if QT_CONFIG(tooltip)
+ self.actionAddConnection.setToolTip(QCoreApplication.translate("graphEditorWidget", u"Add a connection", None))
+#endif // QT_CONFIG(tooltip)
+ self.actionDelete.setText(QCoreApplication.translate("graphEditorWidget", u"Delete", None))
+#if QT_CONFIG(tooltip)
+ self.actionDelete.setToolTip(QCoreApplication.translate("graphEditorWidget", u"Delete selected graph items", None))
+#endif // QT_CONFIG(tooltip)
+ self.graphToolBar.setWindowTitle(QCoreApplication.translate("graphEditorWidget", u"Graph tools", None))
+ # retranslateUi
+
diff --git a/src/bedit_gui/utils/icon.py b/src/bedit_gui/utils/icon.py
index 0dfe220..9d7c780 100644
--- a/src/bedit_gui/utils/icon.py
+++ b/src/bedit_gui/utils/icon.py
@@ -1,13 +1,15 @@
from __future__ import annotations
from PySide6.QtCore import QRectF, QSize, Qt
-from PySide6.QtGui import QBrush, QColor, QFont, QIcon, QPainter, QPen, QPixmap
+from PySide6.QtGui import QBrush, QColor, QFont, QIcon, QPainter, QPen, QPixmap, QRegion
from bedit_core.models import Port, PortID, SignalDirection
from bedit_gui.models import Icon, Line, LineType, Rectangle, Text
PORT_SIZE = 16
DEFAULT_ICON_SIZE = QSize(48, 48)
+ICON_MARGIN = 4
+ICON_PREVIEW_OVERSAMPLE = 4
def get_bounding_box(icon: Icon) -> QRectF:
@@ -35,6 +37,12 @@ def get_bounding_box(icon: Icon) -> QRectF:
return QRectF(left, top, right - left, bottom - top)
+def get_pixmap_bounding_box(pixmap: QPixmap) -> QRectF:
+ """Return the bounds of the pixels actually painted in a transparent pixmap."""
+ bounds = QRegion(pixmap.mask()).boundingRect()
+ return QRectF(bounds) if not bounds.isEmpty() else QRectF(0, 0, pixmap.width(), pixmap.height())
+
+
def render_icon(icon: Icon, ports: dict[PortID, Port], size: QSize = DEFAULT_ICON_SIZE, render_ports: bool = False) -> QIcon:
pixmap = QPixmap(size)
pixmap.fill(Qt.GlobalColor.transparent)
@@ -42,9 +50,7 @@ def render_icon(icon: Icon, ports: dict[PortID, Port], size: QSize = DEFAULT_ICO
if not icon.shapes and not icon.port_positions:
return QIcon(pixmap)
- available_width = max(1, size.width() - 4)
- available_height = max(1, size.height() - 4)
- scale = min(available_width / max(1, bounds.width()), available_height / max(1, bounds.height()))
+ scale = _render_scale(bounds, size)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.translate(size.width() / 2, size.height() / 2)
@@ -82,6 +88,28 @@ def render_icon(icon: Icon, ports: dict[PortID, Port], size: QSize = DEFAULT_ICO
return QIcon(pixmap)
+def render_fitted_icon(icon: Icon, ports: dict[PortID, Port], size: QSize = DEFAULT_ICON_SIZE) -> QIcon:
+ """Render an icon preview with its painted content fitted to a uniform size."""
+ render_size = QSize(size.width() * ICON_PREVIEW_OVERSAMPLE, size.height() * ICON_PREVIEW_OVERSAMPLE)
+ rendered = render_icon(icon, ports, render_size).pixmap(render_size)
+ bounds = get_pixmap_bounding_box(rendered).toAlignedRect()
+ content = rendered.copy(bounds)
+ available = QSize(max(1, size.width() - ICON_MARGIN), max(1, size.height() - ICON_MARGIN))
+ fitted = content.scaled(available, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
+ pixmap = QPixmap(size)
+ pixmap.fill(Qt.GlobalColor.transparent)
+ painter = QPainter(pixmap)
+ painter.drawPixmap((size.width() - fitted.width()) // 2, (size.height() - fitted.height()) // 2, fitted)
+ painter.end()
+ return QIcon(pixmap)
+
+
+def _render_scale(bounds: QRectF, size: QSize) -> float:
+ available_width = max(1, size.width() - ICON_MARGIN)
+ available_height = max(1, size.height() - ICON_MARGIN)
+ return min(available_width / max(1, bounds.width()), available_height / max(1, bounds.height()))
+
+
def _line_pen(line_type: LineType, thickness: float, color: str) -> QPen:
styles = {LineType.SOLID: Qt.PenStyle.SolidLine, LineType.DASHED: Qt.PenStyle.DashLine, LineType.DOTTED: Qt.PenStyle.DotLine, LineType.DASH_DOT: Qt.PenStyle.DashDotLine}
if line_type is LineType.NONE:
diff --git a/src/bedit_gui/views/graph_editor_widget.py b/src/bedit_gui/views/graph_editor_widget.py
index 12e1b9a..181c2cf 100644
--- a/src/bedit_gui/views/graph_editor_widget.py
+++ b/src/bedit_gui/views/graph_editor_widget.py
@@ -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)
diff --git a/untitled.bedit.json b/untitled.bedit.json
index e42d532..472f1b1 100644
--- a/untitled.bedit.json
+++ b/untitled.bedit.json
@@ -438,8 +438,8 @@
-32,
-32
],
- "width": 64.0,
- "height": 64.0,
+ "width": 64,
+ "height": 64,
"color": "#000000ff",
"bold": true,
"italic": false,
@@ -589,6 +589,118 @@
}
}
},
+ "graph_database": {
+ "format_version": 1,
+ "graphs": {
+ "50e6ef97-f686-4400-bc01-e5a352e8cc22": {
+ "shapes": {},
+ "component_positions": {
+ "43b3aee6-0b38-429b-9c6d-d38fc097297f": [
+ 128,
+ -256
+ ],
+ "9ee2f42b-5ed1-446d-8790-c2a1df6b61d3": [
+ -128,
+ -128
+ ],
+ "5a8b2e8d-489f-467b-8282-7e344dfad576": [
+ 128,
+ -64
+ ],
+ "2804f2f1-6123-4a53-aff1-87a3a8202711": [
+ -320,
+ -128
+ ],
+ "9f392c28-6bef-4b10-9305-93e250747007": [
+ 128,
+ 160
+ ],
+ "77701f68-b14c-4b98-8929-c5fba0261962": [
+ 352,
+ 96
+ ],
+ "033b930e-bf79-403a-8b0b-a159f3c81ce9": [
+ 352,
+ 224
+ ]
+ },
+ "connections": {
+ "7e293c73-1e07-4d4c-b750-6f43870c71c4": {
+ "points": [
+ [
+ -320,
+ -128
+ ],
+ [
+ -128,
+ -128
+ ]
+ ]
+ },
+ "66414b62-ca76-4be6-8da4-72551f8ffc0e": {
+ "points": [
+ [
+ 128,
+ 160
+ ],
+ [
+ 352,
+ 96
+ ]
+ ]
+ },
+ "8aaa96b3-45ed-4674-8f7a-06b28178a5c8": {
+ "points": [
+ [
+ -128,
+ -128
+ ],
+ [
+ 128,
+ -256
+ ]
+ ]
+ },
+ "ff606583-d31f-4638-93f7-926163db2665": {
+ "points": [
+ [
+ -128,
+ -128
+ ],
+ [
+ 128,
+ -64
+ ]
+ ]
+ },
+ "1f52dc5a-1847-4982-aabc-2538cf99a86f": {
+ "points": [
+ [
+ 128,
+ 160
+ ],
+ [
+ 352,
+ 224
+ ]
+ ]
+ },
+ "5af42b61-2317-48a8-b32b-0e2dfd88368e": {
+ "points": [
+ [
+ -128,
+ -128
+ ],
+ [
+ 128,
+ 160
+ ]
+ ]
+ }
+ }
+ }
+ }
+ },
"simulation_database": {
"format_version": 1,
"active_simulation": "7778ca68-7a36-401b-b4aa-236a44c5e771",