From 32154386e8030889df4fec220b071773a97b0317 Mon Sep 17 00:00:00 2001 From: Joppe Blondel Date: Wed, 22 Jul 2026 13:20:49 +0200 Subject: [PATCH] Start with causality inference --- BEdit/AGENTS.md | 14 +- BEdit/README.md | 12 +- BEdit/src/bedit/core/bond_graph.py | 134 ++++++++++++ BEdit/src/bedit/core/model.py | 95 ++++++--- BEdit/src/bedit/core/simulation/composer.py | 16 +- BEdit/src/bedit/core/simulation/service.py | 2 + BEdit/src/bedit/data/libraries/default.beb | Bin 880 -> 891 bytes BEdit/src/bedit/gui/app.py | 18 +- BEdit/src/bedit/gui/controllers/commands.py | 14 +- BEdit/src/bedit/gui/controllers/document.py | 190 ++++++++++-------- .../bedit/gui/dialogs/component_options.py | 9 +- BEdit/src/bedit/gui/dialogs/port_options.py | 52 ++--- .../src/bedit/gui/editors/text_definition.py | 25 +-- BEdit/src/bedit/gui/graphics/icon_editor.py | 9 +- BEdit/src/bedit/gui/graphics/workspace.py | 95 ++++++--- BEdit/src/bedit/gui/main_window.py | 40 ++-- BEdit/untitled.bedit.json | 34 ++-- 17 files changed, 493 insertions(+), 266 deletions(-) create mode 100644 BEdit/src/bedit/core/bond_graph.py diff --git a/BEdit/AGENTS.md b/BEdit/AGENTS.md index 17df6d7..729b9db 100644 --- a/BEdit/AGENTS.md +++ b/BEdit/AGENTS.md @@ -56,8 +56,9 @@ Keep the root of `src/bedit` minimal. New UI modules should go into the relevant anchors may change without changing IDs. - Parameters belong to `Component` rather than a particular implementation kind, so graph and text components share stable-ID name/type/value records. -- Port orientation is presented as one unified list in the UI, while the model - indexes inputs and outputs separately for connection semantics. +- Components store one ordered `ports` list. Each port's `orientation` field is + authoritative (`input`, `output`, or power-only `indifferent`); connection and + presentation code derives any directional groupings when needed. - Port types are registered in `core/port_types.py`. Only compatible types may be connected. Signal ports connect by type; power ports additionally require the same domain. Editable power domains and causalities live in @@ -72,9 +73,15 @@ Keep the root of `src/bedit` minimal. New UI modules should go into the relevant - Port removal or reorientation must be rejected when it would invalidate an existing connection. - Connections reference port IDs, never port names. +- Connections store their port type and bond-graph causality explicitly. + `core/bond_graph.py` owns causality inference; the simulation service invokes + it before Modelica composition. The controller copies inferred values into the + live model and refreshes the workspace before compile, export, or run. + Causality values are `none`, `source`, `target`, `warn_source`, and `warn_target`. - Connection junctions are explicit typed graph objects. Splitting a connection creates one incoming and one outgoing segment; the junction can source further - branches without overlapping full connection paths. + branches without overlapping full connection paths. Power bond connections + cannot contain junctions. - Graph interaction has separate Pointer and Connect modes. Port hints are only visible in Connect mode. Connecting two blocks opens the compatible port-pair chooser; explicit port clicks determine its default selection. Connections @@ -324,6 +331,7 @@ PYTHONPATH=src python3 -m bedit ``` The installed GUI entry point is `bedit.gui.app:main`. +The first non-flag launch argument is opened as the initial document. ## Completion checklist diff --git a/BEdit/README.md b/BEdit/README.md index 41dc0b2..206542b 100644 --- a/BEdit/README.md +++ b/BEdit/README.md @@ -159,12 +159,12 @@ Every component owns its ports, declarative icon, properties, and child graph: "name": "My Component", "position": {"x": 0, "y": 0}, "interface": { - "inputs": [{"id": "in", "name": "Input", "type": "signal", "properties": { - "iconPosition": {"x": 0, "y": 40} - }}], - "outputs": [{"id": "out", "name": "Output", "type": "signal", "properties": { - "iconPosition": {"x": 128, "y": 64} - }}] + "ports": [ + {"id": "in", "name": "Input", "type": "signal", "orientation": "input", + "properties": {"iconPosition": {"x": 0, "y": 40}}}, + {"id": "out", "name": "Output", "type": "signal", "orientation": "output", + "properties": {"iconPosition": {"x": 128, "y": 64}}} + ] }, "icon": { "size": {"width": 128, "height": 128}, diff --git a/BEdit/src/bedit/core/bond_graph.py b/BEdit/src/bedit/core/bond_graph.py new file mode 100644 index 0000000..98fb7ab --- /dev/null +++ b/BEdit/src/bedit/core/bond_graph.py @@ -0,0 +1,134 @@ +"""Bond-graph analysis hooks. + +This module intentionally has no GUI or simulation-engine dependencies. The +causality inference algorithm can grow here without coupling the document model +to OpenModelica or Qt. +""" + +from __future__ import annotations +from typing import Any + +from bedit.core.application_log import get_logger + +log = get_logger(__name__) + +def infer_causality(component: dict[str, Any], toplevel: bool = True) -> dict[str, Any]: + """Infer power-connection causality in a serialized component tree. + + This is currently a traversal stub: it ensures every power connection has a + causality value, while preserving causality already supplied by callers. + Future inference rules should assign ``source``, ``target``, + ``warn_source``, or ``warn_target`` here and return the same tree. + + The input is mutated and returned so the composer receives the inferred + representation without needing a second document conversion. + """ + + if toplevel: + log.info("Causality inference") + + # Reset causalities + reset_causality(component) + + implementation = component.get("implementation", {}) + graph = implementation.get("graph") if isinstance(implementation, dict) else None + if not isinstance(graph, dict): + return component + + id_list = build_id_list(component) + + # Check fixed causality + for connection in graph.get("connections", []): + if isinstance(connection, dict) and connection.get("type") == "power": + source, target = get_ports_from_bond(connection, id_list) + log.info(source) + log.info(target) + if source.get('causality', 'indifferent') == 'fixed effort out': + connection['causality'] = 'target' + elif source.get('causality', 'indifferent') == 'fixed flow out': + connection['causality'] = 'source' + + for block in graph.get("blocks", []): + if isinstance(block, dict): + infer_causality(block, False) + + return component + +def reset_causality(component: dict[str, Any]) -> None: + implementation = component.get("implementation", {}) + graph = implementation.get("graph") if isinstance(implementation, dict) else None + if not isinstance(graph, dict): + return + for connection in graph.get("connections", []): + if isinstance(connection, dict) and connection.get("type") == "power": + connection["causality"] = "none" + for block in graph.get("blocks", []): + if isinstance(block, dict): + reset_causality(block) + +def build_id_list(graph: dict[str, Any]) -> dict[str, Any]: + """Index all addressable objects in a component tree by their stable ID.""" + + id_list: dict[str, Any] = {} + id_kinds: dict[str, str] = {} + + def add(item: dict[str, Any], description: str) -> None: + item_id = item.get("id") + if not item_id: + raise ValueError(f"{description} has no ID") + # Port IDs identify a port on a component definition and may therefore + # recur in cloned component instances. Component and junction IDs are + # document objects and must remain globally unique. + if item_id in id_list and not ( + description == "port" and id_kinds[item_id] == "port" + ): + raise ValueError(f"Duplicate simulation object ID: {item_id}") + id_list[item_id] = item + id_kinds[item_id] = description + + def visit(component: dict[str, Any]) -> None: + add(component, "component") + interface = component.get("interface", {}) + for port in interface.get("ports", []): + add(port, "port") + + implementation = component.get("implementation", {}) + if implementation.get("kind") != "graph": + return + nested_graph = implementation.get("graph", {}) + for junction in nested_graph.get("junctions", []): + add(junction, "junction") + for block in nested_graph.get("blocks", []): + visit(block) + + visit(graph) + return id_list + +def get_ports_from_bond(bond: dict[str, Any], id_list: dict[str, Any]) -> tuple[dict[str,Any], dict[str,Any]]: + source = bond.get('source', None) + target = bond.get('target', None) + if source is None or target is None: + raise ValueError("Source and Target of a power bond cannot be None") + + source_component = id_list.get(source.get('block'), None) + target_component = id_list.get(target.get('block'), None) + if source_component is None or target_component is None: + raise ValueError("Source or Target blocks not found") + + def _find_port(ports: list[dict[str, Any]], port: str) -> dict[str, Any] | None: + for pi in ports: + if pi.get('id', '') == port: + return pi + return None + + source_port = _find_port(source_component.get('interface', {}).get('ports', []), source.get('port')) + target_port = _find_port(target_component.get('interface', {}).get('ports', []), target.get('port')) + if source_port is None or target_port is None: + raise ValueError("Source or Target port not found") + + if source_port.get('type') != 'power': + raise ValueError("Source port is not a power port") + if target_port.get('type') != 'power': + raise ValueError("Target port is not a power port") + + return source_port, target_port diff --git a/BEdit/src/bedit/core/model.py b/BEdit/src/bedit/core/model.py index 52a2460..83f67d3 100644 --- a/BEdit/src/bedit/core/model.py +++ b/BEdit/src/bedit/core/model.py @@ -180,6 +180,8 @@ class Connection: target: Endpoint name: str = "" properties: dict[str, Any] = field(default_factory=dict) + type: str = "signal" + causality: str = "none" def to_dict(self) -> dict[str, Any]: return { @@ -188,6 +190,8 @@ class Connection: "target": self.target.to_dict(), "name": self.name, "properties": self.properties, + "type": self.type, + "causality": self.causality, } @classmethod @@ -198,6 +202,8 @@ class Connection: target=Endpoint.from_dict(data["target"]), name=str(data.get("name", "")), properties=dict(data.get("properties", {})), + type=str(data.get("type", "signal")), + causality=str(data.get("causality", "none")), ) @@ -360,8 +366,7 @@ class Component: x: float = 0.0 y: float = 0.0 rotation: float = 0.0 - inputs: list[Port] = field(default_factory=list) - outputs: list[Port] = field(default_factory=list) + ports: list[Port] = field(default_factory=list) parameters: list[Parameter] = field(default_factory=list) icon: Icon = field(default_factory=Icon) properties: dict[str, Any] = field(default_factory=dict) @@ -381,10 +386,7 @@ class Component: "name": self.name, "position": {"x": self.x, "y": self.y}, "rotation": self.rotation, - "interface": { - "inputs": [port.to_dict() for port in self.inputs], - "outputs": [port.to_dict() for port in self.outputs], - }, + "interface": {"ports": [port.to_dict() for port in self.ports]}, "parameters": [parameter.to_dict() for parameter in self.parameters], "icon": self.icon.to_dict(), "properties": self.properties, @@ -427,8 +429,7 @@ class Component: x=float(position.get("x", 0.0)), y=float(position.get("y", 0.0)), rotation=float(data.get("rotation", 0.0)), - inputs=[Port.from_dict(item, "input") for item in interface.get("inputs", [])], - outputs=[Port.from_dict(item, "output") for item in interface.get("outputs", [])], + ports=[Port.from_dict(item) for item in interface.get("ports", [])], parameters=[Parameter.from_dict(item) for item in parameters], icon=Icon.from_dict(data.get("icon")), properties=dict(data.get("properties", {})), @@ -532,11 +533,10 @@ class GraphDocument: raise ValueError( f"Component names inside {owner.name!r} must be unique" ) - input_ids = {port.id for port in owner.inputs} - output_ids = {port.id for port in owner.outputs} - if len(input_ids) != len(owner.inputs) or len(output_ids) != len(owner.outputs): + port_ids = {port.id for port in owner.ports} + if len(port_ids) != len(owner.ports): raise ValueError(f"Component {owner.name} contains duplicate port IDs") - for port in (*owner.inputs, *owner.outputs): + for port in owner.ports: PortTypeRegistry.get(port.type) if port.orientation not in {"input", "output", "indifferent"}: raise ValueError(f"Port {port.name!r} has an invalid orientation") @@ -553,8 +553,18 @@ class GraphDocument: raise ValueError(f"Port {port.name!r} has invalid dimensions") for junction in owner.graph.junctions.values(): PortTypeRegistry.get(junction.type) + if junction.type == "power": + raise ValueError("Power bond connections cannot contain junctions") endpoint_counts: dict[tuple[str, str, str], int] = {} for connection in owner.graph.connections.values(): + if connection.causality not in { + "none", + "source", + "target", + "warn_source", + "warn_target", + }: + raise ValueError(f"Connection {connection.id} has invalid causality") if connection.source.junction is not None: junction = owner.graph.junctions.get(connection.source.junction) if junction is None: @@ -563,17 +573,25 @@ class GraphDocument: ) source_port = Port(junction.id, "Junction", type=junction.type) elif connection.source.interface is not None: - if connection.source.interface not in input_ids: + source_ports = [ + port + for port in owner.ports + if port.orientation in {"input", "indifferent"} + ] + if connection.source.interface not in {port.id for port in source_ports}: raise ValueError(f"Connection {connection.id} uses an unknown interface input") - source_port = next(p for p in owner.inputs if p.id == connection.source.interface) + source_port = next( + port for port in source_ports if port.id == connection.source.interface + ) else: source = owner.graph.blocks.get(connection.source.block or "") source_ports = ( [] if source is None else [ - *source.outputs, - *(p for p in source.inputs if p.orientation == "indifferent"), + port + for port in source.ports + if port.orientation in {"output", "indifferent"} ] ) if source is None or connection.source.port not in {p.id for p in source_ports}: @@ -592,21 +610,34 @@ class GraphDocument: allows_multiple_connections=False, ) elif connection.target.interface is not None: - indifferent_ids = { - port.id for port in owner.inputs if port.orientation == "indifferent" - } - if connection.target.interface not in output_ids | indifferent_ids: + target_ports = [ + port + for port in owner.ports + if port.orientation in {"output", "indifferent"} + ] + if connection.target.interface not in {port.id for port in target_ports}: raise ValueError(f"Connection {connection.id} uses an unknown interface output") target_port = next( - p - for p in (*owner.outputs, *owner.inputs) - if p.id == connection.target.interface + port for port in target_ports if port.id == connection.target.interface ) else: target = owner.graph.blocks.get(connection.target.block or "") - if target is None or connection.target.port not in {p.id for p in target.inputs}: + target_ports = ( + [] + if target is None + else [ + port + for port in target.ports + if port.orientation in {"input", "indifferent"} + ] + ) + if target is None or connection.target.port not in { + port.id for port in target_ports + }: raise ValueError(f"Connection {connection.id} uses an unknown block input") - target_port = next(p for p in target.inputs if p.id == connection.target.port) + target_port = next( + port for port in target_ports if port.id == connection.target.port + ) if not PortTypeRegistry.compatible( source_port.type, target_port.type, @@ -614,6 +645,9 @@ class GraphDocument: target_port.domain, ): raise ValueError(f"Connection {connection.id} joins incompatible port types") + # The endpoint ports remain authoritative. This also upgrades older + # documents whose connections predate the explicit type field. + connection.type = source_port.type source_key = ( "source-junction" if connection.source.junction is not None @@ -676,6 +710,8 @@ def clone_component(source: Component) -> Component: remap(connection.target), connection.name, deepcopy(connection.properties), + connection.type, + connection.causality, ) for connection in current.graph.connections.values() for new_id in [str(uuid4())] @@ -708,14 +744,7 @@ def clone_component(source: Component) -> Component: name=current.name, x=current.x, y=current.y, - inputs=[ - Port.from_dict(port.to_dict(), "input") - for port in current.inputs - ], - outputs=[ - Port.from_dict(port.to_dict(), "output") - for port in current.outputs - ], + ports=[Port.from_dict(port.to_dict()) for port in current.ports], parameters=deepcopy(current.parameters), icon=Icon.from_dict(current.icon.to_dict()), properties=deepcopy(current.properties), diff --git a/BEdit/src/bedit/core/simulation/composer.py b/BEdit/src/bedit/core/simulation/composer.py index 3328be5..1e1acd3 100644 --- a/BEdit/src/bedit/core/simulation/composer.py +++ b/BEdit/src/bedit/core/simulation/composer.py @@ -59,9 +59,7 @@ def build_id_list(graph: dict[str, Any]) -> dict[str, Any]: def visit(component: dict[str, Any]) -> None: add(component, "component") interface = component.get("interface", {}) - for port in interface.get("inputs", []): - add(port, "port") - for port in interface.get("outputs", []): + for port in interface.get("ports", []): add(port, "port") implementation = component.get("implementation", {}) @@ -110,10 +108,9 @@ def emit_model( ) interface = graph.get("interface", {}) - for port in interface.get("inputs", []): - lines.append(_port_declaration(port, "input", indent + 1, macros)) - for port in interface.get("outputs", []): - lines.append(_port_declaration(port, "output", indent + 1, macros)) + for port in interface.get("ports", []): + direction = "output" if port.get("orientation") == "output" else "input" + lines.append(_port_declaration(port, direction, indent + 1, macros)) for parameter in graph.get("parameters", []): parameter_type = modelica_type(parameter.get("type", "real")) @@ -332,7 +329,7 @@ def _port_count_macros( ) -> dict[str, str]: macros: dict[str, str] = {} interface = component.get("interface", {}) - for port in (*interface.get("inputs", []), *interface.get("outputs", [])): + for port in interface.get("ports", []): if port.get("multipleConnections", False): macros[f"{identifier(port['name'])}_N"] = str( connection_counts.get(port["id"], 0) @@ -354,8 +351,7 @@ def expand_bevalues(text: str, values: dict[str, str]) -> str: def _find_port(component: dict[str, Any], port_id: str) -> dict[str, Any]: interface = component.get("interface", {}) - ports = [*interface.get("inputs", []), *interface.get("outputs", [])] - for port in ports: + for port in interface.get("ports", []): if port.get("id") == port_id: return port raise ValueError( diff --git a/BEdit/src/bedit/core/simulation/service.py b/BEdit/src/bedit/core/simulation/service.py index 32b2e1d..2613c6a 100644 --- a/BEdit/src/bedit/core/simulation/service.py +++ b/BEdit/src/bedit/core/simulation/service.py @@ -2,6 +2,7 @@ from collections.abc import Callable from typing import Any from bedit.core.application_log import get_logger +from bedit.core.bond_graph import infer_causality from bedit.core.simulation.composer import compose_graph from bedit.core.simulation.openmodelica import ( ErrorCallback, @@ -87,6 +88,7 @@ class Simulation: def compose_source(self, graph: dict[str, Any]) -> tuple[str, str]: """Compose Modelica source without asking OpenModelica to build it.""" + infer_causality(graph) result = compose_graph(graph) self.last_composition_input = result.graph self.id_list = result.objects_by_id diff --git a/BEdit/src/bedit/data/libraries/default.beb b/BEdit/src/bedit/data/libraries/default.beb index f3e2ce9f71b1a18cc4e70e6af3641ed56f8adc23..2a3aa794a61a8761e273a9fb0ea589123847f19b 100644 GIT binary patch literal 891 zcmV->1BCoSMM3}pc-qyM-)`GB6vn%E*aHY?F18jLY+1786+<;NKm!ygf+c7R48=x~ zhcOYER7on0dzYZt(4ZIn_X1vJ$7#D_*j`|0F8u_3i(U3|lq}1R6BKFPbu9%LltuD< zhdjSe_T2#$vV-)?2nf?Q5jgDt*ZW%_6%+i6VFxrKNTZ#MqYnIWDV5SZx0cI+eJO-i zkN19KqWqeZ_0p5OR))4 zdEMDeChE%d@=Xt?n_hPB6&f-wI8*{w-d#Y=G@p+5I3HHVuBu|YI^~%IKOuHihKt9Z zq%jx!+Cs7anTt&+LbcjyW3L_uW2oBV_El4%hx;*$B$C~o=lNG_X{05<;Y-#@##L05 z{cg6&I4SCIphPMSrU!5NF4v z>dgX;>@s5fs8NLPV-$~OjDj{?41qgFFI~(iR*&u9I~hfvapMzHZ4_G2bc5#SVPefX zavNx(c5~juo;u%nAJ4`feY!SwzmG*MWMu}WqM{9{;Cjs@Itqii3>R=ZvwOWOHL$@vvKr-tjAB6k|ACcpGClkA}5U~TyB#z-!Ae@A2 z@h+ZW)tSp8oL5&&I4!E@kE0q+6|NjR%@a|5wK%G;t(;YrcD_n~WH&AI@4#Xf<5vCK RrSzw(OV-yH{sm!%c?a+}xSaq1 literal 880 zcmV-$1CRVdMM3}pc-qaD-Hy{n6vyd1^Z_if7X{=haqQSBs%peaT?q-TSVWO3)Q)G) zY)0$MxHIEr!`-eB5{L^v`T{A=$5M)_@&bsx%oFeyz3gSjj^kvr5nV`28B5VPo|*su z%$(n`?+&Pt9i(4IK$y0Pz-b4#9`1rvOz_V}9ngp%jrKE+I`I4DR7&&QS}zCor4U*@ zeeeq-Z`Z(UxDBrs*iMuBw(F9}j+{orCQ;xa{1g+}@F@tLw6GOj^VgyCSoc#>-MXed&hcOKs}E`CddG;t8z@*UqV)Oy`x zGSiie%(VMFks<-98L0gi3=6(FH$J>kjStr+-As2A7$~;QQT(ct#+oHDn3y==e3p9i zJVDYZ8>i~=UE}JH>fWw7PTlt@o><#0mdYfcxW9|y6najU8@Sh3ZIr;FG%F&$9YaQ0 z91qV@H@^mvkObuLY@?Q6nT9=`UC)0My8{RP+l=V;IQ((qcF+d4-PYCp2x7?3PW}Ag z1s0NnyoLx%)j0gT4DXoMboOR>05RjRrMofom53QB?!PMpzxk^aR9cE1nAz*jM>2I+ zu9xp{INRZ}d%y6IalxT7u<`x|YNq*gvd8(bQoBllz3NEkPIrlRm3^ygPs3QyzOj<_ zp9|WQB2=rLR(ta#1tZlKx38KEJsQR=l1O&&x34-aZ=>r zK#5dh*gu4y(&CvKbgi$g-HVeNfxvCn^YY--Xk@z{ZrKPosqOhulR6EAf_k$+Bl{LH ze!M<}A5H z`LyKQd`Doza%4LY7`|OfY~(qf4PNB>0mczU^&+93T_WUIwud)oc;EaWym71L1*W9@ zPk0|K!5cZWMgaXgcwe7c+IWAwAALkv?=Q!4Vz0_-1t;-,D(!rce$Q@M=JVcS7UNd^tF`opt83P|i+= None: app.setPalette(palette) -def main() -> int: +def _document_argument(arguments: Sequence[str]) -> Path | None: + """Return the first positional application argument, if present.""" + + return next( + (Path(argument) for argument in arguments[1:] if not argument.startswith("-")), + None, + ) + + +def main(argv: Sequence[str] | None = None) -> int: QCoreApplication.setApplicationName("BEdit") QCoreApplication.setOrganizationName("BEdit") QCoreApplication.setApplicationVersion("0.1.0") - app = QApplication(sys.argv) + app = QApplication(list(sys.argv if argv is None else argv)) app.setApplicationDisplayName("BEdit") apply_light_theme(app) window = MainWindow() + document_path = _document_argument(app.arguments()) + if document_path is not None: + window.open_document_path(document_path, check_unsaved=False) window.show() return app.exec() diff --git a/BEdit/src/bedit/gui/controllers/commands.py b/BEdit/src/bedit/gui/controllers/commands.py index 29fc295..0273a80 100644 --- a/BEdit/src/bedit/gui/controllers/commands.py +++ b/BEdit/src/bedit/gui/controllers/commands.py @@ -311,24 +311,21 @@ class DeleteSelectionCommand(QUndoCommand): owner_id: str | None, blocks: dict[str, Component], connections: dict[str, Connection], - inputs: list[Port], - outputs: list[Port], + ports: list[Port], ) -> None: super().__init__("Delete selection") self.controller = controller self.owner_id = owner_id self.blocks = blocks self.connections = connections - self.inputs = inputs - self.outputs = outputs + self.ports = ports def redo(self) -> None: self.controller._delete_items( self.owner_id, set(self.blocks), set(self.connections), - {port.id for port in self.inputs}, - {port.id for port in self.outputs}, + {port.id for port in self.ports}, ) def undo(self) -> None: @@ -336,8 +333,7 @@ class DeleteSelectionCommand(QUndoCommand): self.owner_id, self.blocks, self.connections, - self.inputs, - self.outputs, + self.ports, ) @@ -361,7 +357,6 @@ class PasteSelectionCommand(QUndoCommand): self.blocks, self.connections, [], - [], ) def undo(self) -> None: @@ -370,7 +365,6 @@ class PasteSelectionCommand(QUndoCommand): set(self.blocks), set(self.connections), set(), - set(), ) diff --git a/BEdit/src/bedit/gui/controllers/document.py b/BEdit/src/bedit/gui/controllers/document.py index fb41c5b..cceb5b1 100644 --- a/BEdit/src/bedit/gui/controllers/document.py +++ b/BEdit/src/bedit/gui/controllers/document.py @@ -40,6 +40,7 @@ from bedit.core.model import ( Port, clone_component, ) +from bedit.core.bond_graph import infer_causality from bedit.core.simulation import Simulation from bedit.core.port_types import PortTypeRegistry from bedit.core.serializer import DocumentSerializer @@ -307,6 +308,7 @@ class DocumentController(QObject): properties={ "waypoints": [{"x": point.x(), "y": point.y()} for point in (waypoints or [])], }, + type=source_port.type, ) self.undo_stack.push(AddConnectionCommand(self, self.active_component_id, connection)) return connection.id @@ -324,6 +326,8 @@ class DocumentController(QObject): if original is None: raise ValueError("The connection no longer exists") port_type = self.connection_port_type(original) + if port_type == "power": + raise ValueError("Power bond connections cannot contain junctions") junction = Junction(str(uuid4()), position.x(), position.y(), port_type) first_properties = deepcopy(original.properties) first_properties["waypoints"] = [ @@ -338,6 +342,8 @@ class DocumentController(QObject): Endpoint(junction=junction.id), original.name, first_properties, + original.type, + original.causality, ) second = Connection( str(uuid4()), @@ -345,6 +351,8 @@ class DocumentController(QObject): original.target, "", second_properties, + original.type, + original.causality, ) self.undo_stack.push( SplitConnectionCommand( @@ -442,12 +450,14 @@ class DocumentController(QObject): component = self.active_component if component is None or component.implementation_kind != "graph": raise ValueError("Open a graph component before composing") + self._infer_active_graph_causality(component) self.simulation.compose(component.to_dict()) def compose_active_graph_source(self) -> tuple[str, str]: component = self.active_component if component is None or component.implementation_kind != "graph": raise ValueError("Open a graph component before exporting a model") + self._infer_active_graph_causality(component) return self.simulation.compose_source(component.to_dict()) def run_simulation( @@ -460,6 +470,7 @@ class DocumentController(QObject): component = self.active_component if component is None or component.implementation_kind != "graph": raise ValueError("Open a graph component before running a simulation") + self._infer_active_graph_causality(component) self.simulation.run_simulation( component.to_dict(), progress_callback, @@ -468,6 +479,34 @@ class DocumentController(QObject): error_callback, ) + def _infer_active_graph_causality(self, component: Component) -> None: + """Infer causality and copy the derived values into the live model.""" + + inferred = infer_causality(component.to_dict()) + causalities: dict[str, str] = {} + + def collect(serialized_component: dict) -> None: + graph = serialized_component.get("implementation", {}).get("graph", {}) + for connection in graph.get("connections", []): + causalities[str(connection["id"])] = str( + connection.get("causality", "none") + ) + for block in graph.get("blocks", []): + collect(block) + + collect(inferred) + changed = False + for nested_component in self._component_subtree(component): + for connection in nested_component.graph.connections.values(): + causality = causalities.get(connection.id, "none") + if connection.causality != causality: + connection.causality = causality + changed = True + if changed: + if self.document is not None: + self.document.validate() + self.documentReset.emit() + def set_route_waypoints(self, item_kind: str, item_id: str, waypoints: list[QPointF]) -> None: item = ( self.active_graph.connections @@ -577,25 +616,26 @@ class DocumentController(QObject): allows_multiple_connections=role == "source", ) if endpoint.interface is not None: - ports = owner.inputs if role == "source" else owner.outputs + orientations = ( + {"input", "indifferent"} + if role == "source" + else {"output", "indifferent"} + ) ports = [ - *ports, - *( - port - for port in owner.inputs - if port.orientation == "indifferent" and port not in ports - ), + port for port in owner.ports if port.orientation in orientations ] else: component = owner.graph.blocks.get(endpoint.block or "") if component is None: return None - ports = component.outputs if role == "source" else component.inputs - if role == "source": - ports = [ - *ports, - *(port for port in component.inputs if port.orientation == "indifferent"), - ] + orientations = ( + {"output", "indifferent"} + if role == "source" + else {"input", "indifferent"} + ) + ports = [ + port for port in component.ports if port.orientation in orientations + ] return next( (port for port in ports if port.id == (endpoint.interface or endpoint.port)), None ) @@ -619,12 +659,13 @@ class DocumentController(QObject): component = self.active_component if component is None or component.implementation_kind != "graph": raise ValueError("Open a graph component before adding an interface") - ports = component.inputs if direction == "input" else component.outputs + ports = [port for port in component.ports if port.orientation == direction] port = Port( id=f"{direction}-{uuid4().hex[:8]}", name=f"{direction.title()} {len(ports) + 1}", x=position.x(), y=position.y(), + orientation=direction, ) self.undo_stack.push(AddInterfacePortCommand(self, component.id, direction, port)) return port.id @@ -639,7 +680,7 @@ class DocumentController(QObject): owner = self.active_component if owner is None: return - port = next((p for p in (*owner.inputs, *owner.outputs) if p.id == port_id), None) + port = next((port for port in owner.ports if port.id == port_id), None) if port is not None and port.name != name: self.undo_stack.push( RenameInterfacePortCommand(self, owner.id, port_id, port.name, name) @@ -670,8 +711,7 @@ class DocumentController(QObject): def replace_active_text_definition( self, - inputs: list[Port], - outputs: list[Port], + ports: list[Port], declarations: str, initial_equations: str, equations: str, @@ -680,14 +720,17 @@ class DocumentController(QObject): component = self.active_component if component is None or component.implementation_kind != "text": raise ValueError("Only text-defined components can be edited here") - input_ids = [port.id for port in inputs] - output_ids = [port.id for port in outputs] - source_ids = output_ids + [ - port.id for port in inputs if port.orientation == "indifferent" - ] - if len(set(input_ids)) != len(input_ids) or len(set(output_ids)) != len(output_ids): - raise ValueError("Input and output IDs must be unique") - if any(not port.name.strip() for port in (*inputs, *outputs)): + port_ids = [port.id for port in ports] + input_ids = { + port.id for port in ports if port.orientation in {"input", "indifferent"} + } + output_ids = { + port.id for port in ports if port.orientation in {"output", "indifferent"} + } + source_ids = output_ids + if len(set(port_ids)) != len(port_ids): + raise ValueError("Port IDs must be unique") + if any(not port.name.strip() for port in ports): raise ValueError("Every port must have a name") parameter_ids = [parameter.id for parameter in parameters] if len(set(parameter_ids)) != len(parameter_ids): @@ -713,14 +756,12 @@ class DocumentController(QObject): f"Output {connection.source.port!r} is still connected in the containing graph" ) old = { - "inputs": [port.to_dict() for port in component.inputs], - "outputs": [port.to_dict() for port in component.outputs], + "ports": [port.to_dict() for port in component.ports], "source": deepcopy(component.source), "parameters": [parameter.to_dict() for parameter in component.parameters], } new = { - "inputs": [port.to_dict() for port in inputs], - "outputs": [port.to_dict() for port in outputs], + "ports": [port.to_dict() for port in ports], "source": { "equations": equations, "declarations": declarations, @@ -731,8 +772,7 @@ class DocumentController(QObject): if old != new: candidate = deepcopy(self.document) candidate_component = candidate.find_component(component.id) - candidate_component.inputs = deepcopy(inputs) - candidate_component.outputs = deepcopy(outputs) + candidate_component.ports = deepcopy(ports) candidate_component.source = deepcopy(new["source"]) candidate_component.parameters = deepcopy(parameters) candidate.validate() @@ -743,8 +783,7 @@ class DocumentController(QObject): component_id: str, name: str, icon: Icon, - inputs: list[Port], - outputs: list[Port], + ports: list[Port], show_subtree: bool, show_name: bool, ) -> None: @@ -759,8 +798,7 @@ class DocumentController(QObject): old = { "name": component.name, "icon": component.icon.to_dict(), - "inputs": [port.to_dict() for port in component.inputs], - "outputs": [port.to_dict() for port in component.outputs], + "ports": [port.to_dict() for port in component.ports], "show_subtree": component.show_subtree_in_library, "properties": deepcopy(component.properties), } @@ -772,8 +810,7 @@ class DocumentController(QObject): new = { "name": name, "icon": icon.to_dict(), - "inputs": [port.to_dict() for port in inputs], - "outputs": [port.to_dict() for port in outputs], + "ports": [port.to_dict() for port in ports], "show_subtree": show_subtree, "properties": properties, } @@ -782,8 +819,7 @@ class DocumentController(QObject): candidate_component = candidate.find_component(component_id) candidate_component.name = name candidate_component.icon = Icon.from_dict(icon.to_dict()) - candidate_component.inputs = deepcopy(inputs) - candidate_component.outputs = deepcopy(outputs) + candidate_component.ports = deepcopy(ports) candidate_component.properties = deepcopy(properties) candidate.validate() self.undo_stack.push(EditComponentAppearanceCommand(self, component_id, old, new)) @@ -853,19 +889,19 @@ class DocumentController(QObject): ) ) - def edit_component_ports( - self, component_id: str, inputs: list[Port], outputs: list[Port] - ) -> None: + def edit_component_ports(self, component_id: str, ports: list[Port]) -> None: if self.document is None: return component = self.document.find_component(component_id) if component is None: return - input_ids = {port.id for port in inputs} - output_ids = {port.id for port in outputs} - source_ids = output_ids | { - port.id for port in inputs if port.orientation == "indifferent" + input_ids = { + port.id for port in ports if port.orientation in {"input", "indifferent"} } + output_ids = { + port.id for port in ports if port.orientation in {"output", "indifferent"} + } + source_ids = output_ids parent = self.document.find_parent(component_id) if parent is not None: for connection in parent.graph.connections.values(): @@ -886,21 +922,18 @@ class DocumentController(QObject): raise ValueError("An interface output cannot be removed while connected") candidate = deepcopy(self.document) candidate_component = candidate.find_component(component_id) - candidate_component.inputs = deepcopy(inputs) - candidate_component.outputs = deepcopy(outputs) + candidate_component.ports = deepcopy(ports) candidate.validate() old = { "name": component.name, "icon": component.icon.to_dict(), - "inputs": [port.to_dict() for port in component.inputs], - "outputs": [port.to_dict() for port in component.outputs], + "ports": [port.to_dict() for port in component.ports], "show_subtree": component.show_subtree_in_library, "properties": deepcopy(component.properties), } new = { **old, - "inputs": [port.to_dict() for port in inputs], - "outputs": [port.to_dict() for port in outputs], + "ports": [port.to_dict() for port in ports], } if old != new: self.undo_stack.push(EditComponentAppearanceCommand(self, component_id, old, new)) @@ -930,8 +963,7 @@ class DocumentController(QObject): self, block_ids: set[str], connection_ids: set[str], - input_ids: set[str], - output_ids: set[str], + port_ids: set[str], ) -> None: component = self.active_component if component is None or component.implementation_kind != "graph": @@ -942,8 +974,8 @@ class DocumentController(QObject): if ( connection.source.block in block_ids or connection.target.block in block_ids - or connection.source.interface in input_ids - or connection.target.interface in output_ids + or connection.source.interface in port_ids + or connection.target.interface in port_ids ): all_connection_ids.add(connection.id) blocks = { @@ -954,9 +986,8 @@ class DocumentController(QObject): for connection_id in all_connection_ids if connection_id in graph.connections } - inputs = [port for port in component.inputs if port.id in input_ids] - outputs = [port for port in component.outputs if port.id in output_ids] - if not (blocks or connections or inputs or outputs): + ports = [port for port in component.ports if port.id in port_ids] + if not (blocks or connections or ports): return self.undo_stack.push( DeleteSelectionCommand( @@ -964,8 +995,7 @@ class DocumentController(QObject): component.id, blocks, connections, - inputs, - outputs, + ports, ) ) @@ -1003,6 +1033,8 @@ class DocumentController(QObject): target=Endpoint(block=id_map[source.target.block], port=source.target.port), name=source.name, properties=properties, + type=source.type, + causality=source.causality, ) connections[connection.id] = connection if blocks: @@ -1063,6 +1095,8 @@ class DocumentController(QObject): ), name=source.name, properties=properties, + type=source.type, + causality=source.causality, ) connections[connection.id] = connection @@ -1278,9 +1312,9 @@ class DocumentController(QObject): owner = self.document.find_component(owner_id) if owner is None: return - ports = owner.inputs if direction == "input" else owner.outputs - if all(existing.id != port.id for existing in ports): - ports.append(port) + port.orientation = direction + if all(existing.id != port.id for existing in owner.ports): + owner.ports.append(port) self.interfaceChanged.emit() self.documentReset.emit() @@ -1290,8 +1324,7 @@ class DocumentController(QObject): owner = self.document.find_component(owner_id) if owner is None: return - ports = owner.inputs if direction == "input" else owner.outputs - ports[:] = [port for port in ports if port.id != port_id] + owner.ports[:] = [port for port in owner.ports if port.id != port_id] self.interfaceChanged.emit() self.documentReset.emit() @@ -1301,7 +1334,7 @@ class DocumentController(QObject): owner = self.document.find_component(owner_id) if owner is None: return - port = next((p for p in (*owner.inputs, *owner.outputs) if p.id == port_id), None) + port = next((port for port in owner.ports if port.id == port_id), None) if port is not None: port.x, port.y = position.x(), position.y() self.interfaceChanged.emit() @@ -1311,7 +1344,7 @@ class DocumentController(QObject): owner = self.document.find_component(owner_id) if self.document else None if owner is None: return - port = next((p for p in (*owner.inputs, *owner.outputs) if p.id == port_id), None) + port = next((port for port in owner.ports if port.id == port_id), None) if port is not None: port.name = name self.interfaceChanged.emit() @@ -1342,8 +1375,7 @@ class DocumentController(QObject): return component.name = values["name"] component.icon = Icon.from_dict(values["icon"]) - component.inputs = [Port.from_dict(port) for port in values["inputs"]] - component.outputs = [Port.from_dict(port) for port in values["outputs"]] + component.ports = [Port.from_dict(port) for port in values["ports"]] component.show_subtree_in_library = values["show_subtree"] component.properties = deepcopy(values["properties"]) self.documentReset.emit() @@ -1374,8 +1406,7 @@ class DocumentController(QObject): owner_id: str | None, block_ids: set[str], connection_ids: set[str], - input_ids: set[str], - output_ids: set[str], + port_ids: set[str], ) -> None: if self.document is None: return @@ -1398,8 +1429,7 @@ class DocumentController(QObject): owner.graph.blocks.pop(block_id, None) for connection_id in connection_ids: owner.graph.connections.pop(connection_id, None) - owner.inputs[:] = [port for port in owner.inputs if port.id not in input_ids] - owner.outputs[:] = [port for port in owner.outputs if port.id not in output_ids] + owner.ports[:] = [port for port in owner.ports if port.id not in port_ids] if active_was_deleted: self.active_component_id = owner_id or next(iter(self.document.roots), None) self.activeGraphChanged.emit() @@ -1410,8 +1440,7 @@ class DocumentController(QObject): owner_id: str | None, blocks: dict[str, Component], connections: dict[str, Connection], - inputs: list[Port], - outputs: list[Port], + ports: list[Port], ) -> None: if self.document is None: return @@ -1423,10 +1452,8 @@ class DocumentController(QObject): return owner.graph.blocks.update(blocks) owner.graph.connections.update(connections) - existing_inputs = {port.id for port in owner.inputs} - existing_outputs = {port.id for port in owner.outputs} - owner.inputs.extend(port for port in inputs if port.id not in existing_inputs) - owner.outputs.extend(port for port in outputs if port.id not in existing_outputs) + existing_ports = {port.id for port in owner.ports} + owner.ports.extend(port for port in ports if port.id not in existing_ports) self.documentReset.emit() @staticmethod @@ -1441,8 +1468,7 @@ class DocumentController(QObject): component = self.document.find_component(component_id) if component is None: return - component.inputs = [Port.from_dict(item) for item in values["inputs"]] - component.outputs = [Port.from_dict(item) for item in values["outputs"]] + component.ports = [Port.from_dict(item) for item in values["ports"]] component.source = deepcopy(values["source"]) component.parameters = [ Parameter.from_dict(item) for item in values.get("parameters", []) diff --git a/BEdit/src/bedit/gui/dialogs/component_options.py b/BEdit/src/bedit/gui/dialogs/component_options.py index 6862286..12bd1df 100644 --- a/BEdit/src/bedit/gui/dialogs/component_options.py +++ b/BEdit/src/bedit/gui/dialogs/component_options.py @@ -12,8 +12,7 @@ class ComponentOptionsDialog(QDialog): self.ui.setupUi(self) self.component = component self.edited_icon = component.icon - self.edited_inputs = component.inputs - self.edited_outputs = component.outputs + self.edited_ports = component.ports self.ui.nameEdit.setText(component.name) self.ui.editIconButton.clicked.connect(self.edit_icon) self.ui.showSubtreeCheckBox.setChecked(component.show_subtree_in_library) @@ -22,13 +21,11 @@ class ComponentOptionsDialog(QDialog): def edit_icon(self) -> None: working = Component.from_dict(self.component.to_dict()) working.icon = self.edited_icon - working.inputs = self.edited_inputs - working.outputs = self.edited_outputs + working.ports = self.edited_ports dialog = IconEditorDialog(working, self) if dialog.exec() == dialog.DialogCode.Accepted: self.edited_icon = dialog.icon - self.edited_inputs = dialog.inputs - self.edited_outputs = dialog.outputs + self.edited_ports = dialog.ports def accept(self) -> None: if not self.ui.nameEdit.text().strip(): diff --git a/BEdit/src/bedit/gui/dialogs/port_options.py b/BEdit/src/bedit/gui/dialogs/port_options.py index 6b6e8c7..1e9493c 100644 --- a/BEdit/src/bedit/gui/dialogs/port_options.py +++ b/BEdit/src/bedit/gui/dialogs/port_options.py @@ -14,13 +14,6 @@ from bedit.gui.generated.ui_port_options_dialog import Ui_PortOptionsDialog PORT_ROLE = Qt.ItemDataRole.UserRole -def _oriented_copy(port: Port, fallback: str) -> Port: - copied = deepcopy(port) - if copied.orientation != "indifferent": - copied.orientation = fallback - return copied - - class PortOptionsDialog(QDialog): """Unified editor for a component's typed, oriented ports.""" @@ -32,10 +25,7 @@ class PortOptionsDialog(QDialog): self.ui.setupUi(self) self.setWindowTitle(f"Port Options — {component.name}") self.read_only = read_only - self.ports: list[tuple[Port, str]] = [ - *((_oriented_copy(port, "input"), port.orientation or "input") for port in component.inputs), - *((_oriented_copy(port, "output"), "output") for port in component.outputs), - ] + self.ports = deepcopy(component.ports) self._loading = False self.ui.typeCombo.clear() for port_type in PortTypeRegistry.all(): @@ -77,22 +67,10 @@ class PortOptionsDialog(QDialog): self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).hide() self._rebuild_list(0 if self.ports else -1) - @property - def inputs(self) -> list[Port]: - return [ - port - for port, orientation in self.ports - if orientation in {"input", "indifferent"} - ] - - @property - def outputs(self) -> list[Port]: - return [port for port, orientation in self.ports if orientation == "output"] - def _rebuild_list(self, row: int = -1) -> None: self.ui.portList.clear() - for port, orientation in self.ports: - item = QListWidgetItem(f"{port.name} [{orientation}, {port.type}]") + for port in self.ports: + item = QListWidgetItem(f"{port.name} [{port.orientation}, {port.type}]") item.setData(PORT_ROLE, port.id) self.ui.portList.addItem(item) self.ui.portList.setCurrentRow(min(row, len(self.ports) - 1)) @@ -102,10 +80,12 @@ class PortOptionsDialog(QDialog): self._loading = True enabled = 0 <= row < len(self.ports) if enabled: - port, orientation = self.ports[row] + port = self.ports[row] self.ui.nameEdit.setText(port.name) self.ui.typeCombo.setCurrentIndex(self.ui.typeCombo.findData(port.type)) - self.ui.orientationCombo.setCurrentIndex(self.ui.orientationCombo.findData(orientation)) + self.ui.orientationCombo.setCurrentIndex( + self.ui.orientationCombo.findData(port.orientation) + ) self.ui.multipleConnectionsCheckBox.setChecked( port.allows_multiple_connections ) @@ -161,7 +141,7 @@ class PortOptionsDialog(QDialog): row = self.ui.portList.currentRow() if self._loading or not (0 <= row < len(self.ports)): return - port, _orientation = self.ports[row] + port = self.ports[row] port.name = self.ui.nameEdit.text() port.type = self.ui.typeCombo.currentData() port.allows_multiple_connections = self.ui.multipleConnectionsCheckBox.isChecked() @@ -179,18 +159,12 @@ class PortOptionsDialog(QDialog): ) orientation = self.ui.orientationCombo.currentData() port.orientation = orientation - self.ports[row] = (port, orientation) - self.ui.portList.item(row).setText( - f"{port.name} [{self.ports[row][1]}, {port.type}]" - ) + self.ui.portList.item(row).setText(f"{port.name} [{port.orientation}, {port.type}]") self.edited.emit() - def set_ports(self, inputs: list[Port], outputs: list[Port]) -> None: + def set_ports(self, ports: list[Port]) -> None: self._loading = True - self.ports = [ - *((_oriented_copy(port, "input"), port.orientation or "input") for port in inputs), - *((_oriented_copy(port, "output"), "output") for port in outputs), - ] + self.ports = deepcopy(ports) self._loading = False self._rebuild_list(0 if self.ports else -1) @@ -201,7 +175,7 @@ class PortOptionsDialog(QDialog): properties={"iconPosition": {"x": 0.0, "y": 0.0}}, type="signal", ) - self.ports.append((port, "input")) + self.ports.append(port) self._rebuild_list(len(self.ports) - 1) self.ui.nameEdit.selectAll() self.ui.nameEdit.setFocus() @@ -216,7 +190,7 @@ class PortOptionsDialog(QDialog): def accept(self) -> None: self._store_current() - if any(not port.name.strip() for port, _orientation in self.ports): + if any(not port.name.strip() for port in self.ports): QMessageBox.warning(self, "Invalid port", "Every port must have a name.") return super().accept() diff --git a/BEdit/src/bedit/gui/editors/text_definition.py b/BEdit/src/bedit/gui/editors/text_definition.py index c165b6c..9dbcdcc 100644 --- a/BEdit/src/bedit/gui/editors/text_definition.py +++ b/BEdit/src/bedit/gui/editors/text_definition.py @@ -43,8 +43,8 @@ class TextDefinitionEditor(QWidget): return self.ui.initialEquationsEdit.toPlainText() @property - def ports(self) -> tuple[list[Port], list[Port]]: - return self.ui.portEditor.inputs, self.ui.portEditor.outputs + def ports(self) -> list[Port]: + return self.ui.portEditor.ports @property def parameters(self) -> list[Parameter]: @@ -55,17 +55,16 @@ class TextDefinitionEditor(QWidget): declarations: str, initial_equations: str, equations: str, - inputs: list[Port], - outputs: list[Port], + ports: list[Port], parameters: list[Parameter], ) -> None: self._loading = True self.ui.declarationsEdit.setPlainText(declarations) self.ui.initialEquationsEdit.setPlainText(initial_equations) self.ui.equationsEdit.setPlainText(equations) - self.ui.portEditor.set_ports(inputs, outputs) + self.ui.portEditor.set_ports(ports) self.ui.parameterEditor.set_parameters(parameters) - self._set_editor_symbols(inputs, outputs, parameters) + self._set_editor_symbols(ports, parameters) self._loading = False self.set_modified(False) @@ -85,18 +84,20 @@ class TextDefinitionEditor(QWidget): self._mark_modified() def _refresh_editor_symbols(self) -> None: - inputs, outputs = self.ports - self._set_editor_symbols(inputs, outputs, self.parameters) + self._set_editor_symbols(self.ports, self.parameters) def _set_editor_symbols( self, - inputs: list[Port], - outputs: list[Port], + ports: list[Port], parameters: list[Parameter], ) -> None: self._apply_editor_symbols( - [port.name for port in inputs], - [port.name for port in outputs], + [ + port.name + for port in ports + if port.orientation in {"input", "indifferent"} + ], + [port.name for port in ports if port.orientation == "output"], [parameter.name for parameter in parameters], ) diff --git a/BEdit/src/bedit/gui/graphics/icon_editor.py b/BEdit/src/bedit/gui/graphics/icon_editor.py index 4151624..0f87094 100644 --- a/BEdit/src/bedit/gui/graphics/icon_editor.py +++ b/BEdit/src/bedit/gui/graphics/icon_editor.py @@ -458,8 +458,7 @@ class IconEditorDialog(QDialog): self.ui.setupUi(self) self.setWindowTitle(f"Icon Editor — {component.name}") self.icon = Icon.from_dict(component.icon.to_dict()) - self.inputs = deepcopy(component.inputs) - self.outputs = deepcopy(component.outputs) + self.ports = deepcopy(component.ports) self.tool_group = QButtonGroup(self) self.tool_group.setExclusive(True) self.tool_group.addButton(self.ui.pointerButton) @@ -485,8 +484,10 @@ class IconEditorDialog(QDialog): self.scene.addRect(self.scene.sceneRect(), QPen(QColor("#64748b"), 0)).setZValue(-100) for element in self.icon.elements: self.scene.addItem(ShapeItem(element)) - self._add_ports(self.inputs, "input", 0.0) - self._add_ports(self.outputs, "output", self.icon.width) + input_side = [port for port in self.ports if port.orientation != "output"] + output_side = [port for port in self.ports if port.orientation == "output"] + self._add_ports(input_side, "input", 0.0) + self._add_ports(output_side, "output", self.icon.width) self._initial_fit_pending = True def showEvent(self, event) -> None: # noqa: N802 (Qt API name) diff --git a/BEdit/src/bedit/gui/graphics/workspace.py b/BEdit/src/bedit/gui/graphics/workspace.py index b1e3399..5e17a96 100644 --- a/BEdit/src/bedit/gui/graphics/workspace.py +++ b/BEdit/src/bedit/gui/graphics/workspace.py @@ -173,8 +173,16 @@ class ComponentGraphicsItem(QGraphicsObject): | QGraphicsItem.GraphicsItemFlag.ItemIsSelectable | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges ) - self.input_ports = self._create_ports(component.inputs, "target", 0.0) - self.output_ports = self._create_ports(component.outputs, "source", self.WIDTH) + self.input_ports = self._create_ports( + [port for port in component.ports if port.orientation != "output"], + "target", + 0.0, + ) + self.output_ports = self._create_ports( + [port for port in component.ports if port.orientation == "output"], + "source", + self.WIDTH, + ) self.setTransformOriginPoint(self.hitbox.center()) self.setRotation(component.rotation) self.name_label: NameLabelItem | None = None @@ -371,8 +379,10 @@ class ConnectionGraphicsItem(QGraphicsPathItem): super().__init__() self.connection_id = connection.id self.name = connection.name + self.connection_type = connection.type self.source_is_junction = connection.source.junction is not None self.target_is_junction = connection.target.junction is not None + self.causality = connection.causality self.controller = controller self.style = style or ConnectionStyle() self.start = QPointF() @@ -417,7 +427,11 @@ class ConnectionGraphicsItem(QGraphicsPathItem): self.setSelected(True) menu = QMenu() add_node_action = menu.addAction("Add Node") - add_junction_action = menu.addAction("Add Junction") + add_junction_action = ( + menu.addAction("Add Junction") + if self.connection_type != "power" + else None + ) menu.addSeparator() options_action = menu.addAction("Connection Options…") selected = menu.exec(event.screenPos()) @@ -425,7 +439,7 @@ class ConnectionGraphicsItem(QGraphicsPathItem): scene = self.scene() if isinstance(scene, GraphScene): scene.add_route_node("connection", self.connection_id, event.scenePos()) - elif selected is add_junction_action: + elif add_junction_action is not None and selected is add_junction_action: scene = self.scene() if isinstance(scene, GraphScene): scene.add_connection_junction(self.connection_id, event.scenePos()) @@ -512,12 +526,36 @@ class ConnectionGraphicsItem(QGraphicsPathItem): if self.style.arrow_style == "open": painter.drawLine(tip, right) + def _draw_causality_mark( + self, painter: QPainter, point: QPointF, direction: QPointF, *, warning: bool + ) -> None: + length = max(0.001, (direction.x() ** 2 + direction.y() ** 2) ** 0.5) + normal = QPointF(-direction.y() / length, direction.x() / length) + half_length = 6.0 + color = QColor("#c25a00") if warning else self.pen().color() + painter.setPen(QPen(color, max(2.0, self.pen().widthF()), Qt.PenStyle.SolidLine)) + painter.drawLine(point - normal * half_length, point + normal * half_length) + def paint(self, painter: QPainter, option, widget=None) -> None: super().paint(painter, option, widget) if self.style.arrow_at_target and not self.target_is_junction: self._draw_arrow(painter, self.end, self.end_direction) if self.style.arrow_at_source and not self.source_is_junction: self._draw_arrow(painter, self.start, -self.start_direction) + if self.causality in {"source", "warn_source"}: + self._draw_causality_mark( + painter, + self.start, + self.start_direction, + warning=self.causality == "warn_source", + ) + elif self.causality in {"target", "warn_target"}: + self._draw_causality_mark( + painter, + self.end, + self.end_direction, + warning=self.causality == "warn_target", + ) class WaypointHandle(QGraphicsEllipseItem): @@ -898,12 +936,12 @@ class GraphScene(QGraphicsScene): owner = self.controller.active_component if owner is None or owner.implementation_kind != "graph": return - for port in owner.inputs: + for port in (port for port in owner.ports if port.orientation != "output"): item = InterfaceTerminalItem(port, "input", self.controller) self.addItem(item) item.setPos(port.x, port.y) self.input_items[port.id] = item - for port in owner.outputs: + for port in (port for port in owner.ports if port.orientation == "output"): item = InterfaceTerminalItem(port, "output", self.controller) self.addItem(item) item.setPos(port.x, port.y) @@ -1224,15 +1262,16 @@ class GraphScene(QGraphicsScene): source_item: ComponentGraphicsItem, target_item: ComponentGraphicsItem ) -> None: source_ports = [ - *source_item.component.outputs, - *( - port - for port in source_item.component.inputs - if port.orientation == "indifferent" - ), + port + for port in source_item.component.ports + if port.orientation in {"output", "indifferent"} ] for output in source_ports: - for input_port in target_item.component.inputs: + for input_port in ( + port + for port in target_item.component.ports + if port.orientation in {"input", "indifferent"} + ): if not PortTypeRegistry.compatible( output.type, input_port.type, @@ -1267,7 +1306,11 @@ class GraphScene(QGraphicsScene): choices: list[ConnectionChoice] = [] interface = Endpoint(interface=terminal.port.id) if terminal.direction == "input": - for port in component.component.inputs: + for port in ( + port + for port in component.component.ports + if port.orientation in {"input", "indifferent"} + ): target = Endpoint(block=component.component_id, port=port.id) if not PortTypeRegistry.compatible( terminal.port.type, port.type, terminal.port.domain, port.domain @@ -1286,12 +1329,9 @@ class GraphScene(QGraphicsScene): ) else: ports = [ - *component.component.outputs, - *( - port - for port in component.component.inputs - if port.orientation == "indifferent" - ), + port + for port in component.component.ports + if port.orientation in {"output", "indifferent"} ] for port in ports: source = Endpoint(block=component.component_id, port=port.id) @@ -1319,7 +1359,11 @@ class GraphScene(QGraphicsScene): ) -> list[ConnectionChoice]: choices: list[ConnectionChoice] = [] source = junction.endpoint - for port in component.component.inputs: + for port in ( + port + for port in component.component.ports + if port.orientation in {"input", "indifferent"} + ): target = Endpoint(block=component.component_id, port=port.id) if not PortTypeRegistry.compatible(junction.junction.type, port.type): continue @@ -1600,6 +1644,8 @@ class GraphScene(QGraphicsScene): graphics = self.connection_items.get(connection_id) if connection is None or graphics is None: return + if connection.type == "power": + return snapped = _snapped(position) anchors = [graphics.start, *points, graphics.end] @@ -1850,8 +1896,7 @@ class GraphWorkspaceView(QGraphicsView): return blocks: set[str] = set() connections: set[str] = set() - inputs: set[str] = set() - outputs: set[str] = set() + ports: set[str] = set() annotations: set[str] = set() for item in self.scene().selectedItems(): if isinstance(item, ComponentGraphicsItem): @@ -1859,10 +1904,10 @@ class GraphWorkspaceView(QGraphicsView): elif isinstance(item, ConnectionGraphicsItem): connections.add(item.connection_id) elif isinstance(item, InterfaceTerminalItem): - (inputs if item.direction == "input" else outputs).add(item.port.id) + ports.add(item.port.id) elif isinstance(item, (AnnotationGraphicsItem, LineAnnotationGraphicsItem)): annotations.add(item.annotation_id) - self.controller.delete_selection(blocks, connections, inputs, outputs) + self.controller.delete_selection(blocks, connections, ports) self.controller.delete_annotations(annotations) def has_selected_components(self) -> bool: diff --git a/BEdit/src/bedit/gui/main_window.py b/BEdit/src/bedit/gui/main_window.py index 41903ef..b05ac8a 100644 --- a/BEdit/src/bedit/gui/main_window.py +++ b/BEdit/src/bedit/gui/main_window.py @@ -540,8 +540,7 @@ class MainWindow(QMainWindow): component.source.get("declarations", ""), component.source.get("initialEquations", ""), component.source.get("equations", ""), - component.inputs, - component.outputs, + component.ports, component.parameters, ) @@ -569,12 +568,11 @@ class MainWindow(QMainWindow): @Slot() def apply_text_definition(self) -> bool: try: - inputs, outputs = self.ui.textDefinitionEditor.ports + ports = self.ui.textDefinitionEditor.ports self._applying_text_definition = True try: self.document_controller.replace_active_text_definition( - inputs, - outputs, + ports, self.ui.textDefinitionEditor.declarations, self.ui.textDefinitionEditor.initial_equations, self.ui.textDefinitionEditor.equations, @@ -643,12 +641,23 @@ class MainWindow(QMainWindow): ) if not filename: return + self.open_document_path(Path(filename), check_unsaved=False) + + def open_document_path(self, path: Path, *, check_unsaved: bool = True) -> bool: + """Open a document path selected by the UI or supplied at startup.""" + + if check_unsaved and ( + not self._resolve_source_edits() or not self._maybe_save() + ): + return False try: - self.document_controller.load(Path(filename)) - self.log.info("Opened document %s", filename) + self.document_controller.load(path) + self.log.info("Opened document %s", path) except (OSError, ValueError) as error: - self.log.error("Could not open document %s: %s", filename, error) + self.log.error("Could not open document %s: %s", path, error) QMessageBox.critical(self, "Could not open graph", str(error)) + return False + return True @Slot() def save_document(self) -> bool: @@ -818,9 +827,8 @@ class MainWindow(QMainWindow): elif selected is ports_action: dialog = PortOptionsDialog(component, self) if dialog.exec() == dialog.DialogCode.Accepted: - old_inputs, old_outputs = deepcopy(component.inputs), deepcopy(component.outputs) - component.inputs = dialog.inputs - component.outputs = dialog.outputs + old_ports = deepcopy(component.ports) + component.ports = dialog.ports library = next( ( library @@ -834,7 +842,7 @@ class MainWindow(QMainWindow): library.document.validate() DocumentSerializer.save(library.document, Path(library.source_path)) except (OSError, ValueError) as error: - component.inputs, component.outputs = old_inputs, old_outputs + component.ports = old_ports self.log.error("Could not change library ports: %s", error) QMessageBox.warning(self, "Cannot change library ports", str(error)) self.library_tree_model.rebuild() @@ -876,7 +884,7 @@ class MainWindow(QMainWindow): return try: self.document_controller.edit_component_ports( - component_id, dialog.inputs, dialog.outputs + component_id, dialog.ports ) except ValueError as error: self.log.error("Could not change component ports: %s", error) @@ -912,8 +920,7 @@ class MainWindow(QMainWindow): component_id, dialog.ui.nameEdit.text().strip(), dialog.edited_icon, - dialog.edited_inputs, - dialog.edited_outputs, + dialog.edited_ports, dialog.ui.showSubtreeCheckBox.isChecked(), dialog.ui.showNameCheckBox.isChecked(), ) @@ -926,8 +933,7 @@ class MainWindow(QMainWindow): owner = self.document_controller.active_component if owner is None: return - ports = owner.inputs if direction == "input" else owner.outputs - port = next((item for item in ports if item.id == port_id), None) + port = next((item for item in owner.ports if item.id == port_id), None) if port is None: return dialog = ItemOptionsDialog(f"{direction.title()} Options", port.name, self) diff --git a/BEdit/untitled.bedit.json b/BEdit/untitled.bedit.json index ab15081..1c4e573 100644 --- a/BEdit/untitled.bedit.json +++ b/BEdit/untitled.bedit.json @@ -14,8 +14,7 @@ }, "rotation": 0.0, "interface": { - "inputs": [], - "outputs": [] + "ports": [] }, "parameters": [], "icon": { @@ -69,7 +68,7 @@ }, "rotation": 0.0, "interface": { - "inputs": [ + "ports": [ { "id": "port-2e1d884f", "name": "p", @@ -96,9 +95,7 @@ "orientation": "input", "domain": "power", "causality": "preferred effort out" - } - ], - "outputs": [ + }, { "id": "port-ca7716d5", "name": "state", @@ -193,7 +190,7 @@ }, "rotation": 0.0, "interface": { - "inputs": [ + "ports": [ { "id": "port-3e53bf80", "name": "p", @@ -221,8 +218,7 @@ "domain": "power", "causality": "indifferent" } - ], - "outputs": [] + ] }, "parameters": [], "icon": { @@ -275,7 +271,7 @@ }, "rotation": 0.0, "interface": { - "inputs": [ + "ports": [ { "id": "port-2e1d884f", "name": "p", @@ -303,8 +299,7 @@ "domain": "power", "causality": "indifferent" } - ], - "outputs": [] + ] }, "parameters": [ { @@ -371,8 +366,7 @@ }, "rotation": 0.0, "interface": { - "inputs": [], - "outputs": [ + "ports": [ { "id": "port-2e1d884f", "name": "p", @@ -473,7 +467,9 @@ "name": "", "properties": { "waypoints": [] - } + }, + "type": "power", + "causality": "none" }, { "id": "a73a99a1-54e2-461d-a9a7-2d6d11d500f4", @@ -488,7 +484,9 @@ "name": "", "properties": { "waypoints": [] - } + }, + "type": "power", + "causality": "none" }, { "id": "dd5fb1e2-b125-4b5f-a792-dac89dd89ad3", @@ -503,7 +501,9 @@ "name": "", "properties": { "waypoints": [] - } + }, + "type": "power", + "causality": "none" } ], "annotations": [],