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/planar.py
ADDED
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Planarity-aware 2D layout layer: planar embedding + st-numbering + dual longest paths + lane packing.
|
|
3
|
+
|
|
4
|
+
INTERNAL INTERFACE -- coordinates are not the stdraw deliverable. The
|
|
5
|
+
product interface is the CLI (`stdraw layout ...` -> demo-2d.html, the
|
|
6
|
+
interactive deliverable with drag touch-up + embedded certificate) or
|
|
7
|
+
stdraw.recipes.run for programmatic use. Coordinate dicts produced by
|
|
8
|
+
this module carry a delivery contract saying exactly that
|
|
9
|
+
("delivery": "coordinates-only", "interactive": false, "engine_version",
|
|
10
|
+
one-line "delivery_warning" -- see stdraw/delivery.py); treat them as
|
|
11
|
+
an internal layout-layer interface, not a rendering deliverable.
|
|
12
|
+
|
|
13
|
+
Layout math (Rosenstiehl-Tarjan visibility representation -> all wires strictly vertical, 0 crossings guaranteed by theorem),
|
|
14
|
+
graph data is passed in explicitly through the Graph model, decoupled from any concrete business template.
|
|
15
|
+
|
|
16
|
+
Invariants (verified item by item by check.geometry_report):
|
|
17
|
+
- One strictly vertical line per edge (0 bends, 0 diagonals); parallel/collinear vertical lines never intersect;
|
|
18
|
+
- Two columns closer than GAP_NEAR have non-overlapping y coverage bands (with 1 row of margin);
|
|
19
|
+
- A leader bar spans only the wire columns it relates to; unrelated wires may pass underneath cards (cards are opaque and occlude them);
|
|
20
|
+
- Cards in the same row never overlap and keep a CARD_GAP clearance; coordinates on a 2px grid.
|
|
21
|
+
|
|
22
|
+
Precondition: the core subgraph (after removing isolated nodes) must be planar -- this layer does not degrade via planarization;
|
|
23
|
+
non-planar input raises NotPlanarError (honest boundary: degradation policy belongs to replaceable layout-layer implementations, M1+).
|
|
24
|
+
"""
|
|
25
|
+
import json
|
|
26
|
+
from collections import defaultdict, deque
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
|
|
29
|
+
import networkx as nx
|
|
30
|
+
|
|
31
|
+
from .model import Graph
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class NotPlanarError(Exception):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class LayoutConfig:
|
|
40
|
+
fs_title: float = 12.5 # title font size
|
|
41
|
+
fs_value: float = 14.0 # value font size (bold)
|
|
42
|
+
card_h: int = 42
|
|
43
|
+
card_min: int = 96 # minimum card width
|
|
44
|
+
card_pad: int = 22 # left/right padding for the title
|
|
45
|
+
card_gap: int = 8 # minimum gap between cards in a row
|
|
46
|
+
pitch: int = 56 # row pitch (card 42 + wire gap 14)
|
|
47
|
+
top: int = 76 # center y of the first row
|
|
48
|
+
park_y: int = 26 # center y of the parking strip for isolated nodes
|
|
49
|
+
left: int = 40 # minimum x of the first column
|
|
50
|
+
gap_col: int = 8 # minimum spacing between adjacent wire columns (unconditional)
|
|
51
|
+
gap_near: int = 16 # below this spacing, y coverage must not overlap
|
|
52
|
+
target_w: int = 1280
|
|
53
|
+
target_h: int = 680 # net svg viewport for the 1280x800 tier (scoring baseline)
|
|
54
|
+
aspect_min: float = 1.72 # when content is narrow and tall, stretch column spacing to this ratio
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def text_px(s, fs, bold=False):
|
|
58
|
+
w = 0.0
|
|
59
|
+
for ch in s:
|
|
60
|
+
w += fs if ord(ch) > 0x2E7F else fs * 0.58
|
|
61
|
+
return w * (1.05 if bold else 1.0)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class PlanarLayout:
|
|
65
|
+
def __init__(self, graph: Graph, config: LayoutConfig = None):
|
|
66
|
+
self.cfg = config or LayoutConfig()
|
|
67
|
+
self.graph = graph
|
|
68
|
+
self.id2n = graph.id2node()
|
|
69
|
+
self.nodes = graph.node_ids()
|
|
70
|
+
self.edges = [(e.a, e.b) for e in graph.edges]
|
|
71
|
+
adj = graph.adj_undirected()
|
|
72
|
+
self.deg0 = [n for n in graph.node_ids() if not adj[n]]
|
|
73
|
+
self.core = [n for n in graph.node_ids() if adj[n]]
|
|
74
|
+
self.core_set = set(self.core)
|
|
75
|
+
self.real_adj = defaultdict(set)
|
|
76
|
+
for a, b in self.edges:
|
|
77
|
+
self.real_adj[a].add(b)
|
|
78
|
+
self.real_adj[b].add(a)
|
|
79
|
+
# per-node card width: max(title, value) + padding, on a 2px grid
|
|
80
|
+
c = self.cfg
|
|
81
|
+
self.card_w = {}
|
|
82
|
+
for nid in self.nodes:
|
|
83
|
+
nd = self.id2n[nid]
|
|
84
|
+
w = max(text_px(nd.label, c.fs_title) + c.card_pad,
|
|
85
|
+
text_px(nd.value, c.fs_value, bold=True) + 22,
|
|
86
|
+
c.card_min)
|
|
87
|
+
self.card_w[nid] = int((w + nd.extra_w + 1) // 2 * 2)
|
|
88
|
+
self._g = None # st-numbering of the current candidate
|
|
89
|
+
|
|
90
|
+
# ── 1. Planar embedding -> rotation system ─────────────────────────────
|
|
91
|
+
def build_rotation(self):
|
|
92
|
+
G = nx.Graph()
|
|
93
|
+
G.add_nodes_from(self.core)
|
|
94
|
+
G.add_edges_from(self.edges)
|
|
95
|
+
planar, emb = nx.check_planarity(G)
|
|
96
|
+
if not planar:
|
|
97
|
+
raise NotPlanarError(
|
|
98
|
+
f"core subgraph non-planar ({len(self.core)} nodes / {len(self.edges)} edges) — "
|
|
99
|
+
"2D planar layer inapplicable; degrade to the 3D layer or use the uniform placement forms")
|
|
100
|
+
return {v: list(emb.neighbors_cw_order(v)) for v in self.core}
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def faces_of(rot):
|
|
104
|
+
seen = set()
|
|
105
|
+
faces = []
|
|
106
|
+
half2face = {}
|
|
107
|
+
for u in rot:
|
|
108
|
+
for v in rot[u]:
|
|
109
|
+
if (u, v) in seen:
|
|
110
|
+
continue
|
|
111
|
+
f = []
|
|
112
|
+
cu, cv = u, v
|
|
113
|
+
while (cu, cv) not in seen:
|
|
114
|
+
seen.add((cu, cv))
|
|
115
|
+
f.append(cu)
|
|
116
|
+
half2face[(cu, cv)] = len(faces)
|
|
117
|
+
nxt = rot[cv][(rot[cv].index(cu) + 1) % len(rot[cv])]
|
|
118
|
+
cu, cv = cv, nxt
|
|
119
|
+
faces.append(f)
|
|
120
|
+
return faces, half2face
|
|
121
|
+
|
|
122
|
+
def euler_check(self, rot):
|
|
123
|
+
faces, h2f = self.faces_of(rot)
|
|
124
|
+
V = len(rot)
|
|
125
|
+
E = sum(len(l) for l in rot.values()) // 2
|
|
126
|
+
F = len(faces)
|
|
127
|
+
edge_faces = defaultdict(set)
|
|
128
|
+
for (u, v), fi in h2f.items():
|
|
129
|
+
edge_faces[frozenset((u, v))].add(fi)
|
|
130
|
+
assert all(len(s) == 2 for s in edge_faces.values()), "edge not in exactly 2 faces"
|
|
131
|
+
assert V - E + F == 2, f"V-E+F={V}-{E}+{F}!=2 (rotation system invalid)"
|
|
132
|
+
assert E == len(edge_faces), "multi-edge?"
|
|
133
|
+
return faces, h2f
|
|
134
|
+
|
|
135
|
+
# ── 2. Connectivity + triangulation (edge augmentation) ─────────────────────────
|
|
136
|
+
def augment(self, rot):
|
|
137
|
+
rot = {v: list(l) for v, l in rot.items()}
|
|
138
|
+
dummy = set()
|
|
139
|
+
G = nx.Graph({v: list(l) for v, l in rot.items()})
|
|
140
|
+
comps = list(nx.connected_components(G))
|
|
141
|
+
while len(comps) > 1:
|
|
142
|
+
a = next(iter(comps[0]))
|
|
143
|
+
b = next(iter(comps[1]))
|
|
144
|
+
rot[a].append(b)
|
|
145
|
+
rot[b].append(a)
|
|
146
|
+
dummy.add(frozenset((a, b)))
|
|
147
|
+
G = nx.Graph({v: list(l) for v, l in rot.items()})
|
|
148
|
+
comps = list(nx.connected_components(G))
|
|
149
|
+
while True:
|
|
150
|
+
faces, _ = self.faces_of(rot)
|
|
151
|
+
target = None
|
|
152
|
+
for f in faces:
|
|
153
|
+
if len(f) > 3:
|
|
154
|
+
target = f
|
|
155
|
+
break
|
|
156
|
+
if target is None:
|
|
157
|
+
break
|
|
158
|
+
n = len(target)
|
|
159
|
+
placed = False
|
|
160
|
+
for i in range(n):
|
|
161
|
+
a, succ, b2, nxt2 = target[i], target[(i + 1) % n], target[(i + 2) % n], target[(i + 3) % n]
|
|
162
|
+
if frozenset((a, b2)) not in [frozenset(p) for p in dummy] and b2 not in rot[a]:
|
|
163
|
+
rot[a].insert(rot[a].index(succ), b2)
|
|
164
|
+
rot[b2].insert(rot[b2].index(nxt2), a)
|
|
165
|
+
dummy.add(frozenset((a, b2)))
|
|
166
|
+
placed = True
|
|
167
|
+
break
|
|
168
|
+
assert placed, "triangulation stuck"
|
|
169
|
+
faces, _ = self.faces_of(rot)
|
|
170
|
+
assert all(len(f) == 3 for f in faces), "not fully triangulated"
|
|
171
|
+
self.euler_check(rot)
|
|
172
|
+
return rot, dummy
|
|
173
|
+
|
|
174
|
+
# ── 3. Even-Tarjan st-numbering ────────────────────────────
|
|
175
|
+
@staticmethod
|
|
176
|
+
def st_numbering(adj, s, t):
|
|
177
|
+
n = len(adj)
|
|
178
|
+
dfi, low, parent, children = {}, {}, {}, defaultdict(list)
|
|
179
|
+
cyc_back = defaultdict(list)
|
|
180
|
+
cyc_fwd = defaultdict(list)
|
|
181
|
+
cnt = [0]
|
|
182
|
+
|
|
183
|
+
def dfs(v):
|
|
184
|
+
dfi[v] = cnt[0]; cnt[0] += 1
|
|
185
|
+
low[v] = dfi[v]
|
|
186
|
+
nbrs = list(adj[v])
|
|
187
|
+
if v == t and s in nbrs:
|
|
188
|
+
nbrs.remove(s); nbrs.insert(0, s)
|
|
189
|
+
for w in nbrs:
|
|
190
|
+
if w not in dfi:
|
|
191
|
+
parent[w] = v; children[v].append(w)
|
|
192
|
+
dfs(w)
|
|
193
|
+
low[v] = min(low[v], low[w])
|
|
194
|
+
elif w != parent.get(v) or parent.get(v) is None:
|
|
195
|
+
if dfi[w] > dfi[v]:
|
|
196
|
+
cyc_fwd[v].append(w)
|
|
197
|
+
cyc_back[w].append(v)
|
|
198
|
+
else:
|
|
199
|
+
cyc_back[v].append(w)
|
|
200
|
+
cyc_fwd[w].append(v)
|
|
201
|
+
low[v] = min(low[v], dfi[w])
|
|
202
|
+
|
|
203
|
+
dfs(t)
|
|
204
|
+
assert len(dfi) == n
|
|
205
|
+
old_edge = {frozenset((s, t))}
|
|
206
|
+
old_v = {s, t}
|
|
207
|
+
|
|
208
|
+
def find_path(v):
|
|
209
|
+
for w in cyc_back[v]:
|
|
210
|
+
if frozenset((v, w)) not in old_edge:
|
|
211
|
+
old_edge.add(frozenset((v, w)))
|
|
212
|
+
return [v, w]
|
|
213
|
+
for w in children[v]:
|
|
214
|
+
if frozenset((v, w)) not in old_edge:
|
|
215
|
+
old_edge.add(frozenset((v, w)))
|
|
216
|
+
path = [v, w]
|
|
217
|
+
while w not in old_v:
|
|
218
|
+
nxt = None
|
|
219
|
+
for x in adj[w]:
|
|
220
|
+
if frozenset((w, x)) in old_edge:
|
|
221
|
+
continue
|
|
222
|
+
if (dfi.get(x) == low[w]) or (low.get(x) == low[w] and x in children[w]):
|
|
223
|
+
nxt = x
|
|
224
|
+
break
|
|
225
|
+
assert nxt is not None, "pathfinder case2 stuck"
|
|
226
|
+
old_v.add(w)
|
|
227
|
+
old_edge.add(frozenset((w, nxt)))
|
|
228
|
+
path.append(nxt)
|
|
229
|
+
w = nxt
|
|
230
|
+
return path
|
|
231
|
+
for w in cyc_fwd[v]:
|
|
232
|
+
if frozenset((v, w)) not in old_edge:
|
|
233
|
+
old_edge.add(frozenset((v, w)))
|
|
234
|
+
path = [v, w]
|
|
235
|
+
while w not in old_v:
|
|
236
|
+
x = parent[w]
|
|
237
|
+
old_v.add(w)
|
|
238
|
+
old_edge.add(frozenset((w, x)))
|
|
239
|
+
path.append(x)
|
|
240
|
+
w = x
|
|
241
|
+
return path
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
stack = [t, s]
|
|
245
|
+
g = {}
|
|
246
|
+
i = 0
|
|
247
|
+
guard = 0
|
|
248
|
+
while stack:
|
|
249
|
+
guard += 1
|
|
250
|
+
assert guard < 10000, "st-numbering loop guard"
|
|
251
|
+
v = stack.pop()
|
|
252
|
+
p = find_path(v)
|
|
253
|
+
if p:
|
|
254
|
+
for x in reversed(p[:-1]):
|
|
255
|
+
stack.append(x)
|
|
256
|
+
else:
|
|
257
|
+
i += 1
|
|
258
|
+
g[v] = i
|
|
259
|
+
assert len(g) == n and g[s] == 1 and g[t] == n, f"st fail {len(g)} {g[s]} {g[t]}"
|
|
260
|
+
for v in adj:
|
|
261
|
+
if v in (s, t):
|
|
262
|
+
continue
|
|
263
|
+
assert any(g[w] < g[v] for w in adj[v]) and any(g[w] > g[v] for w in adj[v]), \
|
|
264
|
+
f"st property violated at {v}"
|
|
265
|
+
return g
|
|
266
|
+
|
|
267
|
+
# ── 4. Visibility representation ─────────────────────────────
|
|
268
|
+
def visibility(self, rot, g, st_edge):
|
|
269
|
+
u0, v0 = st_edge
|
|
270
|
+
assert g[u0] < g[v0]
|
|
271
|
+
num2v = sorted(rot, key=lambda v: g[v])
|
|
272
|
+
Y = {}
|
|
273
|
+
for v in num2v:
|
|
274
|
+
preds = [u for u in rot[v] if g[u] < g[v]]
|
|
275
|
+
Y[v] = 0 if not preds else 1 + max(Y[u] for u in preds)
|
|
276
|
+
faces, h2f = self.faces_of(rot)
|
|
277
|
+
outer = h2f[(u0, v0)]
|
|
278
|
+
for polarity in (0, 1):
|
|
279
|
+
arcs = defaultdict(list)
|
|
280
|
+
nodes = [f"f{i}" for i in range(len(faces)) if i != outer] + \
|
|
281
|
+
[frozenset((u, v)) for (u, v) in h2f if g[u] < g[v]]
|
|
282
|
+
indeg = {n: 0 for n in nodes}
|
|
283
|
+
|
|
284
|
+
def add_arc(a, b):
|
|
285
|
+
arcs[a].append(b)
|
|
286
|
+
indeg[b] += 1
|
|
287
|
+
for (u, v), fi in h2f.items():
|
|
288
|
+
if g[u] > g[v]:
|
|
289
|
+
continue
|
|
290
|
+
fj = h2f[(v, u)]
|
|
291
|
+
L, R = (f"f{fi}", f"f{fj}") if polarity == 0 else (f"f{fj}", f"f{fi}")
|
|
292
|
+
e = frozenset((u, v))
|
|
293
|
+
if L in indeg:
|
|
294
|
+
add_arc(L, e)
|
|
295
|
+
if R in indeg:
|
|
296
|
+
add_arc(e, R)
|
|
297
|
+
dq = deque([n for n in nodes if indeg[n] == 0])
|
|
298
|
+
tau = {n: 0 for n in nodes}
|
|
299
|
+
seen = 0
|
|
300
|
+
while dq:
|
|
301
|
+
a = dq.popleft()
|
|
302
|
+
seen += 1
|
|
303
|
+
for b in arcs[a]:
|
|
304
|
+
if tau[b] < tau[a] + 1:
|
|
305
|
+
tau[b] = tau[a] + 1
|
|
306
|
+
indeg[b] -= 1
|
|
307
|
+
if indeg[b] == 0:
|
|
308
|
+
dq.append(b)
|
|
309
|
+
if seen != len(nodes):
|
|
310
|
+
continue
|
|
311
|
+
Xe = {e: tau[e] for e in tau if isinstance(e, frozenset)}
|
|
312
|
+
if self.verify_unit(rot, g, Y, Xe):
|
|
313
|
+
return Y, Xe
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
@staticmethod
|
|
317
|
+
def bar_span(v, Xe_incident):
|
|
318
|
+
xs = [Xe_incident[e] for e in Xe_incident if v in e]
|
|
319
|
+
return min(xs) - 1, max(xs) + 1
|
|
320
|
+
|
|
321
|
+
def verify_unit(self, rot, g, Y, Xe):
|
|
322
|
+
inc = defaultdict(dict)
|
|
323
|
+
for e, x in Xe.items():
|
|
324
|
+
u, v = tuple(e)
|
|
325
|
+
inc[u][e] = x
|
|
326
|
+
inc[v][e] = x
|
|
327
|
+
spans = {v: self.bar_span(v, inc[v]) for v in rot}
|
|
328
|
+
byrow = defaultdict(list)
|
|
329
|
+
for v in rot:
|
|
330
|
+
byrow[Y[v]].append(v)
|
|
331
|
+
for row, vs in byrow.items():
|
|
332
|
+
iv = sorted((spans[v][0], spans[v][1], v) for v in vs)
|
|
333
|
+
for i in range(1, len(iv)):
|
|
334
|
+
if iv[i][0] <= iv[i - 1][1]:
|
|
335
|
+
return False
|
|
336
|
+
for e, x in Xe.items():
|
|
337
|
+
u, v = tuple(e)
|
|
338
|
+
lo, hi = min(Y[u], Y[v]), max(Y[u], Y[v])
|
|
339
|
+
for w in rot:
|
|
340
|
+
if w in (u, v):
|
|
341
|
+
continue
|
|
342
|
+
if lo < Y[w] < hi:
|
|
343
|
+
a, b = spans[w]
|
|
344
|
+
if a <= x <= b:
|
|
345
|
+
return False
|
|
346
|
+
bycol = defaultdict(list)
|
|
347
|
+
for e, x in Xe.items():
|
|
348
|
+
u, v = tuple(e)
|
|
349
|
+
bycol[x].append((min(Y[u], Y[v]), max(Y[u], Y[v])))
|
|
350
|
+
for x, rs in bycol.items():
|
|
351
|
+
rs.sort()
|
|
352
|
+
for i in range(1, len(rs)):
|
|
353
|
+
if rs[i][0] < rs[i - 1][1]:
|
|
354
|
+
return False
|
|
355
|
+
return True
|
|
356
|
+
|
|
357
|
+
# ── 5. Real-graph handling: row merging -> pixel coordinates ──────────────────
|
|
358
|
+
def real_dag_levels(self, g):
|
|
359
|
+
order = sorted(self.core, key=lambda v: g[v])
|
|
360
|
+
Y = {}
|
|
361
|
+
for v in order:
|
|
362
|
+
preds = [u for u in self.real_adj[v] if g[u] < g[v]]
|
|
363
|
+
Y[v] = 0 if not preds else 1 + max(Y[u] for u in preds)
|
|
364
|
+
return Y
|
|
365
|
+
|
|
366
|
+
def bar_unit(self, Y, RXe):
|
|
367
|
+
inc = defaultdict(list)
|
|
368
|
+
for e, x in RXe.items():
|
|
369
|
+
u, v = tuple(e)
|
|
370
|
+
inc[u].append(x)
|
|
371
|
+
inc[v].append(x)
|
|
372
|
+
return {v: (min(xs) - 1, max(xs) + 1) for v, xs in inc.items()}
|
|
373
|
+
|
|
374
|
+
def unit_ok(self, Y, RXe):
|
|
375
|
+
spans = self.bar_unit(Y, RXe)
|
|
376
|
+
byrow = defaultdict(list)
|
|
377
|
+
for v in self.core:
|
|
378
|
+
byrow[Y[v]].append(v)
|
|
379
|
+
for row, vs in byrow.items():
|
|
380
|
+
iv = sorted((spans[v][0], spans[v][1], v) for v in vs)
|
|
381
|
+
for i in range(1, len(iv)):
|
|
382
|
+
if iv[i][0] <= iv[i - 1][1]:
|
|
383
|
+
return False
|
|
384
|
+
for e, x in RXe.items():
|
|
385
|
+
u, v = tuple(e)
|
|
386
|
+
lo, hi = min(Y[u], Y[v]), max(Y[u], Y[v])
|
|
387
|
+
for w in self.core:
|
|
388
|
+
if w in (u, v):
|
|
389
|
+
continue
|
|
390
|
+
if lo < Y[w] < hi and spans[w][0] <= x <= spans[w][1]:
|
|
391
|
+
return False
|
|
392
|
+
bycol = defaultdict(list)
|
|
393
|
+
for e, x in RXe.items():
|
|
394
|
+
u, v = tuple(e)
|
|
395
|
+
bycol[x].append((min(Y[u], Y[v]), max(Y[u], Y[v])))
|
|
396
|
+
for x, rs in bycol.items():
|
|
397
|
+
rs.sort()
|
|
398
|
+
for i in range(1, len(rs)):
|
|
399
|
+
if rs[i][0] < rs[i - 1][1]:
|
|
400
|
+
return False
|
|
401
|
+
return True
|
|
402
|
+
|
|
403
|
+
def compact_rows(self, Y, RXe, g):
|
|
404
|
+
def gless(u, v):
|
|
405
|
+
return g[u] < g[v]
|
|
406
|
+
for _ in range(4):
|
|
407
|
+
moved = False
|
|
408
|
+
for v in sorted(self.core, key=lambda x: -Y[x]):
|
|
409
|
+
preds = [u for u in self.real_adj[v] if gless(u, v)]
|
|
410
|
+
lo_bound = max([Y[u] for u in preds], default=-1) + 1
|
|
411
|
+
succs = [w for w in self.real_adj[v] if gless(v, w)]
|
|
412
|
+
hi_bound = min([Y[w] for w in succs], default=Y[v]) - 1
|
|
413
|
+
old = Y[v]
|
|
414
|
+
for b in range(lo_bound, min(hi_bound, old - 1) + 1):
|
|
415
|
+
Y2 = dict(Y)
|
|
416
|
+
Y2[v] = b
|
|
417
|
+
if self.unit_ok(Y2, RXe):
|
|
418
|
+
Y.clear()
|
|
419
|
+
Y.update(Y2)
|
|
420
|
+
moved = True
|
|
421
|
+
break
|
|
422
|
+
if not moved:
|
|
423
|
+
break
|
|
424
|
+
return Y
|
|
425
|
+
|
|
426
|
+
# ── 6. Pixel packing ───────────────────────────────────────
|
|
427
|
+
def col_extent(self, RXe, Y, c):
|
|
428
|
+
rows = []
|
|
429
|
+
for e, x in RXe.items():
|
|
430
|
+
if x != c:
|
|
431
|
+
continue
|
|
432
|
+
u, v = tuple(e)
|
|
433
|
+
rows += [Y[u], Y[v]]
|
|
434
|
+
return (min(rows), max(rows)) if rows else (10 ** 9, -10 ** 9)
|
|
435
|
+
|
|
436
|
+
def pack_columns(self, Y, RXe):
|
|
437
|
+
c = self.cfg
|
|
438
|
+
cols = sorted(set(RXe.values()))
|
|
439
|
+
ext = {col: self.col_extent(RXe, Y, col) for col in cols}
|
|
440
|
+
rowcards = defaultdict(list)
|
|
441
|
+
for v in self.core:
|
|
442
|
+
xs = [RXe[e] for e in RXe if v in e]
|
|
443
|
+
rowcards[Y[v]].append((min(xs), max(xs), v))
|
|
444
|
+
for r in rowcards:
|
|
445
|
+
rowcards[r].sort()
|
|
446
|
+
x = {cols[0]: c.left}
|
|
447
|
+
for i in range(1, len(cols)):
|
|
448
|
+
col = cols[i]
|
|
449
|
+
pos = x[cols[i - 1]] + c.gap_col
|
|
450
|
+
while True:
|
|
451
|
+
x[col] = pos
|
|
452
|
+
if self._col_fits(x, cols[:i + 1], ext, rowcards, col):
|
|
453
|
+
break
|
|
454
|
+
pos += 2
|
|
455
|
+
assert pos < c.left + 30000, "packing stuck"
|
|
456
|
+
x[col] = pos
|
|
457
|
+
return x
|
|
458
|
+
|
|
459
|
+
def _col_fits(self, x, placed, ext, rowcards, col):
|
|
460
|
+
c = self.cfg
|
|
461
|
+
pset = set(placed)
|
|
462
|
+
e_lo, e_hi = ext[col]
|
|
463
|
+
for d in placed:
|
|
464
|
+
if d != col and abs(x[d] - x[col]) < c.gap_near:
|
|
465
|
+
d_lo, d_hi = ext[d]
|
|
466
|
+
if not (e_hi + 1 < d_lo or d_hi + 1 < e_lo):
|
|
467
|
+
return False
|
|
468
|
+
for r, lst in rowcards.items():
|
|
469
|
+
ok = [t for t in lst if t[0] in pset and t[1] in pset]
|
|
470
|
+
for i in range(1, len(ok)):
|
|
471
|
+
u, v = ok[i - 1][2], ok[i][2]
|
|
472
|
+
c0 = (x[ok[i - 1][0]] + x[ok[i - 1][1]]) / 2
|
|
473
|
+
c1 = (x[ok[i][0]] + x[ok[i][1]]) / 2
|
|
474
|
+
if c1 - c0 < (self.card_w[u] + self.card_w[v]) / 2 + c.card_gap:
|
|
475
|
+
return False
|
|
476
|
+
return True
|
|
477
|
+
|
|
478
|
+
def _build(self, Y, RXe, x):
|
|
479
|
+
c = self.cfg
|
|
480
|
+
inc = defaultdict(list)
|
|
481
|
+
for e, cx in RXe.items():
|
|
482
|
+
u, v = tuple(e)
|
|
483
|
+
inc[u].append(cx)
|
|
484
|
+
inc[v].append(cx)
|
|
485
|
+
nodes, bars = {}, {}
|
|
486
|
+
for v in self.core:
|
|
487
|
+
xs = inc[v]
|
|
488
|
+
b0, b1 = x[min(xs)], x[max(xs)]
|
|
489
|
+
nodes[v] = {"x": ((b0 + b1) // 2 // 2) * 2, "y": c.top + Y[v] * c.pitch,
|
|
490
|
+
"w": self.card_w[v]}
|
|
491
|
+
if b1 - b0 > 8:
|
|
492
|
+
bars[v] = [b0 - 4, b1 + 4]
|
|
493
|
+
edges = []
|
|
494
|
+
for (a, b) in self.edges:
|
|
495
|
+
col = RXe[(a, b)]
|
|
496
|
+
ya, yb = nodes[a]["y"], nodes[b]["y"]
|
|
497
|
+
down = yb > ya
|
|
498
|
+
wB = self.card_w[b]
|
|
499
|
+
in_card = abs(x[col] - nodes[b]["x"]) < wB / 2 - 8
|
|
500
|
+
if in_card:
|
|
501
|
+
ay = yb - (c.card_h // 2 + 5) if down else yb + (c.card_h // 2 + 5)
|
|
502
|
+
else:
|
|
503
|
+
ay = yb - 9 if down else yb + 9
|
|
504
|
+
edges.append({"a": a, "b": b, "x": x[col], "y1": ya, "y2": yb,
|
|
505
|
+
"arrow": [x[col], ay, 1 if down else -1]})
|
|
506
|
+
return nodes, bars, edges
|
|
507
|
+
|
|
508
|
+
def stage3(self, g, Y, RXe):
|
|
509
|
+
c = self.cfg
|
|
510
|
+
x = self.pack_columns(Y, RXe)
|
|
511
|
+
nodes, bars, edges = self._build(Y, RXe, x)
|
|
512
|
+
nrow = len({Y[v] for v in self.core})
|
|
513
|
+
H = c.top + (nrow - 1) * c.pitch + c.card_h // 2 + 18
|
|
514
|
+
xmax = max(n["x"] + n["w"] / 2 for n in nodes.values())
|
|
515
|
+
W = max(int(xmax + 20), int(H * c.aspect_min))
|
|
516
|
+
for i, v in enumerate(self.deg0):
|
|
517
|
+
w = self.card_w[v]
|
|
518
|
+
nodes[v] = {"x": int((W - 24 - w / 2 - i * (w + 14)) // 2 * 2),
|
|
519
|
+
"y": c.park_y, "w": w}
|
|
520
|
+
target = W - c.left - 24
|
|
521
|
+
span = max(x.values()) - c.left
|
|
522
|
+
if span > 0 and target > span:
|
|
523
|
+
ratio = target / span
|
|
524
|
+
x = {col: int((c.left + (px - c.left) * ratio) // 2 * 2) for col, px in x.items()}
|
|
525
|
+
nodes, bars, edges = self._build(Y, RXe, x)
|
|
526
|
+
for i, v in enumerate(self.deg0):
|
|
527
|
+
w = self.card_w[v]
|
|
528
|
+
nodes[v] = {"x": int((W - 24 - w / 2 - i * (w + 14)) // 2 * 2),
|
|
529
|
+
"y": c.park_y, "w": w}
|
|
530
|
+
W = ((max(int(max(n["x"] + n["w"] / 2 for n in nodes.values()) + 16), W) + 7) // 8) * 8
|
|
531
|
+
H = ((H + 7) // 8) * 8
|
|
532
|
+
minL = min(n["x"] - n["w"] / 2 for n in nodes.values())
|
|
533
|
+
if minL < 4:
|
|
534
|
+
dx = int((4 - minL + 1) // 2 * 2)
|
|
535
|
+
x = {col: px + dx for col, px in x.items()}
|
|
536
|
+
nodes, bars, edges = self._build(Y, RXe, x)
|
|
537
|
+
for i, v in enumerate(self.deg0):
|
|
538
|
+
w = self.card_w[v]
|
|
539
|
+
nodes[v] = {"x": int((W - 24 - w / 2 - i * (w + 14)) // 2 * 2),
|
|
540
|
+
"y": c.park_y, "w": w}
|
|
541
|
+
W = ((max(int(max(n["x"] + n["w"] / 2 for n in nodes.values()) + 16), W) + 7) // 8) * 8
|
|
542
|
+
return {"viewBox": [0, 0, W, H], "nodes": nodes, "edges": edges,
|
|
543
|
+
"bars": bars, "meta": {
|
|
544
|
+
"rows": nrow,
|
|
545
|
+
"rowY": [c.top + r * c.pitch for r in range(nrow)],
|
|
546
|
+
"pitch": c.pitch, "deg0": self.deg0,
|
|
547
|
+
"cols": sorted(set(x.values())),
|
|
548
|
+
"cardH": c.card_h, "fs": [c.fs_title, c.fs_value]}}
|
|
549
|
+
|
|
550
|
+
def finalize(self, rot, g, Y_aug, RXe):
|
|
551
|
+
Yr = self.real_dag_levels(g)
|
|
552
|
+
Y = Yr if self.unit_ok(Yr, RXe) else dict(Y_aug)
|
|
553
|
+
Y = self.compact_rows(Y, RXe, g)
|
|
554
|
+
rows = sorted({Y[v] for v in self.core})
|
|
555
|
+
remap = {r: i for i, r in enumerate(rows)}
|
|
556
|
+
Y = {v: remap[y] for v, y in Y.items()}
|
|
557
|
+
nrow = len(rows)
|
|
558
|
+
down = sum(1 for a, b in self.edges if Y[a] < Y[b])
|
|
559
|
+
v4 = self.stage3(g, Y, RXe)
|
|
560
|
+
W, H = v4["viewBox"][2], v4["viewBox"][3]
|
|
561
|
+
s = min(self.cfg.target_w / W, self.cfg.target_h / H)
|
|
562
|
+
return -s, Y, {"rows": nrow, "down": down, "W": W, "H": H,
|
|
563
|
+
"scale": round(s, 3)}
|
|
564
|
+
|
|
565
|
+
def try_candidate(self, s, t):
|
|
566
|
+
rot, dummy = self.augment(self.build_rotation())
|
|
567
|
+
g = self.st_numbering(rot, s, t)
|
|
568
|
+
res = self.visibility(rot, g, (s, t))
|
|
569
|
+
if not res:
|
|
570
|
+
return None
|
|
571
|
+
Y_aug, Xe = res
|
|
572
|
+
re_set = {frozenset(e) for e in self.edges}
|
|
573
|
+
RXe = {e: x for e, x in Xe.items() if e in re_set}
|
|
574
|
+
# split bidirectional edge pairs into independent columns: keys become directed; the reverse edge gets column value +0.5 to force a separate column
|
|
575
|
+
pair_n = defaultdict(int)
|
|
576
|
+
directed = {}
|
|
577
|
+
for (a, b) in self.edges:
|
|
578
|
+
fs = frozenset((a, b))
|
|
579
|
+
n = pair_n[fs]
|
|
580
|
+
pair_n[fs] += 1
|
|
581
|
+
directed[(a, b)] = RXe[fs] + (0.5 * n)
|
|
582
|
+
RXe = directed
|
|
583
|
+
sc, Y, metrics = self.finalize(rot, g, Y_aug, RXe)
|
|
584
|
+
return sc, s, t, rot, g, Y_aug, Xe, RXe, dummy, Y, metrics
|
|
585
|
+
|
|
586
|
+
def run(self):
|
|
587
|
+
"""Search all candidates and return the best layout dict (deterministic: same graph, same output)."""
|
|
588
|
+
cands = []
|
|
589
|
+
seen_st = set()
|
|
590
|
+
for a, b in self.edges:
|
|
591
|
+
for s, t in ((a, b), (b, a)):
|
|
592
|
+
try:
|
|
593
|
+
r = self.try_candidate(s, t)
|
|
594
|
+
except AssertionError:
|
|
595
|
+
continue
|
|
596
|
+
if not r:
|
|
597
|
+
continue
|
|
598
|
+
key = tuple(sorted(r[4].items()))
|
|
599
|
+
if key in seen_st:
|
|
600
|
+
continue
|
|
601
|
+
seen_st.add(key)
|
|
602
|
+
cands.append(r)
|
|
603
|
+
assert cands, "no viable candidate"
|
|
604
|
+
cands.sort(key=lambda x: x[0])
|
|
605
|
+
sc, s, t, rot, g, Y_aug, Xe, RXe, dummy, Y, data = cands[0]
|
|
606
|
+
v4 = self.stage3(g, Y, RXe)
|
|
607
|
+
v4["meta"]["st"] = [s, t]
|
|
608
|
+
v4["meta"]["down"] = data["down"]
|
|
609
|
+
v4["meta"]["n"] = len(self.nodes)
|
|
610
|
+
v4["meta"]["m"] = len(self.edges)
|
|
611
|
+
v4["meta"]["method"] = ("visibility-representation (Rosenstiehl-Tarjan) "
|
|
612
|
+
"0-crossing guaranteed; wires strictly vertical; "
|
|
613
|
+
"compact packing (occlusion allowed under cards)")
|
|
614
|
+
from .delivery import stamp_coordinates_only
|
|
615
|
+
v4 = stamp_coordinates_only(v4)
|
|
616
|
+
return v4
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def layout(graph: Graph, config: LayoutConfig = None) -> dict:
|
|
620
|
+
return PlanarLayout(graph, config).run()
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def layout_to_json(v4: dict, path: str):
|
|
624
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
625
|
+
json.dump(v4, f, ensure_ascii=False, indent=1)
|
stdraw/recipes.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Recipe registry — the versioned interface to the engine's outward behavior.
|
|
3
|
+
|
|
4
|
+
Callers **must specify the recipe explicitly** (the stdraw CLI defaults to auto-v2):
|
|
5
|
+
- ortho-v1: visibility-representation construction (planar.py, orthogonal
|
|
6
|
+
vertical lines). **Behavior frozen**;
|
|
7
|
+
production callers explicitly pin this recipe — stdraw CI protects it with
|
|
8
|
+
byte-for-byte regression against tests/baselines/
|
|
9
|
+
(any change to planar.py must pass this gate first).
|
|
10
|
+
Known limitations (legacy, declared honestly): unrelated lines may pass
|
|
11
|
+
through cards and occlude them; the 16px clearance gate is left for
|
|
12
|
+
callers to tune via --pitch. Future quality fixes evolve only in auto-v2,
|
|
13
|
+
never backported to the frozen layer.
|
|
14
|
+
- auto-v2: layered + crossing count in the objective + straight/arc
|
|
15
|
+
first-class routing + new hard gates
|
|
16
|
+
(box press=0, edge-box clearance>=16px, anchor smoothing, simple-graph
|
|
17
|
+
regularity). Default recipe.
|
|
18
|
+
|
|
19
|
+
Usage (import form):
|
|
20
|
+
import stdraw
|
|
21
|
+
v4 = stdraw.recipes.run("ortho-v1", graph, config) # production behavior pin
|
|
22
|
+
v4 = stdraw.recipes.run("auto-v2", graph, linestyle="arc")
|
|
23
|
+
|
|
24
|
+
Delivery contract (stdraw/delivery.py): coordinate dicts returned by
|
|
25
|
+
both recipes carry "delivery": "coordinates-only", "interactive":
|
|
26
|
+
false, "engine_version" and a one-line "delivery_warning" (coordinates
|
|
27
|
+
are not the stdraw deliverable; the deliverable is demo-2d.html). Pass
|
|
28
|
+
suppress_delivery_warning=True only when you truly consume raw
|
|
29
|
+
coordinates. The CLI re-stamps interactive=true (no warning) when the
|
|
30
|
+
full deliverable was produced; the ortho CLI layout.json stays
|
|
31
|
+
byte-frozen (production pin) and carries its contract on
|
|
32
|
+
certificate.json ("interactive" / "drag_layer") instead.
|
|
33
|
+
"""
|
|
34
|
+
from . import __version__
|
|
35
|
+
|
|
36
|
+
RECIPE_VERSIONS = {
|
|
37
|
+
"ortho-v1": "1 (frozen 2026-11-05, byte-stable, pin-safe)",
|
|
38
|
+
"auto-v2": "2 (2026-11-05: hard gates + straight/arc routing; "
|
|
39
|
+
"2026-11-05 WS3: visibility-graph Dijkstra detour + arc fillet "
|
|
40
|
+
"smoothing + crossing dual-caliber + R/B markers)",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def ortho_v1(graph, config=None, suppress_delivery_warning: bool = False) -> dict:
|
|
45
|
+
from .planar import PlanarLayout
|
|
46
|
+
v4 = PlanarLayout(graph, config).run()
|
|
47
|
+
if suppress_delivery_warning:
|
|
48
|
+
v4.pop("delivery_warning", None)
|
|
49
|
+
return v4
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def auto_v2(graph, config=None, linestyle: str = "straight",
|
|
53
|
+
suppress_delivery_warning: bool = False) -> dict:
|
|
54
|
+
from .layout_auto import layout as _layout, AutoConfig
|
|
55
|
+
cfg = config or AutoConfig()
|
|
56
|
+
v4 = _layout(graph, cfg, linestyle)
|
|
57
|
+
if suppress_delivery_warning:
|
|
58
|
+
v4.pop("delivery_warning", None)
|
|
59
|
+
return v4
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def run(recipe: str, graph, config=None, linestyle: str = "straight",
|
|
63
|
+
suppress_delivery_warning: bool = False) -> dict:
|
|
64
|
+
if recipe == "ortho-v1":
|
|
65
|
+
return ortho_v1(graph, config, suppress_delivery_warning)
|
|
66
|
+
if recipe == "auto-v2":
|
|
67
|
+
return auto_v2(graph, config, linestyle, suppress_delivery_warning)
|
|
68
|
+
raise ValueError(f"unknown recipe {recipe!r}; available {sorted(RECIPE_VERSIONS)}")
|