Start with causality inference
This commit is contained in:
134
BEdit/src/bedit/core/bond_graph.py
Normal file
134
BEdit/src/bedit/core/bond_graph.py
Normal file
@@ -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
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -1,4 +1,6 @@
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QCoreApplication, Qt
|
||||
from PySide6.QtGui import QColor, QPalette
|
||||
@@ -38,14 +40,26 @@ def apply_light_theme(app: QApplication) -> 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()
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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", [])
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user