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/__init__.py +32 -0
- stdraw/__main__.py +350 -0
- stdraw/_interactive_js.py +2030 -0
- stdraw/anchors.py +103 -0
- stdraw/bowtielayout.py +350 -0
- stdraw/check.py +651 -0
- stdraw/columnlayout.py +531 -0
- stdraw/comblayout.py +533 -0
- stdraw/crossing_exact.py +1004 -0
- stdraw/delivery.py +81 -0
- stdraw/dsl.py +99 -0
- stdraw/layout3d.py +167 -0
- stdraw/layout_auto.py +1827 -0
- stdraw/layoutdiff.py +424 -0
- stdraw/loops.py +145 -0
- stdraw/metrics.py +1225 -0
- stdraw/model.py +134 -0
- stdraw/placement.py +220 -0
- stdraw/planar.py +625 -0
- stdraw/recipes.py +68 -0
- stdraw/regularity.py +176 -0
- stdraw/render.py +361 -0
- stdraw/render3d.py +165 -0
- stdraw/rendercheck.py +90 -0
- stdraw/ringlayout.py +475 -0
- stdraw/routing.py +2630 -0
- stdraw/spinewinglayout.py +329 -0
- stdraw/symlayout.py +245 -0
- stdraw/textmetrics.py +97 -0
- stdraw/visibility.py +382 -0
- stdraw-0.7.13.dist-info/METADATA +383 -0
- stdraw-0.7.13.dist-info/RECORD +36 -0
- stdraw-0.7.13.dist-info/WHEEL +5 -0
- stdraw-0.7.13.dist-info/entry_points.txt +2 -0
- stdraw-0.7.13.dist-info/licenses/LICENSE +21 -0
- stdraw-0.7.13.dist-info/top_level.txt +1 -0
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
|