Add the QGraphicsView layer: NodeItem (SVG symbol + title + draggable, snap-to-grid), PortItem (relation-colored anchors with compatibility hints), EdgeItem (orthogonal routing, arc hops on crossings, line styles, head/tail decorations, animated flow dashes), and DiagramScene binding the document to items with edge-drawing interaction and a grid background. Add EditorController with a QUndoStack (add/connect/delete/move/edit) and scene interaction tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
224 lines
8.9 KiB
Python
224 lines
8.9 KiB
Python
"""EdgeItem: orthogonal connector with styles, arc hops and flow animation."""
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
|
|
from PyQt6.QtCore import QLineF, QPointF, QRectF, Qt
|
|
from PyQt6.QtGui import QBrush, QColor, QPainterPath, QPen, QPolygonF
|
|
from PyQt6.QtWidgets import QGraphicsObject, QGraphicsItem, QStyle
|
|
|
|
from ..model.edge import EdgeModel, EndpointDecoration, LineStyle
|
|
from . import routing
|
|
|
|
_PEN_STYLES = {
|
|
LineStyle.SOLID: Qt.PenStyle.SolidLine,
|
|
LineStyle.DASHED: Qt.PenStyle.DashLine,
|
|
LineStyle.DOTTED: Qt.PenStyle.DotLine,
|
|
LineStyle.DASH_DOT: Qt.PenStyle.DashDotLine,
|
|
}
|
|
|
|
|
|
class EdgeItem(QGraphicsObject):
|
|
"""Graphics item bound to an :class:`EdgeModel`."""
|
|
|
|
def __init__(self, scene_ref, edge: EdgeModel) -> None:
|
|
super().__init__()
|
|
self._scene = scene_ref
|
|
self.edge = edge
|
|
self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
|
|
self.setAcceptHoverEvents(True)
|
|
self.setZValue(0)
|
|
self._points: list[routing.Point] = []
|
|
self._path = QPainterPath()
|
|
self._anim_phase = 0.0
|
|
self.reroute()
|
|
|
|
# -- data -------------------------------------------------------------
|
|
def relation_color(self) -> QColor:
|
|
if self.edge.style.color:
|
|
return QColor(self.edge.style.color)
|
|
doc = getattr(self._scene, "document", None)
|
|
if doc is not None and self.edge.relation:
|
|
return QColor(doc.relations.color(self.edge.relation))
|
|
return QColor("#607d8b")
|
|
|
|
def endpoints_scene(self):
|
|
"""Return ((sx,sy),(snx,sny)),((ex,ey),(enx,eny)) in scene coords."""
|
|
return self._scene.edge_endpoints(self.edge)
|
|
|
|
# -- routing ----------------------------------------------------------
|
|
def reroute(self) -> None:
|
|
data = self.endpoints_scene()
|
|
if data is None:
|
|
return
|
|
(start, snorm), (end, enorm) = data
|
|
cfg = getattr(self._scene, "config", None)
|
|
grid = cfg.grid_size if cfg else 20
|
|
stub = cfg.route_stub if cfg else 20
|
|
self.prepareGeometryChange()
|
|
self._points = routing.orthogonal_route(
|
|
start, snorm, end, enorm, grid=grid, stub=stub)
|
|
self._rebuild_path()
|
|
self.update()
|
|
|
|
def _crossings(self) -> list[routing.Point]:
|
|
cfg = getattr(self._scene, "config", None)
|
|
if not (cfg and cfg.arc_on_crossing):
|
|
return []
|
|
my_segs = routing.polyline_segments(self._points)
|
|
others = self._scene.other_edge_segments(self.edge.edge_id)
|
|
return routing.segment_crossings(my_segs, others)
|
|
|
|
def _rebuild_path(self) -> None:
|
|
pts = [QPointF(*p) for p in self._points]
|
|
path = QPainterPath()
|
|
if len(pts) < 2:
|
|
self._path = path
|
|
return
|
|
cfg = getattr(self._scene, "config", None)
|
|
radius = cfg.arc_radius if cfg else 5.0
|
|
crossings = self._crossings()
|
|
|
|
path.moveTo(pts[0])
|
|
for i in range(len(pts) - 1):
|
|
a, b = pts[i], pts[i + 1]
|
|
hops = self._hops_on_segment(a, b, crossings, radius)
|
|
for cx, cy in hops:
|
|
self._draw_hop(path, a, b, QPointF(cx, cy), radius)
|
|
path.lineTo(b)
|
|
self._path = path
|
|
|
|
@staticmethod
|
|
def _hops_on_segment(a: QPointF, b: QPointF, crossings, radius):
|
|
"""Crossings lying on horizontal segment a->b, ordered along it."""
|
|
eps = 1e-6
|
|
if abs(a.y() - b.y()) > eps: # only horizontal segments hop
|
|
return []
|
|
y = a.y()
|
|
lo, hi = sorted((a.x(), b.x()))
|
|
on = [(cx, cy) for (cx, cy) in crossings
|
|
if abs(cy - y) < 1e-3 and lo + radius < cx < hi - radius]
|
|
on.sort(key=lambda c: c[0] if b.x() >= a.x() else -c[0])
|
|
return on
|
|
|
|
@staticmethod
|
|
def _draw_hop(path: QPainterPath, a: QPointF, b: QPointF, c: QPointF, radius: float):
|
|
sign = 1.0 if b.x() >= a.x() else -1.0
|
|
path.lineTo(c.x() - sign * radius, c.y())
|
|
rect = QRectF(c.x() - radius, c.y() - radius, 2 * radius, 2 * radius)
|
|
# semicircular hop above the line
|
|
start_angle = 180 if sign > 0 else 0
|
|
sweep = -180 if sign > 0 else 180
|
|
path.arcTo(rect, start_angle, sweep)
|
|
|
|
# -- geometry for the scene ------------------------------------------
|
|
def path_points(self) -> list[routing.Point]:
|
|
return list(self._points)
|
|
|
|
def boundingRect(self) -> QRectF:
|
|
if self._path.isEmpty():
|
|
return QRectF()
|
|
extra = self.edge.style.width + 12
|
|
return self._path.boundingRect().adjusted(-extra, -extra, extra, extra)
|
|
|
|
def shape(self) -> QPainterPath:
|
|
stroker = QPen()
|
|
from PyQt6.QtGui import QPainterPathStroker
|
|
s = QPainterPathStroker()
|
|
s.setWidth(max(10.0, self.edge.style.width + 8))
|
|
return s.createStroke(self._path)
|
|
|
|
# -- animation --------------------------------------------------------
|
|
def set_phase(self, phase: float) -> None:
|
|
self._anim_phase = phase
|
|
if self.edge.flow_direction != 0:
|
|
self.update()
|
|
|
|
# -- painting ---------------------------------------------------------
|
|
def paint(self, painter, option, widget=None):
|
|
if self._path.isEmpty():
|
|
return
|
|
painter.setRenderHint(painter.RenderHint.Antialiasing, True)
|
|
color = self.relation_color()
|
|
style = self.edge.style
|
|
selected = bool(option.state & QStyle.StateFlag.State_Selected)
|
|
|
|
# base line
|
|
pen = QPen(color, style.width, _PEN_STYLES.get(style.line_style, Qt.PenStyle.SolidLine))
|
|
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
|
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
|
if selected:
|
|
halo = QPen(QColor(25, 118, 210, 120), style.width + 6)
|
|
halo.setCapStyle(Qt.PenCapStyle.RoundCap)
|
|
painter.setPen(halo)
|
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
|
painter.drawPath(self._path)
|
|
painter.setPen(pen)
|
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
|
painter.drawPath(self._path)
|
|
|
|
# flow animation: marching dashes overlaid, moving with flow direction
|
|
if self.edge.flow_direction != 0:
|
|
self._paint_flow(painter, color)
|
|
|
|
# endpoint decorations
|
|
if len(self._points) >= 2:
|
|
self._paint_decoration(painter, color, style.tail,
|
|
self._points[1], self._points[0])
|
|
self._paint_decoration(painter, color, style.head,
|
|
self._points[-2], self._points[-1])
|
|
|
|
def _paint_flow(self, painter, color: QColor):
|
|
width = max(2.0, min(8.0, 1.5 + self.edge.flow / 25.0))
|
|
dash = QPen(color.lighter(150), width, Qt.PenStyle.CustomDashLine)
|
|
dash.setCapStyle(Qt.PenCapStyle.RoundCap)
|
|
dash.setDashPattern([2.0, 4.0])
|
|
offset = self._anim_phase * (2.0 + self.edge.flow / 20.0)
|
|
# dashes travel source->target for +1, reverse for -1
|
|
dash.setDashOffset(-offset if self.edge.flow_direction > 0 else offset)
|
|
painter.setPen(dash)
|
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
|
painter.drawPath(self._path)
|
|
|
|
def _paint_decoration(self, painter, color: QColor,
|
|
deco: EndpointDecoration, from_pt, to_pt):
|
|
if deco == EndpointDecoration.NONE:
|
|
return
|
|
p_from = QPointF(*from_pt)
|
|
p_to = QPointF(*to_pt)
|
|
line = QLineF(p_from, p_to)
|
|
angle = math.radians(line.angle()) # QLineF angle is CCW from +x
|
|
size = 9.0
|
|
painter.setPen(QPen(color, 1.5))
|
|
painter.setBrush(QBrush(color))
|
|
|
|
if deco == EndpointDecoration.ARROW:
|
|
a1 = angle + math.radians(150)
|
|
a2 = angle - math.radians(150)
|
|
p1 = QPointF(p_to.x() + size * math.cos(a1), p_to.y() - size * math.sin(a1))
|
|
p2 = QPointF(p_to.x() + size * math.cos(a2), p_to.y() - size * math.sin(a2))
|
|
painter.drawPolygon(QPolygonF([p_to, p1, p2]))
|
|
elif deco == EndpointDecoration.CIRCLE:
|
|
painter.drawEllipse(p_to, size * 0.55, size * 0.55)
|
|
elif deco == EndpointDecoration.DIAMOND:
|
|
d = size * 0.7
|
|
perp = angle + math.radians(90)
|
|
fx, fy = math.cos(angle), -math.sin(angle)
|
|
px, py = math.cos(perp), -math.sin(perp)
|
|
c = QPointF(p_to.x() - d * fx, p_to.y() - d * fy)
|
|
poly = QPolygonF([
|
|
QPointF(c.x() + d * fx, c.y() + d * fy),
|
|
QPointF(c.x() + d * px, c.y() + d * py),
|
|
QPointF(c.x() - d * fx, c.y() - d * fy),
|
|
QPointF(c.x() - d * px, c.y() - d * py),
|
|
])
|
|
painter.drawPolygon(poly)
|
|
elif deco == EndpointDecoration.BAR:
|
|
perp = angle + math.radians(90)
|
|
px, py = math.cos(perp), -math.sin(perp)
|
|
painter.setPen(QPen(color, 2.5))
|
|
painter.drawLine(
|
|
QPointF(p_to.x() + size * 0.7 * px, p_to.y() + size * 0.7 * py),
|
|
QPointF(p_to.x() - size * 0.7 * px, p_to.y() - size * 0.7 * py),
|
|
)
|