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/ringlayout.py
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Loop-dominated ring placement (2026-11-05 product decision, t_af83815d).
|
|
3
|
+
|
|
4
|
+
> "When a balancing loop forms, a ring helps people understand the cycle
|
|
5
|
+
> better, and the figure is more beautiful too."
|
|
6
|
+
|
|
7
|
+
Trigger detection (graph-theoretic metrics, not feelings; fully
|
|
8
|
+
deterministic):
|
|
9
|
+
1. Small graph: 3 <= n <= 12 (n includes isolated nodes);
|
|
10
|
+
2. Main ring: a simple loop covering >= n-2 nodes exists; or ring
|
|
11
|
+
edges (non-bridge edges) make up >= 0.9 of undirected edges;
|
|
12
|
+
3. Pure ring (single connected component, all degree 2): take that
|
|
13
|
+
ring directly (reusing the WS2 cycle regular-polygon slot
|
|
14
|
+
construction; the construction itself unchanged).
|
|
15
|
+
|
|
16
|
+
Main-ring selection: candidates = declared loops (DSL loops that form
|
|
17
|
+
a simple loop, keeping the declared order — R/B polarity badges stay
|
|
18
|
+
readable around the ring) ∪ graph-theoretically enumerated simple
|
|
19
|
+
loops (canonical-form dedup). Among candidates covering >= n-2,
|
|
20
|
+
selection is deterministic by "zero chord crossings first -> most
|
|
21
|
+
coverage -> canonical-form lexicographic order".
|
|
22
|
+
|
|
23
|
+
Placement (place_ring):
|
|
24
|
+
- Main-ring nodes go into regular-polygon slots in ring order
|
|
25
|
+
(same-source construction as placement._place_cycle: evenly
|
|
26
|
+
divided angles i·2π/k − π/2, quantized to the 2px grid; radius
|
|
27
|
+
adapts to adjacent card-width needs, upper bound constrained by
|
|
28
|
+
the check8 1280x800 rendering-ratio tier);
|
|
29
|
+
- Residual nodes (chord tails / external variables): toward the
|
|
30
|
+
centroid direction of ring neighbors, a fixed fractional in-ring
|
|
31
|
+
candidate order; on card-gap conflicts fall back to the near side
|
|
32
|
+
outside the ring; isolated nodes park in the top parking strip;
|
|
33
|
+
- Radius need above the upper bound -> InfeasibleRing (the caller
|
|
34
|
+
falls back to the existing layered path, byte-for-byte
|
|
35
|
+
unchanged).
|
|
36
|
+
|
|
37
|
+
Routing/certificate: delegated to the existing Router and
|
|
38
|
+
certificate_auto; the ring-mode metric criteria (equal-spacing CV on
|
|
39
|
+
the ring + regular-polygon template fitting error, replacing row
|
|
40
|
+
alignment / column snapping) switch inside metrics/check on
|
|
41
|
+
meta.placement == "ring-cycle".
|
|
42
|
+
"""
|
|
43
|
+
import math
|
|
44
|
+
from collections import defaultdict
|
|
45
|
+
|
|
46
|
+
MAX_RING_N = 12 # trigger upper bound: small graphs
|
|
47
|
+
COVER_SLACK = 2 # main ring covers >= n - 2
|
|
48
|
+
CYCLE_EDGE_RATIO = 0.90 # ring-edge (non-bridge) ratio threshold (OR branch)
|
|
49
|
+
DFS_STEP_CAP = 2_000_000 # simple-loop enumeration step cap (deterministic: operation-count criterion)
|
|
50
|
+
RADIUS_MIN = 150
|
|
51
|
+
RADIUS_MAX = 320 # radius upper bound for check8 rendering ratio >= 0.85 (2r+card+edge geometry <= ~780px)
|
|
52
|
+
ADJ_GAP = 60 # center-distance margin between adjacent ring cards (>= 16 gate + routing channel)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class InfeasibleRing(Exception):
|
|
56
|
+
"""Ring slot infeasible (radius need above the upper bound) — the caller falls back to the existing layered path."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _adjacency(edges):
|
|
60
|
+
adj = defaultdict(set)
|
|
61
|
+
pairs = set()
|
|
62
|
+
for a, b in edges:
|
|
63
|
+
adj[a].add(b)
|
|
64
|
+
adj[b].add(a)
|
|
65
|
+
pairs.add((a, b) if a < b else (b, a))
|
|
66
|
+
return adj, pairs
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _canonical(seq):
|
|
70
|
+
"""Canonical form of a ring sequence: lexicographically smallest over all rotations x both directions (for dedup/ordering)."""
|
|
71
|
+
best = None
|
|
72
|
+
for s in (list(seq), list(seq)[::-1]):
|
|
73
|
+
for i in range(len(s)):
|
|
74
|
+
rot = s[i:] + s[:i]
|
|
75
|
+
if best is None or rot < best:
|
|
76
|
+
best = rot
|
|
77
|
+
return tuple(best)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _seg_cross(p1, p2, p3, p4):
|
|
81
|
+
"""Whether two segments properly cross (endpoint touching / collinear overlap excluded; straight-line proxy convention)."""
|
|
82
|
+
|
|
83
|
+
def orient(a, b, c):
|
|
84
|
+
v = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
|
|
85
|
+
if abs(v) < 1e-9:
|
|
86
|
+
return 0
|
|
87
|
+
return 1 if v > 0 else -1
|
|
88
|
+
|
|
89
|
+
o1, o2 = orient(p1, p2, p3), orient(p1, p2, p4)
|
|
90
|
+
o3, o4 = orient(p3, p4, p1), orient(p3, p4, p2)
|
|
91
|
+
return o1 * o2 < 0 and o3 * o4 < 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _crossing_count(segments):
|
|
95
|
+
"""Count of proper pairwise crossings (segments: (p1, p2, endpoint_ids); shared endpoints skipped)."""
|
|
96
|
+
n = len(segments)
|
|
97
|
+
c = 0
|
|
98
|
+
for i in range(n):
|
|
99
|
+
for j in range(i + 1, n):
|
|
100
|
+
a1, b1, e1 = segments[i]
|
|
101
|
+
a2, b2, e2 = segments[j]
|
|
102
|
+
if e1[0] in e2 or e1[1] in e2:
|
|
103
|
+
continue
|
|
104
|
+
if _seg_cross(a1, b1, a2, b2):
|
|
105
|
+
c += 1
|
|
106
|
+
return c
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class _CapExceeded(Exception):
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _enumerate_cycles(nodes, adj):
|
|
114
|
+
"""Enumerate all simple loops (canonical-form dedup; step-cap protection — stop at the cap and use what was found)."""
|
|
115
|
+
steps = [0]
|
|
116
|
+
found = {}
|
|
117
|
+
|
|
118
|
+
def dfs(start, v, path, seen):
|
|
119
|
+
steps[0] += 1
|
|
120
|
+
if steps[0] > DFS_STEP_CAP:
|
|
121
|
+
raise _CapExceeded
|
|
122
|
+
for w in sorted(adj[v]):
|
|
123
|
+
if w == start and len(path) >= 3:
|
|
124
|
+
can = _canonical(path)
|
|
125
|
+
if can not in found:
|
|
126
|
+
found[can] = can
|
|
127
|
+
elif w not in seen:
|
|
128
|
+
seen.add(w)
|
|
129
|
+
dfs(start, w, path + [w], seen)
|
|
130
|
+
seen.remove(w)
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
for s in sorted(nodes):
|
|
134
|
+
dfs(s, s, [s], {s})
|
|
135
|
+
except _CapExceeded:
|
|
136
|
+
pass
|
|
137
|
+
return [list(c) for c in found.values()]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _declared_loop_cycles(graph, adj):
|
|
141
|
+
"""Declared loops that form simple loops. Returns [(loop_id, kind, declared loop order)]."""
|
|
142
|
+
out = []
|
|
143
|
+
for lp in graph.loops:
|
|
144
|
+
seq = list(lp.nodes)
|
|
145
|
+
if len(seq) < 3 or len(set(seq)) != len(seq):
|
|
146
|
+
continue
|
|
147
|
+
if not all(seq[(i + 1) % len(seq)] in adj[seq[i]] for i in range(len(seq))):
|
|
148
|
+
continue
|
|
149
|
+
out.append((lp.id, getattr(lp, "kind", None), seq))
|
|
150
|
+
return out
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _is_pure_cycle(nodes, adj):
|
|
154
|
+
if len(nodes) < 3 or any(len(adj[v]) != 2 for v in nodes):
|
|
155
|
+
return False
|
|
156
|
+
seen, stack = {min(nodes)}, [min(nodes)]
|
|
157
|
+
while stack:
|
|
158
|
+
x = stack.pop()
|
|
159
|
+
for w in adj[x]:
|
|
160
|
+
if w not in seen:
|
|
161
|
+
seen.add(w)
|
|
162
|
+
stack.append(w)
|
|
163
|
+
return len(seen) == len(nodes)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _pure_cycle_order(nodes, adj):
|
|
167
|
+
"""Pure-ring order: smallest id as start, first step toward the smaller neighbor (deterministic)."""
|
|
168
|
+
start = min(nodes)
|
|
169
|
+
order = [start]
|
|
170
|
+
prev, cur = None, start
|
|
171
|
+
while True:
|
|
172
|
+
nxts = sorted(w for w in adj[cur] if w != prev)
|
|
173
|
+
if not nxts:
|
|
174
|
+
return None
|
|
175
|
+
step = nxts[0]
|
|
176
|
+
if step == start:
|
|
177
|
+
break
|
|
178
|
+
order.append(step)
|
|
179
|
+
prev, cur = cur, step
|
|
180
|
+
if len(order) > len(nodes):
|
|
181
|
+
return None
|
|
182
|
+
return order if len(order) == len(nodes) else None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _non_bridge_ratio(nodes, adj, pairs):
|
|
186
|
+
"""Ring-edge ratio = non-bridge edges / undirected edge count (a bridge = removing it increases the component count)."""
|
|
187
|
+
def ncomp(adj2):
|
|
188
|
+
seen, c = set(), 0
|
|
189
|
+
for s in nodes:
|
|
190
|
+
if s in seen:
|
|
191
|
+
continue
|
|
192
|
+
c += 1
|
|
193
|
+
st = [s]
|
|
194
|
+
seen.add(s)
|
|
195
|
+
while st:
|
|
196
|
+
x = st.pop()
|
|
197
|
+
for y in adj2[x]:
|
|
198
|
+
if y not in seen:
|
|
199
|
+
seen.add(y)
|
|
200
|
+
st.append(y)
|
|
201
|
+
return c
|
|
202
|
+
|
|
203
|
+
base = ncomp(adj)
|
|
204
|
+
nb = 0
|
|
205
|
+
for e in sorted(pairs):
|
|
206
|
+
adj2 = defaultdict(set)
|
|
207
|
+
for v in nodes:
|
|
208
|
+
for w in adj[v]:
|
|
209
|
+
if ((v, w) if v < w else (w, v)) != e:
|
|
210
|
+
adj2[v].add(w)
|
|
211
|
+
adj2[w].add(v)
|
|
212
|
+
if ncomp(adj2) == base:
|
|
213
|
+
nb += 1
|
|
214
|
+
return nb / len(pairs) if pairs else 0.0
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _chord_segments(ring, edges):
|
|
218
|
+
"""Unit-circle straight segments of in-ring chord edges (ring-ring non-ring edges) (crossing proxy)."""
|
|
219
|
+
rset = set(ring)
|
|
220
|
+
rpos = {v: (math.cos(2 * math.pi * i / len(ring) - math.pi / 2),
|
|
221
|
+
math.sin(2 * math.pi * i / len(ring) - math.pi / 2))
|
|
222
|
+
for i, v in enumerate(ring)}
|
|
223
|
+
cyc_pairs = {(ring[i], ring[(i + 1) % len(ring)]) for i in range(len(ring))}
|
|
224
|
+
cyc_pairs |= {(b, a) for a, b in cyc_pairs}
|
|
225
|
+
segs, seen = [], set()
|
|
226
|
+
for a, b in edges:
|
|
227
|
+
if a not in rset or b not in rset:
|
|
228
|
+
continue
|
|
229
|
+
p = (a, b) if a < b else (b, a)
|
|
230
|
+
if p in cyc_pairs or p in seen:
|
|
231
|
+
continue
|
|
232
|
+
seen.add(p)
|
|
233
|
+
segs.append((rpos[a], rpos[b], (a, b)))
|
|
234
|
+
return segs
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def detect_ring(graph, why=None):
|
|
238
|
+
"""Trigger detection + main-ring selection. Returns a ring plan dict or
|
|
239
|
+
None (no trigger).
|
|
240
|
+
|
|
241
|
+
plan = {"ring": [ring order], "k": int, "declared_loop": id|None,
|
|
242
|
+
"polarity": "R"|"B"|None, "trigger": "pure-cycle"|"cover"|"ratio",
|
|
243
|
+
"residuals": [non-ring non-isolated nodes], "deg0": [isolated nodes],
|
|
244
|
+
"chord_crossings": int}
|
|
245
|
+
|
|
246
|
+
why: optional dict — receives a deterministic one-line reason for the
|
|
247
|
+
outcome (mode decision trace, certificate mode_decision_trace field).
|
|
248
|
+
"""
|
|
249
|
+
nodes = list(graph.node_ids())
|
|
250
|
+
n = len(nodes)
|
|
251
|
+
if n < 3 or n > MAX_RING_N:
|
|
252
|
+
if why is not None:
|
|
253
|
+
why["reason"] = (f"n={n} (need 3..{MAX_RING_N})")
|
|
254
|
+
return None
|
|
255
|
+
edges = [(e.a, e.b) for e in graph.edges]
|
|
256
|
+
adj, pairs = _adjacency(edges)
|
|
257
|
+
core = [v for v in nodes if adj[v]]
|
|
258
|
+
deg0 = [v for v in nodes if not adj[v]]
|
|
259
|
+
|
|
260
|
+
if _is_pure_cycle(core, adj):
|
|
261
|
+
seq = _pure_cycle_order(core, adj)
|
|
262
|
+
if seq:
|
|
263
|
+
if why is not None:
|
|
264
|
+
why["reason"] = f"pure cycle, all degree-2, k=n={n}"
|
|
265
|
+
return {"ring": seq, "k": len(seq), "declared_loop": None,
|
|
266
|
+
"polarity": None, "trigger": "pure-cycle",
|
|
267
|
+
"residuals": [], "deg0": deg0,
|
|
268
|
+
"chord_crossings": 0}
|
|
269
|
+
|
|
270
|
+
cands = [] # [(ring order, loop_id, kind)]
|
|
271
|
+
for lid, kind, seq in _declared_loop_cycles(graph, adj):
|
|
272
|
+
cands.append((seq, lid, kind))
|
|
273
|
+
for seq in _enumerate_cycles(core, adj):
|
|
274
|
+
cands.append((seq, None, None))
|
|
275
|
+
if not cands:
|
|
276
|
+
if why is not None:
|
|
277
|
+
why["reason"] = ("no simple loop (declared or enumerated) — "
|
|
278
|
+
"not loop-dominated")
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
ratio = _non_bridge_ratio(nodes, adj, pairs)
|
|
282
|
+
cover_ok = any(len(s) >= n - COVER_SLACK for s, _, _ in cands)
|
|
283
|
+
if not (cover_ok or ratio >= CYCLE_EDGE_RATIO):
|
|
284
|
+
if why is not None:
|
|
285
|
+
why["reason"] = (f"not loop-dominated: main-ring coverage < "
|
|
286
|
+
f"n-{COVER_SLACK} and non-bridge edge ratio "
|
|
287
|
+
f"{ratio:.2f} < {CYCLE_EDGE_RATIO}")
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
eligible = [(s, lid, pol) for s, lid, pol in cands
|
|
291
|
+
if len(s) >= n - COVER_SLACK] or cands
|
|
292
|
+
if not cover_ok:
|
|
293
|
+
# v0.7.7 ratio-branch guard (K_{m,2} misfire): a high non-bridge
|
|
294
|
+
# ratio whose best ring is only k=4 is the bilateral-comb signature
|
|
295
|
+
# (two hubs + two sources forming a 4-cycle, everything else hanging
|
|
296
|
+
# off it) — that family belongs to comb-bilateral placement, not
|
|
297
|
+
# ring. Non-comb graphs keep the historical ratio behavior (e.g.
|
|
298
|
+
# classic-cld: ratio trigger with k=4 is legitimate). Probe with the
|
|
299
|
+
# comb detector to draw the line structurally, not by k alone.
|
|
300
|
+
kmax = max(len(s) for s, _, _ in eligible)
|
|
301
|
+
if kmax <= 4:
|
|
302
|
+
from .comblayout import detect_comb
|
|
303
|
+
if detect_comb(graph) is not None:
|
|
304
|
+
if why is not None:
|
|
305
|
+
why["reason"] = ("ratio branch with best ring k<=4 and "
|
|
306
|
+
"the bilateral-comb signature — that "
|
|
307
|
+
"family belongs to comb-bilateral")
|
|
308
|
+
return None
|
|
309
|
+
best = None
|
|
310
|
+
for seq, lid, pol in eligible:
|
|
311
|
+
cross = _crossing_count(_chord_segments(seq, edges))
|
|
312
|
+
# Main ring = max-coverage ring (the truly dominant loop); same coverage: zero chord crossings first -> lexicographic
|
|
313
|
+
key = (-len(seq), cross, _canonical(seq))
|
|
314
|
+
if best is None or key < best[0]:
|
|
315
|
+
best = (key, seq, lid, pol, cross)
|
|
316
|
+
_, ring, lid, pol, cross = best
|
|
317
|
+
residuals = [v for v in core if v not in set(ring)]
|
|
318
|
+
if why is not None:
|
|
319
|
+
why["reason"] = (f"loop-dominated (trigger="
|
|
320
|
+
f"{'cover' if cover_ok else 'ratio'}), main ring "
|
|
321
|
+
f"k={len(ring)}, chord crossings={cross}")
|
|
322
|
+
return {"ring": list(ring), "k": len(ring), "declared_loop": lid,
|
|
323
|
+
"polarity": pol, "trigger": "cover" if cover_ok else "ratio",
|
|
324
|
+
"residuals": residuals, "deg0": deg0, "chord_crossings": cross}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _ring_radius(k, ring_widths, n_residuals=0):
|
|
328
|
+
"""Regular k-gon radius: adjacent card center distance >= (w_u+w_v)/2 +
|
|
329
|
+
ADJ_GAP; above the upper bound is infeasible.
|
|
330
|
+
|
|
331
|
+
With n_residuals > 0 the radius is raised to make room inside the
|
|
332
|
+
ring (+40px per residual node, within the upper bound).
|
|
333
|
+
"""
|
|
334
|
+
need = 0.0
|
|
335
|
+
for i in range(k):
|
|
336
|
+
wu = ring_widths.get(i, 96)
|
|
337
|
+
wv = ring_widths.get((i + 1) % k, 96)
|
|
338
|
+
need = max(need, (wu + wv) / 2 + ADJ_GAP)
|
|
339
|
+
r = need / (2 * math.sin(math.pi / k))
|
|
340
|
+
r = max(r, RADIUS_MIN + 40 * n_residuals)
|
|
341
|
+
if r > RADIUS_MAX:
|
|
342
|
+
raise InfeasibleRing(f"radius requirement {r:.0f}px exceeds the upper bound {RADIUS_MAX}")
|
|
343
|
+
return max(RADIUS_MIN, int(math.ceil(r / 2) * 2))
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# Deterministic residual candidate family: in-ring fractions x angle offsets (fixed order: near-center first, zero offset first)
|
|
347
|
+
_INNER_FS = (0.42, 0.34, 0.50, 0.26, 0.58, 0.18, 0.66)
|
|
348
|
+
_ANGLE_DS = (0.0, 20.0, -20.0, 40.0, -40.0, 60.0, -60.0, 90.0, -90.0, 120.0, -120.0)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def place_ring(plan, widths, card_h, cfg, adj, edges):
|
|
352
|
+
"""Ring placement -> ({v: x}, {v: y}, meta). All-integer 2px grid,
|
|
353
|
+
deterministic.
|
|
354
|
+
|
|
355
|
+
Residual assignment: candidates = in-ring fraction x angle offset
|
|
356
|
+
(ring-neighbor centroid at 0°); each residual (id order) takes the
|
|
357
|
+
collision-free candidate with "fewest new straight-proxy crossings
|
|
358
|
+
-> smallest angle offset -> fraction closest to 0.42"; if all
|
|
359
|
+
in-ring candidates collide, try the near side outside the ring;
|
|
360
|
+
still none -> InfeasibleRing. edges: used for straight-proxy
|
|
361
|
+
crossing scoring (ring edges + chords + spokes of already-placed
|
|
362
|
+
residuals).
|
|
363
|
+
"""
|
|
364
|
+
ring, k = plan["ring"], plan["k"]
|
|
365
|
+
ring_widths = {i: widths.get(v, 96) for i, v in enumerate(ring)}
|
|
366
|
+
r = _ring_radius(k, ring_widths, len(plan["residuals"]))
|
|
367
|
+
cx = int(r // 2) * 2
|
|
368
|
+
cy = int((r + card_h // 2 + 40) // 2) * 2
|
|
369
|
+
pos, y, angles = {}, {}, {}
|
|
370
|
+
for i, v in enumerate(ring):
|
|
371
|
+
ang = 2 * math.pi * i / k - math.pi / 2
|
|
372
|
+
angles[v] = ang
|
|
373
|
+
pos[v] = int(round((cx + r * math.cos(ang)) / 2) * 2)
|
|
374
|
+
y[v] = int(round((cy + r * math.sin(ang)) / 2) * 2)
|
|
375
|
+
|
|
376
|
+
def _rect(v, px, py):
|
|
377
|
+
w = widths.get(v, 96)
|
|
378
|
+
return (px - w / 2, py - card_h / 2, px + w / 2, py + card_h / 2)
|
|
379
|
+
|
|
380
|
+
placed = {v: _rect(v, pos[v], y[v]) for v in ring}
|
|
381
|
+
|
|
382
|
+
def _free(v, px, py, gap=18):
|
|
383
|
+
rx0, ry0, rx1, ry1 = _rect(v, px, py)
|
|
384
|
+
for ux0, uy0, ux1, uy1 in placed.values():
|
|
385
|
+
if rx0 < ux1 + gap and ux0 < rx1 + gap and ry0 < uy1 + gap and uy0 < ry1 + gap:
|
|
386
|
+
return False
|
|
387
|
+
return True
|
|
388
|
+
|
|
389
|
+
# Straight-proxy segments (for crossing scoring): ring edges + in-ring chords
|
|
390
|
+
segs = []
|
|
391
|
+
rpos = {v: (pos[v], y[v]) for v in ring}
|
|
392
|
+
for i in range(k):
|
|
393
|
+
a, b = ring[i], ring[(i + 1) % k]
|
|
394
|
+
segs.append((rpos[a], rpos[b], frozenset((a, b))))
|
|
395
|
+
cyc_pairs = {(ring[i], ring[(i + 1) % k]) for i in range(k)}
|
|
396
|
+
cyc_pairs |= {(b, a) for a, b in cyc_pairs}
|
|
397
|
+
rset = set(ring)
|
|
398
|
+
seen_e = set()
|
|
399
|
+
for a, b in edges:
|
|
400
|
+
if a in rset and b in rset:
|
|
401
|
+
p = (a, b) if a < b else (b, a)
|
|
402
|
+
if p not in cyc_pairs and p not in seen_e:
|
|
403
|
+
seen_e.add(p)
|
|
404
|
+
segs.append((rpos[a], rpos[b], frozenset((a, b))))
|
|
405
|
+
|
|
406
|
+
def _add_cross(px, py, v):
|
|
407
|
+
"""New straight-proxy crossings added by placing v at candidate (px,py) (against placed neighbors)."""
|
|
408
|
+
c = 0
|
|
409
|
+
for u in sorted(adj[v]):
|
|
410
|
+
if u not in pos:
|
|
411
|
+
continue
|
|
412
|
+
seg = ((px, py), (pos[u], y[u]), frozenset((v, u)))
|
|
413
|
+
for q1, q2, eids in segs:
|
|
414
|
+
if v in eids or u in eids:
|
|
415
|
+
continue
|
|
416
|
+
if _seg_cross(seg[0], seg[1], q1, q2):
|
|
417
|
+
c += 1
|
|
418
|
+
return c
|
|
419
|
+
|
|
420
|
+
residual_pos = {}
|
|
421
|
+
for v in sorted(plan["residuals"]):
|
|
422
|
+
nbrs = [angles[u] for u in sorted(adj[v]) if u in angles]
|
|
423
|
+
ang0 = (math.atan2(sum(math.sin(a) for a in nbrs),
|
|
424
|
+
sum(math.cos(a) for a in nbrs))
|
|
425
|
+
if nbrs else 0.0)
|
|
426
|
+
best = None
|
|
427
|
+
for zone in ("inner", "outer"):
|
|
428
|
+
for f in (_INNER_FS if zone == "inner"
|
|
429
|
+
else (1.0 + (card_h / 2 + 40 + 40 * i) / r for i in range(3))):
|
|
430
|
+
for d in _ANGLE_DS:
|
|
431
|
+
ang = ang0 + math.radians(d)
|
|
432
|
+
px = int(round((cx + f * r * math.cos(ang)) / 2) * 2)
|
|
433
|
+
py = int(round((cy + f * r * math.sin(ang)) / 2) * 2)
|
|
434
|
+
if not _free(v, px, py):
|
|
435
|
+
continue
|
|
436
|
+
cross = _add_cross(px, py, v)
|
|
437
|
+
key = (cross, abs(d), abs(f - 0.42) if zone == "inner" else 1.0 + abs(f - 1.3),
|
|
438
|
+
px, py)
|
|
439
|
+
if best is None or key < best[0]:
|
|
440
|
+
best = (key, px, py, zone, f)
|
|
441
|
+
if best is not None and best[0][0] == 0 and zone == "inner":
|
|
442
|
+
break # zero crossings inside the ring already; no need to scan further
|
|
443
|
+
if best is not None:
|
|
444
|
+
break
|
|
445
|
+
if best is None:
|
|
446
|
+
raise InfeasibleRing(f"residual node {v} has no collision-free candidate")
|
|
447
|
+
_, px, py, zone, f = best
|
|
448
|
+
pos[v], y[v] = px, py
|
|
449
|
+
placed[v] = _rect(v, px, py)
|
|
450
|
+
residual_pos[v] = [zone, round(f, 3)]
|
|
451
|
+
for u in sorted(adj[v]):
|
|
452
|
+
if u in pos:
|
|
453
|
+
segs.append(((px, py), (pos[u], y[u]), frozenset((v, u))))
|
|
454
|
+
if plan["deg0"]:
|
|
455
|
+
total = sum(widths[v] for v in plan["deg0"]) + 18 * (len(plan["deg0"]) + 1)
|
|
456
|
+
cur = max(cfg.margin, cx - total / 2)
|
|
457
|
+
for v in plan["deg0"]:
|
|
458
|
+
pos[v] = int(round((cur + widths[v] / 2) / 2) * 2)
|
|
459
|
+
y[v] = cfg.park_y
|
|
460
|
+
cur += widths[v] + 18
|
|
461
|
+
|
|
462
|
+
meta = {
|
|
463
|
+
"placement": "ring-cycle",
|
|
464
|
+
"ring": {
|
|
465
|
+
"nodes": list(ring),
|
|
466
|
+
"radius": r,
|
|
467
|
+
"center": [cx, cy],
|
|
468
|
+
"declared_loop": plan.get("declared_loop"),
|
|
469
|
+
"polarity": plan.get("polarity"),
|
|
470
|
+
"trigger": plan.get("trigger"),
|
|
471
|
+
"chord_crossings": plan.get("chord_crossings", 0),
|
|
472
|
+
"residuals": {v: info for v, info in residual_pos.items()},
|
|
473
|
+
},
|
|
474
|
+
}
|
|
475
|
+
return pos, y, meta
|