Added array ports and junctions

This commit is contained in:
2026-07-20 15:00:06 +02:00
parent a067c85994
commit 495edd42f4
15 changed files with 1686 additions and 72 deletions

View File

@@ -60,6 +60,9 @@ 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 - Port removal or reorientation must be rejected when it would invalidate an
existing connection. existing connection.
- Connections reference port IDs, never port names. - Connections reference port IDs, never port names.
- 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.
- Graph interaction has separate Pointer and Connect modes. Port hints are only - Graph interaction has separate Pointer and Connect modes. Port hints are only
visible in Connect mode. Connecting two blocks opens the compatible port-pair visible in Connect mode. Connecting two blocks opens the compatible port-pair
chooser; explicit port clicks determine its default selection. Connections chooser; explicit port clicks determine its default selection. Connections

View File

@@ -16,6 +16,7 @@ class Port:
y: float = 0.0 y: float = 0.0
properties: dict[str, Any] = field(default_factory=dict) properties: dict[str, Any] = field(default_factory=dict)
type: str = "signal" type: str = "signal"
allows_multiple_connections: bool = False
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
return { return {
@@ -24,6 +25,7 @@ class Port:
"position": {"x": self.x, "y": self.y}, "position": {"x": self.x, "y": self.y},
"properties": self.properties, "properties": self.properties,
"type": self.type, "type": self.type,
"multipleConnections": self.allows_multiple_connections,
} }
@classmethod @classmethod
@@ -36,6 +38,7 @@ class Port:
y=float(position.get("y", 0.0)), y=float(position.get("y", 0.0)),
properties=dict(data.get("properties", {})), properties=dict(data.get("properties", {})),
type=str(data.get("type", "signal")), type=str(data.get("type", "signal")),
allows_multiple_connections=bool(data.get("multipleConnections", False)),
) )
@@ -109,10 +112,13 @@ class Endpoint:
block: str | None = None block: str | None = None
port: str | None = None port: str | None = None
interface: str | None = None interface: str | None = None
junction: str | None = None
def to_dict(self) -> dict[str, str]: def to_dict(self) -> dict[str, str]:
if self.interface is not None: if self.interface is not None:
return {"interface": self.interface} return {"interface": self.interface}
if self.junction is not None:
return {"junction": self.junction}
if self.block is None or self.port is None: if self.block is None or self.port is None:
raise ValueError("A block endpoint requires both block and port") raise ValueError("A block endpoint requires both block and port")
return {"block": self.block, "port": self.port} return {"block": self.block, "port": self.port}
@@ -121,6 +127,8 @@ class Endpoint:
def from_dict(cls, data: dict[str, Any]) -> "Endpoint": def from_dict(cls, data: dict[str, Any]) -> "Endpoint":
if "interface" in data: if "interface" in data:
return cls(interface=str(data["interface"])) return cls(interface=str(data["interface"]))
if "junction" in data:
return cls(junction=str(data["junction"]))
return cls(block=str(data["block"]), port=str(data["port"])) return cls(block=str(data["block"]), port=str(data["port"]))
@@ -176,6 +184,31 @@ class Parameter:
) )
@dataclass
class Junction:
id: str
x: float
y: float
type: str = "signal"
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"position": {"x": self.x, "y": self.y},
"type": self.type,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Junction":
position = data.get("position", {})
return cls(
id=str(data["id"]),
x=float(position.get("x", 0)),
y=float(position.get("y", 0)),
type=str(data.get("type", "signal")),
)
@dataclass @dataclass
class Annotation: class Annotation:
id: str id: str
@@ -221,12 +254,14 @@ class Graph:
blocks: dict[str, Component] = field(default_factory=dict) blocks: dict[str, Component] = field(default_factory=dict)
connections: dict[str, Connection] = field(default_factory=dict) connections: dict[str, Connection] = field(default_factory=dict)
annotations: dict[str, Annotation] = field(default_factory=dict) annotations: dict[str, Annotation] = field(default_factory=dict)
junctions: dict[str, Junction] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
return { return {
"blocks": [block.to_dict() for block in self.blocks.values()], "blocks": [block.to_dict() for block in self.blocks.values()],
"connections": [connection.to_dict() for connection in self.connections.values()], "connections": [connection.to_dict() for connection in self.connections.values()],
"annotations": [item.to_dict() for item in self.annotations.values()], "annotations": [item.to_dict() for item in self.annotations.values()],
"junctions": [junction.to_dict() for junction in self.junctions.values()],
} }
@classmethod @classmethod
@@ -235,18 +270,22 @@ class Graph:
blocks = [Component.from_dict(item) for item in data.get("blocks", [])] blocks = [Component.from_dict(item) for item in data.get("blocks", [])]
connections = [Connection.from_dict(item) for item in data.get("connections", [])] connections = [Connection.from_dict(item) for item in data.get("connections", [])]
annotations = [Annotation.from_dict(item) for item in data.get("annotations", [])] annotations = [Annotation.from_dict(item) for item in data.get("annotations", [])]
junctions = [Junction.from_dict(item) for item in data.get("junctions", [])]
if len({block.id for block in blocks}) != len(blocks): if len({block.id for block in blocks}) != len(blocks):
raise ValueError("A graph contains duplicate component IDs") raise ValueError("A graph contains duplicate component IDs")
if len({connection.id for connection in connections}) != len(connections): if len({connection.id for connection in connections}) != len(connections):
raise ValueError("A graph contains duplicate connection IDs") raise ValueError("A graph contains duplicate connection IDs")
if len({item.id for item in annotations}) != len(annotations): if len({item.id for item in annotations}) != len(annotations):
raise ValueError("A graph contains duplicate annotation IDs") raise ValueError("A graph contains duplicate annotation IDs")
if len({item.id for item in junctions}) != len(junctions):
raise ValueError("A graph contains duplicate junction IDs")
if any(item.kind not in {"box", "line", "text"} for item in annotations): if any(item.kind not in {"box", "line", "text"} for item in annotations):
raise ValueError("A graph contains an unknown annotation kind") raise ValueError("A graph contains an unknown annotation kind")
return cls( return cls(
blocks={block.id: block for block in blocks}, blocks={block.id: block for block in blocks},
connections={connection.id: connection for connection in connections}, connections={connection.id: connection for connection in connections},
annotations={item.id: item for item in annotations}, annotations={item.id: item for item in annotations},
junctions={junction.id: junction for junction in junctions},
) )
@@ -332,7 +371,7 @@ class GraphDocument:
@classmethod @classmethod
def empty(cls) -> "GraphDocument": def empty(cls) -> "GraphDocument":
return cls(metadata={"name": "Untitled"}) return cls(metadata={"name": "Current Document"})
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
return { return {
@@ -409,8 +448,18 @@ class GraphDocument:
raise ValueError(f"Component {owner.name} contains duplicate port IDs") raise ValueError(f"Component {owner.name} contains duplicate port IDs")
for port in (*owner.inputs, *owner.outputs): for port in (*owner.inputs, *owner.outputs):
PortTypeRegistry.get(port.type) PortTypeRegistry.get(port.type)
for junction in owner.graph.junctions.values():
PortTypeRegistry.get(junction.type)
endpoint_counts: dict[tuple[str, str, str], int] = {}
for connection in owner.graph.connections.values(): for connection in owner.graph.connections.values():
if connection.source.interface is not None: if connection.source.junction is not None:
junction = owner.graph.junctions.get(connection.source.junction)
if junction is None:
raise ValueError(
f"Connection {connection.id} uses an unknown source junction"
)
source_port = Port(junction.id, "Junction", type=junction.type)
elif connection.source.interface is not None:
if connection.source.interface not in input_ids: if connection.source.interface not in input_ids:
raise ValueError(f"Connection {connection.id} uses an unknown interface input") 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(p for p in owner.inputs if p.id == connection.source.interface)
@@ -419,7 +468,19 @@ class GraphDocument:
if source is None or connection.source.port not in {p.id for p in source.outputs}: if source is None or connection.source.port not in {p.id for p in source.outputs}:
raise ValueError(f"Connection {connection.id} uses an unknown block output") raise ValueError(f"Connection {connection.id} uses an unknown block output")
source_port = next(p for p in source.outputs if p.id == connection.source.port) source_port = next(p for p in source.outputs if p.id == connection.source.port)
if connection.target.interface is not None: if connection.target.junction is not None:
junction = owner.graph.junctions.get(connection.target.junction)
if junction is None:
raise ValueError(
f"Connection {connection.id} uses an unknown target junction"
)
target_port = Port(
junction.id,
"Junction",
type=junction.type,
allows_multiple_connections=False,
)
elif connection.target.interface is not None:
if connection.target.interface not in output_ids: if connection.target.interface not in output_ids:
raise ValueError(f"Connection {connection.id} uses an unknown interface output") raise ValueError(f"Connection {connection.id} uses an unknown interface output")
target_port = next(p for p in owner.outputs if p.id == connection.target.interface) target_port = next(p for p in owner.outputs if p.id == connection.target.interface)
@@ -430,6 +491,40 @@ class GraphDocument:
target_port = next(p for p in target.inputs if p.id == connection.target.port) target_port = next(p for p in target.inputs if p.id == connection.target.port)
if not PortTypeRegistry.compatible(source_port.type, target_port.type): if not PortTypeRegistry.compatible(source_port.type, target_port.type):
raise ValueError(f"Connection {connection.id} joins incompatible port types") raise ValueError(f"Connection {connection.id} joins incompatible port types")
source_key = (
"source-junction"
if connection.source.junction is not None
else "source-interface"
if connection.source.interface is not None
else "source-block",
connection.source.block or "",
connection.source.junction
or connection.source.interface
or connection.source.port
or "",
)
target_key = (
"target-junction"
if connection.target.junction is not None
else "target-interface"
if connection.target.interface is not None
else "target-block",
connection.target.block or "",
connection.target.junction
or connection.target.interface
or connection.target.port
or "",
)
endpoint_counts[source_key] = endpoint_counts.get(source_key, 0) + 1
endpoint_counts[target_key] = endpoint_counts.get(target_key, 0) + 1
if (
endpoint_counts[target_key] > 1
and not target_port.allows_multiple_connections
):
raise ValueError(
f"Input {target_port.name!r} has multiple incoming connections "
"but does not allow them"
)
def clone_component(source: Component) -> Component: def clone_component(source: Component) -> Component:
@@ -438,10 +533,15 @@ def clone_component(source: Component) -> Component:
def clone(current: Component) -> Component: def clone(current: Component) -> Component:
child_pairs = [(child, clone(child)) for child in current.graph.blocks.values()] child_pairs = [(child, clone(child)) for child in current.graph.blocks.values()]
child_ids = {old.id: new.id for old, new in child_pairs} child_ids = {old.id: new.id for old, new in child_pairs}
junction_ids = {
junction.id: str(uuid4()) for junction in current.graph.junctions.values()
}
def remap(endpoint: Endpoint) -> Endpoint: def remap(endpoint: Endpoint) -> Endpoint:
if endpoint.interface is not None: if endpoint.interface is not None:
return endpoint return endpoint
if endpoint.junction is not None:
return Endpoint(junction=junction_ids[endpoint.junction])
return Endpoint(block=child_ids[endpoint.block or ""], port=endpoint.port) return Endpoint(block=child_ids[endpoint.block or ""], port=endpoint.port)
graph = Graph( graph = Graph(
@@ -472,6 +572,12 @@ def clone_component(source: Component) -> Component:
for annotation in current.graph.annotations.values() for annotation in current.graph.annotations.values()
for new_id in [str(uuid4())] for new_id in [str(uuid4())]
}, },
junctions={
junction_ids[junction.id]: Junction(
junction_ids[junction.id], junction.x, junction.y, junction.type
)
for junction in current.graph.junctions.values()
},
) )
return Component( return Component(
id=str(uuid4()), id=str(uuid4()),
@@ -479,11 +585,27 @@ def clone_component(source: Component) -> Component:
x=current.x, x=current.x,
y=current.y, y=current.y,
inputs=[ inputs=[
Port(port.id, port.name, port.x, port.y, deepcopy(port.properties), port.type) Port(
port.id,
port.name,
port.x,
port.y,
deepcopy(port.properties),
port.type,
port.allows_multiple_connections,
)
for port in current.inputs for port in current.inputs
], ],
outputs=[ outputs=[
Port(port.id, port.name, port.x, port.y, deepcopy(port.properties), port.type) Port(
port.id,
port.name,
port.x,
port.y,
deepcopy(port.properties),
port.type,
port.allows_multiple_connections,
)
for port in current.outputs for port in current.outputs
], ],
icon=Icon.from_dict(current.icon.to_dict()), icon=Icon.from_dict(current.icon.to_dict()),

View File

@@ -28,7 +28,8 @@
"y": 64.0 "y": 64.0
} }
}, },
"type": "signal" "type": "signal",
"multipleConnections": false
} }
], ],
"outputs": [ "outputs": [
@@ -45,7 +46,8 @@
"y": 40.0 "y": 40.0
} }
}, },
"type": "signal" "type": "signal",
"multipleConnections": false
} }
] ]
}, },
@@ -131,7 +133,8 @@
"y": 64.0 "y": 64.0
} }
}, },
"type": "signal" "type": "signal",
"multipleConnections": false
} }
], ],
"outputs": [ "outputs": [
@@ -148,7 +151,8 @@
"y": 40.0 "y": 40.0
} }
}, },
"type": "signal" "type": "signal",
"multipleConnections": false
} }
] ]
}, },
@@ -210,6 +214,222 @@
] ]
} }
} }
},
{
"id": "afdc7696-622b-47e4-b2c3-28ec60786bf2",
"name": "integrate",
"position": {
"x": 0.0,
"y": 0.0
},
"rotation": 0.0,
"interface": {
"inputs": [
{
"id": "port-12903207",
"name": "in",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {
"iconPosition": {
"x": 64.0,
"y": 64.0
}
},
"type": "signal",
"multipleConnections": false
}
],
"outputs": [
{
"id": "port-b3370b1a",
"name": "out",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {
"iconPosition": {
"x": 88.0,
"y": 40.0
}
},
"type": "signal",
"multipleConnections": false
}
]
},
"icon": {
"shape": "rectangle",
"fill": "#f4f4f4",
"border": "#303030",
"text": "Text",
"size": {
"width": 128.0,
"height": 128.0
},
"elements": [
{
"type": "rectangle",
"x": 32.0,
"y": 32.0,
"width": 64.0,
"height": 64.0,
"fill": "#f4f4f4",
"stroke": "#303030",
"lineWidth": 1.5,
"lineStyle": "solid",
"cornerRadius": 5.0
},
{
"type": "text",
"x": 64.0,
"y": 40.0,
"width": 28.0,
"height": 48.0,
"text": "dt",
"color": "#00007f",
"fontSize": 18.0,
"lineStyle": "solid",
"lineWidth": 1.5,
"stroke": "#00007f",
"fill": "#ffffff"
},
{
"type": "text",
"x": 40.0,
"y": 32.0,
"width": 28.0,
"height": 54.0,
"fill": "none",
"stroke": "#00007f",
"lineWidth": 1.5,
"lineStyle": "none",
"text": "\u222b",
"fontSize": 32.0,
"color": "#00007f"
}
]
},
"properties": {
"showName": false
},
"library": {
"showSubtree": true
},
"implementation": {
"kind": "text",
"source": {
"equations": "initial out = initial;\nder(out) = in;",
"parameters": [
{
"id": "parameter-6bdc1c76",
"name": "initial",
"type": "real",
"value": "0"
}
]
}
}
},
{
"id": "f6b8e5c9-4d13-4f7b-b574-e00ca96e42c3",
"name": "add",
"position": {
"x": 0.0,
"y": 0.0
},
"rotation": 0.0,
"interface": {
"inputs": [
{
"id": "port-c995845c",
"name": "in",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {
"iconPosition": {
"x": 64.0,
"y": 64.0
}
},
"type": "signal",
"multipleConnections": true
}
],
"outputs": [
{
"id": "port-a2f0dea6",
"name": "out",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {
"iconPosition": {
"x": 80.0,
"y": 48.0
}
},
"type": "signal",
"multipleConnections": false
}
]
},
"icon": {
"shape": "rectangle",
"fill": "#f4f4f4",
"border": "#303030",
"text": "Text",
"size": {
"width": 128.0,
"height": 128.0
},
"elements": [
{
"type": "circle",
"x": 40.0,
"y": 40.0,
"width": 48.0,
"height": 48.0,
"fill": "#f4f4f4",
"stroke": "#303030",
"lineWidth": 1.5,
"lineStyle": "solid"
},
{
"type": "text",
"x": 48.0,
"y": 48.0,
"width": 32.0,
"height": 32.0,
"fill": "none",
"stroke": "#00007f",
"lineWidth": 1.5,
"lineStyle": "none",
"text": "+",
"fontSize": 18.0,
"color": "#00007f"
}
]
},
"properties": {
"showName": false
},
"library": {
"showSubtree": true
},
"implementation": {
"kind": "text",
"source": {
"equations": "out = sum(in[i] for i in 1:in.N);",
"parameters": []
}
}
} }
] ]
} }

