stdraw 0.7.13__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.
stdraw/routing.py ADDED
@@ -0,0 +1,2630 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Routing layer (line-style layer 3): anchor planning + straight/arc/detour candidates + obstacle-avoidance validation.
3
+
4
+ Hard-gate geometric accounting (v0.2 quality rework):
5
+ - box press = 0: no edge at any angle may enter the interior rectangle of a box (incl. 1px margin);
6
+ endpoints may only attach to the **own** box border (planned anchors); any contact with other boxes is forbidden;
7
+ - edge-box clearance >= CLEARANCE px (non-endpoint boxes; minimum distance over polyline/arc sample points);
8
+ - anchor smoothness: the edge attaches at a planned anchor on the box border; the departure direction vs the border tangent must form an angle >= ANCHOR_MIN_DEG;
9
+ - mutual pairs (a<->b bidirectional edges) must separate into a lens-shaped pair of arcs (one arc one arrow); the two lines coinciding is forbidden;
10
+ scoring includes a coincidence penalty (PAIR_SEP_MIN/PAIR_SEP_W); vg/detour candidates use tangential anchor offsets.
11
+
12
+ All candidates are enumerated in fixed order and picked by fixed scoring -- no RNG, same graph yields same output.
13
+ """
14
+ import math
15
+ import os
16
+ import sys
17
+
18
+ from . import visibility as _vis
19
+ from .anchors import standard_anchors as _std_anchors
20
+
21
+ CLEARANCE = 16 # minimum edge-box clearance (v0.2 hard-gate accounting)
22
+ PRESS_MARGIN = 1 # box-press detection margin px
23
+ ANCHOR_MIN_DEG = 20 # minimum angle between the departure direction and the box-border tangent
24
+ SAMPLE_STEP = 4 # candidate validation sampling step px
25
+ ARC_KS = (0.14, 0.24, 0.36, 0.55, 0.75) # arc curvature coefficient x distance (enumeration ladder)
26
+ ARC_BULGE_BASE = 6.0 # t_7fe64f08 arc-curvature parameterization: minimum bulge px (small bends for short edges)
27
+ ARC_BULGE_SLOPE = 0.06 # bulge grows linearly with chord length (typical chord 130px -> k~=0.21 ~= the old-look tier)
28
+ ARC_BULGE_MAX = 40.0 # bulge cap for long edges
29
+ ARC_K_MIN = 0.12
30
+ ARC_K_MAX = 0.50
31
+
32
+
33
+ def adaptive_arc_k(dist):
34
+ """Chord-length-adaptive arc curvature coefficient (t_7fe64f08 aesthetics v3).
35
+
36
+ Bulge (half the ctrl offset at t=0.5) = min(8 + 0.12*chord, 40) px: gentle bends for short edges,
37
+ capped for long ones; k = 2*bulge/chord, clamped to [0.12, 0.50]. The first arc candidate tier uses this value
38
+ (the ladder is kept as a feasibility fallback); arc-curvature consistency within a loop is achieved by constructed arcs uniformly taking the median chord tier.
39
+ """
40
+ bulge = min(ARC_BULGE_BASE + ARC_BULGE_SLOPE * dist, ARC_BULGE_MAX)
41
+ k = 2.0 * bulge / max(dist, 1e-6)
42
+ return max(ARC_K_MIN, min(ARC_K_MAX, k))
43
+
44
+
45
+ SIDE_MARGIN = 18.5 # clearance between side channels and occluding boxes (16 + 1px margin + construction allowance)
46
+ VG_PAD = 1.5 # WS3 visibility-graph build inflation allowance (fallback for the arc tangent-angle dip)
47
+
48
+ # F1 rework (2026-11-05): coincidence penalty and lens double-arc for mutual pairs (a<->b bidirectional edges)
49
+ PAIR_SEP_MIN = 10.0 # target minimum spacing between the two lines of a mutual pair (scoring accounting; DOM hard gate 4px + rounding margin)
50
+ PAIR_SEP_W = 600.0 # coincidence penalty weight (linear in the gap; full coincidence ~6000 >> single crossing 40)
51
+ PAIR_VG_OFF = 12 # tangential anchor offset for mutual-pair vg/detour candidates (opposite signs for the two directions -> parallel corridors)
52
+ ROUND_GUARD = 0.15 # conservative margin for delivery rounding (0.1px): border-hugging within <0.15px counts as a violation
53
+
54
+ # Line-line visual-coincidence targeted re-routing accounting (t_fce6f438): the random n10 fan-out edges run through the same
55
+ # inter-row corridor candidates (stair/lane, y = row center +/- pitch/2) with per-segment <1px adherence. The authoritative accounting = this
56
+ # module's span_one_way_segs; the certificate side's metrics._coincidence_span delegates both directions to the same source.
57
+ COINCIDE_THR = 1.0 # distance <1px counts as adherence (same source as the certificate accounting)
58
+ COINCIDE_SPAN = 8.0 # accumulated adherence must exceed 8px to count as "visual coincidence" (endpoint meeting exempted)
59
+ FAN_STEP = 18.0 # uniform spread step for multiple anchors on the same node side (t_7fe64f08 rule 3)
60
+ ARRIVAL_TARGET_DEG = 10.0 # t_94a585da: mutual-lens arrow-normal target (card-suggested; hard-gating pending owner decision)
61
+ MIN_FROZEN_ANCHOR_SEP = 8.0 # t_d3844f8a: fan slots must clear frozen anchors on the same side by at least this (px)
62
+ REANCHOR_STEP = 4.0 # t_abe038c1 midband_reanchor ladder-clamping step
63
+ ARC_CTRL_SCALE_LADDER = (1.0, 0.85, 1.15, 0.7, 1.3, 0.55, 1.45, 0.45, 1.6)
64
+ # t_51392037 anchor_snap: after snapping an arc endpoint, the departure angle
65
+ # vs the border tangent (>= ANCHOR_MIN_DEG) cannot always be met by ctrl
66
+ # lockstep translation alone (a long-tangent detour arc keeps its near-tangent
67
+ # departure). The ladder rescales the ctrl offset around the new chord
68
+ # midpoint (preserving the detour side; gates recheck press/crossing/clearance)
69
+ # so the arc re-fits its shape to the anchored chord. 1.0 first = zero-shape-
70
+ # change preference.
71
+
72
+ EPS = 1e-9
73
+
74
+
75
+ # -- basic geometry ----------------------------------------------------
76
+ def norm(vx, vy):
77
+ d = math.hypot(vx, vy)
78
+ return (vx / d, vy / d) if d > EPS else (0.0, 0.0)
79
+
80
+
81
+ def border_point(cx, cy, hw, hh, dx, dy):
82
+ """Intersection of the ray from the box center in direction (dx,dy) with the box border (planned anchor).
83
+
84
+ t_7fe64f08 layered accounting: the routing decision layer keeps the legacy exit geometry (candidate coverage unchanged, WS3 hard cases
85
+ stay stable); the **delivery layer** has Router.midband_reanchor clamp anchors into the edge-midpoint +/- 1/4
86
+ edge-length band (corners excluded, per-edge hard-gate recheck) -- see docs/layout-modes.md for the connection-point accounting.
87
+ """
88
+ if abs(dx) < EPS and abs(dy) < EPS:
89
+ return [cx, cy]
90
+ if abs(dx) < EPS:
91
+ return [cx, cy + (hh if dy > 0 else -hh)]
92
+ if abs(dy) < EPS:
93
+ return [cx + (hw if dx > 0 else -hw), cy]
94
+ t = min(hw / abs(dx), hh / abs(dy))
95
+ return [cx + dx * t, cy + dy * t]
96
+
97
+
98
+ def dist_point_rect(px, py, cx, cy, hw, hh):
99
+ """Minimum distance from a point to a rectangle (0 when inside)."""
100
+ dx = max(abs(px - cx) - hw, 0.0)
101
+ dy = max(abs(py - cy) - hh, 0.0)
102
+ return math.hypot(dx, dy)
103
+
104
+
105
+ def sample_polyline(points, step=SAMPLE_STEP):
106
+ """Densely sampled point sequence of a polyline (including arc discretization)."""
107
+ out = []
108
+ for i in range(len(points) - 1):
109
+ (x1, y1), (x2, y2) = points[i], points[i + 1]
110
+ d = math.hypot(x2 - x1, y2 - y1)
111
+ n = max(1, int(d / step))
112
+ for k in range(n):
113
+ t = k / n
114
+ out.append((x1 + (x2 - x1) * t, y1 + (y2 - y1) * t))
115
+ out.append(tuple(points[-1]))
116
+ return out
117
+
118
+
119
+ def _pt_seg_dist(p, a, b):
120
+ """Distance from a point to a segment (for line-line adherence tests)."""
121
+ vx, vy = b[0] - a[0], b[1] - a[1]
122
+ l2 = vx * vx + vy * vy
123
+ if l2 < EPS:
124
+ return math.hypot(p[0] - a[0], p[1] - a[1])
125
+ t = max(0.0, min(1.0, ((p[0] - a[0]) * vx + (p[1] - a[1]) * vy) / l2))
126
+ return math.hypot(p[0] - a[0] - t * vx, p[1] - a[1] - t * vy)
127
+
128
+
129
+ def span_one_way_segs(samples, segs, step=SAMPLE_STEP, thr=COINCIDE_THR):
130
+ """One-way adherence span (px) of a sampled point sequence against a segment set -- the authoritative line-line visual-coincidence accounting.
131
+
132
+ metrics._coincidence_span (certificate side, takes the max over both directions) delegates to this function;
133
+ on the routing side a candidate vs an already-routed line only needs the src->routed one-way direction (long-span parallel adherence is necessarily <thr both ways,
134
+ and endpoint-meeting momentary contact yields only 1-2 sample steps in either direction, never crossing the 8px exemption gate).
135
+ """
136
+ total = 0.0
137
+ for p in samples:
138
+ dmin = float("inf")
139
+ for s in segs:
140
+ d = _pt_seg_dist(p, s[0], s[1])
141
+ if d < dmin:
142
+ dmin = d
143
+ if dmin < thr:
144
+ break
145
+ if dmin < thr:
146
+ total += step
147
+ return total
148
+
149
+
150
+ def quad_points(p0, ctrl, p1, n=24):
151
+ """Quadratic Bezier discretization (arcs)."""
152
+ pts = []
153
+ for i in range(n + 1):
154
+ t = i / n
155
+ u = 1 - t
156
+ pts.append([u * u * p0[0] + 2 * u * t * ctrl[0] + t * t * p1[0],
157
+ u * u * p0[1] + 2 * u * t * ctrl[1] + t * t * p1[1]])
158
+ return pts
159
+
160
+
161
+ def min_pair_dist(pts_a, pts_b, early_px=1.0):
162
+ """Minimum point distance between two sampled point sequences (mutual-pair separation accounting).
163
+
164
+ Early exit: once a pair < early_px is found, report 0 (coincidence penalty capped, ranking unaffected); deterministic.
165
+ """
166
+ best = float("inf")
167
+ e2 = early_px * early_px
168
+ for ax, ay in pts_a:
169
+ for bx, by in pts_b:
170
+ d2 = (ax - bx) ** 2 + (ay - by) ** 2
171
+ if d2 < best:
172
+ if d2 < e2:
173
+ return 0.0
174
+ best = d2
175
+ return math.sqrt(best) if best < float("inf") else None
176
+
177
+
178
+ def seg_cross(p1, p2, p3, p4):
179
+ """Strict intersection test (includes collinear overlap; endpoint touching does not count). Same accounting as metrics."""
180
+ def orient(a, b, c):
181
+ v = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
182
+ return 0 if abs(v) < EPS else (1 if v > 0 else -1)
183
+ d1, d2 = orient(p3, p4, p1), orient(p3, p4, p2)
184
+ d3, d4 = orient(p1, p2, p3), orient(p1, p2, p4)
185
+ if d1 != d2 and d3 != d4 and d1 != 0 and d3 != 0:
186
+ return True
187
+ if d1 == 0 and d2 == 0 and d3 == 0 and d4 == 0:
188
+ def on(a, b, c):
189
+ return (min(a[0], b[0]) - EPS <= c[0] <= max(a[0], b[0]) + EPS and
190
+ min(a[1], b[1]) - EPS <= c[1] <= max(a[1], b[1]) + EPS)
191
+ if on(p3, p4, p1) or on(p3, p4, p2) or on(p1, p2, p3) or on(p1, p2, p4):
192
+ near = [q for q in (p3, p4) for p in (p1, p2)
193
+ if abs(q[0] - p[0]) < EPS and abs(q[1] - p[1]) < EPS]
194
+ return len(near) < 1
195
+ return False
196
+
197
+
198
+ # -- router ------------------------------------------------------------
199
+ class Router:
200
+ """Plan a route for each edge from the node box positions on the canvas. Deterministic: fixed candidate order + fixed scoring."""
201
+
202
+ def __init__(self, nodes: dict, edges: list, clearance=CLEARANCE, pitch=80,
203
+ quality=False):
204
+ """nodes: {id: (x,y,w,h)} center + size; edges: [(a,b)] directed.
205
+
206
+ quality=False (default): the legacy candidate pool, output byte-for-byte identical to v0.4.x;
207
+ quality=True: WS3-Q routing-quality rework extends the candidate pool (multi-row stair) -- enabled only on
208
+ the gated re-layout path after the layout-layer routing hard gate fails; never enters the normal layout path.
209
+ """
210
+ self.N = {v: (p[0], p[1], p[2] / 2.0, p[3] / 2.0) for v, p in nodes.items()}
211
+ self.edges = list(edges)
212
+ self.cl = clearance
213
+ self.pitch = pitch
214
+ self.quality = quality
215
+ mutual = set()
216
+ for a, b in edges:
217
+ if (b, a) in set(edges) and a < b:
218
+ mutual.add((a, b))
219
+ self.mutual = mutual
220
+ self.eidx = {(a, b): i for i, (a, b) in enumerate(edges)}
221
+ # Routing order: edges spanning more rows are routed first (large spans decide the corridors), the rest by (a,b)
222
+ self.order = sorted(range(len(edges)),
223
+ key=lambda i: (-self._span(edges[i]), edges[i][0], edges[i][1]))
224
+
225
+ def _span(self, e):
226
+ ya, yb = self.N[e[0]][1], self.N[e[1]][1]
227
+ return abs(yb - ya)
228
+
229
+ # -- anchors --
230
+ def _band_clamp_pt(self, v, p):
231
+ """t_abe038c1 anchor accounting: mutual-pair candidate anchors clamped into the edge-midpoint +/- 1/4 band.
232
+
233
+ Only when departing from in-band anchors does the delivery-layer midband_reanchor have nothing out-of-band left to clamp --
234
+ out-of-band anchors, once delivery-clamped, tend to send the arc tangent stabbing into the box and trigger a press fallback (classic-cld
235
+ crowding<->quickfix measured: anchors falling back out-of-band = the root cause of the second-review rejection).
236
+ Called only in the mutual-pair candidate layer; the decision-layer exit geometry of non-mutual edges is unchanged (zero drift).
237
+ """
238
+ cx, cy, hw, hh = self.N[v]
239
+ on_x = abs(abs(p[0] - cx) - hw) <= abs(abs(p[1] - cy) - hh)
240
+ if on_x:
241
+ lim = hh / 2
242
+ return [p[0], cy + max(-lim, min(lim, p[1] - cy))]
243
+ lim = hw / 2
244
+ return [cx + max(-lim, min(lim, p[0] - cx)), p[1]]
245
+
246
+ def _anchors(self, a, b, side):
247
+ """Planned anchors on box borders. side=0 is normal; side=+/-1 separates mutual pairs by tangential offset along the border."""
248
+ ax, ay, ahw, ahh = self.N[a]
249
+ bx, by, bhw, bhh = self.N[b]
250
+ dx, dy = bx - ax, by - ay
251
+ pa = border_point(ax, ay, ahw, ahh, dx, dy)
252
+ pb = border_point(bx, by, bhw, bhh, -dx, -dy)
253
+ if side:
254
+ pa, pb = self._offset_anchors(a, b, pa, pb, side)
255
+ return pa, pb
256
+
257
+ def _offset_anchors(self, a, b, pa, pb, off):
258
+ """Offset anchors by off px along each box's own border tangent (clamped inside the border segment, still border anchors)."""
259
+ out = []
260
+ for v, p, q in ((a, pa, pb), (b, pb, pa)):
261
+ cx, cy, hw, hh = self.N[v]
262
+ on_x = abs(abs(p[0] - cx) - hw) <= abs(abs(p[1] - cy) - hh)
263
+ if on_x: # vertical edge: tangent (0,1)
264
+ t = max(-hh + 3, min(hh - 3, p[1] + off - cy))
265
+ q2 = [p[0], cy + t]
266
+ else: # horizontal edge: tangent (1,0)
267
+ t = max(-hw + 3, min(hw - 3, p[0] + off - cx))
268
+ q2 = [cx + t, p[1]]
269
+ out.append(q2)
270
+ return out
271
+
272
+ def _anchor_variants(self, a, b):
273
+ """Anchor variants: midline anchors + +/-10/+/-20/+/-30 along the border (mutual pairs add opposite +/-12 separation)."""
274
+ base = self._anchors(a, b, 0)
275
+ variants = [base]
276
+ key = (min(a, b), max(a, b))
277
+ if key in self.mutual:
278
+ for s in (1, -1):
279
+ variants.append(self._offset_anchors(a, b, *base, s * 12))
280
+ for off in (10, 20, 30):
281
+ for s in (1, -1):
282
+ variants.append(self._offset_anchors(a, b, *base, s * off))
283
+ return variants
284
+
285
+ def _band_anchor_variants(self, a, b):
286
+ """t_abe038c1 in-band anchor variants (only as the fallback for mutual-pair local re-routing).
287
+
288
+ All variants (midline + +/-12 separation + +/-10/20/+/-30 exploration) are pulled into the edge-midpoint
289
+ +/- 1/4 band via _band_clamp_pt. Not in the regular candidate pool -- the decision-layer legacy exit geometry stays unchanged
290
+ (zero drift on the other 18 graphs); enabled only in the mutual-pair local re-routing after delivery-layer midband_reanchor
291
+ clamping fails -- the classic classic-cld lens double-arc being exactly this case.
292
+ """
293
+ base = self._anchors(a, b, 0)
294
+ variants = [(self._band_clamp_pt(a, base[0]),
295
+ self._band_clamp_pt(b, base[1]))]
296
+ for off in (12, 10, 20, 30):
297
+ for s in (1, -1):
298
+ pa, pb = self._offset_anchors(a, b, *base, s * off)
299
+ variants.append((self._band_clamp_pt(a, pa),
300
+ self._band_clamp_pt(b, pb)))
301
+ return variants
302
+
303
+ # -- candidate generation --
304
+ def _candidates(self, a, b, idx):
305
+ """Fixed-order candidates: straight -> arc (left/right x curvature) -> detour channels, x anchor variants."""
306
+ cands = []
307
+ key = (min(a, b), max(a, b))
308
+ # F1 lens double-arc: both directions of a mutual pair take the same arc side sign (their reference frames are opposite -> geometrically opposite arcs,
309
+ # forming a lens; one arc one arrow per direction, no more double-headed-arrow look)
310
+ mutual_side = 1 if key in self.mutual else None
311
+ for pa, pb in self._anchor_variants(a, b):
312
+ cands.append(("line", [pa, pb], pa, pb))
313
+ vx, vy = pb[0] - pa[0], pb[1] - pa[1]
314
+ dist = math.hypot(vx, vy)
315
+ ux, uy = norm(-vy, vx)
316
+ # t_7fe64f08 arc-curvature parameterization: first tier = chord-adaptive k (small bends for short edges,
317
+ # capped for long ones), the ladder kept as a feasibility fallback; order-preserving dedupe, determinism unchanged
318
+ ks = tuple(dict.fromkeys((adaptive_arc_k(dist),) + ARC_KS))
319
+ for k in ks:
320
+ sides = (mutual_side,) if mutual_side else (1, -1)
321
+ for s in sides:
322
+ ctrl = [(pa[0] + pb[0]) / 2 + ux * k * dist * s,
323
+ (pa[1] + pb[1]) / 2 + uy * k * dist * s]
324
+ cands.append(("arc", quad_points(pa, ctrl, pb), pa, pb, ctrl))
325
+ # WS3 visibility-graph shortest-path candidates (mathematically optimal obstacle avoidance): generated only when the straight line is blocked (deterministic)
326
+ # F1: mutual pairs build vg from several tangentially offset anchor sets x two inflation tiers -- mutually opposite shortest
327
+ # corridors with the same inflation and same anchors hug the same obstacle corner and coincide point-by-point (0.0px), while the outer-ring
328
+ # inflation (+PAIR_VG_OFF) corridor separates from the sibling edge by ~= the offset, preserving a "separated and clearance-compliant" candidate
329
+ base_a, base_b = self._anchors(a, b, 0)
330
+ rects = [n for v, n in self.N.items() if v not in (a, b)]
331
+ if rects:
332
+ c_vg = self.cl + VG_PAD
333
+ anchor_sets = [(base_a, base_b)]
334
+ if key in self.mutual:
335
+ d = 1 if (a, b) == key else -1
336
+ for o in (PAIR_VG_OFF * d, -PAIR_VG_OFF * d):
337
+ anchor_sets.append(self._offset_anchors(a, b, base_a, base_b, o))
338
+ for sa, sb in anchor_sets:
339
+ blocked = any(_vis.seg_rect_distance(sa, sb, rect) < c_vg - 1e-6
340
+ for rect in rects)
341
+ if not blocked:
342
+ continue
343
+ infls = ((c_vg, c_vg + PAIR_VG_OFF) if key in self.mutual
344
+ else (c_vg,))
345
+ for c in infls:
346
+ route = _vis.plan_route(sa, sb, rects, c)
347
+ if route:
348
+ cands.append(("vg", route["dense"], sa, sb, route))
349
+ # Detour channels: horizontal inter-row lanes (base anchors; mutual pairs separate by picking different lanes/inflation rings via the coincidence penalty)
350
+ pa0, pb0 = base_a, base_b
351
+ ax, ay = pa0
352
+ bx, by = pb0
353
+ if abs(by - ay) > 4:
354
+ lane = (ay + by) / 2
355
+ for off in (0, -30, 30, -60, 60, -90, 90):
356
+ mx = (pa0[0] + pb0[0]) / 2 + off
357
+ cands.append(("poly", [pa0, [pa0[0], lane], [mx, lane],
358
+ [pb0[0], lane], pb0], pa0, pb0))
359
+ # Side channels (right/left): fallback for mid-column occlusion in the same column -- anchors switch to orthogonal side entry/exit
360
+ acx, acy, ahw2, ahh2 = self.N[a]
361
+ bcx, bcy, bhw2, bhh2 = self.N[b]
362
+ lo, hi = min(acy, bcy), max(acy, bcy)
363
+ between = [n for v, n in self.N.items() if v not in (a, b) and
364
+ not (n[1] + n[3] < lo or n[1] - n[3] > hi)]
365
+ for s in (1, -1):
366
+ if s > 0:
367
+ x_side = max([n[0] + n[2] for n in between] +
368
+ [acx + ahw2, bcx + bhw2]) + SIDE_MARGIN
369
+ pa_s, pb_s = [acx + ahw2, acy], [bcx + bhw2, bcy]
370
+ else:
371
+ x_side = min([n[0] - n[2] for n in between] +
372
+ [acx - ahw2, bcx - bhw2]) - SIDE_MARGIN
373
+ pa_s, pb_s = [acx - ahw2, acy], [bcx - bhw2, bcy]
374
+ cands.append(("poly", [pa_s, [x_side, acy], [x_side, bcy], pb_s],
375
+ pa_s, pb_s))
376
+ # Out-of-row detour corridor: when same-row neighbors seal the side exits, detour outside via the bottom/top of this row
377
+ acx, acy, ahw2, ahh2 = self.N[a]
378
+ bcx, bcy, bhw2, bhh2 = self.N[b]
379
+ all_bottom = max(n[1] + n[3] for n in self.N.values()) + SIDE_MARGIN
380
+ all_top = min(n[1] - n[3] for n in self.N.values()) - SIDE_MARGIN
381
+ others = [n for v, n in self.N.items() if v not in (a, b)]
382
+ for off in (0, -24, 24):
383
+ if a_below := (acy > bcy):
384
+ ex = acx + max(-(ahw2 - 6), min(ahw2 - 6, off))
385
+ pa_e = [ex, acy + ahh2]
386
+ y_out = all_bottom
387
+ y_mid = bcy + self.pitch / 2
388
+ if y_mid >= y_out - 4:
389
+ continue
390
+ side_opts = []
391
+ for s in (1, -1):
392
+ xs = (min([n[0] - n[2] for n in others] + [ex]) - SIDE_MARGIN if s < 0
393
+ else max([n[0] + n[2] for n in others] + [ex]) + SIDE_MARGIN)
394
+ side_opts.append(xs)
395
+ else:
396
+ ex = acx + max(-(ahw2 - 6), min(ahw2 - 6, off))
397
+ pa_e = [ex, acy - ahh2]
398
+ y_out = all_top
399
+ y_mid = bcy - self.pitch / 2
400
+ if y_mid <= y_out + 4:
401
+ continue
402
+ side_opts = []
403
+ for s in (1, -1):
404
+ xs = (min([n[0] - n[2] for n in others] + [ex]) - SIDE_MARGIN if s < 0
405
+ else max([n[0] + n[2] for n in others] + [ex]) + SIDE_MARGIN)
406
+ side_opts.append(xs)
407
+ for xs in side_opts:
408
+ bx2 = bcx + max(-(bhw2 - 6), min(bhw2 - 6, ex - bcx))
409
+ pb_e = [bx2, bcy - bhh2]
410
+ pts_e = [pa_e, [ex, y_out], [xs, y_out], [xs, y_mid],
411
+ [bx2, y_mid], pb_e]
412
+ cands.append(("poly", pts_e, pa_e, pb_e))
413
+ # WS3-Q routing-quality rework: multi-row S-shape (stair). Single-lane candidates all sit on the midpoint row y;
414
+ # horizontal connectors in the middle rows of dense graphs seal them all off (measured: i04 N02->N03 has cross>=1 on
415
+ # every candidate). The stair runs in the inter-row corridors beside the a/b rows (y = row center +/- pitch/2, 18px from card
416
+ # edges >= the 16px clearance gate); its mid vertical passes outside the side x_s of every card in the middle rows --
417
+ # structurally it never enters any middle-row card band. Fixed variant order: side x
418
+ # a exit (side anchor / top-bottom anchor x lateral offset), deterministic.
419
+ _ca, _cb = self.N[a], self.N[b]
420
+ if self.quality and abs(_cb[1] - _ca[1]) > self.pitch * 1.5:
421
+ a_top = _cb[1] > _ca[1] # b is below a
422
+ band = [n for v, n in self.N.items() if v not in (a, b) and
423
+ (_ca[1] < n[1] < _cb[1] if a_top else _cb[1] < n[1] < _ca[1])]
424
+ y_cor_b = (bcy - self.pitch / 2) if a_top else (bcy + self.pitch / 2)
425
+ y_cor_a = (acy + self.pitch / 2) if a_top else (acy - self.pitch / 2)
426
+ bx3 = bcx + (bhw2 - 6)
427
+ for s in (1, -1):
428
+ if band:
429
+ x_s = (max([n[0] + n[2] for n in band] +
430
+ [acx + ahw2, bcx + bhw2]) + SIDE_MARGIN if s > 0
431
+ else min([n[0] - n[2] for n in band] +
432
+ [acx - ahw2, bcx - bhw2]) - SIDE_MARGIN)
433
+ else:
434
+ x_s = (max(acx + ahw2, bcx + bhw2) + SIDE_MARGIN if s > 0
435
+ else min(acx - ahw2, bcx - bhw2) - SIDE_MARGIN)
436
+ # Shape 1: a exits horizontally from its side -> vertical at x_s -> horizontal in the corridor beside b -> b enters vertically at top/bottom
437
+ pa_s3 = [acx + s * ahw2, acy]
438
+ # a_top (entering b from above) -> enter the top border (bcy-bhh); from below -> enter the bottom border
439
+ pb_s3 = [bx3, bcy + (-bhh2 if a_top else bhh2)]
440
+ cands.append(("poly", [pa_s3, [x_s, acy], [x_s, y_cor_b],
441
+ [bx3, y_cor_b], pb_s3], pa_s3, pb_s3))
442
+ # Shape 2: a exits vertically from a top/bottom anchor -> corridor beside a -> x_s -> corridor beside b -> b enters at top/bottom
443
+ # (fallback when a's same-row neighbors seal the side anchor's horizontal exit; exit lateral offsets are a fixed enumeration)
444
+ for off in (0, ahw2 - 6, -(ahw2 - 6)):
445
+ ex3 = acx + off
446
+ pa_s4 = [ex3, acy + (ahh2 if a_top else -ahh2)]
447
+ pb_s4 = pb_s3
448
+ cands.append(("poly", [pa_s4, [ex3, y_cor_a], [x_s, y_cor_a],
449
+ [x_s, y_cor_b], [bx3, y_cor_b],
450
+ pb_s4], pa_s4, pb_s4))
451
+ # F1 mutual-pair outer detour: the inter-row channel = pitch - card height = 36px (16+16+4); with both lines in the same channel
452
+ # each keeping 16px clearance, the separation ceiling is exactly 4px -- unreliable. When they cannot separate, detour wholesale
453
+ # outside the rows: a exits its box at top/bottom -> global upper/lower corridor -> vertical outside the sides -> b enters
454
+ # its box vertically via a top/bottom lane (both up and down directions are tried -- a's lower exit may be sealed by a box
455
+ # further below; fixed variant order: direction x off x side x outer distance, deterministic).
456
+ if key in self.mutual:
457
+ acx2, acy2, ahw3, ahh3 = self.N[a]
458
+ bcx2, bcy2, bhw3, bhh3 = self.N[b]
459
+ y_below = max(n[1] + n[3] for n in self.N.values()) + SIDE_MARGIN
460
+ y_above = min(n[1] - n[3] for n in self.N.values()) - SIDE_MARGIN
461
+ x_right = max(n[0] + n[2] for n in self.N.values())
462
+ x_left = min(n[0] - n[2] for n in self.N.values())
463
+ for d in ("down", "up"):
464
+ pa_y = acy2 + ahh3 if d == "down" else acy2 - ahh3
465
+ for entry in ("bottom", "top"):
466
+ if entry == "bottom":
467
+ pb_y = bcy2 + bhh3
468
+ else:
469
+ pb_y = bcy2 - bhh3
470
+ for off in (0, ahw3 - 6, -(ahw3 - 6)):
471
+ ex2 = acx2 + off
472
+ for s in (1, -1):
473
+ for pad in (8.0, 20.0):
474
+ # The corridor y and the box-entry lane are staggered by the same pad -- the sibling edge's outer-ring
475
+ # corridor/lane sharing the same baseline would end up collinear segment by segment (sep=0)
476
+ if d == "down":
477
+ y_out2 = y_below + pad
478
+ else:
479
+ y_out2 = y_above - pad
480
+ if entry == "bottom":
481
+ lane2 = bcy2 + bhh3 + 18.0 + pad
482
+ else:
483
+ lane2 = bcy2 - bhh3 - 18.0 - pad
484
+ x_out2 = (x_right + SIDE_MARGIN + pad if s > 0
485
+ else x_left - SIDE_MARGIN - pad)
486
+ bxf = bcx2 + s * (bhw3 - 6)
487
+ pa_w = [ex2, pa_y]
488
+ pb_w = [bxf, pb_y]
489
+ pts_w = [pa_w, [ex2, y_out2], [x_out2, y_out2],
490
+ [x_out2, lane2], [bxf, lane2], pb_w]
491
+ cands.append(("wrap", pts_w, pa_w, pb_w))
492
+ return cands
493
+
494
+ # -- candidate evaluation --
495
+ def _evaluate(self, cand, a, b, routed_segs, sib_samples=None):
496
+ """Return (score, info); info carries press/clearance/crossing/anchor-angle/mutual-separation details.
497
+
498
+ routed_segs: {j: (list of routed segments, list of per-segment bboxes)} (precomputed by route_all).
499
+ F1: when the minimum spacing between a mutual pair (a<->b) and its already-routed sibling edge (b,a) is < PAIR_SEP_MIN,
500
+ apply a heavy linear penalty (the shared-endpoint exemption no longer masks coincidence -- separation is measured directly on sampled point distances).
501
+ """
502
+ kind, pts, pa, pb = cand[0], cand[1], cand[2], cand[3]
503
+ samples = sample_polyline(pts)
504
+ # Delivery-side coordinates are rounded to 0.1px (worst case pushing a point 0.07px closer to a box) -- routing-side
505
+ # checks add the ROUND_GUARD conservative margin to keep 0.0x-level border-hugging candidates from flipping into violations after rounding
506
+ press, clr_def = 0, 0.0
507
+ min_clr = 1e9
508
+ # WS5 performance: lower bound on the distance between the candidate bbox and each node's (PRESS_MARGIN-inflated) bbox --
509
+ # if the lower bound > ROUND_GUARD, press is impossible; if it >= the current min_clr, min_clr cannot be
510
+ # refreshed. When both hold the whole node is skipped (exactly equivalent, changes no value).
511
+ bxl = min(p[0] for p in pts)
512
+ bxh = max(p[0] for p in pts)
513
+ byl = min(p[1] for p in pts)
514
+ byh = max(p[1] for p in pts)
515
+ for v, (cx, cy, hw, hh) in self.N.items():
516
+ if v in (a, b):
517
+ continue
518
+ mhw = hw + PRESS_MARGIN
519
+ mhh = hh + PRESS_MARGIN
520
+ gx = cx - mhw - bxh
521
+ g = bxl - cx - mhw
522
+ if g > gx:
523
+ gx = g
524
+ gy = cy - mhh - byh
525
+ g = byl - cy - mhh
526
+ if g > gy:
527
+ gy = g
528
+ if gx > ROUND_GUARD or gy > ROUND_GUARD:
529
+ lb2 = 0.0
530
+ if gx > 0.0:
531
+ lb2 = gx * gx
532
+ if gy > 0.0:
533
+ lb2 += gy * gy
534
+ if lb2 >= min_clr * min_clr:
535
+ continue # press impossible, min_clr not refreshable
536
+ for (px, py) in samples:
537
+ d = dist_point_rect(px, py, cx, cy, hw + PRESS_MARGIN, hh + PRESS_MARGIN)
538
+ if d <= ROUND_GUARD:
539
+ press += 1
540
+ break
541
+ min_clr = min(min_clr, d)
542
+ clr_def = max(0.0, self.cl - min_clr) if min_clr < 1e9 else 0.0
543
+ # Own box: endpoints must land on the box border; all other sample points must stay out of the box interior (incl. 1px margin)
544
+ for v, endp in ((a, pa), (b, pb)):
545
+ cx, cy, hw, hh = self.N[v]
546
+ if dist_point_rect(endp[0], endp[1], cx, cy, hw, hh) > 0.75:
547
+ return (1e9, None)
548
+ interior = False
549
+ for (px, py) in samples[1:-1]:
550
+ if dist_point_rect(px, py, cx, cy, hw + PRESS_MARGIN, hh + PRESS_MARGIN) <= ROUND_GUARD:
551
+ interior = True
552
+ break
553
+ if interior:
554
+ return (1e9, None)
555
+ # Crossings against already-routed routes (per-segment count cross + per-edge hit accounting cross_edges for the dual-accounting cross-check)
556
+ # F1 performance fidelity: segment pairs go through a bbox-disjoint pre-filter first (disjoint bboxes => no intersection possible, exactly equivalent)
557
+ cross = 0
558
+ hit_edges = 0
559
+ cand_segs = list(zip(pts[:-1], pts[1:]))
560
+ cand_bb = [(min(a[0], b[0]), min(a[1], b[1]),
561
+ max(a[0], b[0]), max(a[1], b[1]))
562
+ for a, b in cand_segs]
563
+ for j, (rsegs, rbb) in routed_segs.items():
564
+ hit = False
565
+ for si, s1 in enumerate(cand_segs):
566
+ x1l, y1l, x1h, y1h = cand_bb[si]
567
+ for k, s2 in enumerate(rsegs):
568
+ x2l, y2l, x2h, y2h = rbb[k]
569
+ if x1l > x2h or x2l > x1h or y1l > y2h or y2l > y1h:
570
+ continue
571
+ if seg_cross(s1[0], s1[1], s2[0], s2[1]):
572
+ cross += 1
573
+ hit = True
574
+ if hit:
575
+ hit_edges += 1
576
+ length = sum(math.dist(pts[i], pts[i + 1]) for i in range(len(pts) - 1))
577
+ kind_pref = 0.0
578
+ # F1: mutual-pair sibling-edge coincidence penalty (sib_samples precomputed by route_all at 12px coarse sampling)
579
+ mutual_pair = (min(a, b), max(a, b)) in self.mutual
580
+ pair_sep = None
581
+ sep_def = 0.0
582
+ if mutual_pair and sib_samples:
583
+ pair_sep = min_pair_dist(sample_polyline(pts, step=12), sib_samples)
584
+ if pair_sep is not None:
585
+ sep_def = max(0.0, PAIR_SEP_MIN - pair_sep)
586
+ if mutual_pair and kind == "arc":
587
+ kind_pref = -2.0 # slight preference for the lens double-arc (avoids double straight lines winning on separation ties)
588
+ # t_c6ab760d line-style hierarchy: arc > straight > polyline -- vg obstacle-avoidance bends and poly lane
589
+ # polylines carry a fixed type cost, so when straight/arc are feasible they no longer compete with polylines on score (polyline = last resort)
590
+ if kind == "vg":
591
+ kind_pref += 120.0
592
+ if kind == "poly":
593
+ kind_pref += 40.0
594
+ if kind == "wrap":
595
+ # Fixed cost for the outer detour: only worth it to fix coincidence (600*gap), does not compete with short candidates on score
596
+ kind_pref += 200.0
597
+ if mutual_pair:
598
+ # F1 tightened clearance: separation candidates must not sacrifice the edge-box clearance gate (total weight 150*gap)
599
+ clr_def *= 5.0
600
+ score = (4000 * press + 40 * cross + 30.0 * clr_def + 0.01 * length +
601
+ PAIR_SEP_W * sep_def + kind_pref)
602
+ # Anchor smoothness: angle between the departure/entry direction and the box-border tangent
603
+ ang_a = self._anchor_angle(a, pa, pts[0], pts[1])
604
+ ang_b = self._anchor_angle(b, pb, pts[-1], pts[-2])
605
+ ang_def = max(0.0, ANCHOR_MIN_DEG - ang_a) + max(0.0, ANCHOR_MIN_DEG - ang_b)
606
+ score += 2.0 * ang_def
607
+ if ang_def > 0.5:
608
+ score += 1500.0 # soft rejection of tangent-anchor exits: not chosen when a smooth candidate exists (the hard gate preserves smoothness)
609
+ info = {"kind": kind, "points": pts, "press": press,
610
+ "min_clear": round(min_clr, 1) if min_clr < 1e9 else None,
611
+ "cross": cross, "cross_edges": hit_edges,
612
+ "ang_a": ang_a, "ang_b": ang_b,
613
+ "pair_sep": round(pair_sep, 1) if pair_sep is not None else None,
614
+ "sep_def": round(sep_def, 2)}
615
+ if kind == "arc":
616
+ info["ctrl"] = cand[4]
617
+ if kind == "vg":
618
+ info["vg_corners"] = [list(p) for p in cand[4]["corner_pts"]]
619
+ info["vg_fillets"] = cand[4]["fillets"]
620
+ return (score, info)
621
+
622
+ def _anchor_angle(self, v, anchor, p_in, p_out):
623
+ """Angle (degrees) between the route direction at the anchor and the box-border tangent."""
624
+ cx, cy, hw, hh = self.N[v]
625
+ dx, dy = norm(p_out[0] - p_in[0], p_out[1] - p_in[1])
626
+ on_x = abs(abs(anchor[0] - cx) - hw) < abs(abs(anchor[1] - cy) - hh)
627
+ # Box-border tangent: vertical edge -> (0,1); horizontal edge -> (1,0)
628
+ tx, ty = (0.0, 1.0) if on_x else (1.0, 0.0)
629
+ c = abs(dx * tx + dy * ty)
630
+ return math.degrees(math.acos(max(0.0, min(1.0, c))))
631
+
632
+ # -- WS3 dual accounting: theoretical accounting --
633
+ def final_crossing_pairs(self, out):
634
+ """Number of crossing edge pairs among the final routes (unrounded float geometry).
635
+
636
+ The per-edge counts taken during evaluation include stale crossings against old route snapshots and cannot serve as the
637
+ theoretical accounting; this method recomputes all pairs over the final routes after route_all finishes (strict intersection, shared endpoints excluded).
638
+ """
639
+ items = sorted(out.items())
640
+ total = 0
641
+ for a in range(len(items)):
642
+ ia, info_a = items[a]
643
+ ea = self.edges[ia]
644
+ for b in range(a + 1, len(items)):
645
+ ib, info_b = items[b]
646
+ eb = self.edges[ib]
647
+ if ea[0] in eb or ea[1] in eb:
648
+ continue
649
+ pa, pb = info_a["points"], info_b["points"]
650
+ hit = False
651
+ for s1 in zip(pa[:-1], pa[1:]):
652
+ for s2 in zip(pb[:-1], pb[1:]):
653
+ if seg_cross(s1[0], s1[1], s2[0], s2[1]):
654
+ hit = True
655
+ break
656
+ if hit:
657
+ break
658
+ if hit:
659
+ total += 1
660
+ return total
661
+
662
+ # -- WS3-Q routing-quality rework: joint re-routing of crossing pairs --
663
+ def _poly_cross(self, pa, pb):
664
+ """Whether two polylines strictly intersect (the shared-endpoint exemption is handled by the caller)."""
665
+ for s1 in zip(pa[:-1], pa[1:]):
666
+ for s2 in zip(pb[:-1], pb[1:]):
667
+ if seg_cross(s1[0], s1[1], s2[0], s2[1]):
668
+ return True
669
+ return False
670
+
671
+ def joint_reroute(self, out, max_sweeps=3, top_k=25):
672
+ """Joint re-routing of crossing pairs: breaks the "each is the other's optimum" deadlock of pass2 coordinate descent.
673
+
674
+ For each currently crossing independent edge pair (i,j): first, against "the other routed lines (excluding i/j)",
675
+ pre-evaluate all candidates of both sides separately, take each side's top top_k by (crossing count, score), then
676
+ evaluate the combinations jointly and exactly (i vs rest+cj, j vs rest+ci); the whole pair is replaced only
677
+ if the total score strictly decreases. Fixed traversal order + fixed truncation, no RNG, same graph yields same output.
678
+ """
679
+ idx = sorted(out.keys())
680
+ for _ in range(max_sweeps):
681
+ changed = False
682
+ for ii in range(len(idx)):
683
+ i = idx[ii]
684
+ for jj in range(ii + 1, len(idx)):
685
+ j = idx[jj]
686
+ ea, eb = self.edges[i], self.edges[j]
687
+ if ea[0] in eb or ea[1] in eb:
688
+ continue
689
+ pi, pj = out[i]["points"], out[j]["points"]
690
+ if not self._poly_cross(pi, pj):
691
+ continue
692
+
693
+ def base_segs():
694
+ d = {}
695
+ for k in idx:
696
+ if k in (i, j):
697
+ continue
698
+ pts = out[k]["points"]
699
+ segs = list(zip(pts[:-1], pts[1:]))
700
+ bb = [(min(p[0], q[0]), min(p[1], q[1]),
701
+ max(p[0], q[0]), max(p[1], q[1]))
702
+ for p, q in segs]
703
+ d[k] = (segs, bb)
704
+ return d
705
+
706
+ a, b = self.edges[i]
707
+ base = base_segs()
708
+ li = []
709
+ for cand in self._candidates(ea[0], ea[1], i):
710
+ sc, info = self._evaluate(cand, ea[0], ea[1], base, None)
711
+ if info is not None:
712
+ li.append((info["cross"], sc, cand, info))
713
+ lj = []
714
+ c, d = self.edges[j]
715
+ for cand in self._candidates(c, d, j):
716
+ sc, info = self._evaluate(cand, c, d, base, None)
717
+ if info is not None:
718
+ lj.append((info["cross"], sc, cand, info))
719
+ if not li or not lj:
720
+ continue
721
+ li.sort(key=lambda t: (t[0], t[1]))
722
+ lj.sort(key=lambda t: (t[0], t[1]))
723
+ best = None
724
+ for ci in li[:top_k]:
725
+ segs_j = list(zip(ci[3]["points"][:-1], ci[3]["points"][1:]))
726
+ bb_j = [(min(p[0], q[0]), min(p[1], q[1]),
727
+ max(p[0], q[0]), max(p[1], q[1])) for p, q in segs_j]
728
+ base_ij = dict(base)
729
+ base_ij[j] = (segs_j, bb_j)
730
+ for cj in lj[:top_k]:
731
+ segs_i = list(zip(cj[3]["points"][:-1], cj[3]["points"][1:]))
732
+ bb_i = [(min(p[0], q[0]), min(p[1], q[1]),
733
+ max(p[0], q[0]), max(p[1], q[1])) for p, q in segs_i]
734
+ base_ji = dict(base)
735
+ base_ji[i] = (segs_i, bb_i)
736
+ _, info_i = self._evaluate(ci[2], ea[0], ea[1],
737
+ base_ij, None)
738
+ _, info_j = self._evaluate(cj[2], c, d,
739
+ base_ji, None)
740
+ if info_i is None or info_j is None:
741
+ continue
742
+ tot = (self._joint_score(info_i) +
743
+ self._joint_score(info_j))
744
+ if best is None or tot < best[0]:
745
+ best = (tot, info_i, info_j)
746
+ if best is None:
747
+ continue
748
+ cur_tot = (self._joint_score(out[i]) +
749
+ self._joint_score(out[j]))
750
+ if best[0] < cur_tot - 1e-9:
751
+ out[i], out[j] = best[1], best[2]
752
+ changed = True
753
+ if not changed:
754
+ break
755
+ return out
756
+
757
+ def _joint_score(self, info):
758
+ """Score for joint evaluation (same weight accounting as _evaluate + the same anchor soft-rejection penalty)."""
759
+ length = sum(math.dist(info["points"][k], info["points"][k + 1])
760
+ for k in range(len(info["points"]) - 1))
761
+ clr = info.get("min_clear")
762
+ clr_pen = (30.0 * max(0.0, self.cl - clr) if clr is not None
763
+ else 30.0 * self.cl)
764
+ ang_pen = (1500.0 if min(info.get("ang_a", ANCHOR_MIN_DEG),
765
+ info.get("ang_b", ANCHOR_MIN_DEG)) < ANCHOR_MIN_DEG
766
+ else 0.0)
767
+ return (4000 * info.get("press", 0) + 40 * info.get("cross", 0) +
768
+ clr_pen + ang_pen + 0.01 * length)
769
+
770
+ # -- Line-line visual-coincidence targeted re-routing (t_fce6f438) --
771
+ def _pair_coincide_span(self, pts_a, pts_b):
772
+ """Adherence span (px) between two polylines, max over both directions -- same source as the certificate's _coincidence_span."""
773
+ sa = sample_polyline(pts_a)
774
+ sb = sample_polyline(pts_b)
775
+ return max(span_one_way_segs(sa, list(zip(sb[:-1], sb[1:]))),
776
+ span_one_way_segs(sb, list(zip(sa[:-1], sa[1:]))))
777
+
778
+ def _route_coincide_pairs(self, routes):
779
+ """Edge pairs among the final routes whose adherence span exceeds COINCIDE_SPAN (deterministic ascending order)."""
780
+ idx = sorted(routes.keys())
781
+ out = []
782
+ for a in range(len(idx)):
783
+ for b in range(a + 1, len(idx)):
784
+ ia, ib = idx[a], idx[b]
785
+ span = self._pair_coincide_span(routes[ia]["points"],
786
+ routes[ib]["points"])
787
+ if span > COINCIDE_SPAN:
788
+ out.append((ia, ib, span))
789
+ return out
790
+
791
+ def _reroute_candidates(self, a, b, i):
792
+ """Re-routing candidate pool: the legacy pool + anchor-variant vg separation variants (t_fce6f438).
793
+
794
+ Generalizes the F1 mutual-pair separation mechanism: fan-in/out sibling edges with the same inflation and same anchors have shortest
795
+ paths hugging the same obstacle corner and coinciding point-by-point -- enumerate `_anchor_variants` border anchor positions x
796
+ tangential offsets of +/-PAIR_VG_OFF to plan vg (anchors stay on the box border, so the check13 render/verification contract is
797
+ unchanged, and own-box cut-ins are naturally rejected by _evaluate's endpoint/interior checks); corridors passing different
798
+ obstacle corners separate naturally with the clearance gate still holding. Enabled only in this channel (coincidence targeted
799
+ re-routing); the baseline candidate pool is untouched. Deterministic enumeration.
800
+ """
801
+ cands = list(self._candidates(a, b, i))
802
+ rects = [n for v, n in self.N.items() if v not in (a, b)]
803
+ if rects:
804
+ c_vg = self.cl + VG_PAD
805
+ for pa, pb in self._anchor_variants(a, b):
806
+ for off in (0, PAIR_VG_OFF, -PAIR_VG_OFF):
807
+ sa, sb = ((pa, pb) if not off
808
+ else self._offset_anchors(a, b, pa, pb, off))
809
+ route = _vis.plan_route(sa, sb, rects, c_vg)
810
+ if route:
811
+ cands.append(("vg", route["dense"], sa, sb, route))
812
+ return cands
813
+
814
+ def coincide_reroute(self, routes, frozen=(), max_sweeps=3):
815
+ """Targeted re-routing of edges counted as line-line visual coincidence (reduce adherence spans without regressing any hard gate).
816
+
817
+ Only edges involved in coincidence pairs are touched (frozen: edges anchored by constructed routes / user waypoints stay put;
818
+ mutual pairs go through their dedicated mechanism and are untouched here too). A replacement must satisfy all of: press=0,
819
+ clearance >= clearance, anchor smoothness, no increase in crossing-edge count against the other routed lines, and a strictly
820
+ decreasing adherence span against every other edge (leaving no new adherence pairs). Fixed traversal order + fixed candidate order, no RNG.
821
+ Graphs with zero baseline coincidence (the whole classic domain) have no affected edges -- output byte-for-byte unchanged.
822
+ """
823
+ for _ in range(max_sweeps):
824
+ pairs = self._route_coincide_pairs(routes)
825
+ if not pairs:
826
+ break
827
+ affected = sorted({k for i, j, _s in pairs for k in (i, j)
828
+ if k not in frozen})
829
+ changed = False
830
+ for i in affected:
831
+ a, b = self.edges[i]
832
+ cur = routes[i]
833
+ cands = self._reroute_candidates(a, b, i)
834
+ base = {} # j -> (segs, per-seg bb) (reused for crossing checks)
835
+ base_pts = {} # j -> polyline point sequence
836
+ base_pbox = {} # j -> whole-polyline bbox (adherence pre-filter)
837
+ for j, info in routes.items():
838
+ if j == i:
839
+ continue
840
+ pts_j = info["points"]
841
+ segs = list(zip(pts_j[:-1], pts_j[1:]))
842
+ bb = [(min(p[0], q[0]), min(p[1], q[1]),
843
+ max(p[0], q[0]), max(p[1], q[1])) for p, q in segs]
844
+ base[j] = (segs, bb)
845
+ base_pts[j] = pts_j
846
+ base_pbox[j] = (min(x[0] for x in bb), min(x[1] for x in bb),
847
+ max(x[2] for x in bb), max(x[3] for x in bb))
848
+
849
+ def span_vs_routes(samples):
850
+ total = 0.0
851
+ for j, (jl, jt, jh, jb) in base_pbox.items():
852
+ if (jl - samples["_h"][0] > COINCIDE_THR or
853
+ samples["_l"][0] - jh > COINCIDE_THR or
854
+ jt - samples["_h"][1] > COINCIDE_THR or
855
+ samples["_l"][1] - jb > COINCIDE_THR):
856
+ continue
857
+ total = max(total, span_one_way_segs(
858
+ samples["pts"], base[j][0]))
859
+ return total
860
+
861
+ cur_samples = sample_polyline(cur["points"])
862
+ cur_s = {"pts": cur_samples,
863
+ "_l": (min(p[0] for p in cur_samples),
864
+ min(p[1] for p in cur_samples)),
865
+ "_h": (max(p[0] for p in cur_samples),
866
+ max(p[1] for p in cur_samples))}
867
+ cur_span = span_vs_routes(cur_s)
868
+ cur_cross = sum(1 for pts_j in base_pts.values()
869
+ if self._poly_cross(cur["points"], pts_j))
870
+ best = None
871
+ for cand in cands:
872
+ sc, info = self._evaluate(cand, a, b, base, None)
873
+ if info is None or info.get("press", 0):
874
+ continue
875
+ mc = info.get("min_clear")
876
+ if mc is not None and mc < self.cl - 1e-6:
877
+ continue # hard gate: edge-box clearance
878
+ if min(info.get("ang_a", 0.0), info.get("ang_b", 0.0)) \
879
+ < ANCHOR_MIN_DEG:
880
+ continue # hard gate: anchor smoothness
881
+ if (min(a, b), max(a, b)) in self.mutual:
882
+ continue # mutual pairs go through their dedicated mechanism
883
+ pts_n = info["points"]
884
+ s_n = sample_polyline(pts_n)
885
+ s_ns = {"pts": s_n,
886
+ "_l": (min(p[0] for p in s_n),
887
+ min(p[1] for p in s_n)),
888
+ "_h": (max(p[0] for p in s_n),
889
+ max(p[1] for p in s_n))}
890
+ span = span_vs_routes(s_ns)
891
+ cross = sum(1 for pts_j in base_pts.values()
892
+ if self._poly_cross(pts_n, pts_j))
893
+ if cross > cur_cross:
894
+ continue # crossing count must not increase
895
+ key = (span, cross, sc)
896
+ if best is None or key < best[0]:
897
+ best = (key, info)
898
+ if best is not None and best[0][0] < cur_span - 1e-9:
899
+ routes[i] = best[1]
900
+ changed = True
901
+ if not changed:
902
+ break
903
+ return routes
904
+
905
+ def _pair_cross_vs_base(self, pts, base):
906
+ """(kept for diagnostics) Crossing-edge count of one edge against a snapshot of the other routed lines."""
907
+ n = 0
908
+ for k, (segs, bb) in base.items():
909
+ hit = False
910
+ for s1 in zip(pts[:-1], pts[1:]):
911
+ for s2 in segs:
912
+ if seg_cross(s1[0], s1[1], s2[0], s2[1]):
913
+ hit = True
914
+ break
915
+ if hit:
916
+ break
917
+ if hit:
918
+ n += 1
919
+ return n
920
+
921
+ # -- Uniform spread of multiple anchors on the same node side (t_7fe64f08 connection-point accounting rule 3) --
922
+ def _side_of(self, v, p):
923
+ """Which border (r/l/b/t) of node v's box anchor p sits on."""
924
+ cx, cy, hw, hh = self.N[v]
925
+ on_x = abs(abs(p[0] - cx) - hw) <= abs(abs(p[1] - cy) - hh)
926
+ if on_x:
927
+ return "r" if p[0] > cx else "l"
928
+ return "b" if p[1] > cy else "t"
929
+
930
+ def _edge_stats(self, i, routes):
931
+ """Hard-gate summary of edge i's current route: (press, min_clear, ang_min, cross, mutual_sep)."""
932
+ a, b = self.edges[i]
933
+ pts = routes[i]["points"]
934
+ samples = sample_polyline(pts)
935
+ press, min_clr = 0, None
936
+ for v, (cx, cy, hw, hh) in self.N.items():
937
+ if v in (a, b):
938
+ continue
939
+ for (px, py) in samples:
940
+ d = dist_point_rect(px, py, cx, cy, hw + PRESS_MARGIN,
941
+ hh + PRESS_MARGIN)
942
+ if d <= ROUND_GUARD:
943
+ press += 1
944
+ break
945
+ min_clr = d if min_clr is None else min(min_clr, d)
946
+ ang_min = min(self._anchor_angle(a, pts[0], pts[0], pts[1]),
947
+ self._anchor_angle(b, pts[-1], pts[-1], pts[-2]))
948
+ cross = 0
949
+ for j, o in routes.items():
950
+ if j == i or self.edges[j][0] in self.edges[i] or \
951
+ self.edges[j][1] in self.edges[i]:
952
+ continue
953
+ if self._poly_cross(pts, o["points"]):
954
+ cross += 1
955
+ msep = None
956
+ j = self.eidx.get((b, a))
957
+ if j is not None and j in routes and j != i:
958
+ msep = min_pair_dist(sample_polyline(pts, step=4),
959
+ sample_polyline(routes[j]["points"], step=4))
960
+ return (press, min_clr, ang_min, cross, msep)
961
+
962
+ def fan_spread(self, routes, frozen=()):
963
+ """Same node side with >= 2 anchors: sort by the other endpoint's azimuth and spread symmetrically on both sides of the midpoint along the border tangent.
964
+
965
+ 2026-11-06 connection-point accounting hard rule 3 (jacky): multiple outgoing lines on the same node must not pile up at the
966
+ same border midpoint -- spread uniformly with step FAN_STEP, clamped inside the border at +/-(half-3).
967
+ 2026-11-09 t_5d9aaa9f (jacky): slot ORDER is azimuth-monotone -- on t/b borders by the other box center's horizontal
968
+ offset, on r/l borders by its vertical offset -- so anchor order along the border always matches the targets'
969
+ left/right (top/bottom) positions (the former incidence-angle key could invert neighbours and produce a
970
+ visible X crossing, classic-cld adopters box). After spreading, every
971
+ edge rechecks the hard gates (press/clearance/anchor angle/no crossing increase/mutual separation >= 4px); on any violation
972
+ the whole node falls back to border-midpoint anchors (deterministic: fixed traversal order + fixed step, no RNG).
973
+ frozen edges (constructed routes / user waypoints) are untouched.
974
+
975
+ t_d3844f8a: frozen edges' anchors on the same node side OCCUPY their
976
+ position. A fan whose middle slot lands on a frozen anchor produced a
977
+ V-shaped co-point at the border midpoint (classic-cld: the frozen spine
978
+ rate->adopters leaves rate's top-border midpoint while the 3-slot fan
979
+ held word->rate on the same midpoint; same pattern at adopters' bottom).
980
+ On conflict the whole fan gives way: deterministic candidate shifts
981
+ (+FAN_STEP/2 preferred, then -FAN_STEP/2, then whole steps), each
982
+ candidate band-scaled as a whole (slot order and equal spacing
983
+ preserved) and rechecked against the same hard gates; a candidate that
984
+ still lands within MIN_FROZEN_ANCHOR_SEP of a frozen anchor is skipped.
985
+ No frozen anchors / no conflict -> byte-for-byte the previous behavior.
986
+ """
987
+ # t_d3844f8a: index the frozen edges' anchors by (node, side) so the
988
+ # fan slot assignment below can treat them as occupied positions.
989
+ # Stored as (anchor point, other-endpoint node id) so the group loop
990
+ # can also check azimuth monotonicity across the frozen anchors.
991
+ frozen_side = {}
992
+ for i2 in sorted(routes.keys()):
993
+ if i2 not in frozen:
994
+ continue
995
+ pts2 = routes[i2]["points"]
996
+ a2, b2 = self.edges[i2]
997
+ for end2, p2 in (("a", pts2[0]), ("b", pts2[-1])):
998
+ v2 = a2 if end2 == "a" else b2
999
+ other2 = b2 if end2 == "a" else a2
1000
+ frozen_side.setdefault((v2, self._side_of(v2, p2)),
1001
+ []).append((tuple(p2), other2))
1002
+ # t_94a585da: arc-kind routes are NOT slot-slidable. Sliding only the
1003
+ # anchor endpoint of a smooth Q-arc leaves the curve (and its ctrl
1004
+ # point) shaped for the OLD anchor -- measured on classic-cld
1005
+ # crowding<->fundamental: the anchor slid 519.3->508.0 while the arc
1006
+ # body stayed, departure skew 5.3->33.4 deg, and the mutual pair's
1007
+ # two lines converged 12.0->7.3px (the lens "twisted arrow" defect).
1008
+ # Arc anchors therefore OCCUPY their position (same accounting as
1009
+ # frozen anchors below: MIN_FROZEN_ANCHOR_SEP clearance + merged
1010
+ # azimuth monotonicity) and the fan spreads only the
1011
+ # implicitly-reshaping routes (straight lines) around them.
1012
+ for i2 in sorted(routes.keys()):
1013
+ if i2 in frozen or routes[i2].get("kind") != "arc":
1014
+ continue
1015
+ pts2 = routes[i2]["points"]
1016
+ a2, b2 = self.edges[i2]
1017
+ for end2, p2 in (("a", pts2[0]), ("b", pts2[-1])):
1018
+ v2 = a2 if end2 == "a" else b2
1019
+ other2 = b2 if end2 == "a" else a2
1020
+ frozen_side.setdefault((v2, self._side_of(v2, p2)),
1021
+ []).append((tuple(p2), other2))
1022
+ groups = {}
1023
+ for i in sorted(routes.keys()):
1024
+ if i in frozen or routes[i].get("kind") == "arc":
1025
+ continue
1026
+ pts = routes[i]["points"]
1027
+ a, b = self.edges[i]
1028
+ for end, p in (("a", pts[0]), ("b", pts[-1])):
1029
+ v = a if end == "a" else b
1030
+ groups.setdefault((v, self._side_of(v, p)), []).append(
1031
+ (i, end, tuple(p)))
1032
+ for (v, side), entries in sorted(groups.items()):
1033
+ # t_0dedbfab: single-entry groups join when frozen/arc anchors
1034
+ # occupy the same (node, side) -- the occupied-slot accounting
1035
+ # (clearance >= MIN_FROZEN_ANCHOR_SEP + merged azimuth
1036
+ # monotonicity) must also cover the 1-slidable + N-frozen case
1037
+ # (renewable-resource harvest box: the frozen lens arc and the
1038
+ # detour poly both landed on the border midpoint -> the poly's
1039
+ # final segment ran into the arc's arrival, a T-junction the
1040
+ # shared-endpoint exemption hides from the old caliber). Groups
1041
+ # with no frozen anchors on their side keep the >= 2 rule.
1042
+ if len(entries) < 2 and not frozen_side.get((v, side)):
1043
+ continue
1044
+ cx, cy, hw, hh = self.N[v]
1045
+ vertical = side in ("r", "l")
1046
+ half_len = (hh if vertical else hw) - 3
1047
+
1048
+ def _azi_key(ent):
1049
+ # t_5d9aaa9f: slot order must be monotone with the other
1050
+ # endpoint's azimuth (not the route segment's incidence
1051
+ # angle): on a horizontal border (t/b) sort by the other
1052
+ # box center's horizontal offset, on a vertical border
1053
+ # (r/l) by its vertical offset, ascending = left-to-right /
1054
+ # top-to-bottom slot order. The incidence-angle key could
1055
+ # invert left/right neighbours (classic-cld adopters box:
1056
+ # word vs potential anchors swapped -> visible X crossing
1057
+ # under the box). Tie-break by edge index (deterministic).
1058
+ i2, end, _p = ent
1059
+ a2, b2 = self.edges[i2]
1060
+ ox, oy, _ow, _oh = self.N[b2 if end == "a" else a2]
1061
+ # t_0dedbfab: entry-side (target-end) tie-break -- when two
1062
+ # fan members' other-box azimuths tie (both boxes centered on
1063
+ # the border axis), the box-center key cannot order them and
1064
+ # the index tie-break inverted neighbours whose ROUTES
1065
+ # approach from opposite sides (population-logistic births
1066
+ # box: the detour poly arrives from the right, the direct
1067
+ # line from straight above -> visible X). Secondary key = the
1068
+ # approach point's projection (the polyline point just before
1069
+ # the anchor): for straight lines this is the other border
1070
+ # anchor (azimuth-consistent), for detour routes it is the
1071
+ # last via -- the direction the line actually comes from.
1072
+ pts2 = routes[i2]["points"]
1073
+ ap = pts2[1] if end == "a" else pts2[-2]
1074
+ proj = (ap[1] - cy) if vertical else (ap[0] - cx)
1075
+ return ((oy - cy) if vertical else (ox - cx)), proj, i2
1076
+ ents = sorted(entries, key=_azi_key)
1077
+ k = len(ents)
1078
+ # t_5d9aaa9f: keep the fan inside the t_7fe64f08 mid-band
1079
+ # (+/- 1/4 of the owning box dimension, the CI in-band gate):
1080
+ # when the fan half-span exceeds the band, scale the whole fan
1081
+ # uniformly into it (monotone slot order and equal spacing are
1082
+ # preserved, no stacking; deterministic).
1083
+ band = (hh if vertical else hw) / 2.0
1084
+ # t_d3844f8a: frozen anchors on this same node side occupy their
1085
+ # tangent offset; the fan must not land on them (V co-point).
1086
+ frozen_offs = []
1087
+ for _pf, _of in frozen_side.get((v, side), ()):
1088
+ frozen_offs.append((_pf[1] - cy) if vertical
1089
+ else (_pf[0] - cx))
1090
+ _frozen_az = []
1091
+ for _pf, _of in frozen_side.get((v, side), ()):
1092
+ ox2, oy2, _ow2, _oh2 = self.N[_of]
1093
+ _frozen_az.append(((oy2 - cy) if vertical else (ox2 - cx),
1094
+ (_pf[1] - cy) if vertical
1095
+ else (_pf[0] - cx)))
1096
+
1097
+ # its slot base is the CURRENT anchor position (not the border
1098
+ # midpoint): with no frozen conflict it must reproduce the
1099
+ # incoming position byte-for-byte (offs == [base] restores it),
1100
+ # and on conflict the candidate ladder shifts it off the frozen
1101
+ # anchor while staying clamped inside the border.
1102
+ k1_base = 0.0
1103
+ if k == 1:
1104
+ _p0 = ents[0][2]
1105
+ k1_base = (_p0[1] - cy) if vertical else (_p0[0] - cx)
1106
+
1107
+ def _offs_for(shift):
1108
+ if k == 1:
1109
+ return [max(-half_len, min(half_len, k1_base + shift))]
1110
+ raw = [(t2 - (k - 1) / 2.0) * FAN_STEP + shift
1111
+ for t2 in range(k)]
1112
+ half_span = max(abs(o) for o in raw)
1113
+ if half_span > band:
1114
+ raw = [o * band / half_span for o in raw]
1115
+ return raw
1116
+
1117
+ def _frozen_conflict(offs):
1118
+ # (a) clearance: no slot within MIN_FROZEN_ANCHOR_SEP of a
1119
+ # frozen anchor (kills the V co-point);
1120
+ # (b) azimuth monotonicity ACROSS the frozen anchors (the
1121
+ # t_5d9aaa9f rule extended): slots that would move to the
1122
+ # opposite side of a frozen anchor from their target box
1123
+ # produce a line crossing that edge (its shared-endpoint
1124
+ # exemption hides it from _edge_stats), so merged
1125
+ # (position, other-box azimuth) must stay monotone.
1126
+ for o in offs:
1127
+ for f in frozen_offs:
1128
+ if abs(o - f) < MIN_FROZEN_ANCHOR_SEP:
1129
+ return True
1130
+ merged = [(o, az) for o, az in
1131
+ zip(offs, [(_azi_key(e)[0]) for e in ents])]
1132
+ merged += [(f, az) for az, f in _frozen_az]
1133
+ merged.sort()
1134
+ for m1, m2 in zip(merged, merged[1:]):
1135
+ if m2[1] < m1[1] - 1e-9:
1136
+ return True
1137
+ return False
1138
+
1139
+ # t_0dedbfab: a single-slidable-entry fan is give-way-only --
1140
+ # and with NO frozen conflict at the current slot it is skipped
1141
+ # entirely: rewriting the anchor through offs == [base] would
1142
+ # flip the coordinate's int/float JSON face (limits-to-growth
1143
+ # word->rate measured: geometry identical, layout.json bytes
1144
+ # drifted). Only a real conflict enters the candidate ladder.
1145
+ if (k == 1
1146
+ and not (frozen_offs
1147
+ and _frozen_conflict(_offs_for(0.0)))):
1148
+ continue
1149
+ # candidate give-way shifts, deterministic: centered first, then
1150
+ # half-steps (right preferred), then whole steps. Shift 0 with no
1151
+ # frozen anchors / no conflict reproduces the previous offs
1152
+ # exactly (byte-for-byte zero drift for unaffected figures).
1153
+ cand_shifts = [0.0]
1154
+ if frozen_offs and _frozen_conflict(_offs_for(0.0)):
1155
+ cand_shifts += [FAN_STEP / 2.0, -FAN_STEP / 2.0,
1156
+ FAN_STEP, -FAN_STEP,
1157
+ 1.5 * FAN_STEP, -1.5 * FAN_STEP]
1158
+ before = {i2: [list(p) for p in routes[i2]["points"]]
1159
+ for i2, _e, _p in ents}
1160
+ stats_before = {i2: self._edge_stats(i2, routes)
1161
+ for i2, _e, _p in ents}
1162
+ ok = False
1163
+ for shift in cand_shifts:
1164
+ offs = _offs_for(shift)
1165
+ if _frozen_conflict(offs):
1166
+ continue
1167
+ for i2, pts_b in before.items():
1168
+ routes[i2]["points"] = [list(p) for p in pts_b]
1169
+ for (i2, end, _p), off in zip(ents, offs):
1170
+ pts2 = routes[i2]["points"]
1171
+ idx = 0 if end == "a" else len(pts2) - 1
1172
+ if vertical:
1173
+ pts2[idx] = [pts2[idx][0],
1174
+ max(cy - half_len, min(cy + half_len,
1175
+ cy + off))]
1176
+ else:
1177
+ pts2[idx] = [max(cx - half_len, min(cx + half_len,
1178
+ cx + off)),
1179
+ pts2[idx][1]]
1180
+ ok = True
1181
+ for i2, _e, _p in ents:
1182
+ pr, clr, ang, cross, msep = self._edge_stats(i2, routes)
1183
+ pr0, clr0, ang0, cross0, msep0 = stats_before[i2]
1184
+ if pr > pr0 or cross > cross0:
1185
+ ok = False
1186
+ if clr is not None and clr < self.cl - 1e-6:
1187
+ ok = False
1188
+ if ang < ANCHOR_MIN_DEG:
1189
+ ok = False
1190
+ if msep is not None and msep < 4.0:
1191
+ ok = False
1192
+ if not ok:
1193
+ break
1194
+ if ok:
1195
+ break
1196
+ if not ok:
1197
+ for i2, pts_b in before.items():
1198
+ routes[i2]["points"] = pts_b
1199
+ return routes
1200
+
1201
+ def _pt_in_band(self, v, p):
1202
+ """Whether anchor p is already inside the border's +/- 1/4 band (and on the border)."""
1203
+ q = self._band_clamp_pt(v, p)
1204
+ return abs(q[0] - p[0]) <= 1e-6 and abs(q[1] - p[1]) <= 1e-6
1205
+
1206
+ def _own_box_ok(self, i, routes):
1207
+ """No intrusion into the own box interior (t_abe038c1): _edge_stats only counts other-box press,
1208
+ but after in-band anchor clamping + ctrl following, the arc tangent can dip into the own box (measured on classic-cld
1209
+ crowding->quickfix), so the delivery gate must add an own-box interior check -- the same
1210
+ accounting as _evaluate's own-box rule (interior sample points + PRESS_MARGIN/ROUND_GUARD)."""
1211
+ pts = routes[i]["points"]
1212
+ a, b = self.edges[i]
1213
+ samples = sample_polyline(pts)[1:-1]
1214
+ for v in (a, b):
1215
+ cx, cy, hw, hh = self.N[v]
1216
+ for (px, py) in samples:
1217
+ if dist_point_rect(px, py, cx, cy,
1218
+ hw + PRESS_MARGIN, hh + PRESS_MARGIN) \
1219
+ <= ROUND_GUARD:
1220
+ return False
1221
+ return True
1222
+
1223
+ def _anchors_in_band(self, i, routes):
1224
+ pts = routes[i]["points"]
1225
+ for idx in (0, len(pts) - 1):
1226
+ v = self.edges[i][0] if idx == 0 else self.edges[i][1]
1227
+ if not self._pt_in_band(v, pts[idx]):
1228
+ return False
1229
+ return True
1230
+
1231
+ def _band_candidates(self, a, b, idx):
1232
+ """t_abe038c1 mutual-pair in-band candidates.
1233
+
1234
+ line/arc: for every anchor variant, first pull into the band via _band_clamp_pt, then recompute the geometry on the
1235
+ new chord (pure filtering is not enough -- under a narrow box's +/- 1/4 band almost all raw variants are out-of-band; on
1236
+ classic-cld crowding, band width 48px vs variant offsets 30-55px left only 2 candidates measured); arc takes both side
1237
+ signs (nested arcs = the mutual-separation escape hatch under in-band accounting; the north arc's tangent stabbing into the box
1238
+ is already sealed by the _own_box_ok hard gate). vg/poly/wrap corridor families: anchors are decided by the corridor structure,
1239
+ midpoints cannot be recomputed with the anchors, so only those whose anchors are naturally in-band are kept (side-margin
1240
+ midpoint poly etc.). Called only by _reroute_pair_band (the delivery clamping-failure fallback); not in the regular candidate pool.
1241
+ """
1242
+ out = []
1243
+ seen = set()
1244
+ mutual = (min(a, b), max(a, b)) in self.mutual
1245
+ for pa, pb in self._anchor_variants(a, b):
1246
+ pa2 = pa if self._pt_in_band(a, pa) else self._band_clamp_pt(a, pa)
1247
+ pb2 = pb if self._pt_in_band(b, pb) else self._band_clamp_pt(b, pb)
1248
+ cands = [("line", [pa2, pb2], pa2, pb2)]
1249
+ vx, vy = pb2[0] - pa2[0], pb2[1] - pa2[1]
1250
+ dist = math.hypot(vx, vy)
1251
+ if dist > EPS:
1252
+ ux, uy = norm(-vy, vx)
1253
+ ks = tuple(dict.fromkeys((adaptive_arc_k(dist),) + ARC_KS))
1254
+ for k in ks:
1255
+ for s in (1, -1):
1256
+ ctrl = [(pa2[0] + pb2[0]) / 2 + ux * k * dist * s,
1257
+ (pa2[1] + pb2[1]) / 2 + uy * k * dist * s]
1258
+ cands.append(("arc", quad_points(pa2, ctrl, pb2),
1259
+ pa2, pb2, ctrl))
1260
+ if mutual:
1261
+ ctrl_m = [(pa2[0] + pb2[0]) / 2 - ux * k * dist * s,
1262
+ (pa2[1] + pb2[1]) / 2 - uy * k * dist * s]
1263
+ cands.append(("arc", quad_points(pa2, ctrl_m, pb2),
1264
+ pa2, pb2, ctrl_m))
1265
+ for cand in cands:
1266
+ key = (cand[0], tuple(map(tuple, cand[1])))
1267
+ if key not in seen:
1268
+ seen.add(key)
1269
+ out.append(cand)
1270
+ for cand in self._candidates(a, b, idx):
1271
+ if cand[0] in ("line", "arc"):
1272
+ continue
1273
+ if not (self._pt_in_band(a, cand[2]) and
1274
+ self._pt_in_band(b, cand[3])):
1275
+ continue
1276
+ key = (cand[0], tuple(map(tuple, cand[1])))
1277
+ if key not in seen:
1278
+ seen.add(key)
1279
+ out.append(cand)
1280
+ return out
1281
+
1282
+ def _reroute_pair_band(self, routes, i, j):
1283
+ """Delivery fallback: mutual-pair local re-routing on in-band anchors (t_abe038c1).
1284
+
1285
+ Enabled when every tier of the midband_reanchor ladder clamping fails (out-of-band anchors cannot be clamped past the hard
1286
+ gate -- the lens arc's tangent stabbing into the box falls back): only these two edges are rebuilt with in-band candidates,
1287
+ a greedy two passes (i fixed -> j fixed against the new i -> re-evaluate) picks the best combination; the hard gates match the
1288
+ delivery accounting (press/clearance/anchor angle/no crossing increase/mutual separation >= 4px), falling back to the
1289
+ original routes when unsolvable. Deterministic: fixed candidate order + fixed scoring, no RNG. Other edges are untouched (the structural guarantee of zero drift on the other 18 graphs).
1290
+ """
1291
+ a, b = self.edges[i]
1292
+ ctx = {}
1293
+ for k2, r in routes.items():
1294
+ if k2 in (i, j):
1295
+ continue
1296
+ segs = list(zip(r["points"][:-1], r["points"][1:]))
1297
+ bb = [(min(p[0], q[0]), min(p[1], q[1]),
1298
+ max(p[0], q[0]), max(p[1], q[1])) for p, q in segs]
1299
+ ctx[k2] = (segs, bb)
1300
+ cands_i = self._band_candidates(a, b, i)
1301
+ cands_j = self._band_candidates(b, a, j)
1302
+ keep_i, keep_j = routes[i], routes[j]
1303
+ st_i0 = self._edge_stats(i, routes)
1304
+ st_j0 = self._edge_stats(j, routes)
1305
+
1306
+ def gates_ok():
1307
+ for k2, st0 in ((i, st_i0), (j, st_j0)):
1308
+ s = self._edge_stats(k2, routes)
1309
+ if (s[0] > st0[0] or s[3] > st0[3] or
1310
+ (s[1] is not None and s[1] < self.cl - 1e-6) or
1311
+ s[2] < ANCHOR_MIN_DEG or
1312
+ (s[4] is not None and s[4] < 4.0) or
1313
+ not self._own_box_ok(k2, routes)):
1314
+ return False
1315
+ return True
1316
+
1317
+ def pick(ii, aa, bb, cands, other):
1318
+ sib = sample_polyline(routes[other]["points"], step=12)
1319
+ best = None
1320
+ for cand in cands:
1321
+ sc, info = self._evaluate(cand, aa, bb, ctx, sib)
1322
+ if info is None:
1323
+ continue
1324
+ old = routes[ii]
1325
+ routes[ii] = info
1326
+ ok = gates_ok()
1327
+ routes[ii] = old
1328
+ if ok and (best is None or sc < best[0] - EPS):
1329
+ best = (sc, info)
1330
+ return best
1331
+
1332
+ done = False
1333
+ for _pass in (0, 1):
1334
+ r1 = pick(i, a, b, cands_i, j)
1335
+ if r1 is None:
1336
+ break
1337
+ routes[i] = r1[1]
1338
+ r2 = pick(j, b, a, cands_j, i)
1339
+ if r2 is None:
1340
+ routes[i] = keep_i
1341
+ break
1342
+ routes[j] = r2[1]
1343
+ else:
1344
+ done = routes[i] is not keep_i and routes[j] is not keep_j \
1345
+ and gates_ok()
1346
+ if not done:
1347
+ routes[i] = keep_i
1348
+ routes[j] = keep_j
1349
+ return done
1350
+
1351
+ # -- t_c4b1bfa5 rework 1: cost allocation admitting "straight-line feasible" candidates for mutual pairs --
1352
+ def mutual_straight_reroute(self, routes, frozen=()):
1353
+ """Mutual-pair straight-line reassignment (WS3: when a straight line is feasible the polyline must not win on score; polyline = last resort).
1354
+
1355
+ In the v0.7.3 candidate pool, all line-family anchor variants hang on the baseline-direction borders (the direction exit
1356
+ border of _anchors x the peer's entry border, _offset_anchors only moves along the same border's tangent); cross-border
1357
+ anchor combinations (e.g. a's left-midpoint x b's top-midpoint) simply are not in the pool -- mutual pairs get pushed
1358
+ by PAIR_SEP_W into poly side-lane detours (measured on classic-cld quickfix->crowding: a 298px polyline vs a 101px
1359
+ natural straight line, with the polyline truly crossing already-routed lines). This channel is enabled only for
1360
+ mutual-pair edges whose current route is a polyline (poly/vg/wrap): enumerate the two boxes' four border-midpoint anchors x
1361
+ straight-line candidates (midpoints are naturally in the +/- 1/4 band); the replacement happens only if the delivery hard
1362
+ gates (press 0/clearance/anchor angle/no crossing increase/mutual separation >= 4/own box) all pass and the total score is
1363
+ strictly better than the current route; with no feasible straight line, return as-is (byte-for-byte unchanged). The sibling
1364
+ edge (reverse edge) is rechecked in the same batch -- the separation/crossing/clearance gates must not regress because this
1365
+ edge switched routes (rework 2 accounting). Deterministic: fixed edge order + fixed enumeration order, no RNG. Invoked only
1366
+ in the enhanced (spine-construct) domain; the other engines are structurally zero-drift.
1367
+ """
1368
+ for i in sorted(routes.keys()):
1369
+ if i in frozen:
1370
+ continue
1371
+ a, b = self.edges[i]
1372
+ if (min(a, b), max(a, b)) not in self.mutual:
1373
+ continue
1374
+ cur = routes[i]
1375
+ if cur.get("kind") not in ("poly", "vg", "wrap"):
1376
+ continue
1377
+ j = self.eidx.get((b, a))
1378
+ if j is None or j in frozen:
1379
+ continue
1380
+ # Evaluation context: the other routed segments + bboxes (for _evaluate crossing/press checks)
1381
+ ctx = {}
1382
+ for k2, r in routes.items():
1383
+ if k2 in (i, j):
1384
+ continue
1385
+ segs = list(zip(r["points"][:-1], r["points"][1:]))
1386
+ bb = [(min(p[0], q[0]), min(p[1], q[1]),
1387
+ max(p[0], q[0]), max(p[1], q[1])) for p, q in segs]
1388
+ ctx[k2] = (segs, bb)
1389
+ sib = sample_polyline(routes[j]["points"], step=12)
1390
+ # Reference score of the current route (same _evaluate weights; the vg corner/fillet fields
1391
+ # are padded as empty shells -- scoring only consumes kind/points)
1392
+ cur_cand = (cur.get("kind", "poly"), cur["points"],
1393
+ cur["points"][0], cur["points"][-1],
1394
+ {"corner_pts": [], "fillets": []})
1395
+ cur_sc, _ = self._evaluate(cur_cand, a, b, ctx, sib)
1396
+ cur_cross = cur.get("cross", 0)
1397
+ st_j0 = self._edge_stats(j, routes)
1398
+
1399
+ def mids(v):
1400
+ cx, cy, hw, hh = self.N[v]
1401
+ return {"t": [cx, cy - hh], "b": [cx, cy + hh],
1402
+ "l": [cx - hw, cy], "r": [cx + hw, cy]}
1403
+
1404
+ ma, mb = mids(a), mids(b)
1405
+ best = None
1406
+ for sa in ("t", "b", "l", "r"):
1407
+ for sb in ("t", "b", "l", "r"):
1408
+ pa, pb = ma[sa], mb[sb]
1409
+ if abs(pa[0] - pb[0]) < EPS and abs(pa[1] - pb[1]) < EPS:
1410
+ continue
1411
+ cand = ("line", [pa, pb], pa, pb)
1412
+ sc, info = self._evaluate(cand, a, b, ctx, sib)
1413
+ if info is None or info.get("press", 0):
1414
+ continue
1415
+ mc = info.get("min_clear")
1416
+ if mc is not None and mc < self.cl - 1e-6:
1417
+ continue # hard gate: edge-box clearance
1418
+ if min(info.get("ang_a", 0.0),
1419
+ info.get("ang_b", 0.0)) < ANCHOR_MIN_DEG:
1420
+ continue # hard gate: anchor smoothness
1421
+ if sc >= cur_sc - EPS:
1422
+ continue # WS3: the total score must strictly beat the current route
1423
+ old = routes[i]
1424
+ routes[i] = info
1425
+ ok = True
1426
+ for k2, st0 in ((i, None), (j, st_j0)):
1427
+ st = self._edge_stats(k2, routes)
1428
+ if st[0] > 0:
1429
+ ok = False # hard gate: box press
1430
+ elif (st[1] is not None and st[1] < self.cl - 1e-6):
1431
+ ok = False # hard gate: clearance
1432
+ elif st[2] < ANCHOR_MIN_DEG:
1433
+ ok = False # hard gate: anchor smoothness
1434
+ elif st[4] is not None and st[4] < 4.0:
1435
+ ok = False # hard gate: mutual-pair separation
1436
+ elif not self._own_box_ok(k2, routes):
1437
+ ok = False # hard gate: own box
1438
+ elif k2 == i and st[3] > cur_cross:
1439
+ ok = False # hard gate: crossing count must not increase
1440
+ elif k2 == j and st[3] > st0[3]:
1441
+ ok = False # sibling-edge crossing count must not increase
1442
+ if not ok:
1443
+ break
1444
+ routes[i] = old
1445
+ if ok and (best is None or sc < best[0] - EPS):
1446
+ best = (sc, info)
1447
+ if best is not None:
1448
+ routes[i] = best[1]
1449
+ return routes
1450
+
1451
+ def midband_reanchor(self, routes, frozen=(), enhanced=True):
1452
+ """Delivery-layer connection-point accounting (t_7fe64f08): anchors clamped into the edge-midpoint +/- 1/4 edge-length band.
1453
+
1454
+ jacky: "the points where lines attach to boxes should also prefer each border's midpoint; avoid the corners
1455
+ possible." The routing decision layer keeps the legacy exit geometry (candidate coverage unchanged); before delivery, this channel
1456
+ clamps out-of-band anchors along their own border tangent into the +/- 1/4 band -- corners are excluded wholesale (in-band anchors
1457
+ can never hug a corner). Per-edge hard-gate recheck (press/clearance/anchor angle/no crossing increase/mutual separation >= 4px).
1458
+
1459
+ t_abe038c1 rework (rejected in jacky's second review): three stages --
1460
+ Tier 0 "baseline accounting": the original v0.7.2 semantics (endpoint clamping, ctrl untouched, the original hard-gate set)
1461
+ runs first and delivers on success (the guarantee that the other 18 graphs stay byte-for-byte identical to v0.7.2);
1462
+ Tier 1 "enhanced ladder": enabled only on baseline failure / still out-of-band -- (1) for arcs (ctrl present), the ctrl
1463
+ translates in lockstep with anchor clamping (preserving the departure tangent) and dense points are re-discretized from the
1464
+ Bezier (the old version only moved the endpoint samples, leaving the arc geometry disconnected from ctrl); (2) step in a
1465
+ ladder along the border toward the border midpoint (REANCHOR_STEP=4px, capped at half the band), retrying tier by tier with
1466
+ the gate set augmented by the own-box interior check (_edge_stats only counts other-box press; after ctrl follows, the tangent
1467
+ can dip into the own box, measured on classic-cld); (3) all tiers failing -> mutual-pair in-band local re-routing
1468
+ (_reroute_pair_band). Deterministic: fixed edge order + fixed tier order, no RNG.
1469
+ frozen edges (constructed routes / waypoints) are untouched -- the symmetric-mode constructed arcs already carry their own
1470
+ chord-coordinate mapping accounting.
1471
+ """
1472
+ handled = set()
1473
+ for i in sorted(routes.keys()):
1474
+ if i in frozen or i in handled:
1475
+ continue
1476
+ before_pts = [list(p) for p in routes[i]["points"]]
1477
+ before = self._edge_stats(i, routes)
1478
+ ctrl0 = routes[i].get("ctrl")
1479
+ # -- Tier 0: baseline accounting (original v0.7.2 semantics, byte-identical on success) --
1480
+ baseline_done = False
1481
+ if self._anchors_in_band(i, routes):
1482
+ continue # already in band, no clamping needed
1483
+ pts = [list(p) for p in before_pts]
1484
+ changed = False
1485
+ for idx in (0, len(pts) - 1):
1486
+ v = self.edges[i][0] if idx == 0 else self.edges[i][1]
1487
+ cx, cy, hw, hh = self.N[v]
1488
+ p = pts[idx]
1489
+ dxo, dyo = p[0] - cx, p[1] - cy
1490
+ on_x = abs(abs(dxo) - hw) <= abs(abs(dyo) - hh)
1491
+ if on_x:
1492
+ lim = hh / 2.0
1493
+ np_ = [p[0], cy + max(-lim, min(lim, dyo))]
1494
+ else:
1495
+ lim = hw / 2.0
1496
+ np_ = [cx + max(-lim, min(lim, dxo)), p[1]]
1497
+ if abs(np_[0] - p[0]) > 1e-9 or abs(np_[1] - p[1]) > 1e-9:
1498
+ pts[idx] = np_
1499
+ changed = True
1500
+ if changed:
1501
+ routes[i]["points"] = pts
1502
+ after = self._edge_stats(i, routes)
1503
+ ok = (after[0] <= before[0] and after[3] <= before[3] and
1504
+ (after[1] is None or after[1] >= self.cl - 1e-6) and
1505
+ after[2] >= ANCHOR_MIN_DEG and
1506
+ (after[4] is None or after[4] >= 4.0))
1507
+ if ok:
1508
+ baseline_done = True
1509
+ else:
1510
+ routes[i]["points"] = before_pts
1511
+ if baseline_done:
1512
+ continue
1513
+ if not enhanced:
1514
+ continue # not spine-wing: stop at the baseline accounting (the other 18 graphs stay byte-identical)
1515
+ # -- Tier 1: enhanced ladder (ctrl follows + own_box gate) --
1516
+ for k in range(0, 9):
1517
+ pts = [list(p) for p in before_pts]
1518
+ changed = False
1519
+ for idx in (0, len(pts) - 1):
1520
+ v = self.edges[i][0] if idx == 0 else self.edges[i][1]
1521
+ cx, cy, hw, hh = self.N[v]
1522
+ p = pts[idx]
1523
+ dxo, dyo = p[0] - cx, p[1] - cy
1524
+ on_x = abs(abs(dxo) - hw) <= abs(abs(dyo) - hh)
1525
+ if on_x:
1526
+ lim = hh / 2.0
1527
+ np_ = [p[0], cy + max(-lim, min(lim, dyo))]
1528
+ if k:
1529
+ t = np_[1] - cy
1530
+ step = max(-lim, min(lim,
1531
+ t - (REANCHOR_STEP if t > 0
1532
+ else -REANCHOR_STEP) * k))
1533
+ np_[1] = cy + step
1534
+ else:
1535
+ lim = hw / 2.0
1536
+ np_ = [cx + max(-lim, min(lim, dxo)), p[1]]
1537
+ if k:
1538
+ t = np_[0] - cx
1539
+ step = max(-lim, min(lim,
1540
+ t - (REANCHOR_STEP if t > 0
1541
+ else -REANCHOR_STEP) * k))
1542
+ np_[0] = cx + step
1543
+ if abs(np_[0] - p[0]) > 1e-9 or abs(np_[1] - p[1]) > 1e-9:
1544
+ pts[idx] = np_
1545
+ changed = True
1546
+ if not changed:
1547
+ if k == 0:
1548
+ break # already in band, no clamping needed
1549
+ continue
1550
+ # Arcs: ctrl translates in lockstep (sum of the endpoint displacement vectors) + dense re-discretized, keeping the geometry consistent
1551
+ if ctrl0 is not None:
1552
+ da = [pts[0][0] - before_pts[0][0],
1553
+ pts[0][1] - before_pts[0][1]]
1554
+ db = [pts[-1][0] - before_pts[-1][0],
1555
+ pts[-1][1] - before_pts[-1][1]]
1556
+ ctrl = [ctrl0[0] + da[0] + db[0], ctrl0[1] + da[1] + db[1]]
1557
+ pts = quad_points(pts[0], ctrl, pts[-1])
1558
+ routes[i]["ctrl"] = ctrl
1559
+ routes[i]["points"] = pts
1560
+ after = self._edge_stats(i, routes)
1561
+ ok = (after[0] <= before[0] and after[3] <= before[3] and
1562
+ (after[1] is None or after[1] >= self.cl - 1e-6) and
1563
+ after[2] >= ANCHOR_MIN_DEG and
1564
+ (after[4] is None or after[4] >= 4.0) and
1565
+ self._own_box_ok(i, routes))
1566
+ if ok:
1567
+ break
1568
+ routes[i]["points"] = before_pts
1569
+ if ctrl0 is not None:
1570
+ routes[i]["ctrl"] = list(ctrl0)
1571
+ # Every ladder tier failed (final state still out-of-band) -> mutual-pair in-band local re-routing fallback
1572
+ if not self._anchors_in_band(i, routes):
1573
+ a_, b_ = self.edges[i]
1574
+ j = self.eidx.get((b_, a_))
1575
+ if j is not None and j not in frozen and j not in handled:
1576
+ if self._reroute_pair_band(routes, i, j):
1577
+ handled.add(j)
1578
+ return routes
1579
+
1580
+ def mutual_arrival_flip(self, routes, frozen=(), enhanced_gates=False):
1581
+ """t_94a585da: mutual-lens arrow-normal channel (engine-wide).
1582
+
1583
+ A Q-arc lens between vertically stacked but horizontally offset boxes
1584
+ has end tangents that trade off with the bulge: on classic-cld
1585
+ crowding<->fundamental the delivered lens (bulge 16.3px, ctrl at 0.5
1586
+ of the chord) made BOTH arrows arrive 24.1 deg off the border normal
1587
+ -- the "twisted arrow" look. Arrows are drawn along the arrival
1588
+ tangent, so the arrival deviation is the defect carrier.
1589
+
1590
+ Feasibility (measured on this pair, chord tilt 8.6 deg): both ends
1591
+ <= ARRIVAL_TARGET_DEG requires a slim lens (bulge ~1-2px, ctrl at
1592
+ ~0.3 of the chord) -- the fat lens is geometrically incompatible with
1593
+ near-normal arrows on a tilted chord. This channel re-derives both
1594
+ arcs' ctrl from an (alpha along chord, outward bulge B) ladder,
1595
+ searching for the FULLEST lens that meets the arrival target
1596
+ (largest B first among meeting candidates); if none meets the
1597
+ target, the min-max-deviation candidate wins. Strict-improvement +
1598
+ hard-gate guarded (press / clearance / crossings / mutual separation
1599
+ no-regression / anchor angles / own-box); anchors are untouched, so
1600
+ the fan_spread occupied-slot accounting stays consistent. Routes are
1601
+ returned untouched (byte-for-byte) when no candidate improves.
1602
+
1603
+ t_51392037 enhanced_gates=True (the post-anchor_snap second call):
1604
+ the gate accounting switches to the anchor-snap calibers --
1605
+ clearance at the CI raw accounting (>= CLEARANCE - PRESS_MARGIN on
1606
+ the inflated measure) and own-box intrusion comparative via
1607
+ _own_press (inset-3px anchors put attachment-zone samples inside the
1608
+ 1px-inflated margin by construction, so the absolute _own_box_ok
1609
+ gate can never pass and would veto every candidate). The first
1610
+ (pre-snap) call keeps the legacy gates byte-for-byte.
1611
+ """
1612
+ SideNorm = {"t": (0.0, -1.0), "b": (0.0, 1.0),
1613
+ "r": (1.0, 0.0), "l": (-1.0, 0.0)}
1614
+
1615
+ def _devs(inf, a, b):
1616
+ ctrl = inf.get("ctrl")
1617
+ if ctrl is None:
1618
+ return None
1619
+ pts = inf["points"]
1620
+ p0, p1 = pts[0], pts[-1]
1621
+ n0 = SideNorm[self._side_of(a, p0)]
1622
+ n1 = SideNorm[self._side_of(b, p1)]
1623
+
1624
+ def _d(v, n):
1625
+ n_ = math.hypot(v[0], v[1])
1626
+ if n_ < 1e-9:
1627
+ return None
1628
+ c = abs(v[0] * n[0] + v[1] * n[1]) / n_
1629
+ return math.degrees(math.acos(max(0.0, min(1.0, c))))
1630
+
1631
+ dep = _d((ctrl[0] - p0[0], ctrl[1] - p0[1]), n0)
1632
+ arr = _d((p1[0] - ctrl[0], p1[1] - ctrl[1]), n1)
1633
+ if dep is None or arr is None:
1634
+ return None
1635
+ return dep, arr
1636
+
1637
+ ALPHAS = (0.3, 0.4, 0.5, 0.6, 0.7)
1638
+ BULGES = (6.0, 5.0, 4.0, 3.0, 2.5, 2.0, 1.5, 1.0)
1639
+ if enhanced_gates:
1640
+ # t_51392037: on the anchored mirror chords the box-center offset
1641
+ # (crowding/fundamental: 24px over 122px) puts one chord's tilt at
1642
+ # ~13.8 deg, so the arrival target (<=10 deg) is unreachable in
1643
+ # the fat-lens domain; the high-alpha tiers (ctrl near the arrival
1644
+ # end) let the flip re-fit the pair into a slim near-parallel
1645
+ # double line that meets the target on BOTH arrows.
1646
+ ALPHAS = ALPHAS + (0.8, 0.9, 0.95)
1647
+
1648
+ for key in sorted(self.mutual):
1649
+ i1 = self.eidx.get((key[0], key[1]))
1650
+ i2 = self.eidx.get((key[1], key[0]))
1651
+ if i1 is None or i2 is None or i1 in frozen or i2 in frozen:
1652
+ continue
1653
+ inf1, inf2 = routes.get(i1), routes.get(i2)
1654
+ if not inf1 or not inf2:
1655
+ continue
1656
+ if inf1.get("kind") != "arc" or inf2.get("kind") != "arc":
1657
+ continue
1658
+ if inf1.get("ctrl") is None or inf2.get("ctrl") is None:
1659
+ continue
1660
+ geo = []
1661
+ ok_geo = True
1662
+ for i in (i1, i2):
1663
+ inf = routes[i]
1664
+ pts = inf["points"]
1665
+ dx, dy = pts[-1][0] - pts[0][0], pts[-1][1] - pts[0][1]
1666
+ L = math.hypot(dx, dy)
1667
+ if L < 1e-9:
1668
+ ok_geo = False
1669
+ break
1670
+ ux, uy = dx / L, dy / L
1671
+ px, py = -uy, ux
1672
+ mid = (pts[0][0] + dx / 2.0, pts[0][1] + dy / 2.0)
1673
+ b_sign = ((inf["ctrl"][0] - mid[0]) * px
1674
+ + (inf["ctrl"][1] - mid[1]) * py)
1675
+ geo.append((pts[0], dx, dy, (px, py),
1676
+ 1.0 if b_sign >= 0 else -1.0, abs(b_sign)))
1677
+ if not ok_geo:
1678
+ continue
1679
+ cur1 = _devs(inf1, *self.edges[i1])
1680
+ cur2 = _devs(inf2, *self.edges[i2])
1681
+ if cur1 is None or cur2 is None:
1682
+ continue
1683
+ cur_all = cur1 + cur2
1684
+ cur_m = (max(cur_all), max(cur1[1], cur2[1]))
1685
+ cands = []
1686
+ for alpha in ALPHAS:
1687
+ for B in BULGES:
1688
+ c1 = (geo[0][0][0] + alpha * geo[0][1]
1689
+ + geo[0][4] * B * geo[0][3][0],
1690
+ geo[0][0][1] + alpha * geo[0][2]
1691
+ + geo[0][4] * B * geo[0][3][1])
1692
+ c2 = (geo[1][0][0] + alpha * geo[1][1]
1693
+ + geo[1][4] * B * geo[1][3][0],
1694
+ geo[1][0][1] + alpha * geo[1][2]
1695
+ + geo[1][4] * B * geo[1][3][1])
1696
+ t1 = _devs({"ctrl": c1, "points": inf1["points"]},
1697
+ *self.edges[i1])
1698
+ t2 = _devs({"ctrl": c2, "points": inf2["points"]},
1699
+ *self.edges[i2])
1700
+ if t1 is None or t2 is None:
1701
+ continue
1702
+ all_d = t1 + t2
1703
+ m = (max(all_d), max(t1[1], t2[1]))
1704
+ meets = m[0] <= ARRIVAL_TARGET_DEG + 1e-9
1705
+ cands.append(((0 if meets else 1, -B, m[0], m[1]),
1706
+ alpha, B, m))
1707
+ cands.sort(key=lambda c: (c[0][0], c[0][2], -c[0][1])
1708
+ if enhanced_gates else c[0])
1709
+ if os.environ.get("STDRAW_SNAP_DEBUG"):
1710
+ print(f" [flipdbg] pair={key} cur_m={cur_m} ncands={len(cands)} "
1711
+ f"best_meet={next((c[0] for c in cands if c[0][0] == 0), None)}",
1712
+ file=sys.stderr)
1713
+ stats_before = {i: self._edge_stats(i, routes)
1714
+ for i in (i1, i2)}
1715
+ own_before = ({i: self._own_press(i, routes) for i in (i1, i2)}
1716
+ if enhanced_gates else None)
1717
+ sep_floor = max(4.0, min(stats_before[i1][4]
1718
+ if stats_before[i1][4] is not None
1719
+ else 4.0,
1720
+ stats_before[i2][4]
1721
+ if stats_before[i2][4] is not None
1722
+ else 4.0) - 0.05)
1723
+ if enhanced_gates:
1724
+ # t_51392037: post-snap the joint lens pass delivers a wide
1725
+ # mirrored attachment (separation ~21px); locking the floor to
1726
+ # that would veto every slim re-fit candidate -- the arrival
1727
+ # target needs a SLIM lens on the anchored chord. The lens
1728
+ # floor returns to the t_94a585da caliber (>= 12px).
1729
+ sep_floor = max(12.0, min(sep_floor, 12.0))
1730
+ for _rk, alpha, B, m in cands:
1731
+ if not enhanced_gates:
1732
+ # legacy strict improvement (pre-snap path, zero drift)
1733
+ if not (m[0] < cur_m[0] - 1e-9
1734
+ and m[1] < cur_m[1] - 1e-9):
1735
+ continue
1736
+ # t_51392037 enhanced: no strict-improvement path dependence --
1737
+ # every candidate is gate-checked against the pre-flip state
1738
+ # (stats_before) below and the search naturally stops at the
1739
+ # best-scoring candidate via the sorted order + ok break.
1740
+ saved = {i: (routes[i]["points"], routes[i].get("ctrl"))
1741
+ for i in (i1, i2)}
1742
+ for i, g in ((i1, geo[0]), (i2, geo[1])):
1743
+ inf = routes[i]
1744
+ cx = g[0][0] + alpha * g[1] + g[4] * B * g[3][0]
1745
+ cy = g[0][1] + alpha * g[2] + g[4] * B * g[3][1]
1746
+ inf["points"] = quad_points(inf["points"][0],
1747
+ [cx, cy], inf["points"][-1])
1748
+ inf["ctrl"] = [cx, cy]
1749
+ ok = True
1750
+ for i in (i1, i2):
1751
+ pr, clr, ang, cross, msep = self._edge_stats(i, routes)
1752
+ pr0, clr0, ang0, cross0, msep0 = stats_before[i]
1753
+ ok = (pr <= pr0 and cross <= cross0
1754
+ and (clr is None or
1755
+ clr >= (self.cl - 1e-6 if not enhanced_gates
1756
+ else self.cl - PRESS_MARGIN - 1e-6))
1757
+ and ang >= ANCHOR_MIN_DEG
1758
+ and (msep is None or msep >= sep_floor)
1759
+ and (self._own_box_ok(i, routes) if not enhanced_gates
1760
+ else self._own_press(i, routes) <= own_before[i]))
1761
+ if os.environ.get("STDRAW_SNAP_DEBUG"):
1762
+ print(f" [flipdbg] {i} alpha={alpha} B={B} ok={ok} "
1763
+ f"pr={pr}<={pr0} clr={clr} ang={ang:.1f} "
1764
+ f"msep={msep}>={sep_floor:.1f} "
1765
+ f"own={self._own_press(i, routes)}"
1766
+ f"<={own_before[i] if own_before else '?'}", file=sys.stderr)
1767
+ if not ok:
1768
+ break
1769
+ if ok:
1770
+ break
1771
+ for i, (pts_b, ctrl_b) in saved.items():
1772
+ routes[i]["points"] = pts_b
1773
+ if ctrl_b is not None:
1774
+ routes[i]["ctrl"] = ctrl_b
1775
+ return routes
1776
+
1777
+ # -- t_51392037: default endpoint snapping to the 16 standard anchors --
1778
+ ANCHOR_SNAP_TOL = 0.51 # px: an endpoint within this of an anchor IS on that anchor (delivery rounds to 0.1px)
1779
+
1780
+ def _box_anchors(self, v):
1781
+ """The 16 standard anchors of node v's box (anchors.py caliber; cached per layout)."""
1782
+ cache = getattr(self, "_anchor_cache", None)
1783
+ if cache is None:
1784
+ cache = self._anchor_cache = {}
1785
+ if v not in cache:
1786
+ cx, cy, hw, hh = self.N[v]
1787
+ cache[v] = _std_anchors(cx, cy, hw, hh)
1788
+ return cache[v]
1789
+
1790
+ def _anchor_at(self, v, p):
1791
+ """Anchor id if endpoint p already sits on one of v's 16 standard anchors, else None.
1792
+
1793
+ Deterministic tie-break: none needed -- the anchors are mutually
1794
+ far apart (>= ~18px), far beyond ANCHOR_SNAP_TOL.
1795
+ """
1796
+ for aid, ax, ay in self._box_anchors(v):
1797
+ if abs(ax - p[0]) <= self.ANCHOR_SNAP_TOL and \
1798
+ abs(ay - p[1]) <= self.ANCHOR_SNAP_TOL:
1799
+ return aid
1800
+ return None
1801
+
1802
+ def _own_press(self, i, routes):
1803
+ """Body-intrusion count of edge i against its OWN boxes (0 = clean).
1804
+
1805
+ Comparative gate basis for anchor_snap: (a) several delivered
1806
+ vg/detour routes legitimately hug their own box border, so the
1807
+ ABSOLUTE own-box gate of _own_box_ok can never pass for them --
1808
+ the anchor move is accepted only when it does not ADD own-box
1809
+ intrusion vs the delivered state; (b) samples within 6px of an
1810
+ endpoint are excluded -- the attachment zone always grazes the
1811
+ inflated border by construction (the anchor sits ON the border,
1812
+ inset 3px), what the gate must catch is the arc/polyline BODY
1813
+ stabbing into the box, not the attachment stub.
1814
+ """
1815
+ pts = routes[i]["points"]
1816
+ a, b = self.edges[i]
1817
+ p0, p1 = pts[0], pts[-1]
1818
+ samples = [s for s in sample_polyline(pts)[1:-1]
1819
+ if math.hypot(s[0] - p0[0], s[1] - p0[1]) > 6.0 and
1820
+ math.hypot(s[0] - p1[0], s[1] - p1[1]) > 6.0]
1821
+ n = 0
1822
+ for v in (a, b):
1823
+ cx, cy, hw, hh = self.N[v]
1824
+ for (px, py) in samples:
1825
+ if dist_point_rect(px, py, cx, cy, hw + PRESS_MARGIN,
1826
+ hh + PRESS_MARGIN) <= ROUND_GUARD:
1827
+ n += 1
1828
+ break
1829
+ return n
1830
+
1831
+ def _cert_angle_ok(self, i, routes):
1832
+ """Anchor smoothness re-checked in the CERTIFICATE's border-side
1833
+ classification caliber (metrics._anchor_tangent_angle).
1834
+
1835
+ At CORNER anchors the two accountings can classify the border side
1836
+ differently (metrics: the smaller |Δ| wins; router _anchor_angle:
1837
+ its own rule) -- measured on population-logistic population->births
1838
+ nw: router 50.2 deg vs certificate 0.0 deg. A snap candidate is
1839
+ feasible only when BOTH accountings pass (the delivered gate is
1840
+ the certificate's).
1841
+ """
1842
+ pts = routes[i]["points"]
1843
+ for v, idx, nidx in ((self.edges[i][0], 0, 1),
1844
+ (self.edges[i][1], len(pts) - 1, len(pts) - 2)):
1845
+ cx, cy, hw, hh = self.N[v]
1846
+ a0 = pts[idx]
1847
+ on_x = abs(abs(a0[0] - cx) - hw) <= abs(abs(a0[1] - cy) - hh)
1848
+ tx, ty = (0.0, 1.0) if on_x else (1.0, 0.0)
1849
+ dx, dy = pts[nidx][0] - a0[0], pts[nidx][1] - a0[1]
1850
+ n = math.hypot(dx, dy)
1851
+ if n < 1e-9:
1852
+ return False
1853
+ c = abs(dx * tx + dy * ty) / n
1854
+ ang = math.degrees(math.acos(max(0.0, min(1.0, c))))
1855
+ if ang < ANCHOR_MIN_DEG:
1856
+ return False
1857
+ return True
1858
+
1859
+ def _sibling_cross(self, i, routes):
1860
+ """Mid-air intersection count between edge i and edges SHARING an
1861
+ endpoint with it (fan pairs).
1862
+
1863
+ Router._edge_stats exempts shared-endpoint pairs (graph-theory
1864
+ caliber), which misses fan lines visibly crossing mid-air under
1865
+ the same box (t_c135a6e3 blind spot) -- the certificate's
1866
+ visual_crossings_zero gate counts them (metrics accounting:
1867
+ strict seg_cross over the dense points, endpoint touch exempt).
1868
+ The snap gate uses the same accounting so a candidate that folds
1869
+ a fan into an X is rejected.
1870
+ """
1871
+ a, b = self.edges[i]
1872
+ pts = routes[i]["points"]
1873
+ s1 = list(zip(pts[:-1], pts[1:]))
1874
+ n = 0
1875
+ for j, o in routes.items():
1876
+ if j == i:
1877
+ continue
1878
+ ea, eb = self.edges[j]
1879
+ if not ({a, b} & {ea, eb}):
1880
+ continue
1881
+ opts = o["points"]
1882
+ s2 = list(zip(opts[:-1], opts[1:]))
1883
+ hit = False
1884
+ for q1 in s1:
1885
+ for q2 in s2:
1886
+ if seg_cross(q1[0], q1[1], q2[0], q2[1]):
1887
+ hit = True
1888
+ break
1889
+ if hit:
1890
+ break
1891
+ if hit:
1892
+ n += 1
1893
+ return n
1894
+
1895
+ LENS_OFFSET_PAIRS = "golden-first" # t_51392037: golden mirror pair before corner pair
1896
+
1897
+ def _lens_pair_joint_snap(self, routes, occ, report):
1898
+ """Mutual-pair lens arcs: JOINT symmetric anchoring (t_51392037).
1899
+
1900
+ Per-endpoint nearest-first snapping breaks the lens geometry: the
1901
+ two arcs' attachments on a shared box stop being mirrored, the
1902
+ chord tilts and the arrows twist (classic-cld crowding<->fundamental
1903
+ measured: arrival 11.8 deg > target 10, separation 10.5 < 12).
1904
+ This pre-pass assigns BOTH arcs of a mutual arc pair to the SAME
1905
+ mirrored anchor-offset pair per box (golden mirror pair first,
1906
+ corner mirror pair as fallback), preserving the lens symmetry and
1907
+ keeping the chord tilt small. Deterministic: fixed pair ladder,
1908
+ fixed sign assignment (arc with the lower current offset takes the
1909
+ lower anchor). Gates match the delivery accounting on BOTH arcs
1910
+ (press / crossings / clearance at CI accounting / departure angles
1911
+ in both calibers / mutual separation >= 12px lens floor / own-box
1912
+ body intrusion / fan-sibling mid-air crossings).
1913
+ """
1914
+ for key in sorted(self.mutual):
1915
+ i1 = self.eidx.get((key[0], key[1]))
1916
+ i2 = self.eidx.get((key[1], key[0]))
1917
+ if i1 is None or i2 is None or i1 == i2:
1918
+ continue
1919
+ if i1 not in routes or i2 not in routes:
1920
+ continue
1921
+ r1, r2 = routes[i1], routes[i2]
1922
+ if r1.get("kind") != "arc" or r2.get("kind") != "arc":
1923
+ continue
1924
+ if r1.get("ctrl") is None or r2.get("ctrl") is None:
1925
+ continue
1926
+ # ends: (edge_idx, end_idx, box) for each arc on each box
1927
+ ends = []
1928
+ for i, v in ((i1, key[0]), (i1, key[1]), (i2, key[0]), (i2, key[1])):
1929
+ e = self.edges[i]
1930
+ ends.append((i, 0 if e[0] == v else 1, v))
1931
+ pidx = {} # (edge_idx, box) -> point index (0 or len-1; arcs are dense)
1932
+ for i, end, v in ends:
1933
+ pidx[(i, v)] = 0 if end == 0 else len(routes[i]["points"]) - 1
1934
+ # both arcs must attach on the SAME border side per box (facing
1935
+ # borders; otherwise the lens pre-pass does not apply)
1936
+ s1 = self._side_of(ends[0][2], routes[i1]["points"][pidx[(i1, key[0])]])
1937
+ s2 = self._side_of(ends[1][2], routes[i1]["points"][pidx[(i1, key[1])]])
1938
+ s3 = self._side_of(ends[2][2], routes[i2]["points"][pidx[(i2, key[0])]])
1939
+ s4 = self._side_of(ends[3][2], routes[i2]["points"][pidx[(i2, key[1])]])
1940
+ if os.environ.get("STDRAW_SNAP_DEBUG"):
1941
+ print(f" [lensdbg] {key} s={s1}/{s2} vs {s3}/{s4} "
1942
+ f"N0={self.N.get(key[0])} N1={self.N.get(key[1])} "
1943
+ f"e{self.edges[i1]} e{self.edges[i2]} "
1944
+ f"p1={routes[i1]['points'][pidx[(i1, key[0])]]} "
1945
+ f"p3={routes[i2]['points'][pidx[(i2, key[0])]]}",
1946
+ file=sys.stderr)
1947
+ if s1 != s3 or s2 != s4:
1948
+ continue
1949
+ sep_ok_floor = 12.0
1950
+ before_sep = min_pair_dist(
1951
+ sample_polyline(r1["points"], step=4),
1952
+ sample_polyline(r2["points"], step=4))
1953
+ if before_sep is None:
1954
+ continue
1955
+ sep_ok_floor = max(sep_ok_floor, before_sep - 0.05)
1956
+ # local occupancy: OTHER edges' endpoints already exactly on an
1957
+ # anchor keep their claim (the pair's own 4 ends are excluded --
1958
+ # they are what this pass re-assigns)
1959
+ occ_local = {}
1960
+ for j in sorted(routes):
1961
+ if j in (i1, i2):
1962
+ continue
1963
+ pj = routes[j]["points"]
1964
+ for end, v, p in ((0, self.edges[j][0], pj[0]),
1965
+ (1, self.edges[j][1], pj[-1])):
1966
+ aid = self._anchor_at(v, p)
1967
+ if aid is not None:
1968
+ occ_local.setdefault((v, aid), set()).add((j, end))
1969
+ saved = {i: ([list(p) for p in routes[i]["points"]],
1970
+ list(routes[i]["ctrl"]))
1971
+ for i in (i1, i2)}
1972
+ before_stats = {i: self._edge_stats(i, routes) for i in (i1, i2)}
1973
+ before_own = {i: self._own_press(i, routes) for i in (i1, i2)}
1974
+ before_sib = {i: self._sibling_cross(i, routes) for i in (i1, i2)}
1975
+ # mirrored offset pairs per box: golden pair first, corner pair last
1976
+ box_pairs = {}
1977
+ ok_plan = True
1978
+ for v, s in ((key[0], s1), (key[1], s2)):
1979
+ cx, cy, hw, hh = self.N[v]
1980
+ ancs = self._box_anchors(v)
1981
+ horiz = s in ("t", "b")
1982
+ # border coordinate of the chosen side (anchors of OTHER sides
1983
+ # share the tangential axis -- filter by the border coordinate)
1984
+ bcoord = (cy + (hh - 3.0) if s == "b" else
1985
+ cy - (hh - 3.0) if s == "t" else
1986
+ cx + (hw - 3.0) if s == "r" else
1987
+ cx - (hw - 3.0))
1988
+ low = high = None
1989
+ lows, highs = [], []
1990
+ for aid, ax, ay in ancs:
1991
+ bc = ay if horiz else ax # t/b borders share y; r/l share x
1992
+ if abs(bc - bcoord) > 0.01:
1993
+ continue # anchor on a different border side
1994
+ off = (ax - cx) if horiz else (ay - cy)
1995
+ if abs(off) < 1e-6:
1996
+ continue # midpoint: cannot host two arcs
1997
+ (lows if off < 0 else highs).append((abs(off), aid))
1998
+ # golden mirror pair first: smallest |tangential offset|
1999
+ # (deterministic tie -> ANCHOR_IDS order); corners only remain
2000
+ # when no golden pair exists on this border
2001
+ if lows and highs:
2002
+ low = min(lows)[1]
2003
+ high = min(highs)[1]
2004
+ if low is None or high is None:
2005
+ ok_plan = False
2006
+ break
2007
+ box_pairs[v] = (low, high)
2008
+ if not ok_plan:
2009
+ if os.environ.get("STDRAW_SNAP_DEBUG"):
2010
+ print(f" [lensdbg] {key} ok_plan=False", file=sys.stderr)
2011
+ continue
2012
+ # which arc is "low" per current offsets on box key[0]
2013
+ cx0, cy0, hw0, hh0 = self.N[key[0]]
2014
+ horiz0 = s1 in ("t", "b")
2015
+ p_a = routes[i1]["points"][pidx[(i1, key[0])]]
2016
+ p_b = routes[i2]["points"][pidx[(i2, key[0])]]
2017
+ off_a = (p_a[0] - cx0) if horiz0 else (p_a[1] - cy0)
2018
+ off_b = (p_b[0] - cx0) if horiz0 else (p_b[1] - cy0)
2019
+ arc_low, arc_high = ((i1, i2) if off_a <= off_b else (i2, i1))
2020
+ end_of = {(i1, key[0]): ends[0][1], (i1, key[1]): ends[1][1],
2021
+ (i2, key[0]): ends[2][1], (i2, key[1]): ends[3][1]}
2022
+ golden = True
2023
+ while True:
2024
+ lo1, hi1 = box_pairs[key[0]] if golden else \
2025
+ self._corner_pair(key[0], s1)
2026
+ lo2, hi2 = box_pairs[key[1]] if golden else \
2027
+ self._corner_pair(key[1], s2)
2028
+ if lo1 is None or lo2 is None:
2029
+ break
2030
+ assign = [(arc_low, key[0], end_of[(arc_low, key[0])], lo1),
2031
+ (arc_low, key[1], end_of[(arc_low, key[1])], lo2),
2032
+ (arc_high, key[0], end_of[(arc_high, key[0])], hi1),
2033
+ (arc_high, key[1], end_of[(arc_high, key[1])], hi2)]
2034
+ # occupancy: mirrored pair must be free for this pair
2035
+ if any(occ_local.get((v, aid)) for _i, v, _e, aid in assign):
2036
+ if golden:
2037
+ golden = False
2038
+ continue
2039
+ break
2040
+ # apply: endpoint + ctrl lockstep per arc, then a ctrl-rescale
2041
+ # ladder around the new chord midpoint (same caliber as the
2042
+ # per-endpoint snap): the lockstep-translated arc body may dip
2043
+ # into its own box (own-press gate) -- the ladder re-fits the
2044
+ # bulge until the gates pass (the post-snap arrival flip then
2045
+ # restores the arrow-normal caliber).
2046
+ ladder = []
2047
+ for f in ARC_CTRL_SCALE_LADDER:
2048
+ geo = {}
2049
+ for i in (i1, i2):
2050
+ pts = [list(q) for q in saved[i][0]]
2051
+ ctrl = list(saved[i][1])
2052
+ p0, p1 = list(pts[0]), list(pts[-1])
2053
+ for _i, v, end, aid in assign:
2054
+ if _i != i:
2055
+ continue
2056
+ idx = 0 if end == 0 else len(pts) - 1
2057
+ cand = next(q for q in self._box_anchors(v)
2058
+ if q[0] == aid)
2059
+ if end == 0:
2060
+ p0 = [cand[1], cand[2]]
2061
+ else:
2062
+ p1 = [cand[1], cand[2]]
2063
+ mid = [(p0[0] + p1[0]) / 2.0, (p0[1] + p1[1]) / 2.0]
2064
+ # translated ctrl = original ctrl + endpoint deltas
2065
+ tctrl = [ctrl[0] + (p0[0] - saved[i][0][0][0])
2066
+ + (p1[0] - saved[i][0][-1][0]),
2067
+ ctrl[1] + (p0[1] - saved[i][0][0][1])
2068
+ + (p1[1] - saved[i][0][-1][1])]
2069
+ fctrl = [mid[0] + (tctrl[0] - mid[0]) * f,
2070
+ mid[1] + (tctrl[1] - mid[1]) * f]
2071
+ geo[i] = ([list(q) for q in quad_points(p0, fctrl, p1)],
2072
+ fctrl)
2073
+ ladder.append(geo)
2074
+ ok = False
2075
+ seen = []
2076
+ for geo in ladder:
2077
+ for i in (i1, i2):
2078
+ routes[i]["points"] = geo[i][0]
2079
+ routes[i]["ctrl"] = geo[i][1]
2080
+ sep = min_pair_dist(
2081
+ sample_polyline(routes[i1]["points"], step=4),
2082
+ sample_polyline(routes[i2]["points"], step=4))
2083
+ if sep is None or sep < sep_ok_floor:
2084
+ continue
2085
+ ok = True
2086
+ for i in (i1, i2):
2087
+ st = self._edge_stats(i, routes)
2088
+ st0 = before_stats[i]
2089
+ if (st[0] > st0[0] or st[3] > st0[3] or
2090
+ (st[1] is not None and
2091
+ st[1] < self.cl - PRESS_MARGIN - 1e-6) or
2092
+ st[2] < ANCHOR_MIN_DEG or
2093
+ self._own_press(i, routes) > before_own[i] or
2094
+ self._sibling_cross(i, routes) > before_sib[i] or
2095
+ not self._cert_angle_ok(i, routes)):
2096
+ if os.environ.get("STDRAW_SNAP_DEBUG"):
2097
+ seen.append(
2098
+ f"e{i}:own{self._own_press(i, routes)}"
2099
+ f"/sib{self._sibling_cross(i, routes)}"
2100
+ f"/cert{self._cert_angle_ok(i, routes)}"
2101
+ f"/clr{None if st[1] is None else round(st[1],1)}"
2102
+ f"/ang{round(st[2],1)}")
2103
+ ok = False
2104
+ break
2105
+ if ok:
2106
+ break
2107
+ if os.environ.get("STDRAW_SNAP_DEBUG"):
2108
+ print(f" [lensdbg] {key} golden={golden} ok={ok} sep={sep} "
2109
+ f"floor={sep_ok_floor:.2f} "
2110
+ f"own1={self._own_press(i1, routes)}<={before_own[i1]} "
2111
+ f"own2={self._own_press(i2, routes)}<={before_own[i2]} "
2112
+ f"sib1={self._sibling_cross(i1, routes)}<={before_sib[i1]} "
2113
+ f"sib2={self._sibling_cross(i2, routes)}<={before_sib[i2]} "
2114
+ f"cert1={self._cert_angle_ok(i1, routes)} "
2115
+ f"cert2={self._cert_angle_ok(i2, routes)} "
2116
+ f"assign={assign} rejects={seen}", file=sys.stderr)
2117
+ if ok:
2118
+ for _i, v, end, aid in assign:
2119
+ occ.setdefault((v, aid), set()).add(
2120
+ (_i, 0 if end_of[(_i, v)] == 0 else 1))
2121
+ report["moved"] += 4
2122
+ if os.environ.get("STDRAW_SNAP_DEBUG"):
2123
+ print(f" [lensdbg] {key} APPLIED golden={golden} "
2124
+ f"assign={assign}", file=sys.stderr)
2125
+ break
2126
+ for i in (i1, i2):
2127
+ routes[i]["points"] = [list(q) for q in saved[i][0]]
2128
+ routes[i]["ctrl"] = list(saved[i][1])
2129
+ if golden:
2130
+ golden = False # next: corner mirror pair
2131
+ continue
2132
+ break
2133
+
2134
+ def _off_of(self, v, horiz, ancs, aid):
2135
+ cx, cy, hw, hh = self.N[v]
2136
+ for a, ax, ay in ancs:
2137
+ if a == aid:
2138
+ return (ax - cx) if horiz else (ay - cy)
2139
+ return 0.0
2140
+
2141
+ def _corner_pair(self, v, s):
2142
+ """Low/high corner anchor ids on border side s of box v (or (None, None))."""
2143
+ cx, cy, hw, hh = self.N[v]
2144
+ horiz = s in ("t", "b")
2145
+ ancs = self._box_anchors(v)
2146
+ low = high = None
2147
+ best_lo = best_hi = None
2148
+ for aid, ax, ay in ancs:
2149
+ off = (ax - cx) if horiz else (ay - cy)
2150
+ is_corner = abs(abs(off) - ((hw if horiz else hh) - 3.0)) < 1e-6
2151
+ if not is_corner:
2152
+ continue
2153
+ if off < 0:
2154
+ low = aid
2155
+ else:
2156
+ high = aid
2157
+ return low, high
2158
+
2159
+ def anchor_snap(self, routes, max_sweeps=4):
2160
+ """Delivery-layer endpoint snapping to the 16 standard per-box anchor set (card t_51392037).
2161
+
2162
+ jacky 2026-11-09: the 16 standard connection points (t_2c2496ca) must
2163
+ be the DEFAULT attachment caliber of every rendered edge endpoint --
2164
+ not only the interactive layer. Algorithm path (joint selection, not
2165
+ blind post-hoc snapping):
2166
+
2167
+ 1. Occupancy: every endpoint already exactly on an anchor claims it
2168
+ (per (node, anchor) occupancy -- two endpoints never share one
2169
+ anchor point, so no V co-points can be created by this pass).
2170
+ 2. Sweep loop (deterministic edge order, fixed candidate ladder):
2171
+ each off-anchor endpoint evaluates the 16 anchors NEAREST FIRST
2172
+ (tie-break = fixed ANCHOR_IDS order). A candidate is applied
2173
+ only if the full delivery hard-gate set holds against the
2174
+ CURRENT routes (press no-worse / no new crossing vs any other
2175
+ edge / clearance >= CLEARANCE / departure angle >= ANCHOR_MIN_DEG
2176
+ / mutual separation >= 4px / own-box interior clean). Arcs
2177
+ translate their ctrl in lockstep with the endpoint displacement
2178
+ and re-discretize the Bezier (same caliber as midband_reanchor
2179
+ tier 1). Every applied move updates the shared geometry, so a
2180
+ later neighbour's gates see the already-snapped state -- the
2181
+ assignment is sequential-joint, crossings cannot be introduced
2182
+ by a move the mover's own gate did not count.
2183
+ 3. Sweeps repeat (max max_sweeps) until no endpoint changes: an
2184
+ endpoint that failed against a neighbour's pre-snap geometry may
2185
+ become feasible after that neighbour settles.
2186
+
2187
+ Endpoints for which no anchor passes the gates stay untouched and
2188
+ are reported in the returned report (honest degradation; the
2189
+ certificate's anchors_standard gate counts them). Deterministic:
2190
+ fixed order + fixed ladder, no RNG.
2191
+ """
2192
+ occ = {}
2193
+ report = {"anchored": 0, "moved": 0, "failures": []}
2194
+ # t_51392037: the mutual-lens joint pass runs FIRST -- the generic
2195
+ # per-endpoint pass would grab the nearest anchors for the lens ends
2196
+ # independently and break the mirror symmetry. Its occupancy only
2197
+ # counts OTHER edges' endpoints that already sit exactly on an anchor
2198
+ # (the rest of the graph is re-anchored afterwards, occupancy claims
2199
+ # from the joint pass are honoured in the main registration below).
2200
+ self._lens_pair_joint_snap(routes, occ, report)
2201
+ for i in sorted(routes):
2202
+ a, b = self.edges[i]
2203
+ pts = routes[i]["points"]
2204
+ for end, v, p in ((0, a, pts[0]), (1, b, pts[-1])):
2205
+ aid = self._anchor_at(v, p)
2206
+ if aid is not None:
2207
+ occ.setdefault((v, aid), set()).add((i, end))
2208
+ report["anchored"] += 1
2209
+ for _sweep in range(max_sweeps):
2210
+ changed = False
2211
+ for i in sorted(routes):
2212
+ a, b = self.edges[i]
2213
+ for end, v in ((0, a), (1, b)):
2214
+ idx = 0 if end == 0 else len(routes[i]["points"]) - 1
2215
+ p = routes[i]["points"][idx]
2216
+ if self._anchor_at(v, p) is not None:
2217
+ continue
2218
+ before_pts = [list(q) for q in routes[i]["points"]]
2219
+ before_ctrl = (list(routes[i]["ctrl"])
2220
+ if routes[i].get("ctrl") is not None else None)
2221
+ before_vg = ([list(q) for q in routes[i]["vg_corners"]],
2222
+ [dict(f) for f in routes[i]["vg_fillets"]]) \
2223
+ if routes[i].get("kind") == "vg" else None
2224
+ before = self._edge_stats(i, routes)
2225
+ own_before = self._own_press(i, routes)
2226
+ sib_before = self._sibling_cross(i, routes)
2227
+ applied = False
2228
+ cands = sorted(self._box_anchors(v),
2229
+ key=lambda q: (math.hypot(q[1] - p[0],
2230
+ q[2] - p[1]), q[0]))
2231
+ for aid, ax, ay in cands:
2232
+ if occ.get((v, aid)):
2233
+ continue # occupied by another edge endpoint
2234
+ dxa, dya = ax - before_pts[idx][0], ay - before_pts[idx][1]
2235
+ geo_cands = [] # [(dense_points, ctrl_or_None), ...]
2236
+ if before_ctrl is not None:
2237
+ # arc: endpoint to the anchor; ctrl first translates
2238
+ # in lockstep with the endpoint displacements (this
2239
+ # end + the possibly-already-snapped other end),
2240
+ # then a ladder rescales the ctrl offset around the
2241
+ # new chord midpoint so the departure angle
2242
+ # (>= ANCHOR_MIN_DEG) can be met on long-tangent
2243
+ # detour arcs; Bezier re-discretized per tier
2244
+ oidx = len(before_pts) - 1 - idx
2245
+ dox = routes[i]["points"][oidx][0] - before_pts[oidx][0]
2246
+ doy = routes[i]["points"][oidx][1] - before_pts[oidx][1]
2247
+ cs = [before_ctrl[0] + dxa + dox,
2248
+ before_ctrl[1] + dya + doy]
2249
+ if idx == 0:
2250
+ p0, p1 = [ax, ay], list(routes[i]["points"][-1])
2251
+ else:
2252
+ p0, p1 = list(routes[i]["points"][0]), [ax, ay]
2253
+ mid = [(p0[0] + p1[0]) / 2.0, (p0[1] + p1[1]) / 2.0]
2254
+ for f in ARC_CTRL_SCALE_LADDER:
2255
+ ctrl = [mid[0] + (cs[0] - mid[0]) * f,
2256
+ mid[1] + (cs[1] - mid[1]) * f]
2257
+ geo_cands.append(([list(q) for q in
2258
+ quad_points(p0, ctrl, p1)],
2259
+ ctrl))
2260
+ else:
2261
+ new_pts = [list(q) for q in before_pts]
2262
+ if routes[i].get("kind") == "vg":
2263
+ # vg: move the endpoint corner AND translate the
2264
+ # adjacent interior corner by the same delta (the
2265
+ # first stub segment stays collinear, departure
2266
+ # direction preserved), then RE-DERIVE every
2267
+ # fillet from the new corners -- rigidly shifting
2268
+ # a fillet keeps it tangent to the shifted
2269
+ # segment but breaks tangency against the
2270
+ # unshifted continuation (random-n10-i05 G1 7.2
2271
+ # deg measured); smooth_corners is deterministic,
2272
+ # unmoved corners reproduce their original fillet
2273
+ corners = [list(q) for q in routes[i]["vg_corners"]]
2274
+ # NOTE: idx indexes the DENSE points; corners is
2275
+ # the raw corner list (dense >= corners when
2276
+ # fillets re-sample arcs) -> corner index from
2277
+ # the END side, not from idx
2278
+ cidx = 0 if idx == 0 else len(corners) - 1
2279
+ jj1 = 1 if cidx == 0 else len(corners) - 2
2280
+ corners[cidx] = [ax, ay]
2281
+ if len(corners) > 2:
2282
+ corners[jj1] = [corners[jj1][0] + dxa,
2283
+ corners[jj1][1] + dya]
2284
+ rects_vg = [n for vv, n in self.N.items()
2285
+ if vv not in (a, b)]
2286
+ fillets, _cp = _vis.smooth_corners(
2287
+ corners, rects_vg, self.cl + VG_PAD)
2288
+ new_pts = [[px, py] for px, py in
2289
+ _vis.dense_geometry(corners, fillets)]
2290
+ routes[i]["vg_corners"] = corners
2291
+ routes[i]["vg_fillets"] = fillets
2292
+ elif routes[i].get("kind") in ("poly", "wrap") \
2293
+ and len(new_pts) > 2:
2294
+ # poly/wrap: translate the adjacent via by the
2295
+ # same delta (departure direction preserved; the
2296
+ # gates recheck the shifted run)
2297
+ j1 = 1 if idx == 0 else len(new_pts) - 2
2298
+ new_pts[j1] = [new_pts[j1][0] + dxa,
2299
+ new_pts[j1][1] + dya]
2300
+ new_pts[idx] = [ax, ay]
2301
+ else:
2302
+ new_pts[idx] = [ax, ay]
2303
+ geo_cands.append((new_pts, None))
2304
+ for new_pts, new_ctrl in geo_cands:
2305
+ routes[i]["points"] = new_pts
2306
+ if new_ctrl is not None:
2307
+ routes[i]["ctrl"] = new_ctrl
2308
+ after = self._edge_stats(i, routes)
2309
+ # clearance at the CI/certificate accounting (raw
2310
+ # border rect >= CLEARANCE): the router's
2311
+ # _edge_stats measures to the 1px-inflated rect, so
2312
+ # the inflated threshold is CLEARANCE - 1 -- the
2313
+ # certificate (raw >= 16) stays authoritative and
2314
+ # anchor feasibility is not lost to the margin
2315
+ # convention (population-logistic n-g2: inflated
2316
+ # 15.79 = raw 16.79).
2317
+ ok = (after[0] <= before[0] and after[3] <= before[3] and
2318
+ (after[1] is None or
2319
+ after[1] >= self.cl - PRESS_MARGIN - 1e-6) and
2320
+ after[2] >= ANCHOR_MIN_DEG and
2321
+ (after[4] is None or after[4] >= 4.0) and
2322
+ self._own_press(i, routes) <= own_before and
2323
+ self._sibling_cross(i, routes) <= sib_before and
2324
+ self._cert_angle_ok(i, routes))
2325
+ if os.environ.get("STDRAW_SNAP_DEBUG"):
2326
+ _c = dict(p=after[0] <= before[0],
2327
+ x=after[3] <= before[3],
2328
+ cl=(after[1] is None or after[1] >= self.cl - PRESS_MARGIN - 1e-6),
2329
+ an=after[2] >= ANCHOR_MIN_DEG,
2330
+ ms=(after[4] is None or after[4] >= 4.0),
2331
+ ow=self._own_press(i, routes) <= own_before,
2332
+ sb=self._sibling_cross(i, routes) <= sib_before,
2333
+ ct=self._cert_angle_ok(i, routes))
2334
+ print(f" [snapdbg] {a}->{b} end={end} "
2335
+ f"anchor={aid} press {after[0]}<={before[0]} "
2336
+ f"clr {after[1]} ang {after[2]:.1f} "
2337
+ f"cross {after[3]}<={before[3]} "
2338
+ f"msep {after[4]} own {self._own_press(i, routes)}"
2339
+ f"<={own_before} sib {self._sibling_cross(i, routes)}"
2340
+ f"<={sib_before} occ {bool(occ.get((v, aid)))} "
2341
+ f"cert {self._cert_angle_ok(i, routes)} "
2342
+ f"comp {_c} "
2343
+ f"-> {'OK' if ok else 'REJECT'}",
2344
+ file=sys.stderr)
2345
+ if ok:
2346
+ occ.setdefault((v, aid), set()).add((i, end))
2347
+ report["moved"] += 1
2348
+ changed = True
2349
+ applied = True
2350
+ break
2351
+ routes[i]["points"] = before_pts
2352
+ if before_ctrl is not None:
2353
+ routes[i]["ctrl"] = list(before_ctrl)
2354
+ if before_vg is not None:
2355
+ routes[i]["vg_corners"] = [list(q) for q in before_vg[0]]
2356
+ routes[i]["vg_fillets"] = [dict(f) for f in before_vg[1]]
2357
+ if applied:
2358
+ break
2359
+ if not applied:
2360
+ report["failures"].append(
2361
+ {"edge": f"{a}->{b}", "end": "a" if end == 0 else "b"})
2362
+ if not changed:
2363
+ break
2364
+ self._snap_retry_conflicts(routes, occ, report)
2365
+ report["failures"] = sorted({(f["edge"], f["end"])
2366
+ for f in report["failures"]})
2367
+ report["failures"] = [{"edge": e, "end": n} for e, n in report["failures"]]
2368
+ return report
2369
+
2370
+ def _snap_retry_conflicts(self, routes, occ, report):
2371
+ """Post-sweep repair: for an endpoint that failed all 16 anchors
2372
+ because earlier-snapped sibling edges occupy/blocked its feasible
2373
+ anchors (wide-comb hub fans measured: hoya->stx end b), let the
2374
+ CONFLICTING sibling give way to its next-nearest feasible anchor and
2375
+ retry the failed endpoint. Deterministic: failures in sorted order;
2376
+ for each, blocker siblings in sorted order x blocker alternative
2377
+ anchors nearest-first x the failed endpoint's full nearest-first
2378
+ ladder; committed only when BOTH endpoints pass the full delivery
2379
+ hard-gate set (same accounting as the sweeps). One bounded round.
2380
+ """
2381
+ eidx = {(e, n): None for e in []} # placeholder no-op
2382
+ for fail in list(report["failures"]):
2383
+ target = None
2384
+ for i in sorted(routes):
2385
+ a, b = self.edges[i]
2386
+ if f"{a}->{b}" == fail["edge"]:
2387
+ target = (i, 0 if fail["end"] == "a" else 1)
2388
+ break
2389
+ if target is None:
2390
+ continue
2391
+ i, end = target
2392
+ v = self.edges[i][end]
2393
+ idx = 0 if end == 0 else len(routes[i]["points"]) - 1
2394
+ p = routes[i]["points"][idx]
2395
+ if self._anchor_at(v, p) is not None:
2396
+ continue # a later sweep already resolved it
2397
+ before_pts = [list(q) for q in routes[i]["points"]]
2398
+ before_ctrl = (list(routes[i]["ctrl"])
2399
+ if routes[i].get("ctrl") is not None else None)
2400
+ before_vg = ([list(q) for q in routes[i]["vg_corners"]],
2401
+ [dict(f) for f in routes[i]["vg_fillets"]]) \
2402
+ if routes[i].get("kind") == "vg" else None
2403
+ before = self._edge_stats(i, routes)
2404
+ own_before = self._own_press(i, routes)
2405
+ sib_before = self._sibling_cross(i, routes)
2406
+ done = False
2407
+ for j in sorted(routes):
2408
+ if j == i or done:
2409
+ continue
2410
+ ja, jb = self.edges[j]
2411
+ if v not in (ja, jb):
2412
+ continue
2413
+ jend = 0 if ja == v else 1
2414
+ jidx = 0 if jend == 0 else len(routes[j]["points"]) - 1
2415
+ jp = routes[j]["points"][jidx]
2416
+ jaid = self._anchor_at(v, jp)
2417
+ if jaid is None:
2418
+ continue # j's end on this box is not the blocker
2419
+ jsaved_pts = [list(q) for q in routes[j]["points"]]
2420
+ jsaved_ctrl = (list(routes[j]["ctrl"])
2421
+ if routes[j].get("ctrl") is not None else None)
2422
+ jbefore = self._edge_stats(j, routes)
2423
+ jown_before = self._own_press(j, routes)
2424
+ jsib_before = self._sibling_cross(j, routes)
2425
+ jocc_key = (v, jaid)
2426
+ claimants = occ.get(jocc_key, set())
2427
+ occ.pop(jocc_key, None) # release j's claim for the retry
2428
+ cands_i = sorted(self._box_anchors(v),
2429
+ key=lambda q: (math.hypot(q[1] - p[0],
2430
+ q[2] - p[1]), q[0]))
2431
+ for aid, ax, ay in cands_i:
2432
+ if occ.get((v, aid)):
2433
+ continue
2434
+ # -- the blocker gives way FIRST: its next-nearest
2435
+ # feasible alternative on this box (gate-checked); if
2436
+ # no alternative passes, this anchor stays unavailable
2437
+ jp0 = routes[j]["points"][jidx]
2438
+ jbefore_pts = [list(q) for q in routes[j]["points"]]
2439
+ jbefore_ctrl = jsaved_ctrl
2440
+ jbefore_vg = ([list(q) for q in routes[j]["vg_corners"]],
2441
+ [dict(f) for f in routes[j]["vg_fillets"]]) \
2442
+ if routes[j].get("kind") == "vg" else None
2443
+ jbs = self._edge_stats(j, routes)
2444
+ jownb = self._own_press(j, routes)
2445
+ jsibb = self._sibling_cross(j, routes)
2446
+ jalt = None
2447
+ jcands = sorted(
2448
+ (q for q in self._box_anchors(v) if q[0] != aid),
2449
+ key=lambda q: (math.hypot(q[1] - jp0[0],
2450
+ q[2] - jp0[1]), q[0]))
2451
+ for jaid2, jax, jay in jcands:
2452
+ if occ.get((v, jaid2)) or (v, jaid2) == jocc_key:
2453
+ continue
2454
+ jidx2 = 0 if jend == 0 else len(routes[j]["points"]) - 1
2455
+ jdxa = jax - jbefore_pts[jidx2][0]
2456
+ jdya = jay - jbefore_pts[jidx2][1]
2457
+ jnp = [list(q) for q in jbefore_pts]
2458
+ if routes[j].get("kind") in ("poly", "wrap") \
2459
+ and len(jnp) > 2:
2460
+ jj1 = 1 if jidx2 == 0 else len(jnp) - 2
2461
+ jnp[jj1] = [jnp[jj1][0] + jdxa, jnp[jj1][1] + jdya]
2462
+ jnp[jidx2] = [jax, jay]
2463
+ routes[j]["points"] = jnp
2464
+ jst = self._edge_stats(j, routes)
2465
+ ok_j = (jst[0] <= jbs[0] and jst[3] <= jbs[3] and
2466
+ (jst[1] is None or
2467
+ jst[1] >= self.cl - PRESS_MARGIN - 1e-6) and
2468
+ jst[2] >= ANCHOR_MIN_DEG and
2469
+ (jst[4] is None or jst[4] >= 4.0) and
2470
+ self._own_press(j, routes) <= jownb and
2471
+ self._sibling_cross(j, routes) <= jsibb and
2472
+ self._cert_angle_ok(j, routes))
2473
+ if ok_j:
2474
+ jalt = (jaid2, [list(q) for q in jnp])
2475
+ break
2476
+ routes[j]["points"] = jbefore_pts
2477
+ if jbefore_ctrl is not None:
2478
+ routes[j]["ctrl"] = list(jbefore_ctrl)
2479
+ if jbefore_vg is not None:
2480
+ routes[j]["vg_corners"] = [list(q) for q in jbefore_vg[0]]
2481
+ routes[j]["vg_fillets"] = [dict(f) for f in jbefore_vg[1]]
2482
+ if jalt is None:
2483
+ continue
2484
+ # -- retry the failed endpoint at aid --
2485
+ dxa, dya = ax - before_pts[idx][0], ay - before_pts[idx][1]
2486
+ geo_cands = []
2487
+ if before_ctrl is not None:
2488
+ oidx = len(before_pts) - 1 - idx
2489
+ dox = routes[i]["points"][oidx][0] - before_pts[oidx][0]
2490
+ doy = routes[i]["points"][oidx][1] - before_pts[oidx][1]
2491
+ cs = [before_ctrl[0] + dxa + dox,
2492
+ before_ctrl[1] + dya + doy]
2493
+ if idx == 0:
2494
+ q0, q1 = [ax, ay], list(routes[i]["points"][-1])
2495
+ else:
2496
+ q0, q1 = list(routes[i]["points"][0]), [ax, ay]
2497
+ mid = [(q0[0] + q1[0]) / 2.0, (q0[1] + q1[1]) / 2.0]
2498
+ for f in ARC_CTRL_SCALE_LADDER:
2499
+ ctrl = [mid[0] + (cs[0] - mid[0]) * f,
2500
+ mid[1] + (cs[1] - mid[1]) * f]
2501
+ geo_cands.append(([list(q) for q in
2502
+ quad_points(q0, ctrl, q1)],
2503
+ ctrl))
2504
+ else:
2505
+ np_ = [list(q) for q in before_pts]
2506
+ if routes[i].get("kind") == "vg":
2507
+ corners = [list(q) for q in routes[i]["vg_corners"]]
2508
+ # idx indexes DENSE points; corners is the raw
2509
+ # corner list -> corner index from the END side
2510
+ cidx = 0 if idx == 0 else len(corners) - 1
2511
+ jj1 = 1 if cidx == 0 else len(corners) - 2
2512
+ corners[cidx] = [ax, ay]
2513
+ if len(corners) > 2:
2514
+ corners[jj1] = [corners[jj1][0] + dxa,
2515
+ corners[jj1][1] + dya]
2516
+ # re-derive fillets from the new corners (rigid
2517
+ # fillet shift breaks G1 vs the unshifted
2518
+ # continuation -- same caliber as the sweeps)
2519
+ rects_vg = [n for vv, n in self.N.items()
2520
+ if vv not in self.edges[i]]
2521
+ fillets, _cp = _vis.smooth_corners(
2522
+ corners, rects_vg, self.cl + VG_PAD)
2523
+ np_ = [[px, py] for px, py in
2524
+ _vis.dense_geometry(corners, fillets)]
2525
+ routes[i]["vg_corners"] = corners
2526
+ routes[i]["vg_fillets"] = fillets
2527
+ elif routes[i].get("kind") in ("poly", "wrap") \
2528
+ and len(np_) > 2:
2529
+ jj1 = 1 if idx == 0 else len(np_) - 2
2530
+ np_[jj1] = [np_[jj1][0] + dxa, np_[jj1][1] + dya]
2531
+ np_[idx] = [ax, ay]
2532
+ else:
2533
+ np_[idx] = [ax, ay]
2534
+ geo_cands.append((np_, None))
2535
+ applied_i = False
2536
+ for new_pts, new_ctrl in geo_cands:
2537
+ routes[i]["points"] = new_pts
2538
+ if new_ctrl is not None:
2539
+ routes[i]["ctrl"] = new_ctrl
2540
+ st = self._edge_stats(i, routes)
2541
+ ok_i = (st[0] <= before[0] and st[3] <= before[3] and
2542
+ (st[1] is None or
2543
+ st[1] >= self.cl - PRESS_MARGIN - 1e-6) and
2544
+ st[2] >= ANCHOR_MIN_DEG and
2545
+ (st[4] is None or st[4] >= 4.0) and
2546
+ self._own_press(i, routes) <= own_before and
2547
+ self._sibling_cross(i, routes) <= sib_before and
2548
+ self._cert_angle_ok(i, routes))
2549
+ if ok_i:
2550
+ applied_i = True
2551
+ break
2552
+ if applied_i:
2553
+ jaid2, jpts_new = jalt
2554
+ occ.setdefault((v, jaid2), set()).add((j, jend))
2555
+ occ.setdefault((v, aid), set()).add((i, end))
2556
+ report["moved"] += 2
2557
+ if os.environ.get("STDRAW_SNAP_DEBUG"):
2558
+ print(f" [snapdbg] retry {fail['edge']}:{fail['end']} "
2559
+ f"-> {aid}; blocker {self.edges[j]} gave way "
2560
+ f"{jaid}->{jaid2}", file=sys.stderr)
2561
+ done = True
2562
+ break
2563
+ # rollback both (i tried every anchor against this jalt)
2564
+ routes[i]["points"] = before_pts
2565
+ if before_ctrl is not None:
2566
+ routes[i]["ctrl"] = list(before_ctrl)
2567
+ if before_vg is not None:
2568
+ routes[i]["vg_corners"] = [list(q) for q in before_vg[0]]
2569
+ routes[i]["vg_fillets"] = [dict(f) for f in before_vg[1]]
2570
+ routes[j]["points"] = jbefore_pts
2571
+ if jbefore_ctrl is not None:
2572
+ routes[j]["ctrl"] = list(jbefore_ctrl)
2573
+ if jbefore_vg is not None:
2574
+ routes[j]["vg_corners"] = [list(q) for q in jbefore_vg[0]]
2575
+ routes[j]["vg_fillets"] = [dict(f) for f in jbefore_vg[1]]
2576
+ if done:
2577
+ report["failures"] = [
2578
+ f for f in report["failures"] if f != fail]
2579
+
2580
+ # -- main entry --
2581
+ def route_all(self):
2582
+ """Two-pass routing: pass one routes each edge in order; pass two re-evaluates every edge against "the other edges already routed" and swaps in improvements.
2583
+
2584
+ Both passes are deterministic (fixed candidate order + fixed scoring); same graph yields same output.
2585
+ """
2586
+ routed = {}
2587
+ out = {}
2588
+ order = self.order
2589
+ # WS5 performance: routed_segs maintained incrementally -- each routed line's segments/bboxes are computed once
2590
+ # (the old version fully rebuilt all routed lines for every edge, O(m^2) repeated derivation; contents are item-by-item equivalent)
2591
+ seg_cache = {}
2592
+ for _pass in (1, 2):
2593
+ for i in order:
2594
+ a, b = self.edges[i]
2595
+ # F1: precomputed routed lines (segments + bboxes, for the crossing bbox pre-filter); if the mutual sibling
2596
+ # edge (b,a) is already routed -> also prepare 12px coarse samples for the coincidence penalty
2597
+ routed_segs = {}
2598
+ sib = None
2599
+ for j, rpts in routed.items():
2600
+ if j == i:
2601
+ continue
2602
+ ent = seg_cache.get(j)
2603
+ if ent is None:
2604
+ segs = list(zip(rpts[:-1], rpts[1:]))
2605
+ bb = [(min(p[0], q[0]), min(p[1], q[1]),
2606
+ max(p[0], q[0]), max(p[1], q[1]))
2607
+ for p, q in segs]
2608
+ ent = (segs, bb)
2609
+ seg_cache[j] = ent
2610
+ routed_segs[j] = ent
2611
+ if self.eidx.get((b, a)) == j:
2612
+ sib = sample_polyline(rpts, step=12)
2613
+ best = None
2614
+ for cand in self._candidates(a, b, i):
2615
+ sc, info = self._evaluate(cand, a, b, routed_segs, sib)
2616
+ if info is not None and (best is None or sc < best[0] - EPS):
2617
+ best = (sc, cand, info)
2618
+ if best is None:
2619
+ cand = self._candidates(a, b, i)[0]
2620
+ sc, info = self._evaluate(cand, a, b, routed_segs, sib)
2621
+ best = (sc, cand,
2622
+ info or {"kind": "line", "points": cand[1],
2623
+ "press": 0, "min_clear": None,
2624
+ "cross": 0, "cross_edges": 0,
2625
+ "ang_a": 0.0, "ang_b": 0.0})
2626
+ sc, cand, info = best
2627
+ routed[i] = info["points"]
2628
+ seg_cache.pop(i, None) # this edge was re-routed: old cache invalidated
2629
+ out[i] = info
2630
+ return out