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/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)}")