View File

@@ -1,7 +1,7 @@
from PySide6.QtCore import QPointF from PySide6.QtCore import QPointF
from PySide6.QtGui import QUndoCommand from PySide6.QtGui import QUndoCommand
from bedit.core.model import Annotation, Component, Connection, Port from bedit.core.model import Annotation, Component, Connection, Junction, Port
class AddComponentCommand(QUndoCommand): class AddComponentCommand(QUndoCommand):
@@ -76,6 +76,32 @@ class AddConnectionCommand(QUndoCommand):
self.controller._remove_connection(self.owner_id, self.connection.id) self.controller._remove_connection(self.owner_id, self.connection.id)
class SplitConnectionCommand(QUndoCommand):
def __init__(
self,
controller,
owner_id: str,
original: Connection,
junction: Junction,
first: Connection,
second: Connection,
) -> None:
super().__init__("Add connection junction")
self.controller, self.owner_id = controller, owner_id
self.original, self.junction = original, junction
self.first, self.second = first, second
def redo(self) -> None:
self.controller._split_connection(
self.owner_id, self.original.id, self.junction, self.first, self.second
)
def undo(self) -> None:
self.controller._restore_split_connection(
self.owner_id, self.original, self.junction.id, self.first.id, self.second.id
)
class AddAnnotationCommand(QUndoCommand): class AddAnnotationCommand(QUndoCommand):
def __init__(self, controller, owner_id: str, annotation: Annotation) -> None: def __init__(self, controller, owner_id: str, annotation: Annotation) -> None:
super().__init__(f"Draw {annotation.kind}") super().__init__(f"Draw {annotation.kind}")

View File

