Graph view shows graph
This commit is contained in:
@@ -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():
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
89
src/bedit_gui/ui/forms/graph_editor_widget_ui.py
Normal file
89
src/bedit_gui/ui/forms/graph_editor_widget_ui.py
Normal 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
|
||||||
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user