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