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/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