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/routing.py ADDED
@@ -0,0 +1,932 @@
1
+ """Obstacle-aware ortho routing and edge bundling helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import heapq
6
+ import math as _math
7
+ from collections import defaultdict
8
+ from typing import Dict, List, Optional, Sequence, Tuple
9
+
10
+ from .node import Node
11
+
12
+ Pt = Tuple[float, float]
13
+ Rect = Tuple[float, float, float, float] # x, y, w, h
14
+
15
+ class ORect(tuple):
16
+ """(x, y, w, h) obstacle rectangle carrying its layering ``role``."""
17
+
18
+ role: str = "hard"
19
+ pad: float = 0.0
20
+
21
+ def __new__(cls, x: float, y: float, w: float, h: float, role: str = "hard",
22
+ pad: float = 0.0) -> "ORect":
23
+ obj = super().__new__(cls, (x, y, w, h))
24
+ obj.role = role
25
+ obj.pad = pad
26
+ return obj
27
+
28
+
29
+ # routing cost of crossing a non-hard obstacle (px-equivalents per crossing):
30
+ # soft text / background regions are *preferred* to be avoided but may be crossed.
31
+ SOFT_COST = {"hard": 10000.0, "soft": 45.0, "background": 12.0}
32
+
33
+
34
+ def _role(r) -> str:
35
+ return getattr(r, "role", "hard")
36
+
37
+
38
+ def hard_only(obstacles):
39
+ return [r for r in obstacles if _role(r) == "hard"]
40
+
41
+
42
+ # Node class names treated as obstacles (visual boxes) — roles from codefig.policy
43
+ _SOLID_TYPES = {
44
+ "Legend",
45
+ "TokenStrip",
46
+ "Table",
47
+ "Box",
48
+ "Matrix",
49
+ "Group",
50
+ "Brace",
51
+ "CircleHighlight",
52
+ "Callout",
53
+ "Text",
54
+ "DotGrid",
55
+ "Strip",
56
+ "Badge",
57
+ "BarChart",
58
+ "VectorFan",
59
+ "Swatch",
60
+ }
61
+
62
+
63
+ def collect_obstacles(
64
+ root: Node,
65
+ *,
66
+ exclude: Optional[Sequence[Node]] = None,
67
+ pad: float = 4.0,
68
+ transparent: Optional[Sequence[Node]] = None,
69
+ ) -> List[Rect]:
70
+ """Walk the scene graph; return inflated bboxes of solid leaf-ish nodes.
71
+
72
+ ``exclude``: whole subtrees skipped (the edge's own endpoints).
73
+ ``transparent``: nodes whose own box is skipped but whose children are still
74
+ obstacles (the endpoints' ancestors — an edge must be able to leave the
75
+ region it starts in). Nodes built with ``obstacle=False`` are transparent too.
76
+ """
77
+ from .policy import role_of
78
+
79
+ skip = set(id(n) for n in (exclude or ()))
80
+ clear = set(id(n) for n in (transparent or ()))
81
+ out: List[Rect] = []
82
+
83
+ def walk(n: Node) -> None:
84
+ if id(n) in skip:
85
+ # still walk children? no — whole subtree excluded (src/dst)
86
+ return
87
+ kids = list(n.children())
88
+ role = role_of(n)
89
+ see_through = id(n) in clear or role in (None, "none")
90
+ if not see_through:
91
+ g = n.geom
92
+ if g.w > 1 and g.h > 1:
93
+ out.append(ORect(g.x - pad, g.y - pad, g.w + 2 * pad, g.h + 2 * pad, role, pad))
94
+ for k in kids:
95
+ walk(k)
96
+
97
+ walk(root)
98
+ return out
99
+
100
+
101
+ def ancestors_in(root: Node, targets: Sequence[Node]) -> List[Node]:
102
+ """All proper ancestors (within ``root``) of any node in ``targets``."""
103
+ want = set(id(t) for t in targets if t is not None)
104
+ found: List[Node] = []
105
+ seen: set = set()
106
+
107
+ def walk(n: Node, path: List[Node]) -> None:
108
+ if id(n) in want:
109
+ for a in path:
110
+ if id(a) not in seen:
111
+ seen.add(id(a))
112
+ found.append(a)
113
+ path.append(n)
114
+ for k in n.children():
115
+ walk(k, path)
116
+ path.pop()
117
+
118
+ walk(root, [])
119
+ return found
120
+
121
+
122
+ def _v_hits_rect(mx: float, y0: float, y1: float, r: Rect) -> bool:
123
+ rx, ry, rw, rh = r
124
+ if mx < rx or mx > rx + rw:
125
+ return False
126
+ lo, hi = min(y0, y1), max(y0, y1)
127
+ return not (hi < ry or lo > ry + rh)
128
+
129
+
130
+ def _h_hits_rect(my: float, x0: float, x1: float, r: Rect) -> bool:
131
+ rx, ry, rw, rh = r
132
+ if my < ry or my > ry + rh:
133
+ return False
134
+ lo, hi = min(x0, x1), max(x0, x1)
135
+ return not (hi < rx or lo > rx + rw)
136
+
137
+
138
+ def _seg_hits(x1: float, y1: float, x2: float, y2: float, r: Rect) -> bool:
139
+ if abs(x1 - x2) < 0.51:
140
+ return _v_hits_rect(x1, y1, y2, r)
141
+ if abs(y1 - y2) < 0.51:
142
+ return _h_hits_rect(y1, x1, x2, r)
143
+ return seg_rect_intersect((x1, y1), (x2, y2), r)
144
+
145
+
146
+ # cost of only grazing an obstacle's clearance ring (padding), not the node itself
147
+ PAD_GRAZE_COST = 30.0
148
+
149
+
150
+ def _core(r: Rect) -> Rect:
151
+ p = getattr(r, "pad", 0.0)
152
+ x, y, w, h = r
153
+ return (x + p, y + p, max(0.0, w - 2 * p), max(0.0, h - 2 * p))
154
+
155
+
156
+ def _legs_cost(pts: List[Pt], obstacles: List[Rect]) -> float:
157
+ """Per-obstacle crossing cost of a polyline; padding-only grazes are cheap."""
158
+ c = 0.0
159
+ for r in obstacles:
160
+ segs = [(pts[i][0], pts[i][1], pts[i + 1][0], pts[i + 1][1]) for i in range(len(pts) - 1)]
161
+ if not any(_seg_hits(*sg, r) for sg in segs):
162
+ continue
163
+ w = SOFT_COST.get(_role(r), 10000.0)
164
+ if getattr(r, "pad", 0.0) > 0 and not any(_seg_hits(*sg, _core(r)) for sg in segs):
165
+ w = min(w, PAD_GRAZE_COST)
166
+ c += w
167
+ return c
168
+
169
+
170
+ def seg_rect_intersect(p: Pt, q: Pt, r: Rect) -> bool:
171
+ """Liang–Barsky: does segment p→q touch rect r = (x, y, w, h)?"""
172
+ rx, ry, rw, rh = r
173
+ x0, y0 = p
174
+ dx, dy = q[0] - x0, q[1] - y0
175
+ t0, t1 = 0.0, 1.0
176
+ for pp, qq in ((-dx, x0 - rx), (dx, rx + rw - x0), (-dy, y0 - ry), (dy, ry + rh - y0)):
177
+ if abs(pp) < 1e-12:
178
+ if qq < 0:
179
+ return False
180
+ continue
181
+ t = qq / pp
182
+ if pp < 0:
183
+ t0 = max(t0, t)
184
+ else:
185
+ t1 = min(t1, t)
186
+ if t0 > t1:
187
+ return False
188
+ return True
189
+
190
+
191
+ def _path_hits(pts: List[Pt], obstacles: List[Rect], roles: Tuple[str, ...] = ("hard",)) -> int:
192
+ """Number of (segment, obstacle) crossings, counting obstacles with ``roles``."""
193
+ hits = 0
194
+ for i in range(len(pts) - 1):
195
+ x1, y1 = pts[i]
196
+ x2, y2 = pts[i + 1]
197
+ for r in obstacles:
198
+ if _role(r) in roles and _seg_hits(x1, y1, x2, y2, r):
199
+ hits += 1
200
+ return hits
201
+
202
+
203
+ def path_cost(pts: List[Pt], obstacles: List[Rect]) -> float:
204
+ """Weighted crossings: hard ≫ soft text > background region (see SOFT_COST)."""
205
+ c = 0.0
206
+ for r in obstacles:
207
+ w = SOFT_COST.get(_role(r), 10000.0)
208
+ if any(_seg_hits(pts[i][0], pts[i][1], pts[i + 1][0], pts[i + 1][1], r)
209
+ for i in range(len(pts) - 1)):
210
+ c += w
211
+ return c
212
+
213
+
214
+ def choose_clear_mid_x(
215
+ ax: float,
216
+ ay: float,
217
+ bx: float,
218
+ by: float,
219
+ prefer: float,
220
+ obstacles: List[Rect],
221
+ ) -> float:
222
+ """Pick a vertical-trunk x that clears obstacles between ay..by."""
223
+ y0, y1 = min(ay, by), max(ay, by)
224
+ lo, hi = min(ax, bx), max(ax, bx)
225
+ cands = [prefer, (ax + bx) / 2.0, lo - 8.0, hi + 8.0]
226
+ for rx, ry, rw, rh in obstacles:
227
+ if ry + rh < y0 or ry > y1:
228
+ continue
229
+ cands.append(rx - 2.0)
230
+ cands.append(rx + rw + 2.0)
231
+ # de-dupe roughly
232
+ uniq = []
233
+ for c in cands:
234
+ if not any(abs(c - u) < 0.5 for u in uniq):
235
+ uniq.append(c)
236
+
237
+ def cost(mx: float) -> float:
238
+ # whole path (stub legs included), not just the trunk (v0.7.0)
239
+ return _legs_cost([(ax, ay), (mx, ay), (mx, by), (bx, by)], obstacles) + abs(mx - prefer)
240
+
241
+ return min(uniq, key=cost)
242
+
243
+
244
+ def choose_clear_mid_y(
245
+ ax: float,
246
+ ay: float,
247
+ bx: float,
248
+ by: float,
249
+ prefer: float,
250
+ obstacles: List[Rect],
251
+ ) -> float:
252
+ x0, x1 = min(ax, bx), max(ax, bx)
253
+ lo, hi = min(ay, by), max(ay, by)
254
+ cands = [prefer, (ay + by) / 2.0, lo - 8.0, hi + 8.0]
255
+ for rx, ry, rw, rh in obstacles:
256
+ if rx + rw < x0 or rx > x1:
257
+ continue
258
+ cands.append(ry - 2.0)
259
+ cands.append(ry + rh + 2.0)
260
+ uniq = []
261
+ for c in cands:
262
+ if not any(abs(c - u) < 0.5 for u in uniq):
263
+ uniq.append(c)
264
+
265
+ def cost(my: float) -> float:
266
+ # whole path (stub legs included), not just the trunk (v0.7.0)
267
+ return _legs_cost([(ax, ay), (ax, my), (bx, my), (bx, by)], obstacles) + abs(my - prefer)
268
+
269
+ return min(uniq, key=cost)
270
+
271
+
272
+ def _visibility_route(a, b, obstacles):
273
+ """Exact rectilinear fallback when a sampled grid misses narrow blockers."""
274
+ from .styles import current_theme
275
+ clearance = current_theme().L(2)
276
+ boxes = [_core(o) for o in hard_only(obstacles)]
277
+ xs = sorted({a[0], b[0]} | {v for x, y, w, h in boxes for v in (x-clearance, x+w+clearance)})
278
+ ys = sorted({a[1], b[1]} | {v for x, y, w, h in boxes for v in (y-clearance, y+h+clearance)})
279
+ start, goal = (xs.index(a[0]), ys.index(a[1])), (xs.index(b[0]), ys.index(b[1]))
280
+ queue, costs, previous = [(0.0, start)], {start: 0.0}, {}
281
+ while queue:
282
+ cost, point = heapq.heappop(queue)
283
+ if cost != costs[point]:
284
+ continue
285
+ if point == goal:
286
+ path = [b]
287
+ while point != start:
288
+ point = previous[point]
289
+ path.append((xs[point[0]], ys[point[1]]))
290
+ return _simplify_ortho(list(reversed(path)))
291
+ i, j = point
292
+ p = (xs[i], ys[j])
293
+ for ni, nj in ((i-1,j), (i+1,j), (i,j-1), (i,j+1)):
294
+ if not (0 <= ni < len(xs) and 0 <= nj < len(ys)):
295
+ continue
296
+ q = (xs[ni], ys[nj])
297
+ if any(seg_rect_intersect(p, q, r) for r in boxes):
298
+ continue
299
+ value = cost + abs(q[0]-p[0]) + abs(q[1]-p[1])
300
+ if value < costs.get((ni,nj), float("inf")):
301
+ costs[ni,nj] = value
302
+ previous[ni,nj] = point
303
+ heapq.heappush(queue, (value, (ni,nj)))
304
+ return None
305
+
306
+
307
+ def route_ortho_avoid(
308
+ x1: float,
309
+ y1: float,
310
+ d1: str,
311
+ x2: float,
312
+ y2: float,
313
+ d2: str,
314
+ *,
315
+ stub: float = 12.0,
316
+ mid_bias: float = 0.0,
317
+ obstacles: Optional[List[Rect]] = None,
318
+ route_ortho_fn=None,
319
+ stub_fn=None,
320
+ use_astar: bool = True,
321
+ astar_cell: float = 10.0,
322
+ ) -> List[Pt]:
323
+ """
324
+ Obstacle-aware Manhattan route.
325
+
326
+ 1. Mid-shift: stub→mid→stub with mid chosen to clear boxes (lane mid_bias).
327
+ 2. If the mid-shift path still hits obstacles and use_astar, run 4-connected
328
+ grid A* between stubs (bend penalty), simplify collinear points.
329
+ 3. Fall back to mid-shift if A* fails or finds no improvement.
330
+ """
331
+ from .edges import _dedupe, _facing_stub, _stub, _trunk0, route_ortho
332
+
333
+ obstacles = obstacles or []
334
+ facing = {("down", "up"): y2 - y1, ("up", "down"): y1 - y2,
335
+ ("right", "left"): x2 - x1,
336
+ ("left", "right"): x1 - x2}.get((d1, d2), 0.0) > 0.0
337
+ stub = _facing_stub(x1, y1, d1, x2, y2, d2, stub)
338
+ base = route_ortho(x1, y1, d1, x2, y2, d2, stub=stub, mid_bias=mid_bias)
339
+ if not obstacles:
340
+ return base
341
+
342
+ ax, ay = _stub(x1, y1, d1, stub)
343
+ bx, by = _stub(x2, y2, d2, stub)
344
+ horiz_src = d1 in ("left", "right")
345
+ horiz_dst = d2 in ("left", "right")
346
+
347
+ if horiz_src and horiz_dst:
348
+ prefer = _trunk0(ax, bx, d1, d2) + mid_bias
349
+ mx = choose_clear_mid_x(ax, ay, bx, by, prefer, obstacles)
350
+ mid_pts = _dedupe([(x1, y1), (ax, ay), (mx, ay), (mx, by), (bx, by), (x2, y2)])
351
+ elif (not horiz_src) and (not horiz_dst):
352
+ prefer = _trunk0(ay, by, d1, d2) + mid_bias
353
+ my = choose_clear_mid_y(ax, ay, bx, by, prefer, obstacles)
354
+ mid_pts = _dedupe([(x1, y1), (ax, ay), (ax, my), (bx, my), (bx, by), (x2, y2)])
355
+ else:
356
+ mid_pts = base
357
+
358
+ mid_hits = _path_hits(mid_pts, obstacles)
359
+ if mid_hits == 0 or not use_astar:
360
+ return mid_pts
361
+ if facing and _legs_cost(mid_pts, obstacles) < PAD_GRAZE_COST * 8:
362
+ # Facing ports across a gap narrower than the clearance padding (e.g. a
363
+ # WrapFlow z-mode row change): the in-gap trunk only grazes padding, whereas
364
+ # the grid A* detour loops around the whole row. Keep the gap (v0.7.0).
365
+ return mid_pts
366
+
367
+ astar_core = route_ortho_astar(ax, ay, bx, by, hard_only(obstacles), cell=astar_cell)
368
+ astar_pts = _dedupe([(x1, y1)] + list(astar_core or []) + [(x2, y2)])
369
+ if not astar_core or _path_hits(astar_pts, [_core(o) for o in hard_only(obstacles)]):
370
+ exact = _visibility_route((ax, ay), (bx, by), obstacles)
371
+ if exact:
372
+ astar_pts = _dedupe([(x1, y1)] + exact + [(x2, y2)])
373
+ elif not astar_core:
374
+ return mid_pts
375
+ cores = [_core(o) for o in hard_only(obstacles)]
376
+ if _path_hits(astar_pts, cores) < _path_hits(mid_pts, cores):
377
+ return astar_pts
378
+ if _path_hits(astar_pts, obstacles) < mid_hits:
379
+ return astar_pts
380
+ # Prefer A* if equal hits but more corners cleared a blocking box
381
+ if _path_hits(astar_pts, obstacles) == 0:
382
+ return astar_pts
383
+ return mid_pts
384
+
385
+
386
+ def route_loop_avoid(
387
+ x1: float,
388
+ y1: float,
389
+ d1: str,
390
+ x2: float,
391
+ y2: float,
392
+ d2: str,
393
+ *,
394
+ stub: float = 12.0,
395
+ side: str = "auto",
396
+ clearance: float = 40.0,
397
+ obstacles: Optional[List[Rect]] = None,
398
+ ) -> List[Pt]:
399
+ """Loop route; push clearance outward until the trunk clears obstacles."""
400
+ from .edges import route_loop, _stub, _dedupe
401
+
402
+ obstacles = obstacles or []
403
+ pts = route_loop(
404
+ x1, y1, d1, x2, y2, d2, stub=stub, side=side, clearance=clearance
405
+ )
406
+ if not obstacles:
407
+ return pts
408
+
409
+ # Resolve effective side
410
+ eff = side
411
+ if eff == "auto":
412
+ if d1 == d2 and d1 in ("left", "right", "up", "down"):
413
+ eff = {"right": "right", "left": "left", "up": "top", "down": "bottom"}[d1]
414
+ else:
415
+ eff = "right"
416
+
417
+ ax, ay = _stub(x1, y1, d1, stub)
418
+ bx, by = _stub(x2, y2, d2, stub)
419
+ clear = clearance
420
+ for _ in range(8):
421
+ if eff == "right":
422
+ trunk = max(ax, bx, x1, x2) + clear
423
+ cand = [(x1, y1), (ax, ay), (trunk, ay), (trunk, by), (bx, by), (x2, y2)]
424
+ elif eff == "left":
425
+ trunk = min(ax, bx, x1, x2) - clear
426
+ cand = [(x1, y1), (ax, ay), (trunk, ay), (trunk, by), (bx, by), (x2, y2)]
427
+ elif eff == "bottom":
428
+ trunk = max(ay, by, y1, y2) + clear
429
+ cand = [(x1, y1), (ax, ay), (ax, trunk), (bx, trunk), (bx, by), (x2, y2)]
430
+ else:
431
+ trunk = min(ay, by, y1, y2) - clear
432
+ cand = [(x1, y1), (ax, ay), (ax, trunk), (bx, trunk), (bx, by), (x2, y2)]
433
+ cand = _dedupe(cand)
434
+ # a feedback loop goes *around* the content, so framed groups count too
435
+ if _path_hits(cand, obstacles, ("hard", "background")) == 0:
436
+ return cand
437
+ clear += 14.0
438
+ return cand
439
+
440
+
441
+
442
+ # ---- A* / maze-style ortho routing ----------------------------------------
443
+
444
+
445
+ def _point_in_rect(x: float, y: float, r: Rect, inset: float = 0.0) -> bool:
446
+ rx, ry, rw, rh = r
447
+ return (rx + inset) <= x <= (rx + rw - inset) and (ry + inset) <= y <= (ry + rh - inset)
448
+
449
+
450
+ def _cell_blocked(cx: float, cy: float, obstacles: List[Rect], cell: float) -> bool:
451
+ # Treat cell center as blocked if inside any obstacle
452
+ for r in obstacles:
453
+ if _point_in_rect(cx, cy, r, inset=0.0):
454
+ return True
455
+ return False
456
+
457
+
458
+ def _simplify_ortho(pts: List[Pt]) -> List[Pt]:
459
+ """Drop collinear intermediate points on an ortho polyline."""
460
+ if len(pts) <= 2:
461
+ return list(pts)
462
+ out: List[Pt] = [pts[0]]
463
+ for i in range(1, len(pts) - 1):
464
+ x0, y0 = out[-1]
465
+ x1, y1 = pts[i]
466
+ x2, y2 = pts[i + 1]
467
+ if abs(x1 - x0) < 0.01 and abs(y1 - y0) < 0.01:
468
+ continue
469
+ same_h = abs(y0 - y1) < 0.01 and abs(y1 - y2) < 0.01
470
+ same_v = abs(x0 - x1) < 0.01 and abs(x1 - x2) < 0.01
471
+ if same_h or same_v:
472
+ continue
473
+ out.append(pts[i])
474
+ last = pts[-1]
475
+ if abs(out[-1][0] - last[0]) > 0.01 or abs(out[-1][1] - last[1]) > 0.01:
476
+ out.append(last)
477
+ return out
478
+
479
+
480
+ def _absorb_jogs(path: List[Pt], tol: float) -> List[Pt]:
481
+ """Propose a terminal jog collapse; caller validates endpoint movement.
482
+
483
+ Returns a fresh path. Port permissions and collision checks require the
484
+ diagram context and must be checked before accepting this proposal.
485
+ """
486
+ pts = _simplify_ortho(path)
487
+ for i in range(1, len(pts) - 3):
488
+ a, b, c, d, end = pts[i - 1:i + 4]
489
+ for k in (0, 1):
490
+ n = 1 - k
491
+ if (a[k] == b[k] == end[k] and c[k] == d[k]
492
+ and b[n] == c[n] and d[n] == end[n]
493
+ and 0 < abs(c[k] - b[k]) < tol
494
+ and (b[n] - a[n]) * (d[n] - c[n]) > 0):
495
+ return _absorb_jogs(pts[:i] + [end] + pts[i + 4:], tol)
496
+ if len(pts) == 4:
497
+ a, b, c, d = pts
498
+ for k in (0, 1):
499
+ n = 1 - k
500
+ jog = abs(c[k] - b[k])
501
+ if (a[k] == b[k] and c[k] == d[k] and b[n] == c[n]
502
+ and 0 < jog < tol
503
+ and min(abs(b[n] - a[n]), abs(d[n] - c[n])) > jog
504
+ and (b[n] - a[n]) * (d[n] - c[n]) > 0):
505
+ q = list(a)
506
+ q[k] = d[k]
507
+ return [tuple(q), d]
508
+ return pts
509
+
510
+
511
+ def _snap_ortho_ends(pts: List[Pt], a: Pt, b: Pt) -> List[Pt]:
512
+ """Move the first/last grid runs onto the exact stub coordinates so every
513
+ segment stays axis-aligned (snapping only the endpoints made diagonals)."""
514
+ pts = [tuple(p) for p in pts]
515
+ if len(pts) < 2:
516
+ return [a, b] if (abs(a[0] - b[0]) < 0.01 or abs(a[1] - b[1]) < 0.01) else [a, (b[0], a[1]), b]
517
+ # start run
518
+ if abs(pts[0][1] - pts[1][1]) < 0.01: # horizontal
519
+ pts[1] = (pts[1][0], a[1])
520
+ else:
521
+ pts[1] = (a[0], pts[1][1])
522
+ pts[0] = a
523
+ # end run
524
+ if len(pts) >= 3:
525
+ if abs(pts[-1][1] - pts[-2][1]) < 0.01:
526
+ pts[-2] = (pts[-2][0], b[1])
527
+ else:
528
+ pts[-2] = (b[0], pts[-2][1])
529
+ pts[-1] = b
530
+ # safety: insert elbows for any remaining diagonal pair
531
+ out: List[Pt] = [pts[0]]
532
+ for p in pts[1:]:
533
+ q = out[-1]
534
+ if abs(p[0] - q[0]) > 0.01 and abs(p[1] - q[1]) > 0.01:
535
+ out.append((p[0], q[1]))
536
+ out.append(p)
537
+ return _simplify_ortho(out)
538
+
539
+
540
+ def route_ortho_astar(
541
+ ax: float,
542
+ ay: float,
543
+ bx: float,
544
+ by: float,
545
+ obstacles: List[Rect],
546
+ *,
547
+ cell: float = 10.0,
548
+ max_cells: int = 10000,
549
+ ) -> Optional[List[Pt]]:
550
+ """
551
+ 4-connected grid A* from (ax,ay) to (bx,by), avoiding obstacle rects.
552
+
553
+ Returns polyline of cell centers (including start/end snapped), or None.
554
+ """
555
+ if cell < 4.0:
556
+ cell = 4.0
557
+ # Bounding box with margin
558
+ xs = [ax, bx]
559
+ ys = [ay, by]
560
+ for rx, ry, rw, rh in obstacles:
561
+ xs.extend([rx, rx + rw])
562
+ ys.extend([ry, ry + rh])
563
+ margin = cell * 3
564
+ min_x, max_x = min(xs) - margin, max(xs) + margin
565
+ min_y, max_y = min(ys) - margin, max(ys) + margin
566
+ cols = int(_math.ceil((max_x - min_x) / cell)) + 1
567
+ rows = int(_math.ceil((max_y - min_y) / cell)) + 1
568
+ if cols * rows > max_cells:
569
+ # coarsen
570
+ scale = _math.sqrt((cols * rows) / max_cells)
571
+ cell = cell * scale
572
+ cols = int(_math.ceil((max_x - min_x) / cell)) + 1
573
+ rows = int(_math.ceil((max_y - min_y) / cell)) + 1
574
+ if cols * rows > max_cells * 1.5:
575
+ return None
576
+
577
+ def to_ij(x: float, y: float) -> Tuple[int, int]:
578
+ return (
579
+ int(round((x - min_x) / cell)),
580
+ int(round((y - min_y) / cell)),
581
+ )
582
+
583
+ def to_xy(i: int, j: int) -> Pt:
584
+ return min_x + i * cell, min_y + j * cell
585
+
586
+ si, sj = to_ij(ax, ay)
587
+ gi, gj = to_ij(bx, by)
588
+ si = max(0, min(cols - 1, si))
589
+ sj = max(0, min(rows - 1, sj))
590
+ gi = max(0, min(cols - 1, gi))
591
+ gj = max(0, min(rows - 1, gj))
592
+
593
+ blocked = set()
594
+ for i in range(cols):
595
+ for j in range(rows):
596
+ cx, cy = to_xy(i, j)
597
+ if _cell_blocked(cx, cy, obstacles, cell):
598
+ blocked.add((i, j))
599
+ # Never block start/goal
600
+ blocked.discard((si, sj))
601
+ blocked.discard((gi, gj))
602
+
603
+ def h(i: int, j: int) -> float:
604
+ return abs(i - gi) + abs(j - gj)
605
+
606
+ open_h: List[Tuple[float, int, int, int]] = [] # f, tie, i, j
607
+ heapq.heappush(open_h, (h(si, sj), 0, si, sj))
608
+ came: Dict[Tuple[int, int], Tuple[int, int]] = {}
609
+ gscore = {(si, sj): 0.0}
610
+ closed = set()
611
+ tie = 0
612
+ dirs = ((1, 0), (-1, 0), (0, 1), (0, -1))
613
+
614
+ while open_h:
615
+ _f, _, i, j = heapq.heappop(open_h)
616
+ if (i, j) in closed:
617
+ continue
618
+ closed.add((i, j))
619
+ if i == gi and j == gj:
620
+ # reconstruct
621
+ path_ij = [(i, j)]
622
+ while (i, j) in came:
623
+ i, j = came[(i, j)]
624
+ path_ij.append((i, j))
625
+ path_ij.reverse()
626
+ pts = _simplify_ortho([to_xy(ii, jj) for ii, jj in path_ij])
627
+ return _snap_ortho_ends(pts, (ax, ay), (bx, by))
628
+ for di, dj in dirs:
629
+ ni, nj = i + di, j + dj
630
+ if ni < 0 or nj < 0 or ni >= cols or nj >= rows:
631
+ continue
632
+ if (ni, nj) in blocked or (ni, nj) in closed:
633
+ continue
634
+ # bend penalty: prefer straight
635
+ ng = gscore[(i, j)] + 1.0
636
+ if (i, j) in came:
637
+ pi, pj = came[(i, j)]
638
+ if (i - pi, j - pj) != (di, dj):
639
+ ng += 0.15
640
+ if ng < gscore.get((ni, nj), 1e18):
641
+ gscore[(ni, nj)] = ng
642
+ came[(ni, nj)] = (i, j)
643
+ tie += 1
644
+ heapq.heappush(open_h, (ng + h(ni, nj), tie, ni, nj))
645
+ return None
646
+
647
+
648
+
649
+ def _seg_cross(p1: Pt, p2: Pt, q1: Pt, q2: Pt) -> bool:
650
+ """Proper crossing of two axis-aligned segments (touching ends / collinear = no)."""
651
+ ph = abs(p1[1] - p2[1]) < 1e-6
652
+ qh = abs(q1[1] - q2[1]) < 1e-6
653
+ if ph == qh:
654
+ return False
655
+ if not ph:
656
+ p1, p2, q1, q2 = q1, q2, p1, p2 # p horizontal, q vertical
657
+ y = p1[1]
658
+ x = q1[0]
659
+ x0, x1 = sorted((p1[0], p2[0]))
660
+ y0, y1 = sorted((q1[1], q2[1]))
661
+ e = 0.3
662
+ return x0 + e < x < x1 - e and y0 + e < y < y1 - e
663
+
664
+
665
+ def count_crossings(paths: Sequence[Sequence[Pt]]) -> int:
666
+ """Number of proper crossings between axis-aligned polylines (pairs of paths)."""
667
+ segs = [[(pts[k], pts[k + 1]) for k in range(len(pts) - 1)] for pts in paths]
668
+ c = 0
669
+ for i in range(len(segs)):
670
+ for j in range(i + 1, len(segs)):
671
+ for a in segs[i]:
672
+ for b in segs[j]:
673
+ if _seg_cross(a[0], a[1], b[0], b[1]):
674
+ c += 1
675
+ return c
676
+
677
+
678
+ def _lane_order(items: List[Tuple[float, float, float, float]], lanes: List[float],
679
+ build) -> List[int]:
680
+ """Assign lanes (positions ``lanes``) to edges minimising pairwise crossings.
681
+
682
+ ``items`` = (u_src, v_src, u_dst, v_dst) in the bundle frame; ``build(i, lane)``
683
+ returns edge i's polyline. Brute force up to 7 edges, else sort + 2-opt."""
684
+ import itertools
685
+
686
+ n = len(items)
687
+ if n <= 1:
688
+ return list(range(n))
689
+
690
+ def cost(perm: Sequence[int]) -> Tuple[int, float]:
691
+ paths = [build(i, lanes[perm[i]]) for i in range(n)]
692
+ # tie-break: keep lanes in source order (stable, predictable)
693
+ disorder = sum(abs(perm[i] - i) for i in range(n))
694
+ return count_crossings(paths), disorder
695
+
696
+ base = sorted(range(n), key=lambda i: (items[i][1], items[i][3]))
697
+ if n <= 7:
698
+ best = None
699
+ best_c = None
700
+ for perm_l in itertools.permutations(range(n)):
701
+ perm = [0] * n
702
+ for rank, e in enumerate(base):
703
+ perm[e] = perm_l[rank]
704
+ c = cost(perm)
705
+ if best_c is None or c < best_c:
706
+ best, best_c = perm, c
707
+ if c[0] == 0 and c[1] == 0:
708
+ break
709
+ return list(best or range(n))
710
+ perm = [0] * n
711
+ for rank, e in enumerate(base):
712
+ perm[e] = rank
713
+ cur = cost(perm)
714
+ improved = True
715
+ while improved:
716
+ improved = False
717
+ for a in range(n):
718
+ for b in range(a + 1, n):
719
+ perm[a], perm[b] = perm[b], perm[a]
720
+ c = cost(perm)
721
+ if c < cur:
722
+ cur, improved = c, True
723
+ else:
724
+ perm[a], perm[b] = perm[b], perm[a]
725
+ return perm
726
+
727
+
728
+ def apply_bundling(
729
+ edges_meta: List[dict],
730
+ *,
731
+ lane_gap: float = 7.0,
732
+ style: str = "lanes",
733
+ ) -> Tuple[List[Optional[List[Pt]]], List[dict]]:
734
+ """
735
+ Group edges that share the same ``bundle`` id.
736
+
737
+ Styles (per edge ``bundle_style=`` or the theme ``edge_bundle_style``):
738
+
739
+ * ``"lanes"`` (default, v0.7.0): the edges run as parallel, evenly spaced trunks
740
+ (``edge_lane_gap`` apart) centred on the median natural mid; the lane order is
741
+ chosen to minimise edge–edge crossings. No underlay.
742
+ * ``"merge"``: every edge uses one shared trunk (overlapping), no underlay.
743
+ * ``"bus"``: the shared trunk plus a thick semi-transparent underlay (pre-0.7 look).
744
+
745
+ Returns (pts_or_None per meta index, list of bus underlay descriptors).
746
+ """
747
+ from .edges import _stub, _dedupe
748
+
749
+ n = len(edges_meta)
750
+ pts_out: List[Optional[List[Pt]]] = [None] * n
751
+ buses: List[dict] = []
752
+
753
+ groups: Dict[str, List[int]] = defaultdict(list)
754
+ for i, m in enumerate(edges_meta):
755
+ key = m.get("bundle")
756
+ if key:
757
+ groups[str(key)].append(i)
758
+
759
+ for key, idxs in groups.items():
760
+ if len(idxs) < 2:
761
+ continue
762
+ sample = edges_meta[idxs[0]]
763
+ d1, d2 = sample["d1"], sample["d2"]
764
+ horiz = d1 in ("left", "right") and d2 in ("left", "right")
765
+ vert = d1 in ("up", "down") and d2 in ("up", "down")
766
+ if not (horiz or vert):
767
+ continue
768
+ gstyle = next((edges_meta[i].get("bundle_style") for i in idxs
769
+ if edges_meta[i].get("bundle_style")), None) or style
770
+ if gstyle not in ("lanes", "merge", "bus"):
771
+ raise ValueError(f"bundle_style must be lanes|merge|bus, got {gstyle!r}")
772
+
773
+ # bundle frame: u = along the flow (x for horizontal), v = across
774
+ def fr(x: float, y: float) -> Tuple[float, float]:
775
+ return (x, y) if horiz else (y, x)
776
+
777
+ def to(u: float, v: float) -> Pt:
778
+ return (u, v) if horiz else (v, u)
779
+
780
+ items = []
781
+ for i in idxs:
782
+ m = edges_meta[i]
783
+ e = m["edge"]
784
+ ax, ay = _stub(m["x1"], m["y1"], m["d1"], e.stub)
785
+ bx, by = _stub(m["x2"], m["y2"], m["d2"], e.stub)
786
+ au, av = fr(ax, ay)
787
+ bu, bv = fr(bx, by)
788
+ items.append((au, av, bu, bv))
789
+ mids = sorted((au + bu) / 2.0 for au, _av, bu, _bv in items)
790
+ trunk = mids[len(mids) // 2]
791
+ color = None
792
+ width = 1.5
793
+ for i in idxs:
794
+ st = edges_meta[i]["st"]
795
+ color = color or st.color
796
+ width = max(width, st.width)
797
+
798
+ def path(k: int, lane: float) -> List[Pt]:
799
+ m = edges_meta[idxs[k]]
800
+ au, av, bu, bv = items[k]
801
+ p1 = (m["x1"], m["y1"])
802
+ p2 = (m["x2"], m["y2"])
803
+ if abs(av - bv) < 0.5: # already in line: one straight run
804
+ return _dedupe([p1, to(au, av), to(bu, bv), p2])
805
+ return _dedupe([p1, to(au, av), to(lane, av), to(lane, bv), to(bu, bv), p2])
806
+
807
+ if gstyle == "lanes":
808
+ bent = [k for k in range(len(idxs)) if abs(items[k][1] - items[k][3]) >= 0.5]
809
+ nb = len(bent)
810
+ gap = float(lane_gap)
811
+ lo = max(it[0] for it in items) if items else trunk
812
+ hi = min(it[2] for it in items) if items else trunk
813
+ if lo > hi:
814
+ lo, hi = hi, lo
815
+ span = gap * max(0, nb - 1)
816
+ if hi - lo >= span: # keep every lane between the stubs when there is room
817
+ trunk = min(max(trunk, lo + span / 2), hi - span / 2)
818
+ elif nb > 1:
819
+ gap = max((hi - lo) / (nb - 1), 0.5 * float(lane_gap))
820
+ span = gap * (nb - 1)
821
+ trunk = (lo + hi) / 2
822
+ lanes = [trunk - span / 2 + gap * r for r in range(nb)]
823
+ sub = [items[k] for k in bent]
824
+ perm = _lane_order(sub, lanes, lambda j, lane: path(bent[j], lane)) if nb else []
825
+ lane_of = {bent[j]: lanes[perm[j]] for j in range(nb)}
826
+ for k, i in enumerate(idxs):
827
+ pts_out[i] = path(k, lane_of.get(k, trunk))
828
+ continue
829
+
830
+ for k, i in enumerate(idxs):
831
+ pts_out[i] = path(k, trunk)
832
+ if gstyle != "bus":
833
+ continue
834
+ vs = [v for it in items for v in (it[1], it[3])]
835
+ if horiz:
836
+ buses.append({"kind": "v", "x": trunk, "y0": min(vs), "y1": max(vs),
837
+ "color": color or "#111", "width": width * 2.8, "opacity": 0.3,
838
+ "bundle": key})
839
+ else:
840
+ buses.append({"kind": "h", "y": trunk, "x0": min(vs), "x1": max(vs),
841
+ "color": color or "#111", "width": width * 2.8, "opacity": 0.3,
842
+ "bundle": key})
843
+
844
+ return pts_out, buses
845
+
846
+
847
+ def route_channel(x1: float, y1: float, d1: str, x2: float, y2: float, d2: str, *,
848
+ stub: float = 12.0, over: bool = True,
849
+ obstacles: Optional[List[Rect]] = None) -> List[Pt]:
850
+ """v0.14.0: ``Edge(route="over" | "under")`` — leave the source, rise (or drop) to one
851
+ horizontal trunk just clear of every hard obstacle between the two ends, run across
852
+ and come down (up) into the target. Readers see one bracket instead of a detour
853
+ that winds between boxes."""
854
+ from .edges import _dedupe, _stub
855
+
856
+ ax, ay = _stub(x1, y1, d1, stub)
857
+ bx, by = _stub(x2, y2, d2, stub)
858
+ hard = hard_only(obstacles or [])
859
+ level = min(ay, by) if over else max(ay, by)
860
+ for _ in range(40):
861
+ segs = [((ax, ay), (ax, level)), ((ax, level), (bx, level)), ((bx, level), (bx, by))]
862
+ hit = [r for r in hard for (p, q) in segs if _seg_hits(p[0], p[1], q[0], q[1], r)]
863
+ if not hit:
864
+ break
865
+ level = (min(r[1] for r in hit) - 0.5) if over else (max(r[1] + r[3] for r in hit) + 0.5)
866
+ pts = _dedupe([(x1, y1), (ax, ay), (ax, level), (bx, level), (bx, by), (x2, y2)])
867
+ return _simplify_ortho(pts)
868
+
869
+
870
+ def route_back(x1, y1, d1, x2, y2, d2, *, stub, side="auto", clearance=6.0,
871
+ obstacles=None, bounds=None, container_bounds=None):
872
+ """Choose a feedback corridor, penalizing collisions and boundary expansion."""
873
+ from .edges import _stub, _dedupe
874
+ obstacles = obstacles or []
875
+ sides = ("top", "bottom", "left", "right")
876
+ if side not in ("auto", *sides):
877
+ raise ValueError("loop_side must be auto, top, bottom, left, right")
878
+ def expansion(path, box):
879
+ if box is None:
880
+ return 0.0
881
+ return (max(0, box.x-min(x for x,y in path)) +
882
+ max(0, max(x for x,y in path)-box.x-box.w) +
883
+ max(0, box.y-min(y for x,y in path)) +
884
+ max(0, max(y for x,y in path)-box.y-box.h))
885
+ candidates = []
886
+ ax, ay = _stub(x1, y1, d1, stub)
887
+ bx, by = _stub(x2, y2, d2, stub)
888
+ for rank, chosen in enumerate(sides):
889
+ if side != "auto" and chosen != side:
890
+ continue
891
+ axis = 1 if chosen in ("top", "bottom") else 0
892
+ intervals = sorted((r[axis], r[axis]+r[axis+2]) for r in obstacles
893
+ if _role(r) == "hard")
894
+ merged = []
895
+ for lo, hi in intervals:
896
+ if merged and lo <= merged[-1][1]:
897
+ merged[-1][1] = max(merged[-1][1], hi)
898
+ else:
899
+ merged.append([lo, hi])
900
+ channels = [(a[1]+b[0])/2 for a,b in zip(merged, merged[1:])
901
+ if b[0]-a[1] >= 2*clearance]
902
+ if bounds is not None:
903
+ low = bounds.y if axis else bounds.x
904
+ high = low + (bounds.h if axis else bounds.w)
905
+ channels += [low+clearance, high-clearance]
906
+ for t in sorted(set(channels)):
907
+ reference = (ay, by) if axis else (ax, bx)
908
+ if chosen in ("top", "left") and t > min(reference):
909
+ continue
910
+ if chosen in ("bottom", "right") and t < max(reference):
911
+ continue
912
+ if axis:
913
+ core = [(ax,t),(bx,t)]
914
+ else:
915
+ core = [(t,ay),(t,by)]
916
+ path = _dedupe([(x1,y1),(ax,ay)]+core+[(bx,by),(x2,y2)])
917
+ candidates.append((rank,path))
918
+ candidates.append((rank, route_loop_avoid(x1,y1,d1,x2,y2,d2,
919
+ stub=stub,side=chosen,clearance=clearance,obstacles=obstacles)))
920
+ internal = [(r,p) for r,p in candidates if bounds is not None
921
+ and expansion(p,bounds) == 0
922
+ and _path_hits(p,obstacles,("hard", "background")) == 0]
923
+ if internal:
924
+ candidates = internal
925
+ def cost(item):
926
+ rank, path = item
927
+ length = sum(abs(a[0]-b[0])+abs(a[1]-b[1]) for a,b in zip(path,path[1:]))
928
+ bends = sum((b[0]-a[0])*(c[1]-b[1]) != (b[1]-a[1])*(c[0]-b[0])
929
+ for a,b,c in zip(path,path[1:],path[2:]))
930
+ return (length + 8*bends + 1000*_path_hits(path,obstacles,("hard",))
931
+ + 10*expansion(path,bounds) + 50*expansion(path,container_bounds), rank)
932
+ return min(candidates,key=cost)[1]