Compare commits
3 Commits
fcee2ac8b6
...
4591e6b7b0
| Author | SHA1 | Date | |
|---|---|---|---|
| 4591e6b7b0 | |||
| cb9e03a6bf | |||
| 0bb2141673 |
@@ -83,6 +83,7 @@ class _CausalityEngine():
|
|||||||
def clear_causalities(self) -> None:
|
def clear_causalities(self) -> None:
|
||||||
for bond in self._network.bonds:
|
for bond in self._network.bonds:
|
||||||
bond.connection.causality = BondCausality.NONE
|
bond.connection.causality = BondCausality.NONE
|
||||||
|
bond.connection.undesired = False
|
||||||
|
|
||||||
def propagate_from_port(self, port: NetworkPort, causality: BondCausality) -> None:
|
def propagate_from_port(self, port: NetworkPort, causality: BondCausality) -> None:
|
||||||
attached_connections = self._network.bonds_for(port)
|
attached_connections = self._network.bonds_for(port)
|
||||||
@@ -106,7 +107,7 @@ class _CausalityEngine():
|
|||||||
def propagate_to_neighbor(self, component: Component, connection: NetworkBond) -> None:
|
def propagate_to_neighbor(self, component: Component, connection: NetworkBond) -> None:
|
||||||
# Get neighor
|
# Get neighor
|
||||||
is_source = (component == connection.source.component)
|
is_source = (component == connection.source.component)
|
||||||
neighbor = connection.target if is_source else connection.target
|
neighbor = connection.target if is_source else connection.source
|
||||||
# Direct the evaluation based on what type of port it is
|
# Direct the evaluation based on what type of port it is
|
||||||
if neighbor.port.causality_preference in [PortCausality.SINGLE_EFFORT_IN, PortCausality.SINGLE_FLOW_IN]:
|
if neighbor.port.causality_preference in [PortCausality.SINGLE_EFFORT_IN, PortCausality.SINGLE_FLOW_IN]:
|
||||||
self.evaluate_junction_constraints(neighbor)
|
self.evaluate_junction_constraints(neighbor)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import argparse
|
|||||||
|
|
||||||
from PySide6.QtWidgets import QApplication, QMessageBox
|
from PySide6.QtWidgets import QApplication, QMessageBox
|
||||||
|
|
||||||
from bedit_gui.controllers.clipboard_controller import ClipboardController, DocumentTreeClipboardHandler, TextClipboardHandler
|
from bedit_gui.controllers.clipboard_controller import ClipboardController, DocumentTreeClipboardHandler, GraphEditorClipboardHandler, TextClipboardHandler
|
||||||
from bedit_gui.controllers.document_controller import DocumentController
|
from bedit_gui.controllers.document_controller import DocumentController
|
||||||
from bedit_gui.controllers.log_controller import LogController
|
from bedit_gui.controllers.log_controller import LogController
|
||||||
from bedit_gui.controllers.settings_controller import SettingsController
|
from bedit_gui.controllers.settings_controller import SettingsController
|
||||||
@@ -59,7 +59,7 @@ def main() -> int:
|
|||||||
ViewMenuController(window)
|
ViewMenuController(window)
|
||||||
document_tree_controller = DocumentTreeController(document, window)
|
document_tree_controller = DocumentTreeController(document, window)
|
||||||
clipboard = ClipboardService(app)
|
clipboard = ClipboardService(app)
|
||||||
ClipboardController(window, clipboard, [TextClipboardHandler(clipboard), DocumentTreeClipboardHandler(document, window.ui.documentTree, document_tree_controller.model, clipboard)])
|
ClipboardController(window, clipboard, [TextClipboardHandler(clipboard), DocumentTreeClipboardHandler(document, window.ui.documentTree, document_tree_controller.model, clipboard), GraphEditorClipboardHandler(document, window.graph_editor, clipboard)])
|
||||||
|
|
||||||
window_state_controller = WindowStateController(app, window)
|
window_state_controller = WindowStateController(app, window)
|
||||||
window_state_controller.restore()
|
window_state_controller.restore()
|
||||||
|
|||||||
44
src/bedit_gui/commands/causality_command.py
Normal file
44
src/bedit_gui/commands/causality_command.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from PySide6.QtGui import QUndoCommand
|
||||||
|
|
||||||
|
from bedit_core.models import BondCausality, BondConnection, Component, ConnectionID, GraphImplementation
|
||||||
|
|
||||||
|
CausalityState = dict[ConnectionID, tuple[BondCausality, bool]]
|
||||||
|
|
||||||
|
|
||||||
|
def causality_state(component: Component) -> CausalityState:
|
||||||
|
state = {}
|
||||||
|
if not isinstance(component.implementation, GraphImplementation):
|
||||||
|
return state
|
||||||
|
for connection_id, connection in component.implementation.graph.connections.items():
|
||||||
|
if isinstance(connection, BondConnection):
|
||||||
|
state[connection_id] = (connection.causality, connection.undesired)
|
||||||
|
for child in component.implementation.graph.components.values():
|
||||||
|
state.update(causality_state(child))
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeCausalityCommand(QUndoCommand):
|
||||||
|
def __init__(self, document: object, component: Component, inferred_component: Component) -> None:
|
||||||
|
super().__init__("Infer causality")
|
||||||
|
self.document = document
|
||||||
|
self.component = component
|
||||||
|
self.old_state = causality_state(component)
|
||||||
|
self.new_state = causality_state(inferred_component)
|
||||||
|
|
||||||
|
def redo(self) -> None:
|
||||||
|
self._apply(self.component, self.new_state)
|
||||||
|
self.document.model_changed.emit(self.document.model)
|
||||||
|
|
||||||
|
def undo(self) -> None:
|
||||||
|
self._apply(self.component, self.old_state)
|
||||||
|
self.document.model_changed.emit(self.document.model)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _apply(cls, component: Component, state: CausalityState) -> None:
|
||||||
|
if not isinstance(component.implementation, GraphImplementation):
|
||||||
|
return
|
||||||
|
for connection_id, connection in component.implementation.graph.connections.items():
|
||||||
|
if isinstance(connection, BondConnection) and connection_id in state:
|
||||||
|
connection.causality, connection.undesired = state[connection_id]
|
||||||
|
for child in component.implementation.graph.components.values():
|
||||||
|
cls._apply(child, state)
|
||||||
@@ -108,15 +108,20 @@ class DeleteComponent(QUndoCommand):
|
|||||||
|
|
||||||
|
|
||||||
class PasteComponents(QUndoCommand):
|
class PasteComponents(QUndoCommand):
|
||||||
def __init__(self, document: object, target: dict[ComponentID, Component], components: dict[ComponentID, Component], icons: dict[ComponentID, Icon]) -> None:
|
def __init__(self, document: object, target: dict[ComponentID, Component], components: dict[ComponentID, Component], icons: dict[ComponentID, Icon], graph_id: ComponentID | None = None, positions: dict[ComponentID, tuple[int, int]] | None = None) -> None:
|
||||||
super().__init__("Paste components")
|
super().__init__("Paste components")
|
||||||
self.document = document
|
self.document = document
|
||||||
self.target = target
|
self.target = target
|
||||||
self.components = components
|
self.components = components
|
||||||
self.icons = icons
|
self.icons = icons
|
||||||
|
self.graph_id = graph_id
|
||||||
|
self.positions = positions or {}
|
||||||
|
|
||||||
def redo(self) -> None:
|
def redo(self) -> None:
|
||||||
self.target.update(self.components)
|
self.target.update(self.components)
|
||||||
|
if self.graph_id is not None:
|
||||||
|
for component_id, position in self.positions.items():
|
||||||
|
self.document._set_graph_component_position(self.graph_id, component_id, position)
|
||||||
self.document.model_changed.emit(self.document.model)
|
self.document.model_changed.emit(self.document.model)
|
||||||
for component_id, icon in self.icons.items():
|
for component_id, icon in self.icons.items():
|
||||||
self.document._set_component_icon(component_id, icon)
|
self.document._set_component_icon(component_id, icon)
|
||||||
@@ -124,6 +129,9 @@ class PasteComponents(QUndoCommand):
|
|||||||
def undo(self) -> None:
|
def undo(self) -> None:
|
||||||
for component_id in self.components:
|
for component_id in self.components:
|
||||||
self.target.pop(component_id, None)
|
self.target.pop(component_id, None)
|
||||||
|
if self.graph_id is not None:
|
||||||
|
for component_id in self.positions:
|
||||||
|
self.document._set_graph_component_position(self.graph_id, component_id, None)
|
||||||
self.document.model_changed.emit(self.document.model)
|
self.document.model_changed.emit(self.document.model)
|
||||||
for component_id in self.icons:
|
for component_id in self.icons:
|
||||||
self.document._set_component_icon(component_id, None)
|
self.document._set_component_icon(component_id, None)
|
||||||
|
|||||||
54
src/bedit_gui/commands/graph_connection_command.py
Normal file
54
src/bedit_gui/commands/graph_connection_command.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
from PySide6.QtGui import QUndoCommand
|
||||||
|
|
||||||
|
from bedit_core.models import Component, Connection, ConnectionID, GraphImplementation
|
||||||
|
|
||||||
|
|
||||||
|
class AddGraphConnectionCommand(QUndoCommand):
|
||||||
|
def __init__(self, document: object, graph_component: Component, connection: Connection) -> None:
|
||||||
|
super().__init__("Add connection")
|
||||||
|
if not isinstance(graph_component.implementation, GraphImplementation):
|
||||||
|
raise TypeError("component must have a graph implementation")
|
||||||
|
self.document = document
|
||||||
|
self.graph = graph_component.implementation.graph
|
||||||
|
self.connection_id = ConnectionID()
|
||||||
|
self.connection = connection
|
||||||
|
|
||||||
|
def redo(self) -> None:
|
||||||
|
self.graph.connections[self.connection_id] = self.connection
|
||||||
|
self.document.model_changed.emit(self.document.model)
|
||||||
|
|
||||||
|
def undo(self) -> None:
|
||||||
|
self.graph.connections.pop(self.connection_id, None)
|
||||||
|
self.document.model_changed.emit(self.document.model)
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteGraphConnectionCommand(QUndoCommand):
|
||||||
|
def __init__(self, document: object, graph_component: Component, connection_id: ConnectionID) -> None:
|
||||||
|
super().__init__("Delete connection")
|
||||||
|
if not isinstance(graph_component.implementation, GraphImplementation):
|
||||||
|
raise TypeError("component must have a graph implementation")
|
||||||
|
self.document = document
|
||||||
|
self.graph_id = document.component_id(graph_component)
|
||||||
|
self.connections = graph_component.implementation.graph.connections
|
||||||
|
self.connection_id = connection_id
|
||||||
|
self.connection = self.connections[connection_id]
|
||||||
|
self.index = list(self.connections).index(connection_id)
|
||||||
|
database = document._graph_database(False)
|
||||||
|
graph = database.graphs.get(self.graph_id) if database is not None else None
|
||||||
|
self.visual_connection = deepcopy(graph.connections.get(connection_id)) if graph is not None else None
|
||||||
|
|
||||||
|
def redo(self) -> None:
|
||||||
|
self.connections.pop(self.connection_id, None)
|
||||||
|
self.document._set_graph_connection_points(self.graph_id, self.connection_id, None)
|
||||||
|
self.document.model_changed.emit(self.document.model)
|
||||||
|
|
||||||
|
def undo(self) -> None:
|
||||||
|
items = list(self.connections.items())
|
||||||
|
items.insert(self.index, (self.connection_id, self.connection))
|
||||||
|
self.connections.clear()
|
||||||
|
self.connections.update(items)
|
||||||
|
if self.visual_connection is not None:
|
||||||
|
self.document._set_graph_connection_points(self.graph_id, self.connection_id, self.visual_connection.points)
|
||||||
|
self.document.model_changed.emit(self.document.model)
|
||||||
24
src/bedit_gui/commands/graph_connection_points_command.py
Normal file
24
src/bedit_gui/commands/graph_connection_points_command.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
from PySide6.QtGui import QUndoCommand
|
||||||
|
|
||||||
|
from bedit_core.models import ComponentID, ConnectionID
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeGraphConnectionPointsCommand(QUndoCommand):
|
||||||
|
def __init__(self, document: object, graph_id: ComponentID, connection_id: ConnectionID, points: list[tuple[int, int]], text: str) -> None:
|
||||||
|
super().__init__(text)
|
||||||
|
self.document = document
|
||||||
|
self.graph_id = graph_id
|
||||||
|
self.connection_id = connection_id
|
||||||
|
database = document._graph_database(False)
|
||||||
|
graph = database.graphs.get(graph_id) if database is not None else None
|
||||||
|
connection = graph.connections.get(connection_id) if graph is not None else None
|
||||||
|
self.old_points = deepcopy(connection.points) if connection is not None else None
|
||||||
|
self.new_points = deepcopy(points)
|
||||||
|
|
||||||
|
def redo(self) -> None:
|
||||||
|
self.document._set_graph_connection_points(self.graph_id, self.connection_id, self.new_points)
|
||||||
|
|
||||||
|
def undo(self) -> None:
|
||||||
|
self.document._set_graph_connection_points(self.graph_id, self.connection_id, self.old_points)
|
||||||
@@ -8,6 +8,7 @@ from bedit_core.models import Document as CoreDocument
|
|||||||
from bedit_gui.documents import Document
|
from bedit_gui.documents import Document
|
||||||
from bedit_gui.services.clipboard import ClipboardService
|
from bedit_gui.services.clipboard import ClipboardService
|
||||||
from bedit_gui.services.component_clipboard import export_components, import_components
|
from bedit_gui.services.component_clipboard import export_components, import_components
|
||||||
|
from bedit_gui.views.graph_editor_widget import GraphEditorWidget
|
||||||
from bedit_gui.views.models.document_tree_model import DocumentTreeModel
|
from bedit_gui.views.models.document_tree_model import DocumentTreeModel
|
||||||
|
|
||||||
|
|
||||||
@@ -28,6 +29,9 @@ class ClipboardHandler(QObject):
|
|||||||
def can_paste(self) -> bool:
|
def can_paste(self) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def can_delete(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
def copy(self) -> None:
|
def copy(self) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -37,6 +41,9 @@ class ClipboardHandler(QObject):
|
|||||||
def paste(self) -> None:
|
def paste(self) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def delete(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class TextClipboardHandler(ClipboardHandler):
|
class TextClipboardHandler(ClipboardHandler):
|
||||||
def __init__(self, clipboard: ClipboardService, parent: QObject | None = None) -> None:
|
def __init__(self, clipboard: ClipboardService, parent: QObject | None = None) -> None:
|
||||||
@@ -106,6 +113,9 @@ class DocumentTreeClipboardHandler(ClipboardHandler):
|
|||||||
def can_paste(self) -> bool:
|
def can_paste(self) -> bool:
|
||||||
return self._target() is not None and self.clipboard.has_format(ClipboardService.COMPONENTS_MIME)
|
return self._target() is not None and self.clipboard.has_format(ClipboardService.COMPONENTS_MIME)
|
||||||
|
|
||||||
|
def can_delete(self) -> bool:
|
||||||
|
return bool(self._selected_components())
|
||||||
|
|
||||||
def copy(self) -> None:
|
def copy(self) -> None:
|
||||||
components = self._selected_components()
|
components = self._selected_components()
|
||||||
if components:
|
if components:
|
||||||
@@ -129,6 +139,9 @@ class DocumentTreeClipboardHandler(ClipboardHandler):
|
|||||||
return
|
return
|
||||||
self.document.paste_components(target, components, icons)
|
self.document.paste_components(target, components, icons)
|
||||||
|
|
||||||
|
def delete(self) -> None:
|
||||||
|
self.document.delete_components(self._selected_components())
|
||||||
|
|
||||||
def _write_components(self, components: list[Component]) -> None:
|
def _write_components(self, components: list[Component]) -> None:
|
||||||
payload = export_components(self.document, components)
|
payload = export_components(self.document, components)
|
||||||
self.clipboard.set_json(ClipboardService.COMPONENTS_MIME, payload, "\n".join(component.name for component in components))
|
self.clipboard.set_json(ClipboardService.COMPONENTS_MIME, payload, "\n".join(component.name for component in components))
|
||||||
@@ -162,6 +175,79 @@ class DocumentTreeClipboardHandler(ClipboardHandler):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class GraphEditorClipboardHandler(ClipboardHandler):
|
||||||
|
def __init__(self, document: Document, editor: GraphEditorWidget, clipboard: ClipboardService, parent: QObject | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.document = document
|
||||||
|
self.editor = editor
|
||||||
|
self.clipboard = clipboard
|
||||||
|
editor.scene.selectionChanged.connect(self.availability_changed)
|
||||||
|
|
||||||
|
def owns_focus(self, widget: QWidget) -> bool:
|
||||||
|
return widget is self.editor or self.editor.isAncestorOf(widget)
|
||||||
|
|
||||||
|
def can_copy(self) -> bool:
|
||||||
|
return bool(self._selected_components())
|
||||||
|
|
||||||
|
def can_cut(self) -> bool:
|
||||||
|
return self.can_copy()
|
||||||
|
|
||||||
|
def can_paste(self) -> bool:
|
||||||
|
return self._target() is not None and self.clipboard.has_format(ClipboardService.COMPONENTS_MIME)
|
||||||
|
|
||||||
|
def can_delete(self) -> bool:
|
||||||
|
return bool(self._selected_components() or self.editor.selected_connection_ids())
|
||||||
|
|
||||||
|
def copy(self) -> None:
|
||||||
|
components = self._selected_components()
|
||||||
|
if components:
|
||||||
|
self._write_components(components)
|
||||||
|
|
||||||
|
def cut(self) -> None:
|
||||||
|
components = self._selected_components()
|
||||||
|
if components:
|
||||||
|
self._write_components(components)
|
||||||
|
self.document.delete_components(components)
|
||||||
|
|
||||||
|
def paste(self) -> None:
|
||||||
|
graph_component = self.editor.component()
|
||||||
|
payload = self.clipboard.get_json(ClipboardService.COMPONENTS_MIME)
|
||||||
|
if graph_component is None or not isinstance(graph_component.implementation, GraphImplementation) or payload is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
components, icons = import_components(payload)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
QMessageBox.critical(self.editor, "Could not paste components", str(exc))
|
||||||
|
return
|
||||||
|
x, y = self.editor.paste_position()
|
||||||
|
spacing = self.editor.snap_to_grid_size * 4
|
||||||
|
positions = {component_id: (x + index * spacing, y + index * spacing) for index, component_id in enumerate(components)}
|
||||||
|
self.document.paste_graph_components(graph_component, components, icons, positions)
|
||||||
|
|
||||||
|
def delete(self) -> None:
|
||||||
|
components = self._selected_components()
|
||||||
|
connection_ids = self.editor.selected_connection_ids()
|
||||||
|
if components:
|
||||||
|
self.document.delete_components(components)
|
||||||
|
graph_component = self.editor.component()
|
||||||
|
if graph_component is not None and connection_ids:
|
||||||
|
self.document.delete_graph_connections(graph_component, connection_ids)
|
||||||
|
|
||||||
|
def _write_components(self, components: list[Component]) -> None:
|
||||||
|
payload = export_components(self.document, components)
|
||||||
|
self.clipboard.set_json(ClipboardService.COMPONENTS_MIME, payload, "\n".join(component.name for component in components))
|
||||||
|
|
||||||
|
def _selected_components(self) -> list[Component]:
|
||||||
|
target = self._target()
|
||||||
|
if target is None:
|
||||||
|
return []
|
||||||
|
return [target[component_id] for component_id in self.editor.selected_component_ids() if component_id in target]
|
||||||
|
|
||||||
|
def _target(self) -> dict | None:
|
||||||
|
component = self.editor.component()
|
||||||
|
return component.implementation.graph.components if component is not None and isinstance(component.implementation, GraphImplementation) else None
|
||||||
|
|
||||||
|
|
||||||
class ClipboardController(QObject):
|
class ClipboardController(QObject):
|
||||||
def __init__(self, window: QMainWindow, clipboard: ClipboardService, handlers: list[ClipboardHandler]) -> None:
|
def __init__(self, window: QMainWindow, clipboard: ClipboardService, handlers: list[ClipboardHandler]) -> None:
|
||||||
super().__init__(window)
|
super().__init__(window)
|
||||||
@@ -171,6 +257,7 @@ class ClipboardController(QObject):
|
|||||||
window.ui.actionCopy.triggered.connect(self.copy)
|
window.ui.actionCopy.triggered.connect(self.copy)
|
||||||
window.ui.actionCut.triggered.connect(self.cut)
|
window.ui.actionCut.triggered.connect(self.cut)
|
||||||
window.ui.actionPaste.triggered.connect(self.paste)
|
window.ui.actionPaste.triggered.connect(self.paste)
|
||||||
|
window.ui.actionDelete.triggered.connect(self.delete)
|
||||||
application = QApplication.instance()
|
application = QApplication.instance()
|
||||||
application.focusChanged.connect(self.update_actions)
|
application.focusChanged.connect(self.update_actions)
|
||||||
application.installEventFilter(self)
|
application.installEventFilter(self)
|
||||||
@@ -209,8 +296,15 @@ class ClipboardController(QObject):
|
|||||||
handler.paste()
|
handler.paste()
|
||||||
self.update_actions()
|
self.update_actions()
|
||||||
|
|
||||||
|
def delete(self) -> None:
|
||||||
|
handler = self.active_handler()
|
||||||
|
if handler is not None and handler.can_delete():
|
||||||
|
handler.delete()
|
||||||
|
self.update_actions()
|
||||||
|
|
||||||
def update_actions(self, *_args: object) -> None:
|
def update_actions(self, *_args: object) -> None:
|
||||||
handler = self.active_handler()
|
handler = self.active_handler()
|
||||||
self.window.ui.actionCopy.setEnabled(handler is not None and handler.can_copy())
|
self.window.ui.actionCopy.setEnabled(handler is not None and handler.can_copy())
|
||||||
self.window.ui.actionCut.setEnabled(handler is not None and handler.can_cut())
|
self.window.ui.actionCut.setEnabled(handler is not None and handler.can_cut())
|
||||||
self.window.ui.actionPaste.setEnabled(handler is not None and handler.can_paste())
|
self.window.ui.actionPaste.setEnabled(handler is not None and handler.can_paste())
|
||||||
|
self.window.ui.actionDelete.setEnabled(handler is not None and handler.can_delete())
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ from collections.abc import Callable
|
|||||||
from functools import partial
|
from functools import partial
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
|
||||||
from PySide6.QtCore import QEvent, QObject, QPoint, QSize, Qt
|
from PySide6.QtCore import QEvent, QItemSelectionModel, QObject, QPoint, QSize, Qt
|
||||||
from PySide6.QtGui import QMouseEvent
|
from PySide6.QtGui import QMouseEvent
|
||||||
from PySide6.QtWidgets import QAbstractItemView, QDialog, QHeaderView, QMenu
|
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, ConnectionID, 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 Graph, Icon
|
from bedit_gui.models import Graph, Icon
|
||||||
@@ -63,11 +63,16 @@ class DocumentTreeController(QObject):
|
|||||||
document.model_changed.connect(self._on_document_changed)
|
document.model_changed.connect(self._on_document_changed)
|
||||||
document.icon_changed.connect(self._on_icon_changed)
|
document.icon_changed.connect(self._on_icon_changed)
|
||||||
document.graph_component_position_changed.connect(self._on_graph_component_position_changed)
|
document.graph_component_position_changed.connect(self._on_graph_component_position_changed)
|
||||||
|
document.graph_connection_points_changed.connect(self._on_graph_connection_points_changed)
|
||||||
document.equation_text_changed.connect(self._on_equation_text_changed)
|
document.equation_text_changed.connect(self._on_equation_text_changed)
|
||||||
self.model.rename_document_requested.connect(self.document.rename)
|
self.model.rename_document_requested.connect(self.document.rename)
|
||||||
self.model.rename_component_requested.connect(self.document.rename_component)
|
self.model.rename_component_requested.connect(self.document.rename_component)
|
||||||
window.ui.actionDelete.triggered.connect(self.delete_selected_component)
|
|
||||||
window.graph_editor.component_move_requested.connect(self.document.move_graph_component)
|
window.graph_editor.component_move_requested.connect(self.document.move_graph_component)
|
||||||
|
window.graph_editor.component_context_menu_requested.connect(self._show_graph_component_context_menu)
|
||||||
|
window.graph_editor.component_open_requested.connect(self._open_graph_component)
|
||||||
|
window.graph_editor.connection_points_change_requested.connect(self.document.change_graph_connection_points)
|
||||||
|
window.graph_editor.connection_add_requested.connect(self.document.add_graph_connection)
|
||||||
|
window.graph_editor.connections_delete_requested.connect(self.document.delete_graph_connections)
|
||||||
|
|
||||||
# Add deselection with esc to this widget
|
# Add deselection with esc to this widget
|
||||||
window.ui.actionEscape.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
|
window.ui.actionEscape.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
|
||||||
@@ -98,13 +103,14 @@ class DocumentTreeController(QObject):
|
|||||||
|
|
||||||
def _on_document_changed(self, model: CoreDocument) -> None:
|
def _on_document_changed(self, model: CoreDocument) -> None:
|
||||||
"""Rebuild the tree whenever New/Open replaces the core document."""
|
"""Rebuild the tree whenever New/Open replaces the core document."""
|
||||||
self._show_component(None)
|
displayed_component = self.window.graph_editor.component() or self.window.equation_editor.component()
|
||||||
self.model.set_document(model)
|
self.model.set_document(model)
|
||||||
self._components = {}
|
self._components = {}
|
||||||
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_fitted_icon(icon, component.interface.ports, ICON_SIZE))
|
self.model.set_component_icon(component_id, render_fitted_icon(icon, component.interface.ports, ICON_SIZE))
|
||||||
|
self._show_component(displayed_component if any(component is displayed_component for component in self._components.values()) else None)
|
||||||
|
|
||||||
# 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.
|
||||||
@@ -149,6 +155,11 @@ class DocumentTreeController(QObject):
|
|||||||
if graph_component is not None and self.document.component_id(graph_component) == graph_id:
|
if graph_component is not None and self.document.component_id(graph_component) == graph_id:
|
||||||
self.window.graph_editor.set_component_position(component_id, position)
|
self.window.graph_editor.set_component_position(component_id, position)
|
||||||
|
|
||||||
|
def _on_graph_connection_points_changed(self, graph_id: ComponentID, connection_id: ConnectionID, points: list[tuple[int, int]] | None) -> None:
|
||||||
|
graph_component = self.window.graph_editor.component()
|
||||||
|
if graph_component is not None and self.document.component_id(graph_component) == graph_id:
|
||||||
|
self.window.graph_editor.set_connection_points(connection_id, points)
|
||||||
|
|
||||||
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():
|
||||||
self._components[component_id] = component
|
self._components[component_id] = component
|
||||||
@@ -161,27 +172,42 @@ class DocumentTreeController(QObject):
|
|||||||
if not isinstance(component, Component):
|
if not isinstance(component, Component):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
self._show_component_context_menu(component, self.window.ui.documentTree.viewport().mapToGlobal(position))
|
||||||
|
|
||||||
|
def _show_graph_component_context_menu(self, component_id: ComponentID, global_position: QPoint) -> None:
|
||||||
|
component = self._components.get(component_id)
|
||||||
|
if component is not None:
|
||||||
|
self._show_component_context_menu(component, global_position)
|
||||||
|
|
||||||
|
def _open_graph_component(self, component_id: ComponentID) -> None:
|
||||||
|
index = self.model.component_index(component_id)
|
||||||
|
if index.isValid():
|
||||||
|
self.window.ui.documentTree.selectionModel().setCurrentIndex(index, QItemSelectionModel.SelectionFlag.ClearAndSelect | QItemSelectionModel.SelectionFlag.Rows)
|
||||||
|
self.window.ui.documentTree.scrollTo(index)
|
||||||
|
|
||||||
|
def _show_component_context_menu(self, component: Component, global_position: QPoint) -> None:
|
||||||
menu = QMenu(self.window.ui.documentTree)
|
menu = QMenu(self.window.ui.documentTree)
|
||||||
edit_interface = menu.addAction("Edit Interface")
|
edit_interface = menu.addAction("Edit Interface")
|
||||||
edit_params = menu.addAction("Edit Parameters")
|
edit_params = menu.addAction("Edit Parameters")
|
||||||
edit_icon = menu.addAction("Edit Icon")
|
edit_icon = menu.addAction("Edit Icon")
|
||||||
menu.addSeparator()
|
menu.addSeparator()
|
||||||
|
add_graph_component = None
|
||||||
|
add_equation_component = None
|
||||||
|
if isinstance(component.implementation, GraphImplementation):
|
||||||
add_graph_component = menu.addAction("Add Graph Component")
|
add_graph_component = menu.addAction("Add Graph Component")
|
||||||
add_equation_component = menu.addAction("Add Equation Component")
|
add_equation_component = menu.addAction("Add Equation Component")
|
||||||
menu.addSeparator()
|
menu.addSeparator()
|
||||||
delete_component = menu.addAction("Delete Component")
|
delete_component = menu.addAction("Delete Component")
|
||||||
selected = menu.exec(
|
selected = menu.exec(global_position)
|
||||||
self.window.ui.documentTree.viewport().mapToGlobal(position)
|
|
||||||
)
|
|
||||||
if selected is edit_interface:
|
if selected is edit_interface:
|
||||||
self._edit_interface(component)
|
self._edit_interface(component)
|
||||||
elif selected is edit_params:
|
elif selected is edit_params:
|
||||||
self._edit_params(component)
|
self._edit_params(component)
|
||||||
elif selected is edit_icon:
|
elif selected is edit_icon:
|
||||||
self._edit_icon(component)
|
self._edit_icon(component)
|
||||||
elif selected is add_graph_component:
|
elif add_graph_component is not None and selected is add_graph_component:
|
||||||
self._add_graph_component(component)
|
self._add_graph_component(component)
|
||||||
elif selected is add_equation_component:
|
elif add_equation_component is not None and selected is add_equation_component:
|
||||||
self._add_equation_component(component)
|
self._add_equation_component(component)
|
||||||
elif selected is delete_component:
|
elif selected is delete_component:
|
||||||
self._delete_component(component)
|
self._delete_component(component)
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ class SimulationController(QObject):
|
|||||||
self.window.statusBar().showMessage(f"Compiling {component_path}…")
|
self.window.statusBar().showMessage(f"Compiling {component_path}…")
|
||||||
logger.info("Compiling model: %s", component_path)
|
logger.info("Compiling model: %s", component_path)
|
||||||
try:
|
try:
|
||||||
|
self.document.infer_causality(component)
|
||||||
build = self.compiler(component, build_directory)
|
build = self.compiler(component, build_directory)
|
||||||
except (OSError, RuntimeError, ValueError) as exc:
|
except (OSError, RuntimeError, ValueError) as exc:
|
||||||
logger.exception("Could not compile model %s", component_path)
|
logger.exception("Could not compile model %s", component_path)
|
||||||
|
|||||||
@@ -6,18 +6,22 @@ from pathlib import Path
|
|||||||
from PySide6.QtCore import QObject, Signal
|
from PySide6.QtCore import QObject, Signal
|
||||||
from PySide6.QtGui import QUndoStack
|
from PySide6.QtGui import QUndoStack
|
||||||
|
|
||||||
from bedit_core.models import ID, Component, ComponentID, GraphImplementation, Port, PortID, Parameter, ParameterID
|
from bedit_core.models import ID, Component, ComponentID, Connection, ConnectionID, GraphImplementation, Port, PortID, Parameter, ParameterID
|
||||||
from bedit_core.models import Document as CoreDocument
|
from bedit_core.models import Document as CoreDocument
|
||||||
|
from bedit_core.bondgraph import causality_inference
|
||||||
|
from bedit_gui.commands.causality_command import ChangeCausalityCommand, causality_state
|
||||||
from bedit_gui.commands.change_icon_command import ChangeIconCommand
|
from bedit_gui.commands.change_icon_command import ChangeIconCommand
|
||||||
from bedit_gui.commands.equation_text_command import ChangeEquationTextCommand
|
from bedit_gui.commands.equation_text_command import ChangeEquationTextCommand
|
||||||
from bedit_gui.commands.graph_position_command import MoveGraphComponentCommand
|
from bedit_gui.commands.graph_position_command import MoveGraphComponentCommand
|
||||||
|
from bedit_gui.commands.graph_connection_points_command import ChangeGraphConnectionPointsCommand
|
||||||
|
from bedit_gui.commands.graph_connection_command import AddGraphConnectionCommand, DeleteGraphConnectionCommand
|
||||||
from bedit_gui.commands.port_commands import AddPortCommand, ChangePortCommand, RemovePortCommand
|
from bedit_gui.commands.port_commands import AddPortCommand, ChangePortCommand, RemovePortCommand
|
||||||
from bedit_gui.commands.param_commands import AddParamCommand, ChangeParamCommand, RemoveParamCommand
|
from bedit_gui.commands.param_commands import AddParamCommand, ChangeParamCommand, RemoveParamCommand
|
||||||
from bedit_gui.commands.rename_component_command import RenameComponentCommand
|
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 Graph, GraphDatabase, Icon, IconDatabase, Simulation, SimulationDatabase
|
from bedit_gui.models import Graph, GraphConnection, GraphDatabase, Icon, IconDatabase, Simulation, SimulationDatabase
|
||||||
from bedit_gui.services import document_files
|
from bedit_gui.services import document_files
|
||||||
|
|
||||||
|
|
||||||
@@ -31,6 +35,7 @@ class Document(QObject):
|
|||||||
equation_text_changed = Signal(object, str)
|
equation_text_changed = Signal(object, str)
|
||||||
simulation_database_changed = Signal(object)
|
simulation_database_changed = Signal(object)
|
||||||
graph_component_position_changed = Signal(object, object, object)
|
graph_component_position_changed = Signal(object, object, object)
|
||||||
|
graph_connection_points_changed = Signal(object, object, object)
|
||||||
|
|
||||||
def __init__(self, parent: QObject | None = None) -> None:
|
def __init__(self, parent: QObject | None = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -100,6 +105,12 @@ class Document(QObject):
|
|||||||
def rename_component(self, component: Component, name: str) -> None:
|
def rename_component(self, component: Component, name: str) -> None:
|
||||||
self.undo_stack.push(RenameComponentCommand(self, component, name))
|
self.undo_stack.push(RenameComponentCommand(self, component, name))
|
||||||
|
|
||||||
|
def infer_causality(self, component: Component) -> None:
|
||||||
|
inferred_component = deepcopy(component)
|
||||||
|
causality_inference(inferred_component)
|
||||||
|
if causality_state(component) != causality_state(inferred_component):
|
||||||
|
self.undo_stack.push(ChangeCausalityCommand(self, component, inferred_component))
|
||||||
|
|
||||||
def component_id(self, component: Component) -> ComponentID:
|
def component_id(self, component: Component) -> ComponentID:
|
||||||
def find(components: dict[ComponentID, Component]) -> ComponentID | None:
|
def find(components: dict[ComponentID, Component]) -> ComponentID | None:
|
||||||
for component_id, candidate in components.items():
|
for component_id, candidate in components.items():
|
||||||
@@ -163,6 +174,36 @@ class Document(QObject):
|
|||||||
metadata["graph_database"] = database
|
metadata["graph_database"] = database
|
||||||
return database
|
return database
|
||||||
|
|
||||||
|
def change_graph_connection_points(self, graph_component: Component, connection_id: ConnectionID, points: list[tuple[int, int]], text: str) -> None:
|
||||||
|
graph_id = self.component_id(graph_component)
|
||||||
|
self.undo_stack.push(ChangeGraphConnectionPointsCommand(self, graph_id, connection_id, points, text))
|
||||||
|
|
||||||
|
def add_graph_connection(self, graph_component: Component, connection: Connection) -> None:
|
||||||
|
self.undo_stack.push(AddGraphConnectionCommand(self, graph_component, connection))
|
||||||
|
|
||||||
|
def delete_graph_connections(self, graph_component: Component, connection_ids: list[ConnectionID]) -> None:
|
||||||
|
if not isinstance(graph_component.implementation, GraphImplementation):
|
||||||
|
return
|
||||||
|
connection_ids = [connection_id for connection_id in connection_ids if connection_id in graph_component.implementation.graph.connections]
|
||||||
|
if not connection_ids:
|
||||||
|
return
|
||||||
|
self.undo_stack.beginMacro("Delete connections")
|
||||||
|
for connection_id in connection_ids:
|
||||||
|
self.undo_stack.push(DeleteGraphConnectionCommand(self, graph_component, connection_id))
|
||||||
|
self.undo_stack.endMacro()
|
||||||
|
|
||||||
|
def _set_graph_connection_points(self, graph_id: ComponentID, connection_id: ConnectionID, points: list[tuple[int, int]] | None) -> None:
|
||||||
|
if points is None:
|
||||||
|
database = self._graph_database(False)
|
||||||
|
graph = database.graphs.get(graph_id) if database is not None else None
|
||||||
|
if graph is not None:
|
||||||
|
graph.connections.pop(connection_id, None)
|
||||||
|
else:
|
||||||
|
database = self._graph_database(True)
|
||||||
|
graph = database.graphs.setdefault(graph_id, Graph())
|
||||||
|
graph.connections[connection_id] = GraphConnection(points=list(points))
|
||||||
|
self.graph_connection_points_changed.emit(graph_id, connection_id, points)
|
||||||
|
|
||||||
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))
|
||||||
|
|
||||||
@@ -322,6 +363,16 @@ class Document(QObject):
|
|||||||
names.add(component.name)
|
names.add(component.name)
|
||||||
self.undo_stack.push(PasteComponents(self, target, components, icons))
|
self.undo_stack.push(PasteComponents(self, target, components, icons))
|
||||||
|
|
||||||
|
def paste_graph_components(self, graph_component: Component, components: dict[ComponentID, Component], icons: dict[ComponentID, Icon], positions: dict[ComponentID, tuple[int, int]]) -> None:
|
||||||
|
if not isinstance(graph_component.implementation, GraphImplementation) or not components:
|
||||||
|
return
|
||||||
|
target = graph_component.implementation.graph.components
|
||||||
|
names = {component.name for component in target.values()}
|
||||||
|
for component in components.values():
|
||||||
|
component.name = self._unique_name(names, component.name)
|
||||||
|
names.add(component.name)
|
||||||
|
self.undo_stack.push(PasteComponents(self, target, components, icons, self.component_id(graph_component), positions))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _unique_component_name(components: dict[ComponentID, Component], name: str) -> str:
|
def _unique_component_name(components: dict[ComponentID, Component], name: str) -> str:
|
||||||
return Document._unique_name({component.name for component in components.values()}, name)
|
return Document._unique_name({component.name for component in components.values()}, name)
|
||||||
|
|||||||
@@ -41,9 +41,8 @@
|
|||||||
<bool>false</bool>
|
<bool>false</bool>
|
||||||
</property>
|
</property>
|
||||||
<addaction name="actionZoomToFit"/>
|
<addaction name="actionZoomToFit"/>
|
||||||
<addaction name="actionAddComponent"/>
|
<addaction name="actionMouseMode"/>
|
||||||
<addaction name="actionAddConnection"/>
|
<addaction name="actionConnectionMode"/>
|
||||||
<addaction name="actionDelete"/>
|
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
@@ -72,28 +71,34 @@
|
|||||||
<string>Zoom canvas to fit</string>
|
<string>Zoom canvas to fit</string>
|
||||||
</property>
|
</property>
|
||||||
</action>
|
</action>
|
||||||
<action name="actionAddComponent">
|
<action name="actionMouseMode">
|
||||||
|
<property name="checkable">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
<property name="icon">
|
||||||
|
<iconset resource="../../resources/resources.qrc">
|
||||||
|
<normaloff>:/icons/icons/edit-select.png</normaloff>:/icons/icons/edit-select.png</iconset>
|
||||||
|
</property>
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Add Component</string>
|
<string>Mouse Mode</string>
|
||||||
</property>
|
</property>
|
||||||
<property name="toolTip">
|
<property name="toolTip">
|
||||||
<string>Add a component</string>
|
<string>Mouse Mode</string>
|
||||||
</property>
|
</property>
|
||||||
</action>
|
</action>
|
||||||
<action name="actionAddConnection">
|
<action name="actionConnectionMode">
|
||||||
|
<property name="checkable">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
<property name="icon">
|
||||||
|
<iconset resource="../../resources/resources.qrc">
|
||||||
|
<normaloff>:/icons/icons/network-connect.png</normaloff>:/icons/icons/network-connect.png</iconset>
|
||||||
|
</property>
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Add Connection</string>
|
<string>Connection Mode</string>
|
||||||
</property>
|
</property>
|
||||||
<property name="toolTip">
|
<property name="toolTip">
|
||||||
<string>Add a connection</string>
|
<string>Connection Mode</string>
|
||||||
</property>
|
|
||||||
</action>
|
|
||||||
<action name="actionDelete">
|
|
||||||
<property name="text">
|
|
||||||
<string>Delete</string>
|
|
||||||
</property>
|
|
||||||
<property name="toolTip">
|
|
||||||
<string>Delete selected graph items</string>
|
|
||||||
</property>
|
</property>
|
||||||
</action>
|
</action>
|
||||||
</widget>
|
</widget>
|
||||||
|
|||||||
@@ -30,12 +30,18 @@ class Ui_graphEditorWidget(object):
|
|||||||
icon = QIcon()
|
icon = QIcon()
|
||||||
icon.addFile(u":/icons/icons/zoom-original.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
icon.addFile(u":/icons/icons/zoom-original.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||||
self.actionZoomToFit.setIcon(icon)
|
self.actionZoomToFit.setIcon(icon)
|
||||||
self.actionAddComponent = QAction(graphEditorWidget)
|
self.actionMouseMode = QAction(graphEditorWidget)
|
||||||
self.actionAddComponent.setObjectName(u"actionAddComponent")
|
self.actionMouseMode.setObjectName(u"actionMouseMode")
|
||||||
self.actionAddConnection = QAction(graphEditorWidget)
|
self.actionMouseMode.setCheckable(True)
|
||||||
self.actionAddConnection.setObjectName(u"actionAddConnection")
|
icon1 = QIcon()
|
||||||
self.actionDelete = QAction(graphEditorWidget)
|
icon1.addFile(u":/icons/icons/edit-select.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||||
self.actionDelete.setObjectName(u"actionDelete")
|
self.actionMouseMode.setIcon(icon1)
|
||||||
|
self.actionConnectionMode = QAction(graphEditorWidget)
|
||||||
|
self.actionConnectionMode.setObjectName(u"actionConnectionMode")
|
||||||
|
self.actionConnectionMode.setCheckable(True)
|
||||||
|
icon2 = QIcon()
|
||||||
|
icon2.addFile(u":/icons/icons/network-connect.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||||
|
self.actionConnectionMode.setIcon(icon2)
|
||||||
self.verticalLayout = QVBoxLayout(graphEditorWidget)
|
self.verticalLayout = QVBoxLayout(graphEditorWidget)
|
||||||
self.verticalLayout.setSpacing(0)
|
self.verticalLayout.setSpacing(0)
|
||||||
self.verticalLayout.setObjectName(u"verticalLayout")
|
self.verticalLayout.setObjectName(u"verticalLayout")
|
||||||
@@ -57,9 +63,8 @@ class Ui_graphEditorWidget(object):
|
|||||||
|
|
||||||
|
|
||||||
self.graphToolBar.addAction(self.actionZoomToFit)
|
self.graphToolBar.addAction(self.actionZoomToFit)
|
||||||
self.graphToolBar.addAction(self.actionAddComponent)
|
self.graphToolBar.addAction(self.actionMouseMode)
|
||||||
self.graphToolBar.addAction(self.actionAddConnection)
|
self.graphToolBar.addAction(self.actionConnectionMode)
|
||||||
self.graphToolBar.addAction(self.actionDelete)
|
|
||||||
|
|
||||||
self.retranslateUi(graphEditorWidget)
|
self.retranslateUi(graphEditorWidget)
|
||||||
|
|
||||||
@@ -72,17 +77,13 @@ class Ui_graphEditorWidget(object):
|
|||||||
#if QT_CONFIG(tooltip)
|
#if QT_CONFIG(tooltip)
|
||||||
self.actionZoomToFit.setToolTip(QCoreApplication.translate("graphEditorWidget", u"Zoom canvas to fit", None))
|
self.actionZoomToFit.setToolTip(QCoreApplication.translate("graphEditorWidget", u"Zoom canvas to fit", None))
|
||||||
#endif // QT_CONFIG(tooltip)
|
#endif // QT_CONFIG(tooltip)
|
||||||
self.actionAddComponent.setText(QCoreApplication.translate("graphEditorWidget", u"Add Component", None))
|
self.actionMouseMode.setText(QCoreApplication.translate("graphEditorWidget", u"Mouse Mode", None))
|
||||||
#if QT_CONFIG(tooltip)
|
#if QT_CONFIG(tooltip)
|
||||||
self.actionAddComponent.setToolTip(QCoreApplication.translate("graphEditorWidget", u"Add a component", None))
|
self.actionMouseMode.setToolTip(QCoreApplication.translate("graphEditorWidget", u"Mouse Mode", None))
|
||||||
#endif // QT_CONFIG(tooltip)
|
#endif // QT_CONFIG(tooltip)
|
||||||
self.actionAddConnection.setText(QCoreApplication.translate("graphEditorWidget", u"Add Connection", None))
|
self.actionConnectionMode.setText(QCoreApplication.translate("graphEditorWidget", u"Connection Mode", None))
|
||||||
#if QT_CONFIG(tooltip)
|
#if QT_CONFIG(tooltip)
|
||||||
self.actionAddConnection.setToolTip(QCoreApplication.translate("graphEditorWidget", u"Add a connection", None))
|
self.actionConnectionMode.setToolTip(QCoreApplication.translate("graphEditorWidget", u"Connection Mode", 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)
|
#endif // QT_CONFIG(tooltip)
|
||||||
self.graphToolBar.setWindowTitle(QCoreApplication.translate("graphEditorWidget", u"Graph tools", None))
|
self.graphToolBar.setWindowTitle(QCoreApplication.translate("graphEditorWidget", u"Graph tools", None))
|
||||||
# retranslateUi
|
# retranslateUi
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from itertools import pairwise
|
||||||
from math import hypot
|
from math import hypot
|
||||||
|
|
||||||
from PySide6.QtCore import QEvent, QObject, QPointF, QRectF, QSize, QTimer, Qt, Signal
|
from PySide6.QtCore import QEvent, QObject, QPointF, QRectF, QSize, QTimer, Qt, Signal
|
||||||
from PySide6.QtGui import QColor, QPainter, QPainterPath, QPen, QWheelEvent
|
from PySide6.QtGui import QActionGroup, QBrush, QColor, QCursor, QKeySequence, QMouseEvent, QPainter, QPainterPath, QPen, QShortcut, QWheelEvent
|
||||||
from PySide6.QtWidgets import QGraphicsItem, QGraphicsPathItem, QGraphicsPixmapItem, QGraphicsScene, QGraphicsSceneMouseEvent, QGraphicsView, QWidget
|
from PySide6.QtWidgets import QGraphicsEllipseItem, QGraphicsItem, QGraphicsPathItem, QGraphicsPixmapItem, QGraphicsScene, QGraphicsSceneMouseEvent, QGraphicsView, QMenu, QWidget
|
||||||
|
|
||||||
from bedit_core.models import BondCausality, BondConnection, Component, ComponentID, ConnectionID, GraphImplementation, SignalConnection
|
from bedit_core.models import BondCausality, BondConnection, BondPort, Component, ComponentID, Connection, ConnectionID, GraphImplementation, Port, PortID, SignalConnection, SignalDirection, SignalPort
|
||||||
from bedit_gui.models import Graph, Icon
|
from bedit_gui.models import Graph, GraphConnection, 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
|
from bedit_gui.utils.icon import get_pixmap_bounding_box, render_icon
|
||||||
|
|
||||||
@@ -27,6 +29,11 @@ ARROW_HALF_WIDTH = 16.0
|
|||||||
CAUSALITY_TICK_HALF_LENGTH = 16.0
|
CAUSALITY_TICK_HALF_LENGTH = 16.0
|
||||||
|
|
||||||
|
|
||||||
|
class GraphEditorMode(Enum):
|
||||||
|
NORMAL = "normal"
|
||||||
|
CONNECTION = "connection"
|
||||||
|
|
||||||
|
|
||||||
class GraphGraphicsScene(QGraphicsScene):
|
class GraphGraphicsScene(QGraphicsScene):
|
||||||
"""Graph canvas with a lightweight dotted-line grid."""
|
"""Graph canvas with a lightweight dotted-line grid."""
|
||||||
|
|
||||||
@@ -51,14 +58,32 @@ class GraphGraphicsScene(QGraphicsScene):
|
|||||||
class GraphConnectionItem(QGraphicsPathItem):
|
class GraphConnectionItem(QGraphicsPathItem):
|
||||||
"""A routed connection with a full signal arrow or half bond arrow."""
|
"""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:
|
def __init__(self, points: list[tuple[float, float]], *, half_arrow: bool, tick_at_source: bool | None = None, connection_id: ConnectionID | None = None, editor: GraphEditorWidget | None = None) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
self.connection_id = connection_id
|
||||||
|
self.editor = editor
|
||||||
self.setPath(self._connection_path(points, half_arrow, tick_at_source))
|
self.setPath(self._connection_path(points, half_arrow, tick_at_source))
|
||||||
pen = QPen(QColor("#202020"), CONNECTION_WIDTH)
|
pen = QPen(QColor("#202020"), CONNECTION_WIDTH)
|
||||||
pen.setCosmetic(True)
|
|
||||||
self.setPen(pen)
|
self.setPen(pen)
|
||||||
|
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
|
||||||
self.setZValue(-1)
|
self.setZValue(-1)
|
||||||
|
|
||||||
|
def contextMenuEvent(self, event) -> None:
|
||||||
|
if self.editor is None or self.connection_id is None:
|
||||||
|
return
|
||||||
|
if not self.isSelected():
|
||||||
|
self.scene().clearSelection()
|
||||||
|
self.setSelected(True)
|
||||||
|
menu = QMenu(self.editor)
|
||||||
|
add_point = menu.addAction("Add Point")
|
||||||
|
delete_connection = menu.addAction("Delete Connection")
|
||||||
|
selected = menu.exec(event.screenPos())
|
||||||
|
if selected is add_point:
|
||||||
|
self.editor.add_connection_point(self.connection_id, event.scenePos())
|
||||||
|
elif selected is delete_connection:
|
||||||
|
self.editor.delete_selected_connections()
|
||||||
|
event.accept()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _connection_path(points: list[tuple[float, float]], half_arrow: bool, tick_at_source: bool | None = None) -> QPainterPath:
|
def _connection_path(points: list[tuple[float, float]], half_arrow: bool, tick_at_source: bool | None = None) -> QPainterPath:
|
||||||
path = QPainterPath(QPointF(*points[0]))
|
path = QPainterPath(QPointF(*points[0]))
|
||||||
@@ -112,7 +137,7 @@ class GraphComponentItem(QGraphicsPixmapItem):
|
|||||||
self._drag_start = QPointF()
|
self._drag_start = QPointF()
|
||||||
self._dragging = False
|
self._dragging = False
|
||||||
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
|
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
|
||||||
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable)
|
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable, editor.mode is GraphEditorMode.NORMAL)
|
||||||
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
|
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
|
||||||
|
|
||||||
def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: object) -> object:
|
def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: object) -> object:
|
||||||
@@ -124,6 +149,66 @@ class GraphComponentItem(QGraphicsPixmapItem):
|
|||||||
self.editor.refresh_connections()
|
self.editor.refresh_connections()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None:
|
||||||
|
if event.button() == Qt.MouseButton.LeftButton and self.editor.mode is GraphEditorMode.CONNECTION:
|
||||||
|
self.editor.choose_connection_component(self.component_id, event.screenPos())
|
||||||
|
event.accept()
|
||||||
|
return
|
||||||
|
self._drag_start = QPointF(self.pos())
|
||||||
|
self._dragging = True
|
||||||
|
super().mousePressEvent(event)
|
||||||
|
|
||||||
|
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None:
|
||||||
|
if self.editor.mode is GraphEditorMode.CONNECTION:
|
||||||
|
event.accept()
|
||||||
|
return
|
||||||
|
super().mouseReleaseEvent(event)
|
||||||
|
self._dragging = False
|
||||||
|
position = (round(self.pos().x()), round(self.pos().y()))
|
||||||
|
self.setPos(*position)
|
||||||
|
if self.pos() != self._drag_start:
|
||||||
|
self.editor.finish_component_move(self.component_id, position)
|
||||||
|
|
||||||
|
def mouseDoubleClickEvent(self, event: QGraphicsSceneMouseEvent) -> None:
|
||||||
|
if event.button() == Qt.MouseButton.LeftButton:
|
||||||
|
self.editor.open_component(self.component_id)
|
||||||
|
event.accept()
|
||||||
|
return
|
||||||
|
super().mouseDoubleClickEvent(event)
|
||||||
|
|
||||||
|
def contextMenuEvent(self, event) -> None:
|
||||||
|
if not self.isSelected():
|
||||||
|
self.scene().clearSelection()
|
||||||
|
self.setSelected(True)
|
||||||
|
self.editor.component_context_menu_requested.emit(self.component_id, event.screenPos())
|
||||||
|
event.accept()
|
||||||
|
|
||||||
|
|
||||||
|
class GraphConnectionPointItem(QGraphicsEllipseItem):
|
||||||
|
def __init__(self, connection_id: ConnectionID, index: int, position: tuple[int, int], editor: GraphEditorWidget) -> None:
|
||||||
|
radius = CONNECTION_WIDTH
|
||||||
|
super().__init__(-radius, -radius, radius * 2, radius * 2)
|
||||||
|
self.connection_id = connection_id
|
||||||
|
self.index = index
|
||||||
|
self.editor = editor
|
||||||
|
self._drag_start = QPointF()
|
||||||
|
self._dragging = False
|
||||||
|
self.setPos(*position)
|
||||||
|
self.setPen(QPen(Qt.PenStyle.NoPen))
|
||||||
|
self.setBrush(QBrush(QColor("#202020")))
|
||||||
|
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable)
|
||||||
|
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
|
||||||
|
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
|
||||||
|
|
||||||
|
def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: object) -> object:
|
||||||
|
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange and self._dragging and isinstance(value, QPointF):
|
||||||
|
size = self.editor.snap_to_grid_size
|
||||||
|
value = QPointF(round(value.x() / size) * size, round(value.y() / size) * size)
|
||||||
|
result = super().itemChange(change, value)
|
||||||
|
if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
|
||||||
|
self.editor.refresh_connections()
|
||||||
|
return result
|
||||||
|
|
||||||
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None:
|
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None:
|
||||||
self._drag_start = QPointF(self.pos())
|
self._drag_start = QPointF(self.pos())
|
||||||
self._dragging = True
|
self._dragging = True
|
||||||
@@ -135,11 +220,23 @@ class GraphComponentItem(QGraphicsPixmapItem):
|
|||||||
position = (round(self.pos().x()), round(self.pos().y()))
|
position = (round(self.pos().x()), round(self.pos().y()))
|
||||||
self.setPos(*position)
|
self.setPos(*position)
|
||||||
if self.pos() != self._drag_start:
|
if self.pos() != self._drag_start:
|
||||||
self.editor.finish_component_move(self.component_id, position)
|
self.editor.finish_connection_point_move(self.connection_id)
|
||||||
|
|
||||||
|
def contextMenuEvent(self, event) -> None:
|
||||||
|
menu = QMenu(self.editor)
|
||||||
|
delete_point = menu.addAction("Delete Point")
|
||||||
|
if menu.exec(event.screenPos()) is delete_point:
|
||||||
|
self.editor.delete_connection_point(self.connection_id, self.index)
|
||||||
|
event.accept()
|
||||||
|
|
||||||
|
|
||||||
class GraphEditorWidget(QWidget):
|
class GraphEditorWidget(QWidget):
|
||||||
component_move_requested = Signal(object, object, object)
|
component_move_requested = Signal(object, object, object)
|
||||||
|
component_context_menu_requested = Signal(object, object)
|
||||||
|
component_open_requested = Signal(object)
|
||||||
|
connection_points_change_requested = Signal(object, object, object, str)
|
||||||
|
connection_add_requested = Signal(object, object)
|
||||||
|
connections_delete_requested = Signal(object, object)
|
||||||
|
|
||||||
def __init__(self, parent: QWidget | None = None, snap_to_grid_size: int = 4) -> None:
|
def __init__(self, parent: QWidget | None = None, snap_to_grid_size: int = 4) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -148,16 +245,32 @@ class GraphEditorWidget(QWidget):
|
|||||||
self.ui.setupUi(self)
|
self.ui.setupUi(self)
|
||||||
self._component: Component | None = None
|
self._component: Component | None = None
|
||||||
self._graph = Graph()
|
self._graph = Graph()
|
||||||
|
self._icons: dict[ComponentID, Icon] = {}
|
||||||
|
self._mode = GraphEditorMode.NORMAL
|
||||||
|
self._connection_start: ComponentID | None = None
|
||||||
|
self._connection_preview: QGraphicsPathItem | None = None
|
||||||
self._component_items: dict[ComponentID, GraphComponentItem] = {}
|
self._component_items: dict[ComponentID, GraphComponentItem] = {}
|
||||||
self._component_bounds: dict[ComponentID, QRectF] = {}
|
self._component_bounds: dict[ComponentID, QRectF] = {}
|
||||||
self._connection_items: dict[ConnectionID, GraphConnectionItem] = {}
|
self._connection_items: dict[ConnectionID, GraphConnectionItem] = {}
|
||||||
|
self._connection_point_items: dict[ConnectionID, list[GraphConnectionPointItem]] = {}
|
||||||
self.set_snap_to_grid_size(snap_to_grid_size)
|
self.set_snap_to_grid_size(snap_to_grid_size)
|
||||||
self.scene = GraphGraphicsScene(self)
|
self.scene = GraphGraphicsScene(self)
|
||||||
self.scene.setSceneRect(-SCENE_SIZE / 2, -SCENE_SIZE / 2, SCENE_SIZE, SCENE_SIZE)
|
self.scene.setSceneRect(-SCENE_SIZE / 2, -SCENE_SIZE / 2, SCENE_SIZE, SCENE_SIZE)
|
||||||
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._mode_actions = QActionGroup(self)
|
||||||
|
self._mode_actions.setExclusive(True)
|
||||||
|
self._mode_actions.addAction(self.ui.actionMouseMode)
|
||||||
|
self._mode_actions.addAction(self.ui.actionConnectionMode)
|
||||||
|
self.ui.actionMouseMode.triggered.connect(lambda: self.set_mode(GraphEditorMode.NORMAL))
|
||||||
|
self.ui.actionConnectionMode.triggered.connect(lambda: self.set_mode(GraphEditorMode.CONNECTION))
|
||||||
|
self.ui.actionMouseMode.setChecked(True)
|
||||||
|
self._mode_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Space), self)
|
||||||
|
self._mode_shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
|
||||||
|
self._mode_shortcut.activated.connect(self.toggle_mode)
|
||||||
self.ui.actionZoomToFit.triggered.connect(self.zoom_to_fit)
|
self.ui.actionZoomToFit.triggered.connect(self.zoom_to_fit)
|
||||||
|
self.ui.graphicsView.viewport().setMouseTracking(True)
|
||||||
self.ui.graphicsView.centerOn(0, 0)
|
self.ui.graphicsView.centerOn(0, 0)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -169,25 +282,46 @@ class GraphEditorWidget(QWidget):
|
|||||||
raise ValueError("snap-to-grid size must be positive")
|
raise ValueError("snap-to-grid size must be positive")
|
||||||
self._snap_to_grid_size = size
|
self._snap_to_grid_size = size
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mode(self) -> GraphEditorMode:
|
||||||
|
return self._mode
|
||||||
|
|
||||||
|
def set_mode(self, mode: GraphEditorMode) -> None:
|
||||||
|
if mode is self._mode:
|
||||||
|
return
|
||||||
|
self._mode = mode
|
||||||
|
self.ui.actionMouseMode.setChecked(mode is GraphEditorMode.NORMAL)
|
||||||
|
self.ui.actionConnectionMode.setChecked(mode is GraphEditorMode.CONNECTION)
|
||||||
|
self._clear_connection_start()
|
||||||
|
component = self._component
|
||||||
|
if component is not None:
|
||||||
|
self.set_component(component, self._graph, self._icons)
|
||||||
|
|
||||||
|
def toggle_mode(self) -> None:
|
||||||
|
self.set_mode(GraphEditorMode.CONNECTION if self._mode is GraphEditorMode.NORMAL else GraphEditorMode.NORMAL)
|
||||||
|
|
||||||
def set_component(self, component: Component | None, graph: Graph | None = None, icons: dict[ComponentID, Icon] | None = 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
|
component_changed = component is not self._component
|
||||||
|
self._clear_connection_start()
|
||||||
self._component = component
|
self._component = component
|
||||||
self._graph = graph or Graph()
|
self._graph = graph or Graph()
|
||||||
|
self._icons = icons or {}
|
||||||
self._component_items = {}
|
self._component_items = {}
|
||||||
self._component_bounds = {}
|
self._component_bounds = {}
|
||||||
self._connection_items = {}
|
self._connection_items = {}
|
||||||
|
self._connection_point_items = {}
|
||||||
self.scene.clear()
|
self.scene.clear()
|
||||||
if component is None:
|
if component is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
graph = self._graph
|
graph = self._graph
|
||||||
icons = icons or {}
|
icons = self._icons
|
||||||
positions = {component_id: graph.component_positions.get(component_id, (index * FALLBACK_COMPONENT_SPACING, 0)) for index, component_id in enumerate(component.implementation.graph.components)}
|
positions = {component_id: graph.component_positions.get(component_id, (index * FALLBACK_COMPONENT_SPACING, 0)) for index, component_id in enumerate(component.implementation.graph.components)}
|
||||||
for component_id, child in component.implementation.graph.components.items():
|
for component_id, child in component.implementation.graph.components.items():
|
||||||
icon = icons.get(component_id, Icon())
|
icon = icons.get(component_id, Icon())
|
||||||
pixmap = render_icon(icon, child.interface.ports, COMPONENT_ICON_SIZE).pixmap(COMPONENT_ICON_SIZE)
|
pixmap = render_icon(icon, child.interface.ports, COMPONENT_ICON_SIZE, render_ports=self._mode is GraphEditorMode.CONNECTION).pixmap(COMPONENT_ICON_SIZE)
|
||||||
item = GraphComponentItem(component_id, pixmap, self)
|
item = GraphComponentItem(component_id, pixmap, self)
|
||||||
item.setOffset(-pixmap.width() / 2, -pixmap.height() / 2)
|
item.setOffset(-pixmap.width() / 2, -pixmap.height() / 2)
|
||||||
item.setPos(*positions[component_id])
|
item.setPos(*positions[component_id])
|
||||||
@@ -206,10 +340,12 @@ class GraphEditorWidget(QWidget):
|
|||||||
tick_at_source = False
|
tick_at_source = False
|
||||||
elif connection.causality is BondCausality.FLOW_OUT:
|
elif connection.causality is BondCausality.FLOW_OUT:
|
||||||
tick_at_source = True
|
tick_at_source = True
|
||||||
connection_item = GraphConnectionItem([(0, 0), (1, 0)], half_arrow=isinstance(connection, BondConnection), tick_at_source=tick_at_source)
|
connection_item = GraphConnectionItem([(0, 0), (1, 0)], half_arrow=isinstance(connection, BondConnection), tick_at_source=tick_at_source, connection_id=connection_id, editor=self)
|
||||||
connection_item.setData(0, str(connection_id))
|
connection_item.setData(0, str(connection_id))
|
||||||
self._connection_items[connection_id] = connection_item
|
self._connection_items[connection_id] = connection_item
|
||||||
self.scene.addItem(connection_item)
|
self.scene.addItem(connection_item)
|
||||||
|
visual_connection = graph.connections.get(connection_id)
|
||||||
|
self._create_connection_point_items(connection_id, visual_connection.points[1:-1] if visual_connection is not None and len(visual_connection.points) >= 2 else [])
|
||||||
self.refresh_connections()
|
self.refresh_connections()
|
||||||
if component_changed:
|
if component_changed:
|
||||||
QTimer.singleShot(0, self.ui.actionZoomToFit.trigger)
|
QTimer.singleShot(0, self.ui.actionZoomToFit.trigger)
|
||||||
@@ -220,16 +356,17 @@ class GraphEditorWidget(QWidget):
|
|||||||
return
|
return
|
||||||
port_owners = {port_id: component_id for component_id, child in component.implementation.graph.components.items() for port_id in child.interface.ports}
|
port_owners = {port_id: component_id for component_id, child in component.implementation.graph.components.items() for port_id in child.interface.ports}
|
||||||
for connection_id, item in self._connection_items.items():
|
for connection_id, item in self._connection_items.items():
|
||||||
connection = component.implementation.graph.connections[connection_id]
|
connection = component.implementation.graph.connections.get(connection_id)
|
||||||
|
if connection is None:
|
||||||
|
continue
|
||||||
source_component = port_owners.get(connection.source)
|
source_component = port_owners.get(connection.source)
|
||||||
target_component = port_owners.get(connection.target)
|
target_component = port_owners.get(connection.target)
|
||||||
if source_component is None or target_component is None:
|
if source_component is None or target_component is None:
|
||||||
continue
|
continue
|
||||||
source_position = self._item_position(source_component)
|
source_position = self._item_position(source_component)
|
||||||
target_position = self._item_position(target_component)
|
target_position = self._item_position(target_component)
|
||||||
visual_connection = self._graph.connections.get(connection_id)
|
point_items = self._connection_point_items.get(connection_id, [])
|
||||||
points = list(visual_connection.points) if visual_connection is not None else []
|
points = [source_position, *((point.pos().x(), point.pos().y()) for point in point_items), target_position]
|
||||||
points = [source_position, target_position] if len(points) < 2 else [source_position, *points[1:-1], target_position]
|
|
||||||
points = self._straighten_direct_connection(points)
|
points = self._straighten_direct_connection(points)
|
||||||
source_bounds = self._component_bounds[source_component].translated(*source_position)
|
source_bounds = self._component_bounds[source_component].translated(*source_position)
|
||||||
target_bounds = self._component_bounds[target_component].translated(*target_position)
|
target_bounds = self._component_bounds[target_component].translated(*target_position)
|
||||||
@@ -239,6 +376,85 @@ class GraphEditorWidget(QWidget):
|
|||||||
tick_at_source = False if connection.causality is BondCausality.EFFORT_OUT else True if connection.causality is BondCausality.FLOW_OUT else None
|
tick_at_source = False if connection.causality is BondCausality.EFFORT_OUT else True if connection.causality is BondCausality.FLOW_OUT else None
|
||||||
item.setPath(item._connection_path(points, isinstance(connection, BondConnection), tick_at_source))
|
item.setPath(item._connection_path(points, isinstance(connection, BondConnection), tick_at_source))
|
||||||
|
|
||||||
|
def add_connection_point(self, connection_id: ConnectionID, scene_position: QPointF) -> None:
|
||||||
|
points = self._connection_metadata_points(connection_id)
|
||||||
|
position = self._snap_position(scene_position)
|
||||||
|
index = self._nearest_segment_index(points, position) + 1
|
||||||
|
points.insert(index, position)
|
||||||
|
self._request_connection_points_change(connection_id, points, "Add connection point")
|
||||||
|
|
||||||
|
def delete_connection_point(self, connection_id: ConnectionID, index: int) -> None:
|
||||||
|
points = self._connection_metadata_points(connection_id)
|
||||||
|
if 0 < index < len(points) - 1:
|
||||||
|
points.pop(index)
|
||||||
|
self._request_connection_points_change(connection_id, points, "Delete connection point")
|
||||||
|
|
||||||
|
def finish_connection_point_move(self, connection_id: ConnectionID) -> None:
|
||||||
|
self._request_connection_points_change(connection_id, self._connection_metadata_points(connection_id), "Move connection point")
|
||||||
|
|
||||||
|
def set_connection_points(self, connection_id: ConnectionID, points: list[tuple[int, int]] | None) -> None:
|
||||||
|
if points is None:
|
||||||
|
self._graph.connections.pop(connection_id, None)
|
||||||
|
for item in self._connection_point_items.pop(connection_id, []):
|
||||||
|
self.scene.removeItem(item)
|
||||||
|
connection_item = self._connection_items.pop(connection_id, None)
|
||||||
|
if connection_item is not None:
|
||||||
|
self.scene.removeItem(connection_item)
|
||||||
|
return
|
||||||
|
self._graph.connections[connection_id] = GraphConnection(points=list(points))
|
||||||
|
interior_points = points[1:-1]
|
||||||
|
items = self._connection_point_items.get(connection_id, [])
|
||||||
|
if len(items) == len(interior_points):
|
||||||
|
for item, position in zip(items, interior_points):
|
||||||
|
item.setPos(*position)
|
||||||
|
else:
|
||||||
|
for item in items:
|
||||||
|
self.scene.removeItem(item)
|
||||||
|
self._create_connection_point_items(connection_id, interior_points)
|
||||||
|
self.refresh_connections()
|
||||||
|
|
||||||
|
def _create_connection_point_items(self, connection_id: ConnectionID, positions: list[tuple[int, int]]) -> None:
|
||||||
|
items = [GraphConnectionPointItem(connection_id, index, position, self) for index, position in enumerate(positions, 1)]
|
||||||
|
self._connection_point_items[connection_id] = items
|
||||||
|
for item in items:
|
||||||
|
self.scene.addItem(item)
|
||||||
|
|
||||||
|
def _connection_metadata_points(self, connection_id: ConnectionID) -> list[tuple[int, int]]:
|
||||||
|
component = self._component
|
||||||
|
if component is None:
|
||||||
|
return []
|
||||||
|
connection = component.implementation.graph.connections[connection_id]
|
||||||
|
port_owners = {port_id: component_id for component_id, child in component.implementation.graph.components.items() for port_id in child.interface.ports}
|
||||||
|
source = self._item_position(port_owners[connection.source])
|
||||||
|
target = self._item_position(port_owners[connection.target])
|
||||||
|
interior = [(round(item.pos().x()), round(item.pos().y())) for item in self._connection_point_items.get(connection_id, [])]
|
||||||
|
return [(round(source[0]), round(source[1])), *interior, (round(target[0]), round(target[1]))]
|
||||||
|
|
||||||
|
def _request_connection_points_change(self, connection_id: ConnectionID, points: list[tuple[int, int]], text: str) -> None:
|
||||||
|
if self._component is not None:
|
||||||
|
self.connection_points_change_requested.emit(self._component, connection_id, points, text)
|
||||||
|
|
||||||
|
def _snap_position(self, position: QPointF) -> tuple[int, int]:
|
||||||
|
size = self.snap_to_grid_size
|
||||||
|
return round(position.x() / size) * size, round(position.y() / size) * size
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _nearest_segment_index(points: list[tuple[int, int]], position: tuple[int, int]) -> int:
|
||||||
|
best_index = 0
|
||||||
|
best_distance = float("inf")
|
||||||
|
for index, (start, end) in enumerate(pairwise(points)):
|
||||||
|
dx = end[0] - start[0]
|
||||||
|
dy = end[1] - start[1]
|
||||||
|
length_squared = dx * dx + dy * dy
|
||||||
|
ratio = 0.0 if not length_squared else max(0.0, min(1.0, ((position[0] - start[0]) * dx + (position[1] - start[1]) * dy) / length_squared))
|
||||||
|
closest_x = start[0] + ratio * dx
|
||||||
|
closest_y = start[1] + ratio * dy
|
||||||
|
distance = (position[0] - closest_x) ** 2 + (position[1] - closest_y) ** 2
|
||||||
|
if distance < best_distance:
|
||||||
|
best_index = index
|
||||||
|
best_distance = distance
|
||||||
|
return best_index
|
||||||
|
|
||||||
def finish_component_move(self, component_id: ComponentID, position: tuple[int, int]) -> None:
|
def finish_component_move(self, component_id: ComponentID, position: tuple[int, int]) -> None:
|
||||||
if self._component is not None:
|
if self._component is not None:
|
||||||
self.component_move_requested.emit(self._component, component_id, position)
|
self.component_move_requested.emit(self._component, component_id, position)
|
||||||
@@ -248,8 +464,11 @@ class GraphEditorWidget(QWidget):
|
|||||||
if item is None:
|
if item is None:
|
||||||
return
|
return
|
||||||
if position is None:
|
if position is None:
|
||||||
|
self._graph.component_positions.pop(component_id, None)
|
||||||
index = list(self._component_items).index(component_id)
|
index = list(self._component_items).index(component_id)
|
||||||
position = (index * FALLBACK_COMPONENT_SPACING, 0)
|
position = (index * FALLBACK_COMPONENT_SPACING, 0)
|
||||||
|
else:
|
||||||
|
self._graph.component_positions[component_id] = position
|
||||||
item.setPos(*position)
|
item.setPos(*position)
|
||||||
self.refresh_connections()
|
self.refresh_connections()
|
||||||
|
|
||||||
@@ -298,6 +517,124 @@ class GraphEditorWidget(QWidget):
|
|||||||
def component(self) -> Component | None:
|
def component(self) -> Component | None:
|
||||||
return self._component
|
return self._component
|
||||||
|
|
||||||
|
def selected_component_ids(self) -> list[ComponentID]:
|
||||||
|
return [item.component_id for item in self.scene.selectedItems() if isinstance(item, GraphComponentItem)]
|
||||||
|
|
||||||
|
def selected_connection_ids(self) -> list[ConnectionID]:
|
||||||
|
return [item.connection_id for item in self.scene.selectedItems() if isinstance(item, GraphConnectionItem) and item.connection_id is not None]
|
||||||
|
|
||||||
|
def delete_selected_connections(self) -> None:
|
||||||
|
if self._component is not None:
|
||||||
|
self.connections_delete_requested.emit(self._component, self.selected_connection_ids())
|
||||||
|
|
||||||
|
def open_component(self, component_id: ComponentID) -> None:
|
||||||
|
self._clear_connection_start()
|
||||||
|
self.component_open_requested.emit(component_id)
|
||||||
|
|
||||||
|
def choose_connection_component(self, component_id: ComponentID, screen_position) -> None:
|
||||||
|
if self._component is None or self._mode is not GraphEditorMode.CONNECTION:
|
||||||
|
return
|
||||||
|
if self._connection_start is None:
|
||||||
|
self.scene.clearSelection()
|
||||||
|
self._component_items[component_id].setSelected(True)
|
||||||
|
self._connection_start = component_id
|
||||||
|
self._connection_preview = QGraphicsPathItem()
|
||||||
|
self._connection_preview.setPen(QPen(QColor("#606060"), CONNECTION_WIDTH, Qt.PenStyle.DashLine))
|
||||||
|
self._connection_preview.setZValue(-0.5)
|
||||||
|
self.scene.addItem(self._connection_preview)
|
||||||
|
return
|
||||||
|
start = self._connection_start
|
||||||
|
self._clear_connection_start()
|
||||||
|
self.scene.clearSelection()
|
||||||
|
if start == component_id:
|
||||||
|
return
|
||||||
|
options = self._connection_options(start, component_id)
|
||||||
|
if len(options) == 1:
|
||||||
|
self.connection_add_requested.emit(self._component, options[0][1])
|
||||||
|
return
|
||||||
|
if not options:
|
||||||
|
return
|
||||||
|
menu = QMenu(self)
|
||||||
|
actions = []
|
||||||
|
for label, connection, _preferred in options:
|
||||||
|
action = menu.addAction(label)
|
||||||
|
actions.append((action, connection))
|
||||||
|
menu.setActiveAction(actions[0][0])
|
||||||
|
selected = menu.exec(screen_position)
|
||||||
|
for action, connection in actions:
|
||||||
|
if selected is action:
|
||||||
|
self.connection_add_requested.emit(self._component, connection)
|
||||||
|
break
|
||||||
|
|
||||||
|
def _update_connection_preview(self, mouse_position: QPointF) -> None:
|
||||||
|
if self._connection_start is None or self._connection_preview is None:
|
||||||
|
return
|
||||||
|
source = self._item_position(self._connection_start)
|
||||||
|
if mouse_position == QPointF(*source):
|
||||||
|
self._connection_preview.setPath(QPainterPath(mouse_position))
|
||||||
|
return
|
||||||
|
bounds = self._component_bounds[self._connection_start].translated(*source)
|
||||||
|
start = self._bounding_box_edge(bounds, source, (mouse_position.x(), mouse_position.y()))
|
||||||
|
path = QPainterPath(QPointF(*start))
|
||||||
|
path.lineTo(mouse_position)
|
||||||
|
self._connection_preview.setPath(path)
|
||||||
|
|
||||||
|
def _clear_connection_start(self) -> None:
|
||||||
|
self._connection_start = None
|
||||||
|
if self._connection_preview is not None and self._connection_preview.scene() is self.scene:
|
||||||
|
self.scene.removeItem(self._connection_preview)
|
||||||
|
self._connection_preview = None
|
||||||
|
|
||||||
|
def _connection_options(self, first_id: ComponentID, second_id: ComponentID) -> list[tuple[str, Connection, bool]]:
|
||||||
|
if self._component is None:
|
||||||
|
return []
|
||||||
|
components = self._component.implementation.graph.components
|
||||||
|
first = components[first_id]
|
||||||
|
second = components[second_id]
|
||||||
|
used_ports = {port_id for connection in self._component.implementation.graph.connections.values() for port_id in (connection.source, connection.target)}
|
||||||
|
options = []
|
||||||
|
for first_port_id, first_port in first.interface.ports.items():
|
||||||
|
for second_port_id, second_port in second.interface.ports.items():
|
||||||
|
if not self._compatible_ports(first_port, second_port) or not self._port_available(first_port_id, first_port, used_ports) or not self._port_available(second_port_id, second_port, used_ports):
|
||||||
|
continue
|
||||||
|
preferred = first_port.direction is SignalDirection.OUTPUT and second_port.direction is SignalDirection.INPUT
|
||||||
|
reverse_preferred = second_port.direction is SignalDirection.OUTPUT and first_port.direction is SignalDirection.INPUT
|
||||||
|
if reverse_preferred:
|
||||||
|
source_id, source_port, source_name = second_port_id, second_port, second.name
|
||||||
|
target_id, target_port, target_name = first_port_id, first_port, first.name
|
||||||
|
else:
|
||||||
|
source_id, source_port, source_name = first_port_id, first_port, first.name
|
||||||
|
target_id, target_port, target_name = second_port_id, second_port, second.name
|
||||||
|
connection_type = SignalConnection if isinstance(source_port, SignalPort) else BondConnection
|
||||||
|
label = f"{source_name}.{source_port.name} → {target_name}.{target_port.name}"
|
||||||
|
options.append((label, connection_type(source=source_id, target=target_id), preferred or reverse_preferred))
|
||||||
|
return sorted(options, key=lambda option: not option[2])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _compatible_ports(first: Port, second: Port) -> bool:
|
||||||
|
if isinstance(first, SignalPort) and isinstance(second, SignalPort):
|
||||||
|
return first.direction is not second.direction
|
||||||
|
if isinstance(first, BondPort) and isinstance(second, BondPort):
|
||||||
|
return not first.domain or not second.domain or first.domain == second.domain
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _port_available(port_id: PortID, port: Port, used_ports: set[PortID]) -> bool:
|
||||||
|
if isinstance(port, SignalPort):
|
||||||
|
return port.direction is SignalDirection.OUTPUT or port_id not in used_ports
|
||||||
|
if isinstance(port, BondPort):
|
||||||
|
return port.multiplicity or port_id not in used_ports
|
||||||
|
return False
|
||||||
|
|
||||||
|
def paste_position(self) -> tuple[int, int]:
|
||||||
|
viewport = self.ui.graphicsView.viewport()
|
||||||
|
viewport_position = viewport.mapFromGlobal(QCursor.pos())
|
||||||
|
if not viewport.rect().contains(viewport_position):
|
||||||
|
viewport_position = viewport.rect().center()
|
||||||
|
scene_position = self.ui.graphicsView.mapToScene(viewport_position)
|
||||||
|
size = self.snap_to_grid_size
|
||||||
|
return round(scene_position.x() / size) * size, round(scene_position.y() / size) * size
|
||||||
|
|
||||||
def zoom_to_fit(self) -> None:
|
def zoom_to_fit(self) -> None:
|
||||||
bounds = self.scene.itemsBoundingRect()
|
bounds = self.scene.itemsBoundingRect()
|
||||||
if bounds.isEmpty():
|
if bounds.isEmpty():
|
||||||
@@ -313,6 +650,9 @@ class GraphEditorWidget(QWidget):
|
|||||||
self.ui.graphicsView.centerOn(bounds.center())
|
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.MouseMove:
|
||||||
|
assert isinstance(event, QMouseEvent)
|
||||||
|
self._update_connection_preview(self.ui.graphicsView.mapToScene(event.position().toPoint()))
|
||||||
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)
|
||||||
if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
||||||
|
|||||||
@@ -131,6 +131,12 @@ class DocumentTreeModel(QAbstractItemModel):
|
|||||||
node = index.internalPointer()
|
node = index.internalPointer()
|
||||||
return node.value if isinstance(node, DocumentTreeNode) else None
|
return node.value if isinstance(node, DocumentTreeNode) else None
|
||||||
|
|
||||||
|
def component_index(self, component_id: ComponentID) -> QModelIndex:
|
||||||
|
node = self._component_nodes.get(component_id)
|
||||||
|
if node is None or node.parent is None:
|
||||||
|
return QModelIndex()
|
||||||
|
return self.createIndex(node.parent.children.index(node), 0, node)
|
||||||
|
|
||||||
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
|
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
|
||||||
flags = super().flags(index)
|
flags = super().flags(index)
|
||||||
|
|
||||||
|
|||||||
@@ -374,6 +374,7 @@ class Simulation:
|
|||||||
process = self._run_model_commands(
|
process = self._run_model_commands(
|
||||||
modelica,
|
modelica,
|
||||||
[
|
[
|
||||||
|
f"checkModel({model_name})",
|
||||||
f'buildModel({model_name}, outputFormat="csv")',
|
f'buildModel({model_name}, outputFormat="csv")',
|
||||||
"getErrorString()",
|
"getErrorString()",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -360,20 +360,6 @@
|
|||||||
"connection_type": "bond",
|
"connection_type": "bond",
|
||||||
"source": "497b1f74-1186-471f-976a-36b07a451caf",
|
"source": "497b1f74-1186-471f-976a-36b07a451caf",
|
||||||
"target": "4a3f69f2-b305-4305-92e3-3998634ff226",
|
"target": "4a3f69f2-b305-4305-92e3-3998634ff226",
|
||||||
"causality": "flow_out",
|
|
||||||
"undesired": false
|
|
||||||
},
|
|
||||||
"8aaa96b3-45ed-4674-8f7a-06b28178a5c8": {
|
|
||||||
"connection_type": "bond",
|
|
||||||
"source": "4306701a-6b1b-4d19-b8ff-45dbfa04f2d3",
|
|
||||||
"target": "c87881f1-23b4-4a69-b586-8e3c7bf6e21e",
|
|
||||||
"causality": "flow_out",
|
|
||||||
"undesired": false
|
|
||||||
},
|
|
||||||
"ff606583-d31f-4638-93f7-926163db2665": {
|
|
||||||
"connection_type": "bond",
|
|
||||||
"source": "4306701a-6b1b-4d19-b8ff-45dbfa04f2d3",
|
|
||||||
"target": "0ac7d4ea-77f7-4c0d-8e37-406ef66e4740",
|
|
||||||
"causality": "effort_out",
|
"causality": "effort_out",
|
||||||
"undesired": false
|
"undesired": false
|
||||||
},
|
},
|
||||||
@@ -384,10 +370,24 @@
|
|||||||
"causality": "effort_out",
|
"causality": "effort_out",
|
||||||
"undesired": false
|
"undesired": false
|
||||||
},
|
},
|
||||||
"5af42b61-2317-48a8-b32b-0e2dfd88368e": {
|
"cfff2ad4-a4ec-4f37-aa36-2c37b01422f0": {
|
||||||
"connection_type": "bond",
|
"connection_type": "bond",
|
||||||
"source": "4306701a-6b1b-4d19-b8ff-45dbfa04f2d3",
|
"source": "4306701a-6b1b-4d19-b8ff-45dbfa04f2d3",
|
||||||
"target": "497b1f74-1186-471f-976a-36b07a451caf",
|
"target": "497b1f74-1186-471f-976a-36b07a451caf",
|
||||||
|
"causality": "effort_out",
|
||||||
|
"undesired": false
|
||||||
|
},
|
||||||
|
"743e35a4-e736-407d-bf34-b4afbcde5a0b": {
|
||||||
|
"connection_type": "bond",
|
||||||
|
"source": "4306701a-6b1b-4d19-b8ff-45dbfa04f2d3",
|
||||||
|
"target": "0ac7d4ea-77f7-4c0d-8e37-406ef66e4740",
|
||||||
|
"causality": "flow_out",
|
||||||
|
"undesired": false
|
||||||
|
},
|
||||||
|
"68727b23-888b-45b4-a0f4-801ac7b39601": {
|
||||||
|
"connection_type": "bond",
|
||||||
|
"source": "4306701a-6b1b-4d19-b8ff-45dbfa04f2d3",
|
||||||
|
"target": "c87881f1-23b4-4a69-b586-8e3c7bf6e21e",
|
||||||
"causality": "flow_out",
|
"causality": "flow_out",
|
||||||
"undesired": false
|
"undesired": false
|
||||||
}
|
}
|
||||||
@@ -458,31 +458,6 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"77701f68-b14c-4b98-8929-c5fba0261962": {
|
|
||||||
"shapes": {
|
|
||||||
"2a5bf670-c821-442b-9d46-1b07faa70722": {
|
|
||||||
"layer": 0,
|
|
||||||
"type": "text",
|
|
||||||
"pos": [
|
|
||||||
-32,
|
|
||||||
-32
|
|
||||||
],
|
|
||||||
"width": 64.0,
|
|
||||||
"height": 64.0,
|
|
||||||
"color": "#000000ff",
|
|
||||||
"bold": true,
|
|
||||||
"italic": false,
|
|
||||||
"size": 64.0,
|
|
||||||
"text": "R"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"port_positions": {
|
|
||||||
"4a3f69f2-b305-4305-92e3-3998634ff226": [
|
|
||||||
-8,
|
|
||||||
-8
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"033b930e-bf79-403a-8b0b-a159f3c81ce9": {
|
"033b930e-bf79-403a-8b0b-a159f3c81ce9": {
|
||||||
"shapes": {
|
"shapes": {
|
||||||
"2a5bf670-c821-442b-9d46-1b07faa70722": {
|
"2a5bf670-c821-442b-9d46-1b07faa70722": {
|
||||||
@@ -586,6 +561,31 @@
|
|||||||
-8
|
-8
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"77701f68-b14c-4b98-8929-c5fba0261962": {
|
||||||
|
"shapes": {
|
||||||
|
"2a5bf670-c821-442b-9d46-1b07faa70722": {
|
||||||
|
"layer": 0,
|
||||||
|
"type": "text",
|
||||||
|
"pos": [
|
||||||
|
-32,
|
||||||
|
-32
|
||||||
|
],
|
||||||
|
"width": 64.0,
|
||||||
|
"height": 64.0,
|
||||||
|
"color": "#000000ff",
|
||||||
|
"bold": true,
|
||||||
|
"italic": false,
|
||||||
|
"size": 64.0,
|
||||||
|
"text": "R"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"port_positions": {
|
||||||
|
"4a3f69f2-b305-4305-92e3-3998634ff226": [
|
||||||
|
-8,
|
||||||
|
-8
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -622,6 +622,10 @@
|
|||||||
"033b930e-bf79-403a-8b0b-a159f3c81ce9": [
|
"033b930e-bf79-403a-8b0b-a159f3c81ce9": [
|
||||||
-192,
|
-192,
|
||||||
128
|
128
|
||||||
|
],
|
||||||
|
"1455acad-931b-4caa-be71-aff4283507c6": [
|
||||||
|
64,
|
||||||
|
384
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"connections": {
|
"connections": {
|
||||||
@@ -649,30 +653,6 @@
|
|||||||
]
|
]
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"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": {
|
"1f52dc5a-1847-4982-aabc-2538cf99a86f": {
|
||||||
"points": [
|
"points": [
|
||||||
[
|
[
|
||||||
@@ -684,18 +664,6 @@
|
|||||||
224
|
224
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
},
|
|
||||||
"5af42b61-2317-48a8-b32b-0e2dfd88368e": {
|
|
||||||
"points": [
|
|
||||||
[
|
|
||||||
-128,
|
|
||||||
-128
|
|
||||||
],
|
|
||||||
[
|
|
||||||
128,
|
|
||||||
160
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user