Ilya af66debcde feat(view): graphics scene, node/port/edge items, undoable controller
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>
2026-07-02 23:20:09 +02:00

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),
)