figkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
figkit/connectors.py ADDED
@@ -0,0 +1,689 @@
1
+ """Arrows and connectors: straight, elbow, curved, or via waypoints.
2
+
3
+ Endpoints are stored as *live references* — anchors, elements or raw points —
4
+ and resolved at render time, so moving a box drags its arrows along.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+
11
+ from .core import Anchor, Element
12
+ from .geom import BBox, Point, polyline_length, to_point
13
+ from .paint import paint_attrs
14
+ from .svgdoc import Node, RenderContext
15
+ from .svgpath import (fmt, flatten_path, path_bbox, path_from_points,
16
+ point_at, rounded_polyline)
17
+ from .text import Text
18
+
19
+ __all__ = [
20
+ "Connector", "arrow", "line", "elbow", "curve", "connect", "double_arrow",
21
+ "self_loop", "PathAnchor", "HEADS",
22
+ ]
23
+
24
+ HEADS = ("triangle", "stealth", "open", "vee", "circle", "dot", "diamond",
25
+ "square", "bar", "tee", "cross", "none")
26
+
27
+ _SIDE_NORMAL = {"n": Point(0, -1), "s": Point(0, 1),
28
+ "e": Point(1, 0), "w": Point(-1, 0)}
29
+
30
+
31
+ class PathAnchor(Anchor):
32
+ """A live anchor at a fraction ``t`` along a connector's path.
33
+
34
+ Unlike a bare point it re-resolves on read, so anything attached to the
35
+ middle of an arrow follows the arrow when its endpoints move.
36
+ """
37
+
38
+ __slots__ = ("t",)
39
+
40
+ def __init__(self, connector, t: float = 0.5, dx: float = 0.0,
41
+ dy: float = 0.0):
42
+ super().__init__(connector, name=None, dx=dx, dy=dy)
43
+ self.t = float(t)
44
+
45
+ @property
46
+ def point(self) -> Point:
47
+ p = self.element.point_at(self.t)
48
+ return Point(p.x + self.dx, p.y + self.dy)
49
+
50
+ @property
51
+ def normal(self) -> Point:
52
+ d = self.element.direction_at(self.t)
53
+ return Point(-d.y, d.x)
54
+
55
+ def offset(self, dx: float = 0.0, dy: float = 0.0) -> "PathAnchor":
56
+ return PathAnchor(self.element, self.t, self.dx + dx, self.dy + dy)
57
+
58
+ def __repr__(self) -> str:
59
+ return f"<PathAnchor t={self.t:g} -> {self.point}>"
60
+
61
+
62
+ # ==========================================================================
63
+ # Arrow heads
64
+ # ==========================================================================
65
+
66
+ def _head_geometry(kind: str, tip: Point, direction: Point, size: float,
67
+ width_ratio: float = 0.62) -> tuple:
68
+ """Return ``(path_or_None, node_kwargs, inset)`` for one arrow head.
69
+
70
+ ``inset`` is how far back along the line the stroke should stop so it does
71
+ not poke out of the head.
72
+ """
73
+ kind = (kind or "none").lower()
74
+ if kind in ("none", "", "off"):
75
+ return None, {}, 0.0
76
+ d = direction.normalized()
77
+ if d.length == 0:
78
+ d = Point(1, 0)
79
+ n = Point(-d.y, d.x) # perpendicular
80
+ half = size * width_ratio
81
+
82
+ if kind in ("triangle", "arrow", "filled"):
83
+ back = tip - d * size
84
+ pts = [tip, back + n * half, back - n * half]
85
+ return path_from_points(pts, close=True), {"filled": True}, size * 0.92
86
+ if kind == "stealth":
87
+ back = tip - d * size
88
+ notch = tip - d * (size * 0.55)
89
+ pts = [tip, back + n * half, notch, back - n * half]
90
+ return path_from_points(pts, close=True), {"filled": True}, size * 0.6
91
+ if kind in ("open", "vee", "v", "line"):
92
+ back = tip - d * size
93
+ d_path = (f"M{fmt((back + n * half).x)} {fmt((back + n * half).y)}"
94
+ f"L{fmt(tip.x)} {fmt(tip.y)}"
95
+ f"L{fmt((back - n * half).x)} {fmt((back - n * half).y)}")
96
+ return d_path, {"filled": False}, size * 0.35
97
+ if kind in ("circle", "dot", "disc"):
98
+ r = size * 0.42
99
+ c = tip - d * r
100
+ return None, {"circle": (c, r), "filled": kind != "circle_open"}, r * 1.9
101
+ if kind == "diamond":
102
+ c = tip - d * (size / 2.0)
103
+ pts = [tip, c + n * half, tip - d * size, c - n * half]
104
+ return path_from_points(pts, close=True), {"filled": True}, size * 0.95
105
+ if kind in ("square", "box"):
106
+ c = tip - d * (size * 0.4)
107
+ h = size * 0.4
108
+ pts = [c + d * h + n * h, c + d * h - n * h,
109
+ c - d * h - n * h, c - d * h + n * h]
110
+ return path_from_points(pts, close=True), {"filled": True}, size * 0.78
111
+ if kind in ("bar", "tee", "stop"):
112
+ d_path = (f"M{fmt((tip + n * half).x)} {fmt((tip + n * half).y)}"
113
+ f"L{fmt((tip - n * half).x)} {fmt((tip - n * half).y)}")
114
+ return d_path, {"filled": False}, 0.0
115
+ if kind == "cross":
116
+ h = size * 0.45
117
+ d_path = (f"M{fmt((tip + n * h + d * h).x)} {fmt((tip + n * h + d * h).y)}"
118
+ f"L{fmt((tip - n * h - d * h).x)} {fmt((tip - n * h - d * h).y)}"
119
+ f"M{fmt((tip - n * h + d * h).x)} {fmt((tip - n * h + d * h).y)}"
120
+ f"L{fmt((tip + n * h - d * h).x)} {fmt((tip + n * h - d * h).y)}")
121
+ return d_path, {"filled": False}, 0.0
122
+ raise ValueError(f"unknown arrow head {kind!r}; use one of {HEADS}")
123
+
124
+
125
+ # ==========================================================================
126
+ # Connector
127
+ # ==========================================================================
128
+
129
+ class Connector(Element):
130
+ """A line between two live endpoints, with optional heads and a label.
131
+
132
+ Prefer the helpers :func:`arrow`, :func:`elbow`, :func:`curve` and
133
+ :func:`line` — they are the same class with different defaults.
134
+ """
135
+
136
+ role = "arrow"
137
+ STROKE_WIDTH_ALIAS = True
138
+
139
+ def __init__(self, start, end, *, route: str = "straight", waypoints=None,
140
+ stub: float = 14.0, bend: float = 0.0, bow: float = 0.0,
141
+ corner: float = 6.0,
142
+ gap: float = 0.0, start_gap: float = None, end_gap: float = None,
143
+ head=None, tail=None, head_size=None, tail_size=None,
144
+ start_side: str = None, end_side: str = None,
145
+ label=None, label_pos: float = 0.5, label_offset: float = 9.0,
146
+ label_side: str = "auto", label_style=None, label_bg=None,
147
+ label_rotate: bool = False, **kw):
148
+ self.start_ref = start
149
+ self.end_ref = end
150
+ self.route = str(route).lower()
151
+ self.waypoints = list(waypoints or [])
152
+ self.stub = float(stub)
153
+ self.bend = float(bend)
154
+ self.bow = float(bow)
155
+ self.corner = float(corner)
156
+ self.start_gap = float(gap if start_gap is None else start_gap)
157
+ self.end_gap = float(gap if end_gap is None else end_gap)
158
+ self.start_side = start_side
159
+ self.end_side = end_side
160
+ self._head = head
161
+ self._tail = tail
162
+ self._head_size = head_size
163
+ self._tail_size = tail_size
164
+ self.label_pos = float(label_pos)
165
+ self.label_offset = float(label_offset)
166
+ self.label_side = label_side
167
+ self.label_bg = label_bg
168
+ self.label_rotate = label_rotate
169
+ self._label: Text | None = None
170
+ super().__init__(0, 0, None, None, **kw)
171
+ if label is not None and label != "":
172
+ self._label = Text(label, add=False, style=label_style)
173
+ self._label.parent = self
174
+ self._label.role = "label"
175
+
176
+ # -- endpoint resolution ---------------------------------------------
177
+ def _endpoint(self, ref, toward: Point, side: str = None) -> tuple:
178
+ """Resolve one endpoint to ``(point, outward_normal)``."""
179
+ if isinstance(ref, Anchor):
180
+ p = ref.point
181
+ n = ref.normal
182
+ if n.length == 0:
183
+ n = (p - toward).normalized()
184
+ return p, n
185
+ if isinstance(ref, Element):
186
+ bb = ref.bbox
187
+ if side:
188
+ key = str(side).lower()[:2]
189
+ key = {"to": "n", "bo": "s", "le": "w", "ri": "e"}.get(key, key[0])
190
+ p = bb.anchor(key)
191
+ return p, _SIDE_NORMAL.get(key, Point(0, 0))
192
+ angle = math.degrees(math.atan2(toward.y - bb.cy, toward.x - bb.cx))
193
+ p = bb.at_angle(angle)
194
+ n = _dominant_normal(p, bb)
195
+ return p, n
196
+ p = to_point(ref)
197
+ return p, Point(0, 0)
198
+
199
+ def endpoints(self) -> tuple:
200
+ """``((p0, n0), (p1, n1))`` resolved right now."""
201
+ hint_a = to_point(self.waypoints[0]) if self.waypoints else _rough(self.end_ref)
202
+ hint_b = to_point(self.waypoints[-1]) if self.waypoints else _rough(self.start_ref)
203
+ a = self._endpoint(self.start_ref, hint_a, self.start_side)
204
+ b = self._endpoint(self.end_ref, hint_b, self.end_side)
205
+ if not self.waypoints:
206
+ a = self._endpoint(self.start_ref, b[0], self.start_side)
207
+ b = self._endpoint(self.end_ref, a[0], self.end_side)
208
+ return a, b
209
+
210
+ # -- geometry --------------------------------------------------------
211
+ def geometry(self) -> tuple:
212
+ """``(path_data, start_point, start_dir, end_point, end_dir)``."""
213
+ (p0, n0), (p1, n1) = self.endpoints()
214
+ if self.start_gap:
215
+ p0 = p0 + (n0 if n0.length else (p1 - p0).normalized()) * self.start_gap
216
+ if self.end_gap:
217
+ p1 = p1 + (n1 if n1.length else (p0 - p1).normalized()) * self.end_gap
218
+ wp = [to_point(w) for w in self.waypoints]
219
+ route = self.route
220
+
221
+ if route in ("elbow", "orth", "orthogonal", "hv", "vh", "manhattan"):
222
+ pts = _elbow_points(p0, n0, p1, n1, self.stub, route, wp)
223
+ d = rounded_polyline(pts, self.corner) if self.corner else \
224
+ path_from_points(pts)
225
+ sd = (pts[1] - pts[0]) if len(pts) > 1 else Point(1, 0)
226
+ ed = (pts[-1] - pts[-2]) if len(pts) > 1 else Point(1, 0)
227
+ return d, p0, sd, p1, ed
228
+
229
+ if route in ("curve", "bezier", "spline", "arc"):
230
+ d, sd, ed = _curve_path(p0, n0, p1, n1, wp, self.bend,
231
+ self.bow, arc=(route == "arc"))
232
+ return d, p0, sd, p1, ed
233
+
234
+ pts = [p0] + wp + [p1]
235
+ if self.corner and wp:
236
+ d = rounded_polyline(pts, self.corner)
237
+ else:
238
+ d = path_from_points(pts)
239
+ sd = (pts[1] - pts[0]) if len(pts) > 1 else Point(1, 0)
240
+ ed = (pts[-1] - pts[-2]) if len(pts) > 1 else Point(1, 0)
241
+ return d, p0, sd, p1, ed
242
+
243
+ def path_data(self, bb: BBox = None) -> str:
244
+ return self.geometry()[0]
245
+
246
+ def polyline(self, steps: int = 24) -> list:
247
+ pts: list = []
248
+ for poly in flatten_path(self.path_data(), steps):
249
+ pts.extend(poly)
250
+ return [Point(*p) for p in pts]
251
+
252
+ def point_at(self, t: float) -> Point:
253
+ """Point at fraction ``t`` (0..1) along the connector."""
254
+ return point_at(self.path_data(), t)[0]
255
+
256
+ def direction_at(self, t: float) -> Point:
257
+ return point_at(self.path_data(), t)[1]
258
+
259
+ def anchor_at(self, t: float) -> PathAnchor:
260
+ """A *live* anchor at fraction ``t`` along the path."""
261
+ return PathAnchor(self, t)
262
+
263
+ @property
264
+ def mid(self) -> PathAnchor:
265
+ """A live anchor at the midpoint of the path."""
266
+ return PathAnchor(self, 0.5)
267
+
268
+ @property
269
+ def length(self) -> float:
270
+ return polyline_length(self.polyline())
271
+
272
+ def _measure(self) -> None:
273
+ d = self.path_data()
274
+ x0, y0, x1, y1 = path_bbox(d) if d else (0, 0, 0, 0)
275
+ self._x, self._y = x0, y0
276
+ self._w, self._h = x1 - x0, y1 - y0
277
+ if self._label is not None:
278
+ self._place_label()
279
+
280
+ @property
281
+ def local_bbox(self) -> BBox:
282
+ self._dirty = True # endpoints are live; always re-measure
283
+ self._ensure()
284
+ bb = BBox(self._x, self._y, self._w or 0.0, self._h or 0.0)
285
+ if self._label is not None:
286
+ bb = bb.union(self._label.local_bbox)
287
+ return bb
288
+
289
+ # -- label -----------------------------------------------------------
290
+ @property
291
+ def label(self) -> Text | None:
292
+ self._ensure()
293
+ return self._label
294
+
295
+ def set_label(self, text, **style) -> "Connector":
296
+ if self._label is None:
297
+ self._label = Text(text, add=False)
298
+ self._label.parent = self
299
+ self._label.role = "label"
300
+ else:
301
+ self._label.text = text
302
+ if style:
303
+ self._label.restyle(**style)
304
+ return self.invalidate()
305
+
306
+ def _place_label(self) -> None:
307
+ self._label.reset_transform() # placement re-runs on every measure
308
+ p, d = point_at(self.path_data(), self.label_pos)
309
+ n = Point(-d.y, d.x)
310
+ side = str(self.label_side).lower()
311
+ if side in ("auto", "above", "left"):
312
+ if n.y > 0:
313
+ n = -n
314
+ elif side in ("below", "right"):
315
+ if n.y < 0:
316
+ n = -n
317
+ elif side in ("none", "on", "center"):
318
+ n = Point(0, 0)
319
+ target = p + n * self.label_offset
320
+ anchor = "center"
321
+ if self.label_rotate:
322
+ anchor = "center" # a rotated label reads best centred
323
+ elif abs(n.x) > abs(n.y) and n.length:
324
+ anchor = "w" if n.x > 0 else "e"
325
+ elif n.length:
326
+ anchor = "n" if n.y > 0 else "s"
327
+ self._label.at(target.x, target.y, anchor=anchor)
328
+ if self.label_rotate:
329
+ angle = math.degrees(math.atan2(d.y, d.x))
330
+ if angle > 90 or angle < -90: # keep the text right way up
331
+ angle += 180
332
+ self._label.rotate(angle)
333
+
334
+ # -- rendering -------------------------------------------------------
335
+ def _render_content(self, ctx: RenderContext):
336
+ d, p0, sd, p1, ed = self.geometry()
337
+ if not d:
338
+ return None
339
+ stroke = self.prop("stroke", None)
340
+ stroke_w = self.prop("stroke_width", 1.5) or 1.5
341
+ head_kind = self._head if self._head is not None else self.prop("head")
342
+ tail_kind = self._tail if self._tail is not None else self.prop("tail")
343
+ head_size = float(self._head_size if self._head_size is not None
344
+ else self.prop("head_size", 9))
345
+ tail_size = float(self._tail_size if self._tail_size is not None
346
+ else self.prop("tail_size", head_size))
347
+ # heads scale a little with the line weight so they never look pinned on
348
+ head_size = head_size * (0.72 + 0.28 * max(1.0, float(stroke_w)))
349
+ tail_size = tail_size * (0.72 + 0.28 * max(1.0, float(stroke_w)))
350
+
351
+ nodes: list = []
352
+ head_nodes: list = []
353
+ has_head = bool(head_kind) and str(head_kind).lower() != "none"
354
+ has_tail = bool(tail_kind) and str(tail_kind).lower() != "none"
355
+
356
+ # How far back the stroke has to stop for each head to cover its end.
357
+ trim_end = _head_inset(head_kind, head_size) if has_head else 0.0
358
+ trim_start = _head_inset(tail_kind, tail_size) if has_tail else 0.0
359
+ if trim_start or trim_end:
360
+ d = _trim_path(d, trim_start, trim_end)
361
+
362
+ # Point each head from where the stroke now ends toward the tip, rather
363
+ # than along the tangent at the tip. On a curve those differ, and using
364
+ # the tangent lets the stroke escape sideways from under the head.
365
+ if has_head:
366
+ ed = _aim(d, p1, ed, at_start=False)
367
+ hd, meta, _ = _head_geometry(head_kind, p1, ed, head_size)
368
+ head_nodes.append(_head_node(hd, meta, stroke, stroke_w, self, ctx))
369
+ if has_tail:
370
+ sd = -_aim(d, p0, -sd, at_start=True)
371
+ td, meta, _ = _head_geometry(tail_kind, p0, -sd, tail_size)
372
+ head_nodes.append(_head_node(td, meta, stroke, stroke_w, self, ctx))
373
+
374
+ line_attrs = paint_attrs(self, ctx)
375
+ line_attrs["fill"] = "none"
376
+ nodes.append(Node("path", d=d, **line_attrs))
377
+ nodes.extend(n for n in head_nodes if n is not None)
378
+
379
+ if self._label is not None:
380
+ self._ensure()
381
+ if self.label_bg:
382
+ lb = self._label.local_bbox.expand((2, 4))
383
+ bg = self.label_bg if isinstance(self.label_bg, str) else "#ffffff"
384
+ nodes.append(Node("rect", x=lb.x, y=lb.y, width=lb.w,
385
+ height=lb.h, rx=3, fill=bg, stroke="none"))
386
+ n = self._label.render(ctx)
387
+ if n is not None:
388
+ nodes.append(n)
389
+ return nodes
390
+
391
+
392
+ def _head_inset(kind: str, size: float) -> float:
393
+ """How far short of the tip the stroke must stop for this head shape."""
394
+ _d, _meta, inset = _head_geometry(kind, Point(0, 0), Point(1, 0), size)
395
+ return inset
396
+
397
+
398
+ def _aim(d: str, tip: Point, fallback: Point, at_start: bool) -> Point:
399
+ """Direction from the (already trimmed) stroke end toward ``tip``."""
400
+ if not d:
401
+ return fallback
402
+ polys = flatten_path(d, 8)
403
+ if not polys or not polys[0]:
404
+ return fallback
405
+ end = Point(*(polys[0][0] if at_start else polys[-1][-1]))
406
+ direction = tip - end
407
+ return direction.normalized() if direction.length > 1e-6 else fallback
408
+
409
+
410
+ def _head_node(d, meta, stroke, stroke_w, el, ctx) -> Node | None:
411
+ color = stroke if stroke not in (None, "none") else el.prop("fill", "#000")
412
+ if "circle" in meta:
413
+ c, r = meta["circle"]
414
+ return Node("ellipse", cx=c.x, cy=c.y, rx=r, ry=r, fill=color,
415
+ stroke="none")
416
+ if d is None:
417
+ return None
418
+ if meta.get("filled"):
419
+ return Node("path", d=d, fill=color, stroke="none")
420
+ return Node("path", d=d, fill="none", stroke=color, stroke_width=stroke_w,
421
+ stroke_linecap="round", stroke_linejoin="round")
422
+
423
+
424
+ def _rough(ref) -> Point:
425
+ """A cheap point for an endpoint, used only to orient the other end."""
426
+ if isinstance(ref, Anchor):
427
+ return ref.point
428
+ if isinstance(ref, Element):
429
+ return ref.bbox.center
430
+ return to_point(ref)
431
+
432
+
433
+ def _dominant_normal(p: Point, bb: BBox) -> Point:
434
+ """Which side of ``bb`` does point ``p`` sit on?"""
435
+ tol = 1e-6
436
+ if abs(p.x - bb.x0) < tol:
437
+ return Point(-1, 0)
438
+ if abs(p.x - bb.x1) < tol:
439
+ return Point(1, 0)
440
+ if abs(p.y - bb.y0) < tol:
441
+ return Point(0, -1)
442
+ if abs(p.y - bb.y1) < tol:
443
+ return Point(0, 1)
444
+ return Point(0, 0)
445
+
446
+
447
+ def _elbow_points(p0: Point, n0: Point, p1: Point, n1: Point, stub: float,
448
+ mode: str, waypoints: list) -> list:
449
+ """Orthogonal route from ``p0`` to ``p1`` honouring the exit normals."""
450
+ if waypoints:
451
+ pts = [p0]
452
+ prev = p0
453
+ for w in waypoints:
454
+ pts.extend(_ortho_pair(prev, w))
455
+ prev = w
456
+ pts.extend(_ortho_pair(prev, p1))
457
+ pts.append(p1)
458
+ return _dedupe(pts)
459
+
460
+ if n0.length == 0 and n1.length == 0:
461
+ n0, n1 = _infer_normals(p0, p1, mode)
462
+ elif n0.length == 0:
463
+ n0 = -n1 if abs(n1.x) > abs(n1.y) else -n1
464
+ elif n1.length == 0:
465
+ n1 = -n0
466
+
467
+ a = p0 + n0 * stub
468
+ b = p1 + n1 * stub
469
+ h0 = abs(n0.x) > abs(n0.y)
470
+ h1 = abs(n1.x) > abs(n1.y)
471
+
472
+ if h0 and h1:
473
+ mx = (a.x + b.x) / 2.0
474
+ pts = [p0, a, Point(mx, a.y), Point(mx, b.y), b, p1]
475
+ elif (not h0) and (not h1):
476
+ my = (a.y + b.y) / 2.0
477
+ pts = [p0, a, Point(a.x, my), Point(b.x, my), b, p1]
478
+ elif h0 and not h1:
479
+ pts = [p0, a, Point(b.x, a.y), b, p1]
480
+ else:
481
+ pts = [p0, a, Point(a.x, b.y), b, p1]
482
+ return _dedupe(pts)
483
+
484
+
485
+ def _ortho_pair(a: Point, b: Point) -> list:
486
+ b = to_point(b)
487
+ if abs(b.x - a.x) < 1e-9 or abs(b.y - a.y) < 1e-9:
488
+ return []
489
+ return [Point(b.x, a.y)]
490
+
491
+
492
+ def _infer_normals(p0: Point, p1: Point, mode: str) -> tuple:
493
+ dx, dy = p1.x - p0.x, p1.y - p0.y
494
+ horizontal_first = abs(dx) >= abs(dy)
495
+ if mode == "hv":
496
+ horizontal_first = True
497
+ elif mode == "vh":
498
+ horizontal_first = False
499
+ if horizontal_first:
500
+ n0 = Point(1 if dx >= 0 else -1, 0)
501
+ n1 = Point(0, -1 if dy >= 0 else 1)
502
+ else:
503
+ n0 = Point(0, 1 if dy >= 0 else -1)
504
+ n1 = Point(-1 if dx >= 0 else 1, 0)
505
+ return n0, n1
506
+
507
+
508
+ def _dedupe(pts: list) -> list:
509
+ out = [pts[0]]
510
+ for p in pts[1:]:
511
+ if abs(p.x - out[-1].x) > 1e-7 or abs(p.y - out[-1].y) > 1e-7:
512
+ out.append(p)
513
+ return out
514
+
515
+
516
+ def _curve_path(p0: Point, n0: Point, p1: Point, n1: Point, waypoints: list,
517
+ bend: float, bow: float = 0.0, arc: bool = False) -> tuple:
518
+ """Build the curve.
519
+
520
+ ``bend`` deepens the bow: when the endpoints are anchors (so we know which
521
+ way they face) it lengthens the handles *along those normals*; for plain
522
+ points there is no normal to follow, so it bows sideways instead.
523
+ ``bow`` always displaces sideways, positive = left of travel.
524
+ """
525
+ chord = p1 - p0
526
+ dist = chord.length or 1.0
527
+ if waypoints:
528
+ pts = [p0] + waypoints + [p1]
529
+ return _catmull_rom(pts), (waypoints[0] - p0), (p1 - waypoints[-1])
530
+
531
+ left = Point(chord.y, -chord.x).normalized() # left of travel (y is down)
532
+
533
+ if arc or (n0.length == 0 and n1.length == 0):
534
+ b = bend + bow if (bend or bow) else 0.25
535
+ mid = p0.lerp(p1, 0.5) + left * (dist * b)
536
+ d = (f"M{fmt(p0.x)} {fmt(p0.y)}Q{fmt(mid.x)} {fmt(mid.y)} "
537
+ f"{fmt(p1.x)} {fmt(p1.y)}")
538
+ return d, (mid - p0), (p1 - mid)
539
+
540
+ # Handle reach follows how well each exit normal lines up with the chord:
541
+ # a sideways exit gets a short handle so the curve stays tidy.
542
+ u = chord.normalized()
543
+ d0 = n0 if n0.length else u
544
+ d1 = n1 if n1.length else -u
545
+ reach = max(0.18, abs(bend))
546
+ k0 = min(dist * 0.9, max(10.0, dist * 0.55 * max(abs(u.dot(d0)), reach)))
547
+ k1 = min(dist * 0.9, max(10.0, dist * 0.55 * max(abs((-u).dot(d1)), reach)))
548
+ c0 = p0 + d0 * k0
549
+ c1 = p1 + d1 * k1
550
+ if bow:
551
+ c0 = c0 + left * (dist * bow * 0.6)
552
+ c1 = c1 + left * (dist * bow * 0.6)
553
+ d = (f"M{fmt(p0.x)} {fmt(p0.y)}C{fmt(c0.x)} {fmt(c0.y)} "
554
+ f"{fmt(c1.x)} {fmt(c1.y)} {fmt(p1.x)} {fmt(p1.y)}")
555
+ return d, (c0 - p0), (p1 - c1)
556
+
557
+
558
+ def _catmull_rom(points: list, tension: float = 0.5) -> str:
559
+ """Smooth cubic spline through all the points."""
560
+ pts = [to_point(p) for p in points]
561
+ if len(pts) < 3:
562
+ return path_from_points(pts)
563
+ ext = [pts[0]] + pts + [pts[-1]]
564
+ parts = [f"M{fmt(pts[0].x)} {fmt(pts[0].y)}"]
565
+ for i in range(1, len(ext) - 2):
566
+ p0, p1, p2, p3 = ext[i - 1], ext[i], ext[i + 1], ext[i + 2]
567
+ c1 = p1 + (p2 - p0) * (tension / 3.0)
568
+ c2 = p2 - (p3 - p1) * (tension / 3.0)
569
+ parts.append(f"C{fmt(c1.x)} {fmt(c1.y)} {fmt(c2.x)} {fmt(c2.y)} "
570
+ f"{fmt(p2.x)} {fmt(p2.y)}")
571
+ return "".join(parts)
572
+
573
+
574
+ def _trim_path(d: str, start: float, end: float) -> str:
575
+ """Shorten a path from both ends so arrow heads sit flush."""
576
+ polys = flatten_path(d, 28)
577
+ if not polys:
578
+ return d
579
+ pts = [Point(*p) for p in polys[0]]
580
+ for poly in polys[1:]:
581
+ pts.extend(Point(*p) for p in poly)
582
+ if len(pts) < 2:
583
+ return d
584
+ total = polyline_length(pts)
585
+ if total <= start + end + 0.5:
586
+ return ""
587
+ if start > 0:
588
+ pts = _cut(pts, start, from_start=True)
589
+ if end > 0:
590
+ pts = _cut(pts, end, from_start=False)
591
+ return path_from_points(pts)
592
+
593
+
594
+ def _cut(pts: list, amount: float, from_start: bool) -> list:
595
+ work = list(pts) if from_start else list(reversed(pts))
596
+ acc = 0.0
597
+ for i in range(len(work) - 1):
598
+ seg = work[i].distance_to(work[i + 1])
599
+ if acc + seg >= amount:
600
+ t = (amount - acc) / seg if seg else 0.0
601
+ new_pt = work[i].lerp(work[i + 1], t)
602
+ out = [new_pt] + work[i + 1:]
603
+ return out if from_start else list(reversed(out))
604
+ acc += seg
605
+ return pts
606
+
607
+
608
+ # ==========================================================================
609
+ # Friendly constructors
610
+ # ==========================================================================
611
+
612
+ def arrow(start, end, **kw) -> Connector:
613
+ """A straight arrow from ``start`` to ``end``.
614
+
615
+ >>> arrow(fe.e, fm.w)
616
+ >>> arrow(box_a, box_b, head="stealth", label="loss")
617
+ """
618
+ kw.setdefault("route", "straight")
619
+ return Connector(start, end, **kw)
620
+
621
+
622
+ def line(start, end, **kw) -> Connector:
623
+ """A plain line (no arrow head)."""
624
+ kw.setdefault("route", "straight")
625
+ kw.setdefault("head", "none")
626
+ return Connector(start, end, **kw)
627
+
628
+
629
+ def elbow(start, end, stub: float = 14.0, **kw) -> Connector:
630
+ """An orthogonal ``-|`` style connector with a straight ``stub`` at each end."""
631
+ kw.setdefault("route", "elbow")
632
+ return Connector(start, end, stub=stub, **kw)
633
+
634
+
635
+ def curve(start, end, bend: float = 0.0, **kw) -> Connector:
636
+ """A smooth curve between two endpoints.
637
+
638
+ ``bend`` deepens the bow — following the anchors' facing direction when
639
+ you connect anchors (``a.s`` leaves downward), or sideways for plain
640
+ points. ``bow=`` always pushes sideways (positive = left of travel), and
641
+ ``waypoints=[...]`` routes the curve through specific points.
642
+ """
643
+ kw.setdefault("route", "curve")
644
+ return Connector(start, end, bend=bend, **kw)
645
+
646
+
647
+ def connect(start, end, route: str = "straight", **kw) -> Connector:
648
+ """Generic entry point: ``route`` is straight / elbow / curve / arc."""
649
+ return Connector(start, end, route=route, **kw)
650
+
651
+
652
+ def double_arrow(start, end, **kw) -> Connector:
653
+ """An arrow with heads on both ends."""
654
+ kw.setdefault("tail", kw.get("head", "triangle"))
655
+ return Connector(start, end, **kw)
656
+
657
+
658
+ def self_loop(element, side: str = "top", size: float = 36.0,
659
+ spread: float = 0.45, **kw) -> Connector:
660
+ """An arrow that leaves one element and comes back to it.
661
+
662
+ The staple of state machines and recurrent blocks. ``side`` picks the edge
663
+ it bulges from, ``size`` how far out it goes and ``spread`` how far apart
664
+ its feet sit, as a fraction of that edge.
665
+
666
+ >>> self_loop(state, side="top", label="retry")
667
+ """
668
+ box = element.bbox
669
+ half = max(0.02, min(0.9, float(spread))) / 2.0
670
+ s = str(side).lower()
671
+ if s in ("top", "n", "up"):
672
+ start, end = box.uv(0.5 - half, 0.0), box.uv(0.5 + half, 0.0)
673
+ apex = Point(box.cx, box.y0 - size)
674
+ elif s in ("bottom", "s", "down"):
675
+ start, end = box.uv(0.5 + half, 1.0), box.uv(0.5 - half, 1.0)
676
+ apex = Point(box.cx, box.y1 + size)
677
+ elif s in ("left", "w"):
678
+ start, end = box.uv(0.0, 0.5 + half), box.uv(0.0, 0.5 - half)
679
+ apex = Point(box.x0 - size, box.cy)
680
+ elif s in ("right", "e"):
681
+ start, end = box.uv(1.0, 0.5 - half), box.uv(1.0, 0.5 + half)
682
+ apex = Point(box.x1 + size, box.cy)
683
+ else:
684
+ raise ValueError(f"side={side!r}; use top/bottom/left/right")
685
+ kw.setdefault("route", "curve")
686
+ # Put any label outside the loop rather than inside its arc.
687
+ kw.setdefault("label_side", {"bottom": "below", "s": "below",
688
+ "down": "below"}.get(s, "above"))
689
+ return Connector(start, end, waypoints=[apex], **kw)