Source code for pySimBlocks.gui.graphics.connection_item

# ******************************************************************************
#                                  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