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/__init__.py +210 -0
- codefig/_validate.py +12 -0
- codefig/autowrap.py +277 -0
- codefig/charts.py +1806 -0
- codefig/code.py +602 -0
- codefig/compose.py +374 -0
- codefig/constraints.py +1571 -0
- codefig/dim.py +214 -0
- codefig/edge_align.py +231 -0
- codefig/edges.py +2824 -0
- codefig/export.py +204 -0
- codefig/figure.py +1923 -0
- codefig/fills.py +146 -0
- codefig/flows.py +102 -0
- codefig/glyphs.py +395 -0
- codefig/icon.py +288 -0
- codefig/icons/LICENSE-tabler-icons.txt +21 -0
- codefig/icons/__init__.py +101 -0
- codefig/icons/tabler.json +1 -0
- codefig/legend.py +483 -0
- codefig/lint.py +1737 -0
- codefig/lint_geom.py +528 -0
- codefig/node.py +864 -0
- codefig/notices.py +34 -0
- codefig/overlaps.py +392 -0
- codefig/policy.py +137 -0
- codefig/primitives.py +2721 -0
- codefig/py.typed +0 -0
- codefig/regions.py +727 -0
- codefig/render_svg.py +512 -0
- codefig/routing.py +932 -0
- codefig/shapes.py +642 -0
- codefig/solids.py +514 -0
- codefig/steps.py +203 -0
- codefig/styles.py +893 -0
- codefig/textutil.py +889 -0
- codefig/timeline.py +1288 -0
- codefig/tree.py +277 -0
- codefig/venues.py +93 -0
- codefig-0.17.0.dist-info/METADATA +542 -0
- codefig-0.17.0.dist-info/RECORD +44 -0
- codefig-0.17.0.dist-info/WHEEL +5 -0
- codefig-0.17.0.dist-info/licenses/LICENSE +21 -0
- codefig-0.17.0.dist-info/top_level.txt +1 -0
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]
|