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/layout_auto.py
ADDED
|
@@ -0,0 +1,1827 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""auto layout layer (② objective-function rework): layering + order/position optimization + crossing count in the objective + regularity terms.
|
|
3
|
+
|
|
4
|
+
Differences from ortho-v1 (planar.py, plane-embedding construction, used to pin
|
|
5
|
+
production behavior):
|
|
6
|
+
- No planarity requirement: any CLD can be laid out, and the crossing count
|
|
7
|
+
is explicitly minimized as a **first-class term of the objective function**;
|
|
8
|
+
- Objective = crossing count (weight 60) + sum(|dx|) edge-length term
|
|
9
|
+
(regularity/compactness) + grid snapping (regularity);
|
|
10
|
+
- Placement can be refined with OR-Tools CP-SAT (`pip install stdraw[solver]`);
|
|
11
|
+
when not installed it degrades gracefully
|
|
12
|
+
to the deterministic constructive method (barycenter order + even
|
|
13
|
+
placement) - full functionality, quality falls back to the constructive tier;
|
|
14
|
+
- Routing is delegated to routing.Router (straight/arc first-class, boxes do
|
|
15
|
+
not press lines, 16px clearance hard gate).
|
|
16
|
+
|
|
17
|
+
Determinism: CP-SAT fixed at num_workers=1 + random_seed; the constructive
|
|
18
|
+
method has no RNG. Same input, same output.
|
|
19
|
+
"""
|
|
20
|
+
import math
|
|
21
|
+
import sys
|
|
22
|
+
from collections import defaultdict
|
|
23
|
+
|
|
24
|
+
from . import textmetrics
|
|
25
|
+
from .routing import (Router, CLEARANCE, SIDE_MARGIN, PAIR_SEP_W,
|
|
26
|
+
ANCHOR_MIN_DEG, border_point)
|
|
27
|
+
|
|
28
|
+
# t_51392037 finalize (jacky 2026-11-09 ruling "C+B"): edge/node density at or
|
|
29
|
+
# above which anchor_snap runs in gate-guarded probe mode (see the snap block
|
|
30
|
+
# in layout()). Measured basis: random n10 corpus E/N = 1.60 (dense; n10-i05
|
|
31
|
+
# regresses: crossings 0->2 / coincidence / 1 endpoint unanchored), classic
|
|
32
|
+
# corpus <= 1.62 (classic-cld snaps clean) -- the probe keeps snapping any
|
|
33
|
+
# dense graph that passes the hard gates with zero regressions.
|
|
34
|
+
ANCHOR_SNAP_DENSE_E_OVER_N = 1.6
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _clone_route(rt):
|
|
38
|
+
"""Deep copy of one route dict (points/ctrl/vg_corners/vg_fillets only --
|
|
39
|
+
the fields anchor_snap mutates; used by the dense-probe revert path)."""
|
|
40
|
+
c = dict(rt)
|
|
41
|
+
c["points"] = [list(p) for p in rt["points"]]
|
|
42
|
+
if rt.get("ctrl") is not None:
|
|
43
|
+
c["ctrl"] = list(rt["ctrl"])
|
|
44
|
+
if rt.get("vg_corners") is not None:
|
|
45
|
+
c["vg_corners"] = [list(q) for q in rt["vg_corners"]]
|
|
46
|
+
if rt.get("vg_fillets") is not None:
|
|
47
|
+
c["vg_fillets"] = [dict(f) for f in rt["vg_fillets"]]
|
|
48
|
+
return c
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AutoConfig:
|
|
52
|
+
fs_title: float = 12.5
|
|
53
|
+
fs_value: float = 14.0
|
|
54
|
+
card_min_w: int = 96
|
|
55
|
+
card_h: int = 44
|
|
56
|
+
pitch: int = 80 # row pitch: card 44 + inter-row corridor 36 (the source of clearance for detour lanes)
|
|
57
|
+
top: int = 92 # first-row center y (clearance from the parking strip >=18px: 28+22=50 -> 92-22=70)
|
|
58
|
+
park_y: int = 28 # parking-strip center y for isolated nodes
|
|
59
|
+
margin: int = 44
|
|
60
|
+
min_gap: int = 18 # card gap (headroom over the >=16 gate)
|
|
61
|
+
clearance: int = 16 # edge-box clearance hard gate (fed into router scoring)
|
|
62
|
+
grid: int = 8
|
|
63
|
+
snap_regular: int = 24 # regular snap for simple graphs (<=10 nodes)
|
|
64
|
+
# v0.7.7: extra per-side padding for the value (second) text line.
|
|
65
|
+
# Default None = historical behavior (pad-4 baseline) byte-for-byte;
|
|
66
|
+
# opt in for long ticker/price value strings via CLI --value-pad.
|
|
67
|
+
value_pad_extra: float = None
|
|
68
|
+
# v0.7.11 deterministic layout constraints (comb-bilateral; both default
|
|
69
|
+
# None = engine-decided order/side byte-for-byte, the deterministic
|
|
70
|
+
# initial-render contract is untouched):
|
|
71
|
+
# - column_order: explicit node-id list fixing the source-column internal
|
|
72
|
+
# order (top -> bottom). Hard constraint applied at component level:
|
|
73
|
+
# components are ordered by the best (smallest) rank of their members,
|
|
74
|
+
# intra-component DFS order is preserved (pendant trees stay contiguous),
|
|
75
|
+
# unlisted column nodes keep engine order after the listed ones.
|
|
76
|
+
# - hub_side: {node_id: "left"|"right"} for the comb's bilateral hubs;
|
|
77
|
+
# a single explicit side is enough to fix the assignment, contradicting
|
|
78
|
+
# specs degrade deterministically (first left wins, then first right).
|
|
79
|
+
column_order: list = None
|
|
80
|
+
hub_side: dict = None
|
|
81
|
+
target_w: int = 1280
|
|
82
|
+
target_h: int = 680
|
|
83
|
+
aspect_min: float = 1.5
|
|
84
|
+
park_y: int = 28 # parking strip for isolated nodes
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _cycle_break(nodes, edges):
|
|
88
|
+
"""DFS cycle breaking (deterministic via declaration order). Returns (set of edges reversed to point backward, DAG edge list)."""
|
|
89
|
+
adj = defaultdict(list)
|
|
90
|
+
for i, (a, b) in enumerate(edges):
|
|
91
|
+
adj[a].append((b, i))
|
|
92
|
+
state = {}
|
|
93
|
+
reversed_idx = set()
|
|
94
|
+
def dfs(v):
|
|
95
|
+
state[v] = 1
|
|
96
|
+
for w, i in adj[v]:
|
|
97
|
+
if state.get(w, 0) == 0:
|
|
98
|
+
dfs(w)
|
|
99
|
+
elif state.get(w, 0) == 1:
|
|
100
|
+
reversed_idx.add(i) # back edge: treated as w->v during layering
|
|
101
|
+
state[v] = 2
|
|
102
|
+
for v in nodes:
|
|
103
|
+
if state.get(v, 0) == 0:
|
|
104
|
+
dfs(v)
|
|
105
|
+
dag = []
|
|
106
|
+
for i, (a, b) in enumerate(edges):
|
|
107
|
+
dag.append((b, a) if i in reversed_idx else (a, b))
|
|
108
|
+
return reversed_idx, dag
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _layers(nodes, dag):
|
|
112
|
+
"""DAG longest-path layering + empty-layer compression."""
|
|
113
|
+
preds = defaultdict(list)
|
|
114
|
+
outs = defaultdict(list)
|
|
115
|
+
for a, b in dag:
|
|
116
|
+
preds[b].append(a)
|
|
117
|
+
outs[a].append(b)
|
|
118
|
+
layer = {}
|
|
119
|
+
def calc(v):
|
|
120
|
+
if v in layer:
|
|
121
|
+
return layer[v]
|
|
122
|
+
layer[v] = 0 # cycle guard (theoretically acyclic, just in case)
|
|
123
|
+
lv = 0 if not preds[v] else 1 + max(calc(u) for u in preds[v])
|
|
124
|
+
layer[v] = lv
|
|
125
|
+
return lv
|
|
126
|
+
for v in nodes:
|
|
127
|
+
calc(v)
|
|
128
|
+
return layer
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _has_solver():
|
|
132
|
+
try:
|
|
133
|
+
from ortools.sat.python import cp_model # noqa: F401
|
|
134
|
+
return True
|
|
135
|
+
except Exception:
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _solve_positions(nodes, rows, widths, cfg: AutoConfig, linestyle):
|
|
140
|
+
"""Placement solver: CP-SAT available -> crossing-count + edge-length + regularity objective; otherwise the constructive method.
|
|
141
|
+
|
|
142
|
+
Returns ({v: x_px}, engine, extra_meta) (box centers). y is decided by rows.
|
|
143
|
+
"""
|
|
144
|
+
row_of = {v: r for r, vs in enumerate(rows) for v in vs}
|
|
145
|
+
# minimal total width per row -> content width
|
|
146
|
+
row_w = {r: sum(widths[v] for v in vs) + cfg.min_gap * (len(vs) + 1)
|
|
147
|
+
for r, vs in enumerate(rows)}
|
|
148
|
+
content_w = max(row_w.values()) + 2 * cfg.margin
|
|
149
|
+
max_x = int(content_w)
|
|
150
|
+
|
|
151
|
+
# edge endpoint row numbers (comparing crossings on the original y order would need real y; row numbers suffice)
|
|
152
|
+
einfo = []
|
|
153
|
+
for (a, b) in EDGES_REF[0]:
|
|
154
|
+
einfo.append((a, b, row_of[a], row_of[b]))
|
|
155
|
+
|
|
156
|
+
# WS5 scalability: proving CP-SAT global optimality does not scale to large graphs (after O(m^2) booleanization of crossing pairs, proof
|
|
157
|
+
# time explodes with n - measured on n20: 5min+ without completing the proof). Switched to a **deterministic time budget**
|
|
158
|
+
# (max_deterministic_time, OR-Tools' dedicated reproducibility caliber: counts operations instead of wall clock, so the same version
|
|
159
|
+
# and input reach the same incumbent on any machine). The budget is generous: small graphs (<=10 nodes, the entire classic domain)
|
|
160
|
+
# still prove OPTIMAL within budget, output byte-for-byte identical to the unbudgeted version; only the new n>10 domain is budget-bound
|
|
161
|
+
# (FEASIBLE labeled honestly, never passed off as optimal).
|
|
162
|
+
det_budget = CP_SAT_DET_BUDGET if len(nodes) > 10 else None
|
|
163
|
+
|
|
164
|
+
if _has_solver():
|
|
165
|
+
snap = cfg.snap_regular if len(nodes) <= 10 else cfg.grid
|
|
166
|
+
res, status = _cpsat_positions(nodes, rows, widths, cfg, einfo, max_x, snap,
|
|
167
|
+
det_budget)
|
|
168
|
+
if res is None and snap != cfg.grid: # 24px snap infeasible -> fall back to the 8px grid
|
|
169
|
+
res, status = _cpsat_positions(nodes, rows, widths, cfg, einfo, max_x,
|
|
170
|
+
cfg.grid, det_budget)
|
|
171
|
+
if res is not None:
|
|
172
|
+
extra = {}
|
|
173
|
+
if det_budget is not None:
|
|
174
|
+
# only the budgeted path (n>10) writes meta: the small-graph path keeps historical output byte-for-byte unchanged
|
|
175
|
+
extra = {"solver_status": status,
|
|
176
|
+
"solver_det_budget": det_budget}
|
|
177
|
+
return res, "cp-sat", extra
|
|
178
|
+
return _construct_positions(nodes, rows, widths, cfg), "construct", {}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
CP_SAT_DET_BUDGET = 8.0 # deterministic time budget (det units; measured on n20: ~=17s wall clock per 8 det)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
EDGES_REF = [()] # module-level reference slot - injected by layout(), avoids a long parameter list
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _cpsat_positions(nodes, rows, widths, cfg, einfo, max_x, grid, det_budget=None):
|
|
188
|
+
"""CP-SAT: decision variables = box-center x; objective = crossing count + sum(|dx|); constraints = clearance/grid/bounds.
|
|
189
|
+
|
|
190
|
+
det_budget: deterministic time budget (max_deterministic_time, the
|
|
191
|
+
operation-count caliber) - past budget, take the current best incumbent
|
|
192
|
+
(FEASIBLE) and stop proving optimality. None = no budget (the historical
|
|
193
|
+
behavior: solve to optimality). Returns (solution or None, "optimal"/"feasible").
|
|
194
|
+
"""
|
|
195
|
+
try:
|
|
196
|
+
from ortools.sat.python import cp_model
|
|
197
|
+
except Exception:
|
|
198
|
+
return None, "no-solver"
|
|
199
|
+
m = cp_model.CpModel()
|
|
200
|
+
row_of = {v: r for r, vs in enumerate(rows) for v in vs}
|
|
201
|
+
xs = {v: m.NewIntVar(cfg.margin, max_x - cfg.margin, f"x_{v}") for v in nodes}
|
|
202
|
+
for v in nodes:
|
|
203
|
+
m.AddModuloEquality(0, xs[v], grid)
|
|
204
|
+
# same-row clearance (center distance >= half the summed widths + gap)
|
|
205
|
+
for vs in rows:
|
|
206
|
+
for i in range(len(vs)):
|
|
207
|
+
for j in range(i + 1, len(vs)):
|
|
208
|
+
u, w = vs[i], vs[j]
|
|
209
|
+
need = int(math.ceil((widths[u] + widths[w]) / 2.0) + cfg.min_gap)
|
|
210
|
+
d = m.NewIntVar(0, max_x, "")
|
|
211
|
+
m.AddAbsEquality(d, xs[u] - xs[w])
|
|
212
|
+
m.Add(d >= need)
|
|
213
|
+
# crossing objective: two independent edges with interleaved row intervals -> reversed endpoint order means a crossing (conservative: close proximity not counted)
|
|
214
|
+
cross_vars = []
|
|
215
|
+
for i in range(len(einfo)):
|
|
216
|
+
a1, b1, r11, r12 = einfo[i]
|
|
217
|
+
for j in range(i + 1, len(einfo)):
|
|
218
|
+
a2, b2, r21, r22 = einfo[j]
|
|
219
|
+
if {a1, b1} & {a2, b2}:
|
|
220
|
+
continue
|
|
221
|
+
lo1, hi1 = min(r11, r12), max(r11, r12)
|
|
222
|
+
lo2, hi2 = min(r21, r22), max(r21, r22)
|
|
223
|
+
if min(hi1, hi2) - max(lo1, lo2) < 1:
|
|
224
|
+
continue
|
|
225
|
+
# compare the relative order of the two endpoints (at the lowest/highest row of each side)
|
|
226
|
+
top_e = a1 if r11 < r12 else b1
|
|
227
|
+
bot_e = b1 if r11 < r12 else a1
|
|
228
|
+
top_f = a2 if r21 < r22 else b2
|
|
229
|
+
bot_f = b2 if r21 < r22 else a2
|
|
230
|
+
if row_of[top_e] == row_of[top_f] or row_of[bot_e] == row_of[bot_f]:
|
|
231
|
+
continue
|
|
232
|
+
p = m.NewBoolVar("")
|
|
233
|
+
q = m.NewBoolVar("")
|
|
234
|
+
m.Add(xs[top_e] <= xs[top_f] - 4).OnlyEnforceIf(p)
|
|
235
|
+
m.Add(xs[top_e] >= xs[top_f]).OnlyEnforceIf(p.Not())
|
|
236
|
+
m.Add(xs[bot_e] <= xs[bot_f] - 4).OnlyEnforceIf(q)
|
|
237
|
+
m.Add(xs[bot_e] >= xs[bot_f]).OnlyEnforceIf(q.Not())
|
|
238
|
+
c = m.NewBoolVar("")
|
|
239
|
+
m.Add(p + q == 1).OnlyEnforceIf(c)
|
|
240
|
+
m.Add(p + q != 1).OnlyEnforceIf(c.Not())
|
|
241
|
+
cross_vars.append(c)
|
|
242
|
+
# edge-length term |dx|
|
|
243
|
+
lens = []
|
|
244
|
+
for a, b, _, _ in einfo:
|
|
245
|
+
d = m.NewIntVar(0, max_x, "")
|
|
246
|
+
m.AddAbsEquality(d, xs[a] - xs[b])
|
|
247
|
+
lens.append(d)
|
|
248
|
+
m.Minimize(60 * sum(cross_vars) + sum(lens))
|
|
249
|
+
solver = cp_model.CpSolver()
|
|
250
|
+
solver.parameters.num_workers = 1 # determinism iron rule
|
|
251
|
+
solver.parameters.random_seed = 42
|
|
252
|
+
# A wall-clock limit drifts with machine load (breaking determinism) - the budget uses only the deterministic-time caliber;
|
|
253
|
+
# no budget = solve to optimality (historical behavior; small graphs were fast anyway)
|
|
254
|
+
if det_budget is not None:
|
|
255
|
+
solver.parameters.max_deterministic_time = det_budget
|
|
256
|
+
st = solver.Solve(m)
|
|
257
|
+
if st == cp_model.OPTIMAL:
|
|
258
|
+
return {v: int(solver.Value(xs[v])) for v in nodes}, "optimal"
|
|
259
|
+
if st == cp_model.FEASIBLE:
|
|
260
|
+
return {v: int(solver.Value(xs[v])) for v in nodes}, "feasible"
|
|
261
|
+
return None, "no-solution"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _construct_positions(nodes, rows, widths, cfg):
|
|
265
|
+
"""Constructive method: barycenter order + even in-row placement + row centering (deterministic)."""
|
|
266
|
+
pos = {}
|
|
267
|
+
order = {}
|
|
268
|
+
for r, vs in enumerate(rows):
|
|
269
|
+
order[r] = sorted(vs)
|
|
270
|
+
# simple barycenter sweeps (3 rounds, alternating up/down)
|
|
271
|
+
row_of = {v: r for r, vs in enumerate(rows) for v in vs}
|
|
272
|
+
neigh = defaultdict(list)
|
|
273
|
+
for (a, b) in EDGES_REF[0]:
|
|
274
|
+
neigh[a].append(b)
|
|
275
|
+
neigh[b].append(a)
|
|
276
|
+
for _ in range(3):
|
|
277
|
+
for r in list(range(len(rows))) + list(range(len(rows) - 1, -1, -1)):
|
|
278
|
+
vs = rows[r]
|
|
279
|
+
key = {}
|
|
280
|
+
for v in vs:
|
|
281
|
+
ups = [order[row_of[u]].index(u) if u in order[row_of[u]] else 0
|
|
282
|
+
for u in neigh[v]]
|
|
283
|
+
key[v] = (sum(ups) / len(ups)) if ups else order[r].index(v)
|
|
284
|
+
order[r] = sorted(vs, key=lambda v: (key[v], v))
|
|
285
|
+
for r, vs in enumerate(rows):
|
|
286
|
+
xs, cursor = [], 0
|
|
287
|
+
for v in order[r]:
|
|
288
|
+
xs.append(cursor + widths[v] / 2 + cfg.min_gap)
|
|
289
|
+
cursor += widths[v] + cfg.min_gap * 2
|
|
290
|
+
total = xs[-1] + widths[vs[-1]] / 2 + cfg.min_gap
|
|
291
|
+
shift = (max(0, _content_w(rows, widths, cfg)) - total) / 2
|
|
292
|
+
for v, x in zip(vs, xs):
|
|
293
|
+
pos[v] = int(round((x + shift) / cfg.grid)) * cfg.grid
|
|
294
|
+
return pos
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _content_w(rows, widths, cfg):
|
|
298
|
+
return max((sum(widths[v] for v in vs) + cfg.min_gap * (len(vs) + 1))
|
|
299
|
+
for vs in rows) + 2 * cfg.margin
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _route_total(nodes_px, edges, cfg, hard_gates=False, quality=False):
|
|
303
|
+
"""Routing ground-truth total cost: a weighted sum of press/clearance/crossing/coincidence/edge length (the layout layer's true objective function).
|
|
304
|
+
|
|
305
|
+
hard_gates: hard clearance/anchor penalties (1500 per violation). Default
|
|
306
|
+
False - the historical scoring caliber stays unchanged
|
|
307
|
+
(polish trajectories of the classic domain and existing passing cases stay
|
|
308
|
+
byte-for-byte); enabled only on the gated reorder path of routing-quality
|
|
309
|
+
rework. quality: the Router expands the stair candidate pool, likewise
|
|
310
|
+
gated path only.
|
|
311
|
+
t_c6ab760d breathing room (14 hand-drawn samples: min_card_gap averaged
|
|
312
|
+
+35.8px): adds a soft penalty targeting a 36px
|
|
313
|
+
card gap (penalized 8x by the shortfall), raising the gap weight to the
|
|
314
|
+
first tier;
|
|
315
|
+
no hard gate (crowding is caught by the existing clearance gate).
|
|
316
|
+
"""
|
|
317
|
+
from .metrics import rect_gap
|
|
318
|
+
router = Router(nodes_px, edges, cfg.clearance, cfg.pitch, quality=quality)
|
|
319
|
+
routes = router.route_all()
|
|
320
|
+
eset = set(edges)
|
|
321
|
+
total = 0.0
|
|
322
|
+
clr_hard = 0
|
|
323
|
+
ang_hard = 0
|
|
324
|
+
for i, info in routes.items():
|
|
325
|
+
a, b = edges[i]
|
|
326
|
+
clr = info.get("min_clear")
|
|
327
|
+
clr_pen = (30 * max(0.0, cfg.clearance - clr)) if clr is not None else 30 * cfg.clearance
|
|
328
|
+
if (b, a) in eset: # F1: tightened mutual-pair clearance, same caliber as _evaluate
|
|
329
|
+
clr_pen += 120 * (max(0.0, cfg.clearance - clr) if clr is not None
|
|
330
|
+
else cfg.clearance)
|
|
331
|
+
if hard_gates and clr is not None and clr < cfg.clearance - 1e-6:
|
|
332
|
+
clr_hard += 1
|
|
333
|
+
if hard_gates and min(info.get("ang_a", ANCHOR_MIN_DEG),
|
|
334
|
+
info.get("ang_b", ANCHOR_MIN_DEG)) < ANCHOR_MIN_DEG - 1e-9:
|
|
335
|
+
ang_hard += 1
|
|
336
|
+
total += (4000 * info.get("press", 0) + 40 * info.get("cross", 0) +
|
|
337
|
+
PAIR_SEP_W * info.get("sep_def", 0.0) +
|
|
338
|
+
(200.0 if info.get("kind") == "wrap" else 0.0) +
|
|
339
|
+
clr_pen +
|
|
340
|
+
0.01 * sum(math.dist(info["points"][k], info["points"][k + 1])
|
|
341
|
+
for k in range(len(info["points"]) - 1)))
|
|
342
|
+
# CI hard-gate feedback: heavy penalty when crossing pairs > 2 (certificate gate: crossings <= 2)
|
|
343
|
+
total += 1500.0 * max(0, router.final_crossing_pairs(routes) - 2)
|
|
344
|
+
total += 1500.0 * clr_hard
|
|
345
|
+
total += 1500.0 * ang_hard
|
|
346
|
+
# t_c6ab760d breathing room: card gap >=36px target (soft penalty, first tier)
|
|
347
|
+
ids = list(nodes_px)
|
|
348
|
+
if len(ids) > 1:
|
|
349
|
+
mg = min(rect_gap(*nodes_px[u], *nodes_px[v])
|
|
350
|
+
for i2, u in enumerate(ids) for v in ids[i2 + 1:])
|
|
351
|
+
total += 8.0 * max(0.0, GAP_TARGET - mg)
|
|
352
|
+
return total, routes, router
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
GAP_TARGET = 36.0 # breathing room: card-gap target (jacky's 14 hand-drawn samples averaged +35.8px)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _interleave_count(edges, order):
|
|
359
|
+
"""Topological interleaving-pair count (a proxy for the crossing lower bound that routing cannot avoid under a fixed row assignment).
|
|
360
|
+
|
|
361
|
+
Two independent edges (no shared endpoint) whose row intervals overlap by
|
|
362
|
+
>=1 row and whose in-row orders are reversed at both endpoints ->
|
|
363
|
+
an interleaving pair. Equal in-row order (same column) is not counted.
|
|
364
|
+
O(m^2) pure arithmetic, deterministic.
|
|
365
|
+
"""
|
|
366
|
+
pos, row_of = {}, {}
|
|
367
|
+
for r, vs in enumerate(order):
|
|
368
|
+
for k, v in enumerate(vs):
|
|
369
|
+
pos[v] = k
|
|
370
|
+
row_of[v] = r
|
|
371
|
+
f = 0
|
|
372
|
+
for i in range(len(edges)):
|
|
373
|
+
a1, b1 = edges[i]
|
|
374
|
+
for j in range(i + 1, len(edges)):
|
|
375
|
+
a2, b2 = edges[j]
|
|
376
|
+
if {a1, b1} & {a2, b2}:
|
|
377
|
+
continue
|
|
378
|
+
r11, r12 = row_of[a1], row_of[b1]
|
|
379
|
+
r21, r22 = row_of[a2], row_of[b2]
|
|
380
|
+
lo1, hi1 = min(r11, r12), max(r11, r12)
|
|
381
|
+
lo2, hi2 = min(r21, r22), max(r21, r22)
|
|
382
|
+
if min(hi1, hi2) - max(lo1, lo2) < 1:
|
|
383
|
+
continue
|
|
384
|
+
t1 = a1 if r11 < r12 else b1
|
|
385
|
+
q1 = b1 if r11 < r12 else a1
|
|
386
|
+
t2 = a2 if r21 < r22 else b2
|
|
387
|
+
q2 = b2 if r21 < r22 else a2
|
|
388
|
+
if pos[t1] == pos[t2] or pos[q1] == pos[q2]:
|
|
389
|
+
continue
|
|
390
|
+
if (pos[t1] > pos[t2]) != (pos[q1] > pos[q2]):
|
|
391
|
+
f += 1
|
|
392
|
+
return f
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _order_score(edges, order):
|
|
396
|
+
"""Reorder objective: interleaving pairs primary, row-distance*column-distance edge length secondary (keeps things compact on ties)."""
|
|
397
|
+
pos, row_of = {}, {}
|
|
398
|
+
for r, vs in enumerate(order):
|
|
399
|
+
for k, v in enumerate(vs):
|
|
400
|
+
pos[v] = k
|
|
401
|
+
row_of[v] = r
|
|
402
|
+
lensum = 0
|
|
403
|
+
for a, b in edges:
|
|
404
|
+
lensum += abs(pos[a] - pos[b]) + abs(row_of[a] - row_of[b])
|
|
405
|
+
return _interleave_count(edges, order) * 1000.0 + lensum
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _reorder_rows(edges, rows):
|
|
409
|
+
"""Deterministic (row, in-row order) local search: minimizes topological interleaving pairs (routing-quality rework).
|
|
410
|
+
|
|
411
|
+
Neighborhood = all same-row swaps + all single-node cross-row moves
|
|
412
|
+
(keeping >=1 node per row).
|
|
413
|
+
Steepest descent to a local optimum; then a deterministic perturbation
|
|
414
|
+
(take the best non-improving neighbor) and descend again,
|
|
415
|
+
REORDER_CYCLES rounds in total, tracking the global best throughout.
|
|
416
|
+
No RNG; same input, same output.
|
|
417
|
+
"""
|
|
418
|
+
REORDER_CYCLES = 16
|
|
419
|
+
order = [list(vs) for vs in rows]
|
|
420
|
+
|
|
421
|
+
def neighbors(cur):
|
|
422
|
+
out = []
|
|
423
|
+
for r, vs in enumerate(cur):
|
|
424
|
+
for i in range(len(vs)):
|
|
425
|
+
for j in range(i + 1, len(vs)):
|
|
426
|
+
c2 = [list(v) for v in cur]
|
|
427
|
+
c2[r][i], c2[r][j] = c2[r][j], c2[r][i]
|
|
428
|
+
out.append(c2)
|
|
429
|
+
for r, vs in enumerate(cur):
|
|
430
|
+
for i, v in enumerate(vs):
|
|
431
|
+
for r2, vs2 in enumerate(cur):
|
|
432
|
+
if r2 == r or len(cur[r]) < 2:
|
|
433
|
+
continue
|
|
434
|
+
for p in range(len(vs2) + 1):
|
|
435
|
+
c2 = [list(v) for v in cur]
|
|
436
|
+
c2[r].pop(i)
|
|
437
|
+
c2[r2].insert(p, v)
|
|
438
|
+
out.append(c2)
|
|
439
|
+
return out
|
|
440
|
+
|
|
441
|
+
def descend(cur):
|
|
442
|
+
cur_sc = _order_score(edges, cur)
|
|
443
|
+
while True:
|
|
444
|
+
best, best_sc = None, cur_sc
|
|
445
|
+
for cand in neighbors(cur):
|
|
446
|
+
sc = _order_score(edges, cand)
|
|
447
|
+
if sc < best_sc - 1e-9:
|
|
448
|
+
best, best_sc = cand, sc
|
|
449
|
+
if best is None:
|
|
450
|
+
return cur, cur_sc
|
|
451
|
+
cur, cur_sc = best, best_sc
|
|
452
|
+
|
|
453
|
+
best, best_sc = descend(order)
|
|
454
|
+
cur = order
|
|
455
|
+
for _ in range(REORDER_CYCLES):
|
|
456
|
+
# deterministic perturbation: the lowest-scoring non-improving neighbor of the current solution (first one on ties)
|
|
457
|
+
cur_sc = _order_score(edges, cur)
|
|
458
|
+
best_n, best_nsc = None, None
|
|
459
|
+
for cand in neighbors(cur):
|
|
460
|
+
sc = _order_score(edges, cand)
|
|
461
|
+
if best_n is None or sc < best_nsc - 1e-9:
|
|
462
|
+
best_n, best_nsc = cand, sc
|
|
463
|
+
if best_n is None:
|
|
464
|
+
break
|
|
465
|
+
cur, cur_sc = descend(best_n)
|
|
466
|
+
if cur_sc < best_sc - 1e-9:
|
|
467
|
+
best, best_sc = cur, cur_sc
|
|
468
|
+
return best
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _repack_x(order, widths, cfg):
|
|
472
|
+
"""Repack x in the reordered in-row order (24px snap, same caliber as construct, deterministic)."""
|
|
473
|
+
snap = cfg.snap_regular
|
|
474
|
+
pos = {}
|
|
475
|
+
for vs in order:
|
|
476
|
+
xs, cursor = [], 0
|
|
477
|
+
for v in vs:
|
|
478
|
+
xs.append(cursor + widths[v] / 2 + cfg.min_gap)
|
|
479
|
+
cursor += widths[v] + cfg.min_gap * 2
|
|
480
|
+
total = xs[-1] + widths[vs[-1]] / 2 + cfg.min_gap
|
|
481
|
+
shift = (max(0, _content_w(order, widths, cfg)) - total) / 2
|
|
482
|
+
for v, x in zip(vs, xs):
|
|
483
|
+
pos[v] = int(round((x + shift) / snap)) * snap
|
|
484
|
+
return pos
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _polish(nodes, edges, rows, pos, y, widths, card_h, cfg, rounds=2,
|
|
488
|
+
hard_gates=False):
|
|
489
|
+
"""Local search: per-node +/-24/+/-48 horizontal shifts (preserving same-row clearance), scored by routing ground-truth total cost.
|
|
490
|
+
|
|
491
|
+
A supplementary refinement over CP-SAT global placement - it explicitly
|
|
492
|
+
feeds "routing feasibility" back into the placement layer.
|
|
493
|
+
Deterministic: fixed traversal order + fixed perturbation set.
|
|
494
|
+
"""
|
|
495
|
+
def nodes_px(p):
|
|
496
|
+
return {v: (p[v], y[v], widths[v], card_h) for v in nodes}
|
|
497
|
+
|
|
498
|
+
best_pos = dict(pos)
|
|
499
|
+
best_score, best_routes, _ = _route_total(nodes_px(best_pos), edges, cfg,
|
|
500
|
+
hard_gates, quality=hard_gates)
|
|
501
|
+
row_of = {v: r for r, vs in enumerate(rows) for v in vs}
|
|
502
|
+
for _ in range(rounds):
|
|
503
|
+
improved = False
|
|
504
|
+
for v in sorted(nodes):
|
|
505
|
+
row = [u for u in rows[row_of[v]] if u != v]
|
|
506
|
+
for dx in (24, -24, 48, -48, 72, -72):
|
|
507
|
+
nx = best_pos[v] + dx
|
|
508
|
+
if any(abs(nx - best_pos[u]) <
|
|
509
|
+
(widths[v] + widths[u]) / 2 + cfg.min_gap for u in row):
|
|
510
|
+
continue
|
|
511
|
+
if nx < cfg.margin or nx > _content_w(rows, widths, cfg) - cfg.margin:
|
|
512
|
+
continue
|
|
513
|
+
cand = dict(best_pos)
|
|
514
|
+
cand[v] = nx
|
|
515
|
+
sc, routes, _ = _route_total(nodes_px(cand), edges, cfg,
|
|
516
|
+
hard_gates, quality=hard_gates)
|
|
517
|
+
if sc < best_score - 1e-6:
|
|
518
|
+
best_pos, best_score, best_routes = cand, sc, routes
|
|
519
|
+
improved = True
|
|
520
|
+
# adjacent in-row swaps (mechanizing the "a human can see a better spot" class of moves)
|
|
521
|
+
for vs in rows:
|
|
522
|
+
for i in range(len(vs) - 1):
|
|
523
|
+
u, w = vs[i], vs[i + 1]
|
|
524
|
+
cand = dict(best_pos)
|
|
525
|
+
cand[u], cand[w] = best_pos[w], best_pos[u]
|
|
526
|
+
sc, routes, _ = _route_total(nodes_px(cand), edges, cfg,
|
|
527
|
+
hard_gates, quality=hard_gates)
|
|
528
|
+
if sc < best_score - 1e-6:
|
|
529
|
+
best_pos, best_score, best_routes = cand, sc, routes
|
|
530
|
+
improved = True
|
|
531
|
+
if not improved:
|
|
532
|
+
break
|
|
533
|
+
return best_pos, best_score, best_routes
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _manual_waypoints(manual, edges):
|
|
537
|
+
"""--from-layout per-edge waypoints (t_fad260bd): {(a,b): [[x,y],...]}.
|
|
538
|
+
|
|
539
|
+
The export artifact of the demo-2d bend handles ("user anchoring").
|
|
540
|
+
Validation: (a,b) must be an edge of the graph and
|
|
541
|
+
every point a pair of numbers; coordinates are rounded (same caliber as
|
|
542
|
+
nodes); consecutive duplicate points <0.5px apart are
|
|
543
|
+
deduplicated; if empty after dedup it counts as not anchored.
|
|
544
|
+
Deterministic: same input, same output.
|
|
545
|
+
"""
|
|
546
|
+
out = {}
|
|
547
|
+
raw = manual.get("edges")
|
|
548
|
+
if raw is None:
|
|
549
|
+
return out
|
|
550
|
+
if not isinstance(raw, list):
|
|
551
|
+
raise ValueError("manual layout edges must be a [{a,b,waypoints}] list")
|
|
552
|
+
eset = set(edges)
|
|
553
|
+
for it in raw:
|
|
554
|
+
if not isinstance(it, dict):
|
|
555
|
+
raise ValueError(f"unrecognized manual layout edge entry: {it!r}")
|
|
556
|
+
a, b = it.get("a"), it.get("b")
|
|
557
|
+
if (a, b) not in eset:
|
|
558
|
+
raise ValueError(f"manual layout waypoints reference an edge not in the graph: {a}->{b}")
|
|
559
|
+
wps = it.get("waypoints")
|
|
560
|
+
if wps is None:
|
|
561
|
+
continue
|
|
562
|
+
if not isinstance(wps, list):
|
|
563
|
+
raise ValueError(f"waypoints of edge {a}->{b} must be a [[x,y],...] list")
|
|
564
|
+
pts = []
|
|
565
|
+
for p in wps:
|
|
566
|
+
if not isinstance(p, (list, tuple)) or len(p) < 2:
|
|
567
|
+
raise ValueError(f"unrecognized waypoint format for edge {a}->{b}: {p!r}")
|
|
568
|
+
try:
|
|
569
|
+
x, y = float(p[0]), float(p[1])
|
|
570
|
+
except (TypeError, ValueError):
|
|
571
|
+
raise ValueError(f"waypoint coordinates of edge {a}->{b} are not numeric: {p!r}")
|
|
572
|
+
if not (math.isfinite(x) and math.isfinite(y)):
|
|
573
|
+
raise ValueError(f"waypoint coordinates of edge {a}->{b} are not finite: {p!r}")
|
|
574
|
+
q = (int(round(x)), int(round(y)))
|
|
575
|
+
if not pts or math.dist(pts[-1], q) > 0.5:
|
|
576
|
+
pts.append(q)
|
|
577
|
+
if pts:
|
|
578
|
+
out[(a, b)] = pts
|
|
579
|
+
return out
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _manual_positions(nodes, widths, manual):
|
|
583
|
+
"""--from-layout: consume a manual layout (a layout.json-compatible subset), returns (pos, y, rows, meta).
|
|
584
|
+
|
|
585
|
+
Manual coordinates are rounded and aligned to the 2px grid (matching the
|
|
586
|
+
geometry self-check 6b caliber; snap effects are recorded honestly);
|
|
587
|
+
card widths are recomputed from labels via textmetrics (same caliber as the
|
|
588
|
+
automatic pipeline). The id set must match the graph exactly
|
|
589
|
+
(missing/extra nodes -> ValueError, which the CLI turns into an honest error).
|
|
590
|
+
"""
|
|
591
|
+
mn = manual.get("nodes") or {}
|
|
592
|
+
if not isinstance(mn, dict) or not mn:
|
|
593
|
+
raise ValueError("manual layout is missing nodes (node id -> x,y)")
|
|
594
|
+
def _xy(entry):
|
|
595
|
+
if isinstance(entry, dict):
|
|
596
|
+
return entry.get("x"), entry.get("y")
|
|
597
|
+
if isinstance(entry, (list, tuple)) and len(entry) >= 2:
|
|
598
|
+
return entry[0], entry[1]
|
|
599
|
+
raise ValueError(f"unrecognized manual layout node format: {entry!r}")
|
|
600
|
+
missing = [v for v in nodes if v not in mn]
|
|
601
|
+
extra = [v for v in mn if v not in set(nodes)]
|
|
602
|
+
if missing or extra:
|
|
603
|
+
raise ValueError(f"manual layout node set does not match the graph: missing {missing}, extra {extra}")
|
|
604
|
+
pos, y, snapped = {}, {}, []
|
|
605
|
+
for v in nodes:
|
|
606
|
+
xv, yv = _xy(mn[v])
|
|
607
|
+
try:
|
|
608
|
+
xi, yi = int(round(float(xv))), int(round(float(yv)))
|
|
609
|
+
except (TypeError, ValueError):
|
|
610
|
+
raise ValueError(f"manual layout node {v} coordinates are not numeric: {xv!r},{yv!r}")
|
|
611
|
+
if xi % 2 or yi % 2:
|
|
612
|
+
snapped.append(v)
|
|
613
|
+
pos[v] = xi - (xi % 2) # align to the 2px grid (geometry self-check 6b caliber)
|
|
614
|
+
y[v] = yi - (yi % 2)
|
|
615
|
+
rows = [[v for v in nodes if y[v] == yy] for yy in sorted({y[v] for v in nodes})]
|
|
616
|
+
meta = {"source": manual.get("source"),
|
|
617
|
+
"manual_viewBox": manual.get("viewBox"),
|
|
618
|
+
"snapped_to_2px": sorted(snapped)}
|
|
619
|
+
return pos, y, rows, meta
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _manual_edge_styles(manual, edges):
|
|
623
|
+
"""--from-layout per-edge forced line styles (v0.7.7): {(a,b): style}.
|
|
624
|
+
|
|
625
|
+
The export artifact of the demo-2d per-edge style cycle
|
|
626
|
+
(auto/straight/arc/free). Validation mirrors _manual_waypoints: (a,b)
|
|
627
|
+
must be a graph edge; style must be one of the four tokens; "auto"
|
|
628
|
+
entries are dropped (engine default). "free" is carried by waypoints
|
|
629
|
+
(kind=waypoints path), not by this dict.
|
|
630
|
+
"""
|
|
631
|
+
out = {}
|
|
632
|
+
raw = manual.get("edges")
|
|
633
|
+
if raw is None:
|
|
634
|
+
return out
|
|
635
|
+
if not isinstance(raw, list):
|
|
636
|
+
raise ValueError("manual layout edges must be a [{a,b,style}] list")
|
|
637
|
+
eset = set(edges)
|
|
638
|
+
for it in raw:
|
|
639
|
+
if not isinstance(it, dict):
|
|
640
|
+
raise ValueError(f"unrecognized manual layout edge entry: {it!r}")
|
|
641
|
+
a, b = it.get("a"), it.get("b")
|
|
642
|
+
if (a, b) not in eset:
|
|
643
|
+
raise ValueError(f"manual layout styles reference an edge not in the graph: {a}->{b}")
|
|
644
|
+
st = it.get("style")
|
|
645
|
+
if st is None:
|
|
646
|
+
continue
|
|
647
|
+
if st not in ("auto", "straight", "arc", "free"):
|
|
648
|
+
raise ValueError(f"unrecognized line style for edge {a}->{b}: {st!r}")
|
|
649
|
+
if st in ("straight", "arc"):
|
|
650
|
+
out[(a, b)] = st
|
|
651
|
+
return out
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _forced_arc_route(nodes_px, a, b, clearance):
|
|
655
|
+
"""Deterministic forced-arc geometry (v0.7.7 style replay, Python side).
|
|
656
|
+
|
|
657
|
+
Same rule as the demo-2d drag layer: anchors = box borders facing each
|
|
658
|
+
other; curvature = chord-adaptive k (routing.adaptive_arc_k); side = the
|
|
659
|
+
sign whose sampled arc keeps more distance from unrelated boxes (tie ->
|
|
660
|
+
+1). Returns (points, ctrl)."""
|
|
661
|
+
from .routing import border_point, quad_points, adaptive_arc_k, dist_point_rect
|
|
662
|
+
A, B = nodes_px[a], nodes_px[b]
|
|
663
|
+
pa = border_point(A[0], A[1], A[2] / 2.0, A[3] / 2.0, B[0] - A[0], B[1] - A[1])
|
|
664
|
+
pb = border_point(B[0], B[1], B[2] / 2.0, B[3] / 2.0, A[0] - B[0], A[1] - B[1])
|
|
665
|
+
vx, vy = pb[0] - pa[0], pb[1] - pa[1]
|
|
666
|
+
dist = math.hypot(vx, vy)
|
|
667
|
+
k = adaptive_arc_k(dist)
|
|
668
|
+
n = max(dist, 1e-6)
|
|
669
|
+
ux, uy = -vy / n, vx / n
|
|
670
|
+
best = None
|
|
671
|
+
for s in (1, -1):
|
|
672
|
+
ctrl = [(pa[0] + pb[0]) / 2 + ux * k * dist * s,
|
|
673
|
+
(pa[1] + pb[1]) / 2 + uy * k * dist * s]
|
|
674
|
+
pts = quad_points(pa, ctrl, pb)
|
|
675
|
+
mclr = None
|
|
676
|
+
for v, rect in nodes_px.items():
|
|
677
|
+
if v in (a, b):
|
|
678
|
+
continue
|
|
679
|
+
for (px, py) in pts:
|
|
680
|
+
d = dist_point_rect(px, py, rect[0], rect[1],
|
|
681
|
+
rect[2] / 2.0, rect[3] / 2.0)
|
|
682
|
+
mclr = d if mclr is None else min(mclr, d)
|
|
683
|
+
key = (-(mclr if mclr is not None else 1e9), s)
|
|
684
|
+
if best is None or key < best[0]:
|
|
685
|
+
best = (key, pts, ctrl)
|
|
686
|
+
return best[1], best[2]
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _ring_trial(plan, nodes, widths, card_h, cfg, why=None):
|
|
690
|
+
"""Ring placement + routing trial: adopted only if every hard-gate proxy passes; otherwise None (fall back to layered, byte-for-byte unchanged).
|
|
691
|
+
|
|
692
|
+
why: optional dict — receives the trial-gate outcome reason (mode
|
|
693
|
+
decision trace).
|
|
694
|
+
|
|
695
|
+
The proxy caliber shares its source with the delivered certificate:
|
|
696
|
+
crossings (router theory) <=2, box press=0, edge-box clearance>=clearance,
|
|
697
|
+
mutual-pair separation >=4px, card gap >=16, anchor smoothing >=20deg.
|
|
698
|
+
The final word is certificate_auto's recheck on delivered coordinates (this
|
|
699
|
+
layer is only the deterministic adopt/fallback trial gate).
|
|
700
|
+
"""
|
|
701
|
+
from . import ringlayout
|
|
702
|
+
from .routing import (min_pair_dist, sample_polyline, ANCHOR_MIN_DEG)
|
|
703
|
+
|
|
704
|
+
def _why(reason):
|
|
705
|
+
if why is not None:
|
|
706
|
+
why["reason"] = reason
|
|
707
|
+
return None
|
|
708
|
+
adj, _ = ringlayout._adjacency(EDGES_REF[0])
|
|
709
|
+
try:
|
|
710
|
+
pos, y, meta = ringlayout.place_ring(plan, widths, card_h, cfg, adj,
|
|
711
|
+
EDGES_REF[0])
|
|
712
|
+
except ringlayout.InfeasibleRing:
|
|
713
|
+
return _why("trial: constructed placement infeasible (InfeasibleRing)")
|
|
714
|
+
nodes_px = {v: (pos[v], y[v], widths[v], card_h) for v in nodes}
|
|
715
|
+
# card gap >=16 (place_ring places at 18; a pairwise recheck is the backstop)
|
|
716
|
+
vs = sorted(nodes)
|
|
717
|
+
for i in range(len(vs)):
|
|
718
|
+
for j in range(i + 1, len(vs)):
|
|
719
|
+
a, b = vs[i], vs[j]
|
|
720
|
+
gap_x = abs(pos[a] - pos[b]) - (widths[a] + widths[b]) / 2
|
|
721
|
+
gap_y = abs(y[a] - y[b]) - card_h
|
|
722
|
+
if max(gap_x, gap_y) < cfg.min_gap - 2: # rect_gap caliber (>=16 gate)
|
|
723
|
+
return _why("trial gate: card gap < 16 (rect_gap caliber)")
|
|
724
|
+
router = Router(nodes_px, EDGES_REF[0], cfg.clearance, cfg.pitch)
|
|
725
|
+
routes = router.route_all()
|
|
726
|
+
if router.final_crossing_pairs(routes) > 2:
|
|
727
|
+
return _why("trial gate: router-theory crossings > 2")
|
|
728
|
+
eset = set(EDGES_REF[0])
|
|
729
|
+
for i, info in routes.items():
|
|
730
|
+
if info.get("press", 0):
|
|
731
|
+
return _why("trial gate: box press != 0")
|
|
732
|
+
clr = info.get("min_clear")
|
|
733
|
+
if clr is None or clr < cfg.clearance:
|
|
734
|
+
return _why("trial gate: edge-box clearance < 16px")
|
|
735
|
+
# anchor smoothing proxy (same caliber as check item 4)
|
|
736
|
+
pts = info["points"]
|
|
737
|
+
for v, p_idx, n_idx in ((EDGES_REF[0][i][0], 0, 1),
|
|
738
|
+
(EDGES_REF[0][i][1], len(pts) - 1, len(pts) - 2)):
|
|
739
|
+
p = nodes_px[v]
|
|
740
|
+
a0 = pts[p_idx]
|
|
741
|
+
if (abs(abs(a0[0] - p[0]) - p[2] / 2) > abs(abs(a0[1] - p[1]) - p[3] / 2) + 1e-9
|
|
742
|
+
and abs(abs(a0[1] - p[1]) - p[3] / 2) > 0.75):
|
|
743
|
+
return _why("trial gate: anchor not on the box edge")
|
|
744
|
+
dx, dy = pts[n_idx][0] - a0[0], pts[n_idx][1] - a0[1]
|
|
745
|
+
n = math.hypot(dx, dy)
|
|
746
|
+
if n < 1e-9:
|
|
747
|
+
return _why("trial gate: degenerate anchor segment")
|
|
748
|
+
on_x = abs(abs(a0[0] - p[0]) - p[2] / 2) <= abs(abs(a0[1] - p[1]) - p[3] / 2)
|
|
749
|
+
tx, ty = (0.0, 1.0) if on_x else (1.0, 0.0)
|
|
750
|
+
c = abs(dx * tx + dy * ty) / n
|
|
751
|
+
if math.degrees(math.acos(max(0.0, min(1.0, c)))) < ANCHOR_MIN_DEG:
|
|
752
|
+
return _why("trial gate: anchor smoothing < 20deg")
|
|
753
|
+
# mutual-pair separation >=4px (F1 caliber)
|
|
754
|
+
for i in range(len(EDGES_REF[0])):
|
|
755
|
+
for j in range(i + 1, len(EDGES_REF[0])):
|
|
756
|
+
if EDGES_REF[0][i] != (EDGES_REF[0][j][1], EDGES_REF[0][j][0]):
|
|
757
|
+
continue
|
|
758
|
+
md = min_pair_dist(sample_polyline(routes[i]["points"]),
|
|
759
|
+
sample_polyline(routes[j]["points"]))
|
|
760
|
+
if md is None or md < 4.0:
|
|
761
|
+
return _why("trial gate: mutual-pair separation < 4px")
|
|
762
|
+
if why is not None:
|
|
763
|
+
why["reason"] = "trial gate passed"
|
|
764
|
+
plan = dict(plan)
|
|
765
|
+
plan["placed"] = (pos, y, meta)
|
|
766
|
+
return plan
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _constructed_gates(nodes_px, edges, routes, cfg, constructed):
|
|
770
|
+
"""Unified hard-gate proxy over constructed routes (closed arcs / mirror-symmetry arcs) and routed routes (t_c6ab760d).
|
|
771
|
+
|
|
772
|
+
Same caliber as _ring_trial: press=0, edge-box clearance>=clearance,
|
|
773
|
+
anchors on box edges with smoothing
|
|
774
|
+
>=ANCHOR_MIN_DEG, card gap >= min_gap-2, mutual-pair separation >=4px,
|
|
775
|
+
crossings <=2.
|
|
776
|
+
Returns True = gate passed.
|
|
777
|
+
"""
|
|
778
|
+
from .routing import (dist_point_rect, min_pair_dist, sample_polyline,
|
|
779
|
+
ANCHOR_MIN_DEG)
|
|
780
|
+
# card gap (rect_gap caliber, >= min_gap-2)
|
|
781
|
+
vs = sorted(nodes_px)
|
|
782
|
+
for i in range(len(vs)):
|
|
783
|
+
for j in range(i + 1, len(vs)):
|
|
784
|
+
a, b = vs[i], vs[j]
|
|
785
|
+
ax, ay, aw, ah = nodes_px[a]
|
|
786
|
+
bx, by, bw, bh = nodes_px[b]
|
|
787
|
+
gap_x = abs(ax - bx) - (aw + bw) / 2
|
|
788
|
+
gap_y = abs(ay - by) - (ah + bh) / 2
|
|
789
|
+
if max(gap_x, gap_y) < cfg.min_gap - 2:
|
|
790
|
+
return False
|
|
791
|
+
if _final_pairs(nodes_px, edges, routes, cfg) > 2:
|
|
792
|
+
return False
|
|
793
|
+
for i, info in routes.items():
|
|
794
|
+
a, b = edges[i]
|
|
795
|
+
pts = info["points"]
|
|
796
|
+
# same caliber as check3: dense-sampled clearance recheck (a backstop for chord effects)
|
|
797
|
+
dense = sample_polyline([tuple(p) for p in pts], step=4.0)
|
|
798
|
+
for v, (cx, cy, w, h) in nodes_px.items():
|
|
799
|
+
if v in (a, b):
|
|
800
|
+
continue
|
|
801
|
+
for px, py in dense:
|
|
802
|
+
if dist_point_rect(px, py, cx, cy, w / 2, h / 2) < cfg.clearance:
|
|
803
|
+
return False
|
|
804
|
+
# anchor on box edge + smoothing
|
|
805
|
+
for v, p_idx, n_idx in ((a, 0, 1), (b, len(pts) - 1, len(pts) - 2)):
|
|
806
|
+
cx, cy, w, h = nodes_px[v]
|
|
807
|
+
a0 = pts[p_idx]
|
|
808
|
+
if (abs(abs(a0[0] - cx) - w / 2) > abs(abs(a0[1] - cy) - h / 2) + 1e-9
|
|
809
|
+
and abs(abs(a0[1] - cy) - h / 2) > 0.75):
|
|
810
|
+
return False
|
|
811
|
+
dx, dy = pts[n_idx][0] - a0[0], pts[n_idx][1] - a0[1]
|
|
812
|
+
n = math.hypot(dx, dy)
|
|
813
|
+
if n < 1e-9:
|
|
814
|
+
return False
|
|
815
|
+
on_x = abs(abs(a0[0] - cx) - w / 2) <= abs(abs(a0[1] - cy) - h / 2)
|
|
816
|
+
tx, ty = (0.0, 1.0) if on_x else (1.0, 0.0)
|
|
817
|
+
c = abs(dx * tx + dy * ty) / n
|
|
818
|
+
if math.degrees(math.acos(max(0.0, min(1.0, c)))) < ANCHOR_MIN_DEG:
|
|
819
|
+
return False
|
|
820
|
+
# mutual-pair separation >=4px
|
|
821
|
+
for i in range(len(edges)):
|
|
822
|
+
for j in range(i + 1, len(edges)):
|
|
823
|
+
if edges[i] != (edges[j][1], edges[j][0]):
|
|
824
|
+
continue
|
|
825
|
+
md = min_pair_dist(sample_polyline(routes[i]["points"]),
|
|
826
|
+
sample_polyline(routes[j]["points"]))
|
|
827
|
+
if md is None or md < 4.0:
|
|
828
|
+
return False
|
|
829
|
+
return True
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _final_pairs(nodes_px, edges, routes, cfg):
|
|
833
|
+
"""Crossing edge-pair count after merging constructed routes (same caliber as Router.final_crossing_pairs)."""
|
|
834
|
+
router = Router(nodes_px, edges, cfg.clearance, cfg.pitch)
|
|
835
|
+
return router.final_crossing_pairs(routes)
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def _sym_trial(plan, nodes, widths, card_h, cfg, why=None):
|
|
839
|
+
"""Bowtie-pair symmetric placement + routing trial gate: adopted only if the gate passes; otherwise None (fallback, byte-for-byte unchanged).
|
|
840
|
+
|
|
841
|
+
why: optional dict — receives the trial-gate outcome reason (mode
|
|
842
|
+
decision trace).
|
|
843
|
+
"""
|
|
844
|
+
from . import symlayout
|
|
845
|
+
edges = EDGES_REF[0]
|
|
846
|
+
try:
|
|
847
|
+
pos, y, meta, constructed = symlayout.place_bowtie(plan, widths,
|
|
848
|
+
card_h, cfg, edges)
|
|
849
|
+
except symlayout.InfeasibleBowtie:
|
|
850
|
+
if why is not None:
|
|
851
|
+
why["reason"] = ("trial: constructed placement infeasible "
|
|
852
|
+
"(InfeasibleBowtie)")
|
|
853
|
+
return None
|
|
854
|
+
nodes_px = {v: (pos[v], y[v], widths[v], card_h) for v in nodes}
|
|
855
|
+
router = Router(nodes_px, edges, cfg.clearance, cfg.pitch)
|
|
856
|
+
routes = router.route_all()
|
|
857
|
+
for i, (a, b) in enumerate(edges):
|
|
858
|
+
cr = constructed.get((a, b))
|
|
859
|
+
if cr is not None:
|
|
860
|
+
routes[i] = dict(cr)
|
|
861
|
+
# line-style consistency hard rule: no vg/poly polylines inside the symmetric structure (arc > straight > polyline)
|
|
862
|
+
for i, info in routes.items():
|
|
863
|
+
if info["kind"] in ("vg", "poly", "wrap"):
|
|
864
|
+
if why is not None:
|
|
865
|
+
why["reason"] = ("trial gate: polyline route inside the "
|
|
866
|
+
"symmetric structure (line-type consistency "
|
|
867
|
+
"hard rule)")
|
|
868
|
+
return None
|
|
869
|
+
if not _constructed_gates(nodes_px, edges, routes, cfg, constructed):
|
|
870
|
+
if why is not None:
|
|
871
|
+
why["reason"] = ("trial gate: hard-gate proxy failed "
|
|
872
|
+
"(crossings<=2 / press=0 / clearance>=16 / "
|
|
873
|
+
"smooth anchors / mutual separation / card gap)")
|
|
874
|
+
return None
|
|
875
|
+
if why is not None:
|
|
876
|
+
why["reason"] = "trial gate passed"
|
|
877
|
+
return {"placed": (pos, y, meta, constructed), "deg0": plan["deg0"]}
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _bowtie_trial(plan, nodes, widths, card_h, cfg, why=None):
|
|
881
|
+
"""Bowtie-vertex symmetric placement + routing trial gate: adopted only if the gate passes; otherwise None (fallback, byte-for-byte unchanged).
|
|
882
|
+
|
|
883
|
+
why: optional dict — receives the trial-gate outcome reason (mode
|
|
884
|
+
decision trace).
|
|
885
|
+
|
|
886
|
+
t_7fe64f08 (aesthetics v3): graphs with exactly 2 simple loops sharing
|
|
887
|
+
exactly 1 node (the wage-price-spiral
|
|
888
|
+
family, benchmarked on jacky's hand-drawn samples) -> bowtie-vertex
|
|
889
|
+
symmetric placement; all-straight-line construction,
|
|
890
|
+
line-style consistency hard rule same as symmetric (vg/poly/wrap polylines
|
|
891
|
+
forbidden).
|
|
892
|
+
"""
|
|
893
|
+
from . import bowtielayout
|
|
894
|
+
edges = EDGES_REF[0]
|
|
895
|
+
try:
|
|
896
|
+
pos, y, meta, constructed = bowtielayout.place_bowtie_vertex(
|
|
897
|
+
plan, widths, card_h, cfg, edges)
|
|
898
|
+
except bowtielayout.InfeasibleBowtieVertex:
|
|
899
|
+
if why is not None:
|
|
900
|
+
why["reason"] = ("trial: constructed placement infeasible "
|
|
901
|
+
"(InfeasibleBowtieVertex)")
|
|
902
|
+
return None
|
|
903
|
+
nodes_px = {v: (pos[v], y[v], widths[v], card_h) for v in nodes}
|
|
904
|
+
router = Router(nodes_px, edges, cfg.clearance, cfg.pitch)
|
|
905
|
+
routes = router.route_all()
|
|
906
|
+
for i, (a, b) in enumerate(edges):
|
|
907
|
+
cr = constructed.get((a, b))
|
|
908
|
+
if cr is not None:
|
|
909
|
+
routes[i] = dict(cr)
|
|
910
|
+
# line-style consistency hard rule: no vg/poly polylines inside the symmetric structure (arc > straight > polyline)
|
|
911
|
+
for i, info in routes.items():
|
|
912
|
+
if info["kind"] in ("vg", "poly", "wrap"):
|
|
913
|
+
if why is not None:
|
|
914
|
+
why["reason"] = ("trial gate: polyline route inside the "
|
|
915
|
+
"symmetric structure (line-type consistency "
|
|
916
|
+
"hard rule)")
|
|
917
|
+
return None
|
|
918
|
+
if not _constructed_gates(nodes_px, edges, routes, cfg, constructed):
|
|
919
|
+
if why is not None:
|
|
920
|
+
why["reason"] = ("trial gate: hard-gate proxy failed "
|
|
921
|
+
"(crossings<=2 / press=0 / clearance>=16 / "
|
|
922
|
+
"smooth anchors / mutual separation / card gap)")
|
|
923
|
+
return None
|
|
924
|
+
if why is not None:
|
|
925
|
+
why["reason"] = "trial gate passed"
|
|
926
|
+
return {"placed": (pos, y, meta, constructed), "deg0": plan["deg0"]}
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def _spine_trial(plan, nodes, widths, card_h, cfg, why=None):
|
|
930
|
+
"""Spine-wing placement + routing trial gate: adopted only if the gate passes; otherwise None (fallback, byte-for-byte unchanged).
|
|
931
|
+
|
|
932
|
+
why: optional dict — receives the trial-gate outcome reason (mode
|
|
933
|
+
decision trace).
|
|
934
|
+
|
|
935
|
+
t_8f211f74 (v0.7.1): hub pair on the spine + midline wing band + mirrored
|
|
936
|
+
clusters beyond the wings
|
|
937
|
+
(the classic-cld-growth-and-burden family, benchmarked on jacky's
|
|
938
|
+
hand-drawn samples); spine edges
|
|
939
|
+
are injected as constructed vertical straight lines, the rest go through
|
|
940
|
+
the Router candidate pool.
|
|
941
|
+
"""
|
|
942
|
+
from . import spinewinglayout
|
|
943
|
+
edges = EDGES_REF[0]
|
|
944
|
+
try:
|
|
945
|
+
pos, y, meta, constructed = spinewinglayout.place_spine_wing(
|
|
946
|
+
plan, widths, card_h, cfg, edges)
|
|
947
|
+
except spinewinglayout.InfeasibleSpineWing:
|
|
948
|
+
if why is not None:
|
|
949
|
+
why["reason"] = ("trial: constructed placement infeasible "
|
|
950
|
+
"(InfeasibleSpineWing)")
|
|
951
|
+
return None
|
|
952
|
+
nodes_px = {v: (pos[v], y[v], widths[v], card_h) for v in nodes}
|
|
953
|
+
router = Router(nodes_px, edges, cfg.clearance, cfg.pitch)
|
|
954
|
+
routes = router.route_all()
|
|
955
|
+
for i, (a, b) in enumerate(edges):
|
|
956
|
+
cr = constructed.get((a, b))
|
|
957
|
+
if cr is not None:
|
|
958
|
+
routes[i] = dict(cr)
|
|
959
|
+
if not _constructed_gates(nodes_px, edges, routes, cfg, constructed):
|
|
960
|
+
if why is not None:
|
|
961
|
+
why["reason"] = ("trial gate: hard-gate proxy failed "
|
|
962
|
+
"(crossings<=2 / press=0 / clearance>=16 / "
|
|
963
|
+
"smooth anchors / mutual separation / card gap)")
|
|
964
|
+
return None
|
|
965
|
+
if why is not None:
|
|
966
|
+
why["reason"] = "trial gate passed"
|
|
967
|
+
return {"placed": (pos, y, meta, constructed), "deg0": plan["deg0"]}
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def _comb_trial(plan, nodes, widths, card_h, cfg, why=None):
|
|
971
|
+
"""Comb-bilateral placement + routing trial gate: adopted only if every
|
|
972
|
+
route is a straight line (the comb contract: column spine vertical +
|
|
973
|
+
hub fan straight) AND the unified hard-gate proxy passes; otherwise None
|
|
974
|
+
(fall back to the next tier, byte-for-byte unchanged).
|
|
975
|
+
|
|
976
|
+
v0.7.7 (t_be5b4175): K_{m,2} bilateral family — two non-adjacent hubs,
|
|
977
|
+
every source connected to both, pendant trees on the source side.
|
|
978
|
+
Geometric ground truth = jacky's hand-pulled HDD supply-chain layout.
|
|
979
|
+
"""
|
|
980
|
+
from . import comblayout
|
|
981
|
+
edges = EDGES_REF[0]
|
|
982
|
+
try:
|
|
983
|
+
pos, y, meta, constructed = comblayout.place_comb(plan, widths,
|
|
984
|
+
card_h, cfg)
|
|
985
|
+
except comblayout.InfeasibleComb:
|
|
986
|
+
if why is not None:
|
|
987
|
+
why["reason"] = ("trial: constructed placement infeasible "
|
|
988
|
+
"(InfeasibleComb — both fan tiers exhausted)")
|
|
989
|
+
return None
|
|
990
|
+
nodes_px = {v: (pos[v], y[v], widths[v], card_h) for v in nodes}
|
|
991
|
+
router = Router(nodes_px, edges, cfg.clearance, cfg.pitch)
|
|
992
|
+
routes = router.route_all()
|
|
993
|
+
# v0.7.8 tier-1 wide-card fan: hub edges constructed between
|
|
994
|
+
# facing-vertical-border anchors replace the routed candidates here (the
|
|
995
|
+
# same injection channel the delivery layer uses); the contract check
|
|
996
|
+
# and the unified hard gates below then review the injected geometry
|
|
997
|
+
# like any other route.
|
|
998
|
+
if constructed:
|
|
999
|
+
idx = {e: i for i, e in enumerate(edges)}
|
|
1000
|
+
for key, cr in constructed.items():
|
|
1001
|
+
i = idx.get(key)
|
|
1002
|
+
if i is None:
|
|
1003
|
+
continue # mirrored direction key absent from the
|
|
1004
|
+
# declared edge list — nothing to replace
|
|
1005
|
+
routes[i] = {"kind": cr["kind"],
|
|
1006
|
+
"points": [list(p) for p in cr["points"]],
|
|
1007
|
+
"press": 0, "min_clear": None, "cross": 0,
|
|
1008
|
+
"cross_edges": 0, "ang_a": 90.0, "ang_b": 90.0}
|
|
1009
|
+
for i, info in routes.items():
|
|
1010
|
+
if info["kind"] != "line":
|
|
1011
|
+
if why is not None:
|
|
1012
|
+
why["reason"] = ("trial gate: comb contract broken — "
|
|
1013
|
+
f"non-straight route ({info['kind']}) in "
|
|
1014
|
+
"the hub fan")
|
|
1015
|
+
return None # comb contract: all-straight fan
|
|
1016
|
+
if not _constructed_gates(nodes_px, edges, routes, cfg, {}):
|
|
1017
|
+
if why is not None:
|
|
1018
|
+
why["reason"] = ("trial gate: hard-gate proxy failed "
|
|
1019
|
+
"(crossings<=2 / press=0 / clearance>=16 / "
|
|
1020
|
+
"smooth anchors / mutual separation / card gap)")
|
|
1021
|
+
return None
|
|
1022
|
+
if why is not None:
|
|
1023
|
+
why["reason"] = "trial gate passed (all-straight fan contract held)"
|
|
1024
|
+
plan = dict(plan)
|
|
1025
|
+
plan["placed"] = (pos, y, meta)
|
|
1026
|
+
plan["constructed"] = {k: dict(v) for k, v in constructed.items()}
|
|
1027
|
+
return plan
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def _column_trial(plan, nodes, widths, card_h, cfg, why=None):
|
|
1031
|
+
"""Column placement + routing trial gate: adopted only if the gate passes; otherwise None (fall back to layered, byte-for-byte unchanged).
|
|
1032
|
+
|
|
1033
|
+
why: optional dict — receives the trial-gate outcome reason (mode
|
|
1034
|
+
decision trace).
|
|
1035
|
+
"""
|
|
1036
|
+
from . import columnlayout
|
|
1037
|
+
from .routing import min_pair_dist, sample_polyline
|
|
1038
|
+
edges = EDGES_REF[0]
|
|
1039
|
+
adj = columnlayout._adjacency(edges)
|
|
1040
|
+
try:
|
|
1041
|
+
pos, y, meta, constructed = columnlayout.place_column(
|
|
1042
|
+
plan, widths, card_h, cfg, adj, edges)
|
|
1043
|
+
except columnlayout.InfeasibleColumn:
|
|
1044
|
+
if why is not None:
|
|
1045
|
+
why["reason"] = ("trial: constructed placement infeasible "
|
|
1046
|
+
"(InfeasibleColumn)")
|
|
1047
|
+
return None
|
|
1048
|
+
nodes_px = {v: (pos[v], y[v], widths[v], card_h) for v in nodes}
|
|
1049
|
+
router = Router(nodes_px, edges, cfg.clearance, cfg.pitch)
|
|
1050
|
+
routes = router.route_all()
|
|
1051
|
+
for i, (a, b) in enumerate(edges):
|
|
1052
|
+
cr = constructed.get((a, b))
|
|
1053
|
+
if cr is not None:
|
|
1054
|
+
routes[i] = dict(cr)
|
|
1055
|
+
if not _constructed_gates(nodes_px, edges, routes, cfg, constructed):
|
|
1056
|
+
if why is not None:
|
|
1057
|
+
why["reason"] = ("trial gate: hard-gate proxy failed "
|
|
1058
|
+
"(crossings<=2 / press=0 / clearance>=16 / "
|
|
1059
|
+
"smooth anchors / mutual separation / card gap)")
|
|
1060
|
+
return None
|
|
1061
|
+
if why is not None:
|
|
1062
|
+
why["reason"] = "trial gate passed"
|
|
1063
|
+
return {"placed": (pos, y, meta, constructed), "deg0": plan["deg0"]}
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def layout(graph, config: AutoConfig = None, linestyle: str = "straight",
|
|
1067
|
+
manual: dict = None):
|
|
1068
|
+
"""auto recipe entry point: graph -> a v4-compatible layout dict (with polyline/arc routing + metadata).
|
|
1069
|
+
|
|
1070
|
+
manual: a --from-layout manual layout ({nodes: {id: x,y}, viewBox, source}).
|
|
1071
|
+
When given, layering/placement/polish are skipped and the manual
|
|
1072
|
+
coordinates go through the same routing + delivery pipeline (hard-gate
|
|
1073
|
+
caliber unchanged, scored honestly). With manual=None (default) the code
|
|
1074
|
+
path is byte-for-byte identical to history.
|
|
1075
|
+
|
|
1076
|
+
t_af83815d loop-dominant ring placement: with manual=None the deterministic
|
|
1077
|
+
trigger detection (ringlayout.detect_ring) runs first;
|
|
1078
|
+
a hit whose routing trial passes the gate -> regular-polygon slots for the
|
|
1079
|
+
main ring;
|
|
1080
|
+
no hit / trial failure -> the existing layered path (byte-for-byte
|
|
1081
|
+
unchanged).
|
|
1082
|
+
"""
|
|
1083
|
+
global EDGES_REF
|
|
1084
|
+
cfg = config or AutoConfig()
|
|
1085
|
+
EDGES_REF = [tuple((e.a, e.b) for e in graph.edges)]
|
|
1086
|
+
nodes = graph.node_ids()
|
|
1087
|
+
adj = graph.adj_undirected()
|
|
1088
|
+
core = [v for v in nodes if adj[v]]
|
|
1089
|
+
deg0 = [v for v in nodes if not adj[v]]
|
|
1090
|
+
|
|
1091
|
+
# ① box-boundary ground truth (layout side): real font metrics + conservative padding
|
|
1092
|
+
widths, heights = {}, {}
|
|
1093
|
+
for v in nodes:
|
|
1094
|
+
nd = graph.id2node()[v]
|
|
1095
|
+
w, h = textmetrics.card_size(nd.label, nd.value, cfg.fs_title, cfg.fs_value,
|
|
1096
|
+
min_w=cfg.card_min_w,
|
|
1097
|
+
value_pad=cfg.value_pad_extra)
|
|
1098
|
+
widths[v], heights[v] = w, h
|
|
1099
|
+
card_h = max(heights.values()) if heights else cfg.card_h
|
|
1100
|
+
|
|
1101
|
+
# ②③ placement: --from-layout manual coordinates, or automatic layering + placement
|
|
1102
|
+
_routes_final = None
|
|
1103
|
+
constructed = {}
|
|
1104
|
+
from_meta = None
|
|
1105
|
+
# t_cbfca790 mode decision trace: every rule evaluation in the deterministic
|
|
1106
|
+
# priority chain is recorded (rule, tier, triggered, reason, trial gate) so
|
|
1107
|
+
# the certificate can show WHY a placement mode was picked (review report
|
|
1108
|
+
# item 5: reduce the trigger-template black-box feel). Recording only — the
|
|
1109
|
+
# decision logic below is untouched, so non-hit paths stay byte-identical.
|
|
1110
|
+
_mode_rules = []
|
|
1111
|
+
if manual is not None:
|
|
1112
|
+
pos, y, rows, from_meta = _manual_positions(nodes, widths, manual)
|
|
1113
|
+
deg0 = []
|
|
1114
|
+
engine, pos_meta = "manual", {}
|
|
1115
|
+
else:
|
|
1116
|
+
# ②'' layout aesthetics v2 (t_c6ab760d, jacky's 2026-11-05 direction for the whole graph): mode priority = bowtie-pair symmetry
|
|
1117
|
+
# (the learning-flywheel family) -> column (long chains / few loops, main ring <=4) -> ring (loop-dominant small graphs, t_af83815d)
|
|
1118
|
+
# -> the existing layered path.
|
|
1119
|
+
# Every new mode carries a trial gate: silent fallback when the gate fails, and the fallback path is byte-for-byte unchanged.
|
|
1120
|
+
def _rule_hit(name, tier, det_ok, plan, det_why, trial_why=None):
|
|
1121
|
+
"""Record one priority-chain rule evaluation (trace only)."""
|
|
1122
|
+
entry = {"tier": tier, "rule": name,
|
|
1123
|
+
"triggered": plan is not None,
|
|
1124
|
+
"reason": (det_why or {}).get("reason")}
|
|
1125
|
+
if det_ok and plan is None and trial_why is not None:
|
|
1126
|
+
entry["trial_gate"] = "fail"
|
|
1127
|
+
entry["reason"] = (entry["reason"] + " — " +
|
|
1128
|
+
(trial_why or {}).get("reason", ""))
|
|
1129
|
+
elif plan is not None:
|
|
1130
|
+
entry["trial_gate"] = "pass"
|
|
1131
|
+
else:
|
|
1132
|
+
entry["trial_gate"] = None
|
|
1133
|
+
_mode_rules.append(entry)
|
|
1134
|
+
return plan
|
|
1135
|
+
|
|
1136
|
+
sym_plan = None
|
|
1137
|
+
bw_plan = None
|
|
1138
|
+
sw_plan = None
|
|
1139
|
+
comb_plan = None
|
|
1140
|
+
column_plan = None
|
|
1141
|
+
ring_plan = None
|
|
1142
|
+
_why_sym, _why_bw, _why_sw = {}, {}, {}
|
|
1143
|
+
_why_comb, _why_col, _why_ring = {}, {}, {}
|
|
1144
|
+
_tw_sym, _tw_bw, _tw_sw = {}, {}, {}
|
|
1145
|
+
_tw_comb, _tw_col, _tw_ring = {}, {}, {}
|
|
1146
|
+
_chain_cap = len(nodes) <= 12
|
|
1147
|
+
if not _chain_cap:
|
|
1148
|
+
_cap_reason = (f"n={len(nodes)} > 12 — structural modes cap at "
|
|
1149
|
+
"12 nodes")
|
|
1150
|
+
for _nm in ("symmetric (shared-edge bowtie)", "bowtie-vertex",
|
|
1151
|
+
"spine-wing", "column-chain", "comb-bilateral",
|
|
1152
|
+
"ring-cycle"):
|
|
1153
|
+
_mode_rules.append({"tier": 0, "rule": _nm,
|
|
1154
|
+
"triggered": False,
|
|
1155
|
+
"reason": f"not evaluated: {_cap_reason}",
|
|
1156
|
+
"trial_gate": None})
|
|
1157
|
+
if _chain_cap:
|
|
1158
|
+
from . import symlayout as _symlayout
|
|
1159
|
+
_sym = _symlayout.detect_bowtie(graph, why=_why_sym)
|
|
1160
|
+
if _sym is not None:
|
|
1161
|
+
sym_plan = _sym_trial(_sym, nodes, widths, card_h, cfg,
|
|
1162
|
+
why=_tw_sym)
|
|
1163
|
+
_rule_hit("symmetric (shared-edge bowtie)", 1, _sym is not None,
|
|
1164
|
+
sym_plan, _why_sym, _tw_sym)
|
|
1165
|
+
# t_7fe64f08 (aesthetics v3) bowtie-vertex (two loops sharing a single node): topologically mutually exclusive with the shared-edge bowtie
|
|
1166
|
+
# (symmetric) and tied with it at the top tier; detection enumerates simple loops (the declared table mixes in 2-node pseudo-loops
|
|
1167
|
+
# and misses derived loops, so it is unusable).
|
|
1168
|
+
if sym_plan is None and _chain_cap:
|
|
1169
|
+
from . import bowtielayout as _bowtielayout
|
|
1170
|
+
_bw = _bowtielayout.detect_bowtie_vertex(graph, why=_why_bw)
|
|
1171
|
+
if _bw is not None:
|
|
1172
|
+
bw_plan = _bowtie_trial(_bw, nodes, widths, card_h, cfg,
|
|
1173
|
+
why=_tw_bw)
|
|
1174
|
+
_rule_hit("bowtie-vertex", 1, _bw is not None, bw_plan,
|
|
1175
|
+
_why_bw, _tw_bw)
|
|
1176
|
+
if bw_plan is None and sym_plan is None and _chain_cap:
|
|
1177
|
+
from . import spinewinglayout as _spinewinglayout
|
|
1178
|
+
_sw = _spinewinglayout.detect_spine_wing(graph, why=_why_sw)
|
|
1179
|
+
if _sw is not None:
|
|
1180
|
+
sw_plan = _spine_trial(_sw, nodes, widths, card_h, cfg,
|
|
1181
|
+
why=_tw_sw)
|
|
1182
|
+
_rule_hit("spine-wing", 2, _sw is not None, sw_plan,
|
|
1183
|
+
_why_sw, _tw_sw)
|
|
1184
|
+
if (bw_plan is None and sym_plan is None and sw_plan is None
|
|
1185
|
+
and _chain_cap):
|
|
1186
|
+
from . import ringlayout as _ringlayout
|
|
1187
|
+
from . import columnlayout as _columnlayout
|
|
1188
|
+
from . import comblayout as _comblayout
|
|
1189
|
+
_ring_detect = _ringlayout.detect_ring(graph, why=_why_ring)
|
|
1190
|
+
# column is detected only when ring would not take the pure-cycle/ratio branch (cover-triggered graphs with a main ring <=4 go
|
|
1191
|
+
# to column; large rings with k>=5 still go to ring)
|
|
1192
|
+
if _ring_detect is None or _ring_detect["trigger"] == "cover":
|
|
1193
|
+
_col = _columnlayout.detect_column(graph, why=_why_col)
|
|
1194
|
+
if _col is not None:
|
|
1195
|
+
column_plan = _column_trial(_col, nodes, widths, card_h,
|
|
1196
|
+
cfg, why=_tw_col)
|
|
1197
|
+
_rule_hit("column-chain", 3, _col is not None, column_plan,
|
|
1198
|
+
_why_col, _tw_col)
|
|
1199
|
+
else:
|
|
1200
|
+
_mode_rules.append({
|
|
1201
|
+
"tier": 3, "rule": "column-chain", "triggered": False,
|
|
1202
|
+
"reason": ("not evaluated: ring detection would take the "
|
|
1203
|
+
f"{_ring_detect['trigger']} branch "
|
|
1204
|
+
"(pure-cycle/ratio graphs go to ring-cycle)"),
|
|
1205
|
+
"trial_gate": None})
|
|
1206
|
+
# v0.7.7 comb-bilateral (K_{m,2} bilateral hubs) sits between
|
|
1207
|
+
# column and ring (preempts ring: these graphs score a high
|
|
1208
|
+
# non-bridge ratio with a 4-cycle "main ring" and historically
|
|
1209
|
+
# misfired the ring ratio branch). Against column it applies a
|
|
1210
|
+
# salience rule: the bilateral reading wins only when the source
|
|
1211
|
+
# column is strictly longer than the best column spine (in the
|
|
1212
|
+
# established column-chain families the spine weaves
|
|
1213
|
+
# hub-source-hub-source and is at least as salient — zero drift).
|
|
1214
|
+
_comb = _comblayout.detect_comb(graph, why=_why_comb)
|
|
1215
|
+
if _comb is not None and _col is not None \
|
|
1216
|
+
and len(_comb["column"]) <= len(_col.get("spine") or ()):
|
|
1217
|
+
_mode_rules.append({
|
|
1218
|
+
"tier": 2, "rule": "comb-bilateral", "triggered": False,
|
|
1219
|
+
"reason": ("detected, but not adopted: the comb source "
|
|
1220
|
+
"column is not strictly longer than the best "
|
|
1221
|
+
"column spine (salience rule — the column "
|
|
1222
|
+
"reading wins)"),
|
|
1223
|
+
"trial_gate": None})
|
|
1224
|
+
_comb = None
|
|
1225
|
+
if _comb is not None:
|
|
1226
|
+
comb_plan = _comb_trial(_comb, nodes, widths, card_h, cfg,
|
|
1227
|
+
why=_tw_comb)
|
|
1228
|
+
_rule_hit("comb-bilateral", 2, True, comb_plan,
|
|
1229
|
+
_why_comb, _tw_comb)
|
|
1230
|
+
if column_plan is None and comb_plan is None and _ring_detect is not None:
|
|
1231
|
+
ring_plan = _ring_trial(_ring_detect, nodes, widths, card_h,
|
|
1232
|
+
cfg, why=_tw_ring)
|
|
1233
|
+
_rule_hit("ring-cycle", 4, True, ring_plan,
|
|
1234
|
+
_why_ring, _tw_ring)
|
|
1235
|
+
elif _ring_detect is None:
|
|
1236
|
+
_mode_rules.append({"tier": 4, "rule": "ring-cycle",
|
|
1237
|
+
"triggered": False,
|
|
1238
|
+
"reason": _why_ring.get("reason"),
|
|
1239
|
+
"trial_gate": None})
|
|
1240
|
+
elif column_plan is not None or comb_plan is not None:
|
|
1241
|
+
_mode_rules.append({"tier": 4, "rule": "ring-cycle",
|
|
1242
|
+
"triggered": False,
|
|
1243
|
+
"reason": ("detected, but a higher-tier "
|
|
1244
|
+
"plan was adopted — trial not "
|
|
1245
|
+
"run"),
|
|
1246
|
+
"trial_gate": None})
|
|
1247
|
+
if not any(p is not None for p in (sym_plan, bw_plan, sw_plan,
|
|
1248
|
+
comb_plan, column_plan,
|
|
1249
|
+
ring_plan)):
|
|
1250
|
+
_mode_rules.append({"tier": 5, "rule": "layered (existing path)",
|
|
1251
|
+
"triggered": True,
|
|
1252
|
+
"reason": ("fallback: no structural mode "
|
|
1253
|
+
"triggered — layered layering + "
|
|
1254
|
+
"placement (byte-stable default)"),
|
|
1255
|
+
"trial_gate": None})
|
|
1256
|
+
constructed = {}
|
|
1257
|
+
if sym_plan is not None:
|
|
1258
|
+
spos, sy, smeta, sconstructed = sym_plan["placed"]
|
|
1259
|
+
pos, y = spos, sy
|
|
1260
|
+
deg0 = list(sym_plan["deg0"])
|
|
1261
|
+
rows = [[v for v in nodes if y[v] == yy]
|
|
1262
|
+
for yy in sorted({y[v] for v in nodes})]
|
|
1263
|
+
engine = "sym-construct"
|
|
1264
|
+
pos_meta = smeta
|
|
1265
|
+
constructed = sconstructed
|
|
1266
|
+
elif bw_plan is not None:
|
|
1267
|
+
bpos, by, bmeta, bconstructed = bw_plan["placed"]
|
|
1268
|
+
pos, y = bpos, by
|
|
1269
|
+
deg0 = list(bw_plan["deg0"])
|
|
1270
|
+
rows = [[v for v in nodes if y[v] == yy]
|
|
1271
|
+
for yy in sorted({y[v] for v in nodes})]
|
|
1272
|
+
engine = "bowtie-construct"
|
|
1273
|
+
pos_meta = bmeta
|
|
1274
|
+
constructed = bconstructed
|
|
1275
|
+
elif sw_plan is not None:
|
|
1276
|
+
wpos, wy, wmeta, wconstructed = sw_plan["placed"]
|
|
1277
|
+
pos, y = wpos, wy
|
|
1278
|
+
deg0 = list(sw_plan["deg0"])
|
|
1279
|
+
rows = [[v for v in nodes if y[v] == yy]
|
|
1280
|
+
for yy in sorted({y[v] for v in nodes})]
|
|
1281
|
+
engine = "spine-construct"
|
|
1282
|
+
pos_meta = wmeta
|
|
1283
|
+
constructed = wconstructed
|
|
1284
|
+
elif comb_plan is not None:
|
|
1285
|
+
cpos, cy, cmeta = comb_plan["placed"]
|
|
1286
|
+
pos, y = cpos, cy
|
|
1287
|
+
deg0 = list(comb_plan["deg0"]) if "deg0" in comb_plan else \
|
|
1288
|
+
[v for v in nodes if not adj[v]]
|
|
1289
|
+
rows = [[v for v in nodes if y[v] == yy]
|
|
1290
|
+
for yy in sorted({y[v] for v in nodes})]
|
|
1291
|
+
engine = "comb-construct"
|
|
1292
|
+
pos_meta = cmeta
|
|
1293
|
+
# v0.7.8: tier-1 fan lines ride the generic constructed-route
|
|
1294
|
+
# injection channel (frozen out of coincide/midband/fan_spread,
|
|
1295
|
+
# hard gates already rechecked by the trial)
|
|
1296
|
+
constructed = comb_plan.get("constructed") or {}
|
|
1297
|
+
elif column_plan is not None:
|
|
1298
|
+
cpos, cy, cmeta, cconstructed = column_plan["placed"]
|
|
1299
|
+
pos, y = cpos, cy
|
|
1300
|
+
deg0 = list(column_plan["deg0"])
|
|
1301
|
+
rows = [[v for v in nodes if y[v] == yy]
|
|
1302
|
+
for yy in sorted({y[v] for v in nodes})]
|
|
1303
|
+
engine = "column-construct"
|
|
1304
|
+
pos_meta = cmeta
|
|
1305
|
+
constructed = cconstructed
|
|
1306
|
+
elif ring_plan is not None:
|
|
1307
|
+
rpos, ry, rmeta = ring_plan["placed"]
|
|
1308
|
+
pos, y = rpos, ry
|
|
1309
|
+
deg0 = list(ring_plan["deg0"])
|
|
1310
|
+
rows = [[v for v in nodes if y[v] == yy]
|
|
1311
|
+
for yy in sorted({y[v] for v in nodes})]
|
|
1312
|
+
engine = "ring-construct"
|
|
1313
|
+
pos_meta = rmeta
|
|
1314
|
+
else:
|
|
1315
|
+
# ② layering (cycle breaking + longest path + empty-layer compression)
|
|
1316
|
+
_, dag = _cycle_break(core, EDGES_REF[0])
|
|
1317
|
+
layer = _layers(core, dag)
|
|
1318
|
+
nrow = max(layer.values()) + 1 if core else 1
|
|
1319
|
+
rows = [[v for v in core if layer[v] == r] for r in range(nrow)]
|
|
1320
|
+
rows = [r for r in rows if r]
|
|
1321
|
+
|
|
1322
|
+
# ③ placement
|
|
1323
|
+
pos, engine, pos_meta = _solve_positions(core, rows, widths, cfg, linestyle)
|
|
1324
|
+
|
|
1325
|
+
# y coordinates (even rows -> the foundation of the regularity gate)
|
|
1326
|
+
y = {v: cfg.top + r * cfg.pitch for r, vs in enumerate(rows) for v in vs}
|
|
1327
|
+
# parking strip for isolated nodes: evenly laid out at the top (24px column snap for small graphs, consistent with the regularity gate)
|
|
1328
|
+
if deg0:
|
|
1329
|
+
total = sum(widths[v] for v in deg0) + cfg.min_gap * (len(deg0) + 1)
|
|
1330
|
+
cw = _content_w(rows, widths, cfg)
|
|
1331
|
+
cur = max(cfg.margin, (cw - total) / 2)
|
|
1332
|
+
snap = cfg.snap_regular if len(nodes) <= 10 else cfg.grid
|
|
1333
|
+
for v in deg0:
|
|
1334
|
+
pos[v] = int(round((cur + widths[v] / 2) / snap)) * snap
|
|
1335
|
+
y[v] = cfg.park_y
|
|
1336
|
+
cur += widths[v] + cfg.min_gap
|
|
1337
|
+
|
|
1338
|
+
# ③b local polish (small graphs <=10 nodes): hill-climbing horizontal shifts on routing ground-truth total cost, explicitly
|
|
1339
|
+
# feeding routing feasibility (press/clearance/crossings) back into the placement layer
|
|
1340
|
+
if len(core) <= 10:
|
|
1341
|
+
pos, _sc, _rt = _polish(core, EDGES_REF[0], rows, pos, y,
|
|
1342
|
+
widths, card_h, cfg)
|
|
1343
|
+
# -- WS3 routing-quality rework (random dense graphs: check1 crossings>2 / check3 clearance<16): when a routing hard gate fails, the
|
|
1344
|
+
# layout bottleneck is not the router (the graph is planar but the placement pushes topological interleaving pairs to 3+, and greedy
|
|
1345
|
+
# routing can only dodge some of them) but the "row assignment + in-row order". Trigger a deterministic (row, order) reorder -> 24px
|
|
1346
|
+
# repack -> hard-gate polish, and pick the better of it and the original placement under the same evaluation caliber (never worse
|
|
1347
|
+
# than the baseline). Gated trigger: layouts whose hard gates already pass (the entire classic domain, existing passing cases) get
|
|
1348
|
+
# zero perturbation, output byte-for-byte unchanged.
|
|
1349
|
+
_r0 = Router({v: (pos[v], y[v], widths[v], card_h) for v in nodes},
|
|
1350
|
+
EDGES_REF[0], cfg.clearance, cfg.pitch)
|
|
1351
|
+
_routes0 = _r0.route_all()
|
|
1352
|
+
_pairs0 = _r0.final_crossing_pairs(_routes0)
|
|
1353
|
+
_clrbad0 = sum(1 for _i in _routes0.values()
|
|
1354
|
+
if _i.get("min_clear") is not None
|
|
1355
|
+
and _i["min_clear"] < cfg.clearance - 1e-6)
|
|
1356
|
+
_angbad0 = sum(1 for _i in _routes0.values()
|
|
1357
|
+
if min(_i.get("ang_a", ANCHOR_MIN_DEG),
|
|
1358
|
+
_i.get("ang_b", ANCHOR_MIN_DEG)) < ANCHOR_MIN_DEG - 1e-9)
|
|
1359
|
+
_pressbad0 = sum(1 for _i in _routes0.values() if _i.get("press", 0))
|
|
1360
|
+
if _pairs0 > 2 or _clrbad0 or _angbad0 or _pressbad0:
|
|
1361
|
+
# The router layer's second move: joint rerouting of crossing pairs (breaks the mutual-local-optimum deadlock). quality=True is what
|
|
1362
|
+
# expands the stair candidate pool - gated path only; the normal path (including polish's _route_total) keeps the historical
|
|
1363
|
+
# candidate pool, zero perturbation for passing cases.
|
|
1364
|
+
_rq = Router({v: (pos[v], y[v], widths[v], card_h) for v in nodes},
|
|
1365
|
+
EDGES_REF[0], cfg.clearance, cfg.pitch, quality=True)
|
|
1366
|
+
_routesA = _rq.joint_reroute(dict(_routes0))
|
|
1367
|
+
_pairsA = _rq.final_crossing_pairs(_routesA)
|
|
1368
|
+
_clrbadA = sum(1 for _i in _routesA.values()
|
|
1369
|
+
if _i.get("min_clear") is not None
|
|
1370
|
+
and _i["min_clear"] < cfg.clearance - 1e-6)
|
|
1371
|
+
_angbadA = sum(1 for _i in _routesA.values()
|
|
1372
|
+
if min(_i.get("ang_a", ANCHOR_MIN_DEG),
|
|
1373
|
+
_i.get("ang_b", ANCHOR_MIN_DEG)) < ANCHOR_MIN_DEG - 1e-9)
|
|
1374
|
+
_pressbadA = sum(1 for _i in _routesA.values() if _i.get("press", 0))
|
|
1375
|
+
_gate0 = (_pairs0, _clrbad0, _angbad0, _pressbad0)
|
|
1376
|
+
_best = (_gate0, None, None, None, _routes0)
|
|
1377
|
+
if (_pairsA, _clrbadA, _angbadA, _pressbadA) < _best[0]:
|
|
1378
|
+
_best = ((_pairsA, _clrbadA, _angbadA, _pressbadA), pos, y, rows, _routesA)
|
|
1379
|
+
# The layout layer's second move: deterministic (row, order) reorder -> 24px repack -> hard-gate polish
|
|
1380
|
+
# -> routing -> joint reroute
|
|
1381
|
+
_order2 = _reorder_rows(EDGES_REF[0], rows)
|
|
1382
|
+
_pos2 = _repack_x(_order2, widths, cfg)
|
|
1383
|
+
_y2 = dict(y)
|
|
1384
|
+
for _r, _vs in enumerate(_order2):
|
|
1385
|
+
for _v in _vs:
|
|
1386
|
+
_y2[_v] = cfg.top + _r * cfg.pitch
|
|
1387
|
+
_pos2, _sc2, _rt2 = _polish(core, EDGES_REF[0], _order2, _pos2,
|
|
1388
|
+
_y2, widths, card_h, cfg,
|
|
1389
|
+
hard_gates=True)
|
|
1390
|
+
# t_fce6f438: isolated nodes are not in rows (delivered on the top parking strip); the gated reorder reorders only core -
|
|
1391
|
+
# parking-strip coordinates are carried back as-is, so the Router's all-node keys stay complete (legacy gap: on graphs
|
|
1392
|
+
# with isolated nodes the gated path raised KeyError on first trigger)
|
|
1393
|
+
for _v in deg0:
|
|
1394
|
+
_pos2.setdefault(_v, pos[_v])
|
|
1395
|
+
_r2 = Router({v: (_pos2[v], _y2[v], widths[v], card_h)
|
|
1396
|
+
for v in nodes}, EDGES_REF[0], cfg.clearance, cfg.pitch,
|
|
1397
|
+
quality=True)
|
|
1398
|
+
_rt2 = _r2.route_all()
|
|
1399
|
+
_rt2 = _r2.joint_reroute(dict(_rt2))
|
|
1400
|
+
_pairs2 = _r2.final_crossing_pairs(_rt2)
|
|
1401
|
+
_clrbad2 = sum(1 for _i in _rt2.values()
|
|
1402
|
+
if _i.get("min_clear") is not None
|
|
1403
|
+
and _i["min_clear"] < cfg.clearance - 1e-6)
|
|
1404
|
+
_angbad2 = sum(1 for _i in _rt2.values()
|
|
1405
|
+
if min(_i.get("ang_a", ANCHOR_MIN_DEG),
|
|
1406
|
+
_i.get("ang_b", ANCHOR_MIN_DEG)) < ANCHOR_MIN_DEG - 1e-9)
|
|
1407
|
+
_pressbad2 = sum(1 for _i in _rt2.values() if _i.get("press", 0))
|
|
1408
|
+
if (_pairs2, _clrbad2, _angbad2, _pressbad2) < _best[0]:
|
|
1409
|
+
_best = ((_pairs2, _clrbad2, _angbad2, _pressbad2),
|
|
1410
|
+
_pos2, _y2, _order2, _rt2)
|
|
1411
|
+
if _best[1] is None:
|
|
1412
|
+
_routes_final = _best[4] # placement unchanged but the routes were swapped for better ones
|
|
1413
|
+
elif _best[0] < _gate0:
|
|
1414
|
+
pos, y, rows = _best[1], _best[2], _best[3]
|
|
1415
|
+
_routes_final = _best[4]
|
|
1416
|
+
|
|
1417
|
+
# ④ routing (straight/arc first-class, boxes do not press lines) + WS3 theoretical caliber (final-route crossing pairs)
|
|
1418
|
+
nodes_px = {v: (pos[v], y[v], widths[v], card_h) for v in nodes}
|
|
1419
|
+
router = Router(nodes_px, EDGES_REF[0], cfg.clearance, cfg.pitch)
|
|
1420
|
+
routes = router.route_all()
|
|
1421
|
+
if _routes_final is not None:
|
|
1422
|
+
routes = _routes_final
|
|
1423
|
+
# t_c6ab760d constructed-route injection (column closed arcs / bowtie mirror-symmetry arcs): these edges skip the candidate pool and are
|
|
1424
|
+
# delivered with the constructed geometry (the trial gate has already rechecked the hard gates); the injection must happen before
|
|
1425
|
+
# final_crossing_pairs so the dual crossing accounting (check11) includes the constructed routes.
|
|
1426
|
+
for i, (a, b) in enumerate(EDGES_REF[0]):
|
|
1427
|
+
cr = constructed.get((a, b))
|
|
1428
|
+
if cr is None:
|
|
1429
|
+
continue
|
|
1430
|
+
info = {"kind": cr["kind"], "points": [list(p) for p in cr["points"]],
|
|
1431
|
+
"press": 0, "min_clear": None, "cross": 0, "cross_edges": 0,
|
|
1432
|
+
"ang_a": 90.0, "ang_b": 90.0}
|
|
1433
|
+
if cr.get("ctrl") is not None:
|
|
1434
|
+
info["ctrl"] = list(cr["ctrl"])
|
|
1435
|
+
routes[i] = info
|
|
1436
|
+
# t_fad260bd: user-anchored waypoint override (demo-2d bend-handle export) - these edges skip the candidate pool; the delivered route is
|
|
1437
|
+
# built as "anchor = box edge aimed at the first/last waypoint + waypoints as-is", and the geometry/certificate recheck scores the same
|
|
1438
|
+
# points honestly (no leniency). The override must happen before final_crossing_pairs, so the dual crossing accounting (check11)
|
|
1439
|
+
# includes the waypoints.
|
|
1440
|
+
manual_wps = {}
|
|
1441
|
+
if manual is not None:
|
|
1442
|
+
manual_wps = _manual_waypoints(manual, EDGES_REF[0])
|
|
1443
|
+
for i, (a, b) in enumerate(EDGES_REF[0]):
|
|
1444
|
+
wps = manual_wps.get((a, b))
|
|
1445
|
+
if not wps:
|
|
1446
|
+
continue
|
|
1447
|
+
A, B = nodes_px[a], nodes_px[b]
|
|
1448
|
+
pa = border_point(A[0], A[1], A[2] / 2.0, A[3] / 2.0,
|
|
1449
|
+
wps[0][0] - A[0], wps[0][1] - A[1])
|
|
1450
|
+
pb = border_point(B[0], B[1], B[2] / 2.0, B[3] / 2.0,
|
|
1451
|
+
wps[-1][0] - B[0], wps[-1][1] - B[1])
|
|
1452
|
+
pts = [list(pa)] + [[float(px), float(py)] for px, py in wps] + [list(pb)]
|
|
1453
|
+
dedup = [pts[0]]
|
|
1454
|
+
for q in pts[1:]:
|
|
1455
|
+
if math.dist(dedup[-1], q) > 0.5:
|
|
1456
|
+
dedup.append(q)
|
|
1457
|
+
routes[i] = {"kind": "waypoints", "points": dedup, "press": 0,
|
|
1458
|
+
"min_clear": None, "cross": 0, "cross_edges": 0,
|
|
1459
|
+
"ang_a": 0.0, "ang_b": 0.0}
|
|
1460
|
+
if manual_wps and from_meta is not None:
|
|
1461
|
+
from_meta["waypoint_edges"] = len(manual_wps)
|
|
1462
|
+
# v0.7.7 per-edge forced styles (demo-2d style cycle export): "straight"
|
|
1463
|
+
# = direct line between box-border anchors facing each other; "arc" =
|
|
1464
|
+
# the deterministic forced-arc rule (_forced_arc_route). "free" is
|
|
1465
|
+
# carried by waypoints above. These edges skip the candidate pool; the
|
|
1466
|
+
# geometry/certificate recheck scores them honestly (no leniency).
|
|
1467
|
+
manual_styles = {}
|
|
1468
|
+
if manual is not None:
|
|
1469
|
+
manual_styles = _manual_edge_styles(manual, EDGES_REF[0])
|
|
1470
|
+
for i, (a, b) in enumerate(EDGES_REF[0]):
|
|
1471
|
+
if manual_wps.get((a, b)):
|
|
1472
|
+
continue
|
|
1473
|
+
st = manual_styles.get((a, b))
|
|
1474
|
+
if st == "straight":
|
|
1475
|
+
A, B = nodes_px[a], nodes_px[b]
|
|
1476
|
+
pa = border_point(A[0], A[1], A[2] / 2.0, A[3] / 2.0,
|
|
1477
|
+
B[0] - A[0], B[1] - A[1])
|
|
1478
|
+
pb = border_point(B[0], B[1], B[2] / 2.0, B[3] / 2.0,
|
|
1479
|
+
A[0] - B[0], A[1] - B[1])
|
|
1480
|
+
routes[i] = {"kind": "line", "points": [list(pa), list(pb)],
|
|
1481
|
+
"press": 0, "min_clear": None, "cross": 0,
|
|
1482
|
+
"cross_edges": 0, "ang_a": 0.0, "ang_b": 0.0}
|
|
1483
|
+
elif st == "arc":
|
|
1484
|
+
pts, ctrl = _forced_arc_route(nodes_px, a, b, cfg.clearance)
|
|
1485
|
+
routes[i] = {"kind": "arc", "points": [list(p) for p in pts],
|
|
1486
|
+
"press": 0, "min_clear": None, "cross": 0,
|
|
1487
|
+
"cross_edges": 0, "ang_a": 0.0, "ang_b": 0.0,
|
|
1488
|
+
"ctrl": list(ctrl)}
|
|
1489
|
+
if manual_styles and from_meta is not None:
|
|
1490
|
+
from_meta["styled_edges"] = len(manual_styles)
|
|
1491
|
+
# t_fce6f438: directed rerouting for line-line visual coincidence - a backstop fix for fan-out edges hugging the same corridor (stair/lane
|
|
1492
|
+
# shared lanes). Constructed routes and user-anchored waypoint edges are untouched; only edges involved in coincident pairs are rerouted,
|
|
1493
|
+
# on the condition that the hard gates do not regress (press/clearance/anchor/crossing count).
|
|
1494
|
+
_frozen = set()
|
|
1495
|
+
for i, (a, b) in enumerate(EDGES_REF[0]):
|
|
1496
|
+
if constructed.get((a, b)) is not None or manual_wps.get((a, b)) \
|
|
1497
|
+
or manual_styles.get((a, b)):
|
|
1498
|
+
_frozen.add(i)
|
|
1499
|
+
routes = router.coincide_reroute(routes, frozen=_frozen)
|
|
1500
|
+
# t_7fe64f08 connection-point caliber: delivery-layer anchors are clamped to the edge-midpoint +/-1/4 band (corners excluded, per-edge
|
|
1501
|
+
# hard-gate recheck); multiple anchors on the same side of the same node are then evenly spread by incidence angle (no stacking).
|
|
1502
|
+
# t_abe038c1: the enhanced channel (ctrl-following stair + in-band local rerouting of mutual pairs) is enabled only for spine-wing
|
|
1503
|
+
# placement - the ring/bowtie baselines have registered out-of-band anchors (mutual-pair baseline fallback), and the fix would change
|
|
1504
|
+
# their delivered bytes (including the viewBox dx shift knock-on), conflicting with this card's "the other 18 graphs byte-for-byte zero
|
|
1505
|
+
# drift" hard gate -> this card fixes only spine-wing (classic-cld); the other engines get their CI anchor gate locked to the v0.7.2
|
|
1506
|
+
# baseline ratio against regression, and out-of-band fixes go to a later card.
|
|
1507
|
+
routes = router.midband_reanchor(routes, frozen=_frozen,
|
|
1508
|
+
enhanced=(engine == "spine-construct"))
|
|
1509
|
+
routes = router.fan_spread(routes, frozen=_frozen)
|
|
1510
|
+
# t_c4b1bfa5 rework (1): mutual-pair "straight-feasible" candidates join the cost allocation (§WS3 polyline = last resort - when a
|
|
1511
|
+
# straight line is feasible the polyline does not steal the score). The poly side lanes produced by the in-band backstop
|
|
1512
|
+
# (_reroute_pair_band) are rechecked here: if a straight candidate among the 4 edge-midpoint anchors of the two boxes is feasible and
|
|
1513
|
+
# scores better overall, replace it (quickfix->crowding measured: a 298px polyline -> a 101px natural straight line).
|
|
1514
|
+
# Enabled only in the spine-construct domain (classic-cld the only hit): the other engines never enter the new code path, output
|
|
1515
|
+
# byte-for-byte unchanged (structural zero drift).
|
|
1516
|
+
if engine == "spine-construct":
|
|
1517
|
+
routes = router.mutual_straight_reroute(routes, frozen=_frozen)
|
|
1518
|
+
# t_94a585da: mutual-pair lens arrival channel -- flip the pair's shared
|
|
1519
|
+
# arc bulge when that turns both arrows into near-normal arrivals
|
|
1520
|
+
# (gate-guarded; untouched routes returned byte-for-byte otherwise).
|
|
1521
|
+
routes = router.mutual_arrival_flip(routes, frozen=_frozen)
|
|
1522
|
+
# t_51392037: default endpoint snapping to the 16 standard per-box anchor
|
|
1523
|
+
# set (anchors.py caliber) -- jacky 2026-11-09: the standard connection
|
|
1524
|
+
# points are the DEFAULT attachment caliber of every rendered endpoint,
|
|
1525
|
+
# not only the interactive layer. Gate-checked nearest-anchor assignment
|
|
1526
|
+
# with per-(node, anchor) occupancy + fixpoint sweeps (see
|
|
1527
|
+
# Router.anchor_snap for the algorithm path); runs across every auto
|
|
1528
|
+
# engine, frozen constructed/waypoint edges included -- a static render
|
|
1529
|
+
# may not show any off-grid endpoint.
|
|
1530
|
+
_density = len(EDGES_REF[0]) / max(len(nodes), 1)
|
|
1531
|
+
_snap_deferred = None
|
|
1532
|
+
if _density >= ANCHOR_SNAP_DENSE_E_OVER_N:
|
|
1533
|
+
# t_51392037 finalize (jacky 2026-11-09 ruling "C+B"): density-gated
|
|
1534
|
+
# snap probe. A static scalar density threshold cannot separate the
|
|
1535
|
+
# classic corpus (classic-cld E/N = 13/8 = 1.62, snaps clean -- 21/21
|
|
1536
|
+
# green) from the random dense n10 corpus (E/N = 16/10 = 1.60, snap
|
|
1537
|
+
# conflicts with the detour-poly routing: measured on n10-i05
|
|
1538
|
+
# visual_crossings 4 / 1 endpoint unanchored / line-line coincidence),
|
|
1539
|
+
# and the failing case's max degree (4) is LOWER than classic-cld's
|
|
1540
|
+
# (7). So the density threshold switches snap into PROBE mode: apply
|
|
1541
|
+
# the snap on a working copy, re-evaluate the hard gates (visual
|
|
1542
|
+
# crossings + line-line coincidence + full anchor membership); keep
|
|
1543
|
+
# the snapped routes only at zero regressions, otherwise revert to
|
|
1544
|
+
# the pre-snap routes and record an honest deferral (the certificate
|
|
1545
|
+
# reports the deferral with its measured reason and the gate stays
|
|
1546
|
+
# measured -- no gate fudging; dense-snap specialization is a
|
|
1547
|
+
# follow-up card). Low-density graphs keep the unconditional snap.
|
|
1548
|
+
_pre_pairs = router.final_crossing_pairs(routes)
|
|
1549
|
+
_pre_coincide = len(router._route_coincide_pairs(routes))
|
|
1550
|
+
_pre_routes = {i: _clone_route(routes[i]) for i in routes}
|
|
1551
|
+
_snap_report = router.anchor_snap(routes)
|
|
1552
|
+
_regress = []
|
|
1553
|
+
_post_pairs = router.final_crossing_pairs(routes)
|
|
1554
|
+
if _post_pairs > _pre_pairs:
|
|
1555
|
+
_regress.append(f"crossings {_pre_pairs}->{_post_pairs}")
|
|
1556
|
+
_post_coincide = len(router._route_coincide_pairs(routes))
|
|
1557
|
+
if _post_coincide > _pre_coincide:
|
|
1558
|
+
_regress.append(f"line_coincidence {_pre_coincide}->{_post_coincide}")
|
|
1559
|
+
if _snap_report["failures"]:
|
|
1560
|
+
_regress.append(f"{len(_snap_report['failures'])} endpoint(s) unanchored")
|
|
1561
|
+
if _regress:
|
|
1562
|
+
routes = _pre_routes
|
|
1563
|
+
_snap_deferred = {"density_en": round(_density, 2),
|
|
1564
|
+
"reason": _regress,
|
|
1565
|
+
"moved_before_revert": _snap_report["moved"]}
|
|
1566
|
+
print(f"[stdraw] anchor_snap DEFERRED (dense, E/N={_density:.2f}): "
|
|
1567
|
+
f"{'; '.join(_regress)} -- pre-snap routes kept", file=sys.stderr)
|
|
1568
|
+
else:
|
|
1569
|
+
print(f"[stdraw] anchor_snap: {_snap_report['moved']} endpoints snapped "
|
|
1570
|
+
f"(dense probe, E/N={_density:.2f}, zero gate regressions)",
|
|
1571
|
+
file=sys.stderr)
|
|
1572
|
+
else:
|
|
1573
|
+
_snap_report = router.anchor_snap(routes)
|
|
1574
|
+
if _snap_report["failures"]:
|
|
1575
|
+
print(f"[stdraw] anchor_snap: {_snap_report['moved']} endpoints snapped, "
|
|
1576
|
+
f"{len(_snap_report['failures'])} endpoint(s) could not be placed "
|
|
1577
|
+
f"on a standard anchor: {_snap_report['failures'][:5]}",
|
|
1578
|
+
file=sys.stderr)
|
|
1579
|
+
# t_51392037: re-run the lens arrival channel AFTER snapping -- snap moves
|
|
1580
|
+
# lens arc endpoints/ctrl, so the arrival-normal caliber (<=10 deg) and the
|
|
1581
|
+
# pair separation may degrade. The flip re-derives ctrl only (endpoints
|
|
1582
|
+
# stay on their anchors -> anchors_standard membership preserved) and is
|
|
1583
|
+
# strict-improvement + hard-gate guarded; enhanced_gates switches to the
|
|
1584
|
+
# anchor-snap gate accounting (see Router.mutual_arrival_flip). Skipped
|
|
1585
|
+
# on deferral: the reverted routes already passed the pre-snap flip above,
|
|
1586
|
+
# and re-flipping unsnapped routes under the enhanced accounting would
|
|
1587
|
+
# perturb the honest pre-snap state.
|
|
1588
|
+
if _snap_deferred is None:
|
|
1589
|
+
routes = router.mutual_arrival_flip(routes, enhanced_gates=True)
|
|
1590
|
+
router_pairs = router.final_crossing_pairs(routes)
|
|
1591
|
+
|
|
1592
|
+
# t_c6ab760d R/B loop coloring: edge -> the set of declared-loop kinds it belongs to (deterministic rules in docs/layout-modes.md):
|
|
1593
|
+
# a single loop in a single color (R=amber/B=sky blue); multiple loops of the same color take that color; multiple loops of different
|
|
1594
|
+
# colors or no loop -> neutral (rendering layer slate); polarity (+/-) data unchanged.
|
|
1595
|
+
_eset_dir = {(e.a, e.b) for e in graph.edges}
|
|
1596
|
+
_edge_loops = defaultdict(set)
|
|
1597
|
+
for lp in getattr(graph, "loops", []):
|
|
1598
|
+
seq = list(lp.nodes)
|
|
1599
|
+
kind = getattr(lp, "kind", None)
|
|
1600
|
+
if not kind:
|
|
1601
|
+
continue
|
|
1602
|
+
for i in range(len(seq)):
|
|
1603
|
+
a, b = seq[i], seq[(i + 1) % len(seq)]
|
|
1604
|
+
if (a, b) in _eset_dir:
|
|
1605
|
+
_edge_loops[(a, b)].add(kind)
|
|
1606
|
+
elif (b, a) in _eset_dir:
|
|
1607
|
+
_edge_loops[(b, a)].add(kind)
|
|
1608
|
+
_LOOP_COLORS = {"R": "#d97706", "B": "#0284c7"} # R=amber, B=sky blue (online convention)
|
|
1609
|
+
|
|
1610
|
+
edges_out = []
|
|
1611
|
+
for i, (a, b) in enumerate(EDGES_REF[0]):
|
|
1612
|
+
info = routes[i]
|
|
1613
|
+
kinds = sorted(_edge_loops.get((a, b), ()))
|
|
1614
|
+
loop_color = (_LOOP_COLORS[kinds[0]] if len(set(kinds)) == 1
|
|
1615
|
+
else None)
|
|
1616
|
+
e = {"a": a, "b": b, "polarity": getattr(graph.edges[i], "polarity", "+"),
|
|
1617
|
+
"loop_kinds": kinds,
|
|
1618
|
+
"loop_color": loop_color,
|
|
1619
|
+
"route": {"kind": info["kind"],
|
|
1620
|
+
"points": [[round(px, 1), round(py, 1)]
|
|
1621
|
+
for px, py in info["points"]]},
|
|
1622
|
+
"anchors": {"a": list(info["points"][0]), "b": list(info["points"][-1])},
|
|
1623
|
+
"arrow": [round(info["points"][-1][0], 1), round(info["points"][-1][1], 1)],
|
|
1624
|
+
"route_cross_edges": int(info.get("cross_edges", 0))} # diagnostic value from the evaluation period
|
|
1625
|
+
if info["kind"] == "arc":
|
|
1626
|
+
e["route"]["arc"] = True
|
|
1627
|
+
e["route"]["ctrl"] = [round(c, 1) for c in info.get("ctrl", info["points"][len(info["points"]) // 2])]
|
|
1628
|
+
if info["kind"] == "vg":
|
|
1629
|
+
# stash raw corner/arc parameters; the SVG path is generated after the dx shift (see finalize below)
|
|
1630
|
+
e["_vg"] = ([list(p) for p in info["vg_corners"]],
|
|
1631
|
+
[dict(f) for f in info["vg_fillets"]])
|
|
1632
|
+
if info["kind"] == "waypoints":
|
|
1633
|
+
# t_fad260bd: user-anchored bends (the via is generated on delivered coordinates after the dx/dy shift, see finalize below
|
|
1634
|
+
# - isomorphic to vg's _vg handling)
|
|
1635
|
+
e["_wp"] = [[float(px), float(py)] for px, py in info["points"]]
|
|
1636
|
+
edges_out.append(e)
|
|
1637
|
+
|
|
1638
|
+
xs_all = [pos[v] - widths[v] / 2 for v in nodes] + [pos[v] + widths[v] / 2 for v in nodes]
|
|
1639
|
+
ys_all = [y[v] - card_h / 2 for v in nodes] + [y[v] + card_h / 2 for v in nodes]
|
|
1640
|
+
|
|
1641
|
+
# F3 rework (t_02e60e33, bullwhip check7 fail): an arc bulge can cross the node-box outer envelope (measured: inventory->forecast
|
|
1642
|
+
# bulged 7.5px left past the viewBox left edge, while since v0.2 the viewBox is computed from node boxes only). Edge geometry (sampled
|
|
1643
|
+
# points plus arc ctrl - a quadratic-Bezier convex-hull upper bound, conservative) must enter the shift amounts and the W/H
|
|
1644
|
+
# computation together with the node boxes; routing-layer decisions are untouched, the viewBox is only widened at the delivery layer,
|
|
1645
|
+
# constructively consistent with check7's "all content inside the viewBox" validation caliber (which reads the delivered route.points
|
|
1646
|
+
# directly).
|
|
1647
|
+
def _edge_geom_bounds():
|
|
1648
|
+
exs, eys = [], []
|
|
1649
|
+
for e in edges_out:
|
|
1650
|
+
for px, py in e["route"]["points"]:
|
|
1651
|
+
exs.append(px)
|
|
1652
|
+
eys.append(py)
|
|
1653
|
+
c = e["route"].get("ctrl")
|
|
1654
|
+
if c:
|
|
1655
|
+
exs.append(c[0])
|
|
1656
|
+
eys.append(c[1])
|
|
1657
|
+
return exs, eys
|
|
1658
|
+
|
|
1659
|
+
exs0, eys0 = _edge_geom_bounds()
|
|
1660
|
+
min_x = min(xs_all + exs0) - cfg.margin / 2
|
|
1661
|
+
dx = max(0, int(math.ceil(cfg.margin / 2 - min_x)))
|
|
1662
|
+
dx = ((dx + 23) // 24) * 24 # shift aligned to 24px, keeping the column snap/grid unchanged
|
|
1663
|
+
min_y = min(ys_all + eys0) - cfg.margin / 2
|
|
1664
|
+
dy = max(0, int(math.ceil(cfg.margin / 2 - min_y)))
|
|
1665
|
+
dy = ((dy + 7) // 8) * 8 # 8px alignment: keeps the 2px grid and the H rounding caliber
|
|
1666
|
+
if dx or dy:
|
|
1667
|
+
for e in edges_out:
|
|
1668
|
+
e["route"]["points"] = [[px + dx, py + dy] for px, py in e["route"]["points"]]
|
|
1669
|
+
e["anchors"] = {"a": [e["anchors"]["a"][0] + dx, e["anchors"]["a"][1] + dy],
|
|
1670
|
+
"b": [e["anchors"]["b"][0] + dx, e["anchors"]["b"][1] + dy]}
|
|
1671
|
+
e["arrow"][0] += dx
|
|
1672
|
+
e["arrow"][1] += dy
|
|
1673
|
+
if "ctrl" in e["route"]: # bugfix: arc control points must shift with dx/dy
|
|
1674
|
+
e["route"]["ctrl"][0] += dx
|
|
1675
|
+
e["route"]["ctrl"][1] += dy
|
|
1676
|
+
for v in nodes:
|
|
1677
|
+
pos[v] += dx
|
|
1678
|
+
y[v] += dy
|
|
1679
|
+
# t_c6ab760d: absolute axis coordinates in the placement metadata shift in sync (symmetry axis / column axis)
|
|
1680
|
+
if isinstance(pos_meta, dict):
|
|
1681
|
+
if isinstance(pos_meta.get("symmetry"), dict):
|
|
1682
|
+
pos_meta["symmetry"]["axis_x"] = \
|
|
1683
|
+
pos_meta["symmetry"].get("axis_x", 0) + dx
|
|
1684
|
+
pos_meta["symmetry"]["axis_y"] = \
|
|
1685
|
+
pos_meta["symmetry"].get("axis_y", 0) + dy
|
|
1686
|
+
if isinstance(pos_meta.get("column"), dict):
|
|
1687
|
+
pos_meta["column"]["axis_x"] = \
|
|
1688
|
+
pos_meta["column"].get("axis_x", 0) + dx
|
|
1689
|
+
# t_8f211f74 spine-wing: the vertical axis / midline axis shift in sync with the translation
|
|
1690
|
+
if isinstance(pos_meta.get("spine_wing"), dict):
|
|
1691
|
+
pos_meta["spine_wing"]["axis_x"] = \
|
|
1692
|
+
pos_meta["spine_wing"].get("axis_x", 0) + dx
|
|
1693
|
+
pos_meta["spine_wing"]["axis_y"] = \
|
|
1694
|
+
pos_meta["spine_wing"].get("axis_y", 0) + dy
|
|
1695
|
+
# v0.7.7 comb-bilateral: the column axis / hub row shift in sync
|
|
1696
|
+
if isinstance(pos_meta.get("comb"), dict):
|
|
1697
|
+
pos_meta["comb"]["axis_x"] = \
|
|
1698
|
+
pos_meta["comb"].get("axis_x", 0) + dx
|
|
1699
|
+
pos_meta["comb"]["hub_y"] = \
|
|
1700
|
+
pos_meta["comb"].get("hub_y", 0) + dy
|
|
1701
|
+
|
|
1702
|
+
# W/H must be taken on the final post-shift coordinates. bugfix (a v0.2.0 leftover exposed by CI): the original implementation computed
|
|
1703
|
+
# W from the pre-shift xs_all; with dx>0 the content overflowed the viewBox after the right shift (analytic box widths are narrower
|
|
1704
|
+
# without fonts -> the min left edge violates and triggers a large dx -> the right edge spills). F3 rework addendum: max also includes
|
|
1705
|
+
# edge geometry, so arc/vg bulges must not cross the right/bottom edges.
|
|
1706
|
+
xs_all = [pos[v] - widths[v] / 2 for v in nodes] + [pos[v] + widths[v] / 2 for v in nodes]
|
|
1707
|
+
ys_all = [y[v] - card_h / 2 for v in nodes] + [y[v] + card_h / 2 for v in nodes]
|
|
1708
|
+
exs1, eys1 = _edge_geom_bounds()
|
|
1709
|
+
W = int(math.ceil(max(xs_all + exs1))) + cfg.margin
|
|
1710
|
+
H = int(math.ceil(max(ys_all + eys1))) + cfg.margin
|
|
1711
|
+
W = ((W + 7) // 8) * 8
|
|
1712
|
+
H = ((H + 7) // 8) * 8
|
|
1713
|
+
|
|
1714
|
+
# WS3 finalize: vg routes generate the exact SVG path (L+A commands) on the delivered post-shift coordinates
|
|
1715
|
+
from .visibility import route_svg_d as _route_svg_d
|
|
1716
|
+
for e in edges_out:
|
|
1717
|
+
vg = e.pop("_vg", None)
|
|
1718
|
+
if vg:
|
|
1719
|
+
corners, fillets = vg
|
|
1720
|
+
corners = [[p[0] + dx, p[1] + dy] for p in corners]
|
|
1721
|
+
fillets = [dict(f, ta=[f["ta"][0] + dx, f["ta"][1] + dy],
|
|
1722
|
+
tb=[f["tb"][0] + dx, f["tb"][1] + dy],
|
|
1723
|
+
center=[f["center"][0] + dx, f["center"][1] + dy])
|
|
1724
|
+
for f in fillets]
|
|
1725
|
+
e["route"]["path_d"] = _route_svg_d(corners, fillets)
|
|
1726
|
+
e["route"]["via"] = [[round(px, 1), round(py, 1)] for px, py in corners]
|
|
1727
|
+
wp = e.pop("_wp", None)
|
|
1728
|
+
if wp:
|
|
1729
|
+
# t_fad260bd: user-anchored bends enter delivery (post-shift coordinates; rendering draws a polyline)
|
|
1730
|
+
e["route"]["via"] = [[round(p[0] + dx, 1), round(p[1] + dy, 1)]
|
|
1731
|
+
for p in wp[1:-1]]
|
|
1732
|
+
|
|
1733
|
+
if manual is not None:
|
|
1734
|
+
row_y_out = [yy + dy for yy in sorted({y[v] for v in nodes})]
|
|
1735
|
+
pitch_out = None
|
|
1736
|
+
elif engine in ("ring-construct", "comb-construct"):
|
|
1737
|
+
# ring/comb placement have no uniform-row semantics: rowY records the
|
|
1738
|
+
# deduplicated order of actual y values; pitch does not apply
|
|
1739
|
+
row_y_out = [yy + dy for yy in sorted({y[v] for v in nodes})]
|
|
1740
|
+
pitch_out = None
|
|
1741
|
+
else:
|
|
1742
|
+
row_y_out = [dy + cfg.top + r * cfg.pitch for r in range(len(rows))]
|
|
1743
|
+
pitch_out = cfg.pitch
|
|
1744
|
+
# t_cbfca790: assemble the mode decision trace (certificate mode_decision_trace).
|
|
1745
|
+
# The anchor-band legacy exemption (v0.7.3 registered baselines): the
|
|
1746
|
+
# enhanced mid-band re-anchor + mutual-straight reroute channels run only
|
|
1747
|
+
# for spine-wing; every other engine keeps its registered v0.7.2 baseline
|
|
1748
|
+
# in-band ratio (CI BASELINE_BAND lock) instead of the enhanced fix — that
|
|
1749
|
+
# registered exemption is disclosed here rather than silently applied.
|
|
1750
|
+
_engine_placement = {"sym-construct": "symmetric",
|
|
1751
|
+
"bowtie-construct": "bowtie-vertex",
|
|
1752
|
+
"spine-construct": "spine-wing",
|
|
1753
|
+
"comb-construct": "comb-bilateral",
|
|
1754
|
+
"column-construct": "column-chain",
|
|
1755
|
+
"ring-construct": "ring-cycle",
|
|
1756
|
+
"manual": "manual"}
|
|
1757
|
+
_final_placement = ((pos_meta.get("placement")
|
|
1758
|
+
if isinstance(pos_meta, dict) else None)
|
|
1759
|
+
or _engine_placement.get(engine, "layered"))
|
|
1760
|
+
if engine == "manual":
|
|
1761
|
+
_mode_trace = {"engine": "manual", "placement": None, "manual": True,
|
|
1762
|
+
"rules": [],
|
|
1763
|
+
"fallback": {"used": False, "reason": None},
|
|
1764
|
+
"legacy_band_exemption": {
|
|
1765
|
+
"triggered": False,
|
|
1766
|
+
"detail": ("manual layout: the auto mode chain did "
|
|
1767
|
+
"not run; routing hard gates still "
|
|
1768
|
+
"apply, graded honestly")}}
|
|
1769
|
+
else:
|
|
1770
|
+
_legacy = engine != "spine-construct"
|
|
1771
|
+
_mode_trace = {
|
|
1772
|
+
"engine": engine, "placement": _final_placement, "manual": False,
|
|
1773
|
+
"rules": _mode_rules,
|
|
1774
|
+
"fallback": {"used": engine not in _engine_placement,
|
|
1775
|
+
"reason": ("no structural mode triggered — layered "
|
|
1776
|
+
"path" if engine not in _engine_placement
|
|
1777
|
+
else None)},
|
|
1778
|
+
"legacy_band_exemption": {
|
|
1779
|
+
"triggered": _legacy,
|
|
1780
|
+
"detail": (f"{_final_placement}: keeps the registered v0.7.2 "
|
|
1781
|
+
"anchor-band baseline ratios (CI BASELINE_BAND "
|
|
1782
|
+
"lock); the enhanced mid-band re-anchor + "
|
|
1783
|
+
"mutual-straight reroute channels are spine-wing "
|
|
1784
|
+
"only"
|
|
1785
|
+
if _legacy else
|
|
1786
|
+
"spine-wing: 100% in-band enforced (enhanced "
|
|
1787
|
+
"re-anchor channel active, no legacy exemption)")},
|
|
1788
|
+
}
|
|
1789
|
+
v4 = {
|
|
1790
|
+
"viewBox": [0, 0, W, H],
|
|
1791
|
+
"nodes": {v: {"x": pos[v], "y": y[v], "w": widths[v]} for v in nodes},
|
|
1792
|
+
"edges": edges_out,
|
|
1793
|
+
"bars": {},
|
|
1794
|
+
"meta": {
|
|
1795
|
+
"rows": len(row_y_out),
|
|
1796
|
+
"rowY": row_y_out,
|
|
1797
|
+
"pitch": pitch_out,
|
|
1798
|
+
"deg0": deg0,
|
|
1799
|
+
"cardH": card_h,
|
|
1800
|
+
"fs": [cfg.fs_title, cfg.fs_value],
|
|
1801
|
+
"recipe": "auto-v2",
|
|
1802
|
+
"line_style": linestyle,
|
|
1803
|
+
"position_engine": engine,
|
|
1804
|
+
# t_cbfca790: deterministic mode-decision audit trail (which rule
|
|
1805
|
+
# hit, why, trial gate, legacy band exemption) — see the certificate
|
|
1806
|
+
# field of the same name.
|
|
1807
|
+
"mode_decision_trace": _mode_trace,
|
|
1808
|
+
# --from-layout metadata (written only on the manual path; the auto path is byte-for-byte unchanged)
|
|
1809
|
+
**({} if from_meta is None else {"from_layout": from_meta}),
|
|
1810
|
+
"clearance": CLEARANCE,
|
|
1811
|
+
"router_cross_pairs": router_pairs,
|
|
1812
|
+
# t_51392037 finalize: honest dense-snap deferral record (None
|
|
1813
|
+
# when snap applied) -- certificate annotates the gate with it.
|
|
1814
|
+
"anchor_snap_deferred": _snap_deferred,
|
|
1815
|
+
"solver_available": _has_solver(),
|
|
1816
|
+
**pos_meta,
|
|
1817
|
+
"routing_math": ("visibility-graph Dijkstra shortest path (clearance-"
|
|
1818
|
+
"inflated obstacles) + tangent-arc fillet smoothing "
|
|
1819
|
+
"(G1, clearance-verified); crossing dual-caliber: "
|
|
1820
|
+
"router theory vs Liang-Barsky delivered geometry"),
|
|
1821
|
+
"method": ("layered + crossing-minimization (CP-SAT if available) "
|
|
1822
|
+
"with straight/arc first-class routing and visibility-graph "
|
|
1823
|
+
"detour; hard gates: box-over-line=0, edge-box clearance>=16px"),
|
|
1824
|
+
},
|
|
1825
|
+
}
|
|
1826
|
+
from .delivery import stamp_coordinates_only
|
|
1827
|
+
return stamp_coordinates_only(v4)
|