@@ -23,6 +23,7 @@ from bedit.gui.controllers.commands import (
RenameInterfacePortCommand, RenameInterfacePortCommand,
ReplaceSourceCommand, ReplaceSourceCommand,
RotateComponentsCommand, RotateComponentsCommand,
SplitConnectionCommand,
) )
from bedit.core.model import ( from bedit.core.model import (
Annotation, Annotation,
@@ -31,6 +32,7 @@ from bedit.core.model import (
Endpoint, Endpoint,
GraphDocument, GraphDocument,
Icon, Icon,
Junction,
Parameter, Parameter,
Port, Port,
clone_component, clone_component,
@@ -228,6 +230,20 @@ class DocumentController(QObject):
MoveComponentCommand(self, self.active_component_id, component_id, old, new) MoveComponentCommand(self, self.active_component_id, component_id, old, new)
) )
def move_junction(self, junction_id: str, old: QPointF, new: QPointF) -> None:
if old != new and self.active_component_id is not None:
self.undo_stack.push(
EditGraphItemCommand(
self,
self.active_component_id,
"junction_geometry",
junction_id,
{"x": old.x(), "y": old.y()},
{"x": new.x(), "y": new.y()},
"Move connection junction",
)
)
def rotate_components(self, component_ids: set[str]) -> None: def rotate_components(self, component_ids: set[str]) -> None:
if self.active_component is None or self.active_component_id is None: if self.active_component is None or self.active_component_id is None:
return return
@@ -254,6 +270,14 @@ class DocumentController(QObject):
raise ValueError("A connection endpoint no longer exists") raise ValueError("A connection endpoint no longer exists")
if not PortTypeRegistry.compatible(source_port.type, target_port.type): if not PortTypeRegistry.compatible(source_port.type, target_port.type):
raise ValueError(f"Cannot connect {source_port.type!r} to {target_port.type!r}") raise ValueError(f"Cannot connect {source_port.type!r} to {target_port.type!r}")
if not self.endpoint_accepts_connection(source, "source"):
raise ValueError(
f"Port {source_port.name!r} already has a connection; enable multiple connections first"
)
if not self.endpoint_accepts_connection(target, "target"):
raise ValueError(
f"Port {target_port.name!r} already has a connection; enable multiple connections first"
)
connection = Connection( connection = Connection(
str(uuid4()), str(uuid4()),
source, source,
@@ -265,6 +289,53 @@ class DocumentController(QObject):
self.undo_stack.push(AddConnectionCommand(self, self.active_component_id, connection)) self.undo_stack.push(AddConnectionCommand(self, self.active_component_id, connection))
return connection.id return connection.id
def split_connection(
self,
connection_id: str,
position: QPointF,
first_waypoints: list[QPointF],
second_waypoints: list[QPointF],
) -> str:
if self.active_component_id is None:
raise ValueError("There is no active graph")
original = self.active_graph.connections.get(connection_id)
if original is None:
raise ValueError("The connection no longer exists")
port_type = self.connection_port_type(original)
junction = Junction(str(uuid4()), position.x(), position.y(), port_type)
first_properties = deepcopy(original.properties)
first_properties["waypoints"] = [
{"x": point.x(), "y": point.y()} for point in first_waypoints
]
second_properties = {
"waypoints": [{"x": point.x(), "y": point.y()} for point in second_waypoints]
}
first = Connection(
str(uuid4()),
original.source,
Endpoint(junction=junction.id),
original.name,
first_properties,
)
second = Connection(
str(uuid4()),
Endpoint(junction=junction.id),
original.target,
"",
second_properties,
)
self.undo_stack.push(
SplitConnectionCommand(
self,
self.active_component_id,
original,
junction,
first,
second,
)
)
return junction.id
def add_annotation( def add_annotation(
self, self,
kind: str, kind: str,
@@ -405,6 +476,16 @@ class DocumentController(QObject):
owner = self.active_component owner = self.active_component
if owner is None: if owner is None:
return None return None
if endpoint.junction is not None:
junction = owner.graph.junctions.get(endpoint.junction)
if junction is None:
return None
return Port(
junction.id,
"Junction",
type=junction.type,
allows_multiple_connections=role == "source",
)
if endpoint.interface is not None: if endpoint.interface is not None:
ports = owner.inputs if role == "source" else owner.outputs ports = owner.inputs if role == "source" else owner.outputs
else: else:
@@ -420,6 +501,17 @@ class DocumentController(QObject):
port = self._port_for_endpoint(connection.source, "source") port = self._port_for_endpoint(connection.source, "source")
return port.type if port is not None else "signal" return port.type if port is not None else "signal"
def endpoint_accepts_connection(self, endpoint: Endpoint, role: str) -> bool:
port = self._port_for_endpoint(endpoint, role)
if port is None:
return False
if role == "source" or port.allows_multiple_connections:
return True
return not any(
endpoint == (connection.source if role == "source" else connection.target)
for connection in self.active_graph.connections.values()
)
def add_interface_port(self, direction: str, position: QPointF) -> str: def add_interface_port(self, direction: str, position: QPointF) -> str:
component = self.active_component component = self.active_component
if component is None or component.implementation_kind != "graph": if component is None or component.implementation_kind != "graph":
@@ -526,6 +618,12 @@ class DocumentController(QObject):
}, },
} }
if old != new: 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.source = deepcopy(new["source"])
candidate.validate()
self.undo_stack.push(EditTextDefinitionCommand(self, component.id, old, new)) self.undo_stack.push(EditTextDefinitionCommand(self, component.id, old, new))
def edit_component_appearance( def edit_component_appearance(
@@ -849,6 +947,36 @@ class DocumentController(QObject):
self.connectionAdded.emit(connection.id) self.connectionAdded.emit(connection.id)
self.documentReset.emit() self.documentReset.emit()
def _split_connection(
self,
owner_id: str,
original_id: str,
junction: Junction,
first: Connection,
second: Connection,
) -> None:
graph = self._graph_for(owner_id)
graph.connections.pop(original_id, None)
graph.junctions[junction.id] = junction
graph.connections[first.id] = first
graph.connections[second.id] = second
self.documentReset.emit()
def _restore_split_connection(
self,
owner_id: str,
original: Connection,
junction_id: str,
first_id: str,
second_id: str,
) -> None:
graph = self._graph_for(owner_id)
graph.connections.pop(first_id, None)
graph.connections.pop(second_id, None)
graph.junctions.pop(junction_id, None)
graph.connections[original.id] = original
self.documentReset.emit()
def _remove_connection(self, owner_id: str, connection_id: str) -> None: def _remove_connection(self, owner_id: str, connection_id: str) -> None:
self._graph_for(owner_id).connections.pop(connection_id, None) self._graph_for(owner_id).connections.pop(connection_id, None)
if owner_id == self.active_component_id: if owner_id == self.active_component_id:
@@ -871,7 +999,11 @@ class DocumentController(QObject):
self, owner_id: str, item_kind: str, item_id: str, values: dict self, owner_id: str, item_kind: str, item_id: str, values: dict
) -> None: ) -> None:
graph = self._graph_for(owner_id) graph = self._graph_for(owner_id)
if item_kind == "connection_data": if item_kind == "junction_geometry":
item = graph.junctions.get(item_id)
if item is not None:
item.x, item.y = float(values["x"]), float(values["y"])
elif item_kind == "connection_data":
item = graph.connections.get(item_id) item = graph.connections.get(item_id)
if item is not None: if item is not None:
item.name = values["name"] item.name = values["name"]

View File

@@ -36,6 +36,7 @@ class PortOptionsDialog(QDialog):
self.ui.nameEdit.textEdited.connect(self._store_current) self.ui.nameEdit.textEdited.connect(self._store_current)
self.ui.typeCombo.currentIndexChanged.connect(self._store_current) self.ui.typeCombo.currentIndexChanged.connect(self._store_current)
self.ui.orientationCombo.currentIndexChanged.connect(self._store_current) self.ui.orientationCombo.currentIndexChanged.connect(self._store_current)
self.ui.multipleConnectionsCheckBox.toggled.connect(self._store_current)
self.ui.portSplitter.setSizes([250, 370]) self.ui.portSplitter.setSizes([250, 370])
if read_only: if read_only:
self.ui.addPortButton.setEnabled(False) self.ui.addPortButton.setEnabled(False)
@@ -43,6 +44,7 @@ class PortOptionsDialog(QDialog):
self.ui.nameEdit.setReadOnly(True) self.ui.nameEdit.setReadOnly(True)
self.ui.typeCombo.setEnabled(False) self.ui.typeCombo.setEnabled(False)
self.ui.orientationCombo.setEnabled(False) self.ui.orientationCombo.setEnabled(False)
self.ui.multipleConnectionsCheckBox.setEnabled(False)
self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Ok).setText("Close") self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Ok).setText("Close")
self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).hide() self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).hide()
self._rebuild_list(0 if self.ports else -1) self._rebuild_list(0 if self.ports else -1)
@@ -72,6 +74,9 @@ class PortOptionsDialog(QDialog):
self.ui.nameEdit.setText(port.name) self.ui.nameEdit.setText(port.name)
self.ui.typeCombo.setCurrentIndex(self.ui.typeCombo.findData(port.type)) 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(orientation))
self.ui.multipleConnectionsCheckBox.setChecked(
port.allows_multiple_connections
)
else: else:
self.ui.nameEdit.clear() self.ui.nameEdit.clear()
self._loading = False self._loading = False
@@ -83,6 +88,7 @@ class PortOptionsDialog(QDialog):
self.ui.nameEdit.setEnabled(enabled) self.ui.nameEdit.setEnabled(enabled)
self.ui.typeCombo.setEnabled(enabled) self.ui.typeCombo.setEnabled(enabled)
self.ui.orientationCombo.setEnabled(enabled) self.ui.orientationCombo.setEnabled(enabled)
self.ui.multipleConnectionsCheckBox.setEnabled(enabled)
def _store_current(self) -> None: def _store_current(self) -> None:
row = self.ui.portList.currentRow() row = self.ui.portList.currentRow()
@@ -91,6 +97,7 @@ class PortOptionsDialog(QDialog):
port, _orientation = self.ports[row] port, _orientation = self.ports[row]
port.name = self.ui.nameEdit.text() port.name = self.ui.nameEdit.text()
port.type = self.ui.typeCombo.currentData() port.type = self.ui.typeCombo.currentData()
port.allows_multiple_connections = self.ui.multipleConnectionsCheckBox.isChecked()
self.ports[row] = (port, self.ui.orientationCombo.currentData()) self.ports[row] = (port, self.ui.orientationCombo.currentData())
self.ui.portList.item(row).setText( self.ui.portList.item(row).setText(
f"{port.name} [{self.ports[row][1]}, {port.type}]" f"{port.name} [{self.ports[row][1]}, {port.type}]"

View File

@@ -2,7 +2,7 @@ from copy import deepcopy
from uuid import uuid4 from uuid import uuid4
from PySide6.QtCore import Qt, Signal from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QComboBox, QHeaderView, QTableWidgetItem, QWidget from PySide6.QtWidgets import QCheckBox, QComboBox, QHeaderView, QTableWidgetItem, QWidget
from bedit.core.model import Parameter, Port from bedit.core.model import Parameter, Port
from bedit.core.port_types import PortTypeRegistry from bedit.core.port_types import PortTypeRegistry
@@ -60,11 +60,13 @@ class TextDefinitionEditor(QWidget):
name_item = self.ui.portsTable.item(row, 0) name_item = self.ui.portsTable.item(row, 0)
type_combo = self.ui.portsTable.cellWidget(row, 1) type_combo = self.ui.portsTable.cellWidget(row, 1)
orientation_combo = self.ui.portsTable.cellWidget(row, 2) orientation_combo = self.ui.portsTable.cellWidget(row, 2)
multiple_check = self.ui.portsTable.cellWidget(row, 3)
port = Port( port = Port(
id=name_item.data(ID_ROLE), id=name_item.data(ID_ROLE),
name=name_item.text().strip(), name=name_item.text().strip(),
type=type_combo.currentData(), type=type_combo.currentData(),
properties=deepcopy(name_item.data(PROPERTIES_ROLE) or {}), properties=deepcopy(name_item.data(PROPERTIES_ROLE) or {}),
allows_multiple_connections=multiple_check.isChecked(),
) )
target = inputs if orientation_combo.currentData() == "input" else outputs target = inputs if orientation_combo.currentData() == "input" else outputs
target.append(port) target.append(port)
@@ -137,6 +139,10 @@ class TextDefinitionEditor(QWidget):
2, 2,
self._new_combo([("Input", "input"), ("Output", "output")], orientation), self._new_combo([("Input", "input"), ("Output", "output")], orientation),
) )
multiple = QCheckBox("Any", self)
multiple.setChecked(port.allows_multiple_connections)
multiple.toggled.connect(self._mark_modified)
table.setCellWidget(row, 3, multiple)
def _append_parameter(self, parameter: Parameter) -> None: def _append_parameter(self, parameter: Parameter) -> None:
table = self.ui.parametersTable table = self.ui.parametersTable

