Graph view shows graph

This commit is contained in:
2026-08-17 11:29:07 +02:00
parent 0c93338e39
commit 8e7d2d3efd
9 changed files with 482 additions and 27 deletions

View File

@@ -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 Component, ComponentID, EquationImplementation, GraphImplementation, Port, PortID, Parameter, ParameterID
from bedit_core.models import Document as CoreDocument from bedit_core.models import Document as CoreDocument
from bedit_gui.documents import Document 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.interface_editor_dialog import InterfaceEditorDialog
from bedit_gui.views.dialogs.param_editor_dialog import ParamEditorDialog from bedit_gui.views.dialogs.param_editor_dialog import ParamEditorDialog
from bedit_gui.views.icon_editor_window import IconEditorWindow from bedit_gui.views.icon_editor_window import IconEditorWindow
from bedit_gui.views.main_window import MainWindow from bedit_gui.views.main_window import MainWindow
from bedit_gui.views.models.document_tree_model import DocumentTreeModel 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) ICON_SIZE = QSize(32, 32)
@@ -102,7 +102,7 @@ class DocumentTreeController(QObject):
self._collect_components(model.root) self._collect_components(model.root)
for component_id, component in self._components.items(): for component_id, component in self._components.items():
icon = self.document.component_icon(component_id) 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 # Optional presentation behavior. Later, you could instead remember
# expanded component IDs and restore only those nodes. # 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.set_component(None)
self.window.equation_editor.hide() self.window.equation_editor.hide()
if component is not None and isinstance(component.implementation, GraphImplementation): 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() self.window.graph_editor.show()
else: else:
self.window.graph_editor.set_component(None) self.window.graph_editor.set_component(None)
@@ -135,7 +137,10 @@ class DocumentTreeController(QObject):
component = self._components.get(component_id) component = self._components.get(component_id)
if component is None: if component is None:
return 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: def _collect_components(self, components: dict[ComponentID, Component]) -> None:
for component_id, component in components.items(): for component_id, component in components.items():

View File

@@ -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.rename_document_command import RenameDocumentCommand
from bedit_gui.commands.simulation_database_command import ChangeSimulationDatabaseCommand from bedit_gui.commands.simulation_database_command import ChangeSimulationDatabaseCommand
from bedit_gui.commands.component_command import AddEmptyEquationComponent, AddEmptyGraphComponent, DeleteComponent, PasteComponents 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 from bedit_gui.services import document_files
@@ -121,6 +121,14 @@ class Document(QObject):
def component_icon(self, component_id: ComponentID) -> Icon: def component_icon(self, component_id: ComponentID) -> Icon:
return self.stored_component_icon(component_id) or 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: def change_icon(self, component_id: ComponentID, icon: Icon) -> None:
self.undo_stack.push(ChangeIconCommand(self, component_id, icon)) self.undo_stack.push(ChangeIconCommand(self, component_id, icon))

View File

@@ -5,7 +5,7 @@ from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from typing import Any from typing import Any
from bedit_core.models import ComponentID, ID, PortID from bedit_core.models import ComponentID, ConnectionID, ID, PortID
class ShapeID(ID): class ShapeID(ID):
@@ -123,6 +123,50 @@ class IconDatabase:
def to_data(self) -> dict[str, Any]: 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()}} 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): class SimulationMethod(Enum):
DASSL = "dassl" DASSL = "dassl"

View File

@@ -6,7 +6,7 @@ from pathlib import Path
from bedit_core.models import Document from bedit_core.models import Document
from bedit_core.serialization import load as load_document from bedit_core.serialization import load as load_document
from bedit_core.serialization import save as save_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: def load(path: str | Path) -> Document:
@@ -14,6 +14,8 @@ def load(path: str | Path) -> Document:
document = load_document(path) document = load_document(path)
if document.metadata is not None and isinstance(document.metadata.get("icon_database"), dict): 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"]) 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): 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"]) document.metadata["simulation_database"] = SimulationDatabase.from_data(document.metadata["simulation_database"])
return document return document
@@ -24,6 +26,8 @@ def save(document: Document, path: str | Path) -> None:
saved_document = deepcopy(document) saved_document = deepcopy(document)
if saved_document.metadata is not None and isinstance(saved_document.metadata.get("icon_database"), IconDatabase): 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() 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): 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() saved_document.metadata["simulation_database"] = saved_document.metadata["simulation_database"].to_data()
save_document(saved_document, path) save_document(saved_document, path)

View File

