Start with causality inference

This commit is contained in:
2026-07-22 13:20:49 +02:00
parent 09248421d0
commit 32154386e8
17 changed files with 493 additions and 266 deletions

View File

@@ -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

View File

@@ -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},

View 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

View File

@@ -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),

View File

@@ -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(

View File

@@ -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

View File

@@ -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()

View File

@@ -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(),
)

View File

@@ -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", [])

View File

@@ -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():

View File

@@ -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()

View File

@@ -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],
)

View File

@@ -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)

View File

@@ -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:

View File

@@ -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)

View File

@@ -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": [],