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

@@ -33,7 +33,7 @@ from PySide6.QtWidgets import (
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.dialogs.connection_chooser import ConnectionChooserDialog
from bedit.gui.models.library_tree import COMPONENT_MIME_TYPE
@@ -79,6 +79,42 @@ class ConnectionPortItem(QGraphicsEllipseItem):
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):
"""Movable italic name label whose position is stored by its owner."""
@@ -248,8 +284,8 @@ class ComponentGraphicsItem(QGraphicsObject):
class InterfaceTerminalItem(QGraphicsObject):
WIDTH = 110.0
HEIGHT = 36.0
WIDTH = 128.0
HEIGHT = 32.0
def __init__(self, port: Port, direction: str, controller: DocumentController) -> None:
super().__init__()
@@ -330,6 +366,8 @@ class ConnectionGraphicsItem(QGraphicsPathItem):
super().__init__()
self.connection_id = connection.id
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.style = style or ConnectionStyle()
self.start = QPointF()
@@ -374,6 +412,7 @@ class ConnectionGraphicsItem(QGraphicsPathItem):
self.setSelected(True)
menu = QMenu()
add_node_action = menu.addAction("Add Node")
add_junction_action = menu.addAction("Add Junction")
menu.addSeparator()
options_action = menu.addAction("Connection Options…")
selected = menu.exec(event.screenPos())
@@ -381,6 +420,10 @@ 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:
scene = self.scene()
if isinstance(scene, GraphScene):
scene.add_connection_junction(self.connection_id, event.scenePos())
elif selected is options_action:
scene = self.scene()
if isinstance(scene, GraphScene):
@@ -448,9 +491,9 @@ class ConnectionGraphicsItem(QGraphicsPathItem):
super().paint(painter, option, widget)
painter.setPen(Qt.PenStyle.NoPen)
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))
if self.style.arrow_at_source:
if self.style.arrow_at_source and not self.source_is_junction:
painter.drawPolygon(
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.output_items: dict[str, InterfaceTerminalItem] = {}
self.connection_items: dict[str, ConnectionGraphicsItem] = {}
self.junction_items: dict[str, JunctionGraphicsItem] = {}
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_preview: QGraphicsPathItem | None = None
self.interaction_mode = "pointer"
@@ -822,6 +868,7 @@ class GraphScene(QGraphicsScene):
self.input_items.clear()
self.output_items.clear()
self.connection_items.clear()
self.junction_items.clear()
self.annotation_items.clear()
self.pending_connection_item = None
self.pending_waypoints.clear()
@@ -844,6 +891,10 @@ class GraphScene(QGraphicsScene):
self.addItem(item)
item.setPos(component.x, component.y)
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():
item = ConnectionGraphicsItem(
connection,
@@ -892,6 +943,15 @@ class GraphScene(QGraphicsScene):
if item_kind in {"connection", "connection_data"}:
self.update_connection(item_id)
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)
graphics = self.annotation_items.get(item_id)
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):
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
# The view paints the grid so it always covers the complete viewport.
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:
terminals = self.input_items if role == "source" else self.output_items
terminal = terminals.get(endpoint.interface)
@@ -979,6 +1048,9 @@ class GraphScene(QGraphicsScene):
if endpoint.block is not None:
component = self.component_items.get(endpoint.block)
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)
return port.scenePos() if port else None
@@ -988,6 +1060,9 @@ class GraphScene(QGraphicsScene):
if endpoint.block is not None:
component = self.component_items.get(endpoint.block)
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)
return port.scenePos() if port else None
@@ -1076,17 +1151,43 @@ class GraphScene(QGraphicsScene):
return item.parentItem()
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
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
def _connection_anchor(
item: ComponentGraphicsItem | ConnectionPortItem,
item: ComponentGraphicsItem | ConnectionPortItem | JunctionGraphicsItem,
) -> QPointF:
return (
item.scenePos()
if isinstance(item, ConnectionPortItem)
if isinstance(item, (ConnectionPortItem, JunctionGraphicsItem))
else item.sceneBoundingRect().center()
)
@@ -1104,10 +1205,16 @@ class GraphScene(QGraphicsScene):
for input_port in target_item.component.inputs:
if not PortTypeRegistry.compatible(output.type, input_port.type):
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(
ConnectionChoice(
Endpoint(block=source_item.component_id, port=output.id),
Endpoint(block=target_item.component_id, port=input_port.id),
source,
target,
f"{source_item.component.name}.{output.name}"
f"{target_item.component.name}.{input_port.name}",
)
@@ -1117,11 +1224,92 @@ class GraphScene(QGraphicsScene):
add_pairs(second, first)
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
def _default_choice_index(
choices: list[ConnectionChoice],
first_component: ComponentGraphicsItem,
second_component: ComponentGraphicsItem,
first_endpoint: Endpoint | None,
second_endpoint: Endpoint | None,
) -> int:
@@ -1131,10 +1319,6 @@ class GraphScene(QGraphicsScene):
value += 8
if second_endpoint is not None and second_endpoint in (choice.source, choice.target):
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 max(range(len(choices)), key=lambda index: score(choices[index]))
@@ -1148,27 +1332,41 @@ class GraphScene(QGraphicsScene):
return
first_component = self._clicked_component(first_item)
second_component = self._clicked_component(second_item)
if (
first_component is None
or second_component is None
or first_component is second_component
):
first_terminal = self._clicked_terminal(first_item)
second_terminal = self._clicked_terminal(second_item)
first_junction = first_item if isinstance(first_item, JunctionGraphicsItem) else None
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()
return
choices = self._connection_choices(first_component, second_component)
if not choices:
QToolTip.showText(
self.views()[0].mapToGlobal(self.views()[0].viewport().rect().center())
if self.views()
else QPointF().toPoint(),
"These blocks have no compatible input/output pairs",
"These items have no available compatible input/output pairs",
)
self._clear_pending_connection()
return
default = self._default_choice_index(
choices,
first_component,
second_component,
self._click_endpoint(first_item),
self._click_endpoint(second_item),
)
@@ -1200,9 +1398,8 @@ class GraphScene(QGraphicsScene):
event.accept()
return
item = self.itemAt(event.scenePos(), QTransform())
component = self._clicked_component(item)
if component is not None:
clicked = item if isinstance(item, ConnectionPortItem) else component
clicked = self._connection_click_item(item)
if clicked is not None:
if self.pending_connection_item is None:
self.pending_connection_item = clicked
self.pending_waypoints = []
@@ -1351,6 +1548,44 @@ class GraphScene(QGraphicsScene):
points.insert(insertion, snapped)
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:
item, points = self._route_values(item_kind, item_id)
if item is None: