# ******************************************************************************
# pySimBlocks
# Copyright (c) 2026 Université de Lille & INRIA
# ******************************************************************************
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
# for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
# ******************************************************************************
# Authors: see Authors.txt
# ******************************************************************************
from PySide6.QtCore import Qt, QPointF
from PySide6.QtGui import QPen, QPainterPath, QPainterPathStroker
from PySide6.QtWidgets import QGraphicsItem, QGraphicsPathItem
from pySimBlocks.gui.graphics.port_item import PortItem
from pySimBlocks.gui.models.connection_instance import ConnectionInstance
[docs]
class OrthogonalRoute:
"""Store routed connection points and the segment being dragged.
Attributes:
points: Ordered route points in scene coordinates.
dragged_index: Index of the segment currently being dragged.
"""
def __init__(self, points: list[QPointF]):
"""Initialize a routed polyline.
Args:
points: Ordered route points in scene coordinates.
Raises:
None.
"""
self.points = points
self.dragged_index: int | None = None
[docs]
class ConnectionItem(QGraphicsPathItem):
"""Render and interact with a connection between two ports.
Attributes:
src_port: Source port item of the connection.
dst_port: Destination port item of the connection.
instance: Connection model represented by this item.
is_temporary: Whether the connection is currently incomplete.
is_manual: Whether the route was manually adjusted.
route: Current orthogonal route definition.
"""
OFFSET = 8
MARGIN = 12
DETOUR = 8
PICK_TOL = 6
GRID = 5
def __init__(self,
src_port: PortItem | None,
dst_port: PortItem | None,
instance: ConnectionInstance,
points: list[QPointF] | None = None):
"""Initialize a connection item.
Args:
src_port: Source port item, if already known.
dst_port: Destination port item, if already known.
instance: Connection model represented by this item.
points: Optional persisted route points.
Raises:
ValueError: If both ports are missing.
"""
super().__init__()
if src_port is None and dst_port is None:
raise ValueError("At least one of the ports must be provided")
self.src_port = src_port
self.dst_port = dst_port
self.instance = instance
self.is_temporary = (src_port is None) or (dst_port is None)
self._valid_port = src_port if src_port is not None else dst_port
self.is_manual: bool = False
self.route: OrthogonalRoute | None = None
if points and len(points) >= 2:
self.apply_manual_route(points)
t = self._valid_port.parent_block.view.theme
if self.is_temporary:
self.setFlag(QGraphicsItem.ItemIsSelectable, False)
self.setAcceptedMouseButtons(Qt.NoButton)
pen = QPen(t.wire, 3, Qt.DashLine)
else:
self.setFlag(QGraphicsItem.ItemIsSelectable, True)
self.setAcceptedMouseButtons(Qt.LeftButton)
pen = QPen(t.wire, 3, Qt.SolidLine)
self.setPen(pen)
self.setZValue(1)
self.update_position()
# --------------------------------------------------------------------------
# Public Methods
# --------------------------------------------------------------------------
[docs]
def update_position(self):
"""Recompute the displayed route from the current port positions."""
if self.is_temporary:
return
p1 = self.src_port.connection_anchor()
p2 = self.dst_port.connection_anchor()
if self.is_manual and self.route and len(self.route.points) >= 2:
self.route.points[0] = p1
self.route.points[-1] = p2
self._apply_route(self.route.points)
return
pts = self._compute_auto_route(p1, p2)
self.route = OrthogonalRoute(pts)
self._apply_route(self.route.points)
[docs]
def update_temp_position(self, scene_pos: QPointF):
"""Update the temporary route endpoint while dragging.
Args:
scene_pos: Current mouse position in scene coordinates.
"""
p1 = self._valid_port.connection_anchor()
pts = [p1, scene_pos]
self._apply_route(pts)
[docs]
def apply_manual_route(self, points: list[QPointF]):
"""Apply a persisted manual route to the connection.
Args:
points: Route points in scene coordinates.
"""
self.route = OrthogonalRoute(points)
self.is_manual = True
self._apply_route(self.route.points)
[docs]
def invalidate_manual_route(self):
"""Discard any manual route so the next update recomputes it."""
self.is_manual = False
self.route = None
[docs]
def segment_at(self, scene_pos: QPointF) -> int | None:
"""Return the route segment index located near the given scene point.
Args:
scene_pos: Scene position to test.
Returns:
Index of the matching segment, or None if none is close enough.
"""
if not self.route:
return None
pts = self.route.points
for i in range(len(pts) - 1):
a, b = pts[i], pts[i + 1]
if a.x() == b.x(): # vertical
if abs(scene_pos.x() - a.x()) < self.PICK_TOL \
and min(a.y(), b.y()) <= scene_pos.y() <= max(a.y(), b.y()):
return i
if a.y() == b.y(): # horizontal
if abs(scene_pos.y() - a.y()) < self.PICK_TOL \
and min(a.x(), b.x()) <= scene_pos.x() <= max(a.x(), b.x()):
return i
return None
[docs]
def shape(self):
"""Return an enlarged hit shape so connections are easier to select.
Returns:
Stroke path used for hit testing.
"""
stroker = QPainterPathStroker()
stroker.setWidth(6)
return stroker.createStroke(self.path())
[docs]
def mousePressEvent(self, event):
"""Start manual segment dragging when pressing a routed segment.
Args:
event: Qt mouse-press event.
"""
idx = self.segment_at(event.scenePos())
if idx is not None:
self.route.dragged_index = idx
self.is_manual = True
event.accept()
else:
super().mousePressEvent(event)
[docs]
def mouseMoveEvent(self, event):
"""Move the selected orthogonal segment during manual route editing.
Args:
event: Qt mouse-move event.
"""
if not self.route or self.route.dragged_index is None:
return
i = self.route.dragged_index
a = self.route.points[i]
b = self.route.points[i + 1]
pos = event.scenePos()
if a.x() == b.x(): # vertical segment
x = self._snap(pos.x())
self.route.points[i] = QPointF(x, a.y())
self.route.points[i + 1] = QPointF(x, b.y())
elif a.y() == b.y(): # horizontal segment
y = self._snap(pos.y())
self.route.points[i] = QPointF(a.x(), y)
self.route.points[i + 1] = QPointF(b.x(), y)
self._apply_route(self.route.points)
[docs]
def mouseReleaseEvent(self, event):
"""Finish manual segment dragging.
Args:
event: Qt mouse-release event.
"""
if self.route:
self.route.dragged_index = None
super().mouseReleaseEvent(event)
# --------------------------------------------------------------------------
# Private Methods
# --------------------------------------------------------------------------
def _compute_auto_route(self, p1: QPointF, p2: QPointF) -> list[QPointF]:
"""Compute an orthogonal route between two port anchors."""
src_block = self.src_port.parent_block
dst_block = self.dst_port.parent_block
# Use the visual block rect (not selection handle hit area) for routing.
src_rect = src_block.mapRectToScene(src_block.rect())
dst_rect = dst_block.mapRectToScene(dst_block.rect())
src_out_sign = 1 if not self.src_port.is_on_left_side else -1
dst_in_sign = -1 if self.dst_port.is_on_left_side else 1
p1_out = QPointF(p1.x() + src_out_sign * self.OFFSET, p1.y())
p2_in = QPointF(p2.x() + dst_in_sign * self.OFFSET, p2.y())
same_block = src_block is dst_block
u_turn = ((p2_in.x() - p1_out.x()) * src_out_sign) < 0
is_feedback = same_block or u_turn
if not is_feedback:
mid_x = (p1_out.x() + p2_in.x()) * 0.5
candidate = [
p1, p1_out,
QPointF(mid_x, p1.y()),
QPointF(mid_x, p2.y()),
p2_in, p2
]
path = self._path_from(candidate)
if not (path.intersects(src_rect) or path.intersects(dst_rect)):
return candidate
# fallback / feedback routing
candidates_y = [
min(src_rect.top(), dst_rect.top()) - self.MARGIN,
max(src_rect.bottom(), dst_rect.bottom()) + self.MARGIN
]
if src_rect.bottom() < dst_rect.top():
candidates_y.append((src_rect.bottom() + dst_rect.top()) * 0.5)
elif dst_rect.bottom() < src_rect.top():
candidates_y.append((dst_rect.bottom() + src_rect.top()) * 0.5)
route_y = min(
candidates_y,
key=lambda y: abs(p1.y() - y) + abs(p2.y() - y)
)
return [
p1, p1_out,
QPointF(p1_out.x(), route_y),
QPointF(p2_in.x(), route_y),
p2_in, p2
]
def _snap(self, v: float) -> float:
"""Snap a scalar coordinate to the routing grid."""
return round(v / self.GRID) * self.GRID
def _apply_route(self, points: list[QPointF]):
"""Apply a route by building and setting the corresponding path."""
path = QPainterPath(points[0])
for p in points[1:]:
path.lineTo(p)
self.setPath(path)
def _path_from(self, pts: list[QPointF]) -> QPainterPath:
"""Build a painter path from an ordered list of route points."""
p = QPainterPath(pts[0])
for pt in pts[1:]:
p.lineTo(pt)
return p