View File

@@ -15,10 +15,11 @@ from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor,
QFont, QFontDatabase, QGradient, QIcon, QFont, QFontDatabase, QGradient, QIcon,
QImage, QKeySequence, QLinearGradient, QPainter, QImage, QKeySequence, QLinearGradient, QPainter,
QPalette, QPixmap, QRadialGradient, QTransform) QPalette, QPixmap, QRadialGradient, QTransform)
from PySide6.QtWidgets import (QAbstractButton, QApplication, QComboBox, QDialog, from PySide6.QtWidgets import (QAbstractButton, QApplication, QCheckBox, QComboBox,
QDialogButtonBox, QFormLayout, QHBoxLayout, QLabel, QDialog, QDialogButtonBox, QFormLayout, QHBoxLayout,
QLineEdit, QListWidget, QListWidgetItem, QPushButton, QLabel, QLineEdit, QListWidget, QListWidgetItem,
QSizePolicy, QSplitter, QVBoxLayout, QWidget) QPushButton, QSizePolicy, QSplitter, QVBoxLayout,
QWidget)
class Ui_PortOptionsDialog(object): class Ui_PortOptionsDialog(object):
def setupUi(self, PortOptionsDialog): def setupUi(self, PortOptionsDialog):
@@ -94,11 +95,16 @@ class Ui_PortOptionsDialog(object):
self.portDetailsForm.setWidget(2, QFormLayout.ItemRole.FieldRole, self.orientationCombo) self.portDetailsForm.setWidget(2, QFormLayout.ItemRole.FieldRole, self.orientationCombo)
self.multipleConnectionsCheckBox = QCheckBox(self.portDetailsPanel)
self.multipleConnectionsCheckBox.setObjectName(u"multipleConnectionsCheckBox")
self.portDetailsForm.setWidget(3, QFormLayout.ItemRole.SpanningRole, self.multipleConnectionsCheckBox)
self.positionHintLabel = QLabel(self.portDetailsPanel) self.positionHintLabel = QLabel(self.portDetailsPanel)
self.positionHintLabel.setObjectName(u"positionHintLabel") self.positionHintLabel.setObjectName(u"positionHintLabel")
self.positionHintLabel.setWordWrap(True) self.positionHintLabel.setWordWrap(True)
self.portDetailsForm.setWidget(3, QFormLayout.ItemRole.SpanningRole, self.positionHintLabel) self.portDetailsForm.setWidget(4, QFormLayout.ItemRole.SpanningRole, self.positionHintLabel)
self.portSplitter.addWidget(self.portDetailsPanel) self.portSplitter.addWidget(self.portDetailsPanel)
@@ -130,6 +136,7 @@ class Ui_PortOptionsDialog(object):
self.orientationCombo.setItemText(0, QCoreApplication.translate("PortOptionsDialog", u"Input", None)) self.orientationCombo.setItemText(0, QCoreApplication.translate("PortOptionsDialog", u"Input", None))
self.orientationCombo.setItemText(1, QCoreApplication.translate("PortOptionsDialog", u"Output", None)) self.orientationCombo.setItemText(1, QCoreApplication.translate("PortOptionsDialog", u"Output", None))
self.multipleConnectionsCheckBox.setText(QCoreApplication.translate("PortOptionsDialog", u"Allow multiple connections", None))
self.positionHintLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"New ports start at (0, 0) in the icon editor.", None)) self.positionHintLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"New ports start at (0, 0) in the icon editor.", None))
# retranslateUi # retranslateUi

View File

@@ -52,18 +52,20 @@ class Ui_TextDefinitionEditor(object):
self.portsLayout = QVBoxLayout(self.portsGroup) self.portsLayout = QVBoxLayout(self.portsGroup)
self.portsLayout.setObjectName(u"portsLayout") self.portsLayout.setObjectName(u"portsLayout")
self.portsTable = QTableWidget(self.portsGroup) self.portsTable = QTableWidget(self.portsGroup)
if (self.portsTable.columnCount() < 3): if (self.portsTable.columnCount() < 4):
self.portsTable.setColumnCount(3) self.portsTable.setColumnCount(4)
__qtablewidgetitem = QTableWidgetItem() __qtablewidgetitem = QTableWidgetItem()
self.portsTable.setHorizontalHeaderItem(0, __qtablewidgetitem) self.portsTable.setHorizontalHeaderItem(0, __qtablewidgetitem)
__qtablewidgetitem1 = QTableWidgetItem() __qtablewidgetitem1 = QTableWidgetItem()
self.portsTable.setHorizontalHeaderItem(1, __qtablewidgetitem1) self.portsTable.setHorizontalHeaderItem(1, __qtablewidgetitem1)
__qtablewidgetitem2 = QTableWidgetItem() __qtablewidgetitem2 = QTableWidgetItem()
self.portsTable.setHorizontalHeaderItem(2, __qtablewidgetitem2) self.portsTable.setHorizontalHeaderItem(2, __qtablewidgetitem2)
__qtablewidgetitem3 = QTableWidgetItem()
self.portsTable.setHorizontalHeaderItem(3, __qtablewidgetitem3)
self.portsTable.setObjectName(u"portsTable") self.portsTable.setObjectName(u"portsTable")
self.portsTable.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.portsTable.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.portsTable.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.portsTable.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.portsTable.setColumnCount(3) self.portsTable.setColumnCount(4)
self.portsLayout.addWidget(self.portsTable) self.portsLayout.addWidget(self.portsTable)
@@ -94,12 +96,12 @@ class Ui_TextDefinitionEditor(object):
self.parametersTable = QTableWidget(self.parametersGroup) self.parametersTable = QTableWidget(self.parametersGroup)
if (self.parametersTable.columnCount() < 3): if (self.parametersTable.columnCount() < 3):
self.parametersTable.setColumnCount(3) self.parametersTable.setColumnCount(3)
__qtablewidgetitem3 = QTableWidgetItem()
self.parametersTable.setHorizontalHeaderItem(0, __qtablewidgetitem3)
__qtablewidgetitem4 = QTableWidgetItem() __qtablewidgetitem4 = QTableWidgetItem()
self.parametersTable.setHorizontalHeaderItem(1, __qtablewidgetitem4) self.parametersTable.setHorizontalHeaderItem(0, __qtablewidgetitem4)
__qtablewidgetitem5 = QTableWidgetItem() __qtablewidgetitem5 = QTableWidgetItem()
self.parametersTable.setHorizontalHeaderItem(2, __qtablewidgetitem5) self.parametersTable.setHorizontalHeaderItem(1, __qtablewidgetitem5)
__qtablewidgetitem6 = QTableWidgetItem()
self.parametersTable.setHorizontalHeaderItem(2, __qtablewidgetitem6)
self.parametersTable.setObjectName(u"parametersTable") self.parametersTable.setObjectName(u"parametersTable")
self.parametersTable.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.parametersTable.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.parametersTable.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.parametersTable.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
@@ -147,15 +149,17 @@ class Ui_TextDefinitionEditor(object):
___qtablewidgetitem1.setText(QCoreApplication.translate("TextDefinitionEditor", u"Type", None)) ___qtablewidgetitem1.setText(QCoreApplication.translate("TextDefinitionEditor", u"Type", None))
___qtablewidgetitem2 = self.portsTable.horizontalHeaderItem(2) ___qtablewidgetitem2 = self.portsTable.horizontalHeaderItem(2)
___qtablewidgetitem2.setText(QCoreApplication.translate("TextDefinitionEditor", u"Orientation", None)) ___qtablewidgetitem2.setText(QCoreApplication.translate("TextDefinitionEditor", u"Orientation", None))
___qtablewidgetitem3 = self.portsTable.horizontalHeaderItem(3)
___qtablewidgetitem3.setText(QCoreApplication.translate("TextDefinitionEditor", u"Multiple", None))
self.addPortButton.setText(QCoreApplication.translate("TextDefinitionEditor", u"Add Port", None)) self.addPortButton.setText(QCoreApplication.translate("TextDefinitionEditor", u"Add Port", None))
self.removePortButton.setText(QCoreApplication.translate("TextDefinitionEditor", u"Remove Port", None)) self.removePortButton.setText(QCoreApplication.translate("TextDefinitionEditor", u"Remove Port", None))
self.parametersGroup.setTitle(QCoreApplication.translate("TextDefinitionEditor", u"Parameters", None)) self.parametersGroup.setTitle(QCoreApplication.translate("TextDefinitionEditor", u"Parameters", None))
___qtablewidgetitem3 = self.parametersTable.horizontalHeaderItem(0) ___qtablewidgetitem4 = self.parametersTable.horizontalHeaderItem(0)
___qtablewidgetitem3.setText(QCoreApplication.translate("TextDefinitionEditor", u"Name", None)) ___qtablewidgetitem4.setText(QCoreApplication.translate("TextDefinitionEditor", u"Name", None))
___qtablewidgetitem4 = self.parametersTable.horizontalHeaderItem(1) ___qtablewidgetitem5 = self.parametersTable.horizontalHeaderItem(1)
___qtablewidgetitem4.setText(QCoreApplication.translate("TextDefinitionEditor", u"Type", None)) ___qtablewidgetitem5.setText(QCoreApplication.translate("TextDefinitionEditor", u"Type", None))
___qtablewidgetitem5 = self.parametersTable.horizontalHeaderItem(2) ___qtablewidgetitem6 = self.parametersTable.horizontalHeaderItem(2)
___qtablewidgetitem5.setText(QCoreApplication.translate("TextDefinitionEditor", u"Value", None)) ___qtablewidgetitem6.setText(QCoreApplication.translate("TextDefinitionEditor", u"Value", None))
self.addParameterButton.setText(QCoreApplication.translate("TextDefinitionEditor", u"Add Parameter", None)) self.addParameterButton.setText(QCoreApplication.translate("TextDefinitionEditor", u"Add Parameter", None))
self.removeParameterButton.setText(QCoreApplication.translate("TextDefinitionEditor", u"Remove Parameter", None)) self.removeParameterButton.setText(QCoreApplication.translate("TextDefinitionEditor", u"Remove Parameter", None))
pass pass

View File

