From 8e7d2d3efd718b673d4171b03b68d3ff45bd9389 Mon Sep 17 00:00:00 2001 From: Joppe Blondel Date: Mon, 17 Aug 2026 11:29:07 +0200 Subject: [PATCH] Graph view shows graph --- .../controllers/document_tree_controller.py | 15 +- src/bedit_gui/documents/document.py | 10 +- src/bedit_gui/models.py | 46 ++++- src/bedit_gui/services/document_files.py | 6 +- src/bedit_gui/ui/forms/graph_editor_widget.ui | 22 ++- .../ui/forms/graph_editor_widget_ui.py | 89 +++++++++ src/bedit_gui/utils/icon.py | 36 +++- src/bedit_gui/views/graph_editor_widget.py | 169 +++++++++++++++++- untitled.bedit.json | 116 +++++++++++- 9 files changed, 482 insertions(+), 27 deletions(-) create mode 100644 src/bedit_gui/ui/forms/graph_editor_widget_ui.py 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",