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/comblayout.py ADDED
@@ -0,0 +1,533 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Comb-bilateral placement (v0.7.7): K_{m,2}-family graphs.
3
+
4
+ Ground truth = jacky's hand-pulled HDD supply-chain layout (2026-11-07):
5
+ 6 source nodes in one vertical column (column x spread <= 10px in the hand
6
+ pull; the engine delivers exact alignment), two non-adjacent hubs in
7
+ symmetric left/right slots at the column middle row, every hub edge a
8
+ straight line forming a near-horizontal fan, and the single in-column edge
9
+ a vertical straight line.
10
+
11
+ Structure detection (deterministic, graph-theoretic):
12
+ - exactly two hub candidates h1, h2, non-adjacent, whose neighbor sets
13
+ are identical and equal to the common-neighbor source set S (|S| = m >= 3);
14
+ - every remaining core node lives in the induced subgraph on S + rest as
15
+ pendant trees, each tree containing exactly one source node (the root);
16
+ - detection sits in the auto-v2 priority chain before ring (a K_{m,2}
17
+ graph scores a high non-bridge ratio and a 4-cycle "main ring", which
18
+ historically misfired the ring ratio branch).
19
+
20
+ Geometry (all coordinates on the 2px grid):
21
+ - sources + pendant trees in one vertical column, constant pitch,
22
+ pendant descendants placed above their root (post-order DFS, children
23
+ first, declaration-order tie-break) so pendants sit at the column end;
24
+ - components ordered by the declaration index of their root;
25
+ - hubs at (axis_x -+ hub_dx, hub_y) with hub_y at the column middle row;
26
+ mirror symmetry is exact by construction (hub_symmetry_err = 0);
27
+ - hub edges are NOT constructed here: the standard Router routes them and
28
+ the trial gate requires every route to be a straight line (the comb
29
+ contract); any deviation falls back to the next tier untouched.
30
+ """
31
+ import math
32
+
33
+
34
+ class InfeasibleComb(Exception):
35
+ """Raised when the comb template cannot be realized (e.g. deg0 strip)."""
36
+
37
+
38
+ MAX_COMB_N = 12 # trigger upper bound, same caliber as the other small-graph modes
39
+ MIN_SOURCES = 3 # K_{3,2} minimum: below this the pair is not a bilateral hub pair
40
+ HUB_CORRIDOR = 110 # extra corridor px beyond the clearance reserve in hub_dx
41
+ HUB_DX_MIN = 200 # aesthetic floor for the hub half-span
42
+
43
+
44
+ def _adjacency(edges):
45
+ adj = {}
46
+ for a, b in edges:
47
+ adj.setdefault(a, set()).add(b)
48
+ adj.setdefault(b, set()).add(a)
49
+ return adj
50
+
51
+
52
+ def detect_comb(graph, why=None):
53
+ """Trigger detection. Returns a comb plan dict or None (no trigger).
54
+
55
+ plan = {"hubs": [left, right], "sources": [S in declaration order],
56
+ "column": [ordered column, top -> bottom], "m": int,
57
+ "components": {root: [tree nodes in column order]}, "deg0": [...]}
58
+
59
+ why: optional dict — receives a deterministic one-line reason for the
60
+ outcome (mode decision trace, certificate mode_decision_trace field).
61
+ """
62
+ nodes = list(graph.node_ids())
63
+ n = len(nodes)
64
+ if n < 2 * 1 + MIN_SOURCES or n > MAX_COMB_N:
65
+ if why is not None:
66
+ why["reason"] = (f"n={n} (need {2 * 1 + MIN_SOURCES}.."
67
+ f"{MAX_COMB_N})")
68
+ return None
69
+ edges = [(e.a, e.b) for e in graph.edges]
70
+ adj = _adjacency(edges)
71
+ core = [v for v in nodes if adj.get(v)]
72
+ deg0 = [v for v in nodes if not adj.get(v)]
73
+ if len(core) < 2 + MIN_SOURCES:
74
+ if why is not None:
75
+ why["reason"] = (f"core n={len(core)} "
76
+ f"(need >= {2 + MIN_SOURCES})")
77
+ return None
78
+
79
+ decl = {v: i for i, v in enumerate(nodes)}
80
+ best = None
81
+ for i in range(len(core)):
82
+ for j in range(i + 1, len(core)):
83
+ h1, h2 = core[i], core[j]
84
+ if h2 in adj[h1]:
85
+ continue # hubs must be non-adjacent
86
+ S = adj[h1] & adj[h2]
87
+ if len(S) < MIN_SOURCES:
88
+ continue
89
+ if adj[h1] != S or adj[h2] != S:
90
+ continue # hubs connect exactly to the sources
91
+ rest = [v for v in core if v not in S and v not in (h1, h2)]
92
+ plan = _pendant_trees(nodes, S, rest, adj)
93
+ if plan is None:
94
+ continue
95
+ key = (-len(S), decl[h1], decl[h2])
96
+ if best is None or key < best[0]:
97
+ best = (key, h1, h2, sorted(S, key=lambda v: decl[v]), plan)
98
+ if best is None:
99
+ if why is not None:
100
+ why["reason"] = ("no bilateral-hub pair: no two non-adjacent "
101
+ f"nodes with identical source-only neighborhoods "
102
+ f"(|S| >= {MIN_SOURCES})")
103
+ return None
104
+ _, h1, h2, sources, (comps, column) = best
105
+ if why is not None:
106
+ why["reason"] = (f"bilateral hubs {h1}/{h2} with identical "
107
+ f"source-only neighborhood |S|={len(sources)}")
108
+ return {"hubs": [h1, h2], "sources": sources, "column": column,
109
+ "components": comps, "m": len(sources), "deg0": deg0}
110
+
111
+
112
+ def _pendant_trees(nodes, S, rest, adj):
113
+ """Induced subgraph on S + rest must be a forest of trees, each holding
114
+ exactly one source node (the root). Returns ({root: column order}, full
115
+ column order) or None."""
116
+ Sset = set(S)
117
+ inside = set(S) | set(rest)
118
+ tree_adj = {v: set() for v in inside}
119
+ for v in inside:
120
+ for w in adj[v]:
121
+ if w in inside:
122
+ tree_adj[v].add(w)
123
+ # connected components (deterministic, declaration-order traversal)
124
+ decl = {v: i for i, v in enumerate(nodes)}
125
+ seen = set()
126
+ comps = []
127
+ for v in sorted(inside, key=lambda x: decl[x]):
128
+ if v in seen:
129
+ continue
130
+ stack, comp = [v], []
131
+ seen.add(v)
132
+ while stack:
133
+ u = stack.pop()
134
+ comp.append(u)
135
+ for w in sorted(tree_adj[u], key=lambda x: decl[x]):
136
+ if w not in seen:
137
+ seen.add(w)
138
+ stack.append(w)
139
+ comps.append(comp)
140
+ roots = {}
141
+ for comp in comps:
142
+ srcs = [v for v in comp if v in Sset]
143
+ if len(srcs) != 1:
144
+ return None # a tree must hang off exactly one source
145
+ if len(comp) > 1:
146
+ # tree check: connected + edges == nodes-1 (a cycle fails this)
147
+ ecount = sum(len(tree_adj[v]) for v in comp) // 2
148
+ if ecount != len(comp) - 1:
149
+ return None # cycle among sources/pendants
150
+ roots[comp[0]] = srcs[0]
151
+ # column order: components by root declaration index; inside a tree,
152
+ # post-order DFS (children before the node, pendant chains at the top)
153
+ column, comp_order = [], {}
154
+ def post(v, parent):
155
+ for w in sorted(tree_adj[v], key=lambda x: decl[x]):
156
+ if w != parent:
157
+ post(w, v)
158
+ column.append(v)
159
+ comp_order.setdefault(roots_root[v], []).append(v)
160
+ # map node -> its component root
161
+ roots_root = {}
162
+ for comp in comps:
163
+ r = roots[comp[0]]
164
+ for v in comp:
165
+ roots_root[v] = r
166
+ for comp in sorted(comps, key=lambda c: decl[roots[c[0]]]):
167
+ r0 = roots[comp[0]]
168
+ post(r0, None)
169
+ # ensure the whole component got appended contiguously in this pass
170
+ return {roots[comp[0]]: [v for v in comp_order.get(roots[comp[0]], [])]
171
+ for comp in comps}, column
172
+
173
+
174
+ def _hub_fan_clear(pos, y, widths, card_h, plan, cfg, margin=4.0,
175
+ ang_margin=2.0):
176
+ """Conservative straight-fan feasibility proxy. For every hub edge
177
+ (anchored via border_point facing the other box): every unrelated box
178
+ must be cleared by clearance + margin AND both anchor departure angles
179
+ must be >= ANCHOR_MIN_DEG + ang_margin (the Router soft-rejects shallow
180
+ anchor exits, which hands the edge to a polyline — the comb contract
181
+ needs the straight candidate to actually win).
182
+
183
+ Wider hub spans improve box clearance but flatten the fan angles, so the
184
+ two conditions bracket an admissible window; the caller widens from the
185
+ aesthetic floor and stops at the first admissible span."""
186
+ import math as _m
187
+ from .routing import border_point, ANCHOR_MIN_DEG
188
+ from .visibility import seg_rect_distance
189
+ boxes = {v: (pos[v], y[v], widths[v] / 2.0, card_h / 2.0) for v in pos}
190
+
191
+ def _ang(v, anchor, other):
192
+ cx, cy, hw, hh = boxes[v]
193
+ on_x = abs(abs(anchor[0] - cx) - hw) <= abs(abs(anchor[1] - cy) - hh)
194
+ dx, dy = other[0] - anchor[0], other[1] - anchor[1]
195
+ n = _m.hypot(dx, dy)
196
+ if n < 1e-9:
197
+ return 0.0
198
+ tx, ty = (0.0, 1.0) if on_x else (1.0, 0.0)
199
+ c = abs(dx * tx + dy * ty) / n
200
+ return _m.degrees(_m.acos(max(0.0, min(1.0, c))))
201
+
202
+ def _slide(v, anchor, off):
203
+ """Slide an anchor along its border tangent by off px (clamped to the
204
+ border segment) — mirrors Router._offset_anchors; the router's
205
+ candidate pool uses +/-10/20/30 slides, so the proxy must too."""
206
+ cx, cy, hw, hh = boxes[v]
207
+ on_x = abs(abs(anchor[0] - cx) - hw) <= abs(abs(anchor[1] - cy) - hh)
208
+ if on_x:
209
+ t = max(-hh + 3, min(hh - 3, anchor[1] + off - cy))
210
+ return [anchor[0], cy + t]
211
+ t = max(-hw + 3, min(hw - 3, anchor[0] + off - cx))
212
+ return [cx + t, anchor[1]]
213
+
214
+ def _edge_ok(s, h):
215
+ A, B = boxes[s], boxes[h]
216
+ base_a = border_point(A[0], A[1], A[2], A[3],
217
+ B[0] - A[0], B[1] - A[1])
218
+ base_b = border_point(B[0], B[1], B[2], B[3],
219
+ A[0] - B[0], A[1] - B[1])
220
+ for off in (0, 10, 20, 30):
221
+ for sign in (1, -1):
222
+ pa = base_a if off == 0 else _slide(s, base_a, off * sign)
223
+ pb = base_b if off == 0 else _slide(h, base_b, off * sign)
224
+ clear_ok = True
225
+ for v, rect in boxes.items():
226
+ if v in (s, h):
227
+ continue
228
+ if seg_rect_distance(pa, pb, rect) < cfg.clearance + margin:
229
+ clear_ok = False
230
+ break
231
+ if not clear_ok:
232
+ continue
233
+ if min(_ang(s, pa, pb), _ang(h, pb, pa)) < ANCHOR_MIN_DEG + ang_margin:
234
+ continue
235
+ return True # some router-pool variant works
236
+ return False
237
+
238
+ for s in plan["sources"]:
239
+ for h in plan["hubs"]:
240
+ if not _edge_ok(s, h):
241
+ return False
242
+ return True
243
+
244
+
245
+ def _side_anchor(box, other, y_override=None):
246
+ """v0.7.8 wide-card fan anchor: the point where the center-to-center
247
+ segment crosses the box's FACING VERTICAL border, clamped into the
248
+ border segment (mirrors the +/-3px corner reserve of Router slides).
249
+
250
+ Rationale (8777 wide-card root cause): for a wide column card the
251
+ center-ray exits via the top/bottom border for almost any slope, and the
252
+ near-horizontal fan line then grazes that horizontal border below
253
+ ANCHOR_MIN_DEG — the global hub-span bracket can never fix it (widening
254
+ makes the line flatter, i.e. the angle strictly worse). On the facing
255
+ vertical border the same near-horizontal line is perpendicular (~90
256
+ degrees) — smooth by construction. Returns [x, y] ints (x on the border
257
+ plane, y on the 2px grid) or None when the boxes share an x-center.
258
+
259
+ y_override: fan-spread resolver result for the hub-side anchor (the raw
260
+ crossing clamps would stack several fan lines at the same border point
261
+ when the column spans many rows)."""
262
+ cx, cy, hw, hh = box
263
+ ox = other[0]
264
+ if abs(ox - cx) < 1e-9:
265
+ return None
266
+ t = hw / abs(ox - cx)
267
+ yy = y_override if y_override is not None else cy + (other[1] - cy) * t
268
+ lim = hh - 3
269
+ yy = max(cy - lim, min(cy + lim, yy))
270
+ yy = float(int(round(yy / 2.0)) * 2)
271
+ yy = max(cy - lim, min(cy + lim, yy))
272
+ return [int(cx + (hw if ox > cx else -hw)), int(yy)]
273
+
274
+
275
+ FAN_SEP_MIN = 8 # minimum px between distinct hub-border fan anchors
276
+
277
+
278
+ def _spread_border_ys(desired, lo, hi, sep=FAN_SEP_MIN):
279
+ """Deterministic 1D separation of hub-border anchor slots (the
280
+ fan_spread rule for constructed fans). The slot ORDER is fixed by the
281
+ desired crossing y (= the source row order: keeping it is exactly the
282
+ non-crossing condition for the fan segments); forward + backward sweeps
283
+ then push clamped slots apart inside the border segment. Returns
284
+ {key: final y} or None when the segment cannot host the anchors."""
285
+ order = sorted(desired, key=lambda k: (desired[k], k))
286
+ vals = [min(hi, max(lo, float(desired[k]))) for k in order]
287
+ for _ in range(2 * len(vals) + 4):
288
+ for i in range(1, len(vals)):
289
+ if vals[i] < vals[i - 1] + sep:
290
+ vals[i] = min(hi, vals[i - 1] + sep)
291
+ for i in range(len(vals) - 2, -1, -1):
292
+ if vals[i] > vals[i + 1] - sep:
293
+ vals[i] = max(lo, vals[i + 1] - sep)
294
+ vals = [float(int(round(v / 2.0)) * 2) for v in vals]
295
+ for i in range(1, len(vals)):
296
+ if vals[i] < vals[i - 1] + sep:
297
+ vals[i] = min(hi, vals[i - 1] + sep)
298
+ for i in range(len(vals) - 2, -1, -1):
299
+ if vals[i] > vals[i + 1] - sep:
300
+ vals[i] = max(lo, vals[i + 1] - sep)
301
+ for i in range(len(vals) - 1):
302
+ if vals[i + 1] - vals[i] < sep - 1e-9:
303
+ return None
304
+ return {k: v for k, v in zip(order, vals)}
305
+
306
+
307
+ def _side_fan_lines(pos, y, widths, card_h, plan, cfg, margin=4.0,
308
+ ang_margin=2.0):
309
+ """Tier-1 straight-fan construction: every hub edge as the segment
310
+ between the two facing-border anchors (_side_anchor on both boxes), with
311
+ hub-side anchor slots spread along the border (no stacking).
312
+
313
+ Acceptance (analytic self-checks, same caliber as _hub_fan_clear):
314
+ - anchors exist and sit on the facing vertical border planes with a
315
+ non-negative corridor between them (no own-box re-entry);
316
+ - dense clearance vs every unrelated box >= clearance + margin;
317
+ - both departure angles vs the vertical border tangent
318
+ >= ANCHOR_MIN_DEG + ang_margin.
319
+ Returns {(a, b): {"kind": "line", "points": [pa, pb]}} for the directed
320
+ hub edges (both declaration directions), or None when any check fails
321
+ (-> caller widens / gives up)."""
322
+ import math as _m
323
+ from .routing import ANCHOR_MIN_DEG
324
+ from .visibility import seg_rect_distance
325
+ boxes = {v: (pos[v], y[v], widths[v] / 2.0, card_h / 2.0) for v in pos}
326
+ # hub-side anchor slots: raw crossing y per (hub, source), then a
327
+ # deterministic spread inside the hub's facing border segment
328
+ hub_ys = {}
329
+ for h in plan["hubs"]:
330
+ desired = {}
331
+ for s in plan["sources"]:
332
+ cx, cy, hw, hh = boxes[h]
333
+ ox = boxes[s][0]
334
+ t = hw / abs(ox - cx)
335
+ desired[s] = cy + (boxes[s][1] - cy) * t
336
+ spread = _spread_border_ys(desired, boxes[h][1] - (boxes[h][3] - 3),
337
+ boxes[h][1] + (boxes[h][3] - 3))
338
+ if spread is None:
339
+ return None
340
+ hub_ys.update({(h, s): v for s, v in spread.items()})
341
+ out = {}
342
+ for s in plan["sources"]:
343
+ for h in plan["hubs"]:
344
+ pa = _side_anchor(boxes[s], boxes[h])
345
+ pb = _side_anchor(boxes[h], boxes[s],
346
+ y_override=hub_ys.get((h, s)))
347
+ if pa is None or pb is None:
348
+ return None
349
+ # corridor sanity: both anchors sit on their facing border
350
+ # planes with a non-negative gap between them (no own-box
351
+ # re-entry: the segment's x-range stays outside both boxes)
352
+ if boxes[h][0] < boxes[s][0]:
353
+ if not (pb[0] + 1e-9 <= pa[0]):
354
+ return None
355
+ else:
356
+ if not (pa[0] + 1e-9 <= pb[0]):
357
+ return None
358
+ for v, rect in boxes.items():
359
+ if v not in (s, h):
360
+ if seg_rect_distance(pa, pb, rect) < cfg.clearance + margin:
361
+ return None
362
+ dx, dy = pb[0] - pa[0], pb[1] - pa[1]
363
+ n = _m.hypot(dx, dy)
364
+ if n < 1e-9:
365
+ return None
366
+ # both borders are vertical (tangent (0,1)): one check covers
367
+ # both departure angles
368
+ c = abs(dy) / n
369
+ if _m.degrees(_m.acos(max(0.0, min(1.0, c)))) \
370
+ < ANCHOR_MIN_DEG + ang_margin:
371
+ return None
372
+ out[(s, h)] = {"kind": "line", "points": [list(pa), list(pb)]}
373
+ # reverse declaration direction carries the same geometry with
374
+ # mirrored point order (the delivery channel keys exact tuples)
375
+ out[(h, s)] = {"kind": "line", "points": [list(pb), list(pa)]}
376
+ # analytic self-check: fan segments converging on the same hub border
377
+ # must not properly intersect (the fixed desired-order spread should
378
+ # guarantee it — verify, never assume)
379
+ from .routing import seg_cross
380
+ for h in plan["hubs"]:
381
+ lines = [out[(s, h)]["points"] for s in plan["sources"]]
382
+ for i in range(len(lines)):
383
+ for j in range(i + 1, len(lines)):
384
+ if seg_cross(lines[i][0], lines[i][1],
385
+ lines[j][0], lines[j][1]):
386
+ return None
387
+ return out
388
+
389
+
390
+ def place_comb(plan, widths, card_h, cfg):
391
+ """Comb template -> ({v: x}, {v: y}, meta, constructed). All integers, 2px grid.
392
+
393
+ v0.7.8: a two-tier fan realization. Tier 0 (unchanged v0.7.7 semantics)
394
+ widens the hub half-span until the router-pool border_point anchors
395
+ realize the all-straight fan — narrow cards stop here, byte-identical to
396
+ v0.7.7. Only when that bracket is exhausted (wide column cards: the
397
+ center-ray anchors graze their horizontal borders below ANCHOR_MIN_DEG
398
+ and widening is anti-correlated with the angle) does tier 1 kick in:
399
+ hub edges are CONSTRUCTED between facing-vertical-border anchors and
400
+ delivered through the constructed-route channel (kind=line), with the
401
+ same analytic self-checks; the caller's trial gate re-runs the router
402
+ for the non-hub edges and the unified hard gates over the whole set."""
403
+
404
+ col = plan["column"]
405
+ k = len(col)
406
+ pitch = cfg.pitch
407
+ top = cfg.top
408
+ # v0.7.11 deterministic constraint #1 (P1, 8777 HDD chain): explicit
409
+ # source-column internal order. Components stay contiguous (a pendant
410
+ # tree never splits); the relative order of LISTED nodes is the hard
411
+ # constraint (component level AND inside a component), everything
412
+ # unlisted keeps engine order after the listed ones.
413
+ col_order = getattr(cfg, "column_order", None)
414
+ if col_order:
415
+ comps = plan.get("components") or {}
416
+ rank = {v: i for i, v in enumerate(col_order)}
417
+ engine_rank = {v: i for i, v in enumerate(col)}
418
+ def _comp_key(root):
419
+ members = comps.get(root) or [root]
420
+ rs = [rank[v] for v in members if v in rank]
421
+ return (min(rs) if rs else len(col_order), engine_rank[root])
422
+ ordered: list = []
423
+ for root in sorted(comps.keys(), key=_comp_key):
424
+ members = comps[root]
425
+ # within a component the LISTED nodes' relative order is also a
426
+ # hard constraint (e.g. a pendant listed after its source root):
427
+ # sort by rank first, engine DFS order as the tiebreak
428
+ engine_pos = {v: i for i, v in enumerate(members)}
429
+ members_sorted = sorted(members,
430
+ key=lambda v: (rank.get(v, len(col_order)
431
+ + engine_pos[v]),
432
+ engine_pos[v]))
433
+ ordered.extend(members_sorted)
434
+ seen = set(ordered)
435
+ ordered.extend(v for v in col if v not in seen)
436
+ col = ordered
437
+ axis_x = int(round((cfg.margin + max(widths[v] for v in col) / 2.0) / 2) * 2)
438
+ y = {v: top + i * pitch for i, v in enumerate(col)}
439
+ hub_y_raw = top + (k - 1) * pitch / 2.0
440
+ hub_y = int(round(hub_y_raw / 2) * 2)
441
+ hl, hr = plan["hubs"]
442
+ # v0.7.11 deterministic constraint #2 (P1): explicit hub side assignment.
443
+ # One explicit side is enough to fix the bilateral assignment; a
444
+ # contradicting spec degrades deterministically (first left wins, then
445
+ # first right) instead of raising — the fan-clearance gates downstream
446
+ # still hold the geometry honest.
447
+ _side = getattr(cfg, "hub_side", None) or {}
448
+ _want = {v: s for v, s in _side.items()
449
+ if s in ("left", "right") and v in (hl, hr)}
450
+ _lefts = [v for v in (hl, hr) if _want.get(v) == "left"]
451
+ _rights = [v for v in (hl, hr) if _want.get(v) == "right"]
452
+ if (_lefts and _lefts[0] != hl) or (not _lefts and _rights and _rights[0] != hr):
453
+ hl, hr = hr, hl
454
+ hub_w = max(widths[hl], widths[hr])
455
+ wmax = max(widths[v] for v in col)
456
+ hub_dx = max(HUB_DX_MIN,
457
+ int((wmax + hub_w) / 2 + cfg.clearance + HUB_CORRIDOR))
458
+ hub_dx = int(math.ceil(hub_dx / 2) * 2)
459
+ hub_dx_floor = hub_dx
460
+ pos = {v: axis_x for v in col}
461
+ pos[hl] = axis_x - hub_dx
462
+ pos[hr] = axis_x + hub_dx
463
+ y[hl] = y[hr] = hub_y
464
+ constructed = {}
465
+ # Tier 0 — deterministic widening with the router-pool border_point
466
+ # anchors (unchanged v0.7.7 semantics): the hub half-span must let the
467
+ # straight fan clear every column box (steep lines from the column ends
468
+ # cut across intermediate boxes when the wings are too close). 24px
469
+ # steps, bounded. Narrow cards stop here.
470
+ for _ in range(16):
471
+ if _hub_fan_clear(pos, y, widths, card_h, plan, cfg):
472
+ break
473
+ hub_dx += 24
474
+ pos[hl] = axis_x - hub_dx
475
+ pos[hr] = axis_x + hub_dx
476
+ else:
477
+ # Tier 1 — v0.7.8 wide-card path: the tier-0 bracket is exhausted
478
+ # (angle-bound: wider spans only flatten the fan and worsen the
479
+ # horizontal-border departure angles). Re-run the bracket with
480
+ # CONSTRUCTED facing-vertical-border anchors: inside the empty
481
+ # corridor left/right of the column band both the clearance and the
482
+ # border angle improve monotonically with the span, so the window
483
+ # that was empty in tier 0 is searched again under a geometry where
484
+ # the two constraints no longer conflict.
485
+ hub_dx = hub_dx_floor
486
+ pos[hl] = axis_x - hub_dx
487
+ pos[hr] = axis_x + hub_dx
488
+ fan = None
489
+ for _ in range(16):
490
+ fan = _side_fan_lines(pos, y, widths, card_h, plan, cfg)
491
+ if fan is not None:
492
+ break
493
+ hub_dx += 24
494
+ pos[hl] = axis_x - hub_dx
495
+ pos[hr] = axis_x + hub_dx
496
+ if fan is None:
497
+ raise InfeasibleComb(
498
+ "hub half-span cannot realize an all-straight fan "
499
+ f"within +{16 * 24}px widening (tier-0 pool anchors and "
500
+ "tier-1 facing-border anchors both exhausted)")
501
+ constructed = fan
502
+ if plan["deg0"]:
503
+ total = sum(widths[v] for v in plan["deg0"]) + 18 * (len(plan["deg0"]) + 1)
504
+ cur = max(cfg.margin, axis_x - total / 2)
505
+ for v in plan["deg0"]:
506
+ pos[v] = int(round((cur + widths[v] / 2) / 2) * 2)
507
+ y[v] = cfg.park_y
508
+ cur += widths[v] + 18
509
+ meta = {
510
+ "placement": "comb-bilateral",
511
+ "comb": {
512
+ "column": list(col),
513
+ "hubs": [hl, hr],
514
+ "sources": list(plan["sources"]),
515
+ "axis_x": axis_x,
516
+ "hub_dx": hub_dx,
517
+ "hub_y": hub_y,
518
+ "m": plan["m"],
519
+ },
520
+ }
521
+ # v0.7.11 constraint provenance — only stamped when a constraint was
522
+ # actually applied, so unconstrained layouts stay byte-identical to the
523
+ # pre-0.7.11 deterministic contract (baselines/digests untouched).
524
+ if col_order:
525
+ meta["comb"]["column_order"] = list(col_order)
526
+ if _want:
527
+ meta["comb"]["hub_side"] = {hl: "left", hr: "right"}
528
+ if constructed:
529
+ # tier-1 (wide-card) fan: certificate-caliber note only — the gate
530
+ # set and semantics are unchanged (hub_edges_straight still reads
531
+ # the delivered line kinds)
532
+ meta["comb"]["fan"] = "facing-border"
533
+ return pos, y, meta, constructed