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/columnlayout.py
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Column placement for long-chain / few-ring graphs (2026-11-06 jacky
|
|
3
|
+
layout aesthetics v2, t_c6ab760d).
|
|
4
|
+
|
|
5
|
+
> "If the box placement can follow a pattern, make it so — e.g. align
|
|
6
|
+
> into a vertical column... Draw the lines to follow the box
|
|
7
|
+
> arrangement. Regularity makes the figure nicer and easier to
|
|
8
|
+
> understand."
|
|
9
|
+
|
|
10
|
+
Trigger detection (graph-theoretic metrics, fully deterministic;
|
|
11
|
+
decided after ringlayout.detect_ring's pure-cycle and ratio branches,
|
|
12
|
+
before the cover branch):
|
|
13
|
+
1. Small graph: 3 <= n <= MAX_COLUMN_N (n includes isolated nodes);
|
|
14
|
+
2. Few rings: the longest simple loop k satisfies 3 <= k <=
|
|
15
|
+
MAX_RING_K (a small main ring -> "ring" does not dominate, strong
|
|
16
|
+
chain feel; a big ring with k = n belongs to ring placement);
|
|
17
|
+
3. Long chain: nodes outside the main ring hang as "branches" beside
|
|
18
|
+
their spine neighbors (residuals <= n - k).
|
|
19
|
+
|
|
20
|
+
Spine selection (isomorphism consistency: structural-signature driven,
|
|
21
|
+
not dependent on declared loop id order):
|
|
22
|
+
candidates = declared loops (those forming simple loops, direction
|
|
23
|
+
kept) ∪ graph-theoretically enumerated simple loops;
|
|
24
|
+
chosen deterministically by "longest ring -> fewest negative edges
|
|
25
|
+
(all-positive growth loops preferred as spine) -> WL structural
|
|
26
|
+
color signature lexicographic order".
|
|
27
|
+
|
|
28
|
+
Placement (place_column):
|
|
29
|
+
- Spine nodes align into one central column (x = column axis,
|
|
30
|
+
y = top + i·pitch);
|
|
31
|
+
- The spine closing edge (last->first) is constructed as a large
|
|
32
|
+
left arc (quadratic bezier, control point extrapolated left of
|
|
33
|
+
the column axis on a deterministic ladder, analytically verified
|
|
34
|
+
to keep clearance >= `clearance` from every box) — the
|
|
35
|
+
column-form realization of "loop cohesion / ring feel";
|
|
36
|
+
- Branches (residuals) hang beside their spine neighbors: right
|
|
37
|
+
column first (opposite the closing arc), aligned to the anchor
|
|
38
|
+
row or to the cross-row midpoint; multi-branch structures go
|
|
39
|
+
right column -> left column (the left column is extrapolated
|
|
40
|
+
further out beyond the closing arc);
|
|
41
|
+
- Isolated nodes park in the top parking strip (same as existing
|
|
42
|
+
patterns).
|
|
43
|
+
|
|
44
|
+
Routing/certificate: delegated to the existing Router and
|
|
45
|
+
certificate_auto; the column-mode metric criteria (spine column
|
|
46
|
+
alignment + uniform row pitch + loop cohesion) switch inside
|
|
47
|
+
metrics/check on meta.placement == "column-chain". The closing arc
|
|
48
|
+
is injected as a constructed route (same override mechanism as
|
|
49
|
+
--from-layout waypoints; translated by dx/dy before coordinate
|
|
50
|
+
delivery).
|
|
51
|
+
"""
|
|
52
|
+
import math
|
|
53
|
+
from collections import defaultdict
|
|
54
|
+
|
|
55
|
+
MAX_COLUMN_N = 12 # trigger upper bound: small graphs
|
|
56
|
+
MAX_RING_K = 4 # main ring <= 4 counts as "few rings" (5+ goes to ring placement)
|
|
57
|
+
SIDE_GAP = 36 # horizontal clearance between branch columns and the spine (breathing-room tier)
|
|
58
|
+
ARC_MARGIN = 20 # extra margin of the closing arc beyond the leftmost obstacle
|
|
59
|
+
ARC_LADDER = (0, 24, 48, 72, 96, 128, 160) # arc control-point extrapolation ladder (px)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class InfeasibleColumn(Exception):
|
|
63
|
+
"""Column slot infeasible (no solution for the closing arc / branch slot) — the caller falls back to the existing path."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _adjacency(edges):
|
|
67
|
+
adj = defaultdict(set)
|
|
68
|
+
for a, b in edges:
|
|
69
|
+
adj[a].add(b)
|
|
70
|
+
adj[b].add(a)
|
|
71
|
+
return adj
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _wl_colors(nodes, adj):
|
|
75
|
+
"""WL-style structural colors (2 refine rounds): {v: tuple}. Corresponding
|
|
76
|
+
nodes of isomorphic graphs share a color (structural signature, id
|
|
77
|
+
independent) — the mechanism behind "isomorphic skeleton -> same layout template"."""
|
|
78
|
+
color = {v: (len(adj[v]),) for v in nodes}
|
|
79
|
+
for _ in range(2):
|
|
80
|
+
nxt = {}
|
|
81
|
+
for v in nodes:
|
|
82
|
+
nxt[v] = (color[v],
|
|
83
|
+
tuple(sorted(color[w] for w in sorted(adj[v]))))
|
|
84
|
+
# compress to ranks (deterministic: index by sorted signature order)
|
|
85
|
+
order = {sig: i for i, sig in enumerate(sorted(set(nxt.values())))}
|
|
86
|
+
color = {v: (order[nxt[v]],) for v in nodes}
|
|
87
|
+
return color
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _edge_signs(edges):
|
|
91
|
+
"""Undirected key -> set of polarities (one-to-many when bidirectional edges exist)."""
|
|
92
|
+
signs = defaultdict(set)
|
|
93
|
+
for a, b, pol in edges:
|
|
94
|
+
signs[(a, b) if a < b else (b, a)].add(pol)
|
|
95
|
+
return signs
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _neg_count(seq, edges, adj):
|
|
99
|
+
"""Number of negative directed edges along the ring order (segments adjacent only undirected count 0)."""
|
|
100
|
+
esign = {}
|
|
101
|
+
for a, b, pol in edges:
|
|
102
|
+
esign[(a, b)] = pol
|
|
103
|
+
esign.setdefault((b, a), pol)
|
|
104
|
+
neg = 0
|
|
105
|
+
for i in range(len(seq)):
|
|
106
|
+
a, b = seq[i], seq[(i + 1) % len(seq)]
|
|
107
|
+
if esign.get((a, b)) == "-":
|
|
108
|
+
neg += 1
|
|
109
|
+
return neg
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _declared_loop_cycles(graph, adj):
|
|
113
|
+
out = []
|
|
114
|
+
for lp in graph.loops:
|
|
115
|
+
seq = list(lp.nodes)
|
|
116
|
+
if len(seq) < 3 or len(set(seq)) != len(seq):
|
|
117
|
+
continue
|
|
118
|
+
if not all(seq[(i + 1) % len(seq)] in adj[seq[i]] for i in range(len(seq))):
|
|
119
|
+
continue
|
|
120
|
+
out.append((lp.id, seq))
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _enumerate_cycles(nodes, adj, cap=2_000_000):
|
|
125
|
+
"""Simple-loop enumeration with the same criteria as ringlayout (canonical-form dedup, step cap)."""
|
|
126
|
+
steps = [0]
|
|
127
|
+
found = {}
|
|
128
|
+
|
|
129
|
+
def canon(seq):
|
|
130
|
+
best = None
|
|
131
|
+
for s in (list(seq), list(seq)[::-1]):
|
|
132
|
+
for i in range(len(s)):
|
|
133
|
+
rot = s[i:] + s[:i]
|
|
134
|
+
if best is None or rot < best:
|
|
135
|
+
best = rot
|
|
136
|
+
return tuple(best)
|
|
137
|
+
|
|
138
|
+
class _Cap(Exception):
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
def dfs(start, v, path, seen):
|
|
142
|
+
steps[0] += 1
|
|
143
|
+
if steps[0] > cap:
|
|
144
|
+
raise _Cap
|
|
145
|
+
for w in sorted(adj[v]):
|
|
146
|
+
if w == start and len(path) >= 3:
|
|
147
|
+
c = canon(path)
|
|
148
|
+
found[c] = c
|
|
149
|
+
elif w not in seen:
|
|
150
|
+
seen.add(w)
|
|
151
|
+
dfs(start, w, path + [w], seen)
|
|
152
|
+
seen.remove(w)
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
for s in sorted(nodes):
|
|
156
|
+
dfs(s, s, [s], {s})
|
|
157
|
+
except _Cap:
|
|
158
|
+
pass
|
|
159
|
+
return [list(c) for c in found.values()]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def detect_column(graph, why=None):
|
|
163
|
+
"""Trigger detection + spine selection. Returns a column plan dict or None.
|
|
164
|
+
|
|
165
|
+
plan = {"spine": [spine order (directed, start = structurally most
|
|
166
|
+
salient node)], "k": int, "declared_loop": id|None,
|
|
167
|
+
"residuals": [non-spine non-isolated nodes], "deg0": [isolated
|
|
168
|
+
nodes], "closing": (last, first) spine closing edge}
|
|
169
|
+
|
|
170
|
+
why: optional dict — receives a deterministic one-line reason for the
|
|
171
|
+
outcome (mode decision trace, certificate mode_decision_trace field).
|
|
172
|
+
"""
|
|
173
|
+
nodes = list(graph.node_ids())
|
|
174
|
+
n = len(nodes)
|
|
175
|
+
if n < 3 or n > MAX_COLUMN_N:
|
|
176
|
+
if why is not None:
|
|
177
|
+
why["reason"] = (f"n={n} (need 3..{MAX_COLUMN_N})")
|
|
178
|
+
return None
|
|
179
|
+
edges = [(e.a, e.b, getattr(e, "polarity", "+")) for e in graph.edges]
|
|
180
|
+
adj = _adjacency([(a, b) for a, b, _ in edges])
|
|
181
|
+
core = [v for v in nodes if adj[v]]
|
|
182
|
+
deg0 = [v for v in nodes if not adj[v]]
|
|
183
|
+
if not core:
|
|
184
|
+
if why is not None:
|
|
185
|
+
why["reason"] = "no core nodes (no edges)"
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
cands = [] # [(ring order, loop_id, negative-edge count)]
|
|
189
|
+
for lid, seq in _declared_loop_cycles(graph, adj):
|
|
190
|
+
cands.append((seq, lid, _neg_count(seq, edges, adj)))
|
|
191
|
+
for seq in _enumerate_cycles(core, adj):
|
|
192
|
+
cands.append((seq, None, _neg_count(seq, edges, adj)))
|
|
193
|
+
if not cands:
|
|
194
|
+
if why is not None:
|
|
195
|
+
why["reason"] = ("no simple loop (declared or enumerated) — "
|
|
196
|
+
"nothing to close the main chain")
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
kmax = max(len(s) for s, _, _ in cands)
|
|
200
|
+
if kmax < 3 or kmax > MAX_RING_K:
|
|
201
|
+
if why is not None:
|
|
202
|
+
why["reason"] = (f"longest simple loop k={kmax} "
|
|
203
|
+
f"(need 3..{MAX_RING_K})")
|
|
204
|
+
return None
|
|
205
|
+
# Pure ring (single component, all degree 2, k=n) goes to ring placement
|
|
206
|
+
if kmax == n and all(len(adj[v]) == 2 for v in core):
|
|
207
|
+
if why is not None:
|
|
208
|
+
why["reason"] = (f"pure ring k=n={n} — belongs to ring-cycle "
|
|
209
|
+
"(pure-cycle trigger)")
|
|
210
|
+
return None
|
|
211
|
+
# Main ring covers the whole graph (k=n) -> ring-dominated, not column
|
|
212
|
+
if kmax >= n:
|
|
213
|
+
if why is not None:
|
|
214
|
+
why["reason"] = (f"main ring covers the whole graph (k={kmax} "
|
|
215
|
+
f">= n={n}) — ring-dominated, not column")
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
colors = _wl_colors(core, adj)
|
|
219
|
+
eligible = [(s, lid, neg) for s, lid, neg in cands if len(s) == kmax]
|
|
220
|
+
# Selection: fewest negative edges (all-positive loop as spine) -> structural-color signature -> declared first -> canonical form
|
|
221
|
+
best = None
|
|
222
|
+
for seq, lid, neg in eligible:
|
|
223
|
+
key = (neg, tuple(sorted(tuple(colors[v]) for v in seq)),
|
|
224
|
+
0 if lid is not None else 1, _canon(seq))
|
|
225
|
+
if best is None or key < best[0]:
|
|
226
|
+
best = (key, seq, lid)
|
|
227
|
+
_, spine0, lid = best
|
|
228
|
+
spine = _orient_spine(spine0, adj, colors, edges)
|
|
229
|
+
residuals = [v for v in core if v not in set(spine)]
|
|
230
|
+
if why is not None:
|
|
231
|
+
why["reason"] = (f"main ring k={len(spine)} < n={n} "
|
|
232
|
+
"(small main ring -> strong chain reading)")
|
|
233
|
+
return {"spine": spine, "k": len(spine), "declared_loop": lid,
|
|
234
|
+
"residuals": residuals, "deg0": deg0,
|
|
235
|
+
"closing": (spine[-1], spine[0])}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _canon(seq):
|
|
239
|
+
best = None
|
|
240
|
+
for s in (list(seq), list(seq)[::-1]):
|
|
241
|
+
for i in range(len(s)):
|
|
242
|
+
rot = s[i:] + s[:i]
|
|
243
|
+
if best is None or rot < best:
|
|
244
|
+
best = rot
|
|
245
|
+
return tuple(best)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _orient_spine(seq, adj, colors, edges):
|
|
249
|
+
"""Ring order -> directed spine order: start = structurally most salient node
|
|
250
|
+
(WL color -> degree -> id); direction = along directed edges (the ring
|
|
251
|
+
adjacency direction that directed edge a->b follows)."""
|
|
252
|
+
esign = set()
|
|
253
|
+
for a, b, _ in edges:
|
|
254
|
+
esign.add((a, b))
|
|
255
|
+
n = len(seq)
|
|
256
|
+
# Candidate start: take the first by (structural color max lexicographically = most salient, degree, -id order)
|
|
257
|
+
start = max(seq, key=lambda v: (tuple(colors[v]), len(adj[v]),
|
|
258
|
+
tuple(-ord(c) for c in str(v))))
|
|
259
|
+
# Of the two ring orientations take the one with more "directed-edge follows"; tie -> smaller canonical order
|
|
260
|
+
def directed_follows(order):
|
|
261
|
+
c = 0
|
|
262
|
+
for i in range(len(order)):
|
|
263
|
+
if (order[i], order[(i + 1) % len(order)]) in esign:
|
|
264
|
+
c += 1
|
|
265
|
+
return c
|
|
266
|
+
o1 = [seq[(seq.index(start) + i) % n] for i in range(n)]
|
|
267
|
+
o2 = [seq[(seq.index(start) - i) % n] for i in range(n)]
|
|
268
|
+
c1, c2 = directed_follows(o1), directed_follows(o2)
|
|
269
|
+
if c1 != c2:
|
|
270
|
+
return o1 if c1 > c2 else o2
|
|
271
|
+
return o1 if _canon(o1) <= _canon(o2) else o2
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _quad_points(p0, ctrl, p1, seg=24):
|
|
275
|
+
"""Quadratic bezier sampling (same criteria as routing.quad_points)."""
|
|
276
|
+
return [[(1 - t) ** 2 * p0[0] + 2 * (1 - t) * t * ctrl[0] + t ** 2 * p1[0],
|
|
277
|
+
(1 - t) ** 2 * p0[1] + 2 * (1 - t) * t * ctrl[1] + t ** 2 * p1[1]]
|
|
278
|
+
for t in (i / seg for i in range(seg + 1))]
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _pt_rect_dist(px, py, cx, cy, hw, hh):
|
|
282
|
+
dx = max(abs(px - cx) - hw, 0.0)
|
|
283
|
+
dy = max(abs(py - cy) - hh, 0.0)
|
|
284
|
+
return math.hypot(dx, dy)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _arc_clear(pts, nodes_px, a, b, clearance):
|
|
288
|
+
"""Arc clearance verification (same criteria as check3): dense sampling via
|
|
289
|
+
sample_polyline step=4 — the bezier chord-tangent effect reaches ~4px at
|
|
290
|
+
the high-curvature ends; sparse sampling would miss it."""
|
|
291
|
+
from .routing import sample_polyline
|
|
292
|
+
dense = sample_polyline([tuple(p) for p in pts], step=4.0)
|
|
293
|
+
for v, (nx, ny, nw, nh) in nodes_px.items():
|
|
294
|
+
if v in (a, b):
|
|
295
|
+
continue
|
|
296
|
+
for px, py in dense:
|
|
297
|
+
if _pt_rect_dist(px, py, nx, ny, nw / 2, nh / 2) < clearance + 2:
|
|
298
|
+
return False
|
|
299
|
+
return True
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _closing_arc(nodes_px, spine, widths, card_h, clearance, side_cols):
|
|
303
|
+
"""Spine closing edge (last->first): a large left-side arc.
|
|
304
|
+
|
|
305
|
+
The bulge of a quadratic bezier at t=0.5 = |ctrl - chord midpoint| /
|
|
306
|
+
2 — the control point is solved directly from "target bulge line =
|
|
307
|
+
left edge of the leftmost obstacle - (clearance + margin)", then
|
|
308
|
+
verified analytically against every box; on failure it is
|
|
309
|
+
extrapolated on a 48px ladder (up to 6 rungs); still infeasible ->
|
|
310
|
+
Infeasible.
|
|
311
|
+
|
|
312
|
+
side_cols: {side(-1 left/+1 right): [(cx, hw), ...]} branch boxes
|
|
313
|
+
already placed on that side (the arc must avoid them).
|
|
314
|
+
Returns {"kind": "arc", "points": [...], "ctrl": [x, y],
|
|
315
|
+
"anchors": {...}, "min_clear": float}
|
|
316
|
+
"""
|
|
317
|
+
a_last, a_first = spine[-1], spine[0]
|
|
318
|
+
xa, ya = nodes_px[a_last][0], nodes_px[a_last][1]
|
|
319
|
+
xb, yb = nodes_px[a_first][0], nodes_px[a_first][1]
|
|
320
|
+
ha = widths[a_last] / 2
|
|
321
|
+
hb = widths[a_first] / 2
|
|
322
|
+
pa = [xa - ha, ya] # midpoint of the lower card's left edge
|
|
323
|
+
pb = [xb - hb, yb] # midpoint of the upper card's left edge
|
|
324
|
+
ymid = (ya + yb) / 2
|
|
325
|
+
# Target bulge line: left edge of every box to avoid - (clearance + 4px conservative margin)
|
|
326
|
+
target = None
|
|
327
|
+
for v, (nx, ny, nw, nh) in nodes_px.items():
|
|
328
|
+
if v in (a_last, a_first):
|
|
329
|
+
continue
|
|
330
|
+
t = nx - nw / 2 - clearance - 4
|
|
331
|
+
target = t if target is None else min(target, t)
|
|
332
|
+
if target is None:
|
|
333
|
+
target = (pa[0] + pb[0]) / 2 - clearance - ARC_MARGIN
|
|
334
|
+
chord_mid = (pa[0] + pb[0]) / 2
|
|
335
|
+
base_ctrl = 2.0 * target - chord_mid # the t=0.5 bulge exactly reaches target
|
|
336
|
+
for step in (0, 48, 96, 144, 192, 240):
|
|
337
|
+
ctrl = [base_ctrl - step, ymid]
|
|
338
|
+
pts = _quad_points(pa, ctrl, pb)
|
|
339
|
+
ok = _arc_clear(pts, nodes_px, a_last, a_first, clearance)
|
|
340
|
+
if ok:
|
|
341
|
+
return {"kind": "arc", "points": pts, "ctrl": ctrl,
|
|
342
|
+
"min_clear": None}
|
|
343
|
+
raise InfeasibleColumn("no feasible extrapolation tier for the closing arc")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _side_arc(nodes_px, a, b, side, widths, card_h, clearance):
|
|
347
|
+
"""A spine cross-row chord edge (spine node pair with |row diff| >= 2) is
|
|
348
|
+
constructed as a large arc on `side`.
|
|
349
|
+
|
|
350
|
+
Same math as _closing_arc: t=0.5 bulge = |ctrl - chord midpoint| / 2;
|
|
351
|
+
the control point is solved from the target bulge line + verified per
|
|
352
|
+
box + extrapolated on a 48px ladder (6 rungs); returns None if
|
|
353
|
+
infeasible (the caller falls back to the Router candidate pool).
|
|
354
|
+
side=+1 right / -1 left."""
|
|
355
|
+
xa, ya = nodes_px[a][0], nodes_px[a][1]
|
|
356
|
+
xb, yb = nodes_px[b][0], nodes_px[b][1]
|
|
357
|
+
pa = [xa + side * widths[a] / 2, ya]
|
|
358
|
+
pb = [xb + side * widths[b] / 2, yb]
|
|
359
|
+
ymid = (ya + yb) / 2
|
|
360
|
+
target = None
|
|
361
|
+
for v, (nx, ny, nw, nh) in nodes_px.items():
|
|
362
|
+
if v in (a, b):
|
|
363
|
+
continue
|
|
364
|
+
t = (nx + side * nw / 2) + side * (clearance + 4)
|
|
365
|
+
target = t if target is None else (max(target, t) if side > 0
|
|
366
|
+
else min(target, t))
|
|
367
|
+
chord_mid = (pa[0] + pb[0]) / 2
|
|
368
|
+
if target is None:
|
|
369
|
+
target = chord_mid + side * (clearance + ARC_MARGIN)
|
|
370
|
+
base_ctrl = 2.0 * target - chord_mid
|
|
371
|
+
for step in (0, 48, 96, 144, 192, 240):
|
|
372
|
+
ctrl = [base_ctrl + side * step, ymid]
|
|
373
|
+
pts = _quad_points(pa, ctrl, pb)
|
|
374
|
+
ok = _arc_clear(pts, nodes_px, a, b, clearance)
|
|
375
|
+
if ok:
|
|
376
|
+
return {"kind": "arc", "points": pts, "ctrl": ctrl,
|
|
377
|
+
"min_clear": None}
|
|
378
|
+
return None
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def place_column(plan, widths, card_h, cfg, adj, edges):
|
|
382
|
+
"""Column placement -> ({v: x}, {v: y}, meta, closing_routes). All on the
|
|
383
|
+
2px grid, deterministic.
|
|
384
|
+
|
|
385
|
+
Branch columns: right column first (+1); extra branches overflow to
|
|
386
|
+
the left column (-1, the closing arc yields by extrapolating further
|
|
387
|
+
out). A single-anchor branch sits on the anchor's row; a two-anchor
|
|
388
|
+
branch sits at the midpoint of the two anchor rows (half-pitch
|
|
389
|
+
offset).
|
|
390
|
+
"""
|
|
391
|
+
spine = plan["spine"]
|
|
392
|
+
k = len(spine)
|
|
393
|
+
x0 = 0 # column axis (uniformly translated at the delivery layer)
|
|
394
|
+
pitch = cfg.pitch
|
|
395
|
+
pos, y = {}, {}
|
|
396
|
+
for i, v in enumerate(spine):
|
|
397
|
+
pos[v] = x0
|
|
398
|
+
y[v] = cfg.top + i * pitch
|
|
399
|
+
spine_w_max = max(widths[v] for v in spine)
|
|
400
|
+
|
|
401
|
+
# Group residuals (mutually adjacent residuals = one branch structure); anchors = the group's spine neighbors
|
|
402
|
+
spine_set = set(spine)
|
|
403
|
+
res_set = set(plan["residuals"])
|
|
404
|
+
groups = [] # [(anchor tuple, [residual nodes] (id order))]
|
|
405
|
+
seen = set()
|
|
406
|
+
for v in sorted(plan["residuals"]):
|
|
407
|
+
if v in seen:
|
|
408
|
+
continue
|
|
409
|
+
comp, stack = [], [v]
|
|
410
|
+
seen.add(v)
|
|
411
|
+
while stack:
|
|
412
|
+
u = stack.pop()
|
|
413
|
+
comp.append(u)
|
|
414
|
+
for w in sorted(adj[u] & res_set):
|
|
415
|
+
if w not in seen:
|
|
416
|
+
seen.add(w)
|
|
417
|
+
stack.append(w)
|
|
418
|
+
anch = tuple(sorted(set().union(*[adj[u] & spine_set for u in comp])))
|
|
419
|
+
groups.append((anch, sorted(comp)))
|
|
420
|
+
# Deterministic group order: anchor row positions on the spine -> anchor tuple -> member ids
|
|
421
|
+
def _group_key(g):
|
|
422
|
+
anch, comp = g
|
|
423
|
+
rows = tuple(sorted(spine.index(a) for a in anch))
|
|
424
|
+
return (rows, anch, tuple(comp))
|
|
425
|
+
groups.sort(key=_group_key)
|
|
426
|
+
|
|
427
|
+
# Column geometry: right column (+1) first; the closing arc occupies the left, overflow groups go left (arc yields)
|
|
428
|
+
closing_side = -1
|
|
429
|
+
wmax_side = max([widths[v] for _, comp in groups for v in comp] or [10])
|
|
430
|
+
side_x = int((spine_w_max / 2 + SIDE_GAP + wmax_side / 2) / 2) * 2
|
|
431
|
+
col_slots = {1: [], -1: []} # occupied row positions [y]
|
|
432
|
+
side_cols = {1: [], -1: []} # (cx, hw) for the closing arc to avoid
|
|
433
|
+
|
|
434
|
+
def _base_y(anch):
|
|
435
|
+
"""Group row position: single anchor = the anchor's row; multiple anchors = midpoint of the first/last anchor rows."""
|
|
436
|
+
if len(anch) == 1:
|
|
437
|
+
return y[anch[0]]
|
|
438
|
+
return (y[anch[0]] + y[anch[-1]]) / 2
|
|
439
|
+
|
|
440
|
+
def _try_place(anch, comp, side):
|
|
441
|
+
"""Place the whole group into the `side` column: members expand from base
|
|
442
|
+
by whole pitches downward/upward (pitch >= card_h+SIDE_GAP,
|
|
443
|
+
guaranteeing in-group clearance); slot conflict -> None."""
|
|
444
|
+
base = _base_y(anch)
|
|
445
|
+
offsets = []
|
|
446
|
+
for i in range(len(comp)):
|
|
447
|
+
offsets.append(((i + 1) // 2) * pitch * (1 if i % 2 == 0 else -1))
|
|
448
|
+
ys = [base + off for off in offsets]
|
|
449
|
+
for yy in ys:
|
|
450
|
+
if any(abs(yy - q) < card_h + SIDE_GAP for q in col_slots[side]):
|
|
451
|
+
return None
|
|
452
|
+
out = {}
|
|
453
|
+
for v, yy in zip(comp, ys):
|
|
454
|
+
out[v] = (int((side * side_x) / 2) * 2, int(round(yy / 2) * 2))
|
|
455
|
+
return out
|
|
456
|
+
|
|
457
|
+
for anch, comp in groups:
|
|
458
|
+
placed = None
|
|
459
|
+
for side in (1, -1):
|
|
460
|
+
cand = _try_place(anch, comp, side)
|
|
461
|
+
if cand is not None:
|
|
462
|
+
placed = cand
|
|
463
|
+
break
|
|
464
|
+
if placed is None:
|
|
465
|
+
# Fallback: same-group row-shifting ladder (probe rungs downward/upward in the right column, whole-pitch steps)
|
|
466
|
+
base = _base_y(anch)
|
|
467
|
+
for dy in (pitch, -pitch, pitch * 2, -pitch * 2,
|
|
468
|
+
pitch * 3 / 2, -pitch * 3 / 2):
|
|
469
|
+
ys = [base + dy + off for off in
|
|
470
|
+
[((i + 1) // 2) * pitch * (1 if i % 2 == 0 else -1)
|
|
471
|
+
for i in range(len(comp))]]
|
|
472
|
+
if all(not any(abs(yy - q) < card_h + SIDE_GAP
|
|
473
|
+
for q in col_slots[1]) for yy in ys):
|
|
474
|
+
placed = {v: (int(side_x / 2) * 2, int(round(yy / 2) * 2))
|
|
475
|
+
for v, yy in zip(comp, ys)}
|
|
476
|
+
break
|
|
477
|
+
if placed is None:
|
|
478
|
+
raise InfeasibleColumn(f"branch group {comp} has no feasible column slots")
|
|
479
|
+
for v, (px, py) in placed.items():
|
|
480
|
+
pos[v], y[v] = px, py
|
|
481
|
+
col_slots[1 if px > x0 else -1].append(py)
|
|
482
|
+
side_cols[1 if px > x0 else -1].append((px, widths[v]))
|
|
483
|
+
|
|
484
|
+
# Isolated-node parking strip
|
|
485
|
+
if plan["deg0"]:
|
|
486
|
+
total = sum(widths[v] for v in plan["deg0"]) + 18 * (len(plan["deg0"]) + 1)
|
|
487
|
+
cur = x0 - total / 2
|
|
488
|
+
for v in plan["deg0"]:
|
|
489
|
+
pos[v] = int(round((cur + widths[v] / 2) / 2) * 2)
|
|
490
|
+
y[v] = cfg.park_y
|
|
491
|
+
cur += widths[v] + 18
|
|
492
|
+
|
|
493
|
+
nodes_px = {v: (pos[v], y[v], widths[v], card_h) for v in pos}
|
|
494
|
+
closing = _closing_arc(nodes_px, spine, widths, card_h,
|
|
495
|
+
cfg.clearance, side_cols)
|
|
496
|
+
|
|
497
|
+
# Spine cross-row chord edges (non-adjacent spine pairs): constructed as right-side
|
|
498
|
+
# arcs for loop-cohesion in-chord routing (opposite the closing arc); infeasible -> Router pool
|
|
499
|
+
constructed = {plan["closing"]: closing}
|
|
500
|
+
row_of = {v: i for i, v in enumerate(spine)}
|
|
501
|
+
eset = set(edges)
|
|
502
|
+
for (a, b) in sorted(eset):
|
|
503
|
+
if a not in row_of or b not in row_of:
|
|
504
|
+
continue
|
|
505
|
+
if (a, b) == plan["closing"] or (b, a) == plan["closing"]:
|
|
506
|
+
continue
|
|
507
|
+
if abs(row_of[a] - row_of[b]) < 2:
|
|
508
|
+
continue
|
|
509
|
+
sa = _side_arc(nodes_px, a, b, 1, widths, card_h, cfg.clearance)
|
|
510
|
+
if sa is not None:
|
|
511
|
+
constructed[(a, b)] = sa
|
|
512
|
+
|
|
513
|
+
meta = {
|
|
514
|
+
"placement": "column-chain",
|
|
515
|
+
"column": {
|
|
516
|
+
"spine": list(spine),
|
|
517
|
+
"pitch": pitch,
|
|
518
|
+
"axis_x": x0,
|
|
519
|
+
"declared_loop": plan.get("declared_loop"),
|
|
520
|
+
"closing": list(plan["closing"]),
|
|
521
|
+
"closing_side": "left",
|
|
522
|
+
"side_columns": {s: [v for v in sorted(pos)
|
|
523
|
+
if v not in spine_set
|
|
524
|
+
and (pos[v] > x0) == (s == 1)]
|
|
525
|
+
for s in (1, -1) if any(
|
|
526
|
+
v not in spine_set and
|
|
527
|
+
(pos[v] > x0) == (s == 1) for v in pos)},
|
|
528
|
+
"residuals": list(plan["residuals"]),
|
|
529
|
+
},
|
|
530
|
+
}
|
|
531
|
+
return pos, y, meta, constructed
|