@@ -40,7 +40,7 @@
<property name="floatable"> <property name="floatable">
<bool>false</bool> <bool>false</bool>
</property> </property>
<addaction name="actionSelect"/> <addaction name="actionZoomToFit"/>
<addaction name="actionAddComponent"/> <addaction name="actionAddComponent"/>
<addaction name="actionAddConnection"/> <addaction name="actionAddConnection"/>
<addaction name="actionDelete"/> <addaction name="actionDelete"/>
@@ -51,21 +51,25 @@
<property name="frameShape"> <property name="frameShape">
<enum>QFrame::Shape::NoFrame</enum> <enum>QFrame::Shape::NoFrame</enum>
</property> </property>
<property name="dragMode">
<enum>QGraphicsView::DragMode::RubberBandDrag</enum>
</property>
<property name="renderHints"> <property name="renderHints">
<set>QPainter::RenderHint::Antialiasing</set> <set>QPainter::RenderHint::Antialiasing</set>
</property> </property>
<property name="dragMode">
<enum>QGraphicsView::DragMode::RubberBandDrag</enum>
</property>
</widget> </widget>
</item> </item>
</layout> </layout>
<action name="actionSelect"> <action name="actionZoomToFit">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/zoom-original.png</normaloff>:/icons/icons/zoom-original.png</iconset>
</property>
<property name="text"> <property name="text">
<string>Select</string> <string>Zoom to Fit</string>
</property> </property>
<property name="toolTip"> <property name="toolTip">
<string>Select graph items</string> <string>Zoom canvas to fit</string>
</property> </property>
</action> </action>
<action name="actionAddComponent"> <action name="actionAddComponent">
@@ -93,6 +97,8 @@
</property> </property>
</action> </action>
</widget> </widget>
<resources/> <resources>
<include location="../../resources/resources.qrc"/>
</resources>
<connections/> <connections/>
</ui> </ui>

View File

@@ -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

View File

@@ -1,13 +1,15 @@
from __future__ import annotations from __future__ import annotations
from PySide6.QtCore import QRectF, QSize, Qt 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_core.models import Port, PortID, SignalDirection
from bedit_gui.models import Icon, Line, LineType, Rectangle, Text from bedit_gui.models import Icon, Line, LineType, Rectangle, Text
PORT_SIZE = 16 PORT_SIZE = 16
DEFAULT_ICON_SIZE = QSize(48, 48) DEFAULT_ICON_SIZE = QSize(48, 48)
ICON_MARGIN = 4
ICON_PREVIEW_OVERSAMPLE = 4
def get_bounding_box(icon: Icon) -> QRectF: 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) 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: def render_icon(icon: Icon, ports: dict[PortID, Port], size: QSize = DEFAULT_ICON_SIZE, render_ports: bool = False) -> QIcon:
pixmap = QPixmap(size) pixmap = QPixmap(size)
pixmap.fill(Qt.GlobalColor.transparent) 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: if not icon.shapes and not icon.port_positions:
return QIcon(pixmap) return QIcon(pixmap)
available_width = max(1, size.width() - 4) scale = _render_scale(bounds, size)
available_height = max(1, size.height() - 4)
scale = min(available_width / max(1, bounds.width()), available_height / max(1, bounds.height()))
painter = QPainter(pixmap) painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.translate(size.width() / 2, size.height() / 2) 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) 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: 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} 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: if line_type is LineType.NONE:

View File

@@ -1,17 +1,29 @@
from __future__ import annotations from __future__ import annotations
from PySide6.QtCore import QEvent, QObject, QRectF, Qt from math import hypot
from PySide6.QtGui import QColor, QPainter, QPen, QWheelEvent
from PySide6.QtWidgets import QGraphicsScene, QGraphicsView, QWidget
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.ui.generated.ui_graph_editor_widget import Ui_graphEditorWidget
from bedit_gui.utils.icon import get_pixmap_bounding_box, render_icon
GRID_SPACING = 32 GRID_SPACING = 32
SCENE_SIZE = 10000 SCENE_SIZE = 10000
MIN_ZOOM = 0.2 MIN_ZOOM = 0.2
MAX_ZOOM = 4.0 MAX_ZOOM = 4.0
ZOOM_STEP = 1.15 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): class GraphGraphicsScene(QGraphicsScene):
@@ -34,6 +46,62 @@ class GraphGraphicsScene(QGraphicsScene):
y += GRID_SPACING 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): class GraphEditorWidget(QWidget):
def __init__(self, parent: QWidget | None = None) -> None: def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent) super().__init__(parent)
@@ -46,16 +114,107 @@ class GraphEditorWidget(QWidget):
self.ui.graphicsView.setScene(self.scene) self.ui.graphicsView.setScene(self.scene)
self.ui.graphicsView.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse) self.ui.graphicsView.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
self.ui.graphicsView.viewport().installEventFilter(self) self.ui.graphicsView.viewport().installEventFilter(self)
self.ui.actionZoomToFit.triggered.connect(self.zoom_to_fit)
self.ui.graphicsView.centerOn(0, 0) 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): if component is not None and not isinstance(component.implementation, GraphImplementation):
raise TypeError("GraphEditorWidget only supports components with a graph implementation") raise TypeError("GraphEditorWidget only supports components with a graph implementation")
component_changed = component is not self._component
self._component = 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: def component(self) -> Component | None:
return self._component 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: def eventFilter(self, watched: QObject, event: QEvent) -> bool:
if watched is self.ui.graphicsView.viewport() and event.type() == QEvent.Type.Wheel: if watched is self.ui.graphicsView.viewport() and event.type() == QEvent.Type.Wheel:
assert isinstance(event, QWheelEvent) assert isinstance(event, QWheelEvent)

View File

@@ -438,8 +438,8 @@
-32, -32,
-32 -32
], ],
"width": 64.0, "width": 64,
"height": 64.0, "height": 64,
"color": "#000000ff", "color": "#000000ff",
"bold": true, "bold": true,
"italic": false, "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": { "simulation_database": {
"format_version": 1, "format_version": 1,
"active_simulation": "7778ca68-7a36-401b-b4aa-236a44c5e771", "active_simulation": "7778ca68-7a36-401b-b4aa-236a44c5e771",