@@ -33,7 +33,7 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from bedit.core.model import Annotation, Component, Connection, Endpoint, Port from bedit.core.model import Annotation, Component, Connection, Endpoint, Junction, Port
from bedit.gui.controllers.document import DocumentController from bedit.gui.controllers.document import DocumentController
from bedit.gui.dialogs.connection_chooser import ConnectionChooserDialog from bedit.gui.dialogs.connection_chooser import ConnectionChooserDialog
from bedit.gui.models.library_tree import COMPONENT_MIME_TYPE from bedit.gui.models.library_tree import COMPONENT_MIME_TYPE
@@ -79,6 +79,42 @@ class ConnectionPortItem(QGraphicsEllipseItem):
self.setToolTip(label) self.setToolTip(label)
class JunctionGraphicsItem(QGraphicsEllipseItem):
def __init__(self, junction: Junction, controller: DocumentController) -> None:
super().__init__(-5, -5, 10, 10)
self.junction = junction
self.controller = controller
self.endpoint = Endpoint(junction=junction.id)
self.drag_start = QPointF(junction.x, junction.y)
self.setPos(junction.x, junction.y)
self.setBrush(QColor("#303030"))
self.setPen(QPen(QColor("#ffffff"), 1))
self.setZValue(3)
self.setToolTip("Connection junction")
self.setCursor(Qt.CursorShape.SizeAllCursor)
self.setFlags(
QGraphicsItem.GraphicsItemFlag.ItemIsMovable
| QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
)
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
self.drag_start = self.pos()
super().mousePressEvent(event)
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
super().mouseReleaseEvent(event)
self.controller.move_junction(self.junction.id, self.drag_start, self.pos())
def itemChange(self, change, value): # noqa: N802
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
return _snapped(value)
if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
scene = self.scene()
if isinstance(scene, GraphScene):
scene.update_connections_for_junction(self.junction.id)
return super().itemChange(change, value)
class NameLabelItem(QGraphicsSimpleTextItem): class NameLabelItem(QGraphicsSimpleTextItem):
"""Movable italic name label whose position is stored by its owner.""" """Movable italic name label whose position is stored by its owner."""
@@ -248,8 +284,8 @@ class ComponentGraphicsItem(QGraphicsObject):
class InterfaceTerminalItem(QGraphicsObject): class InterfaceTerminalItem(QGraphicsObject):
WIDTH = 110.0 WIDTH = 128.0
HEIGHT = 36.0 HEIGHT = 32.0
def __init__(self, port: Port, direction: str, controller: DocumentController) -> None: def __init__(self, port: Port, direction: str, controller: DocumentController) -> None:
super().__init__() super().__init__()
@@ -330,6 +366,8 @@ class ConnectionGraphicsItem(QGraphicsPathItem):
super().__init__() super().__init__()
self.connection_id = connection.id self.connection_id = connection.id
self.name = connection.name self.name = connection.name
self.source_is_junction = connection.source.junction is not None
self.target_is_junction = connection.target.junction is not None
self.controller = controller self.controller = controller
self.style = style or ConnectionStyle() self.style = style or ConnectionStyle()
self.start = QPointF() self.start = QPointF()
@@ -374,6 +412,7 @@ class ConnectionGraphicsItem(QGraphicsPathItem):
self.setSelected(True) self.setSelected(True)
menu = QMenu() menu = QMenu()
add_node_action = menu.addAction("Add Node") add_node_action = menu.addAction("Add Node")
add_junction_action = menu.addAction("Add Junction")
menu.addSeparator() menu.addSeparator()
options_action = menu.addAction("Connection Options…") options_action = menu.addAction("Connection Options…")
selected = menu.exec(event.screenPos()) selected = menu.exec(event.screenPos())
@@ -381,6 +420,10 @@ class ConnectionGraphicsItem(QGraphicsPathItem):
scene = self.scene() scene = self.scene()
if isinstance(scene, GraphScene): if isinstance(scene, GraphScene):
scene.add_route_node("connection", self.connection_id, event.scenePos()) scene.add_route_node("connection", self.connection_id, event.scenePos())
elif selected is add_junction_action:
scene = self.scene()
if isinstance(scene, GraphScene):
scene.add_connection_junction(self.connection_id, event.scenePos())
elif selected is options_action: elif selected is options_action:
scene = self.scene() scene = self.scene()
if isinstance(scene, GraphScene): if isinstance(scene, GraphScene):
@@ -448,9 +491,9 @@ class ConnectionGraphicsItem(QGraphicsPathItem):
super().paint(painter, option, widget) super().paint(painter, option, widget)
painter.setPen(Qt.PenStyle.NoPen) painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(self.pen().color()) painter.setBrush(self.pen().color())
if self.style.arrow_at_target: if self.style.arrow_at_target and not self.target_is_junction:
painter.drawPolygon(self._arrow(self.end, self.end_direction, self.style.arrow_size)) painter.drawPolygon(self._arrow(self.end, self.end_direction, self.style.arrow_size))
if self.style.arrow_at_source: if self.style.arrow_at_source and not self.source_is_junction:
painter.drawPolygon( painter.drawPolygon(
self._arrow(self.start, -self.start_direction, self.style.arrow_size) self._arrow(self.start, -self.start_direction, self.style.arrow_size)
) )
@@ -799,8 +842,11 @@ class GraphScene(QGraphicsScene):
self.input_items: dict[str, InterfaceTerminalItem] = {} self.input_items: dict[str, InterfaceTerminalItem] = {}
self.output_items: dict[str, InterfaceTerminalItem] = {} self.output_items: dict[str, InterfaceTerminalItem] = {}
self.connection_items: dict[str, ConnectionGraphicsItem] = {} self.connection_items: dict[str, ConnectionGraphicsItem] = {}
self.junction_items: dict[str, JunctionGraphicsItem] = {}
self.annotation_items: dict[str, QGraphicsItem] = {} self.annotation_items: dict[str, QGraphicsItem] = {}
self.pending_connection_item: ComponentGraphicsItem | ConnectionPortItem | None = None self.pending_connection_item: (
ComponentGraphicsItem | ConnectionPortItem | JunctionGraphicsItem | None
) = None
self.pending_waypoints: list[QPointF] = [] self.pending_waypoints: list[QPointF] = []
self.pending_preview: QGraphicsPathItem | None = None self.pending_preview: QGraphicsPathItem | None = None
self.interaction_mode = "pointer" self.interaction_mode = "pointer"
@@ -822,6 +868,7 @@ class GraphScene(QGraphicsScene):
self.input_items.clear() self.input_items.clear()
self.output_items.clear() self.output_items.clear()
self.connection_items.clear() self.connection_items.clear()
self.junction_items.clear()
self.annotation_items.clear() self.annotation_items.clear()
self.pending_connection_item = None self.pending_connection_item = None
self.pending_waypoints.clear() self.pending_waypoints.clear()
@@ -844,6 +891,10 @@ class GraphScene(QGraphicsScene):
self.addItem(item) self.addItem(item)
item.setPos(component.x, component.y) item.setPos(component.x, component.y)
self.component_items[component.id] = item self.component_items[component.id] = item
for junction in owner.graph.junctions.values():
item = JunctionGraphicsItem(junction, self.controller)
self.addItem(item)
self.junction_items[junction.id] = item
for connection in owner.graph.connections.values(): for connection in owner.graph.connections.values():
item = ConnectionGraphicsItem( item = ConnectionGraphicsItem(
connection, connection,
@@ -892,6 +943,15 @@ class GraphScene(QGraphicsScene):
if item_kind in {"connection", "connection_data"}: if item_kind in {"connection", "connection_data"}:
self.update_connection(item_id) self.update_connection(item_id)
return return
if item_kind == "junction_geometry":
junction = self.controller.active_graph.junctions.get(item_id)
graphics = self.junction_items.get(item_id)
if junction is not None and graphics is not None:
position = QPointF(junction.x, junction.y)
if graphics.pos() != position:
graphics.setPos(position)
self.update_connections_for_junction(item_id)
return
annotation = self.controller.active_graph.annotations.get(item_id) annotation = self.controller.active_graph.annotations.get(item_id)
graphics = self.annotation_items.get(item_id) graphics = self.annotation_items.get(item_id)
if annotation is None or graphics is None: if annotation is None or graphics is None:
@@ -945,11 +1005,20 @@ class GraphScene(QGraphicsScene):
if port_id in (connection.source.interface, connection.target.interface): if port_id in (connection.source.interface, connection.target.interface):
self.update_connection(connection.id) self.update_connection(connection.id)
def update_connections_for_junction(self, junction_id: str) -> None:
for connection in self.controller.active_graph.connections.values():
if junction_id in (connection.source.junction, connection.target.junction):
self.update_connection(connection.id)
def drawBackground(self, painter: QPainter, rect: QRectF) -> None: # noqa: N802 def drawBackground(self, painter: QPainter, rect: QRectF) -> None: # noqa: N802
# The view paints the grid so it always covers the complete viewport. # The view paints the grid so it always covers the complete viewport.
del painter, rect del painter, rect
def _endpoint_item(self, endpoint: Endpoint, role: str) -> ConnectionPortItem | None: def _endpoint_item(
self, endpoint: Endpoint, role: str
) -> ConnectionPortItem | JunctionGraphicsItem | None:
if endpoint.junction is not None:
return self.junction_items.get(endpoint.junction)
if endpoint.interface is not None: if endpoint.interface is not None:
terminals = self.input_items if role == "source" else self.output_items terminals = self.input_items if role == "source" else self.output_items
terminal = terminals.get(endpoint.interface) terminal = terminals.get(endpoint.interface)
@@ -979,6 +1048,9 @@ class GraphScene(QGraphicsScene):
if endpoint.block is not None: if endpoint.block is not None:
component = self.component_items.get(endpoint.block) component = self.component_items.get(endpoint.block)
return component.mapToScene(component.hitbox.center()) if component else None return component.mapToScene(component.hitbox.center()) if component else None
if endpoint.junction is not None:
junction = self.junction_items.get(endpoint.junction)
return junction.scenePos() if junction else None
port = self._endpoint_item(endpoint, role) port = self._endpoint_item(endpoint, role)
return port.scenePos() if port else None return port.scenePos() if port else None
@@ -988,6 +1060,9 @@ class GraphScene(QGraphicsScene):
if endpoint.block is not None: if endpoint.block is not None:
component = self.component_items.get(endpoint.block) component = self.component_items.get(endpoint.block)
return self._hitbox_intersection(component, reference) if component else None return self._hitbox_intersection(component, reference) if component else None
if endpoint.junction is not None:
junction = self.junction_items.get(endpoint.junction)
return junction.scenePos() if junction else None
port = self._endpoint_item(endpoint, role) port = self._endpoint_item(endpoint, role)
return port.scenePos() if port else None return port.scenePos() if port else None
@@ -1076,17 +1151,43 @@ class GraphScene(QGraphicsScene):
return item.parentItem() return item.parentItem()
return None return None
@staticmethod
def _clicked_terminal(item: QGraphicsItem | None) -> InterfaceTerminalItem | None:
if isinstance(item, InterfaceTerminalItem):
return item
if isinstance(item, ConnectionPortItem) and isinstance(
item.parentItem(), InterfaceTerminalItem
):
return item.parentItem()
return None
@classmethod
def _connection_click_item(
cls, item: QGraphicsItem | None
) -> ComponentGraphicsItem | ConnectionPortItem | JunctionGraphicsItem | None:
if isinstance(item, JunctionGraphicsItem):
return item
component = cls._clicked_component(item)
if component is not None:
return item if isinstance(item, ConnectionPortItem) else component
terminal = cls._clicked_terminal(item)
return terminal.connection_port if terminal is not None else None
@staticmethod @staticmethod
def _click_endpoint(item: QGraphicsItem | None) -> Endpoint | None: def _click_endpoint(item: QGraphicsItem | None) -> Endpoint | None:
return item.endpoint if isinstance(item, ConnectionPortItem) else None return (
item.endpoint
if isinstance(item, (ConnectionPortItem, JunctionGraphicsItem))
else None
)
@staticmethod @staticmethod
def _connection_anchor( def _connection_anchor(
item: ComponentGraphicsItem | ConnectionPortItem, item: ComponentGraphicsItem | ConnectionPortItem | JunctionGraphicsItem,
) -> QPointF: ) -> QPointF:
return ( return (
item.scenePos() item.scenePos()
if isinstance(item, ConnectionPortItem) if isinstance(item, (ConnectionPortItem, JunctionGraphicsItem))
else item.sceneBoundingRect().center() else item.sceneBoundingRect().center()
) )
@@ -1104,10 +1205,16 @@ class GraphScene(QGraphicsScene):
for input_port in target_item.component.inputs: for input_port in target_item.component.inputs:
if not PortTypeRegistry.compatible(output.type, input_port.type): if not PortTypeRegistry.compatible(output.type, input_port.type):
continue continue
source = Endpoint(block=source_item.component_id, port=output.id)
target = Endpoint(block=target_item.component_id, port=input_port.id)
if not self.controller.endpoint_accepts_connection(source, "source"):
continue
if not self.controller.endpoint_accepts_connection(target, "target"):
continue
choices.append( choices.append(
ConnectionChoice( ConnectionChoice(
Endpoint(block=source_item.component_id, port=output.id), source,
Endpoint(block=target_item.component_id, port=input_port.id), target,
f"{source_item.component.name}.{output.name}" f"{source_item.component.name}.{output.name}"
f"{target_item.component.name}.{input_port.name}", f"{target_item.component.name}.{input_port.name}",
) )
@@ -1117,11 +1224,92 @@ class GraphScene(QGraphicsScene):
add_pairs(second, first) add_pairs(second, first)
return choices return choices
def _interface_connection_choices(
self,
terminal: InterfaceTerminalItem,
component: ComponentGraphicsItem,
) -> list[ConnectionChoice]:
choices: list[ConnectionChoice] = []
interface = Endpoint(interface=terminal.port.id)
if terminal.direction == "input":
for port in component.component.inputs:
target = Endpoint(block=component.component_id, port=port.id)
if not PortTypeRegistry.compatible(terminal.port.type, port.type):
continue
if not self.controller.endpoint_accepts_connection(interface, "source"):
continue
if not self.controller.endpoint_accepts_connection(target, "target"):
continue
choices.append(
ConnectionChoice(
interface,
target,
f"IN.{terminal.port.name}{component.component.name}.{port.name}",
)
)
else:
for port in component.component.outputs:
source = Endpoint(block=component.component_id, port=port.id)
if not PortTypeRegistry.compatible(port.type, terminal.port.type):
continue
if not self.controller.endpoint_accepts_connection(source, "source"):
continue
if not self.controller.endpoint_accepts_connection(interface, "target"):
continue
choices.append(
ConnectionChoice(
source,
interface,
f"{component.component.name}.{port.name} → OUT.{terminal.port.name}",
)
)
return choices
def _junction_connection_choices(
self,
junction: JunctionGraphicsItem,
component: ComponentGraphicsItem,
) -> list[ConnectionChoice]:
choices: list[ConnectionChoice] = []
source = junction.endpoint
for port in component.component.inputs:
target = Endpoint(block=component.component_id, port=port.id)
if not PortTypeRegistry.compatible(junction.junction.type, port.type):
continue
if not self.controller.endpoint_accepts_connection(target, "target"):
continue
choices.append(
ConnectionChoice(
source,
target,
f"Junction → {component.component.name}.{port.name}",
)
)
return choices
def _junction_interface_choices(
self,
junction: JunctionGraphicsItem,
terminal: InterfaceTerminalItem,
) -> list[ConnectionChoice]:
if terminal.direction != "output":
return []
target = Endpoint(interface=terminal.port.id)
if not PortTypeRegistry.compatible(junction.junction.type, terminal.port.type):
return []
if not self.controller.endpoint_accepts_connection(target, "target"):
return []
return [
ConnectionChoice(
junction.endpoint,
target,
f"Junction → OUT.{terminal.port.name}",
)
]
@staticmethod @staticmethod
def _default_choice_index( def _default_choice_index(
choices: list[ConnectionChoice], choices: list[ConnectionChoice],
first_component: ComponentGraphicsItem,
second_component: ComponentGraphicsItem,
first_endpoint: Endpoint | None, first_endpoint: Endpoint | None,
second_endpoint: Endpoint | None, second_endpoint: Endpoint | None,
) -> int: ) -> int:
@@ -1131,10 +1319,6 @@ class GraphScene(QGraphicsScene):
value += 8 value += 8
if second_endpoint is not None and second_endpoint in (choice.source, choice.target): if second_endpoint is not None and second_endpoint in (choice.source, choice.target):
value += 8 value += 8
if choice.source.block == first_component.component_id:
value += 2
if choice.target.block == second_component.component_id:
value += 1
return value return value
return max(range(len(choices)), key=lambda index: score(choices[index])) return max(range(len(choices)), key=lambda index: score(choices[index]))
@@ -1148,27 +1332,41 @@ class GraphScene(QGraphicsScene):
return return
first_component = self._clicked_component(first_item) first_component = self._clicked_component(first_item)
second_component = self._clicked_component(second_item) second_component = self._clicked_component(second_item)
if ( first_terminal = self._clicked_terminal(first_item)
first_component is None second_terminal = self._clicked_terminal(second_item)
or second_component is None first_junction = first_item if isinstance(first_item, JunctionGraphicsItem) else None
or first_component is second_component second_junction = second_item if isinstance(second_item, JunctionGraphicsItem) else None
): if first_component is not None and second_component is not None:
if first_component is second_component:
self._clear_pending_connection()
return
choices = self._connection_choices(first_component, second_component)
elif first_terminal is not None and second_component is not None:
choices = self._interface_connection_choices(first_terminal, second_component)
elif first_component is not None and second_terminal is not None:
choices = self._interface_connection_choices(second_terminal, first_component)
elif first_junction is not None and second_component is not None:
choices = self._junction_connection_choices(first_junction, second_component)
elif first_component is not None and second_junction is not None:
choices = self._junction_connection_choices(second_junction, first_component)
elif first_junction is not None and second_terminal is not None:
choices = self._junction_interface_choices(first_junction, second_terminal)
elif first_terminal is not None and second_junction is not None:
choices = self._junction_interface_choices(second_junction, first_terminal)
else:
self._clear_pending_connection() self._clear_pending_connection()
return return
choices = self._connection_choices(first_component, second_component)
if not choices: if not choices:
QToolTip.showText( QToolTip.showText(
self.views()[0].mapToGlobal(self.views()[0].viewport().rect().center()) self.views()[0].mapToGlobal(self.views()[0].viewport().rect().center())
if self.views() if self.views()
else QPointF().toPoint(), else QPointF().toPoint(),
"These blocks have no compatible input/output pairs", "These items have no available compatible input/output pairs",
) )
self._clear_pending_connection() self._clear_pending_connection()
return return
default = self._default_choice_index( default = self._default_choice_index(
choices, choices,
first_component,
second_component,
self._click_endpoint(first_item), self._click_endpoint(first_item),
self._click_endpoint(second_item), self._click_endpoint(second_item),
) )
@@ -1200,9 +1398,8 @@ class GraphScene(QGraphicsScene):
event.accept() event.accept()
return return
item = self.itemAt(event.scenePos(), QTransform()) item = self.itemAt(event.scenePos(), QTransform())
component = self._clicked_component(item) clicked = self._connection_click_item(item)
if component is not None: if clicked is not None:
clicked = item if isinstance(item, ConnectionPortItem) else component
if self.pending_connection_item is None: if self.pending_connection_item is None:
self.pending_connection_item = clicked self.pending_connection_item = clicked
self.pending_waypoints = [] self.pending_waypoints = []
@@ -1351,6 +1548,44 @@ class GraphScene(QGraphicsScene):
points.insert(insertion, snapped) points.insert(insertion, snapped)
self.controller.set_route_waypoints(item_kind, item_id, points) self.controller.set_route_waypoints(item_kind, item_id, points)
def add_connection_junction(self, connection_id: str, position: QPointF) -> None:
connection, points = self._route_values("connection", connection_id)
graphics = self.connection_items.get(connection_id)
if connection is None or graphics is None:
return
snapped = _snapped(position)
anchors = [graphics.start, *points, graphics.end]
def segment_distance(point: QPointF, first: QPointF, second: QPointF) -> float:
delta = second - first
length_squared = delta.x() ** 2 + delta.y() ** 2
if length_squared == 0:
return (point - first).manhattanLength()
ratio = max(
0.0,
min(
1.0,
((point.x() - first.x()) * delta.x()
+ (point.y() - first.y()) * delta.y())
/ length_squared,
),
)
nearest = first + delta * ratio
return (point.x() - nearest.x()) ** 2 + (point.y() - nearest.y()) ** 2
insertion = min(
range(len(anchors) - 1),
key=lambda index: segment_distance(
snapped, anchors[index], anchors[index + 1]
),
)
self.controller.split_connection(
connection_id,
snapped,
points[:insertion],
points[insertion:],
)
def move_route_node(self, item_kind: str, item_id: str, index: int, position: QPointF) -> None: def move_route_node(self, item_kind: str, item_id: str, index: int, position: QPointF) -> None:
item, points = self._route_values(item_kind, item_id) item, points = self._route_values(item_kind, item_id)
if item is None: if item is None:

View File

@@ -315,6 +315,7 @@ class MainWindow(QMainWindow):
self._applying_text_definition = False self._applying_text_definition = False
except (TypeError, ValueError) as error: except (TypeError, ValueError) as error:
QMessageBox.critical(self, "Invalid text component", str(error)) QMessageBox.critical(self, "Invalid text component", str(error))
self._load_text_definition()
return False return False
self.ui.textDefinitionEditor.set_modified(False) self.ui.textDefinitionEditor.set_modified(False)
return True return True

View File

@@ -86,7 +86,9 @@ class DocumentTreeModel(LibraryTreeModel):
self.clear() self.clear()
self.setHorizontalHeaderLabels(["Document"]) self.setHorizontalHeaderLabels(["Document"])
if self.controller.document is not None: if self.controller.document is not None:
current_root = QStandardItem("Current Document") current_root = QStandardItem(
str(self.controller.document.metadata.get("name") or "Current Document")
)
current_root.setDragEnabled(False) current_root.setDragEnabled(False)
current_root.setData("current-document", ITEM_KIND_ROLE) current_root.setData("current-document", ITEM_KIND_ROLE)
for component in self.controller.document.roots.values(): for component in self.controller.document.roots.values():

View File

@@ -27,7 +27,8 @@
<item row="1" column="1"><widget class="QComboBox" name="typeCombo"><item><property name="text"><string>Signal</string></property></item></widget></item> <item row="1" column="1"><widget class="QComboBox" name="typeCombo"><item><property name="text"><string>Signal</string></property></item></widget></item>
<item row="2" column="0"><widget class="QLabel" name="orientationLabel"><property name="text"><string>Orientation:</string></property></widget></item> <item row="2" column="0"><widget class="QLabel" name="orientationLabel"><property name="text"><string>Orientation:</string></property></widget></item>
<item row="2" column="1"><widget class="QComboBox" name="orientationCombo"><item><property name="text"><string>Input</string></property></item><item><property name="text"><string>Output</string></property></item></widget></item> <item row="2" column="1"><widget class="QComboBox" name="orientationCombo"><item><property name="text"><string>Input</string></property></item><item><property name="text"><string>Output</string></property></item></widget></item>
<item row="3" column="0" colspan="2"><widget class="QLabel" name="positionHintLabel"><property name="text"><string>New ports start at (0, 0) in the icon editor.</string></property><property name="wordWrap"><bool>true</bool></property></widget></item> <item row="3" column="0" colspan="2"><widget class="QCheckBox" name="multipleConnectionsCheckBox"><property name="text"><string>Allow multiple connections</string></property></widget></item>
<item row="4" column="0" colspan="2"><widget class="QLabel" name="positionHintLabel"><property name="text"><string>New ports start at (0, 0) in the icon editor.</string></property><property name="wordWrap"><bool>true</bool></property></widget></item>
</layout> </layout>
</widget> </widget>
</widget> </widget>

View File

@@ -24,7 +24,7 @@
<widget class="QGroupBox" name="portsGroup"> <widget class="QGroupBox" name="portsGroup">
<property name="title"><string>Ports</string></property> <property name="title"><string>Ports</string></property>
<layout class="QVBoxLayout" name="portsLayout"> <layout class="QVBoxLayout" name="portsLayout">
<item><widget class="QTableWidget" name="portsTable"><property name="selectionBehavior"><enum>QAbstractItemView::SelectionBehavior::SelectRows</enum></property><property name="selectionMode"><enum>QAbstractItemView::SelectionMode::SingleSelection</enum></property><property name="columnCount"><number>3</number></property><column><property name="text"><string>Name</string></property></column><column><property name="text"><string>Type</string></property></column><column><property name="text"><string>Orientation</string></property></column></widget></item> <item><widget class="QTableWidget" name="portsTable"><property name="selectionBehavior"><enum>QAbstractItemView::SelectionBehavior::SelectRows</enum></property><property name="selectionMode"><enum>QAbstractItemView::SelectionMode::SingleSelection</enum></property><property name="columnCount"><number>4</number></property><column><property name="text"><string>Name</string></property></column><column><property name="text"><string>Type</string></property></column><column><property name="text"><string>Orientation</string></property></column><column><property name="text"><string>Multiple</string></property></column></widget></item>
<item><layout class="QHBoxLayout" name="portButtonsLayout"><item><widget class="QPushButton" name="addPortButton"><property name="text"><string>Add Port</string></property></widget></item><item><widget class="QPushButton" name="removePortButton"><property name="text"><string>Remove Port</string></property></widget></item><item><spacer name="portButtonSpacer"><property name="orientation"><enum>Qt::Orientation::Horizontal</enum></property><property name="sizeHint" stdset="0"><size><width>40</width><height>20</height></size></property></spacer></item></layout></item> <item><layout class="QHBoxLayout" name="portButtonsLayout"><item><widget class="QPushButton" name="addPortButton"><property name="text"><string>Add Port</string></property></widget></item><item><widget class="QPushButton" name="removePortButton"><property name="text"><string>Remove Port</string></property></widget></item><item><spacer name="portButtonSpacer"><property name="orientation"><enum>Qt::Orientation::Horizontal</enum></property><property name="sizeHint" stdset="0"><size><width>40</width><height>20</height></size></property></spacer></item></layout></item>
</layout> </layout>
</widget> </widget>

View File

@@ -6,16 +6,50 @@
}, },
"roots": [ "roots": [
{ {
"id": "b53c8186-0926-46b1-80ec-51a22ac5e0c6", "id": "57fe8127-ea9d-4a8b-8d09-da430ffbbd92",
"name": "New Graph Block 1", "name": "PID",
"position": { "position": {
"x": 0.0, "x": 0.0,
"y": 0.0 "y": 0.0
}, },
"rotation": 0.0, "rotation": 0.0,
"interface": { "interface": {
"inputs": [], "inputs": [
"outputs": [] {
"id": "port-1342716d",
"name": "in",
"position": {
"x": -384.0,
"y": -112.0
},
"properties": {
"iconPosition": {
"x": 0.0,
"y": 0.0
}
},
"type": "signal",
"multipleConnections": false
}
],
"outputs": [
{
"id": "port-c75d0f16",
"name": "out",
"position": {
"x": 320.0,
"y": -112.0
},
"properties": {
"iconPosition": {
"x": 0.0,
"y": 0.0
}
},
"type": "signal",
"multipleConnections": false
}
]
}, },
"icon": { "icon": {
"shape": "rectangle", "shape": "rectangle",
@@ -51,16 +85,830 @@
} }
] ]
}, },
"properties": {}, "properties": {
"showName": false
},
"library": { "library": {
"showSubtree": true "showSubtree": false
}, },
"implementation": { "implementation": {
"kind": "graph", "kind": "graph",
"graph": { "graph": {
"blocks": [], "blocks": [
"connections": [], {
"annotations": [] "id": "cbfb5d1f-38cc-412e-b8e3-53aa82e46eb8",
"name": "g_D",
"position": {
"x": -128.0,
"y": -160.0
},
"rotation": 0.0,
"interface": {
"inputs": [
{
"id": "port-df34ce84",
"name": "in",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {
"iconPosition": {
"x": 64.0,
"y": 64.0
}
},
"type": "signal",
"multipleConnections": false
}
],
"outputs": [
{
"id": "port-fe9e6486",
"name": "out",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {
"iconPosition": {
"x": 88.0,
"y": 40.0
}
},
"type": "signal",
"multipleConnections": false
}
]
},
"icon": {
"shape": "rectangle",
"fill": "#f4f4f4",
"border": "#303030",
"text": "Text",
"size": {
"width": 128.0,
"height": 128.0
},
"elements": [
{
"cornerRadius": 5.0,
"fill": "#f4f4f4",
"height": 64.0,
"lineStyle": "solid",
"lineWidth": 1.5,
"stroke": "#303030",
"type": "rectangle",
"width": 64.0,
"x": 32.0,
"y": 32.0
},
{
"color": "#00007f",
"fill": "#ffffff",
"fontSize": 24.0,
"height": 48.0,
"lineStyle": "solid",
"lineWidth": 1.5,
"stroke": "#00007f",
"text": "K",
"type": "text",
"width": 48.0,
"x": 40.0,
"y": 40.0
}
]
},
"properties": {
"showName": true
},
"library": {
"showSubtree": true
},
"implementation": {
"kind": "text",
"source": {
"equations": "out = k*in;",
"parameters": [
{
"id": "parameter-61a43a86",
"name": "k",
"type": "real",
"value": "1"
}
]
}
}
},
{
"id": "478e7ff8-baa1-467f-8707-e375b99395f6",
"name": "differentiate0",
"position": {
"x": 0.0,
"y": -160.0
},
"rotation": 0.0,
"interface": {
"inputs": [
{
"id": "port-1cbabc8f",
"name": "in",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {
"iconPosition": {
"x": 64.0,
"y": 64.0
}
},
"type": "signal",
"multipleConnections": false
}
],
"outputs": [
{
"id": "port-4eeea4e7",
"name": "out",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {
"iconPosition": {
"x": 88.0,
"y": 40.0
}
},
"type": "signal",
"multipleConnections": false
}
]
},
"icon": {
"shape": "rectangle",
"fill": "#f4f4f4",
"border": "#303030",
"text": "Text",
"size": {
"width": 128.0,
"height": 128.0
},
"elements": [
{
"cornerRadius": 5.0,
"fill": "#f4f4f4",
"height": 64.0,
"lineStyle": "solid",
"lineWidth": 1.5,
"stroke": "#303030",
"type": "rectangle",
"width": 64.0,
"x": 32.0,
"y": 32.0
},
{
"color": "#00007f",
"fill": "#ffffff",
"fontSize": 18.0,
"height": 48.0,
"lineStyle": "solid",
"lineWidth": 1.5,
"stroke": "#00007f",
"text": "d/dt",
"type": "text",
"width": 48.0,
"x": 40.0,
"y": 40.0
}
]
},
"properties": {
"showName": false
},
"library": {
"showSubtree": true
},
"implementation": {
"kind": "text",
"source": {
"equations": "initial out = initial;\nout = der(in);",
"parameters": [
{
"id": "parameter-331e38be",
"name": "initial",
"type": "real",
"value": "0"
}
]
}
}
},
{
"id": "9011e8f6-940a-40ca-8bdf-773f0ed1c9f3",
"name": "g_P",
"position": {
"x": -64.0,
"y": -288.0
},
"rotation": 0.0,
"interface": {
"inputs": [
{
"id": "port-df34ce84",
"name": "in",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {
"iconPosition": {
"x": 64.0,
"y": 64.0
}
},
"type": "signal",
"multipleConnections": false
}
],
"outputs": [
{
"id": "port-fe9e6486",
"name": "out",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {
"iconPosition": {
"x": 88.0,
"y": 40.0
}
},
"type": "signal",
"multipleConnections": false
}
]
},
"icon": {
"shape": "rectangle",
"fill": "#f4f4f4",
"border": "#303030",
"text": "Text",
"size": {
"width": 128.0,
"height": 128.0
},
"elements": [
{
"cornerRadius": 5.0,
"fill": "#f4f4f4",
"height": 64.0,
"lineStyle": "solid",
"lineWidth": 1.5,
"stroke": "#303030",
"type": "rectangle",
"width": 64.0,
"x": 32.0,
"y": 32.0
},
{
"color": "#00007f",
"fill": "#ffffff",
"fontSize": 24.0,
"height": 48.0,
"lineStyle": "solid",
"lineWidth": 1.5,
"stroke": "#00007f",
"text": "K",
"type": "text",
"width": 48.0,
"x": 40.0,
"y": 40.0
}
]
},
"properties": {
"showName": true
},
"library": {
"showSubtree": true
},
"implementation": {
"kind": "text",
"source": {
"equations": "out = k*in;",
"parameters": [
{
"id": "parameter-61a43a86",
"name": "k",
"type": "real",
"value": "1"
}
]
}
}
},
{
"id": "1995074c-4580-4ca3-8691-6923aedc5833",
"name": "g_I",
"position": {
"x": -128.0,
"y": -32.0
},
"rotation": 0.0,
"interface": {
"inputs": [
{
"id": "port-df34ce84",
"name": "in",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {
"iconPosition": {
"x": 64.0,
"y": 64.0
}
},
"type": "signal",
"multipleConnections": false
}
],
"outputs": [
{
"id": "port-fe9e6486",
"name": "out",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {
"iconPosition": {
"x": 88.0,
"y": 40.0
}
},
"type": "signal",
"multipleConnections": false
}
]
},
"icon": {
"shape": "rectangle",
"fill": "#f4f4f4",
"border": "#303030",
"text": "Text",
"size": {
"width": 128.0,
"height": 128.0
},
"elements": [
{
"cornerRadius": 5.0,
"fill": "#f4f4f4",
"height": 64.0,
"lineStyle": "solid",
"lineWidth": 1.5,
"stroke": "#303030",
"type": "rectangle",
"width": 64.0,
"x": 32.0,
"y": 32.0
},
{
"color": "#00007f",
"fill": "#ffffff",
"fontSize": 24.0,
"height": 48.0,
"lineStyle": "solid",
"lineWidth": 1.5,
"stroke": "#00007f",
"text": "K",
"type": "text",
"width": 48.0,
"x": 40.0,
"y": 40.0
}
]
},
"properties": {
"showName": true
},
"library": {
"showSubtree": true
},
"implementation": {
"kind": "text",
"source": {
"equations": "out = k*in;",
"parameters": [
{
"id": "parameter-61a43a86",
"name": "k",
"type": "real",
"value": "1"
}
]
}
}
},
{
"id": "c7440f4d-f206-4c27-9fd2-5722950f207f",
"name": "add0",
"position": {
"x": 128.0,
"y": -160.0
},
"rotation": 0.0,
"interface": {
"inputs": [
{
"id": "port-c995845c",
"name": "in",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {
"iconPosition": {
"x": 64.0,
"y": 64.0
}
},
"type": "signal",
"multipleConnections": true
}
],
"outputs": [
{
"id": "port-a2f0dea6",
"name": "out",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {
"iconPosition": {
"x": 80.0,
"y": 48.0
}
},
"type": "signal",
"multipleConnections": false
}
]
},
"icon": {
"shape": "rectangle",
"fill": "#f4f4f4",
"border": "#303030",
"text": "Text",
"size": {
"width": 128.0,
"height": 128.0
},
"elements": [
{
"fill": "#f4f4f4",
"height": 48.0,
"lineStyle": "solid",
"lineWidth": 1.5,
"stroke": "#303030",
"type": "circle",
"width": 48.0,
"x": 40.0,
"y": 40.0
},
{
"color": "#00007f",
"fill": "none",
"fontSize": 18.0,
"height": 32.0,
"lineStyle": "none",
"lineWidth": 1.5,
"stroke": "#00007f",
"text": "+",
"type": "text",
"width": 32.0,
"x": 48.0,
"y": 48.0
}
]
},
"properties": {
"showName": false
},
"library": {
"showSubtree": true
},
"implementation": {
"kind": "text",
"source": {
"equations": "out = sum(in[i] for i in 1:in.N);",
"parameters": []
}
}
},
{
"id": "48af4110-839a-45f7-a105-7e7dbc51676b",
"name": "integrate0",
"position": {
"x": 0.0,
"y": -32.0
},
"rotation": 0.0,
"interface": {
"inputs": [
{
"id": "port-12903207",
"name": "in",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {
"iconPosition": {
"x": 64.0,
"y": 64.0
}
},
"type": "signal",
"multipleConnections": false
}
],
"outputs": [
{
"id": "port-b3370b1a",
"name": "out",
"position": {
"x": 0.0,
"y": 0.0
},
"properties": {
"iconPosition": {
"x": 88.0,
"y": 40.0
}
},
"type": "signal",
"multipleConnections": false
}
]
},
"icon": {
"shape": "rectangle",
"fill": "#f4f4f4",
"border": "#303030",
"text": "Text",
"size": {
"width": 128.0,
"height": 128.0
},
"elements": [
{
"cornerRadius": 5.0,
"fill": "#f4f4f4",
"height": 64.0,
"lineStyle": "solid",
"lineWidth": 1.5,
"stroke": "#303030",
"type": "rectangle",
"width": 64.0,
"x": 32.0,
"y": 32.0
},
{
"color": "#00007f",
"fill": "#ffffff",
"fontSize": 18.0,
"height": 48.0,
"lineStyle": "solid",
"lineWidth": 1.5,
"stroke": "#00007f",
"text": "dt",
"type": "text",
"width": 28.0,
"x": 64.0,
"y": 40.0
},
{
"color": "#00007f",
"fill": "none",
"fontSize": 32.0,
"height": 54.0,
"lineStyle": "none",
"lineWidth": 1.5,
"stroke": "#00007f",
"text": "\u222b",
"type": "text",
"width": 28.0,
"x": 40.0,
"y": 32.0
}
]
},
"properties": {
"showName": false
},
"library": {
"showSubtree": true
},
"implementation": {
"kind": "text",
"source": {
"equations": "initial out = initial;\nder(out) = in;",
"parameters": [
{
"id": "parameter-6bdc1c76",
"name": "initial",
"type": "real",
"value": "0"
}
]
}
}
}
],
"connections": [
{
"id": "f89a8edb-b662-4a55-aa00-6d681b0bf6ae",
"source": {
"block": "9011e8f6-940a-40ca-8bdf-773f0ed1c9f3",
"port": "port-fe9e6486"
},
"target": {
"block": "c7440f4d-f206-4c27-9fd2-5722950f207f",
"port": "port-c995845c"
},
"name": "",
"properties": {
"waypoints": [
{
"x": 192.0,
"y": -224.0
}
]
}
},
{
"id": "c1a2f8e2-b98f-45ee-9ef6-fad8cd42b71b",
"source": {
"block": "478e7ff8-baa1-467f-8707-e375b99395f6",
"port": "port-4eeea4e7"
},
"target": {
"block": "c7440f4d-f206-4c27-9fd2-5722950f207f",
"port": "port-c995845c"
},
"name": "",
"properties": {
"waypoints": []
}
},
{
"id": "e889f7bd-c684-4e59-8e9d-1693bd2ba87f",
"source": {
"block": "cbfb5d1f-38cc-412e-b8e3-53aa82e46eb8",
"port": "port-fe9e6486"
},
"target": {
"block": "478e7ff8-baa1-467f-8707-e375b99395f6",
"port": "port-1cbabc8f"
},
"name": "",
"properties": {
"waypoints": []
}
},
{
"id": "1cebb01c-7da4-48c7-b7ea-5585f64cd524",
"source": {
"block": "1995074c-4580-4ca3-8691-6923aedc5833",
"port": "port-fe9e6486"
},
"target": {
"block": "48af4110-839a-45f7-a105-7e7dbc51676b",
"port": "port-12903207"
},
"name": "",
"properties": {
"waypoints": []
}
},
{
"id": "6d16540e-d902-4ebf-a6a1-dcdda0cc8458",
"source": {
"block": "48af4110-839a-45f7-a105-7e7dbc51676b",
"port": "port-b3370b1a"
},
"target": {
"block": "c7440f4d-f206-4c27-9fd2-5722950f207f",
"port": "port-c995845c"
},
"name": "",
"properties": {
"waypoints": [
{
"x": 192.0,
"y": 32.0
}
]
}
},
{
"id": "206f1501-4509-4ba7-a28e-156e35de51ea",
"source": {
"block": "c7440f4d-f206-4c27-9fd2-5722950f207f",
"port": "port-a2f0dea6"
},
"target": {
"interface": "port-c75d0f16"
},
"name": "",
"properties": {
"waypoints": []
}
},
{
"id": "5d234bec-8754-46e8-b9e8-1c802bb9f9ba",
"source": {
"interface": "port-1342716d"
},
"target": {
"junction": "7a552e5e-b15d-46ca-a891-37e5129f88d1"
},
"name": "",
"properties": {
"waypoints": []
}
},
{
"id": "33ba6f27-6f3a-4290-baac-b3cddcaa6b50",
"source": {
"junction": "7a552e5e-b15d-46ca-a891-37e5129f88d1"
},
"target": {
"block": "cbfb5d1f-38cc-412e-b8e3-53aa82e46eb8",
"port": "port-df34ce84"
},
"name": "",
"properties": {
"waypoints": []
}
},
{
"id": "ee4a3409-fc46-485c-b475-b456dddb93b5",
"source": {
"junction": "7a552e5e-b15d-46ca-a891-37e5129f88d1"
},
"target": {
"block": "9011e8f6-940a-40ca-8bdf-773f0ed1c9f3",
"port": "port-df34ce84"
},
"name": "",
"properties": {
"waypoints": [
{
"x": -192.0,
"y": -224.0
}
]
}
},
{
"id": "8d1a5f3c-b706-4671-873a-2121f55f748b",
"source": {
"junction": "7a552e5e-b15d-46ca-a891-37e5129f88d1"
},
"target": {
"block": "1995074c-4580-4ca3-8691-6923aedc5833",
"port": "port-df34ce84"
},
"name": "",
"properties": {
"waypoints": [
{
"x": -192.0,
"y": 32.0
}
]
}
}
],
"annotations": [],
"junctions": [
{
"id": "7a552e5e-b15d-46ca-a891-37e5129f88d1",
"position": {
"x": -192.0,
"y": -96.0
},
"type": "signal"
}
]
} }
} }
} }