diff --git a/examples/BondGraphs.beb b/examples/BondGraphs.beb new file mode 100644 index 0000000..230332f Binary files /dev/null and b/examples/BondGraphs.beb differ diff --git a/lib/bondgraph.beb b/lib/bondgraph.beb index c5a1137..c11be35 100644 Binary files a/lib/bondgraph.beb and b/lib/bondgraph.beb differ diff --git a/lib/signal.beb b/lib/signal.beb index a9971a5..6a8eb59 100644 Binary files a/lib/signal.beb and b/lib/signal.beb differ diff --git a/lib/signal_sources.beb b/lib/signal_sources.beb index 985ddd8..2012e71 100644 Binary files a/lib/signal_sources.beb and b/lib/signal_sources.beb differ diff --git a/src/bedit_gui/commands/component_command.py b/src/bedit_gui/commands/component_command.py index 915fd4b..ba14278 100644 --- a/src/bedit_gui/commands/component_command.py +++ b/src/bedit_gui/commands/component_command.py @@ -1,7 +1,9 @@ +from copy import deepcopy + from PySide6.QtGui import QUndoCommand -from bedit_core.models import Component, ComponentID, Connection, ConnectionID, EquationImplementation, Graph, GraphImplementation, Interface -from bedit_gui.models import Icon +from bedit_core.models import Component, ComponentID, Connection, ConnectionID, EquationImplementation, Graph, GraphImplementation, Interface, PortID +from bedit_gui.models import Icon, PortMetadata class AddEmptyGraphComponent(QUndoCommand): @@ -62,10 +64,16 @@ class DeleteComponent(QUndoCommand): if connection.source in port_ids or connection.target in port_ids: self.connections.append((index, connection_id, connection)) self.icons = {} + self.port_metadata = {} for component_id in self._component_ids(self.component_id, component): icon = document.stored_component_icon(component_id) if icon is not None: self.icons[component_id] = icon + database = document._port_metadata_database(False) + if database is not None: + for port_id in self._port_ids(component): + if port_id in database.ports: + self.port_metadata[port_id] = deepcopy(database.ports[port_id]) def redo(self) -> None: if self.parent_graph is not None: @@ -75,6 +83,11 @@ class DeleteComponent(QUndoCommand): self.document.model_changed.emit(self.document.model) for component_id in self.icons: self.document._set_component_icon(component_id, None) + if self.port_metadata: + database = self.document.port_metadata_database() + for port_id in self.port_metadata: + database.ports.pop(port_id, None) + self.document._set_port_metadata_database(database) def undo(self) -> None: self._restore_item(self.components, self.component_id, self.component, self.component_index) @@ -84,6 +97,10 @@ class DeleteComponent(QUndoCommand): self.document.model_changed.emit(self.document.model) for component_id, icon in self.icons.items(): self.document._set_component_icon(component_id, icon) + if self.port_metadata: + database = self.document.port_metadata_database() + database.ports.update(deepcopy(self.port_metadata)) + self.document._set_port_metadata_database(database) @classmethod def _find_component(cls, components: dict[ComponentID, Component], target: Component, parent_graph: Graph | None = None) -> tuple[dict[ComponentID, Component], ComponentID, Graph | None]: @@ -105,6 +122,14 @@ class DeleteComponent(QUndoCommand): component_ids.extend(cls._component_ids(child_id, child)) return component_ids + @classmethod + def _port_ids(cls, component: Component) -> list[PortID]: + port_ids = list(component.interface.ports) + if isinstance(component.implementation, GraphImplementation): + for child in component.implementation.graph.components.values(): + port_ids.extend(cls._port_ids(child)) + return port_ids + @staticmethod def _restore_item(items: dict, item_id: object, item: object, index: int) -> None: values = list(items.items()) @@ -114,7 +139,7 @@ class DeleteComponent(QUndoCommand): class PasteComponents(QUndoCommand): - 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: + 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, port_metadata: dict[PortID, PortMetadata] | None = None) -> None: super().__init__("Paste components") self.document = document self.target = target @@ -122,6 +147,7 @@ class PasteComponents(QUndoCommand): self.icons = icons self.graph_id = graph_id self.positions = positions or {} + self.port_metadata = deepcopy(port_metadata or {}) def redo(self) -> None: self.target.update(self.components) @@ -131,6 +157,10 @@ class PasteComponents(QUndoCommand): self.document.model_changed.emit(self.document.model) for component_id, icon in self.icons.items(): self.document._set_component_icon(component_id, icon) + if self.port_metadata: + database = self.document.port_metadata_database() + database.ports.update(deepcopy(self.port_metadata)) + self.document._set_port_metadata_database(database) def undo(self) -> None: for component_id in self.components: @@ -141,3 +171,8 @@ class PasteComponents(QUndoCommand): self.document.model_changed.emit(self.document.model) for component_id in self.icons: self.document._set_component_icon(component_id, None) + if self.port_metadata: + database = self.document.port_metadata_database() + for port_id in self.port_metadata: + database.ports.pop(port_id, None) + self.document._set_port_metadata_database(database) diff --git a/src/bedit_gui/commands/port_metadata_command.py b/src/bedit_gui/commands/port_metadata_command.py new file mode 100644 index 0000000..78fdd01 --- /dev/null +++ b/src/bedit_gui/commands/port_metadata_command.py @@ -0,0 +1,19 @@ +from copy import deepcopy + +from PySide6.QtGui import QUndoCommand + +from bedit_gui.models import PortMetadataDatabase + + +class ChangePortMetadataDatabaseCommand(QUndoCommand): + def __init__(self, document: object, database: PortMetadataDatabase) -> None: + super().__init__("Change port metadata") + self.document = document + self.old_database = document.stored_port_metadata_database() + self.new_database = deepcopy(database) + + def redo(self) -> None: + self.document._set_port_metadata_database(self.new_database) + + def undo(self) -> None: + self.document._set_port_metadata_database(self.old_database) diff --git a/src/bedit_gui/controllers/clipboard_controller.py b/src/bedit_gui/controllers/clipboard_controller.py index 473d102..ced6127 100644 --- a/src/bedit_gui/controllers/clipboard_controller.py +++ b/src/bedit_gui/controllers/clipboard_controller.py @@ -133,11 +133,11 @@ class DocumentTreeClipboardHandler(ClipboardHandler): if target is None or payload is None: return try: - components, icons = import_components(payload) + components, icons, port_metadata = import_components(payload) except (TypeError, ValueError) as exc: QMessageBox.critical(self.tree, "Could not paste components", str(exc)) return - self.document.paste_components(target, components, icons) + self.document.paste_components(target, components, icons, port_metadata) def delete(self) -> None: self.document.delete_components(self._selected_components()) @@ -223,14 +223,14 @@ class GraphEditorClipboardHandler(ClipboardHandler): def _paste_payload(self, graph_component: Component, payload: dict, position: tuple[int, int]) -> None: try: - components, icons = import_components(payload) + components, icons, port_metadata = import_components(payload) except (TypeError, ValueError) as exc: QMessageBox.critical(self.editor, "Could not paste components", str(exc)) return x, y = 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) + self.document.paste_graph_components(graph_component, components, icons, positions, port_metadata) def delete(self) -> None: components = self._selected_components() diff --git a/src/bedit_gui/controllers/document_tree_controller.py b/src/bedit_gui/controllers/document_tree_controller.py index f8dbd78..5205178 100644 --- a/src/bedit_gui/controllers/document_tree_controller.py +++ b/src/bedit_gui/controllers/document_tree_controller.py @@ -9,7 +9,7 @@ from PySide6.QtWidgets import QAbstractItemView, QDialog, QHeaderView, QMenu from bedit_core.models import Component, ComponentID, ConnectionID, EquationImplementation, GraphImplementation, Port, PortID, Parameter, ParameterID from bedit_core.models import Document as CoreDocument from bedit_gui.documents import Document -from bedit_gui.models import Graph, GraphComponentLabel, Icon +from bedit_gui.models import Graph, GraphComponentLabel, Icon, PortMetadata from bedit_gui.views.dialogs.interface_editor_dialog import InterfaceEditorDialog from bedit_gui.views.dialogs.param_editor_dialog import ParamEditorDialog from bedit_gui.views.icon_editor_window import IconEditorWindow @@ -22,6 +22,7 @@ ICON_SIZE = QSize(16, 16) class InterfaceEditorLike(Protocol): def exec(self) -> int: ... def ports(self) -> dict[PortID, Port]: ... + def port_metadata(self) -> dict[PortID, PortMetadata]: ... class ParamEditorLike(Protocol): def exec(self) -> int: ... @@ -29,7 +30,7 @@ class ParamEditorLike(Protocol): InterfaceEditorFactory = Callable[ - [dict[PortID, Port], MainWindow], + [dict[PortID, Port], dict[PortID, PortMetadata], MainWindow], InterfaceEditorLike, ] @@ -60,15 +61,17 @@ class DocumentTreeController(QObject): window.ui.documentTree.setModel(self.model) window.ui.documentTree.selectionModel().selectionChanged.connect(self._selection_changed) window.equation_editor.equation_text_change_requested.connect(self.document.update_component_equation_text) + window.equation_editor.port_metadata_change_requested.connect(self._change_equation_port_metadata) document.model_changed.connect(self._on_document_changed) document.icon_changed.connect(self._on_icon_changed) + document.port_metadata_database_changed.connect(self._on_port_metadata_database_changed) document.graph_component_position_changed.connect(self._on_graph_component_position_changed) document.graph_component_label_changed.connect(self._on_graph_component_label_changed) document.graph_connection_points_changed.connect(self._on_graph_connection_points_changed) document.equation_text_changed.connect(self._on_equation_text_changed) self.model.rename_document_requested.connect(self.document.rename) self.model.rename_component_requested.connect(self.document.rename_component) - window.graph_editor.component_move_requested.connect(self.document.move_graph_component) + window.graph_editor.component_moves_requested.connect(self.document.move_graph_components) window.graph_editor.component_label_move_requested.connect(self.document.move_graph_component_label) 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) @@ -125,7 +128,8 @@ class DocumentTreeController(QObject): def _show_component(self, component: Component | None) -> None: if component is not None and isinstance(component.implementation, EquationImplementation): - self.window.equation_editor.set_component(component) + port_metadata = {port_id: self.document.port_metadata(port_id) for port_id in component.interface.ports} + self.window.equation_editor.set_component(component, port_metadata) self.window.equation_editor.show() else: self.window.equation_editor.set_component(None) @@ -133,7 +137,8 @@ class DocumentTreeController(QObject): if component is not None and isinstance(component.implementation, GraphImplementation): 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) + port_metadata = {port_id: self.document.port_metadata(port_id) for child in component.implementation.graph.components.values() for port_id in child.interface.ports} + self.window.graph_editor.set_component(component, graph, icons, port_metadata) self.window.graph_editor.show() else: self.window.graph_editor.set_component(None) @@ -152,6 +157,17 @@ class DocumentTreeController(QObject): 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 _on_port_metadata_database_changed(self, _database: object) -> None: + graph_component = self.window.graph_editor.component() + if graph_component is not None: + self._show_component(graph_component) + equation_component = self.window.equation_editor.component() + if equation_component is not None: + self.window.equation_editor.refresh_port_metadata({port_id: self.document.port_metadata(port_id) for port_id in equation_component.interface.ports}) + + def _change_equation_port_metadata(self, component: Component, port_metadata: dict[PortID, PortMetadata]) -> None: + self.document.update_component_ports(component, component.interface.ports, port_metadata) + def _on_graph_component_position_changed(self, graph_id: ComponentID, component_id: ComponentID, position: 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: @@ -242,10 +258,11 @@ class DocumentTreeController(QObject): def _edit_interface(self, component: Component) -> None: dialog = self.interface_editor_factory( component.interface.ports, + {port_id: self.document.port_metadata(port_id) for port_id in component.interface.ports}, self.window, ) if dialog.exec() == QDialog.DialogCode.Accepted: - self.document.update_component_ports(component, dialog.ports()) + self.document.update_component_ports(component, dialog.ports(), dialog.port_metadata()) def _edit_params(self, component: Component) -> None: dialog = self.param_editor_factory( diff --git a/src/bedit_gui/controllers/library_controller.py b/src/bedit_gui/controllers/library_controller.py index 026664d..821dfe0 100644 --- a/src/bedit_gui/controllers/library_controller.py +++ b/src/bedit_gui/controllers/library_controller.py @@ -1,8 +1,8 @@ from PySide6.QtCore import QObject, QSize, Qt from PySide6.QtWidgets import QAbstractItemView, QHeaderView -from bedit_core.models import Component, ComponentID, GraphImplementation -from bedit_gui.models import Icon, IconDatabase +from bedit_core.models import Component, ComponentID, GraphImplementation, PortID +from bedit_gui.models import Icon, IconDatabase, PortMetadata, PortMetadataDatabase from bedit_gui.services.application_settings import ApplicationSettings from bedit_gui.services.component_clipboard import export_component_data from bedit_gui.services.libraries import load_library_documents @@ -18,7 +18,7 @@ class LibraryController(QObject): super().__init__(window) self.window = window self.settings = settings - self._component_sources: dict[int, tuple[ComponentID, dict[ComponentID, Icon]]] = {} + self._component_sources: dict[int, tuple[ComponentID, dict[ComponentID, Icon], dict[PortID, PortMetadata]]] = {} self.model = LibraryTreeModel(self._component_payload) tree = window.ui.libraryTree @@ -44,7 +44,9 @@ class LibraryController(QObject): for library in libraries: database = library.document.metadata.get("icon_database") if library.document.metadata is not None else None icons = database.icons if isinstance(database, IconDatabase) else {} - self._collect_component_sources(library.document.root, icons) + metadata_database = library.document.metadata.get("port_metadata_database") if library.document.metadata is not None else None + port_metadata = metadata_database.ports if isinstance(metadata_database, PortMetadataDatabase) else {} + self._collect_component_sources(library.document.root, icons, port_metadata) self.model.set_documents([library.document for library in libraries]) for library in libraries: database = library.document.metadata.get("icon_database") if library.document.metadata is not None else None @@ -55,20 +57,22 @@ class LibraryController(QObject): def _component_payload(self, components: list[Component]) -> dict: roots = {} icons = {} + port_metadata = {} for component in components: source = self._component_sources.get(id(component)) if source is None: continue - component_id, source_icons = source + component_id, source_icons, source_port_metadata = source roots[component_id] = component icons.update(source_icons) - return export_component_data(roots, icons) + port_metadata.update(source_port_metadata) + return export_component_data(roots, icons, port_metadata) - def _collect_component_sources(self, components: dict[ComponentID, Component], icons: dict[ComponentID, Icon]) -> None: + def _collect_component_sources(self, components: dict[ComponentID, Component], icons: dict[ComponentID, Icon], port_metadata: dict[PortID, PortMetadata]) -> None: for component_id, component in components.items(): - self._component_sources[id(component)] = (component_id, icons) + self._component_sources[id(component)] = (component_id, icons, port_metadata) if isinstance(component.implementation, GraphImplementation): - self._collect_component_sources(component.implementation.graph.components, icons) + self._collect_component_sources(component.implementation.graph.components, icons, port_metadata) def _set_component_icons(self, components: dict[ComponentID, Component], icons: dict[ComponentID, Icon]) -> None: for component_id, component in components.items(): diff --git a/src/bedit_gui/documents/document.py b/src/bedit_gui/documents/document.py index c823b7f..0f18c58 100644 --- a/src/bedit_gui/documents/document.py +++ b/src/bedit_gui/documents/document.py @@ -17,12 +17,13 @@ from bedit_gui.commands.graph_connection_points_command import ChangeGraphConnec from bedit_gui.commands.graph_connection_command import AddGraphConnectionCommand, DeleteGraphConnectionCommand from bedit_gui.commands.graph_label_command import ChangeGraphComponentLabelCommand from bedit_gui.commands.port_commands import AddPortCommand, ChangePortCommand, RemovePortCommand +from bedit_gui.commands.port_metadata_command import ChangePortMetadataDatabaseCommand from bedit_gui.commands.param_commands import AddParamCommand, ChangeParamCommand, RemoveParamCommand from bedit_gui.commands.rename_component_command import RenameComponentCommand from bedit_gui.commands.rename_document_command import RenameDocumentCommand from bedit_gui.commands.simulation_database_command import ChangeSimulationDatabaseCommand from bedit_gui.commands.component_command import AddEmptyEquationComponent, AddEmptyGraphComponent, DeleteComponent, PasteComponents -from bedit_gui.models import Graph, GraphComponentLabel, GraphConnection, GraphDatabase, Icon, IconDatabase, Simulation, SimulationDatabase +from bedit_gui.models import Graph, GraphComponentLabel, GraphConnection, GraphDatabase, Icon, IconDatabase, PortMetadata, PortMetadataDatabase, Simulation, SimulationDatabase from bedit_gui.services import document_files @@ -35,6 +36,7 @@ class Document(QObject): icon_changed = Signal(object, object) equation_text_changed = Signal(object, str) simulation_database_changed = Signal(object) + port_metadata_database_changed = Signal(object) graph_component_position_changed = Signal(object, object, object) graph_component_label_changed = Signal(object, object, object) graph_connection_points_changed = Signal(object, object, object) @@ -140,6 +142,40 @@ class Document(QObject): database = self._graph_database(False) return deepcopy(database) if database is not None else GraphDatabase() + def stored_port_metadata_database(self) -> PortMetadataDatabase | None: + database = self._port_metadata_database(False) + return deepcopy(database) if database is not None else None + + def port_metadata_database(self) -> PortMetadataDatabase: + return self.stored_port_metadata_database() or PortMetadataDatabase() + + def port_metadata(self, port_id: PortID) -> PortMetadata: + return deepcopy(self.port_metadata_database().ports.get(port_id, PortMetadata())) + + def _set_port_metadata_database(self, database: PortMetadataDatabase | None) -> None: + if database is None or not database.ports: + if self.model.metadata is not None: + self.model.metadata.pop("port_metadata_database", None) + else: + if self.model.metadata is None: + self.model.metadata = {} + self.model.metadata["port_metadata_database"] = deepcopy(database) + self.port_metadata_database_changed.emit(self.stored_port_metadata_database()) + + def _port_metadata_database(self, create: bool) -> PortMetadataDatabase | None: + metadata = self.model.metadata + value = metadata.get("port_metadata_database") if metadata is not None else None + if isinstance(value, PortMetadataDatabase): + return value + if not create: + return None + if metadata is None: + metadata = {} + self.model.metadata = metadata + database = PortMetadataDatabase() + metadata["port_metadata_database"] = database + return database + def move_graph_component(self, graph_component: Component, component_id: ComponentID, position: tuple[int, int]) -> None: graph_id = self.component_id(graph_component) database = self._graph_database(False) @@ -147,6 +183,18 @@ class Document(QObject): if graph is None or graph.component_positions.get(component_id) != position: self.undo_stack.push(MoveGraphComponentCommand(self, graph_id, component_id, position)) + def move_graph_components(self, graph_component: Component, positions: dict[ComponentID, tuple[int, int]]) -> None: + graph_id = self.component_id(graph_component) + database = self._graph_database(False) + graph = database.graphs.get(graph_id) if database is not None else None + changed = {component_id: position for component_id, position in positions.items() if graph is None or graph.component_positions.get(component_id) != position} + if not changed: + return + self.undo_stack.beginMacro("Move graph components" if len(changed) > 1 else "Move graph component") + for component_id, position in changed.items(): + self.undo_stack.push(MoveGraphComponentCommand(self, graph_id, component_id, position)) + self.undo_stack.endMacro() + def _set_graph_component_position(self, graph_id: ComponentID, component_id: ComponentID, position: tuple[int, int] | None) -> None: if position is None: database = self._graph_database(False) @@ -309,7 +357,7 @@ class Document(QObject): metadata["icon_database"] = database return database - def update_component_ports(self, component: Component, ports: dict[PortID, Port]) -> None: + def update_component_ports(self, component: Component, ports: dict[PortID, Port], port_metadata: dict[PortID, PortMetadata] | None = None) -> None: current = component.interface.ports removed = [ RemovePortCommand(self, component, port_id) @@ -325,12 +373,23 @@ class Document(QObject): if current[port_id] != ports[port_id] ] commands = [*removed, *added, *changed] - if not commands: + database = self.port_metadata_database() + component_port_ids = set(current) | set(ports) + for port_id in component_port_ids: + metadata = port_metadata.get(port_id, PortMetadata()) if port_metadata is not None and port_id in ports else PortMetadata() + if metadata == PortMetadata(): + database.ports.pop(port_id, None) + else: + database.ports[port_id] = deepcopy(metadata) + metadata_changed = database != self.port_metadata_database() + if not commands and not metadata_changed: return self.undo_stack.beginMacro("Edit interface") for command in commands: self.undo_stack.push(command) + if metadata_changed: + self.undo_stack.push(ChangePortMetadataDatabaseCommand(self, database)) self.undo_stack.endMacro() @@ -396,16 +455,16 @@ class Document(QObject): self.undo_stack.push(DeleteComponent(self, component)) self.undo_stack.endMacro() - def paste_components(self, target: dict[ComponentID, Component], components: dict[ComponentID, Component], icons: dict[ComponentID, Icon]) -> None: + def paste_components(self, target: dict[ComponentID, Component], components: dict[ComponentID, Component], icons: dict[ComponentID, Icon], port_metadata: dict[PortID, PortMetadata] | None = None) -> None: if not components: return 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.undo_stack.push(PasteComponents(self, target, components, icons, port_metadata=port_metadata)) - def paste_graph_components(self, graph_component: Component, components: dict[ComponentID, Component], icons: dict[ComponentID, Icon], positions: dict[ComponentID, tuple[int, int]]) -> None: + def paste_graph_components(self, graph_component: Component, components: dict[ComponentID, Component], icons: dict[ComponentID, Icon], positions: dict[ComponentID, tuple[int, int]], port_metadata: dict[PortID, PortMetadata] | None = None) -> None: if not isinstance(graph_component.implementation, GraphImplementation) or not components: return target = graph_component.implementation.graph.components @@ -413,7 +472,7 @@ class Document(QObject): 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)) + self.undo_stack.push(PasteComponents(self, target, components, icons, self.component_id(graph_component), positions, port_metadata)) @staticmethod def _unique_component_name(components: dict[ComponentID, Component], name: str) -> str: diff --git a/src/bedit_gui/models.py b/src/bedit_gui/models.py index ae37934..8834269 100644 --- a/src/bedit_gui/models.py +++ b/src/bedit_gui/models.py @@ -143,6 +143,31 @@ class IconDatabase: def to_data(self) -> dict[str, Any]: return {"format_version": self.format_version, "icons": {str(key): icon.to_data() for key, icon in self.icons.items()}} +@dataclass +class PortMetadata: + connection_annotation: str | None = None + + @classmethod + def from_data(cls, data: Mapping[str, Any]) -> PortMetadata: + annotation = data.get("connection_annotation") + return cls(connection_annotation=str(annotation) if annotation else None) + + def to_data(self) -> dict[str, Any]: + return {"connection_annotation": self.connection_annotation} + +@dataclass +class PortMetadataDatabase: + format_version: int = 1 + ports: dict[PortID, PortMetadata] = field(default_factory=dict) + + @classmethod + def from_data(cls, data: Mapping[str, Any]) -> PortMetadataDatabase: + ports = {PortID(key): PortMetadata.from_data(value) for key, value in data.get("ports", {}).items()} + return cls(format_version=int(data.get("format_version", 1)), ports=ports) + + def to_data(self) -> dict[str, Any]: + return {"format_version": self.format_version, "ports": {str(key): metadata.to_data() for key, metadata in self.ports.items()}} + @dataclass class GraphConnection: points: list[tuple[int, int]] = field(default_factory=list) diff --git a/src/bedit_gui/services/component_clipboard.py b/src/bedit_gui/services/component_clipboard.py index 1c3fa6d..a14b114 100644 --- a/src/bedit_gui/services/component_clipboard.py +++ b/src/bedit_gui/services/component_clipboard.py @@ -6,7 +6,7 @@ from typing import Any from bedit_core.models import Component, ComponentID, ConnectionID, Document, GraphImplementation, ID, ParameterID, PortID from bedit_core.serialization.schema import document_from_data, document_to_data from bedit_gui.documents import Document as GuiDocument -from bedit_gui.models import Icon, ShapeID +from bedit_gui.models import Icon, PortMetadata, ShapeID FORMAT_VERSION = 1 COMPONENTS_MIME = "application/x-bedit-components+json" @@ -16,21 +16,27 @@ def export_components(document: GuiDocument, components: list[Component]) -> dic roots = {document.component_id(component): component for component in components} component_ids = _all_component_ids(roots) icons = {} + port_metadata = {} for component_id in component_ids: icon = document.stored_component_icon(component_id) if icon is not None: icons[component_id] = icon - return export_component_data(roots, icons) + database = document.port_metadata_database() + for port_id in _all_port_ids(roots): + if port_id in database.ports: + port_metadata[port_id] = database.ports[port_id] + return export_component_data(roots, icons, port_metadata) -def export_component_data(components: dict[ComponentID, Component], icons: dict[ComponentID, Icon]) -> dict[str, Any]: +def export_component_data(components: dict[ComponentID, Component], icons: dict[ComponentID, Icon], port_metadata: dict[PortID, PortMetadata] | None = None) -> dict[str, Any]: serialized = document_to_data(Document(format_version=1, id=ID(), name="Clipboard", root=components)) component_ids = set(_all_component_ids(components)) icon_data = {str(component_id): icon.to_data() for component_id, icon in icons.items() if component_id in component_ids} - return {"format_version": FORMAT_VERSION, "type": "components", "components": serialized["root"], "icons": icon_data} + metadata_data = {str(port_id): metadata.to_data() for port_id, metadata in (port_metadata or {}).items() if port_id in set(_all_port_ids(components))} + return {"format_version": FORMAT_VERSION, "type": "components", "components": serialized["root"], "icons": icon_data, "port_metadata": metadata_data} -def import_components(payload: dict[str, Any]) -> tuple[dict[ComponentID, Component], dict[ComponentID, Icon]]: +def import_components(payload: dict[str, Any]) -> tuple[dict[ComponentID, Component], dict[ComponentID, Icon], dict[PortID, PortMetadata]]: if payload.get("format_version") != FORMAT_VERSION or payload.get("type") != "components": raise ValueError("unsupported component clipboard format") components = payload.get("components") @@ -52,7 +58,11 @@ def import_components(payload: dict[str, Any]) -> tuple[dict[ComponentID, Compon icon.shapes = {ShapeID(): shape for shape in icon.shapes.values()} icon.port_positions = {port_map[port_id]: position for port_id, position in icon.port_positions.items() if port_id in port_map} icons[new_component_id] = icon - return remapped, icons + metadata_data = payload.get("port_metadata", {}) + if not isinstance(metadata_data, dict): + raise TypeError("component clipboard port metadata must be an object") + port_metadata = {port_map[PortID(old_id)]: PortMetadata.from_data(data) for old_id, data in metadata_data.items() if PortID(old_id) in port_map and isinstance(data, dict)} + return remapped, icons, port_metadata def _remap_components(components: dict[ComponentID, Component], component_map: dict[ComponentID, ComponentID], port_map: dict[PortID, PortID]) -> dict[ComponentID, Component]: @@ -87,3 +97,12 @@ def _all_component_ids(components: dict[ComponentID, Component]) -> list[Compone if isinstance(component.implementation, GraphImplementation): component_ids.extend(_all_component_ids(component.implementation.graph.components)) return component_ids + + +def _all_port_ids(components: dict[ComponentID, Component]) -> list[PortID]: + port_ids: list[PortID] = [] + for component in components.values(): + port_ids.extend(component.interface.ports) + if isinstance(component.implementation, GraphImplementation): + port_ids.extend(_all_port_ids(component.implementation.graph.components)) + return port_ids diff --git a/src/bedit_gui/services/document_files.py b/src/bedit_gui/services/document_files.py index 8bdc676..cbafac2 100644 --- a/src/bedit_gui/services/document_files.py +++ b/src/bedit_gui/services/document_files.py @@ -6,7 +6,7 @@ from pathlib import Path from bedit_core.models import Document from bedit_core.serialization import load as load_document from bedit_core.serialization import save as save_document -from bedit_gui.models import GraphDatabase, IconDatabase, SimulationDatabase +from bedit_gui.models import GraphDatabase, IconDatabase, PortMetadataDatabase, SimulationDatabase def load(path: str | Path) -> Document: @@ -18,6 +18,8 @@ def load(path: str | Path) -> Document: document.metadata["graph_database"] = GraphDatabase.from_data(document.metadata["graph_database"]) if document.metadata is not None and isinstance(document.metadata.get("simulation_database"), dict): document.metadata["simulation_database"] = SimulationDatabase.from_data(document.metadata["simulation_database"]) + if document.metadata is not None and isinstance(document.metadata.get("port_metadata_database"), dict): + document.metadata["port_metadata_database"] = PortMetadataDatabase.from_data(document.metadata["port_metadata_database"]) return document @@ -30,4 +32,6 @@ def save(document: Document, path: str | Path) -> None: saved_document.metadata["graph_database"] = saved_document.metadata["graph_database"].to_data() if saved_document.metadata is not None and isinstance(saved_document.metadata.get("simulation_database"), SimulationDatabase): saved_document.metadata["simulation_database"] = saved_document.metadata["simulation_database"].to_data() + if saved_document.metadata is not None and isinstance(saved_document.metadata.get("port_metadata_database"), PortMetadataDatabase): + saved_document.metadata["port_metadata_database"] = saved_document.metadata["port_metadata_database"].to_data() save_document(saved_document, path) diff --git a/src/bedit_gui/ui/forms/port_editor_widget.ui b/src/bedit_gui/ui/forms/port_editor_widget.ui index 869d71f..da3fe60 100644 --- a/src/bedit_gui/ui/forms/port_editor_widget.ui +++ b/src/bedit_gui/ui/forms/port_editor_widget.ui @@ -148,6 +148,20 @@ + + + + Connection annotation: + + + + + + + For example, + or - + + + diff --git a/src/bedit_gui/ui/forms/port_editor_widget_ui.py b/src/bedit_gui/ui/forms/port_editor_widget_ui.py new file mode 100644 index 0000000..0b33c33 --- /dev/null +++ b/src/bedit_gui/ui/forms/port_editor_widget_ui.py @@ -0,0 +1,268 @@ +# -*- coding: utf-8 -*- + +################################################################################ +## Form generated from reading UI file 'port_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 (QBrush, QColor, QConicalGradient, QCursor, + QFont, QFontDatabase, QGradient, QIcon, + QImage, QKeySequence, QLinearGradient, QPainter, + QPalette, QPixmap, QRadialGradient, QTransform) +from PySide6.QtWidgets import (QApplication, QCheckBox, QComboBox, QFormLayout, + QFrame, QHBoxLayout, QLabel, QLineEdit, + QListView, QPlainTextEdit, QPushButton, QRadioButton, + QSizePolicy, QSpacerItem, QSpinBox, QVBoxLayout, + QWidget) + +class Ui_PortEditor(object): + def setupUi(self, PortEditor): + if not PortEditor.objectName(): + PortEditor.setObjectName(u"PortEditor") + PortEditor.resize(541, 420) + self.horizontalLayout_2 = QHBoxLayout(PortEditor) + self.horizontalLayout_2.setObjectName(u"horizontalLayout_2") + self.leftColumn = QVBoxLayout() + self.leftColumn.setObjectName(u"leftColumn") + self.portList = QListView(PortEditor) + self.portList.setObjectName(u"portList") + + self.leftColumn.addWidget(self.portList) + + self.buttonRow = QHBoxLayout() + self.buttonRow.setObjectName(u"buttonRow") + self.addPort = QPushButton(PortEditor) + self.addPort.setObjectName(u"addPort") + + self.buttonRow.addWidget(self.addPort) + + self.removePort = QPushButton(PortEditor) + self.removePort.setObjectName(u"removePort") + + self.buttonRow.addWidget(self.removePort) + + + self.leftColumn.addLayout(self.buttonRow) + + + self.horizontalLayout_2.addLayout(self.leftColumn) + + self.rightColumn = QVBoxLayout() + self.rightColumn.setObjectName(u"rightColumn") + self.basicForm = QFormLayout() + self.basicForm.setObjectName(u"basicForm") + self.nameLabel = QLabel(PortEditor) + self.nameLabel.setObjectName(u"nameLabel") + + self.basicForm.setWidget(0, QFormLayout.ItemRole.LabelRole, self.nameLabel) + + self.nameEdit = QLineEdit(PortEditor) + self.nameEdit.setObjectName(u"nameEdit") + + self.basicForm.setWidget(0, QFormLayout.ItemRole.FieldRole, self.nameEdit) + + self.typeLabel = QLabel(PortEditor) + self.typeLabel.setObjectName(u"typeLabel") + + self.basicForm.setWidget(1, QFormLayout.ItemRole.LabelRole, self.typeLabel) + + self.typeRow = QHBoxLayout() + self.typeRow.setObjectName(u"typeRow") + self.typeSignal = QRadioButton(PortEditor) + self.typeSignal.setObjectName(u"typeSignal") + + self.typeRow.addWidget(self.typeSignal) + + self.typeBond = QRadioButton(PortEditor) + self.typeBond.setObjectName(u"typeBond") + + self.typeRow.addWidget(self.typeBond) + + + self.basicForm.setLayout(1, QFormLayout.ItemRole.FieldRole, self.typeRow) + + self.orientationLabel = QLabel(PortEditor) + self.orientationLabel.setObjectName(u"orientationLabel") + + self.basicForm.setWidget(2, QFormLayout.ItemRole.LabelRole, self.orientationLabel) + + self.orientationRow = QHBoxLayout() + self.orientationRow.setObjectName(u"orientationRow") + self.inputOrientation = QRadioButton(PortEditor) + self.inputOrientation.setObjectName(u"inputOrientation") + + self.orientationRow.addWidget(self.inputOrientation) + + self.outputOrientation = QRadioButton(PortEditor) + self.outputOrientation.setObjectName(u"outputOrientation") + + self.orientationRow.addWidget(self.outputOrientation) + + + self.basicForm.setLayout(2, QFormLayout.ItemRole.FieldRole, self.orientationRow) + + self.sizeLabel = QLabel(PortEditor) + self.sizeLabel.setObjectName(u"sizeLabel") + + self.basicForm.setWidget(3, QFormLayout.ItemRole.LabelRole, self.sizeLabel) + + self.sizeRow = QHBoxLayout() + self.sizeRow.setObjectName(u"sizeRow") + self.widthSize = QSpinBox(PortEditor) + self.widthSize.setObjectName(u"widthSize") + self.widthSize.setMinimum(1) + + self.sizeRow.addWidget(self.widthSize) + + self.heightSize = QSpinBox(PortEditor) + self.heightSize.setObjectName(u"heightSize") + self.heightSize.setMinimum(1) + + self.sizeRow.addWidget(self.heightSize) + + + self.basicForm.setLayout(3, QFormLayout.ItemRole.FieldRole, self.sizeRow) + + self.domainLabel = QLabel(PortEditor) + self.domainLabel.setObjectName(u"domainLabel") + + self.basicForm.setWidget(4, QFormLayout.ItemRole.LabelRole, self.domainLabel) + + self.multiplicityCheckBox = QCheckBox(PortEditor) + self.multiplicityCheckBox.setObjectName(u"multiplicityCheckBox") + + self.basicForm.setWidget(4, QFormLayout.ItemRole.FieldRole, self.multiplicityCheckBox) + + self.connectionAnnotationLabel = QLabel(PortEditor) + self.connectionAnnotationLabel.setObjectName(u"connectionAnnotationLabel") + + self.basicForm.setWidget(5, QFormLayout.ItemRole.LabelRole, self.connectionAnnotationLabel) + + self.connectionAnnotationEdit = QLineEdit(PortEditor) + self.connectionAnnotationEdit.setObjectName(u"connectionAnnotationEdit") + + self.basicForm.setWidget(5, QFormLayout.ItemRole.FieldRole, self.connectionAnnotationEdit) + + + self.rightColumn.addLayout(self.basicForm) + + self.line = QFrame(PortEditor) + self.line.setObjectName(u"line") + self.line.setFrameShape(QFrame.Shape.HLine) + self.line.setFrameShadow(QFrame.Shadow.Sunken) + + self.rightColumn.addWidget(self.line) + + self.signalOptions = QFormLayout() + self.signalOptions.setObjectName(u"signalOptions") + self.signalTypeLabel = QLabel(PortEditor) + self.signalTypeLabel.setObjectName(u"signalTypeLabel") + + self.signalOptions.setWidget(0, QFormLayout.ItemRole.LabelRole, self.signalTypeLabel) + + self.signalTypeComboBox = QComboBox(PortEditor) + self.signalTypeComboBox.setObjectName(u"signalTypeComboBox") + + self.signalOptions.setWidget(0, QFormLayout.ItemRole.FieldRole, self.signalTypeComboBox) + + + self.rightColumn.addLayout(self.signalOptions) + + self.bondOptions = QFormLayout() + self.bondOptions.setObjectName(u"bondOptions") + self.domainLabel_2 = QLabel(PortEditor) + self.domainLabel_2.setObjectName(u"domainLabel_2") + + self.bondOptions.setWidget(0, QFormLayout.ItemRole.LabelRole, self.domainLabel_2) + + self.domainComboBox = QComboBox(PortEditor) + self.domainComboBox.setObjectName(u"domainComboBox") + + self.bondOptions.setWidget(0, QFormLayout.ItemRole.FieldRole, self.domainComboBox) + + self.causalityLabel = QLabel(PortEditor) + self.causalityLabel.setObjectName(u"causalityLabel") + + self.bondOptions.setWidget(1, QFormLayout.ItemRole.LabelRole, self.causalityLabel) + + self.causalityComboBox = QComboBox(PortEditor) + self.causalityComboBox.setObjectName(u"causalityComboBox") + + self.bondOptions.setWidget(1, QFormLayout.ItemRole.FieldRole, self.causalityComboBox) + + + self.rightColumn.addLayout(self.bondOptions) + + self.verticalSpacer = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding) + + self.rightColumn.addItem(self.verticalSpacer) + + self.line_2 = QFrame(PortEditor) + self.line_2.setObjectName(u"line_2") + self.line_2.setFrameShape(QFrame.Shape.HLine) + self.line_2.setFrameShadow(QFrame.Shadow.Sunken) + + self.rightColumn.addWidget(self.line_2) + + self.descriptionForm = QFormLayout() + self.descriptionForm.setObjectName(u"descriptionForm") + self.descriptionForm.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) + self.descriptionLabel = QLabel(PortEditor) + self.descriptionLabel.setObjectName(u"descriptionLabel") + + self.descriptionForm.setWidget(0, QFormLayout.ItemRole.LabelRole, self.descriptionLabel) + + self.descriptionEdit = QPlainTextEdit(PortEditor) + self.descriptionEdit.setObjectName(u"descriptionEdit") + sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.MinimumExpanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.descriptionEdit.sizePolicy().hasHeightForWidth()) + self.descriptionEdit.setSizePolicy(sizePolicy) + self.descriptionEdit.setMinimumSize(QSize(0, 20)) + self.descriptionEdit.setMaximumSize(QSize(16777215, 60)) + + self.descriptionForm.setWidget(0, QFormLayout.ItemRole.FieldRole, self.descriptionEdit) + + + self.rightColumn.addLayout(self.descriptionForm) + + + self.horizontalLayout_2.addLayout(self.rightColumn) + + + self.retranslateUi(PortEditor) + + QMetaObject.connectSlotsByName(PortEditor) + # setupUi + + def retranslateUi(self, PortEditor): + PortEditor.setWindowTitle(QCoreApplication.translate("PortEditor", u"Form", None)) + self.addPort.setText(QCoreApplication.translate("PortEditor", u"Add Port", None)) + self.removePort.setText(QCoreApplication.translate("PortEditor", u"Remove Port", None)) + self.nameLabel.setText(QCoreApplication.translate("PortEditor", u"Name:", None)) + self.typeLabel.setText(QCoreApplication.translate("PortEditor", u"Type:", None)) + self.typeSignal.setText(QCoreApplication.translate("PortEditor", u"Signal", None)) + self.typeBond.setText(QCoreApplication.translate("PortEditor", u"Power Bond", None)) + self.orientationLabel.setText(QCoreApplication.translate("PortEditor", u"Orientation:", None)) + self.inputOrientation.setText(QCoreApplication.translate("PortEditor", u"Input", None)) + self.outputOrientation.setText(QCoreApplication.translate("PortEditor", u"Output", None)) + self.sizeLabel.setText(QCoreApplication.translate("PortEditor", u"Size", None)) + self.widthSize.setSuffix(QCoreApplication.translate("PortEditor", u" rows", None)) + self.heightSize.setSuffix(QCoreApplication.translate("PortEditor", u" columns", None)) + self.domainLabel.setText("") + self.multiplicityCheckBox.setText(QCoreApplication.translate("PortEditor", u"Allow multiple connections", None)) + self.connectionAnnotationLabel.setText(QCoreApplication.translate("PortEditor", u"Connection annotation:", None)) + self.connectionAnnotationEdit.setPlaceholderText(QCoreApplication.translate("PortEditor", u"For example, + or -", None)) + self.signalTypeLabel.setText(QCoreApplication.translate("PortEditor", u"Signal Type:", None)) + self.domainLabel_2.setText(QCoreApplication.translate("PortEditor", u"Domain:", None)) + self.causalityLabel.setText(QCoreApplication.translate("PortEditor", u"Causality:", None)) + self.descriptionLabel.setText(QCoreApplication.translate("PortEditor", u"Description:", None)) + # retranslateUi + diff --git a/src/bedit_gui/views/dialogs/interface_editor_dialog.py b/src/bedit_gui/views/dialogs/interface_editor_dialog.py index 2877f10..82f7747 100644 --- a/src/bedit_gui/views/dialogs/interface_editor_dialog.py +++ b/src/bedit_gui/views/dialogs/interface_editor_dialog.py @@ -8,6 +8,7 @@ from PySide6.QtWidgets import ( ) from bedit_core.models import Port, PortID +from bedit_gui.models import PortMetadata from bedit_gui.views.port_editor_widget import PortEditorWidget @@ -17,6 +18,7 @@ class InterfaceEditorDialog(QDialog): def __init__( self, ports: dict[PortID, Port], + port_metadata: dict[PortID, PortMetadata] | None = None, parent: QWidget | None = None, ) -> None: super().__init__(parent) @@ -25,7 +27,7 @@ class InterfaceEditorDialog(QDialog): layout = QVBoxLayout(self) self.editor = PortEditorWidget(self) - self.editor.set_ports(ports) + self.editor.set_ports(ports, port_metadata) layout.addWidget(self.editor) buttons = QDialogButtonBox( @@ -38,3 +40,6 @@ class InterfaceEditorDialog(QDialog): def ports(self) -> dict[PortID, Port]: return self.editor.ports() + + def port_metadata(self) -> dict[PortID, PortMetadata]: + return self.editor.port_metadata() diff --git a/src/bedit_gui/views/equation_editor_widget.py b/src/bedit_gui/views/equation_editor_widget.py index 0ae8a6f..481f183 100644 --- a/src/bedit_gui/views/equation_editor_widget.py +++ b/src/bedit_gui/views/equation_editor_widget.py @@ -3,7 +3,8 @@ from __future__ import annotations from PySide6.QtCore import QEvent, QObject, QTimer, Qt, Signal from PySide6.QtWidgets import QWidget -from bedit_core.models import Component, EquationImplementation +from bedit_core.models import Component, EquationImplementation, PortID +from bedit_gui.models import PortMetadata from bedit_gui.ui.generated.ui_equation_editor_widget import Ui_equationEditorWidget from bedit_gui.views.param_editor_widget import ParamEditorWidget from bedit_gui.views.port_editor_widget import PortEditorWidget @@ -14,6 +15,7 @@ class EquationEditorWidget(QWidget): component_changed = Signal(object) equation_text_change_requested = Signal(object, str, object, int) + port_metadata_change_requested = Signal(object, object) sidebar_visible_changed = Signal(bool) def __init__(self, parent: QWidget | None = None) -> None: @@ -22,6 +24,7 @@ class EquationEditorWidget(QWidget): self.ui = Ui_equationEditorWidget() self.ui.setupUi(self) self._component: Component | None = None + self._port_metadata: dict[PortID, PortMetadata] = {} self._loading = False self._edit_id = 0 self._edit_timer = QTimer(self) @@ -45,11 +48,12 @@ class EquationEditorWidget(QWidget): self._set_editors_enabled(False) - def set_component(self, component: Component | None) -> None: + def set_component(self, component: Component | None, port_metadata: dict[PortID, PortMetadata] | None = None) -> None: if component is not None and not isinstance(component.implementation, EquationImplementation): raise TypeError("EquationEditorWidget only supports components with an equation implementation") self._component = component + self._port_metadata = port_metadata or {} self.refresh() def component(self) -> Component | None: @@ -88,7 +92,7 @@ class EquationEditorWidget(QWidget): self.ui.initialEquationsTextEdit.setPlainText("\n".join(implementation.initial_equations)) self.ui.equationsTextEdit.setPlainText("\n".join(implementation.equations)) self.param_editor.set_params(component.parameters) - self.port_editor.set_ports(component.interface.ports) + self.port_editor.set_ports(component.interface.ports, self._port_metadata) self._loading = False self._set_editors_enabled(component is not None) @@ -145,8 +149,21 @@ class EquationEditorWidget(QWidget): def _ports_changed(self) -> None: if self._loading or self._component is None: return - self._component.interface.ports = self.port_editor.ports() - self.component_changed.emit(self._component) + ports = self.port_editor.ports() + metadata = self.port_editor.port_metadata() + if ports != self._component.interface.ports: + self._component.interface.ports = ports + self.component_changed.emit(self._component) + if metadata != self._port_metadata: + self._port_metadata = metadata + self.port_metadata_change_requested.emit(self._component, metadata) + + def refresh_port_metadata(self, port_metadata: dict[PortID, PortMetadata]) -> None: + if port_metadata == self._port_metadata: + return + self._port_metadata = port_metadata + if self._component is not None: + self.port_editor.set_ports(self._component.interface.ports, port_metadata) def _replace_placeholder(self, placeholder: QWidget, editor: QWidget) -> None: self.ui.verticalLayout_2.replaceWidget(placeholder, editor) diff --git a/src/bedit_gui/views/graph_editor_widget.py b/src/bedit_gui/views/graph_editor_widget.py index d0e8c8e..eb67ad7 100644 --- a/src/bedit_gui/views/graph_editor_widget.py +++ b/src/bedit_gui/views/graph_editor_widget.py @@ -10,7 +10,7 @@ from PySide6.QtGui import QActionGroup, QBrush, QColor, QCursor, QDragEnterEvent from PySide6.QtWidgets import QGraphicsEllipseItem, QGraphicsItem, QGraphicsPathItem, QGraphicsPixmapItem, QGraphicsScene, QGraphicsSceneMouseEvent, QGraphicsTextItem, QGraphicsView, QMenu, QWidget from bedit_core.models import BondCausality, BondConnection, BondPort, Component, ComponentID, Connection, ConnectionID, GraphImplementation, Port, PortID, SignalConnection, SignalDirection, SignalPort -from bedit_gui.models import Graph, GraphComponentLabel, GraphConnection, Icon +from bedit_gui.models import Graph, GraphComponentLabel, GraphConnection, Icon, PortMetadata from bedit_gui.services.component_clipboard import COMPONENTS_MIME from bedit_gui.ui.generated.ui_graph_editor_widget import Ui_graphEditorWidget from bedit_gui.utils.icon import get_natural_icon_size, get_pixmap_bounding_box, render_icon @@ -34,6 +34,9 @@ ARROW_HALF_WIDTH = 16.0 SIGNAL_ARROW_LENGTH = ARROW_LENGTH / 2 SIGNAL_ARROW_HALF_WIDTH = ARROW_HALF_WIDTH / 2 CAUSALITY_TICK_HALF_LENGTH = 16.0 +CONNECTION_ANNOTATION_FONT_SIZE = 18.0 +CONNECTION_ANNOTATION_BACK_OFFSET = 24.0 +CONNECTION_ANNOTATION_SIDE_OFFSET = 14.0 class GraphEditorMode(Enum): @@ -152,7 +155,7 @@ class GraphComponentItem(QGraphicsPixmapItem): 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): + if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange and self.component_id in self.editor._component_drag_starts and isinstance(value, QPointF): grid_size = self.editor.snap_to_grid_size value = QPointF(round(value.x() / grid_size) * grid_size, round(value.y() / grid_size) * grid_size) result = super().itemChange(change, value) @@ -167,6 +170,7 @@ class GraphComponentItem(QGraphicsPixmapItem): self._drag_start = QPointF(self.pos()) self._dragging = True super().mousePressEvent(event) + self.editor.begin_component_move() def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: if self.editor.mode is GraphEditorMode.CONNECTION: @@ -176,10 +180,7 @@ class GraphComponentItem(QGraphicsPixmapItem): 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) + self.editor.finish_component_moves() def mouseDoubleClickEvent(self, event: QGraphicsSceneMouseEvent) -> None: if event.button() == Qt.MouseButton.LeftButton: @@ -295,7 +296,7 @@ class GraphConnectionPointItem(QGraphicsEllipseItem): class GraphEditorWidget(QWidget): - component_move_requested = Signal(object, object, object) + component_moves_requested = Signal(object, object) component_context_menu_requested = Signal(object, object) component_open_requested = Signal(object) component_label_move_requested = Signal(object, object, object) @@ -312,14 +313,17 @@ class GraphEditorWidget(QWidget): self._component: Component | None = None self._graph = Graph() self._icons: dict[ComponentID, Icon] = {} + self._port_metadata: dict[PortID, PortMetadata] = {} self._mode = GraphEditorMode.NORMAL self._connection_start: ComponentID | None = None self._connection_preview: QGraphicsPathItem | None = None self._component_items: dict[ComponentID, GraphComponentItem] = {} + self._component_drag_starts: dict[ComponentID, tuple[int, int]] = {} self._component_bounds: dict[ComponentID, QRectF] = {} self._component_label_items: dict[ComponentID, GraphComponentLabelItem] = {} self._connection_items: dict[ConnectionID, GraphConnectionItem] = {} self._connection_point_items: dict[ConnectionID, list[GraphConnectionPointItem]] = {} + self._connection_annotation_items: dict[ConnectionID, QGraphicsTextItem] = {} self.set_snap_to_grid_size(snap_to_grid_size) self.scene = GraphGraphicsScene(self) self.scene.setSceneRect(-SCENE_SIZE / 2, -SCENE_SIZE / 2, SCENE_SIZE, SCENE_SIZE) @@ -364,12 +368,12 @@ class GraphEditorWidget(QWidget): self._clear_connection_start() component = self._component if component is not None: - self.set_component(component, self._graph, self._icons) + self.set_component(component, self._graph, self._icons, self._port_metadata) 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, port_metadata: dict[PortID, PortMetadata] | None = None) -> None: if component is not None and not isinstance(component.implementation, GraphImplementation): raise TypeError("GraphEditorWidget only supports components with a graph implementation") component_changed = component is not self._component @@ -377,11 +381,14 @@ class GraphEditorWidget(QWidget): self._component = component self._graph = graph or Graph() self._icons = icons or {} + self._port_metadata = port_metadata or {} + self._component_drag_starts = {} self._component_items = {} self._component_bounds = {} self._component_label_items = {} self._connection_items = {} self._connection_point_items = {} + self._connection_annotation_items = {} self.scene.clear() if component is None: return @@ -420,6 +427,17 @@ class GraphEditorWidget(QWidget): connection_item.setData(0, str(connection_id)) self._connection_items[connection_id] = connection_item self.scene.addItem(connection_item) + annotation = self._port_metadata.get(connection.target, PortMetadata()).connection_annotation if isinstance(connection, SignalConnection) else None + if annotation: + annotation_item = QGraphicsTextItem(annotation) + font = annotation_item.font() + font.setBold(True) + font.setPointSizeF(CONNECTION_ANNOTATION_FONT_SIZE) + annotation_item.setFont(font) + annotation_item.setDefaultTextColor(QColor(SIGNAL_CONNECTION_COLOR)) + annotation_item.setZValue(1) + self._connection_annotation_items[connection_id] = annotation_item + self.scene.addItem(annotation_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() @@ -452,6 +470,23 @@ class GraphEditorWidget(QWidget): if isinstance(connection, BondConnection): 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)) + annotation_item = self._connection_annotation_items.get(connection_id) + if annotation_item is not None: + self._position_connection_annotation(annotation_item, points) + + @staticmethod + def _position_connection_annotation(item: QGraphicsTextItem, points: list[tuple[float, float]]) -> None: + target = QPointF(*points[-1]) + previous = next((QPointF(*point) for point in reversed(points[:-1]) if QPointF(*point) != target), None) + if previous is None: + return + dx = target.x() - previous.x() + dy = target.y() - previous.y() + length = hypot(dx, dy) + x = target.x() - CONNECTION_ANNOTATION_BACK_OFFSET * dx / length - CONNECTION_ANNOTATION_SIDE_OFFSET * dy / length + y = target.y() - CONNECTION_ANNOTATION_BACK_OFFSET * dy / length + CONNECTION_ANNOTATION_SIDE_OFFSET * dx / length + bounds = item.boundingRect() + item.setPos(x - bounds.width() / 2, y - bounds.height() / 2) def add_connection_point(self, connection_id: ConnectionID, scene_position: QPointF) -> None: points = self._connection_metadata_points(connection_id) @@ -477,6 +512,9 @@ class GraphEditorWidget(QWidget): connection_item = self._connection_items.pop(connection_id, None) if connection_item is not None: self.scene.removeItem(connection_item) + annotation_item = self._connection_annotation_items.pop(connection_id, None) + if annotation_item is not None: + self.scene.removeItem(annotation_item) return self._graph.connections[connection_id] = GraphConnection(points=list(points)) interior_points = points[1:-1] @@ -532,9 +570,23 @@ class GraphEditorWidget(QWidget): best_distance = distance return best_index - def finish_component_move(self, component_id: ComponentID, position: tuple[int, int]) -> None: - if self._component is not None: - self.component_move_requested.emit(self._component, component_id, position) + def begin_component_move(self) -> None: + self._component_drag_starts = {item.component_id: (round(item.pos().x()), round(item.pos().y())) for item in self.scene.selectedItems() if isinstance(item, GraphComponentItem)} + + def finish_component_moves(self) -> None: + starts = self._component_drag_starts + self._component_drag_starts = {} + if self._component is None: + return + positions = {} + for component_id, old_position in starts.items(): + item = self._component_items[component_id] + position = (round(item.pos().x()), round(item.pos().y())) + item.setPos(*position) + if position != old_position: + positions[component_id] = position + if positions: + self.component_moves_requested.emit(self._component, positions) def finish_component_label_move(self, component_id: ComponentID, relative_position: tuple[int, int]) -> None: if self._component is not None: diff --git a/src/bedit_gui/views/port_editor_widget.py b/src/bedit_gui/views/port_editor_widget.py index 90a64d7..a9a45ff 100644 --- a/src/bedit_gui/views/port_editor_widget.py +++ b/src/bedit_gui/views/port_editor_widget.py @@ -7,6 +7,7 @@ from PySide6.QtGui import QStandardItem, QStandardItemModel from PySide6.QtWidgets import QButtonGroup, QLayout, QWidget from bedit_core.models import BondPort, Port, PortCausality, PortID, SignalDirection, SignalPort, ValueType +from bedit_gui.models import PortMetadata from bedit_gui.ui.generated.ui_port_editor_widget import Ui_PortEditor @@ -21,6 +22,7 @@ class PortEditorWidget(QWidget): self.ui = Ui_PortEditor() self.ui.setupUi(self) self._ports: dict[PortID, Port] = {} + self._port_metadata: dict[PortID, PortMetadata] = {} self._port_ids: list[PortID] = [] self._loading = False @@ -54,6 +56,7 @@ class PortEditorWidget(QWidget): self.ui.widthSize.valueChanged.connect(self._form_changed) self.ui.heightSize.valueChanged.connect(self._form_changed) self.ui.multiplicityCheckBox.toggled.connect(self._form_changed) + self.ui.connectionAnnotationEdit.textChanged.connect(self._form_changed) self.ui.signalTypeComboBox.currentIndexChanged.connect(self._form_changed) self.ui.domainComboBox.currentTextChanged.connect(self._form_changed) self.ui.causalityComboBox.currentIndexChanged.connect(self._form_changed) @@ -62,14 +65,18 @@ class PortEditorWidget(QWidget): self._set_editor_enabled(False) self._update_option_visibility() - def set_ports(self, ports: dict[PortID, Port]) -> None: + def set_ports(self, ports: dict[PortID, Port], port_metadata: dict[PortID, PortMetadata] | None = None) -> None: self._ports = deepcopy(ports) + self._port_metadata = deepcopy(port_metadata or {}) self._port_ids = list(self._ports) self._rebuild_list() def ports(self) -> dict[PortID, Port]: return deepcopy(self._ports) + def port_metadata(self) -> dict[PortID, PortMetadata]: + return deepcopy(self._port_metadata) + def _rebuild_list(self, selected_id: PortID | None = None) -> None: self._list_model.clear() for port_id in self._port_ids: @@ -90,7 +97,7 @@ class PortEditorWidget(QWidget): self.ui.removePort.setEnabled(port_id is not None) self._set_editor_enabled(port_id is not None) if port_id is not None: - self._load_port(self._ports[port_id]) + self._load_port(self._ports[port_id], self._port_metadata.get(port_id, PortMetadata())) def _selected_port_id(self) -> PortID | None: index = self.ui.portList.currentIndex() @@ -98,7 +105,7 @@ class PortEditorWidget(QWidget): return None return self._port_ids[index.row()] - def _load_port(self, port: Port) -> None: + def _load_port(self, port: Port, metadata: PortMetadata) -> None: self._loading = True self.ui.nameEdit.setText(port.name) self.ui.inputOrientation.setChecked(port.direction is SignalDirection.INPUT) @@ -106,6 +113,7 @@ class PortEditorWidget(QWidget): self.ui.widthSize.setValue(port.matrix_size[0]) self.ui.heightSize.setValue(port.matrix_size[1]) self.ui.multiplicityCheckBox.setChecked(port.multiplicity) + self.ui.connectionAnnotationEdit.setText(metadata.connection_annotation or "") self.ui.descriptionEdit.setPlainText(port.description or "") if isinstance(port, SignalPort): @@ -135,6 +143,12 @@ class PortEditorWidget(QWidget): old_port = self._ports[port_id] self._ports[port_id] = self._port_from_form(old_port) + annotation = (self.ui.connectionAnnotationEdit.text().strip() or None) if self.ui.multiplicityCheckBox.isChecked() else None + metadata = PortMetadata(connection_annotation=annotation) + if metadata == PortMetadata(): + self._port_metadata.pop(port_id, None) + else: + self._port_metadata[port_id] = metadata self._list_model.item(self._port_ids.index(port_id)).setText( self._ports[port_id].name ) @@ -188,6 +202,7 @@ class PortEditorWidget(QWidget): return row = self._port_ids.index(port_id) del self._ports[port_id] + self._port_metadata.pop(port_id, None) self._port_ids.remove(port_id) selected = ( self._port_ids[min(row, len(self._port_ids) - 1)] @@ -201,6 +216,9 @@ class PortEditorWidget(QWidget): signal = self.ui.typeSignal.isChecked() self._set_layout_visible(self.ui.signalOptions, signal) self._set_layout_visible(self.ui.bondOptions, not signal) + annotation_visible = self.ui.multiplicityCheckBox.isChecked() + self.ui.connectionAnnotationLabel.setVisible(annotation_visible) + self.ui.connectionAnnotationEdit.setVisible(annotation_visible) @staticmethod def _set_layout_visible(layout: QLayout, visible: bool) -> None: diff --git a/untitled.bedit.json b/untitled.bedit.json index 2fea8ac..6e8be13 100644 --- a/untitled.bedit.json +++ b/untitled.bedit.json @@ -608,11 +608,219 @@ ] } }, - "ac5011ce-4659-4bdd-900a-63a1c779652c": { + "404e82f4-19e2-44ad-b409-3f021e483a39": { + "name": "integrator", + "interface": { + "ports": { + "441a44d0-bbe5-49f0-bb0e-5bc891218aca": { + "port_type": "signal", + "name": "y", + "direction": "output", + "multiplicity": false, + "matrix_size": [ + 1, + 1 + ], + "description": null, + "value_type": "real", + "quantity": null, + "unit": null + }, + "8d4b0f2c-2e13-4cfd-a8da-6c03ade4fbbf": { + "port_type": "signal", + "name": "u", + "direction": "input", + "multiplicity": false, + "matrix_size": [ + 1, + 1 + ], + "description": null, + "value_type": "real", + "quantity": null, + "unit": null + } + } + }, + "parameters": { + "7150c82e-8c78-4f83-9bc8-e46057a367a8": { + "name": "k", + "value": "1.0", + "value_type": "real", + "matrix_size": [ + 1, + 1 + ], + "quantity": null, + "unit": null, + "description": "Integrator gain" + }, + "f7b9691a-22a4-41e0-9871-44f8dd0e02d2": { + "name": "initType", + "value": "3", + "value_type": "int", + "matrix_size": [ + 1, + 1 + ], + "quantity": null, + "unit": null, + "description": "Type of initialization (1: no init, 2: steady state, 3: initial state, 4: initial output)" + }, + "3b636a9c-257c-4368-855a-094051f29041": { + "name": "y_start", + "value": "0.0", + "value_type": "real", + "matrix_size": [ + 1, + 1 + ], + "quantity": null, + "unit": null, + "description": "Initial or guess value of output (= state)" + } + }, + "implementation": { + "implementation_type": "equation", + "declarations": [], + "initial_equations": [ + "if initType == 2 then", + "\tder(y) = 0;", + "elseif initType >= 3 then", + "\ty = y_start;", + "end if;" + ], + "equations": [ + "der(y) = k*u;" + ] + } + }, + "2b0156f6-9977-4735-88dd-14f5eb3d87b5": { + "name": "derivative", + "interface": { + "ports": { + "d61847c9-0628-4e86-ad91-9390f92f926d": { + "port_type": "signal", + "name": "y", + "direction": "output", + "multiplicity": false, + "matrix_size": [ + 1, + 1 + ], + "description": null, + "value_type": "real", + "quantity": null, + "unit": null + }, + "f18c8fd2-dda5-4e8d-a5a7-a22a38abb091": { + "port_type": "signal", + "name": "u", + "direction": "input", + "multiplicity": false, + "matrix_size": [ + 1, + 1 + ], + "description": null, + "value_type": "real", + "quantity": null, + "unit": null + } + } + }, + "parameters": { + "ec421126-1ff4-4627-8bdd-1841318f3eb6": { + "name": "k", + "value": "1.0", + "value_type": "real", + "matrix_size": [ + 1, + 1 + ], + "quantity": null, + "unit": null, + "description": "Integrator gain" + }, + "0c364769-5f07-4f3e-bb93-91d86a1f2dae": { + "name": "initType", + "value": "3", + "value_type": "int", + "matrix_size": [ + 1, + 1 + ], + "quantity": null, + "unit": null, + "description": "Type of initialization (1: no init, 2: steady state, 3: initial state, 4: initial output)" + }, + "ab3a2bae-ea7f-41ca-9493-93d8debcee27": { + "name": "y_start", + "value": "0.0", + "value_type": "real", + "matrix_size": [ + 1, + 1 + ], + "quantity": null, + "unit": null, + "description": "Initial or guess value of output (= state)" + }, + "e04d9fc5-761c-4b3d-bd7b-93da9896bd35": { + "name": "T", + "value": "0.01", + "value_type": "real", + "matrix_size": [ + 1, + 1 + ], + "quantity": null, + "unit": null, + "description": "Time constants (T>0 required; T=0 is ideal derivative block)" + }, + "29669830-2738-4692-8255-115d1546619c": { + "name": "x_start", + "value": "0.0", + "value_type": "real", + "matrix_size": [ + 1, + 1 + ], + "quantity": null, + "unit": null, + "description": "Initial or guess value of state\"" + } + }, + "implementation": { + "implementation_type": "equation", + "declarations": [ + "parameter Boolean zeroGain = abs(k) < Modelica.Constants.eps;", + "Real x(start=x_start);" + ], + "initial_equations": [ + "if initType == 2 then", + "\tder(x) = 0;", + "elseif initType == 3 then", + "\tx = x_start;", + "elseif initType >= 4 then", + "\tif zeroGain then", + "\t\tx = u;", + "\telse", + "\t\ty = y_start;", + "\tend if;", + "end if;" + ], + "equations": [ + "der(x) = if zeroGain then 0 else (u-x)/T;", + "y = if zeroGain then 0 else (k/T)*(u-x);" + ] + } + }, + "927326ff-f73a-4f98-b7fd-aaf97989121e": { "name": "pulse", "interface": { "ports": { - "69974610-3bd6-42c1-a8bb-afe6086ef0f3": { + "4f9f6b29-36f0-460e-9fd9-d4bdf0ea3c80": { "port_type": "signal", "name": "y", "direction": "output", @@ -629,7 +837,7 @@ } }, "parameters": { - "8e128f7a-cf97-4f27-a664-488921819395": { + "b6460a26-0b89-49e0-8ec7-99281a66668b": { "name": "amplitude", "value": "1.0", "value_type": "real", @@ -641,7 +849,7 @@ "unit": null, "description": null }, - "5ff17831-f2f9-4833-9e4b-4facf7c28c0c": { + "983d6c65-8e81-4f49-9faa-d024e8e6a9d3": { "name": "width", "value": "50.0", "value_type": "real", @@ -653,7 +861,7 @@ "unit": null, "description": "Pulse width in %" }, - "b036592b-4dc0-498b-a966-7146cf9cf95b": { + "c9ccd873-f83a-4b08-8827-4a88a06d502a": { "name": "offset", "value": "0", "value_type": "real", @@ -665,7 +873,7 @@ "unit": null, "description": null }, - "df82150d-973d-4a8a-9bd5-6ce21a2e8bbb": { + "9cd46f38-6ddb-4d69-946d-df96e9f30078": { "name": "period", "value": "1.0", "value_type": "real", @@ -677,7 +885,7 @@ "unit": null, "description": null }, - "1195c93e-3309-494e-9a56-96e8fd5e330c": { + "4e1d29eb-48c8-479a-8968-8f57fd95ea9f": { "name": "nperiod", "value": "-1", "value_type": "int", @@ -689,7 +897,7 @@ "unit": null, "description": "Number of periods (<0 means infinite)" }, - "3f5f7b5c-2794-4e75-a5c8-1ef2b0ecd87d": { + "68102f0d-b58b-4c10-af0d-dffae406fb07": { "name": "startTime", "value": "0.0", "value_type": "real", @@ -723,11 +931,11 @@ ] } }, - "ec0599cb-b57b-4194-b166-526d5cd5382a": { + "ac215607-dd97-4142-b42f-f23502e51cfa": { "name": "plusminus", "interface": { "ports": { - "23a8b215-7c2f-4ccf-b020-4d274d7a228f": { + "baadcb27-0807-4366-819f-7396a1ed205d": { "port_type": "signal", "name": "plus", "direction": "input", @@ -741,7 +949,7 @@ "quantity": null, "unit": null }, - "4c4771f8-22c8-4829-a060-913470e2b2fc": { + "53cc98de-2e05-49ea-af26-e26d5dcd99e7": { "port_type": "signal", "name": "minus", "direction": "input", @@ -755,7 +963,7 @@ "quantity": null, "unit": null }, - "a186a0e1-7aea-4fb6-871c-0ba70d78b8ac": { + "0574652d-df52-4798-8072-da0a26eee873": { "port_type": "signal", "name": "y", "direction": "output", @@ -835,15 +1043,25 @@ "source": "a2709c23-7485-43b2-bc87-28a0f4c37c0b", "target": "8bd1cc42-03b9-4c46-b31b-c1f46933dc9e" }, - "42f8d11c-8b31-476a-8e56-dd1ac3703508": { + "122eaf51-7baf-457f-a944-f946b6ab321a": { "connection_type": "signal", - "source": "69974610-3bd6-42c1-a8bb-afe6086ef0f3", - "target": "23a8b215-7c2f-4ccf-b020-4d274d7a228f" + "source": "4f9f6b29-36f0-460e-9fd9-d4bdf0ea3c80", + "target": "baadcb27-0807-4366-819f-7396a1ed205d" }, - "81e6b0d7-65f2-4edc-a303-6eee78ac1972": { + "8b1c34a9-03c2-4bb5-81ab-7fa66bcafe8e": { "connection_type": "signal", "source": "5807dc83-d049-4f0f-9708-d041a752fcd0", - "target": "4c4771f8-22c8-4829-a060-913470e2b2fc" + "target": "53cc98de-2e05-49ea-af26-e26d5dcd99e7" + }, + "3c659d9e-8218-48ac-9a3d-4297fba275c2": { + "connection_type": "signal", + "source": "0574652d-df52-4798-8072-da0a26eee873", + "target": "8d4b0f2c-2e13-4cfd-a8da-6c03ade4fbbf" + }, + "9b5ab2d7-d302-48e8-98dc-0d60ba54ca01": { + "connection_type": "signal", + "source": "0574652d-df52-4798-8072-da0a26eee873", + "target": "f18c8fd2-dda5-4e8d-a5a7-a22a38abb091" } } } @@ -1333,9 +1551,9 @@ ] } }, - "ac5011ce-4659-4bdd-900a-63a1c779652c": { + "404e82f4-19e2-44ad-b409-3f021e483a39": { "shapes": { - "7a9115fe-4e40-49d9-ab50-2f399ca1038b": { + "29dafe3d-8392-49c0-98ee-80e941b32a52": { "layer": 0, "type": "rectangle", "pos": [ @@ -1350,7 +1568,95 @@ "line_color": "#00007fff", "fill_color": "#ebebebff" }, - "80e150dc-07d3-4d27-9fe0-eecb82fb2689": { + "d24da1c9-bae9-41e6-aee4-79e5f238ea72": { + "layer": 1, + "type": "text", + "pos": [ + -15, + -47 + ], + "width": 31.0, + "height": 81.0, + "color": "#00007fff", + "bold": false, + "italic": false, + "size": 72.0, + "text": "∫" + } + }, + "port_positions": { + "441a44d0-bbe5-49f0-bb0e-5bc891218aca": [ + 7, + -7 + ], + "8d4b0f2c-2e13-4cfd-a8da-6c03ade4fbbf": [ + -22, + -7 + ] + } + }, + "2b0156f6-9977-4735-88dd-14f5eb3d87b5": { + "shapes": { + "702ce5c1-a37a-4f89-8261-0be4b433ae79": { + "layer": 0, + "type": "rectangle", + "pos": [ + -48, + -48 + ], + "width": 96.0, + "height": 96.0, + "line_type": "solid", + "line_thickness": 5.0, + "corner_radius": 8.0, + "line_color": "#00007fff", + "fill_color": "#ebebebff" + }, + "32ccfd50-07dd-4524-be5d-e96a3136d520": { + "layer": 1, + "type": "text", + "pos": [ + -40, + -31 + ], + "width": 78.0, + "height": 62.0, + "color": "#00007fff", + "bold": false, + "italic": false, + "size": 38.0, + "text": "d/dt" + } + }, + "port_positions": { + "d61847c9-0628-4e86-ad91-9390f92f926d": [ + 7, + -7 + ], + "f18c8fd2-dda5-4e8d-a5a7-a22a38abb091": [ + -22, + -7 + ] + } + }, + "927326ff-f73a-4f98-b7fd-aaf97989121e": { + "shapes": { + "89e11690-3faa-482b-a054-10a2c7565c43": { + "layer": 0, + "type": "rectangle", + "pos": [ + -48, + -48 + ], + "width": 96.0, + "height": 96.0, + "line_type": "solid", + "line_thickness": 5.0, + "corner_radius": 8.0, + "line_color": "#00007fff", + "fill_color": "#ebebebff" + }, + "de170a5b-c959-4d6d-afda-fa867dfbca51": { "layer": 4, "type": "line", "pos": [ @@ -1365,7 +1671,7 @@ "line_thickness": 3.0, "line_color": "#000000ff" }, - "66c7fb78-2866-4f9f-9df2-95cfa4c57a2d": { + "94fc1429-0ef2-403b-aa5d-7edcb7b3ebf2": { "layer": 2, "type": "line", "pos": [ @@ -1380,7 +1686,7 @@ "line_thickness": 3.0, "line_color": "#000000ff" }, - "b1b1f425-eea6-48e7-a61d-f9eb221984b7": { + "0d82b577-e3fb-4434-adfb-883e1918e30f": { "layer": 3, "type": "line", "pos": [ @@ -1388,79 +1694,138 @@ 14 ], "end": [ - 3, + -14, 14 ], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff" }, - "0c0b7ec3-557d-4fb4-b8b8-b6b6a269ec55": { + "531dcbea-2c2d-4ed9-bcbc-dfc9b0145527": { "layer": 5, "type": "line", "pos": [ - 3, + -13, 14 ], "end": [ - 3, + -13, + -12 + ], + "line_type": "solid", + "line_thickness": 2.0, + "line_color": "#ffaa00ff" + }, + "c4476a1b-7216-4eab-aad6-69ecb5af4b79": { + "layer": 6, + "type": "line", + "pos": [ + -13, + -13 + ], + "end": [ + 4, -13 ], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff" }, - "e5c99d8a-69db-4915-b1e7-84ac5e52de02": { - "layer": 6, + "2b69a1e2-0f80-4a1a-b31c-1f32b91bc46a": { + "layer": 7, "type": "line", "pos": [ - 3, + 4, -13 ], "end": [ - 32, + 4, + 14 + ], + "line_type": "solid", + "line_thickness": 2.0, + "line_color": "#ffaa00ff" + }, + "7fac6744-2d5b-49a4-bfca-27cc8889a5b6": { + "layer": 8, + "type": "line", + "pos": [ + 4, + 14 + ], + "end": [ + 21, + 14 + ], + "line_type": "solid", + "line_thickness": 2.0, + "line_color": "#ffaa00ff" + }, + "81390b87-3648-4556-a439-9456232781df": { + "layer": 9, + "type": "line", + "pos": [ + 21, + 14 + ], + "end": [ + 21, -13 ], "line_type": "solid", "line_thickness": 2.0, "line_color": "#ffaa00ff" + }, + "c3c9eddf-f4c7-4f76-8bc5-7c313fc9ea72": { + "layer": 10, + "type": "line", + "pos": [ + 21, + -14 + ], + "end": [ + 32, + -14 + ], + "line_type": "solid", + "line_thickness": 2.0, + "line_color": "#ffaa00ff" } }, "port_positions": { - "69974610-3bd6-42c1-a8bb-afe6086ef0f3": [ + "4f9f6b29-36f0-460e-9fd9-d4bdf0ea3c80": [ -8, -8 ] } }, - "ec0599cb-b57b-4194-b166-526d5cd5382a": { + "ac215607-dd97-4142-b42f-f23502e51cfa": { "shapes": { - "ecbbad0e-67e3-4d13-ba25-7660f50bc38e": { - "layer": 0, - "type": "rectangle", + "9336da30-7335-4eea-a8a2-ae69d8cad9e1": { + "layer": 1, + "type": "ellipse", "pos": [ - -48, - -48 + -36, + -36 ], - "width": 96.0, - "height": 96.0, + "width": 72.0, + "height": 72.0, "line_type": "solid", - "line_thickness": 5.0, - "corner_radius": 8.0, + "line_thickness": 8.0, "line_color": "#00007fff", "fill_color": "#ebebebff" } }, "port_positions": { - "23a8b215-7c2f-4ccf-b020-4d274d7a228f": [ + "baadcb27-0807-4366-819f-7396a1ed205d": [ -24, 9 ], - "4c4771f8-22c8-4829-a060-913470e2b2fc": [ + "53cc98de-2e05-49ea-af26-e26d5dcd99e7": [ -24, -26 ], - "a186a0e1-7aea-4fb6-871c-0ba70d78b8ac": [ + "0574652d-df52-4798-8072-da0a26eee873": [ 11, -8 ] @@ -1557,6 +1922,30 @@ "ec0599cb-b57b-4194-b166-526d5cd5382a": [ -120, 656 + ], + "83dd8220-554c-4907-b713-c37e1f600f0d": [ + -96, + 648 + ], + "404e82f4-19e2-44ad-b409-3f021e483a39": [ + 228, + 640 + ], + "2b0156f6-9977-4735-88dd-14f5eb3d87b5": [ + 232, + 828 + ], + "927326ff-f73a-4f98-b7fd-aaf97989121e": [ + -428, + 552 + ], + "ac215607-dd97-4142-b42f-f23502e51cfa": [ + -96, + 636 + ], + "b3e059a3-4517-435d-aa63-ddfbbf6fa071": [ + 488, + 552 ] }, "component_labels": { @@ -1667,6 +2056,17 @@ "dassl_tolerance": 1e-06 } } + }, + "port_metadata_database": { + "format_version": 1, + "ports": { + "53cc98de-2e05-49ea-af26-e26d5dcd99e7": { + "connection_annotation": "-" + }, + "baadcb27-0807-4366-819f-7396a1ed205d": { + "connection_annotation": "+" + } + } } } }