codefig 0.17.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.
codefig/edges.py ADDED
@@ -0,0 +1,2824 @@
1
+ """Port anchors, styled Edges, Diagram overlay: lanes, obstacles, bundling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from dataclasses import dataclass
7
+ from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
8
+
9
+ from .constraints import ConstraintScope
10
+ from .node import BoxGeom, Node
11
+ from .styles import (
12
+ DEFAULT_THEME,
13
+ current_theme,
14
+ themed,
15
+ ResolvedEdgeStyle,
16
+ Theme,
17
+ resolve_edge_style,
18
+ )
19
+ from .textutil import measure_text
20
+
21
+ Side = str # left|right|top|bottom|center
22
+ Pt = Tuple[float, float]
23
+
24
+
25
+ def _stub(x: float, y: float, direction: str, length: float) -> Pt:
26
+ if direction == "right":
27
+ return x + length, y
28
+ if direction == "left":
29
+ return x - length, y
30
+ if direction == "down":
31
+ return x, y + length
32
+ if direction == "up":
33
+ return x, y - length
34
+ return x + length, y
35
+
36
+
37
+ @dataclass
38
+ class Port:
39
+ """Symbolic anchor on a laid-out node (no absolute author coordinates)."""
40
+
41
+ node: Node
42
+ kind: str # side | row | col | cell | cell_range | custom
43
+ side: Side = "right"
44
+ index: int = 0
45
+ col_index: int = 0
46
+ end_index: int = 0
47
+ at: float = 0.5
48
+ align_ref: Any = None
49
+ snap: bool = False
50
+ offset: float = 0.0
51
+ slide: float = 0.0 # v0.11.0: an explicit at= may move by up to this much
52
+ # v0.13.0: Edge(parallel=N) — leg i of n, spread across the overlap with ``peer``
53
+ peer: Any = None
54
+ par_i: int = 0
55
+ par_n: int = 1
56
+ par_gap: Optional[float] = None
57
+
58
+ def resolve(self) -> Tuple[float, float, str]:
59
+ x, y, d = self._resolve()
60
+ if self.offset and self.kind in ("side", "custom"):
61
+ # v0.13.0: offset= shifts a side port along its side (pt), e.g. three
62
+ # parallel arrows port("top", offset=-6/0/6)
63
+ if d in ("up", "down"):
64
+ x += self.offset
65
+ else:
66
+ y += self.offset
67
+ return x, y, d
68
+
69
+ def _resolve(self) -> Tuple[float, float, str]:
70
+ node = self.node
71
+ if self.kind == "parallel":
72
+ return resolve_parallel_port(self)
73
+ if self.kind == "aligned":
74
+ return on_outline(node, self.side, resolve_aligned_port(self))
75
+ if self.kind == "side":
76
+ if self.side == "auto": # resolved against the other end by Edge.endpoints
77
+ g = node.geom
78
+ return g.cx, g.cy, "right"
79
+ custom = getattr(node, "resolve_port_side", None)
80
+ if custom is not None: # e.g. Stacked: in line with the front card
81
+ return custom(self.side, self.at)
82
+ return on_outline(node, self.side, resolve_side_port(node, self.side, self.at))
83
+ if self.kind == "row" and hasattr(node, "resolve_row_port"):
84
+ return node.resolve_row_port(self.index, self.side) # type: ignore[attr-defined]
85
+ if self.kind == "col" and hasattr(node, "resolve_col_port"):
86
+ return node.resolve_col_port(self.index, self.side) # type: ignore[attr-defined]
87
+ if self.kind == "cell" and hasattr(node, "resolve_cell_port"):
88
+ return node.resolve_cell_port(self.index, self.col_index, self.side) # type: ignore[attr-defined]
89
+ if self.kind == "custom" and hasattr(node, "resolve_custom_port"):
90
+ # v0.7.0: node-defined anchors (Cuboid faces, Pyramid levels, …); the
91
+ # name travels in ``side`` and an optional integer in ``index``.
92
+ return node.resolve_custom_port(self.side, self.index, self.at) # type: ignore[attr-defined]
93
+ if self.kind == "cell_range" and hasattr(node, "resolve_cell_range_port"):
94
+ return node.resolve_cell_range_port(self.index, self.end_index, self.side) # type: ignore[attr-defined]
95
+ return resolve_side_port(node, self.side, self.at)
96
+
97
+
98
+ def _side_towards(g: Any, x: float, y: float, wside: str = "top") -> str:
99
+ """Side of box ``g`` that faces the waypoint (x, y) on side ``wside`` of its node
100
+ (v0.14.0): a route passing over / under a node leaves vertically when it can, one
101
+ passing beside a node leaves sideways."""
102
+ vert = ("bottom" if y >= g.bottom else "top" if y <= g.y else None)
103
+ horiz = ("right" if x >= g.right else "left" if x <= g.x else None)
104
+ if wside in ("top", "bottom"):
105
+ return vert or horiz or "right"
106
+ return horiz or vert or "bottom"
107
+
108
+
109
+ def _facing_side(node: Node, other: Node) -> str:
110
+ g, o = node.geom, other.geom
111
+ hgap = max(o.x - g.right, g.x - o.right)
112
+ vgap = max(o.y - g.bottom, g.y - o.bottom)
113
+ if vgap >= hgap:
114
+ return "bottom" if o.cy >= g.cy else "top"
115
+ return "right" if o.cx >= g.cx else "left"
116
+
117
+
118
+ def resolve_parallel_port(port: "Port") -> Tuple[float, float, str]:
119
+ """v0.13.0: end ``par_i`` of ``par_n`` parallel arrows between two nodes.
120
+
121
+ The arrows are spread evenly (``par_gap`` pt apart, default ≈ 6 pt, tighter when the
122
+ sides are short) around the middle of the range the two facing sides share, so they
123
+ run straight; without such an overlap each end spreads around its own side centre.
124
+ """
125
+ node, other = port.node, port.peer
126
+ side = port.side if port.side in ("left", "right", "top", "bottom") else _facing_side(node, other)
127
+ oside = {"left": "right", "right": "left", "top": "bottom", "bottom": "top"}[side]
128
+ x, y, w, h = _side_extent(node, side)
129
+ ox, oy, ow, oh = _side_extent(other, oside)
130
+ horiz = side in ("top", "bottom")
131
+ lo, hi = (x, x + w) if horiz else (y, y + h)
132
+ olo, ohi = (ox, ox + ow) if horiz else (oy, oy + oh)
133
+ n = max(1, int(port.par_n))
134
+ th = getattr(node, "theme", None)
135
+ L = th.L if th is not None else (lambda v: v)
136
+ m = L(2.0)
137
+ span = min(hi - lo, ohi - olo)
138
+ a, b = max(lo, olo), min(hi, ohi)
139
+ if port.par_gap is not None:
140
+ gap = port.par_gap
141
+ else: # spread over the shared range, 4–10 pt apart
142
+ gap = min(L(10.0), max(L(4.0), (max(b - a, 0.0) if b - a > 2 * m else span) / (n + 1)))
143
+ if n > 1:
144
+ gap = min(gap, max(0.5, (span - 2 * m) / (n - 1)))
145
+ need = gap * (n - 1)
146
+ centre = (a + b) / 2 if b - a >= need + 2 * m else (lo + hi) / 2
147
+ v = centre + (port.par_i - (n - 1) / 2) * gap
148
+ v = min(max(v, lo), hi)
149
+ if side == "top":
150
+ pt = (v, y, "up")
151
+ elif side == "bottom":
152
+ pt = (v, y + h, "down")
153
+ elif side == "left":
154
+ pt = (x, v, "left")
155
+ else:
156
+ pt = (x + w, v, "right")
157
+ return on_outline(node, side, pt)
158
+
159
+
160
+ def resolve_side_port(node: Node, side: Side, at: float = 0.5) -> Tuple[float, float, str]:
161
+ g = node.geom
162
+ at = max(0.0, min(1.0, at))
163
+ if side == "right":
164
+ return g.right, g.y + g.h * at, "right"
165
+ if side == "left":
166
+ return g.x, g.y + g.h * at, "left"
167
+ if side == "top":
168
+ return g.x + g.w * at, g.y, "up"
169
+ if side == "bottom":
170
+ return g.x + g.w * at, g.bottom, "down"
171
+ return g.cx, g.cy, "right"
172
+
173
+
174
+ def on_outline(node: Node, side: str, pt: Tuple[float, float, str]) -> Tuple[float, float, str]:
175
+ """Move a bounding-box port onto a shape's true outline (shapes.Shape)."""
176
+ if side in ("top", "bottom", "left", "right") and hasattr(node, "outline_point"):
177
+ x, y = node.outline_point(side, pt[0], pt[1]) # type: ignore[attr-defined]
178
+ return x, y, pt[2]
179
+ return pt
180
+
181
+
182
+ def auto_port(node: Node, tx: float, ty: float) -> Tuple[float, float, str]:
183
+ """Point on ``node``'s outline facing (tx, ty); direction = dominant axis."""
184
+ g = node.geom
185
+ if hasattr(node, "ray_point"):
186
+ tr = node._text_rect(g.x, g.y, g.w, g.h) # type: ignore[attr-defined]
187
+ cx, cy = tr[0] + tr[2] / 2, tr[1] + tr[3] / 2
188
+ else:
189
+ cx, cy = g.cx, g.cy
190
+ dx, dy = tx - cx, ty - cy
191
+ if abs(dx) < 1e-9 and abs(dy) < 1e-9:
192
+ return g.right, g.cy, "right"
193
+ if hasattr(node, "ray_point"):
194
+ x, y = node.ray_point(dx, dy) # type: ignore[attr-defined]
195
+ else:
196
+ from .shapes import ray_hit
197
+
198
+ x, y = ray_hit([(g.x, g.y), (g.right, g.y), (g.right, g.bottom), (g.x, g.bottom)],
199
+ cx, cy, dx, dy)
200
+ if abs(dx) >= abs(dy):
201
+ d = "right" if dx > 0 else "left"
202
+ else:
203
+ d = "down" if dy > 0 else "up"
204
+ return x, y, d
205
+
206
+
207
+ def _auto_in_line(node: Node, other: Tuple[float, float, str]) -> Optional[Tuple[float, float, str]]:
208
+ """v0.13.0: an ``auto`` end facing a fixed side port lines up with it when it can.
209
+
210
+ When the fixed end points straight at a rectangular ``node`` and its x (or y) falls
211
+ inside the node's extent, the auto end sits on the facing side *in line* with it, so
212
+ the connector is one straight segment instead of a ray-to-centre jog (e.g. several
213
+ ``Edge(p_i, gpu_i, via=loader)`` through one tall box stay straight).
214
+ """
215
+ if hasattr(node, "ray_point"):
216
+ return None
217
+ g = node.geom
218
+ x, y, d = other
219
+ m = 1.0
220
+ if d == "right" and x <= g.x + 1e-6 and g.y + m <= y <= g.bottom - m:
221
+ return g.x, y, "left"
222
+ if d == "left" and x >= g.right - 1e-6 and g.y + m <= y <= g.bottom - m:
223
+ return g.right, y, "right"
224
+ if d == "down" and y <= g.y + 1e-6 and g.x + m <= x <= g.right - m:
225
+ return x, g.y, "up"
226
+ if d == "up" and y >= g.bottom - 1e-6 and g.x + m <= x <= g.right - m:
227
+ return x, g.bottom, "down"
228
+ return None
229
+
230
+
231
+ def _side_extent(node: Node, side: Side) -> Tuple[float, float, float, float]:
232
+ """Box (x, y, w, h) whose side a port sits on — a Matrix uses its cell grid."""
233
+ if hasattr(node, "_grid_origin") and hasattr(node, "_grid_size"):
234
+ gx, gy = node._grid_origin() # type: ignore[attr-defined]
235
+ gw, gh = node._grid_size() # type: ignore[attr-defined]
236
+ return gx, gy, gw, gh
237
+ g = node.geom
238
+ return g.x, g.y, g.w, g.h
239
+
240
+
241
+ def resolve_aligned_port(port: "Port") -> Tuple[float, float, str]:
242
+ """Port on ``port.side`` of its node, in line with ``port.align_ref``."""
243
+ from .constraints import as_anchor
244
+
245
+ node, side = port.node, port.side
246
+ x, y, w, h = _side_extent(node, side)
247
+ horizontal_side = side in ("top", "bottom")
248
+ axis = "x" if horizontal_side else "y"
249
+ ref = port.align_ref
250
+ v = as_anchor(ref, axis, "center").value() + port.offset
251
+ if horizontal_side:
252
+ v = min(max(v, x), x + w)
253
+ if port.snap and hasattr(node, "col_at"):
254
+ c = node.col_at(v) # type: ignore[attr-defined]
255
+ v = node._col_center_x(c) # type: ignore[attr-defined]
256
+ elif port.snap and hasattr(node, "bar_at"):
257
+ v = node._bar_cx(node.bar_at(v)) # type: ignore[attr-defined]
258
+ if side == "top":
259
+ return v, y, "up"
260
+ return v, y + h, "down"
261
+ v = min(max(v, y), y + h)
262
+ if port.snap and hasattr(node, "row_at"):
263
+ v = node._row_center_y(node.row_at(v)) # type: ignore[attr-defined]
264
+ if side == "left":
265
+ return x, v, "left"
266
+ if side in ("right", "center"):
267
+ return x + w, v, "right"
268
+ return x + w, v, "right"
269
+
270
+
271
+ def _end_rects(e: "Edge", x1: float, y1: float, d1: str, x2: float, y2: float,
272
+ d2: str) -> list:
273
+ """v0.8.0: an edge's own end nodes as obstacles for its trunk.
274
+
275
+ Endpoint nodes are excluded from the obstacle set so the stubs can leave them; without
276
+ this a detour could still cut back through the source or target box. A node is added
277
+ (inset 0.5 pt, no padding) only when its port and stub point both lie outside it.
278
+ """
279
+ from .routing import ORect
280
+
281
+ out = []
282
+ stub = _facing_stub(x1, y1, d1, x2, y2, d2, e.stub)
283
+ for node, (x, y, d) in ((e.src.node, (x1, y1, d1)), (e.dst.node, (x2, y2, d2))):
284
+ g = getattr(node, "geom", None)
285
+ if g is None or g.w < 2 or g.h < 2:
286
+ continue
287
+ r = (g.x + 0.5, g.y + 0.5, g.w - 1.0, g.h - 1.0)
288
+ sx, sy = _stub(x, y, d, stub)
289
+
290
+ def inside(px: float, py: float) -> bool:
291
+ return r[0] < px < r[0] + r[2] and r[1] < py < r[1] + r[3]
292
+
293
+ if not inside(x, y) and not inside(sx, sy):
294
+ out.append(ORect(*r, role="hard", pad=0.0))
295
+ return out
296
+
297
+
298
+ def route_straight(
299
+ x1: float, y1: float, d1: str, x2: float, y2: float, d2: str, stub: float = 0.0
300
+ ) -> List[Pt]:
301
+ if stub <= 0:
302
+ return [(x1, y1), (x2, y2)]
303
+ ax, ay = _stub(x1, y1, d1, stub)
304
+ bx, by = _stub(x2, y2, d2, stub)
305
+ return [(x1, y1), (ax, ay), (bx, by), (x2, y2)]
306
+
307
+
308
+ def _facing_stub(x1: float, y1: float, d1: str, x2: float, y2: float, d2: str,
309
+ stub: float) -> float:
310
+ """Stub length clamped to half the gap between two facing ports."""
311
+ gap = {("down", "up"): y2 - y1, ("up", "down"): y1 - y2,
312
+ ("right", "left"): x2 - x1, ("left", "right"): x1 - x2}.get((d1, d2))
313
+ if gap is not None and 0.0 < gap < 2.0 * stub:
314
+ return gap / 2.0
315
+ return stub
316
+
317
+
318
+ def _trunk0(a: float, b: float, d1: str, d2: str) -> float:
319
+ """Natural trunk coordinate between two stub ends ``a`` and ``b``.
320
+
321
+ Opposite or facing ports meet half way. Two ports facing the *same* way (both
322
+ ``left``, a C-shaped route) need the trunk outside both stubs: half way would make
323
+ the nearer stub double back on itself (v0.11.0)."""
324
+ if d1 == d2 and d1 in ("left", "up"):
325
+ return min(a, b)
326
+ if d1 == d2 and d1 in ("right", "down"):
327
+ return max(a, b)
328
+ return (a + b) / 2.0
329
+
330
+
331
+ def route_ortho(
332
+ x1: float,
333
+ y1: float,
334
+ d1: str,
335
+ x2: float,
336
+ y2: float,
337
+ d2: str,
338
+ stub: float = 12.0,
339
+ mid_bias: float = 0.0,
340
+ ) -> List[Pt]:
341
+ """Manhattan route; mid_bias shifts the shared trunk for lane separation.
342
+
343
+ Facing ports closer than two stubs (a narrow row / column gap) get shorter stubs so
344
+ the trunk stays inside the gap instead of doubling back through the nodes (v0.7.0).
345
+ """
346
+ stub = _facing_stub(x1, y1, d1, x2, y2, d2, stub)
347
+ ax, ay = _stub(x1, y1, d1, stub)
348
+ bx, by = _stub(x2, y2, d2, stub)
349
+
350
+ horiz_src = d1 in ("left", "right")
351
+ horiz_dst = d2 in ("left", "right")
352
+
353
+ if horiz_src and horiz_dst:
354
+ mid_x = _trunk0(ax, bx, d1, d2) + mid_bias
355
+ pts = [(x1, y1), (ax, ay), (mid_x, ay), (mid_x, by), (bx, by), (x2, y2)]
356
+ elif (not horiz_src) and (not horiz_dst):
357
+ mid_y = _trunk0(ay, by, d1, d2) + mid_bias
358
+ pts = [(x1, y1), (ax, ay), (ax, mid_y), (bx, mid_y), (bx, by), (x2, y2)]
359
+ elif horiz_src:
360
+ pts = [(x1, y1), (ax, ay), (bx + mid_bias, ay), (bx + mid_bias, by), (x2, y2)]
361
+ else:
362
+ pts = [(x1, y1), (ax, ay), (ax, by + mid_bias), (bx, by + mid_bias), (x2, y2)]
363
+
364
+ return _dedupe(pts)
365
+
366
+
367
+
368
+ def route_loop(
369
+ x1: float,
370
+ y1: float,
371
+ d1: str,
372
+ x2: float,
373
+ y2: float,
374
+ d2: str,
375
+ *,
376
+ stub: float = 12.0,
377
+ side: str = "auto",
378
+ clearance: float = 40.0,
379
+ ) -> List[Pt]:
380
+ """
381
+ Port-based feedback path that goes around content.
382
+
383
+ Both endpoints typically face the same outer side (e.g. both "right").
384
+ `side`: right|left|top|bottom|auto — which side of the content the loop hugs.
385
+ `clearance`: how far outside the farther port the trunk runs.
386
+ """
387
+ ax, ay = _stub(x1, y1, d1, stub)
388
+ bx, by = _stub(x2, y2, d2, stub)
389
+
390
+ if side == "auto":
391
+ # Prefer the shared outward direction of the ports.
392
+ if d1 == d2 and d1 in ("left", "right", "up", "down"):
393
+ side = {"right": "right", "left": "left", "up": "top", "down": "bottom"}[d1]
394
+ elif abs(x1 - x2) >= abs(y1 - y2):
395
+ side = "right" if (x1 + x2) / 2 >= 0 else "left"
396
+ else:
397
+ side = "right"
398
+
399
+ if side == "right":
400
+ trunk_x = max(ax, bx, x1, x2) + clearance
401
+ pts = [(x1, y1), (ax, ay), (trunk_x, ay), (trunk_x, by), (bx, by), (x2, y2)]
402
+ elif side == "left":
403
+ trunk_x = min(ax, bx, x1, x2) - clearance
404
+ pts = [(x1, y1), (ax, ay), (trunk_x, ay), (trunk_x, by), (bx, by), (x2, y2)]
405
+ elif side == "bottom":
406
+ trunk_y = max(ay, by, y1, y2) + clearance
407
+ pts = [(x1, y1), (ax, ay), (ax, trunk_y), (bx, trunk_y), (bx, by), (x2, y2)]
408
+ else: # top
409
+ trunk_y = min(ay, by, y1, y2) - clearance
410
+ pts = [(x1, y1), (ax, ay), (ax, trunk_y), (bx, trunk_y), (bx, by), (x2, y2)]
411
+ return _dedupe(pts)
412
+
413
+
414
+ def route_loop_around(x1, y1, d1, x2, y2, d2, edge: "Edge") -> List[Pt]:
415
+ """Loop whose trunk runs ``clearance`` outside ``edge.around``'s box."""
416
+ g = edge.around.geom
417
+ side = edge.loop_side if edge.loop_side != "auto" else "right"
418
+ c = edge.clearance
419
+ ax, ay = _stub(x1, y1, d1, edge.stub)
420
+ bx, by = _stub(x2, y2, d2, edge.stub)
421
+ if side == "top":
422
+ t = min(g.y - c, ay, by)
423
+ pts = [(x1, y1), (ax, ay), (ax, t), (bx, t), (bx, by), (x2, y2)]
424
+ elif side == "bottom":
425
+ t = max(g.y + g.h + c, ay, by)
426
+ pts = [(x1, y1), (ax, ay), (ax, t), (bx, t), (bx, by), (x2, y2)]
427
+ elif side == "left":
428
+ t = min(g.x - c, ax, bx)
429
+ pts = [(x1, y1), (ax, ay), (t, ay), (t, by), (bx, by), (x2, y2)]
430
+ else:
431
+ t = max(g.x + g.w + c, ax, bx)
432
+ pts = [(x1, y1), (ax, ay), (t, ay), (t, by), (bx, by), (x2, y2)]
433
+ return _dedupe(pts)
434
+
435
+
436
+ def _dedupe(pts: List[Pt]) -> List[Pt]:
437
+ out: List[Pt] = []
438
+ for p in pts:
439
+ if not out or abs(out[-1][0] - p[0]) > 0.01 or abs(out[-1][1] - p[1]) > 0.01:
440
+ out.append(p)
441
+ return out
442
+
443
+
444
+ def _simplify_ortho(pts: List[Pt], backtracks_only: bool = False) -> List[Pt]:
445
+ """Drop duplicate / collinear vertices of a polyline (keeps both ends). Collinear
446
+ backtracks shorter than 0.5 are folded too; longer ones are kept. With
447
+ ``backtracks_only`` plain collinear vertices stay (label placement reads them)."""
448
+ out = _dedupe(list(pts))
449
+ changed = True
450
+ while changed and len(out) > 2:
451
+ changed = False
452
+ for i in range(1, len(out) - 1):
453
+ a, b, c = out[i - 1], out[i], out[i + 1]
454
+ col_x = abs(a[0] - b[0]) < 1e-6 and abs(b[0] - c[0]) < 1e-6
455
+ col_y = abs(a[1] - b[1]) < 1e-6 and abs(b[1] - c[1]) < 1e-6
456
+ if not (col_x or col_y):
457
+ continue
458
+ k = 1 if col_x else 0
459
+ back = (b[k] - a[k]) * (c[k] - b[k]) < 0
460
+ if back and min(abs(b[k] - a[k]), abs(c[k] - b[k])) >= 0.5:
461
+ continue
462
+ if backtracks_only and not back:
463
+ continue
464
+ del out[i]
465
+ changed = True
466
+ break
467
+ return out
468
+
469
+
470
+ def _as_port(ref: Union[Port, Node], default_side: Side) -> Port:
471
+ if isinstance(ref, Port):
472
+ return ref
473
+ if isinstance(ref, Node):
474
+ return ref.port(default_side)
475
+ raise TypeError(f"Edge endpoint must be Port or Node, got {type(ref)!r}")
476
+
477
+
478
+ # ---- overlap avoidance -------------------------------------------------
479
+
480
+ def _vertical_trunks(pts: List[Pt]) -> List[Tuple[int, float, float, float]]:
481
+ """Return (seg_index, x, y_min, y_max) for vertical segments longer than epsilon."""
482
+ out = []
483
+ for i in range(len(pts) - 1):
484
+ x1, y1 = pts[i]
485
+ x2, y2 = pts[i + 1]
486
+ if abs(x1 - x2) < 0.51 and abs(y1 - y2) > 1.0:
487
+ out.append((i, (x1 + x2) / 2.0, min(y1, y2), max(y1, y2)))
488
+ return out
489
+
490
+
491
+ def _horizontal_trunks(pts: List[Pt]) -> List[Tuple[int, float, float, float]]:
492
+ out = []
493
+ for i in range(len(pts) - 1):
494
+ x1, y1 = pts[i]
495
+ x2, y2 = pts[i + 1]
496
+ if abs(y1 - y2) < 0.51 and abs(x1 - x2) > 1.0:
497
+ out.append((i, (y1 + y2) / 2.0, min(x1, x2), max(x1, x2)))
498
+ return out
499
+
500
+
501
+ def _ranges_overlap(a0: float, a1: float, b0: float, b1: float, pad: float = 2.0) -> bool:
502
+ return not (a1 + pad < b0 or b1 + pad < a0)
503
+
504
+
505
+ def _offset_segment_x(pts: List[Pt], seg_i: int, dx: float) -> List[Pt]:
506
+ pts = list(pts)
507
+ x1, y1 = pts[seg_i]
508
+ x2, y2 = pts[seg_i + 1]
509
+ pts[seg_i] = (x1 + dx, y1)
510
+ pts[seg_i + 1] = (x2 + dx, y2)
511
+ return pts
512
+
513
+
514
+ def _offset_segment_y(pts: List[Pt], seg_i: int, dy: float) -> List[Pt]:
515
+ pts = list(pts)
516
+ x1, y1 = pts[seg_i]
517
+ x2, y2 = pts[seg_i + 1]
518
+ pts[seg_i] = (x1, y1 + dy)
519
+ pts[seg_i + 1] = (x2, y2 + dy)
520
+ return pts
521
+
522
+
523
+
524
+ _OUT = {"left": -1.0, "up": -1.0, "right": 1.0, "down": 1.0}
525
+
526
+
527
+ def _merge_shared_port_trunks(meta: List[dict], biases: List[float]) -> None:
528
+ """v0.11.0: edges that share one port merge into one trunk, whatever side or
529
+ corridor they come from.
530
+
531
+ Lane buckets group edges by their natural trunk, so a fan-in whose sources sit on
532
+ opposite sides of the port (one above, one below a ``left`` port) or at different
533
+ distances lands in different buckets and gets two trunks a few points apart — two
534
+ vertical runs and a doubled stub next to the arrowhead. For every port shared by
535
+ two or more two-bend routes, all of them take one common trunk: the member trunk
536
+ closest to the port that no member has to double back to reach."""
537
+ def stub_end(m: dict, which: str) -> float:
538
+ d = m["d" + which]
539
+ stub = m["edge"].stub
540
+ k = "x" if d in ("left", "right") else "y"
541
+ return m[k + which] + _OUT[d] * stub
542
+
543
+ done: set = set()
544
+ for which, other in (("2", "1"), ("1", "2")):
545
+ groups: Dict[Tuple[int, int, str], List[int]] = defaultdict(list)
546
+ for i, m in enumerate(meta):
547
+ if m["kind"] not in ("h", "v") or m["edge"].route != "ortho" or \
548
+ m["st"].overlap != "avoid" or getattr(m["edge"], "bundle", None) or \
549
+ not getattr(m["edge"], "trunk", True):
550
+ continue
551
+ groups[(round(m["x" + which] * 10), round(m["y" + which] * 10), m["d" + which])].append(i)
552
+ for (_gx, _gy, d), idxs in groups.items():
553
+ idxs = [i for i in idxs if i not in done]
554
+ if len(idxs) < 2:
555
+ continue
556
+ trunks = {i: meta[i]["mid"] + biases[i] for i in idxs}
557
+ if max(trunks.values()) - min(trunks.values()) < 0.05:
558
+ continue
559
+ p, s_ = stub_end(meta[idxs[0]], which), _OUT[d]
560
+
561
+ def ok(c: float) -> bool:
562
+ if s_ * (c - p) < -0.01:
563
+ return False
564
+ for i in idxs:
565
+ m = meta[i]
566
+ if _OUT[m["d" + other]] * (c - stub_end(m, other)) < -0.01:
567
+ return False
568
+ return True
569
+
570
+ feas = [c for c in set(trunks.values()) if ok(c)]
571
+ if not feas:
572
+ continue
573
+ c = min(feas, key=lambda v: s_ * (v - p))
574
+ for i in idxs:
575
+ biases[i] = c - meta[i]["mid"]
576
+ done.add(i)
577
+
578
+
579
+ def apply_overlap_avoidance(
580
+ routes: List[Tuple["Edge", List[Pt]]],
581
+ *,
582
+ lane_gap: float = 7.0,
583
+ theme: Optional[Theme] = None,
584
+ ) -> List[Tuple["Edge", List[Pt]]]:
585
+ """
586
+ Re-route avoid-edges with per-lane mid_bias so parallel ortho corridors separate.
587
+
588
+ Algorithm:
589
+ 1. Resolve endpoints for every edge; compute the natural ortho mid coordinate
590
+ (mid_x for left↔right flows, mid_y for top↔bottom).
591
+ 2. Bucket avoid/ortho edges by rounded mid coordinate.
592
+ 3. Within each bucket, sort by source cross-axis position; assign centered lanes
593
+ …,-1,0,1,… and re-route with mid_bias = lane * lane_gap.
594
+ 4. overlap='allow' (and straight edges) keep the base route unchanged.
595
+
596
+ This avoids post-hoc segment surgery (which used to bend stubs) and still
597
+ separates edges that share a corridor even when their spans do not overlap.
598
+ """
599
+ if not routes:
600
+ return []
601
+
602
+ # Resolve once
603
+ meta = []
604
+ for edge, base_pts in routes:
605
+ (x1, y1, d1), (x2, y2, d2) = edge.endpoints()
606
+ st = edge.resolved(theme=theme)
607
+ horiz = d1 in ("left", "right") and d2 in ("left", "right")
608
+ vert = d1 in ("up", "down") and d2 in ("up", "down")
609
+ ax, ay = _stub(x1, y1, d1, edge.stub)
610
+ bx, by = _stub(x2, y2, d2, edge.stub)
611
+ if horiz:
612
+ mid = _trunk0(ax, bx, d1, d2)
613
+ sort_key = (y1 + y2) / 2.0
614
+ kind = "h"
615
+ elif vert:
616
+ mid = _trunk0(ay, by, d1, d2)
617
+ sort_key = (x1 + x2) / 2.0
618
+ kind = "v"
619
+ else:
620
+ mid = (ax + bx) / 2.0
621
+ sort_key = (y1 + y2) / 2.0
622
+ kind = "m"
623
+ meta.append(
624
+ {
625
+ "edge": edge,
626
+ "base": base_pts,
627
+ "st": st,
628
+ "x1": x1, "y1": y1, "d1": d1,
629
+ "x2": x2, "y2": y2, "d2": d2,
630
+ "mid": mid,
631
+ "sort_key": sort_key,
632
+ "kind": kind,
633
+ }
634
+ )
635
+
636
+ biases = [0.0] * len(meta)
637
+ buckets: Dict[Tuple[str, float], List[int]] = defaultdict(list)
638
+ gap = max(lane_gap, 1.0)
639
+ for i, m in enumerate(meta):
640
+ if m["st"].overlap != "avoid" or m["edge"].route != "ortho":
641
+ continue
642
+ key = (m["kind"], round(m["mid"] / gap) * gap)
643
+ buckets[key].append(i)
644
+
645
+ def _end(i: int, which: str) -> Tuple[int, int]:
646
+ m = meta[i]
647
+ return (round(m["x" + which] * 10), round(m["y" + which] * 10))
648
+
649
+ for _key, idxs in buckets.items():
650
+ if len(idxs) < 2:
651
+ continue
652
+ # v0.10.0: a fan-in (edges ending on one point) or fan-out (starting from
653
+ # one point) shares a lane, so the edges merge into one clean trunk
654
+ # instead of splitting into parallel stubs next to the shared port
655
+ parent = {i: i for i in idxs}
656
+
657
+ def find(i: int) -> int:
658
+ while parent[i] != i:
659
+ parent[i] = parent[parent[i]]
660
+ i = parent[i]
661
+ return i
662
+
663
+ for which in ("1", "2"):
664
+ seen: Dict[Tuple[int, int], int] = {}
665
+ for i in idxs:
666
+ k = _end(i, which)
667
+ if k in seen:
668
+ parent[find(i)] = find(seen[k])
669
+ else:
670
+ seen[k] = i
671
+ groups: Dict[int, List[int]] = defaultdict(list)
672
+ for i in idxs:
673
+ groups[find(i)].append(i)
674
+ if len(groups) < 2:
675
+ continue
676
+ order = sorted(groups.values(),
677
+ key=lambda g: sum(meta[i]["sort_key"] for i in g) / len(g))
678
+ n = len(order)
679
+ for lane, g in enumerate(order):
680
+ for i in g:
681
+ biases[i] = (lane - (n - 1) / 2.0) * lane_gap
682
+
683
+ _merge_shared_port_trunks(meta, biases)
684
+
685
+ out: List[Tuple[Edge, List[Pt]]] = []
686
+ for i, m in enumerate(meta):
687
+ edge = m["edge"]
688
+ if abs(biases[i]) < 0.01 or edge.route != "ortho":
689
+ out.append((edge, list(m["base"])))
690
+ continue
691
+ pts = route_ortho(
692
+ m["x1"], m["y1"], m["d1"], m["x2"], m["y2"], m["d2"],
693
+ stub=edge.stub, mid_bias=biases[i],
694
+ )
695
+ out.append((edge, pts))
696
+ return out
697
+
698
+
699
+
700
+ _LABEL_SIDES = ("auto", "above", "below", "left", "right", "on")
701
+ _LABEL_ATS = ("mid", "longest", "start", "end")
702
+ _ROUTES = ("auto", "ortho", "straight", "curve", "loop", "over", "under", "back")
703
+
704
+ _DIRV = {"right": (1.0, 0.0), "left": (-1.0, 0.0), "up": (0.0, -1.0), "down": (0.0, 1.0)}
705
+
706
+
707
+ def bezier_point(ctrl: Sequence[Pt], t: float) -> Pt:
708
+ (x0, y0), (x1, y1), (x2, y2), (x3, y3) = ctrl
709
+ u = 1.0 - t
710
+ a, b, c, d = u * u * u, 3 * u * u * t, 3 * u * t * t, t * t * t
711
+ return (a * x0 + b * x1 + c * x2 + d * x3, a * y0 + b * y1 + c * y2 + d * y3)
712
+
713
+
714
+ def bezier_sample(ctrl: Sequence[Pt], n: int = 32) -> List[Pt]:
715
+ return [bezier_point(ctrl, i / n) for i in range(n + 1)]
716
+
717
+
718
+ def curve_controls(x1: float, y1: float, d1: str, x2: float, y2: float, d2: str,
719
+ bend: Optional[float] = None, tension: float = 0.45) -> Tuple[Pt, Pt, Pt, Pt]:
720
+ """Cubic control polygon for ``route='curve'``.
721
+
722
+ ``bend=None`` leaves each end along its port's outward direction (a smooth
723
+ S/C curve, like draw.io's curved connector); ``bend=θ`` (degrees, TikZ
724
+ ``bend left=θ``; negative bends right) uses a symmetric arc leaving at θ to
725
+ the chord. Control arms scale with the end-to-end distance.
726
+ """
727
+ import math
728
+
729
+ L = math.hypot(x2 - x1, y2 - y1) or 1.0
730
+ k = max(14.0, tension * L)
731
+ if bend is not None:
732
+ a = math.atan2(y2 - y1, x2 - x1)
733
+ th = math.radians(bend)
734
+ k = L * 0.39 / max(0.35, math.cos(th))
735
+ c1 = (x1 + k * math.cos(a - th), y1 + k * math.sin(a - th))
736
+ c2 = (x2 - k * math.cos(a + th), y2 - k * math.sin(a + th))
737
+ return (x1, y1), c1, c2, (x2, y2)
738
+ v1 = _DIRV.get(d1)
739
+ v2 = _DIRV.get(d2)
740
+ ux, uy = (x2 - x1) / L, (y2 - y1) / L
741
+ if v1 is None:
742
+ v1 = (ux, uy)
743
+ if v2 is None:
744
+ v2 = (-ux, -uy)
745
+ c1 = (x1 + k * v1[0], y1 + k * v1[1])
746
+ c2 = (x2 + k * v2[0], y2 + k * v2[1])
747
+ return (x1, y1), c1, c2, (x2, y2)
748
+
749
+
750
+ def _trim_bezier(ctrl: Sequence[Pt], start: float, end: float) -> Tuple[Pt, Pt, Pt, Pt]:
751
+ """Pull the curve ends back by ``start`` / ``end`` px along their tangents."""
752
+ import math
753
+
754
+ p0, c1, c2, p3 = [tuple(p) for p in ctrl]
755
+
756
+ def unit(a: Pt, b: Pt) -> Pt:
757
+ dx, dy = b[0] - a[0], b[1] - a[1]
758
+ n = math.hypot(dx, dy) or 1.0
759
+ return dx / n, dy / n
760
+
761
+ if end > 0:
762
+ ux, uy = unit(c2 if c2 != p3 else c1, p3)
763
+ p3 = (p3[0] - ux * end, p3[1] - uy * end)
764
+ c2 = (c2[0] - ux * end, c2[1] - uy * end)
765
+ if start > 0:
766
+ ux, uy = unit(c1 if c1 != p0 else c2, p0)
767
+ p0 = (p0[0] - ux * start, p0[1] - uy * start)
768
+ c1 = (c1[0] - ux * start, c1[1] - uy * start)
769
+ return p0, c1, c2, p3 # type: ignore[return-value]
770
+
771
+
772
+ def _trim_polyline(pts: List[Pt], start: float, end: float) -> List[Pt]:
773
+ import math
774
+
775
+ out = list(pts)
776
+ if end > 0 and len(out) >= 2:
777
+ (ax, ay), (bx, by) = out[-2], out[-1]
778
+ n = math.hypot(bx - ax, by - ay) or 1.0
779
+ e = min(end, n * 0.95)
780
+ out[-1] = (bx - (bx - ax) / n * e, by - (by - ay) / n * e)
781
+ if start > 0 and len(out) >= 2:
782
+ (ax, ay), (bx, by) = out[0], out[1]
783
+ n = math.hypot(bx - ax, by - ay) or 1.0
784
+ s = min(start, n * 0.95)
785
+ out[0] = (ax + (bx - ax) / n * s, ay + (by - ay) / n * s)
786
+ return out
787
+
788
+
789
+ def _merge_collinear(pts: Sequence[Pt], eps: float = 0.5) -> List[Pt]:
790
+ """Drop duplicate and straight-through vertices (a run of collinear segments
791
+ is one segment for label placement)."""
792
+ out: List[Pt] = []
793
+ for p in pts:
794
+ if out and abs(p[0] - out[-1][0]) < eps and abs(p[1] - out[-1][1]) < eps:
795
+ continue
796
+ if len(out) >= 2:
797
+ a, b = out[-2], out[-1]
798
+ cross = (b[0] - a[0]) * (p[1] - b[1]) - (b[1] - a[1]) * (p[0] - b[0])
799
+ dot = (b[0] - a[0]) * (p[0] - b[0]) + (b[1] - a[1]) * (p[1] - b[1])
800
+ if abs(cross) < eps * max(1.0, abs(dot) ** 0.5) and dot > 0:
801
+ out[-1] = p
802
+ continue
803
+ out.append(p)
804
+ return out if len(out) >= 2 else list(pts)
805
+
806
+
807
+ def knockout_clip(buf: List[str], holes: Sequence[Tuple[float, float, float, float]]) -> str:
808
+ """Emit a clipPath that keeps everything *except* ``holes``; return its id.
809
+
810
+ The holes are drawn with reversed winding (nonzero rule), which every SVG
811
+ renderer — cairosvg included — honours, unlike ``clip-rule=evenodd``.
812
+ """
813
+ global _CLIP_SEQ
814
+ _CLIP_SEQ += 1
815
+ cid = f"cf-ko-{_CLIP_SEQ}"
816
+ big = 1.0e5
817
+ d = [f"M {-big},{-big} H {big} V {big} H {-big} Z"]
818
+ for x, y, w, h in holes:
819
+ d.append(f"M {x:.2f},{y:.2f} V {y + h:.2f} H {x + w:.2f} V {y:.2f} Z")
820
+ buf.append(f'<defs><clipPath id="{cid}"><path d="{" ".join(d)}"/></clipPath></defs>')
821
+ return cid
822
+
823
+
824
+ _CLIP_SEQ = 0
825
+
826
+
827
+ def _poly_hits_rect(pts: Sequence[Pt], r: Tuple[float, float, float, float]) -> bool:
828
+ from .routing import seg_rect_intersect
829
+
830
+ return any(seg_rect_intersect(pts[i], pts[i + 1], r) for i in range(len(pts) - 1))
831
+
832
+
833
+ class Edge:
834
+ """
835
+ Connector between two Ports (or Nodes).
836
+
837
+ route: ortho | straight | curve | loop (feedback around content).
838
+ Styling knobs (aliases accepted): color/stroke, opacity/alpha, width/stroke_width,
839
+ style (solid|dashed|dotted|dashdot|custom dash such as ``(6, 2)``), overlap (allow|avoid).
840
+
841
+ Arrowheads (each end independently): ``head=`` (end) / ``tail=`` (start) take a
842
+ shape name — triangle | stealth | vee | open | circle | diamond | bar | square | none —
843
+ or a :class:`~codefig.styles.Head` (shape, size, length, width, hollow, fill).
844
+ ``head_size`` scales both, ``head_length``/``head_width`` set them exactly,
845
+ ``hollow=True`` draws outlined heads. Theme defaults: ``edge_head``, ``edge_tail``…
846
+
847
+ ``route='curve'``: smooth cubic Bézier; ``bend=`` degrees (TikZ ``bend left``),
848
+ otherwise the ends leave along the port directions. Heads follow the tangent.
849
+
850
+ ``knockout`` (default from the theme / figure policy): soft text that the edge
851
+ passes under (free Text, edge labels) cuts a small gap in the line so the text
852
+ stays readable instead of forcing a detour.
853
+ loop_side / clearance control route="loop". ``bundle="id"`` groups parallel edges:
854
+ ``bundle_style="lanes"`` (theme default since v0.7.0) draws them as evenly spaced
855
+ parallel trunks in a crossing-minimising order; ``"merge"`` shares one trunk;
856
+ ``"bus"`` adds the old translucent underlay. ``bus=`` is an alias of ``bundle=``.
857
+
858
+ ``step=`` (v0.5.0; int, str or :class:`~codefig.steps.Step`) draws a numbered
859
+ step badge (①) on the path; ``step_at`` = fraction 0..1 of the path length or
860
+ ``"start" | "mid" | "end"`` (default ``"mid"``). The badge slides along the path
861
+ away from nodes / labels and is checked like an edge label by check_overlaps().
862
+ Example: ``Edge(a, b, step=2)`` or ``Edge(a, b, step=steps.next(), step_at="start")``.
863
+
864
+ ``via=node`` (v0.12.0) passes the arrow *through* any node — an ``OpCircle`` /
865
+ ``Op``, a router ``Box``, a bus — as two drawn legs (``a → node`` without a head,
866
+ ``node → b`` with the head and label); both legs meet ``node`` on the sides facing
867
+ their other ends and, since v0.13.0, stay level with a facing side port, so several
868
+ ``Edge(p_i, q_i, via=loader)`` through one tall box are straight lines.
869
+ ``trunk=False`` (v0.12.0) opts this edge out of shared-port trunk merging (edges
870
+ that share a port merge into one trunk by default — fan-in / fan-out), so two
871
+ edges into one box arrive as two separate arrows.
872
+ ``parallel=N`` (v0.13.0) draws N straight arrows side by side between the two nodes
873
+ (multi-head attention inputs, k/v pairs); ``parallel_gap=`` sets their pitch in pt
874
+ (default ≈ the shared overlap / (N + 1), 4–10 pt); explicit side ports keep their
875
+ side. ``port(side, offset=pt)`` shifts one port along its side.
876
+ A legend entry for a line style: ``Legend([Key("PCIe", kind="arrow", style="dashed")])``
877
+ or ``Key.from_edge(edge, "label")``.
878
+ Public API takes no absolute coordinates.
879
+ """
880
+
881
+ stub = themed(12.0)
882
+ clearance = themed(20.0) # v0.7.0: was 40 (loops hugged the panel edge)
883
+
884
+
885
+ def __init__(
886
+ self,
887
+ src: Union[Port, Node],
888
+ dst: Union[Port, Node],
889
+ *,
890
+ route: str = "ortho",
891
+ label: Optional[str] = None,
892
+ marker_end: bool = True,
893
+ marker_start: bool = False,
894
+ # style
895
+ color: Optional[str] = None,
896
+ stroke: Optional[str] = None,
897
+ opacity: Optional[float] = None,
898
+ alpha: Optional[float] = None,
899
+ width: Optional[float] = None,
900
+ stroke_width: Optional[float] = None,
901
+ head_size: Optional[float] = None,
902
+ style: Any = None,
903
+ overlap: Optional[str] = None,
904
+ head: Any = None,
905
+ tail: Any = None,
906
+ head_length: Optional[float] = None,
907
+ head_width: Optional[float] = None,
908
+ hollow: Optional[bool] = None,
909
+ head_fill: Optional[str] = None,
910
+ bend: Optional[float] = None,
911
+ knockout: Optional[bool] = None,
912
+ legend: Optional[str] = None,
913
+ stub: Optional[float] = None,
914
+ loop_side: str = "auto", # right|left|top|bottom|auto — for route="loop"
915
+ loop_gap: Optional[float] = None,
916
+ jog_tol: Optional[float] = None,
917
+ clearance: Optional[float] = None, # how far the loop trunk sits outside ports
918
+ bundle: Optional[str] = None, # shared bus id — parallel edges share a trunk
919
+ bus: Optional[str] = None, # alias of bundle
920
+ bundle_style: Optional[str] = None, # v0.7.0: lanes | merge | bus (theme default)
921
+ theme: Optional[Theme] = None,
922
+ # label placement (all optional; defaults keep the legacy "above the
923
+ # middle point" behaviour)
924
+ label_at: Optional[Union[float, str]] = None, # 0..1 | mid | longest | start | end
925
+ label_side: Optional[str] = None, # auto | above | below | left | right | on
926
+ label_rotate: Union[bool, str] = False, # True/"along": follow vertical segments
927
+ label_bg: Optional[str] = None, # halo colour behind the label ("on" → white)
928
+ label_size: Optional[float] = None,
929
+ label_color: Optional[str] = None,
930
+ label_gap: Optional[float] = None,
931
+ around: Optional[Node] = None, # route="loop": trunk clears this node's box
932
+ snap: bool = False, # straight connector when facing side ports overlap
933
+ step: Any = None, # v0.5.0: numbered badge (int | str | Step) on the path
934
+ step_at: Union[float, str] = "mid", # 0..1 | start | mid | end
935
+ align: bool = True, # v0.11.0: may the layout nudge nodes to straighten me?
936
+ via: Any = None, # v0.12.0: node to pass through; v0.14.0: (node, side) waypoints
937
+ trunk: bool = True, # v0.12.0: False → never merge my trunk with shared-port edges
938
+ parallel: int = 1, # v0.13.0: draw N side-by-side arrows (multi-head, fan of copies)
939
+ parallel_gap: Optional[float] = None, # v0.13.0: their spacing (pt; default ≈ 6)
940
+ auto_wrap: bool = True, # v0.14.0: may the wrap-before-shrink pass break my label?
941
+ ) -> None:
942
+ _init_kw = {k: v for k, v in locals().items()
943
+ if k not in ("self", "src", "dst", "via", "__class__", "parallel",
944
+ "parallel_gap")}
945
+ if int(parallel) < 1:
946
+ raise ValueError("Edge(parallel=) must be >= 1")
947
+ if int(parallel) > 1 and via is not None:
948
+ raise ValueError("Edge(parallel=) cannot be combined with via=")
949
+ self.parallel = int(parallel)
950
+ self.parallel_gap = None if parallel_gap is None else float(parallel_gap)
951
+ from ._validate import check_enum
952
+ loop_side = check_enum("edges(loop_side)", loop_side, ("auto", "top", "bottom", "left", "right"))
953
+ for name, value in (("loop_gap", loop_gap), ("jog_tol", jog_tol)):
954
+ if value is not None and value < 0:
955
+ raise ValueError(f"{name} must be nonnegative, got {value}")
956
+ self.loop_gap = loop_gap
957
+ self.jog_tol = jog_tol
958
+ from ._validate import check_enum
959
+ route = check_enum("edges(route)", route, _ROUTES)
960
+ # bare nodes (no explicit Port): sides follow the layout — right→left when
961
+ # dst is to the right (the classic default), bottom→top when stacked, …
962
+ self._bare_ends = not isinstance(src, Port) and not isinstance(dst, Port)
963
+ # v0.14.0: via=(node, side) / [(node, side), ...] are waypoints *beside* nodes
964
+ self.waypoints: List[Tuple[Node, str]] = []
965
+ if via is not None and not isinstance(via, Node):
966
+ wps = [via] if (isinstance(via, tuple) and len(via) == 2
967
+ and isinstance(via[1], str)) else list(via)
968
+ for w in wps:
969
+ if not (isinstance(w, tuple) and len(w) == 2 and isinstance(w[0], Node)
970
+ and w[1] in ("top", "bottom", "left", "right")):
971
+ raise TypeError("Edge(via=) takes a node to pass through, or (node, side) "
972
+ "waypoints with side top|bottom|left|right, e.g. "
973
+ "via=(enc, 'top') or via=[(a, 'top'), (b, 'bottom')]")
974
+ self.waypoints.append((w[0], w[1]))
975
+ via = None
976
+ if route not in ("ortho", "over", "under"):
977
+ raise ValueError("Edge(via=(node, side)) waypoints need route='ortho'")
978
+ route = "ortho"
979
+ if route in ("over", "under") and self._bare_ends:
980
+ side = "top" if route == "over" else "bottom"
981
+ src, dst = src.port(side), dst.port(side)
982
+ self._bare_ends = False
983
+ self.src = _as_port(src, "right")
984
+ self.dst = _as_port(dst, "left")
985
+ self.route = route
986
+ self.align = bool(align)
987
+ self.auto_wrap = bool(auto_wrap)
988
+ self.label = label
989
+ self.marker_end = marker_end
990
+ self.marker_start = marker_start
991
+ self.color = color
992
+ self.stroke = stroke
993
+ self.opacity = opacity
994
+ self.alpha = alpha
995
+ self.width = width
996
+ self.stroke_width = stroke_width
997
+ self.head_size = head_size
998
+ self.style = style
999
+ self.overlap = overlap
1000
+ self.head = head
1001
+ self.tail = tail
1002
+ self.head_length = head_length
1003
+ self.head_width = head_width
1004
+ self.hollow = hollow
1005
+ self.head_fill = head_fill
1006
+ self.bend = bend
1007
+ self.knockout = knockout
1008
+ self.legend = legend
1009
+ self.stub = stub
1010
+ self.loop_side = loop_side
1011
+ self.clearance = clearance
1012
+ self.bundle = bundle if bundle is not None else bus
1013
+ if bundle_style is not None and bundle_style not in ("lanes", "merge", "bus"):
1014
+ from ._validate import check_enum
1015
+ check_enum("Edge(bundle_style)", bundle_style, ("lanes", "merge", "bus"))
1016
+ self.bundle_style = bundle_style
1017
+ self._theme = theme
1018
+ if label_side is not None and label_side not in _LABEL_SIDES:
1019
+ from ._validate import check_enum
1020
+ check_enum("Edge(label_side)", label_side, _LABEL_SIDES)
1021
+ if isinstance(label_at, str) and label_at not in _LABEL_ATS:
1022
+ from ._validate import check_enum
1023
+ check_enum("Edge(label_at)", label_at, _LABEL_ATS)
1024
+ if isinstance(label_at, (int, float)) and not 0.0 <= float(label_at) <= 1.0:
1025
+ raise ValueError(f"label_at fraction must be within 0..1, got {label_at!r}")
1026
+ self.label_at = label_at
1027
+ self.label_side = label_side
1028
+ self.label_rotate = label_rotate
1029
+ self.label_bg = label_bg
1030
+ self.label_size = label_size
1031
+ self.label_color = label_color
1032
+ self.label_gap = label_gap
1033
+ self.around = around
1034
+ self.snap = bool(snap)
1035
+ from .steps import as_step
1036
+
1037
+ self.step = as_step(step, theme=self._theme)
1038
+ if isinstance(step_at, str) and step_at not in ("start", "mid", "end"):
1039
+ from ._validate import check_enum
1040
+ check_enum("Edge(step_at)", step_at, ("start", "mid", "end"))
1041
+ if isinstance(step_at, (int, float)) and not 0.0 <= float(step_at) <= 1.0:
1042
+ raise ValueError(f"step_at fraction must be within 0..1, got {step_at!r}")
1043
+ self.step_at = step_at
1044
+ self.trunk = bool(trunk)
1045
+ if self.waypoints or route in ("over", "under"):
1046
+ # the author fixed the path: no trunk merging, no nudging nodes to straighten it
1047
+ self.trunk, self.align = False, False
1048
+ self.via = via
1049
+ self._init_kw = _init_kw
1050
+ self._src_raw, self._dst_raw = src, dst
1051
+ self._legs: Optional[List["Edge"]] = None
1052
+ self._cached_pts: Optional[List[Pt]] = None
1053
+ self._bezier: Optional[Tuple[Pt, Pt, Pt, Pt]] = None
1054
+ self.resolved() # validate style / heads early
1055
+
1056
+ def legs(self) -> List["Edge"]:
1057
+ """v0.12.0: the drawn parts of this edge.
1058
+
1059
+ A plain edge is its own single leg. ``Edge(a, b, via=n)`` becomes two legs —
1060
+ ``a → n`` (no arrowhead) and ``n → b`` (the head, label and step) — so one
1061
+ logical arrow can pass *through* a node (a ⊕ / ⊙ operator, a router, a bus
1062
+ tap) without the author splitting it by hand. Both legs enter / leave ``n``
1063
+ on the sides facing their other end (``port("auto")``). Diagrams expand a
1064
+ ``via`` edge into its legs when it is added.
1065
+ """
1066
+ if self.via is None and self.parallel > 1:
1067
+ if self._legs is None:
1068
+ self._legs = self._parallel_legs()
1069
+ return self._legs
1070
+ if self.via is None:
1071
+ return [self]
1072
+ if self._legs is None:
1073
+ kw = dict(self._init_kw)
1074
+ k1 = dict(kw, label=None, step=None, head="none", marker_end=False, legend=None)
1075
+ k2 = dict(kw, tail=None, marker_start=False)
1076
+ a = Edge(self._src_raw, self.via.port("auto"), **k1)
1077
+ b = Edge(self.via.port("auto"), self._dst_raw, **k2)
1078
+ for leg in (a, b):
1079
+ leg.lint_ignore = getattr(self, "lint_ignore", None)
1080
+ self._legs = [a, b]
1081
+ return self._legs
1082
+
1083
+ def _parallel_legs(self) -> List["Edge"]:
1084
+ """v0.13.0: ``Edge(a, b, parallel=3)`` → three straight arrows side by side.
1085
+
1086
+ Explicit side ports keep their side (``Edge(a.port("bottom"), b.port("top"),
1087
+ parallel=3)``); bare nodes use the sides facing each other. The label (and step
1088
+ badge) go on the middle arrow; the legs never share a trunk or nudge nodes.
1089
+ """
1090
+ n = self.parallel
1091
+ sn, dn = self.src.node, self.dst.node
1092
+ ss = self.src.side if (self.src.kind == "side" and not self._bare_ends) else "auto"
1093
+ ds = self.dst.side if (self.dst.kind == "side" and not self._bare_ends) else "auto"
1094
+ mid = n // 2
1095
+ out = []
1096
+ for i in range(n):
1097
+ kw = dict(self._init_kw, trunk=False, align=False, snap=False)
1098
+ if i != mid:
1099
+ kw.update(label=None, step=None, legend=None)
1100
+ sp = Port(sn, kind="parallel", side=ss, peer=dn, par_i=i, par_n=n, par_gap=self.parallel_gap)
1101
+ dp = Port(dn, kind="parallel", side=ds, peer=sn, par_i=i, par_n=n, par_gap=self.parallel_gap)
1102
+ leg = Edge(sp, dp, **kw)
1103
+ leg.lint_ignore = getattr(self, "lint_ignore", None)
1104
+ out.append(leg)
1105
+ for leg in out:
1106
+ leg._par_group = out
1107
+ leg._par_label = (self.label, self.label_side, self.step)
1108
+ return out
1109
+
1110
+ def _par_assign_label(self) -> None:
1111
+ """Put a parallel group's label on its outermost arrow (right of vertical
1112
+ arrows, above horizontal ones) once the direction is known."""
1113
+ group = getattr(self, "_par_group", None)
1114
+ if not group or group[0] is not self:
1115
+ return
1116
+ label, side, step = self._par_label
1117
+ if label is None and step is None:
1118
+ return
1119
+ try:
1120
+ (x1, y1, d1), _b = self.endpoints()
1121
+ except Exception: # pragma: no cover
1122
+ return
1123
+ vertical = d1 in ("up", "down")
1124
+ owner = max(group, key=lambda e: e.endpoints()[0][0]) if vertical else \
1125
+ min(group, key=lambda e: e.endpoints()[0][1])
1126
+ for e in group:
1127
+ e.label = label if e is owner else None
1128
+ e.step = step if e is owner else None
1129
+ if e is owner and side is None:
1130
+ e.label_side = "right" if vertical else "above"
1131
+
1132
+ @property
1133
+ def theme(self) -> Theme:
1134
+ """Explicit ``theme=`` or the active theme (Figure / ``use_theme``)."""
1135
+ return self._theme if self._theme is not None else current_theme()
1136
+
1137
+ @theme.setter
1138
+ def theme(self, value: Optional[Theme]) -> None:
1139
+ self._theme = value
1140
+
1141
+ def _theme_for_resolve(self) -> Theme:
1142
+ return self.theme
1143
+
1144
+ def resolved(self, theme: Optional[Theme] = None) -> ResolvedEdgeStyle:
1145
+ th = theme or self.theme
1146
+ tail = self.tail
1147
+ if tail is None and self.marker_start:
1148
+ tail = self.head if self.head is not None else th.edge_head
1149
+ head = self.head
1150
+ if not self.marker_end:
1151
+ head = "none"
1152
+ return resolve_edge_style(
1153
+ th,
1154
+ color=self.color,
1155
+ stroke=self.stroke,
1156
+ opacity=self.opacity,
1157
+ alpha=self.alpha,
1158
+ width=self.width,
1159
+ stroke_width=self.stroke_width,
1160
+ head_size=self.head_size,
1161
+ style=self.style,
1162
+ overlap=self.overlap,
1163
+ head=head,
1164
+ tail=tail,
1165
+ head_length=self.head_length,
1166
+ head_width=self.head_width,
1167
+ hollow=self.hollow,
1168
+ head_fill=self.head_fill,
1169
+ )
1170
+
1171
+ def curve_ctrl(self, obstacles: Optional[Sequence[Any]] = None) -> Tuple[Pt, Pt, Pt, Pt]:
1172
+ """Control polygon for route='curve' (tries alternative bends around hard obstacles)."""
1173
+ (x1, y1, d1), (x2, y2, d2) = self.endpoints()
1174
+ ctrl = curve_controls(x1, y1, d1, x2, y2, d2, bend=self.bend)
1175
+ if self.bend is None and obstacles:
1176
+ from .routing import path_cost
1177
+
1178
+ def cost(c):
1179
+ return path_cost(bezier_sample(c, 24), obstacles)
1180
+
1181
+ best, bc = ctrl, cost(ctrl)
1182
+ if bc >= 10000:
1183
+ for cand in ([curve_controls(x1, y1, d1, x2, y2, d2, tension=t) for t in (0.25, 0.7)]
1184
+ + [curve_controls(x1, y1, d1, x2, y2, d2, bend=b)
1185
+ for b in (25, -25, 40, -40, 55, -55)]):
1186
+ c = cost(cand) + 1.0
1187
+ if c < bc:
1188
+ best, bc = cand, c
1189
+ ctrl = best
1190
+ return ctrl
1191
+
1192
+ def points(self) -> List[Pt]:
1193
+ if self._cached_pts is not None:
1194
+ return self._cached_pts
1195
+ (x1, y1, d1), (x2, y2, d2) = self.endpoints()
1196
+ if self.route == "straight":
1197
+ # v0.12.0: a straight route is one segment unless ``stub=`` is given
1198
+ explicit = self.__dict__.get("_th_stub")
1199
+ return route_straight(x1, y1, d1, x2, y2, d2, stub=explicit or 0.0)
1200
+ if self._tiny_jog():
1201
+ return [(x1, y1), (x2, y2)]
1202
+ if self.route == "curve":
1203
+ self._bezier = self.curve_ctrl()
1204
+ return bezier_sample(self._bezier)
1205
+ if self.route in ("loop", "back"):
1206
+ return route_loop(
1207
+ x1, y1, d1, x2, y2, d2,
1208
+ stub=self.stub, side=self.loop_side, clearance=self.clearance,
1209
+ )
1210
+ return route_ortho(x1, y1, d1, x2, y2, d2, stub=self.stub)
1211
+
1212
+ def knockout_enabled(self, theme: Optional[Theme] = None) -> bool:
1213
+ from .policy import knockout_enabled
1214
+
1215
+ if self.knockout is not None:
1216
+ return bool(self.knockout)
1217
+ return knockout_enabled(theme or self.theme)
1218
+
1219
+ def draw(
1220
+ self,
1221
+ buf: List[str],
1222
+ ox: float = 0.0,
1223
+ oy: float = 0.0,
1224
+ *,
1225
+ theme: Optional[Theme] = None,
1226
+ points: Optional[List[Pt]] = None,
1227
+ obstacles: Optional[List[Tuple[float, float, float, float]]] = None,
1228
+ defer_label: bool = False,
1229
+ curve: Optional[Sequence[Pt]] = None,
1230
+ holes: Optional[Sequence[Tuple[float, float, float, float]]] = None,
1231
+ ) -> None:
1232
+ from .render_svg import angle_of, draw_head, draw_path
1233
+
1234
+ th = theme or self.theme
1235
+ st = self.resolved(th)
1236
+ if points is None:
1237
+ pts = self.points()
1238
+ if self.route == "curve" and curve is None:
1239
+ curve = self._bezier
1240
+ else:
1241
+ pts = points
1242
+ if len(pts) < 2:
1243
+ return
1244
+ pts = [(x + ox, y + oy) for x, y in pts]
1245
+ eh, sh = st.end_head, st.start_head
1246
+ sb_end = eh.setback(st.width) if eh is not None and eh.visible else 0.0
1247
+ sb_start = sh.setback(st.width) if sh is not None and sh.visible else 0.0
1248
+
1249
+ if holes:
1250
+ cid = knockout_clip(buf, holes)
1251
+ buf.append(f'<g clip-path="url(#{cid})">')
1252
+ if curve is not None:
1253
+ ctrl = [(x + ox, y + oy) for x, y in curve]
1254
+ p0, c1, c2, p3 = _trim_bezier(ctrl, sb_start, sb_end)
1255
+ d = (f"M {p0[0]:.2f},{p0[1]:.2f} C {c1[0]:.2f},{c1[1]:.2f} "
1256
+ f"{c2[0]:.2f},{c2[1]:.2f} {p3[0]:.2f},{p3[1]:.2f}")
1257
+ end_from = ctrl[2] if ctrl[2] != ctrl[3] else ctrl[1]
1258
+ start_from = ctrl[1] if ctrl[1] != ctrl[0] else ctrl[2]
1259
+ end_ang = angle_of(end_from[0], end_from[1], ctrl[3][0], ctrl[3][1])
1260
+ start_ang = angle_of(start_from[0], start_from[1], ctrl[0][0], ctrl[0][1])
1261
+ tip_end, tip_start = ctrl[3], ctrl[0]
1262
+ else:
1263
+ dp = _trim_polyline(pts, sb_start, sb_end)
1264
+ d = f"M {dp[0][0]:.2f},{dp[0][1]:.2f} " + " ".join(f"L {x:.2f},{y:.2f}" for x, y in dp[1:])
1265
+ end_ang = angle_of(pts[-2][0], pts[-2][1], pts[-1][0], pts[-1][1])
1266
+ start_ang = angle_of(pts[1][0], pts[1][1], pts[0][0], pts[0][1])
1267
+ tip_end, tip_start = pts[-1], pts[0]
1268
+ draw_path(buf, d, stroke=st.color, stroke_width=st.width, opacity=st.opacity,
1269
+ dasharray=st.dasharray(), marker_end=False, marker_start=False)
1270
+ if holes: # heads are never knocked out (check_overlaps flags a head on text)
1271
+ buf.append("</g>")
1272
+ draw_head(buf, tip_end[0], tip_end[1], end_ang, eh, color=st.color, opacity=st.opacity,
1273
+ stroke_width=st.width)
1274
+ draw_head(buf, tip_start[0], tip_start[1], start_ang, sh, color=st.color,
1275
+ opacity=st.opacity, stroke_width=st.width)
1276
+ if self.label and not defer_label:
1277
+ self._draw_label(buf, pts, th, st, obstacles or [])
1278
+
1279
+ def endpoints(self) -> Tuple[Tuple[float, float, str], Tuple[float, float, str]]:
1280
+ """Resolved (x, y, dir) of both ends; ``snap`` straightens facing side ports.
1281
+
1282
+ With ``snap=True`` and two plain side ports facing each other (bottom→top,
1283
+ right→left, …) whose nodes overlap on the cross axis, both ends move to the
1284
+ centre of that overlap so the connector is one straight segment.
1285
+ """
1286
+ if getattr(self, "_bare_ends", False):
1287
+ self._pick_bare_sides()
1288
+ a = self.src.resolve()
1289
+ b = self.dst.resolve()
1290
+ sa = self.src.kind == "side" and self.src.side == "auto"
1291
+ sb = self.dst.kind == "side" and self.dst.side == "auto"
1292
+ if sa or sb:
1293
+ if sa and sb:
1294
+ ga, gb = self.src.node.geom, self.dst.node.geom
1295
+ a = auto_port(self.src.node, gb.cx, gb.cy)
1296
+ b = auto_port(self.dst.node, ga.cx, ga.cy)
1297
+ elif sa:
1298
+ a = _auto_in_line(self.src.node, b) or auto_port(self.src.node, b[0], b[1])
1299
+ else:
1300
+ b = _auto_in_line(self.dst.node, a) or auto_port(self.dst.node, a[0], a[1])
1301
+ return a, b
1302
+ if not self.snap:
1303
+ al = self._align_small_offset(a, b)
1304
+ if al is not None:
1305
+ return al
1306
+ if not self.snap or self.src.kind != "side" or self.dst.kind != "side":
1307
+ return a, b
1308
+ ga, gb = self.src.node.geom, self.dst.node.geom
1309
+ vert = {a[2], b[2]} == {"up", "down"}
1310
+ horiz = {a[2], b[2]} == {"left", "right"}
1311
+ if vert:
1312
+ lo, hi = max(ga.x, gb.x), min(ga.x + ga.w, gb.x + gb.w)
1313
+ if hi - lo > 2:
1314
+ x = min(max((a[0] + b[0]) / 2, lo + 1), hi - 1)
1315
+ return (on_outline(self.src.node, self.src.side, (x, a[1], a[2])),
1316
+ on_outline(self.dst.node, self.dst.side, (x, b[1], b[2])))
1317
+ elif horiz:
1318
+ lo, hi = max(ga.y, gb.y), min(ga.y + ga.h, gb.y + gb.h)
1319
+ if hi - lo > 2:
1320
+ y = min(max((a[1] + b[1]) / 2, lo + 1), hi - 1)
1321
+ return (on_outline(self.src.node, self.src.side, (a[0], y, a[2])),
1322
+ on_outline(self.dst.node, self.dst.side, (b[0], y, b[2])))
1323
+ return a, b
1324
+
1325
+ @staticmethod
1326
+ def _facing(a: Tuple[float, float, str], b: Tuple[float, float, str]) -> Optional[str]:
1327
+ """``"h"`` / ``"v"`` when the two ends point at each other, else None."""
1328
+ if (a[2], b[2]) == ("right", "left") and b[0] > a[0] or \
1329
+ (a[2], b[2]) == ("left", "right") and b[0] < a[0]:
1330
+ return "h"
1331
+ if (a[2], b[2]) == ("down", "up") and b[1] > a[1] or \
1332
+ (a[2], b[2]) == ("up", "down") and b[1] < a[1]:
1333
+ return "v"
1334
+ return None
1335
+
1336
+ def _align_small_offset(self, a, b):
1337
+ """v0.8.0: remove a tiny ortho jog between two facing ends.
1338
+
1339
+ A fixed anchor (a Cuboid face, a slab edge, …) facing a plain side port a few pt
1340
+ off-line slides the side port into line with it, so the connector is one straight
1341
+ segment; two plain facing side ports a few pt apart snap to their overlap.
1342
+ """
1343
+ if self.route != "ortho":
1344
+ return None
1345
+ f = self._facing(a, b)
1346
+ if f is None:
1347
+ return None
1348
+ k = 1 if f == "h" else 0
1349
+ off = b[k] - a[k]
1350
+ if abs(off) < 0.05 or abs(off) > self.theme.L(10.0):
1351
+ return None
1352
+
1353
+ def slidable(p: Port) -> bool:
1354
+ return p.kind == "side" and p.side in ("left", "right", "top", "bottom") and \
1355
+ abs(p.at - 0.5) < 1e-9 and p.offset == 0
1356
+
1357
+ sa, sb = slidable(self.src), slidable(self.dst)
1358
+ if sa and sb:
1359
+ (a0, a1), (b0, b1) = self._span(self.src, k), self._span(self.dst, k)
1360
+ lo, hi = max(a0, b0), min(a1, b1)
1361
+ m = self.theme.L(2.0)
1362
+ if hi - lo <= 2 * m or abs(off) > self.theme.L(8.0):
1363
+ return None
1364
+ t = min(max((a[k] + b[k]) / 2, lo + m), hi - m)
1365
+ return self._slide(self.src, t, k), self._slide(self.dst, t, k)
1366
+ if sa != sb and (self.src.kind == "custom" if sb else self.dst.kind == "custom"):
1367
+ mov, fixed = (self.dst, a) if sb else (self.src, b)
1368
+ lo, hi = self._span(mov, k)
1369
+ span = hi - lo
1370
+ m = max(self.theme.L(2.0), 0.15 * span)
1371
+ if not (lo + m <= fixed[k] <= lo + span - m):
1372
+ return None
1373
+ p = self._slide(mov, fixed[k], k)
1374
+ return (a, p) if sb else (p, b)
1375
+ return None
1376
+
1377
+ @staticmethod
1378
+ def _span(p: Port, k: int) -> Tuple[float, float]:
1379
+ """Extent of side port ``p`` along axis ``k`` (``at`` 0 → 1), as resolved."""
1380
+ u = Port(p.node, kind="side", side=p.side, at=0.0).resolve()[k]
1381
+ v = Port(p.node, kind="side", side=p.side, at=1.0).resolve()[k]
1382
+ return min(u, v), max(u, v)
1383
+
1384
+ @classmethod
1385
+ def _slide(cls, p: Port, t: float, k: int) -> Tuple[float, float, str]:
1386
+ lo, hi = cls._span(p, k)
1387
+ at = 0.5 if hi - lo < 1e-9 else (t - lo) / (hi - lo)
1388
+ q = Port(p.node, kind="side", side=p.side, at=at).resolve()
1389
+ # shapes whose side is not parametrised linearly (cylinder caps …): a few
1390
+ # secant steps so the slid point really lands on ``t``
1391
+ a0, v0 = 0.5, Port(p.node, kind="side", side=p.side, at=0.5).resolve()[k]
1392
+ for _ in range(6):
1393
+ if abs(q[k] - t) <= 0.01 or abs(q[k] - v0) < 1e-9:
1394
+ break
1395
+ a1 = at + (t - q[k]) * (at - a0) / (q[k] - v0)
1396
+ a0, v0 = at, q[k]
1397
+ at = min(1.0, max(0.0, a1))
1398
+ q = Port(p.node, kind="side", side=p.side, at=at).resolve()
1399
+ return q
1400
+
1401
+ def _tiny_jog(self) -> bool:
1402
+ """Two fixed facing anchors a few pt off-line: one short slope beats a jog."""
1403
+ if self.route != "ortho":
1404
+ return False
1405
+ a, b = self.endpoints()
1406
+ f = self._facing(a, b)
1407
+ if f is None:
1408
+ return False
1409
+ k = 1 if f == "h" else 0
1410
+ off, along = abs(b[k] - a[k]), abs(b[1 - k] - a[1 - k])
1411
+ return 0.05 < off <= self.theme.L(4.0) and along >= 3.0 * off
1412
+
1413
+ def _pick_bare_sides(self) -> None:
1414
+ """Facing sides for an Edge built from two bare nodes (v0.5.0)."""
1415
+ na, nb = self.src.node, self.dst.node
1416
+ ga, gb = na.geom, nb.geom
1417
+ if self.waypoints: # v0.14.0: face the first / last waypoint
1418
+ wp = self.waypoint_pts()
1419
+ sides = (_side_towards(ga, *wp[0]), _side_towards(gb, *wp[-1]))
1420
+ if (self.src.side, self.dst.side) != sides:
1421
+ self.src = Port(na, kind="side", side=sides[0])
1422
+ self.dst = Port(nb, kind="side", side=sides[1])
1423
+ return
1424
+ if gb.x >= ga.right - 0.5:
1425
+ sides = ("right", "left")
1426
+ elif gb.right <= ga.x + 0.5:
1427
+ sides = ("left", "right")
1428
+ elif gb.y >= ga.bottom - 0.5:
1429
+ sides = ("bottom", "top")
1430
+ elif gb.bottom <= ga.y + 0.5:
1431
+ sides = ("top", "bottom")
1432
+ else:
1433
+ sides = ("right", "left")
1434
+ if (self.src.side, self.dst.side) != sides:
1435
+ self.src = Port(na, kind="side", side=sides[0])
1436
+ self.dst = Port(nb, kind="side", side=sides[1])
1437
+
1438
+ def waypoint_pts(self) -> List[Tuple[float, float, str]]:
1439
+ """v0.14.0: ``(x, y, side)`` of each ``via=(node, side)`` waypoint — just outside
1440
+ that side of the node (clear of its routing padding), at the side's centre."""
1441
+ out = []
1442
+ for node, side in self.waypoints:
1443
+ g = node.geom
1444
+ gap = 5.0 + self.theme.L(3.0)
1445
+ x, y = {"top": (g.cx, g.y - gap), "bottom": (g.cx, g.bottom + gap),
1446
+ "left": (g.x - gap, g.cy), "right": (g.right + gap, g.cy)}[side]
1447
+ out.append((x, y, side))
1448
+ return out
1449
+
1450
+ # ---- label placement ------------------------------------------------------
1451
+ def _custom_label(self) -> bool:
1452
+ return any(
1453
+ v not in (None, False)
1454
+ for v in (self.label_at, self.label_side, self.label_bg, self.label_size,
1455
+ self.label_color, self.label_gap)
1456
+ ) or bool(self.label_rotate) or self.route == "curve" or self.step is not None
1457
+
1458
+ def _label_fs(self, th: Theme) -> float:
1459
+ return self.label_size if self.label_size is not None else th.font_size * 0.85
1460
+
1461
+ def label_geometry(
1462
+ self,
1463
+ pts: List[Pt],
1464
+ theme: Optional[Theme] = None,
1465
+ obstacles: Optional[List[Tuple[float, float, float, float]]] = None,
1466
+ paths: Optional[Sequence[Sequence[Pt]]] = None,
1467
+ ) -> Tuple[Tuple[float, float, float, float], float, str]:
1468
+ """(bbox x, y, w, h), rotation, text align of the label on routed ``pts``.
1469
+
1470
+ ``obstacles`` (boxes) and ``paths`` (other edges) steer ``label_side='auto'``.
1471
+ """
1472
+ import math
1473
+
1474
+ th = theme or self.theme
1475
+ fs = self._label_fs(th)
1476
+ gap = self.label_gap if self.label_gap is not None else th.edge_label_gap * 0.6
1477
+ if (self._custom_label() or getattr(self, "_label_unlegacy", False)) and self.route != "curve":
1478
+ pts = _merge_collinear(pts)
1479
+ segs = [(pts[i], pts[i + 1]) for i in range(len(pts) - 1)]
1480
+ lens = [math.hypot(b[0] - a[0], b[1] - a[1]) for a, b in segs]
1481
+ total = sum(lens) or 1.0
1482
+ legacy = not self._custom_label() and not getattr(self, "_label_unlegacy", False)
1483
+ at = self.label_at if self.label_at is not None else "mid"
1484
+ if legacy:
1485
+ k = min(len(pts) // 2, len(segs) - 1)
1486
+ a, b = segs[k]
1487
+ px, py = pts[len(pts) // 2]
1488
+ elif isinstance(at, (int, float)) or (at == "mid" and self.route == "curve"):
1489
+ frac = 0.5 if isinstance(at, str) else float(at)
1490
+ target, acc, k = frac * total, 0.0, 0
1491
+ for k, L in enumerate(lens):
1492
+ if acc + L >= target or k == len(lens) - 1:
1493
+ break
1494
+ acc += L
1495
+ a, b = segs[k]
1496
+ t = 0.0 if lens[k] == 0 else min(1.0, max(0.0, (target - acc) / lens[k]))
1497
+ px, py = a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t
1498
+ else:
1499
+ idx = list(range(len(segs)))
1500
+ longish = [i for i in idx if lens[i] > max(self.stub + 1.0, 0.15 * max(lens))]
1501
+ if at == "longest":
1502
+ k = max(idx, key=lambda i: lens[i])
1503
+ elif at == "start":
1504
+ k = (longish or idx)[0]
1505
+ elif at == "end":
1506
+ k = (longish or idx)[-1]
1507
+ else: # mid: middle of the path by length
1508
+ target, acc, k = total / 2, 0.0, 0
1509
+ for k, L in enumerate(lens):
1510
+ if acc + L >= target:
1511
+ break
1512
+ acc += L
1513
+ a, b = segs[k]
1514
+ if at == "mid":
1515
+ t = 0.0 if lens[k] == 0 else min(1.0, max(0.0, (total / 2 - acc) / lens[k]))
1516
+ else:
1517
+ t = 0.5
1518
+ px, py = a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t
1519
+ vertical = abs(b[0] - a[0]) < abs(b[1] - a[1])
1520
+ rot = -90.0 if (vertical and self.label_rotate) else 0.0
1521
+ tw, tht = measure_text(self.label or "", fs)
1522
+ bw, bh = (tht, tw) if rot else (tw, tht)
1523
+ # a curve bulges away from its chord: push the label off the local tangent
1524
+ # direction by the box half-extent so it never sits on the stroke
1525
+ dx, dy = b[0] - a[0], b[1] - a[1]
1526
+ dn = math.hypot(dx, dy) or 1.0
1527
+ diag = self.route == "curve" and min(abs(dx), abs(dy)) / dn > 0.3
1528
+
1529
+ def box(side: str, px: float = px, py: float = py) -> Tuple[float, float, float, float]:
1530
+ if side == "on":
1531
+ return (px - bw / 2, py - bh / 2, bw, bh)
1532
+ if diag:
1533
+ # offset along the normal so the nearest box corner clears the curve
1534
+ nx, ny = -dy / dn, dx / dn
1535
+ if (side in ("above", "left") and ny > 0) or (side in ("below", "right") and ny < 0):
1536
+ nx, ny = -nx, -ny
1537
+ reach = abs(nx) * bw / 2 + abs(ny) * bh / 2 + gap
1538
+ cx, cy = px + nx * reach, py + ny * reach
1539
+ return (cx - bw / 2, cy - bh / 2, bw, bh)
1540
+ if side == "above":
1541
+ return (px - bw / 2, py - gap - bh, bw, bh)
1542
+ if side == "below":
1543
+ return (px - bw / 2, py + gap, bw, bh)
1544
+ if side == "left":
1545
+ return (px - gap - bw, py - bh / 2, bw, bh)
1546
+ return (px + gap, py - bh / 2, bw, bh) # right
1547
+
1548
+ obs = obstacles or []
1549
+ others = paths or []
1550
+
1551
+ def cost(bx_: Tuple[float, float, float, float]) -> float:
1552
+ x, y, w, h = bx_
1553
+ c = 0.0
1554
+ for ox_, oy_, ow, oh in obs:
1555
+ ix = min(x + w, ox_ + ow) - max(x, ox_)
1556
+ iy = min(y + h, oy_ + oh) - max(y, oy_)
1557
+ if ix > 0 and iy > 0:
1558
+ c += ix * iy
1559
+ for p in others:
1560
+ if len(p) >= 2 and _poly_hits_rect(p, (x - 1, y - 1, w + 2, h + 2)):
1561
+ c += 60.0
1562
+ # v0.12: the label must not sit on another segment of its own edge (a
1563
+ # label beside a short jog used to be struck by the next leg)
1564
+ if own_check and len(pts) >= 2 and side != "on" and \
1565
+ _poly_hits_rect(pts, (x + 0.5, y + 0.5, max(0.0, w - 1), max(0.0, h - 1))):
1566
+ c += 80.0
1567
+ return c
1568
+
1569
+ side = "above" if legacy else (self.label_side or "auto")
1570
+ own_check = not legacy and self.route != "curve"
1571
+ sides = ([side] if side != "auto" else
1572
+ (["right", "left"] if vertical else ["above", "below"]))
1573
+ # candidate anchor points: the requested one, then (for named label_at
1574
+ # positions) slides along the same segment while the label still fits on it
1575
+ anchors = [(px, py, 0.0)]
1576
+ slide = (not legacy and not isinstance(at, (int, float)) and self.route != "curve"
1577
+ and self.label_side != "on")
1578
+ if slide and (obs or others or (own_check and any(
1579
+ cost(box(sd)) > 0 for sd in sides))):
1580
+ seg_len = math.hypot(b[0] - a[0], b[1] - a[1])
1581
+ along = bh if vertical else bw
1582
+ room = (seg_len - along) / 2 - 2.0
1583
+ if room > 2.0:
1584
+ ux, uy = (b[0] - a[0]) / seg_len, (b[1] - a[1]) / seg_len
1585
+ mx, my = (a[0] + b[0]) / 2, (a[1] + b[1]) / 2
1586
+ for f in (0.25, -0.25, 0.5, -0.5, 0.75, -0.75, 1.0, -1.0):
1587
+ q = f * room
1588
+ anchors.append((mx + ux * q, my + uy * q, abs(q)))
1589
+ best = None
1590
+ for (ax_, ay_, shift) in anchors:
1591
+ for sd in sides:
1592
+ bb = box(sd, ax_, ay_)
1593
+ c = cost(bb) + 0.05 * shift
1594
+ if best is None or c < best[0] - 1e-9:
1595
+ best = (c, sd, bb)
1596
+ if best is not None and best[0] < 1e-6:
1597
+ break
1598
+ if own_check and best is not None and best[0] >= 80.0 and len(segs) >= 2 and not rot:
1599
+ # v0.12: every spot on the chosen leg sits on another leg of this edge —
1600
+ # try the middle of the other legs (nearest first)
1601
+ for j in sorted(range(len(segs)), key=lambda j: abs(j - k)):
1602
+ if j == k or lens[j] < 1.0:
1603
+ continue
1604
+ (qa, qb) = segs[j]
1605
+ qv = abs(qb[0] - qa[0]) < abs(qb[1] - qa[1])
1606
+ mx, my = (qa[0] + qb[0]) / 2, (qa[1] + qb[1]) / 2
1607
+ ux, uy = (qb[0] - qa[0]) / lens[j], (qb[1] - qa[1]) / lens[j]
1608
+ room = max(0.0, (lens[j] - (bh if qv else bw)) / 2 - 2.0)
1609
+ for f in (0.0, 0.25, -0.25, 0.5, -0.5, 0.75, -0.75, 1.0, -1.0):
1610
+ if f and room <= 2.0:
1611
+ break
1612
+ ax_, ay_ = mx + ux * f * room, my + uy * f * room
1613
+ for sd in (["right", "left"] if qv else ["above", "below"]):
1614
+ if self.label_side not in (None, "auto") and sd != self.label_side:
1615
+ continue
1616
+ bb = box(sd, ax_, ay_)
1617
+ c = cost(bb) + 0.02 * abs(j - k) + 0.05 * abs(f * room)
1618
+ if c < best[0] - 1e-9:
1619
+ best = (c, sd, bb)
1620
+ if best[0] < 1.0:
1621
+ break
1622
+ _c, side, bbox = best # type: ignore[misc]
1623
+ if legacy and len(pts) >= 2 and _c >= 1.0 and not rot:
1624
+ # v0.12.0: the classic mid-point label sits on a frame, another edge or a
1625
+ # box — let it slide along its leg like an explicit label_at='mid'
1626
+ self._label_unlegacy = True
1627
+ try:
1628
+ return self.label_geometry(pts, theme, obstacles, paths)
1629
+ finally:
1630
+ self._label_unlegacy = False
1631
+ if legacy and len(pts) >= 2: # v0.12.0: also a 2-point (vertical) edge
1632
+ x, y, w, h = bbox
1633
+ if _poly_hits_rect(pts, (x + 0.5, y + 0.5, max(0.0, w - 1), max(0.0, h - 1))):
1634
+ # v0.12: the classic mid-point label would sit on its own edge's next
1635
+ # leg — place it like an explicit label_at='mid' instead
1636
+ self._label_unlegacy = True
1637
+ try:
1638
+ return self.label_geometry(pts, theme, obstacles, paths)
1639
+ finally:
1640
+ self._label_unlegacy = False
1641
+ align = "center"
1642
+ if side == "left" and not rot and not diag:
1643
+ align = "right"
1644
+ elif side == "right" and not rot and not diag:
1645
+ align = "left"
1646
+ return bbox, rot, align
1647
+
1648
+ def step_geometry(
1649
+ self,
1650
+ pts: List[Pt],
1651
+ obstacles: Optional[Sequence[Tuple[float, float, float, float]]] = None,
1652
+ ) -> Optional[Tuple[float, float, float]]:
1653
+ """Centre (x, y) and diameter of this edge's step badge on routed ``pts``.
1654
+
1655
+ ``step_at`` picks the nominal spot (fraction of the path length, or
1656
+ start / mid / end); if that spot covers an obstacle box the badge slides
1657
+ along the path to the nearest free spot. None when the edge has no step.
1658
+ """
1659
+ import math
1660
+
1661
+ if self.step is None or len(pts) < 2:
1662
+ return None
1663
+ d = self.step.diameter()
1664
+ lens = [math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1])
1665
+ for i in range(len(pts) - 1)]
1666
+ total = sum(lens) or 1.0
1667
+
1668
+ def at(dist: float) -> Pt:
1669
+ dist = min(max(dist, 0.0), total)
1670
+ acc = 0.0
1671
+ for i, L in enumerate(lens):
1672
+ if acc + L >= dist or i == len(lens) - 1:
1673
+ t = 0.0 if L == 0 else (dist - acc) / L
1674
+ a, b = pts[i], pts[i + 1]
1675
+ return a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t
1676
+ acc += L
1677
+ return pts[-1]
1678
+
1679
+ margin = d / 2 + 4.0
1680
+ lo, hi = margin, max(margin, total - margin - 6.0) # keep clear of the head
1681
+ sa = self.step_at
1682
+ if sa == "start":
1683
+ nominal = lo + d * 0.3
1684
+ elif sa == "end":
1685
+ nominal = hi - d * 0.3
1686
+ elif sa == "mid":
1687
+ nominal = total / 2
1688
+ else:
1689
+ nominal = float(sa) * total
1690
+ nominal = min(max(nominal, lo), hi)
1691
+ obs = list(obstacles or [])
1692
+
1693
+ def cost(dist: float) -> float:
1694
+ x, y = at(dist)
1695
+ c = 0.0
1696
+ for ox_, oy_, ow, oh in obs:
1697
+ ix = min(x + d / 2, ox_ + ow) - max(x - d / 2, ox_)
1698
+ iy = min(y + d / 2, oy_ + oh) - max(y - d / 2, oy_)
1699
+ if ix > 0 and iy > 0:
1700
+ c += ix * iy
1701
+ return c
1702
+
1703
+ best, bc = nominal, cost(nominal)
1704
+ if bc > 0 and hi > lo:
1705
+ n = 24
1706
+ for k in range(1, n + 1):
1707
+ for sgn in (1, -1):
1708
+ dist = nominal + sgn * (hi - lo) * k / n
1709
+ if lo <= dist <= hi:
1710
+ c = cost(dist) + 0.01 * abs(dist - nominal)
1711
+ if c < bc:
1712
+ best, bc = dist, c
1713
+ if bc < 1.0:
1714
+ break
1715
+ x, y = at(best)
1716
+ return x, y, d
1717
+
1718
+ def _draw_label(self, buf, pts, th, st, obstacles, geometry=None) -> None:
1719
+ from .primitives import Text
1720
+ from .render_svg import draw_rect
1721
+
1722
+ (x, y, w, h), rot, align = geometry or self.label_geometry(pts, th, obstacles)
1723
+ fs = self._label_fs(th)
1724
+ bg = self.label_bg if self.label_bg is not None else None
1725
+ if bg and bg != "none":
1726
+ draw_rect(buf, x - 3, y - 1, w + 6, h + 2, fill=bg, stroke="none", stroke_width=0,
1727
+ radius=2)
1728
+ t = Text(self.label, font_size=fs, color=self.label_color or st.color, align=align,
1729
+ rotate=rot, theme=th)
1730
+ t.geom = BoxGeom(x, y, w, h)
1731
+ t.draw(buf)
1732
+
1733
+
1734
+ class Fork:
1735
+ """
1736
+ Fan-out (``Fork(src, [dst, …])``) or fan-in (``Join([src, …], dst)``) as one
1737
+ orthogonal tree: a trunk, a shared bus at a branch point and one branch per
1738
+ target — the TikZ "|-" / draw.io tree look without hand-placed waypoints.
1739
+
1740
+ ``at`` — where the bus sits between the source side and the nearest target
1741
+ (fraction 0..1); the router shifts it if the bus would cross a hard node.
1742
+ ``dot=True`` marks the branch point. Style kwargs (color, width, style, head,
1743
+ tail, head_size, hollow, …) are the same as :class:`Edge`; heads are drawn only
1744
+ where the tree meets its targets (fork) or its single destination (join).
1745
+ ``leaf_styles`` — optional per-leaf style overrides (one dict or None per leaf)
1746
+ applied to that leaf's branch only, e.g. a dashed / coloured "evicted" branch.
1747
+ """
1748
+
1749
+ def __init__(self, src: Union[Port, Node, Sequence[Union[Port, Node]]],
1750
+ dsts: Union[Port, Node, Sequence[Union[Port, Node]]], *, at: float = 0.5,
1751
+ dot: bool = False, dot_size: Optional[float] = None, join: bool = False,
1752
+ label: Optional[str] = None, labels: Optional[Sequence[Optional[str]]] = None,
1753
+ flow: Optional[str] = None,
1754
+ leaf_styles: Optional[Sequence[Optional[Dict[str, Any]]]] = None,
1755
+ **style: Any) -> None:
1756
+ many_src = isinstance(src, (list, tuple))
1757
+ many_dst = isinstance(dsts, (list, tuple))
1758
+ if join:
1759
+ srcs = list(src) if many_src else [src]
1760
+ dst_list = [dsts] if not many_dst else list(dsts)
1761
+ if len(dst_list) != 1:
1762
+ raise ValueError("Join(srcs, dst): exactly one destination")
1763
+ self.hub, self.leaves = dst_list[0], srcs
1764
+ else:
1765
+ if many_src:
1766
+ raise ValueError("Fork(src, dsts): exactly one source")
1767
+ self.hub, self.leaves = src, (list(dsts) if many_dst else [dsts])
1768
+ if not self.leaves:
1769
+ raise ValueError("Fork/Join needs at least one leaf")
1770
+ self.join = bool(join)
1771
+ self.at = float(at)
1772
+ self.dot = bool(dot)
1773
+ self.dot_size = dot_size
1774
+ self.label = label
1775
+ self.labels = list(labels) if labels is not None else None
1776
+ self.flow = flow
1777
+ self.leaf_styles = [dict(d or {}) for d in leaf_styles] if leaf_styles else None
1778
+ if self.leaf_styles and len(self.leaf_styles) != len(self.leaves):
1779
+ raise ValueError("leaf_styles needs one entry (dict or None) per leaf")
1780
+ style.pop("route", None)
1781
+ self.style = style
1782
+ self.legend = style.pop("legend", None)
1783
+ self._edges: List[Edge] = []
1784
+ self._dot_at: Optional[Pt] = None
1785
+ # style probe (validates kwargs early)
1786
+ Edge(self._node(self.hub), self._node(self.leaves[0]), **self._style_kw())
1787
+
1788
+ @staticmethod
1789
+ def _node(ref: Union[Port, Node]) -> Node:
1790
+ return ref.node if isinstance(ref, Port) else ref
1791
+
1792
+ def _style_kw(self) -> Dict[str, Any]:
1793
+ return dict(self.style)
1794
+
1795
+ def _flow(self) -> str:
1796
+ """down|up|right|left from the hub towards the leaves."""
1797
+ if self.flow:
1798
+ return self.flow
1799
+ if isinstance(self.hub, Port) and self.hub.kind in ("side", "aligned"):
1800
+ d = self.hub.resolve()[2]
1801
+ if d in _DIRV:
1802
+ return d if not self.join else {"up": "down", "down": "up", "left": "right",
1803
+ "right": "left"}[d]
1804
+ hg = self._node(self.hub).geom
1805
+ cx = sum(self._node(l).geom.cx for l in self.leaves) / len(self.leaves)
1806
+ cy = sum(self._node(l).geom.cy for l in self.leaves) / len(self.leaves)
1807
+ dx, dy = cx - hg.cx, cy - hg.cy
1808
+ if self.join:
1809
+ dx, dy = -dx, -dy
1810
+ if abs(dy) >= abs(dx):
1811
+ return "down" if dy > 0 else "up"
1812
+ return "right" if dx > 0 else "left"
1813
+
1814
+ def build(self, obstacles: Sequence[Any] = ()) -> List[Tuple[Edge, List[Pt]]]:
1815
+ """Expand into internal (Edge, points) pieces (trunk, bus, branches)."""
1816
+ from .routing import path_cost
1817
+
1818
+ flow = self._flow() # direction of travel source → targets
1819
+ # frame: v along the flow away from the hub, u across
1820
+ out_dir = flow if not self.join else {"up": "down", "down": "up", "left": "right",
1821
+ "right": "left"}[flow]
1822
+ # out_dir: direction from the hub towards the leaves
1823
+ if out_dir in ("down", "up"):
1824
+ sgn = 1.0 if out_dir == "down" else -1.0
1825
+
1826
+ def to_f(p: Pt) -> Pt:
1827
+ return p[0], sgn * p[1]
1828
+
1829
+ def from_f(u: float, v: float) -> Pt:
1830
+ return u, sgn * v
1831
+ hub_side, leaf_side = ("bottom", "top") if out_dir == "down" else ("top", "bottom")
1832
+ else:
1833
+ sgn = 1.0 if out_dir == "right" else -1.0
1834
+
1835
+ def to_f(p: Pt) -> Pt:
1836
+ return p[1], sgn * p[0]
1837
+
1838
+ def from_f(u: float, v: float) -> Pt:
1839
+ return sgn * v, u
1840
+ hub_side, leaf_side = ("right", "left") if out_dir == "right" else ("left", "right")
1841
+ hub = _as_port(self.hub, hub_side) if isinstance(self.hub, Port) else \
1842
+ Port(self.hub, kind="side", side=hub_side)
1843
+ leaves = [l if isinstance(l, Port) else Port(l, kind="side", side=leaf_side)
1844
+ for l in self.leaves]
1845
+ hx, hy, _ = hub.resolve()
1846
+ hu, hv = to_f((hx, hy))
1847
+ lr = [l.resolve() for l in leaves]
1848
+ lf = [to_f((x, y)) for x, y, _d in lr]
1849
+ stub = 10.0
1850
+ near = min(v for _u, v in lf)
1851
+ lo, hi = hv + stub, near - stub
1852
+ if hi < lo:
1853
+ lo = hi = (hv + near) / 2
1854
+ us = [u for u, _v in lf] + [hu]
1855
+ u0, u1 = min(us), max(us)
1856
+
1857
+ def bus_pts(vj: float) -> List[Pt]:
1858
+ return [from_f(u0, vj), from_f(u1, vj)]
1859
+
1860
+ best_v, best_c = None, None
1861
+ for f in (self.at, 0.5, 0.35, 0.65, 0.2, 0.8, 0.1, 0.9):
1862
+ vj = lo + (hi - lo) * max(0.0, min(1.0, f))
1863
+ pts = [from_f(hu, hv), from_f(hu, vj)] + bus_pts(vj)
1864
+ c = path_cost(pts[:2], obstacles) + path_cost(pts[2:], obstacles) if obstacles else 0.0
1865
+ if best_c is None or c < best_c - 1e-6:
1866
+ best_v, best_c = vj, c
1867
+ if c == 0:
1868
+ break
1869
+ vj = float(best_v)
1870
+ kw = self._style_kw()
1871
+ hub_node = self._node(self.hub)
1872
+ pieces: List[Tuple[Edge, List[Pt]]] = []
1873
+
1874
+ def piece(a: Any, b: Any, pts: List[Pt], head: bool, label: Optional[str] = None,
1875
+ extra: Optional[Dict[str, Any]] = None) -> None:
1876
+ k2 = dict(kw, **(extra or {}))
1877
+ e = Edge(a, b, label=label, **{k: v for k, v in k2.items()
1878
+ if k not in ("head", "tail", "marker_end", "marker_start")},
1879
+ head=k2.get("head") if head else "none", tail="none")
1880
+ pieces.append((e, _dedupe(pts)))
1881
+
1882
+ trunk = [from_f(hu, hv), from_f(hu, vj)]
1883
+ if self.join:
1884
+ trunk = trunk[::-1]
1885
+ bus = bus_pts(vj)
1886
+ branches = []
1887
+ for (lx, ly, ld), (lu, lv), leaf in zip(lr, lf, leaves):
1888
+ dv = _DIRV.get(ld, (0.0, 0.0))
1889
+ du_, dvv = to_f(dv)
1890
+ if abs(du_) > 0.5: # leaf port faces sideways: go along the bus past it, then in
1891
+ bu = lu + (stub if du_ > 0 else -stub)
1892
+ pts = [from_f(bu, vj), from_f(bu, lv), from_f(lu, lv)]
1893
+ else:
1894
+ pts = [from_f(lu, vj), from_f(lu, lv)]
1895
+ branches.append((leaf, pts))
1896
+ # extend the bus to sideways branch columns
1897
+ allu = [to_f(p[0])[0] for _l, p in branches] + [hu]
1898
+ bus = [from_f(min(allu), vj), from_f(max(allu), vj)]
1899
+ leaf_labels = self.labels or [None] * len(branches)
1900
+ leaf_kw = self.leaf_styles or [None] * len(branches)
1901
+ if self.join:
1902
+ for (leaf, pts), lab, xk in zip(branches, leaf_labels, leaf_kw):
1903
+ piece(leaf.node, hub_node, pts[::-1], head=False, label=lab, extra=xk)
1904
+ if abs(bus[0][0] - bus[1][0]) + abs(bus[0][1] - bus[1][1]) > 0.5:
1905
+ piece(hub_node, hub_node, bus, head=False)
1906
+ piece(hub_node, hub_node, trunk, head=True, label=self.label)
1907
+ else:
1908
+ piece(hub_node, hub_node, trunk, head=False, label=self.label)
1909
+ if abs(bus[0][0] - bus[1][0]) + abs(bus[0][1] - bus[1][1]) > 0.5:
1910
+ piece(hub_node, hub_node, bus, head=False)
1911
+ for (leaf, pts), lab, xk in zip(branches, leaf_labels, leaf_kw):
1912
+ piece(hub_node, leaf.node, pts, head=True, label=lab, extra=xk)
1913
+ for e, _p in pieces:
1914
+ e._fork = self # type: ignore[attr-defined]
1915
+ self._edges = [e for e, _p in pieces]
1916
+ self._dot_at = from_f(hu, vj) if self.dot else None
1917
+ return pieces
1918
+
1919
+
1920
+ def Join(srcs: Sequence[Union[Port, Node]], dst: Union[Port, Node], **kw: Any) -> Fork:
1921
+ """Fan-in: several sources merge on a shared bus, one arrow into ``dst``."""
1922
+ return Fork(srcs, dst, join=True, **kw)
1923
+
1924
+
1925
+ def _legs_of(e: Any) -> List[Any]:
1926
+ legs = getattr(e, "legs", None)
1927
+ return legs() if callable(legs) else [e]
1928
+
1929
+
1930
+ @dataclass
1931
+ class _CornerPort(Port):
1932
+ """A side-corner port pushed ``zpad`` outwards on both axes (zoom outline corners)."""
1933
+
1934
+ zpad: float = 0.0
1935
+
1936
+ def resolve(self) -> Tuple[float, float, str]:
1937
+ x, y, d = Port.resolve(self)
1938
+ p, t = self.zpad, (self.at - 0.5) * 2 * self.zpad
1939
+ if self.side in ("left", "right"):
1940
+ return x + (p if self.side == "right" else -p), y + t, d
1941
+ return x + t, y + (p if self.side == "bottom" else -p), d
1942
+
1943
+
1944
+ class Guide(Edge):
1945
+ """v0.12.0: a plain helper line between two ports — zoom-in guides, "same token"
1946
+ links, alignment hints, cross-panel correspondences.
1947
+
1948
+ A Guide is straight when clear and detours around text and hard nodes.
1949
+ It has **no arrowheads** and is exempt from CF-ARR-002 (it is meant to pass over things) and
1950
+ CF-STR-003 (its dash is a drawing convention, not a legend category).
1951
+ Its labels are still checked like any text. Use :class:`Edge` for arrows.
1952
+
1953
+ Args:
1954
+ src / dst: Ports or nodes (``node.port("right", at=0)`` for a corner).
1955
+ style: ``"dotted"`` (default), ``"dashed"`` or ``"solid"``.
1956
+ color: Line colour (default: the theme muted colour).
1957
+ width: Line width (default: the theme thin stroke).
1958
+ avoid_text: Detour around text and nodes (default True); False pins a straight guide.
1959
+ **kw: Further :class:`Edge` options (``label=``, ``opacity=`` …).
1960
+
1961
+ Example:
1962
+ >>> from codefig import Box, Guide
1963
+ >>> a, b = Box("a"), Box("b")
1964
+ >>> g = Guide(a.port("right", at=0), b.port("left", at=0), style="dashed")
1965
+ """
1966
+
1967
+ def __init__(self, src: Any, dst: Any, *, style: str = "dotted", color: Optional[str] = None,
1968
+ width: Optional[float] = None, avoid_text: bool = True, **kw: Any) -> None:
1969
+ self.avoid_text = bool(avoid_text)
1970
+ from ._validate import check_enum
1971
+ style = check_enum("edges(style)", style, ("dotted", "dashed", "solid"))
1972
+ kw.setdefault("route", "straight")
1973
+ if kw["route"] != "straight":
1974
+ raise ValueError("a Guide is always straight; use Edge for routed connectors")
1975
+ kw.setdefault("head", "none")
1976
+ kw.setdefault("tail", "none")
1977
+ kw.setdefault("marker_end", False)
1978
+ kw.setdefault("overlap", "allow")
1979
+ kw.setdefault("stub", 0.0)
1980
+ kw.setdefault("align", False)
1981
+ kw.setdefault("trunk", False)
1982
+ kw.setdefault("knockout", False)
1983
+ super().__init__(src, dst, style=style, color=color, width=width, **kw)
1984
+ self.lint_ignore = ["CF-ARR-002", "CF-ARR-003", "CF-STR-003"]
1985
+ self._cf_guide_edge = True
1986
+
1987
+ def resolved(self, theme: Optional[Theme] = None) -> ResolvedEdgeStyle:
1988
+ th = theme or self.theme
1989
+ if self.color is None and self.stroke is None:
1990
+ self.color = getattr(th, "muted_color", "#777777")
1991
+ try:
1992
+ return super().resolved(theme)
1993
+ finally:
1994
+ self.color = None
1995
+ return super().resolved(theme)
1996
+
1997
+
1998
+ class Diagram(ConstraintScope, Node):
1999
+ """
2000
+ Content + overlay edges. After layout: bundling → lane offsets → obstacle-aware
2001
+ ortho/loop routing. overlap='avoid' (theme default) separates parallel trunks.
2002
+
2003
+ ``diagram.constrain(...)`` registers relational constraints; they are solved by
2004
+ the enclosing Panel after flex layout and *before* edges are routed.
2005
+
2006
+ v0.7.0: ``regions=[Region([a, b], label="pool"), …]`` draws translucent / dashed
2007
+ areas behind arbitrary members (they may overlap each other); see
2008
+ :class:`~codefig.regions.Region`. ``diagram.region(members, label, **kw)`` adds one.
2009
+ """
2010
+
2011
+ def __init__(
2012
+ self,
2013
+ content: Node,
2014
+ edges: Optional[Sequence[Edge]] = None,
2015
+ *,
2016
+ regions: Optional[Sequence[Any]] = None,
2017
+ theme: Optional[Theme] = None,
2018
+ **kwargs: Any,
2019
+ ) -> None:
2020
+ if theme is not None:
2021
+ kwargs.setdefault("theme", theme)
2022
+ super().__init__(**kwargs)
2023
+ self.content = content
2024
+ content.parent = self
2025
+ self.edges: List[Edge] = []
2026
+ for edge in edges or []:
2027
+ self.add_edge(edge)
2028
+ self.regions: List[Any] = list(regions or [])
2029
+
2030
+ def region(self, members: Sequence[Node], label: Optional[str] = None, **kw: Any) -> Any:
2031
+ """Add and return a :class:`~codefig.regions.Region` around ``members``."""
2032
+ from .regions import Region
2033
+
2034
+ r = Region(members, label, **kw)
2035
+ self.regions.append(r)
2036
+ return r
2037
+
2038
+ def _layout_regions(self) -> None:
2039
+ if self.regions:
2040
+ from .regions import layout_regions
2041
+
2042
+ layout_regions(self, self.regions)
2043
+
2044
+ def add_edge(self, edge: Edge) -> "Diagram":
2045
+ """Add an Edge/Fork, or place a spanned Brace beside its referenced node.
2046
+
2047
+ A Brace without a span belongs in the Row/Col content instead.
2048
+ """
2049
+ from .primitives import Brace
2050
+ from .constraints import place
2051
+ if isinstance(edge, Brace):
2052
+ if edge.span is None:
2053
+ raise TypeError("Diagram edges: Brace requires span=; otherwise place it in Row/Col content")
2054
+ self.constrain(place(edge, edge.span[0], side=edge.side, offset=self.theme.L(2)))
2055
+ return self
2056
+ if not isinstance(edge, (Edge, Fork)):
2057
+ raise TypeError("Diagram edges expects Edge, Fork, or Brace(span=...)")
2058
+ self.edges.extend(_legs_of(edge))
2059
+ return self
2060
+
2061
+ def zoom(self, src: Node, dst: Node, *, side: str = "right", style: str = "dashed",
2062
+ color: Optional[str] = None, outline: bool = True,
2063
+ pad: Optional[float] = None) -> Tuple[Any, "Guide", "Guide"]:
2064
+ """v0.12.0: "zoom-in" callout — outline ``src`` and draw two guide lines from
2065
+ its corners to the facing corners of ``dst`` (the enlarged detail view).
2066
+
2067
+ Args:
2068
+ src: The node being magnified (a cell, a block, a group).
2069
+ dst: The node that shows it enlarged.
2070
+ side: Where ``dst`` sits relative to ``src``: ``"right"`` (default),
2071
+ ``"left"``, ``"below"`` or ``"above"``.
2072
+ style: Outline and guide dash: ``"dashed"`` (default), ``"dotted"``, ``"solid"``.
2073
+ color: Outline / guide colour (default: the theme muted colour).
2074
+ outline: Draw the outline around ``src`` (default True; ``False`` when
2075
+ ``src`` already has a visible border).
2076
+ pad: Gap between ``src`` and its outline, in pt (default: 2 design units
2077
+ with an outline, 0 without); the guides start at the outline corners.
2078
+
2079
+ Returns:
2080
+ ``(region_or_None, guide1, guide2)`` — all already added to this Diagram.
2081
+
2082
+ Example:
2083
+ >>> from codefig import Box, Row, Diagram
2084
+ >>> a, b = Box("x"), Box("detail")
2085
+ >>> d = Diagram(Row([a, b], gap=30))
2086
+ >>> r, g1, g2 = d.zoom(a, b)
2087
+ """
2088
+ from ._validate import check_enum
2089
+ side = check_enum("edges(side)", side, ("right", "left", "below", "above"))
2090
+ rs = {"dashed": "dashed", "dotted": "dotted", "solid": "outline"}
2091
+ if style not in rs:
2092
+ raise ValueError("zoom style must be dashed|dotted|solid")
2093
+ r = None
2094
+ pd = float(pad) if pad is not None else (self.theme.L(2.0) if outline else 0.0)
2095
+ if outline:
2096
+ col = color or getattr(self.theme, "muted_color", "#777777")
2097
+ r = self.region([src], None, style=rs[style], pad=pd, radius=0.0, color=col,
2098
+ exclude="none")
2099
+ out, inn = {"right": ("right", "left"), "left": ("left", "right"),
2100
+ "below": ("bottom", "top"), "above": ("top", "bottom")}[side]
2101
+ g1 = Guide(_CornerPort(src, "side", out, at=0.0, zpad=pd), dst.port(inn, at=0.0),
2102
+ style=style, color=color)
2103
+ g2 = Guide(_CornerPort(src, "side", out, at=1.0, zpad=pd), dst.port(inn, at=1.0),
2104
+ style=style, color=color)
2105
+ self.edges += [g1, g2]
2106
+ return r, g1, g2
2107
+
2108
+ def __iadd__(self, other: Union[Edge, Node]) -> "Diagram":
2109
+ if isinstance(other, Edge):
2110
+ self.edges.extend(_legs_of(other))
2111
+ return self
2112
+ raise TypeError("Diagram += expects an Edge; set content in the constructor")
2113
+
2114
+ def children(self) -> Sequence[Node]:
2115
+ return (self.content,)
2116
+
2117
+ def _loop_margins(self) -> Tuple[float, float, float, float]:
2118
+ """Extra space so route='loop' trunks are not clipped.
2119
+
2120
+ Uses loop_side (auto → right) — no port resolve during measure/layout.
2121
+ """
2122
+ l = t = r = b = 0.0
2123
+ for e in self._plain_edges():
2124
+ if e.route != "loop":
2125
+ continue
2126
+ pad = e.clearance + e.stub + 12.0
2127
+ side = e.loop_side if e.loop_side != "auto" else "right"
2128
+ if side == "right":
2129
+ r = max(r, pad)
2130
+ elif side == "left":
2131
+ l = max(l, pad)
2132
+ elif side == "bottom":
2133
+ b = max(b, pad)
2134
+ else:
2135
+ t = max(t, pad)
2136
+ return l, t, r, b
2137
+
2138
+ def _margins(self) -> Tuple[float, float, float, float]:
2139
+ fit = getattr(self, "_fit_margins", None)
2140
+ return fit if fit is not None else self._loop_margins()
2141
+
2142
+ def edge_overflow(self, pad: float = 3.0) -> Tuple[float, float, float, float]:
2143
+ """How far routed edges / labels stick out of the content box (l, t, r, b)."""
2144
+ lay = self.layout_edges()
2145
+ xs: List[float] = []
2146
+ ys: List[float] = []
2147
+ for _e, pts in lay["routed"]:
2148
+ xs += [p[0] for p in pts]
2149
+ ys += [p[1] for p in pts]
2150
+ for _e, _p, ((x, y, w, h), _r, _a) in lay["labels"]:
2151
+ xs += [x, x + w]
2152
+ ys += [y, y + h]
2153
+ for _e, (x, y, w, h) in lay.get("steps", []):
2154
+ xs += [x, x + w]
2155
+ ys += [y, y + h]
2156
+ if self.regions:
2157
+ self._layout_regions()
2158
+ for r in self.regions:
2159
+ bb = r.bbox
2160
+ if bb is not None:
2161
+ xs += [bb[0], bb[0] + bb[2]]
2162
+ ys += [bb[1], bb[1] + bb[3]]
2163
+ g = self.content.geom
2164
+ if not xs:
2165
+ return 0.0, 0.0, 0.0, 0.0
2166
+ return (max(0.0, g.x - min(xs) + pad), max(0.0, g.y - min(ys) + pad),
2167
+ max(0.0, max(xs) - g.right + pad), max(0.0, max(ys) - g.bottom + pad))
2168
+
2169
+ def _measure(self) -> Tuple[float, float]:
2170
+ w, h = self.content.measure()
2171
+ l, t, r, b = self._margins()
2172
+ return w + l + r, h + t + b
2173
+
2174
+ def _layout_children(self) -> None:
2175
+ g = self.geom
2176
+ l, t, r, b = self._margins()
2177
+ self.content.layout(g.x + l, g.y + t, max(1.0, g.w - l - r), max(1.0, g.h - t - b))
2178
+
2179
+ def _edge_meta(self) -> List[dict]:
2180
+ meta = []
2181
+ for edge in self._plain_edges():
2182
+ (x1, y1, d1), (x2, y2, d2) = edge.endpoints()
2183
+ st = edge.resolved(theme=self.theme)
2184
+ meta.append(
2185
+ {
2186
+ "edge": edge,
2187
+ "x1": x1, "y1": y1, "d1": d1,
2188
+ "x2": x2, "y2": y2, "d2": d2,
2189
+ "st": st,
2190
+ "bundle": edge.bundle,
2191
+ "bundle_style": edge.bundle_style,
2192
+ }
2193
+ )
2194
+ return meta
2195
+
2196
+ def _obstacles_for(self, edge: "Edge"):
2197
+ from .routing import ancestors_in, collect_obstacles
2198
+
2199
+ exclude = []
2200
+ try:
2201
+ exclude.append(edge.src.node)
2202
+ exclude.append(edge.dst.node)
2203
+ except Exception:
2204
+ pass
2205
+ transparent = ancestors_in(self.content, exclude)
2206
+ obs = collect_obstacles(self.content, exclude=exclude, pad=5.0, transparent=transparent)
2207
+ from .policy import role_of
2208
+ from .routing import ORect
2209
+
2210
+ pad = 4.0
2211
+ for o in getattr(self, "_cf_overlay_obstacles", None) or []:
2212
+ if any(o is x for x in exclude):
2213
+ continue
2214
+ g = o.geom
2215
+ role = role_of(o) or "hard"
2216
+ if g.w > 1 and g.h > 1 and role != "none":
2217
+ obs.append(ORect(g.x - pad, g.y - pad, g.w + 2 * pad, g.h + 2 * pad, role))
2218
+ return obs
2219
+
2220
+ def _is_back_edge(self, edge):
2221
+ """Return whether an automatic edge travels upstream in its shared layout."""
2222
+ if edge.route != "auto":
2223
+ return False
2224
+ src, dst = edge.src.node, edge.dst.node
2225
+ ancestors = []
2226
+ node = src.parent
2227
+ while node is not None:
2228
+ ancestors.append(node)
2229
+ node = node.parent
2230
+ node = dst.parent
2231
+ while node is not None and node not in ancestors:
2232
+ node = node.parent
2233
+ a, b = src.geom, dst.geom
2234
+ # Cross-axis siblings occupy parallel lanes even if alignment shifts
2235
+ # their centres slightly along the main axis.
2236
+ if src.parent is dst.parent and src.parent is not None:
2237
+ horizontal = getattr(src.parent, "direction", "row") == "row"
2238
+ if horizontal and min(a.y+a.h, b.y+b.h) <= max(a.y,b.y):
2239
+ return False
2240
+ if not horizontal and min(a.x+a.w,b.x+b.w) <= max(a.x,b.x):
2241
+ return False
2242
+ if getattr(node, "direction", "row") == "row":
2243
+ return b.cx < a.cx - 1e-6
2244
+ return b.cy < a.cy - 1e-6
2245
+
2246
+ def _routed(self) -> Tuple[List[Tuple[Edge, List[Pt]]], List[dict]]:
2247
+ """Return (edge, pts) list plus bus underlay descriptors."""
2248
+ from .routing import apply_bundling, route_channel, route_loop_avoid, route_ortho_avoid
2249
+
2250
+ gap = self.theme.edge_lane_gap
2251
+ meta = self._edge_meta()
2252
+
2253
+ # 1) Bundling first — shared trunk for same bus id
2254
+ bundled_pts, buses = apply_bundling(
2255
+ meta, lane_gap=gap, style=getattr(self.theme, "edge_bundle_style", "lanes"))
2256
+
2257
+ # 2) Lane offsets for non-bundled avoid/ortho edges
2258
+ base_for_lanes = []
2259
+ for i, m in enumerate(meta):
2260
+ e = m["edge"]
2261
+ if bundled_pts[i] is not None:
2262
+ base_for_lanes.append((e, bundled_pts[i]))
2263
+ else:
2264
+ base_for_lanes.append((e, e.points()))
2265
+ # Only apply lane avoidance to unbundled edges
2266
+ unbundled = [(e, p) for (e, p), bp in zip(base_for_lanes, bundled_pts) if bp is None]
2267
+ bundled_only = [(e, p) for (e, p), bp in zip(base_for_lanes, bundled_pts) if bp is not None]
2268
+ lanened = apply_overlap_avoidance(unbundled, lane_gap=gap, theme=self.theme)
2269
+
2270
+ # Rebuild in original order
2271
+ lane_map = {id(e): pts for e, pts in lanened}
2272
+ bundle_map = {id(e): pts for e, pts in bundled_only}
2273
+ ordered: List[Tuple[Edge, List[Pt]]] = []
2274
+ for m in meta:
2275
+ e = m["edge"]
2276
+ if id(e) in bundle_map:
2277
+ ordered.append((e, bundle_map[id(e)]))
2278
+ else:
2279
+ ordered.append((e, lane_map.get(id(e), e.points())))
2280
+
2281
+ # 3) Obstacle-aware adjust for ortho + loop
2282
+ final: List[Tuple[Edge, List[Pt]]] = []
2283
+ self._curves: Dict[int, Tuple[Pt, Pt, Pt, Pt]] = {}
2284
+ for e, pts in ordered:
2285
+ obstacles = self._obstacles_for(e)
2286
+ if getattr(e, "_cf_guide_edge", False) and e.avoid_text:
2287
+ from .routing import ORect
2288
+ # Guide geometry must remain continuous: route around text rather
2289
+ # than cutting holes out of the guide at draw time.
2290
+ obstacles = [ORect(*o) for o in obstacles if getattr(o, "role", "hard") != "background"]
2291
+ obstacles += [ORect(x - self.theme.L(2), y - self.theme.L(2),
2292
+ w + self.theme.L(4), h + self.theme.L(4))
2293
+ for x, y, w, h in self._title_boxes()]
2294
+ if any(_poly_hits_rect(pts, o) for o in obstacles):
2295
+ (x1, y1, d1), (x2, y2, d2) = e.endpoints()
2296
+ pts = route_ortho_avoid(x1, y1, d1, x2, y2, d2,
2297
+ stub=e.stub, obstacles=obstacles)
2298
+ final.append((e, pts))
2299
+ continue
2300
+ from .routing import _core
2301
+ blocked = any(getattr(o, "role", "hard") == "hard" and
2302
+ _poly_hits_rect(pts, _core(o)) for o in obstacles)
2303
+ if e.route == "straight" or (e._tiny_jog() and not blocked and e.route != "back" and not self._is_back_edge(e)):
2304
+ final.append((e, pts))
2305
+ continue
2306
+ if e.route == "curve":
2307
+ ctrl = e.curve_ctrl(self._obstacles_for(e))
2308
+ self._curves[id(e)] = ctrl
2309
+ final.append((e, bezier_sample(ctrl)))
2310
+ continue
2311
+ obstacles = self._obstacles_for(e)
2312
+ (x1, y1, d1), (x2, y2, d2) = e.endpoints()
2313
+ if e.route == "back" or self._is_back_edge(e):
2314
+ from .routing import route_back
2315
+ final.append((e, route_back(x1, y1, d1, x2, y2, d2,
2316
+ stub=e.stub, side=e.loop_side,
2317
+ clearance=e.loop_gap if e.loop_gap is not None else e.theme.gap * 0.6,
2318
+ obstacles=obstacles, bounds=self.content.geom,
2319
+ container_bounds=(e.src.node.parent.geom
2320
+ if e.src.node.parent is e.dst.node.parent and e.src.node.parent is not None
2321
+ else self.content.geom))))
2322
+ continue
2323
+ if e.route in ("over", "under"):
2324
+ final.append((e, route_channel(x1, y1, d1, x2, y2, d2, stub=e.stub,
2325
+ over=e.route == "over", obstacles=obstacles)))
2326
+ continue
2327
+ if e.waypoints:
2328
+ final.append((e, self._route_waypoints(e, (x1, y1, d1), (x2, y2, d2), obstacles)))
2329
+ continue
2330
+ if e.route == "loop" and e.around is not None:
2331
+ pts2 = route_loop_around(x1, y1, d1, x2, y2, d2, e)
2332
+ final.append((e, pts2))
2333
+ elif e.route == "loop":
2334
+ pts2 = route_loop_avoid(
2335
+ x1, y1, d1, x2, y2, d2,
2336
+ stub=e.stub, side=e.loop_side, clearance=e.clearance,
2337
+ obstacles=obstacles,
2338
+ )
2339
+ final.append((e, pts2))
2340
+ elif e.route in ("ortho", "auto"):
2341
+ from .routing import choose_clear_mid_x
2342
+
2343
+ if (
2344
+ e.bundle
2345
+ and len(pts) >= 4
2346
+ and d1 in ("left", "right")
2347
+ and d2 in ("left", "right")
2348
+ ):
2349
+ trunk_x = pts[2][0]
2350
+ ax, ay = _stub(x1, y1, d1, e.stub)
2351
+ bx, by = _stub(x2, y2, d2, e.stub)
2352
+ mx = choose_clear_mid_x(ax, ay, bx, by, trunk_x, obstacles)
2353
+ pts2 = _dedupe(
2354
+ [(x1, y1), (ax, ay), (mx, ay), (mx, by), (bx, by), (x2, y2)]
2355
+ )
2356
+ elif (e.bundle and len(pts) >= 4 and d1 in ("up", "down")
2357
+ and d2 in ("up", "down")):
2358
+ from .routing import choose_clear_mid_y
2359
+
2360
+ trunk_y = pts[2][1]
2361
+ ax, ay = _stub(x1, y1, d1, e.stub)
2362
+ bx, by = _stub(x2, y2, d2, e.stub)
2363
+ my = choose_clear_mid_y(ax, ay, bx, by, trunk_y, obstacles)
2364
+ pts2 = _dedupe(
2365
+ [(x1, y1), (ax, ay), (ax, my), (bx, my), (bx, by), (x2, y2)]
2366
+ )
2367
+ elif e.bundle:
2368
+ pts2 = route_ortho_avoid(
2369
+ x1, y1, d1, x2, y2, d2,
2370
+ stub=e.stub, mid_bias=0.0, obstacles=obstacles,
2371
+ )
2372
+ else:
2373
+ mid_bias = 0.0
2374
+ if (
2375
+ len(pts) >= 4
2376
+ and d1 in ("left", "right")
2377
+ and d2 in ("left", "right")
2378
+ ):
2379
+ ax, _ay = _stub(x1, y1, d1, e.stub)
2380
+ bx, _by = _stub(x2, y2, d2, e.stub)
2381
+ natural = _trunk0(ax, bx, d1, d2)
2382
+ mid_bias = pts[2][0] - natural
2383
+ elif len(pts) >= 4 and d1 in ("up", "down") and d2 in ("up", "down"):
2384
+ _ax, ay = _stub(x1, y1, d1, e.stub)
2385
+ _bx, by = _stub(x2, y2, d2, e.stub)
2386
+ mid_bias = pts[2][1] - _trunk0(ay, by, d1, d2)
2387
+ pts2 = route_ortho_avoid(
2388
+ x1, y1, d1, x2, y2, d2,
2389
+ stub=e.stub, mid_bias=mid_bias,
2390
+ obstacles=obstacles + _end_rects(e, x1, y1, d1, x2, y2, d2),
2391
+ )
2392
+ final.append((e, pts2))
2393
+ else:
2394
+ final.append((e, pts))
2395
+ final = self._dejog_all(final)
2396
+ for fork in self._forks():
2397
+ final.extend(fork.build(self._fork_obstacles(fork)))
2398
+ return final, buses
2399
+
2400
+ def _route_waypoints(self, e: "Edge", a: Tuple[float, float, str], b: Tuple[float, float, str],
2401
+ obstacles: list) -> List[Pt]:
2402
+ """v0.14.0: ``Edge(via=(node, side))`` — an ortho route through points just
2403
+ outside the named sides; the route passes the node there (over / under /
2404
+ beside it) instead of finding its own way round."""
2405
+ from .routing import _simplify_ortho, route_ortho_avoid
2406
+
2407
+ wps = e.waypoint_pts()
2408
+ pts: List[Pt] = []
2409
+ cur = a
2410
+ for i, (wx, wy, side) in enumerate(wps):
2411
+ px, py = cur[0], cur[1]
2412
+ nx, ny = (wps[i + 1][:2] if i + 1 < len(wps) else (b[0], b[1]))
2413
+ if side in ("top", "bottom"): # the route crosses the waypoint horizontally
2414
+ d_in = "left" if px <= wx else "right"
2415
+ d_out = "right" if nx >= wx else "left"
2416
+ else:
2417
+ d_in = "up" if py <= wy else "down"
2418
+ d_out = "down" if ny >= wy else "up"
2419
+ seg = route_ortho_avoid(cur[0], cur[1], cur[2], wx, wy, d_in, stub=e.stub,
2420
+ obstacles=obstacles)
2421
+ pts.extend(seg if not pts else seg[1:])
2422
+ cur = (wx, wy, d_out)
2423
+ last = route_ortho_avoid(cur[0], cur[1], cur[2], b[0], b[1], b[2], stub=e.stub,
2424
+ obstacles=obstacles + _end_rects(e, a[0], a[1], a[2], *b))
2425
+ pts.extend(last[1:] if pts else last)
2426
+ return _simplify_ortho(_dedupe(pts))
2427
+
2428
+ def _dejog_all(self, routed: List[Tuple[Edge, List[Pt]]]) -> List[Tuple[Edge, List[Pt]]]:
2429
+ """v0.9.0: tidy every orthogonal route after lanes / obstacle avoidance.
2430
+
2431
+ 1. drop duplicate and collinear vertices, folding sub-point backtracks that
2432
+ lane shifts can leave next to a stub;
2433
+ 2. a Z route whose only cross segment is tiny (≤ L(8)) — whatever the port
2434
+ sides — becomes one straight segment by sliding a *plain* side port (no
2435
+ ``at=`` / ``offset=``) along its side, if the slid point stays on the side
2436
+ (≥ L(2) from its corners), the straight segment misses every hard obstacle
2437
+ and it does not run on top of another edge. v0.10.0: larger jogs and
2438
+ staircase (multi-jog) routes too, while the slid port stays in the middle
2439
+ half of its side.
2440
+ """
2441
+ out: List[Tuple[Edge, List[Pt]]] = []
2442
+ paths = [pts for _e, pts in routed]
2443
+ for i, (e, pts) in enumerate(routed):
2444
+ if (e.route != "ortho" or getattr(e, "bundle", None) or len(pts) < 3
2445
+ or getattr(e, "waypoints", None)):
2446
+ out.append((e, pts))
2447
+ continue
2448
+ from .routing import _absorb_jogs
2449
+
2450
+ tol = e.jog_tol if e.jog_tol is not None else min(6, 0.35 * self.theme.gap)
2451
+ candidate = _absorb_jogs(pts, tol)
2452
+ p3 = None
2453
+ if (candidate != _simplify_ortho(pts) and candidate
2454
+ and candidate[0] == pts[0] and candidate[-1] == pts[-1]
2455
+ and not any(self._seg_blocked(e, a, b, paths[:i] + paths[i + 1:])
2456
+ for a, b in zip(candidate, candidate[1:]))):
2457
+ p3 = candidate
2458
+ if p3 is None and (candidate != _simplify_ortho(pts) or e.jog_tol is None):
2459
+ p3 = self._straighten_z(e, _simplify_ortho(pts), paths[:i] + paths[i + 1:])
2460
+ p2 = p3 if p3 is not None else _simplify_ortho(pts, backtracks_only=True)
2461
+ paths[i] = p2
2462
+ out.append((e, p2))
2463
+ return out
2464
+
2465
+ def _straighten_z(self, e: "Edge", pts: List[Pt], others: List[List[Pt]]) -> Optional[List[Pt]]:
2466
+ """One straight segment for a Z (or, v0.10.0, multi-jog) route, if safe.
2467
+
2468
+ The first and last runs must be parallel and head the same way, and the
2469
+ whole route must make monotonic progress along them. A plain side port is
2470
+ slid along its side so the two ends line up: always for a jog ≤ L(8); for a
2471
+ larger one (v0.10.0) only when the slid point stays in the middle half of
2472
+ the side (``0.25 ≤ at ≤ 0.75``), so the arrow still reads as attached to
2473
+ the node's face rather than to a corner. A port with an explicit ``at=`` and
2474
+ ``slide=s`` (v0.11.0) may move by up to ``s`` instead.
2475
+ """
2476
+ if len(pts) < 4:
2477
+ return None
2478
+ th = self.theme
2479
+ (x0, y0), (x1, y1) = pts[0], pts[1]
2480
+ (x2, y2), (x3, y3) = pts[-2], pts[-1]
2481
+ h_run = abs(y0 - y1) < 1e-6 and abs(y2 - y3) < 1e-6
2482
+ v_run = abs(x0 - x1) < 1e-6 and abs(x2 - x3) < 1e-6
2483
+ if not (h_run or v_run):
2484
+ return None
2485
+ k = 1 if h_run else 0 # coordinate that the jog changes
2486
+ jog = pts[-1][k] - pts[0][k]
2487
+ if abs(jog) <= 0.05:
2488
+ return None
2489
+ # both runs must head the same way (a real Z, not a U-turn), and every
2490
+ # run along that axis must too (no detour back)
2491
+ d01 = pts[1][1 - k] - pts[0][1 - k]
2492
+ d23 = pts[-1][1 - k] - pts[-2][1 - k]
2493
+ if d01 * d23 <= 0:
2494
+ return None
2495
+ for u, v in zip(pts, pts[1:]):
2496
+ if (v[1 - k] - u[1 - k]) * d01 < -1e-6:
2497
+ return None
2498
+ if len(pts) > 4:
2499
+ # multi-jog: every cross run must move the same way (a staircase)
2500
+ steps = [v[k] - u[k] for u, v in zip(pts, pts[1:]) if abs(v[k] - u[k]) > 1e-6]
2501
+ if any(st * jog < 0 for st in steps):
2502
+ return None
2503
+
2504
+ def slidable(p: Port) -> bool:
2505
+ return p.kind == "side" and p.side in ("left", "right", "top", "bottom") and \
2506
+ (abs(p.at - 0.5) < 1e-9 or p.slide > 0) and p.offset == 0
2507
+
2508
+ normal_k = 1 - k
2509
+ cands = []
2510
+ for port, end, t in ((e.src, 0, pts[-1][k]), (e.dst, -1, pts[0][k])):
2511
+ if not slidable(port):
2512
+ continue
2513
+ # the port's side must be perpendicular to the run
2514
+ if (port.side in ("left", "right")) != (k == 1):
2515
+ continue
2516
+ lo, hi = Edge._span(port, k)
2517
+ m = th.L(2.0)
2518
+ if not lo + m <= t <= hi - m:
2519
+ continue
2520
+ at = 0.5 if hi - lo < 1e-9 else (t - lo) / (hi - lo)
2521
+ if port.slide > 0: # explicit at= with a slide tolerance (v0.11.0)
2522
+ if abs(at - port.at) > port.slide + 1e-9:
2523
+ continue
2524
+ elif abs(jog) > th.L(8.0) and not 0.25 <= at <= 0.75:
2525
+ continue
2526
+ cands.append((abs(at - port.at), port, end, t))
2527
+ for _d, port, end, t in sorted(cands, key=lambda c: c[0]):
2528
+ q = Edge._slide(port, t, k)
2529
+ if abs(q[k] - t) > 0.05 or abs(q[normal_k] - pts[end][normal_k]) > 0.3:
2530
+ continue
2531
+ new = [(q[0], q[1]), pts[-1]] if end == 0 else [pts[0], (q[0], q[1])]
2532
+ if any(abs(q[0] - p[0]) < 0.8 and abs(q[1] - p[1]) < 0.8
2533
+ for path in others if path for p in (path[0], path[-1])):
2534
+ continue
2535
+ if self._seg_blocked(e, new[0], new[1], others):
2536
+ continue
2537
+ if e.label and self._label_hits(e, new, others) > self._label_hits(e, pts, others) + 0.5:
2538
+ continue # the straight segment leaves the label no room
2539
+ return new
2540
+ return None
2541
+
2542
+ def _label_hits(self, e: "Edge", pts: List[Pt], others: List[List[Pt]]) -> float:
2543
+ """Area of ``e``'s label (placed on ``pts``) over leaf boxes of the diagram."""
2544
+ from .policy import role_of
2545
+
2546
+ leaves: List[Tuple[float, float, float, float]] = []
2547
+
2548
+ def rec(n: Node) -> None:
2549
+ kids = list(n.children())
2550
+ if not kids and role_of(n) in ("hard", "soft"):
2551
+ g = n.geom
2552
+ if g.w > 0.5 and g.h > 0.5:
2553
+ leaves.append((g.x, g.y, g.w, g.h))
2554
+ for k in kids:
2555
+ rec(k)
2556
+
2557
+ rec(self.content)
2558
+ try:
2559
+ box = e.label_geometry(pts, self.theme, list(leaves), paths=others)[0]
2560
+ except Exception: # pragma: no cover
2561
+ return 0.0
2562
+ bx, by, bw, bh = box
2563
+ tot = 0.0
2564
+ for rx, ry, rw, rh in leaves:
2565
+ w = min(bx + bw, rx + rw) - max(bx, rx)
2566
+ h = min(by + bh, ry + rh) - max(by, ry)
2567
+ if w > 0 and h > 0:
2568
+ tot += w * h
2569
+ return tot
2570
+
2571
+ def _seg_blocked(self, e: "Edge", a: Pt, b: Pt, others: List[List[Pt]]) -> bool:
2572
+ x0, x1 = sorted((a[0], b[0]))
2573
+ y0, y1 = sorted((a[1], b[1]))
2574
+ ins = 0.5
2575
+ for o in self._obstacles_for(e):
2576
+ if getattr(o, "role", "hard") != "hard":
2577
+ continue
2578
+ rx, ry, rw, rh = o[0], o[1], o[2], o[3]
2579
+ if x0 < rx + rw - ins and x1 > rx + ins and y0 < ry + rh - ins and y1 > ry + ins:
2580
+ return True
2581
+ tol = 0.8
2582
+ for pts in others:
2583
+ for (u, v) in zip(pts, pts[1:]):
2584
+ if abs(a[1] - b[1]) < 1e-6 and abs(u[1] - v[1]) < 1e-6 and abs(u[1] - a[1]) < tol:
2585
+ if min(max(u[0], v[0]), x1) - max(min(u[0], v[0]), x0) > tol:
2586
+ return True
2587
+ if abs(a[0] - b[0]) < 1e-6 and abs(u[0] - v[0]) < 1e-6 and abs(u[0] - a[0]) < tol:
2588
+ if min(max(u[1], v[1]), y1) - max(min(u[1], v[1]), y0) > tol:
2589
+ return True
2590
+ return False
2591
+
2592
+ # ---- forks ------------------------------------------------------------------
2593
+ def _plain_edges(self) -> List[Edge]:
2594
+ return [e for e in self.edges if isinstance(e, Edge)]
2595
+
2596
+ def _forks(self) -> List[Fork]:
2597
+ return [e for e in self.edges if isinstance(e, Fork)]
2598
+
2599
+ def _fork_obstacles(self, fork: Fork):
2600
+ from .routing import ancestors_in, collect_obstacles
2601
+
2602
+ nodes = [fork._node(fork.hub)] + [fork._node(l) for l in fork.leaves]
2603
+ transparent = ancestors_in(self.content, nodes)
2604
+ return collect_obstacles(self.content, exclude=nodes, pad=5.0, transparent=transparent)
2605
+
2606
+ # ---- layering: knockout holes + label layout --------------------------------
2607
+ def _soft_text_boxes(self) -> List[Tuple[Any, Tuple[float, float, float, float]]]:
2608
+ """(node, ink box) of every soft, knockout-enabled text in content + overlays."""
2609
+ from .policy import knockout_of, role_of
2610
+
2611
+ out: List[Tuple[Any, Tuple[float, float, float, float]]] = []
2612
+
2613
+ def add(n: Node) -> None:
2614
+ if role_of(n) == "soft" and knockout_of(n):
2615
+ g = n.geom
2616
+ if g.w > 0.5 and g.h > 0.5:
2617
+ box = n.ink_box() if hasattr(n, "ink_box") else (g.x, g.y, g.w, g.h)
2618
+ out.append((n, box))
2619
+
2620
+ def walk(n: Node) -> None:
2621
+ add(n)
2622
+ for k in n.children():
2623
+ walk(k)
2624
+
2625
+ walk(self.content)
2626
+ for o in getattr(self, "_cf_overlays_all", None) or []:
2627
+ walk(o)
2628
+ return out
2629
+
2630
+ def layout_edges(self) -> Dict[str, Any]:
2631
+ """Route every edge and place every label (no drawing). Cached per draw."""
2632
+ for e in self._plain_edges():
2633
+ if getattr(e, "_par_group", None):
2634
+ e._par_assign_label()
2635
+ routed, buses = self._routed()
2636
+ obs = self._label_obstacles()
2637
+ paths = [pts for _e, pts in routed]
2638
+ steps: List[Tuple[Edge, Tuple[float, float, float, float]]] = []
2639
+ for edge, pts in routed:
2640
+ if getattr(edge, "step", None) is None:
2641
+ continue
2642
+ sg = edge.step_geometry(pts, obs)
2643
+ if sg is not None:
2644
+ x, y, d = sg
2645
+ box = (x - d / 2, y - d / 2, d, d)
2646
+ steps.append((edge, box))
2647
+ obs.append(box)
2648
+ labels: List[Tuple[Edge, List[Pt], Any]] = []
2649
+ for i, (edge, pts) in enumerate(routed):
2650
+ if edge.label:
2651
+ others = paths[:i] + paths[i + 1:] + list(getattr(self, "_label_frames", []))
2652
+ geo = edge.label_geometry(pts, self.theme, obs, paths=others)
2653
+ labels.append((edge, pts, geo))
2654
+ obs.append(geo[0])
2655
+ from .routing import ORect, route_ortho_avoid
2656
+ for i, (edge, pts) in enumerate(routed):
2657
+ if getattr(edge, "_cf_guide_edge", False) and edge.avoid_text:
2658
+ boxes = [geo[0] for owner, _, geo in labels if owner is not edge]
2659
+ if any(_poly_hits_rect(pts, box) for box in boxes):
2660
+ obstacles = [ORect(*o) for o in self._obstacles_for(edge)
2661
+ if getattr(o, "role", "hard") != "background"]
2662
+ obstacles += [ORect(*box) for box in boxes + self._title_boxes()]
2663
+ a, b = edge.endpoints()
2664
+ routed[i] = (edge, route_ortho_avoid(*a, *b, stub=edge.stub, obstacles=obstacles))
2665
+ return {"routed": routed, "buses": buses, "labels": labels, "steps": steps}
2666
+
2667
+ def _holes_for(self, edge: Edge, pts: List[Pt], soft: Sequence[Any],
2668
+ label_boxes: Sequence[Tuple[Edge, Tuple[float, float, float, float]]]
2669
+ ) -> List[Tuple[float, float, float, float]]:
2670
+ if not edge.knockout_enabled(self.theme):
2671
+ return []
2672
+ from .policy import knockout_enabled # noqa: F401 (figure-level default)
2673
+
2674
+ pad = float(getattr(self.theme, "knockout_pad", 1.5))
2675
+ ends = {id(edge.src.node), id(edge.dst.node)}
2676
+ holes = []
2677
+ for n, (x, y, w, h) in soft:
2678
+ if id(n) in ends:
2679
+ continue
2680
+ r = (x - pad, y - pad, w + 2 * pad, h + 2 * pad)
2681
+ if _poly_hits_rect(pts, r):
2682
+ holes.append(r)
2683
+ for owner, (x, y, w, h) in label_boxes:
2684
+ if owner is edge and edge.label_side != "on":
2685
+ continue
2686
+ r = (x - pad, y - pad, w + 2 * pad, h + 2 * pad)
2687
+ if _poly_hits_rect(pts, r):
2688
+ holes.append(r)
2689
+ # v0.12: an edge leaving a titled Group through its title is cut around the
2690
+ # title text instead of striking it through
2691
+ for (x, y, w, h) in self._title_boxes():
2692
+ r = (x - pad, y - pad, w + 2 * pad, h + 2 * pad)
2693
+ if _poly_hits_rect(pts, r):
2694
+ holes.append(r)
2695
+ return holes
2696
+
2697
+ def _title_boxes(self) -> List[Tuple[float, float, float, float]]:
2698
+ from .constraints import walk
2699
+
2700
+ out: List[Tuple[float, float, float, float]] = []
2701
+ trees = [self.content] + list(getattr(self, "_cf_overlays_all", None) or [])
2702
+ for t in trees:
2703
+ for n in walk(t):
2704
+ tb = getattr(n, "title_boxes", None)
2705
+ if tb is not None and getattr(n, "title", None):
2706
+ try:
2707
+ out.extend(tb())
2708
+ except Exception: # pragma: no cover
2709
+ pass
2710
+ return out
2711
+
2712
+ def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
2713
+ from .render_svg import draw_circle, draw_line
2714
+
2715
+ if self.regions: # behind everything; labels after the content
2716
+ self._layout_regions()
2717
+ for i, r in enumerate(self.regions):
2718
+ r.draw(buf, ox, oy, index=i)
2719
+ for i, r in enumerate(self.regions):
2720
+ r.draw_label(buf, ox, oy, index=i)
2721
+ self.content.draw(buf, ox, oy)
2722
+ lay = self.layout_edges()
2723
+ routed, buses = lay["routed"], lay["buses"]
2724
+ soft = self._soft_text_boxes()
2725
+ label_boxes = [(e, geo[0]) for e, _p, geo in lay["labels"]]
2726
+ label_boxes += [(e, box) for e, box in lay.get("steps", [])]
2727
+ # Bus underlays first (behind individual edges)
2728
+ for bus in buses:
2729
+ if bus["kind"] == "v":
2730
+ draw_line(
2731
+ buf,
2732
+ bus["x"] + ox, bus["y0"] + oy,
2733
+ bus["x"] + ox, bus["y1"] + oy,
2734
+ stroke=bus["color"],
2735
+ stroke_width=bus["width"],
2736
+ opacity=bus["opacity"],
2737
+ )
2738
+ else:
2739
+ draw_line(
2740
+ buf,
2741
+ bus["x0"] + ox, bus["y"] + oy,
2742
+ bus["x1"] + ox, bus["y"] + oy,
2743
+ stroke=bus["color"],
2744
+ stroke_width=bus["width"],
2745
+ opacity=bus["opacity"],
2746
+ )
2747
+ curves = getattr(self, "_curves", {})
2748
+ for edge, pts in routed:
2749
+ holes = self._holes_for(edge, pts, soft, label_boxes)
2750
+ holes = [(x + ox, y + oy, w, h) for x, y, w, h in holes]
2751
+ edge.draw(buf, ox, oy, theme=self.theme, points=pts, defer_label=True,
2752
+ curve=curves.get(id(edge)), holes=holes)
2753
+ for fork in self._forks():
2754
+ if fork._dot_at is not None and fork._edges:
2755
+ st = fork._edges[0].resolved(self.theme)
2756
+ r = fork.dot_size if fork.dot_size is not None else max(2.2, st.width * 1.8)
2757
+ draw_circle(buf, fork._dot_at[0] + ox, fork._dot_at[1] + oy, r, fill=st.color,
2758
+ stroke="none", stroke_width=0)
2759
+ # Step badges (①) sit on their own path, above every edge line
2760
+ for edge, (x, y, w, h) in lay.get("steps", []):
2761
+ edge.step.paint(buf, x + w / 2 + ox, y + h / 2 + oy, w)
2762
+ # Labels go on top of every edge path; edges passing under a label are
2763
+ # knocked out around it (soft-text rule), so no halo colour is needed.
2764
+ for edge, pts, geo in lay["labels"]:
2765
+ p2 = [(x + ox, y + oy) for x, y in pts]
2766
+ (x, y, w, h), r_, a_ = geo
2767
+ edge._draw_label(buf, p2, self.theme, edge.resolved(self.theme), [],
2768
+ geometry=((x + ox, y + oy, w, h), r_, a_))
2769
+ self._placed_labels = [geo[0] for _e, _p, geo in lay["labels"]]
2770
+
2771
+ def _label_obstacles(self) -> List[Tuple[float, float, float, float]]:
2772
+ from .policy import role_of
2773
+
2774
+ out: List[Tuple[float, float, float, float]] = []
2775
+ frames: List[List[Pt]] = []
2776
+
2777
+ def rect_poly(x: float, y: float, w: float, h: float) -> List[Pt]:
2778
+ return [(x, y), (x + w, y), (x + w, y + h), (x, y + h), (x, y)]
2779
+
2780
+ def walk(n: Node) -> None:
2781
+ role = role_of(n)
2782
+ g0 = getattr(n, "geom", None)
2783
+ if g0 is not None and g0.w > 0.5 and g0.h > 0.5 and role not in ("hard", "soft"):
2784
+ # v0.12: visible frames of background containers (Group, Stacked
2785
+ # decks) steer labels too — a label must not sit on an outline
2786
+ st = getattr(n, "stroke", None)
2787
+ if getattr(n, "border", None) != "none" and st and st != "none":
2788
+ frames.append(rect_poly(g0.x, g0.y, g0.w, g0.h))
2789
+ elif type(n).__name__ == "Divider":
2790
+ if getattr(n, "orientation", "vertical") == "vertical":
2791
+ cx_ = g0.x + g0.w / 2
2792
+ frames.append([(cx_, g0.y), (cx_, g0.y + g0.h)])
2793
+ else:
2794
+ cy_ = g0.y + g0.h / 2
2795
+ frames.append([(g0.x, cy_), (g0.x + g0.w, cy_)])
2796
+ elif type(n).__name__ == "Stacked":
2797
+ frames.append(rect_poly(g0.x, g0.y, g0.w, g0.h))
2798
+ fg = n.node.geom
2799
+ for c in range(1, n.n):
2800
+ frames.append(rect_poly(fg.x + n.dx * c, fg.y + n.dy * c, fg.w, fg.h))
2801
+ if role in ("hard", "soft"):
2802
+ g = n.geom
2803
+ if g.w > 0.5 and g.h > 0.5:
2804
+ if role == "soft" and hasattr(n, "ink_box"):
2805
+ x, y, w, h = n.ink_box()
2806
+ out.append((x - 1, y - 1, w + 2, h + 2))
2807
+ else:
2808
+ out.append((g.x - 1, g.y - 1, g.w + 2, g.h + 2))
2809
+ for k in n.children():
2810
+ walk(k)
2811
+
2812
+ walk(self.content)
2813
+ for o in getattr(self, "_cf_overlays_all", None) or []:
2814
+ walk(o)
2815
+ self._n_label_obs = len(out)
2816
+ self._label_frames = frames
2817
+ return out
2818
+
2819
+ def label_boxes(self) -> List[Tuple[float, float, float, float]]:
2820
+ """Bounding boxes of every edge label (page coordinates) after layout."""
2821
+ if getattr(self, "_placed_labels", None) is None:
2822
+ lay = self.layout_edges()
2823
+ self._placed_labels = [geo[0] for _e, _p, geo in lay["labels"]]
2824
+ return list(self._placed_labels)