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/check.py ADDED
@@ -0,0 +1,651 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Geometry self-report (11 checks) — takes only a layout JSON, generic.
3
+
4
+ Checks run on the **delivered coordinates** (the rounded/exported JSON); this
5
+ is discipline, not an optimization option.
6
+ Writes a report JSON (usable as a CI gate); exit 1 when fail > 0.
7
+ """
8
+ import json
9
+ import math
10
+ import sys
11
+ from typing import List
12
+
13
+
14
+ # ── SVG path d (M/L/A) parsing and geometry verification helpers ──
15
+ def _arc_points(x0, y0, rx, sweep, x1, y1, n=48):
16
+ """SVG arc (rot=0, circle, rx=ry; this engine always uses laf=0=minor arc)
17
+ -> sampled point list."""
18
+ rx = abs(rx)
19
+ if rx < 1e-9 or (abs(x1 - x0) < 1e-12 and abs(y1 - y0) < 1e-12):
20
+ return [(x0, y0), (x1, y1)]
21
+ mx, my = (x0 + x1) / 2.0, (y0 + y1) / 2.0
22
+ dx, dy = x1 - x0, y1 - y0
23
+ chord = math.hypot(dx, dy)
24
+ if chord < 1e-12:
25
+ return [(x0, y0), (x1, y1)]
26
+ if chord / 2.0 > rx:
27
+ rx = chord / 2.0 # degenerate guard: radius clamped to semicircle
28
+ h = math.sqrt(max(0.0, rx * rx - (chord / 2.0) ** 2))
29
+ # Two candidate centers on either side of the chord; for the minor arc the
30
+ # sweep angle is in (-pi, pi), take the one whose direction matches sweep
31
+ for sgn in (1.0, -1.0):
32
+ cx = mx - dy / chord * h * sgn
33
+ cy = my + dx / chord * h * sgn
34
+ a0 = math.atan2(y0 - cy, x0 - cx)
35
+ a1 = math.atan2(y1 - cy, x1 - cx)
36
+ da = a1 - a0
37
+ while da > math.pi:
38
+ da -= 2 * math.pi
39
+ while da < -math.pi:
40
+ da += 2 * math.pi
41
+ if abs(da) > 1e-9 and (da > 0) == (sweep == 1):
42
+ return [(cx + rx * math.cos(a0 + da * k / n),
43
+ cy + rx * math.sin(a0 + da * k / n)) for k in range(n + 1)]
44
+ return [(x0, y0), (x1, y1)]
45
+
46
+
47
+ def _parse_path_d(d):
48
+ """path d (M/L/A only, this engine's output shape) -> element list.
49
+
50
+ ('L', (x0,y0), (x1,y1)) or ('A', [(sampled points)...]). Returns None for
51
+ unrecognized commands.
52
+ """
53
+ toks = d.replace(",", " ").split()
54
+ elems, cur, i = [], None, 0
55
+ try:
56
+ while i < len(toks):
57
+ t = toks[i]
58
+ if t == "M":
59
+ cur = (float(toks[i + 1]), float(toks[i + 2]))
60
+ i += 3
61
+ elif t == "L":
62
+ p = (float(toks[i + 1]), float(toks[i + 2]))
63
+ elems.append(("L", cur, p))
64
+ cur = p
65
+ i += 3
66
+ elif t == "A":
67
+ rx = float(toks[i + 1])
68
+ sweep = int(float(toks[i + 5]))
69
+ p = (float(toks[i + 6]), float(toks[i + 7]))
70
+ elems.append(("A", _arc_points(cur[0], cur[1], rx, sweep,
71
+ p[0], p[1])))
72
+ cur = p
73
+ i += 8
74
+ else:
75
+ return None
76
+ except (ValueError, IndexError, TypeError):
77
+ return None
78
+ return elems
79
+
80
+
81
+ def _path_d_all_points(elems):
82
+ out = []
83
+ for el in elems:
84
+ if el[0] == "L":
85
+ out.append(el[1])
86
+ out.append(el[2])
87
+ else:
88
+ out.extend(el[1])
89
+ return out
90
+
91
+
92
+ def _max_poly_dev(pa, pb):
93
+ """Two-directional maximum minimum distance between two polylines
94
+ (sampled Hausdorff caliber)."""
95
+ from .routing import _pt_seg_dist
96
+ def one_way(src, other):
97
+ worst = 0.0
98
+ for p in src:
99
+ dmin = min(_pt_seg_dist(p, other[k], other[k + 1])
100
+ for k in range(len(other) - 1))
101
+ worst = max(worst, dmin)
102
+ return worst
103
+ return max(one_way(pa, pb), one_way(pb, pa))
104
+
105
+
106
+ def _path_d_g1_max_deg(elems):
107
+ """Maximum tangent-discontinuity angle (degrees) of a path_d element
108
+ sequence. Analytic tangents at L<->A joins."""
109
+ def line_dir(a, b):
110
+ n = math.hypot(b[0] - a[0], b[1] - a[1])
111
+ return ((b[0] - a[0]) / n, (b[1] - a[1]) / n) if n > 1e-12 else None
112
+
113
+ def elem_dirs(el):
114
+ if el[0] == "L":
115
+ d = line_dir(el[1], el[2])
116
+ return (d, d)
117
+ pts = el[1]
118
+ # Arc end tangent = end chord direction (n=48 sampling; chord vs true
119
+ # tangent differs by <0.1 deg)
120
+ return (line_dir(pts[0], pts[1]), line_dir(pts[-2], pts[-1]))
121
+
122
+ worst = 0.0
123
+ for k in range(len(elems) - 1):
124
+ d_out = elem_dirs(elems[k])[1]
125
+ d_in = elem_dirs(elems[k + 1])[0]
126
+ if d_out is None or d_in is None:
127
+ continue
128
+ ang = math.degrees(math.acos(max(-1.0, min(1.0,
129
+ d_out[0] * d_in[0] + d_out[1] * d_in[1]))))
130
+ worst = max(worst, ang)
131
+ return worst
132
+
133
+
134
+ def geometry_report(layout: dict) -> dict:
135
+ meta = layout.get("meta", {})
136
+ if meta.get("recipe") == "auto-v2":
137
+ return auto_geometry_report(layout)
138
+ nodes, edges, bars = layout["nodes"], layout["edges"], layout.get("bars", {})
139
+ meta = layout["meta"]
140
+ W, H = layout["viewBox"][2], layout["viewBox"][3]
141
+ CARD_H = meta.get("cardH", 42)
142
+ CW = {v: p.get("w", 140) for v, p in nodes.items()}
143
+ report = {"checks": [], "fail": 0}
144
+
145
+ def chk(name, ok, detail=""):
146
+ report["checks"].append({"check": name, "pass": bool(ok), "detail": detail})
147
+ if not ok:
148
+ report["fail"] += 1
149
+ print(("PASS " if ok else "FAIL ") + name + (" " + detail if detail else ""))
150
+
151
+ # 1. Pairwise zero intersection (incl. collinear overlap / near parallel adhesion)
152
+ bad = []
153
+ for i in range(len(edges)):
154
+ for j in range(i + 1, len(edges)):
155
+ a, b = edges[i], edges[j]
156
+ lo1, hi1 = sorted((a["y1"], a["y2"]))
157
+ lo2, hi2 = sorted((b["y1"], b["y2"]))
158
+ if a["x"] == b["x"]:
159
+ if lo1 < hi2 and lo2 < hi1:
160
+ bad.append((i, j, "collinear overlap"))
161
+ elif abs(a["x"] - b["x"]) < 8:
162
+ if lo1 < hi2 and lo2 < hi1:
163
+ bad.append((i, j, "near-parallel adhesion"))
164
+ chk("1. pairwise zero intersection (incl. collinear overlap / near-parallel adhesion)", not bad,
165
+ f"{len(edges)} edges pairwise intersected, conflicts {len(bad)}" + (str(bad[:5]) if bad else ""))
166
+
167
+ # 2. Line endpoints docked + unrelated lines may only pierce cards occlusion-style
168
+ bad2 = []
169
+ for e in edges:
170
+ x = e["x"]
171
+ for endv in (e["a"], e["b"]):
172
+ cx = nodes[endv]["x"]
173
+ in_card = abs(x - cx) <= CW[endv] / 2
174
+ in_bar = endv in bars and bars[endv][0] - 1 <= x <= bars[endv][1] + 1
175
+ if not (in_card or in_bar):
176
+ bad2.append((e["a"] + "->" + e["b"], endv))
177
+ chk("2. line endpoints docked at endpoint nodes (card or leader bar)", not bad2, str(bad2[:5]))
178
+
179
+ # 3. Leader bars untouched by foreign lines
180
+ bad3 = []
181
+ for v, (b0, b1) in bars.items():
182
+ cy = nodes[v]["y"]
183
+ for e in edges:
184
+ if v in (e["a"], e["b"]):
185
+ continue
186
+ if b0 - 1 <= e["x"] <= b1 + 1:
187
+ lo, hi = sorted((e["y1"], e["y2"]))
188
+ if lo - 1 <= cy <= hi + 1:
189
+ bad3.append((v, e["a"] + "->" + e["b"]))
190
+ chk("3. leader bars free of foreign line contact/crossing", not bad3, str(bad3[:5]))
191
+
192
+ # 4. Cards pairwise non-overlapping
193
+ bad4 = []
194
+ items = list(nodes.items())
195
+ for i in range(len(items)):
196
+ for j in range(i + 1, len(items)):
197
+ (v1, p1), (v2, p2) = items[i], items[j]
198
+ if abs(p1["x"] - p2["x"]) < (CW[v1] + CW[v2]) / 2 + 2 and \
199
+ abs(p1["y"] - p2["y"]) < CARD_H + 2:
200
+ bad4.append((v1, v2))
201
+ chk("4. cards pairwise non-overlapping (per-node width)", not bad4, str(bad4[:5]))
202
+
203
+ # 5. Grid alignment
204
+ deg0 = set(meta.get("deg0", []))
205
+ rowy = {}
206
+ for v, p in nodes.items():
207
+ if v in deg0:
208
+ continue
209
+ rowy.setdefault(p["y"], []).append(v)
210
+ pitch_ok = all((b - a) == meta["pitch"] for a, b in zip(sorted(rowy), sorted(rowy)[1:]))
211
+ rowdesc = "; ".join(f"y={y}:{len(vs)}" for y, vs in sorted(rowy.items()))
212
+ chk("5a. same-row nodes share exactly one y (grouped by y)", len(rowy) == meta["rows"],
213
+ f"{len(rowy)} y values = meta.rows={meta['rows']}; {rowdesc}")
214
+ chk("5b. uniform row pitch", pitch_ok, f"pitch={meta['pitch']}")
215
+ odd = [v for v, p in nodes.items() if p["x"] % 2 or p["y"] % 2]
216
+ chk("5c. all coordinates on the 2px grid (even)", not odd, str(odd))
217
+ chk("5d. all lines strictly vertical (0 bends 0 diagonals)",
218
+ all(e["y1"] != e["y2"] for e in edges),
219
+ f"all {len(edges)} edges have x1==x2; bend count 0")
220
+
221
+ # 6. Arrow placement legality
222
+ bad6 = []
223
+ for e in edges:
224
+ ax, ay = e["arrow"][0], e["arrow"][1]
225
+ b, yb = nodes[e["b"]], e["y2"]
226
+ in_card = abs(ax - b["x"]) <= CW[e["b"]] / 2 + 4
227
+ in_bar = e["b"] in bars and bars[e["b"]][0] - 6 <= ax <= bars[e["b"]][1] + 6
228
+ if not (in_card or in_bar):
229
+ bad6.append((e["a"] + "->" + e["b"], "arrow beyond target card/bar horizontal range"))
230
+ if abs(ay - yb) > CARD_H / 2 + 12:
231
+ bad6.append((e["a"] + "->" + e["b"], "arrow too far from target row center"))
232
+ chk("6. arrows point at the target card edge / leader bar", not bad6, str(bad6[:5]))
233
+
234
+ # 7. viewBox fully contains everything
235
+ out = []
236
+ for v, p in nodes.items():
237
+ if not (0 < p["x"] - CW[v] / 2 and p["x"] + CW[v] / 2 < W and
238
+ 0 < p["y"] - CARD_H / 2 and p["y"] + CARD_H / 2 < H):
239
+ out.append(v)
240
+ for e in edges:
241
+ if not (0 < e["x"] < W):
242
+ out.append(e["a"] + "->" + e["b"])
243
+ chk("7. all content inside the viewBox", not out, str(out))
244
+
245
+ # 8. Rendering scale (1280x800 tier)
246
+ s = min(1280 / W, 680 / H) if W and H else 0
247
+ fs_title, fs_value = meta.get("fs", [12.5, 14.0])
248
+ chk("8. 1280x800 rendering scale >= 0.85", s >= 0.85,
249
+ f"viewBox {W}x{H} -> s={s:.3f}; title {fs_title}x{s:.2f}~={fs_title * s:.1f}px")
250
+
251
+ report["summary"] = {
252
+ "viewBox": [W, H], "aspect": round(W / H, 3) if H else 0,
253
+ "nodes": len(nodes), "edges": len(edges),
254
+ "bars": len(bars), "rows": meta["rows"], "pitch": meta["pitch"],
255
+ "scale_1280x800": round(s, 3),
256
+ "fail": report["fail"]}
257
+ report["rows"] = rowdesc
258
+ return report
259
+
260
+
261
+ def geometry_report_to_file(layout: dict, path: str) -> int:
262
+ rep = geometry_report(layout)
263
+ with open(path, "w", encoding="utf-8") as f:
264
+ json.dump(rep, f, ensure_ascii=False, indent=1)
265
+ print(f"\n{len(rep['checks'])} checks, fail={rep['fail']} -> {path}")
266
+ return 1 if rep["fail"] else 0
267
+
268
+
269
+ def run_check_file(layout_path: str, report_path: str) -> int:
270
+ with open(layout_path, encoding="utf-8") as f:
271
+ layout = json.load(f)
272
+ rc = geometry_report_to_file(layout, report_path)
273
+ sys.exit(rc)
274
+
275
+
276
+ # ═══ auto-v2 geometry self-report (new hard gates) ═══
277
+ def auto_geometry_report(layout: dict) -> dict:
278
+ from .routing import (sample_polyline, dist_point_rect, seg_cross,
279
+ PRESS_MARGIN, ANCHOR_MIN_DEG, CLEARANCE)
280
+ nodes, edges = layout["nodes"], layout["edges"]
281
+ meta = layout["meta"]
282
+ # --from-layout manual layout: uniform card pitch / simple-graph
283
+ # regularity are "automatic-pipeline structural checks", inapplicable to
284
+ # manual coordinates (the certificate's regularity gate still grades
285
+ # honestly, no leniency because of this).
286
+ manual_mode = bool(meta.get("from_layout"))
287
+ # Ring placement: uniform pitch / column snap are likewise row-structure
288
+ # checks, inapplicable to ring placement (the certificate switches to
289
+ # on-ring equal spacing CV + regular-polygon template fit error; see
290
+ # metrics).
291
+ ring_mode = meta.get("placement") == "ring-cycle"
292
+ # comb-bilateral placement (v0.7.7): row caliber = column pitch + column
293
+ # axis + hub-pair mirror symmetry (same caliber as the certificate)
294
+ comb_mode = meta.get("placement") == "comb-bilateral"
295
+ # column-chain / symmetric placement: row caliber switches per mode
296
+ # (column = uniform main-chain pitch + column-axis alignment;
297
+ # symmetric = mirror-axis error); 24px column snap inapplicable to both.
298
+ column_mode = meta.get("placement") == "column-chain"
299
+ sym_mode = meta.get("placement") == "symmetric"
300
+ # bowtie-vertex: mirror axes = vertical axis x + horizontal axis y
301
+ # (upper/lower rings mirrored)
302
+ bowtie_mode = meta.get("placement") == "bowtie-vertex"
303
+ # spine-wing: spine x on-axis + wings hugging the midline + mirror-pair error
304
+ spine_wing_mode = meta.get("placement") == "spine-wing"
305
+ W, H = layout["viewBox"][2], layout["viewBox"][3]
306
+ CARD_H = meta.get("cardH", 44)
307
+ CW = {v: p.get("w", 140) for v, p in nodes.items()}
308
+ report = {"checks": [], "fail": 0}
309
+
310
+ def chk(name, ok, detail=""):
311
+ report["checks"].append({"check": name, "pass": bool(ok), "detail": detail})
312
+ if not ok:
313
+ report["fail"] += 1
314
+ print(("PASS " if ok else "FAIL ") + name + (" " + detail if detail else ""))
315
+
316
+ def polys():
317
+ return [([tuple(p) for p in e["route"]["points"]], e)
318
+ for e in edges]
319
+
320
+ # 1. Crossings (pairwise polyline intersection, edges sharing an endpoint excluded)
321
+ bad = []
322
+ P = polys()
323
+ for i in range(len(P)):
324
+ for j in range(i + 1, len(P)):
325
+ pi, ei = P[i]
326
+ pj, ej = P[j]
327
+ if ei["a"] in (ej["a"], ej["b"]) or ei["b"] in (ej["a"], ej["b"]):
328
+ continue
329
+ hit = False
330
+ for s1 in zip(pi[:-1], pi[1:]):
331
+ for s2 in zip(pj[:-1], pj[1:]):
332
+ if seg_cross(s1[0], s1[1], s2[0], s2[1]):
333
+ hit = True
334
+ break
335
+ if hit:
336
+ break
337
+ if hit:
338
+ bad.append((f"{ei['a']}->{ei['b']}", f"{ej['a']}->{ej['b']}"))
339
+ chk("1. crossings <= 2 (polyline/arc sampled intersection; on hard-gate conflict keep gates, release crossings)", len(bad) <= 2,
340
+ f"{len(edges)} edges, conflicts {len(bad)}" + (str(bad[:5]) if bad else ""))
341
+
342
+ # 2. Box press = 0 (1px margin; endpoints only touch their own box
343
+ # border, no positive-length traversal inside their own box)
344
+ bad2 = []
345
+ for pts, e in P:
346
+ dense = sample_polyline(pts)
347
+ for v, p in nodes.items():
348
+ hw, hh = CW[v] / 2, CARD_H / 2
349
+ if v in (e["a"], e["b"]):
350
+ continue
351
+ if any(dist_point_rect(px, py, p["x"], p["y"], hw + PRESS_MARGIN,
352
+ hh + PRESS_MARGIN) <= 0 for (px, py) in dense):
353
+ bad2.append((f"{e['a']}->{e['b']}", v))
354
+ for v in (e["a"], e["b"]):
355
+ p = nodes[v]
356
+ hw, hh = CW[v] / 2, CARD_H / 2
357
+ # t_51392037: standard anchors sit INSET 3px inside the border, so
358
+ # samples within 6px of an endpoint (the attachment zone, before
359
+ # the departure clears the 1px-inflated margin) are exempt -- same
360
+ # accounting as Router._own_press / metrics own-box gate; the
361
+ # check still catches positive-length traversal inside the box.
362
+ q0, q1 = pts[0], pts[-1]
363
+ if any(dist_point_rect(px, py, p["x"], p["y"], hw + PRESS_MARGIN,
364
+ hh + PRESS_MARGIN) <= 0 for (px, py) in dense[1:-1]
365
+ if ((px - q0[0]) ** 2 + (py - q0[1]) ** 2) > 36.0 and
366
+ ((px - q1[0]) ** 2 + (py - q1[1]) ** 2) > 36.0):
367
+ bad2.append((f"{e['a']}->{e['b']}", v, "own"))
368
+ chk("2. box press = 0 (any angle, incl. 1px margin)", not bad2, str(bad2[:5]))
369
+
370
+ # 3. Edge-box clearance >= 16px (non-endpoint boxes)
371
+ worst = None
372
+ for pts, e in P:
373
+ dense = sample_polyline(pts)
374
+ for v, p in nodes.items():
375
+ if v in (e["a"], e["b"]):
376
+ continue
377
+ hw, hh = CW[v] / 2, CARD_H / 2
378
+ d = min(dist_point_rect(px, py, p["x"], p["y"], hw, hh) for (px, py) in dense)
379
+ if worst is None or d < worst[0]:
380
+ worst = (d, e["a"], e["b"], v)
381
+ chk("3. edge-box clearance >= 16px", worst is None or worst[0] >= CLEARANCE - 1e-6,
382
+ f"minimum {worst[0]:.1f}px ({worst[1]}->{worst[2]} vs {worst[3]})" if worst else "")
383
+
384
+ # 4. Anchor smoothness: anchor on box border + departure direction vs
385
+ # border tangent angle >= 20 deg
386
+ bad4 = []
387
+ for pts, e in P:
388
+ for v, idx, nidx in ((e["a"], 0, 1), (e["b"], len(pts) - 1, len(pts) - 2)):
389
+ p = nodes[v]
390
+ hw, hh = CW[v] / 2, CARD_H / 2
391
+ a0 = pts[idx]
392
+ off_border = dist_point_rect(a0[0], a0[1], p["x"], p["y"], hw, hh) > 0.75
393
+ dx, dy = pts[nidx][0] - a0[0], pts[nidx][1] - a0[1]
394
+ n = (dx * dx + dy * dy) ** 0.5
395
+ on_x = abs(abs(a0[0] - p["x"]) - hw) <= abs(abs(a0[1] - p["y"]) - hh)
396
+ tx, ty = (0.0, 1.0) if on_x else (1.0, 0.0)
397
+ c = abs(dx * tx + dy * ty) / n if n > 1e-9 else 1.0
398
+ import math as _m
399
+ ang = _m.degrees(_m.acos(max(0.0, min(1.0, c))))
400
+ if off_border or ang < ANCHOR_MIN_DEG:
401
+ bad4.append((f"{e['a']}->{e['b']}", v, round(ang, 1),
402
+ "off-border" if off_border else "tangential"))
403
+ chk("4. anchor smoothness (border anchors, tangent angle >= 20 deg)", not bad4, str(bad4[:5]))
404
+
405
+ # 5. Cards pairwise non-overlapping
406
+ bad5 = []
407
+ items = list(nodes.items())
408
+ for i in range(len(items)):
409
+ for j in range(i + 1, len(items)):
410
+ (v1, p1), (v2, p2) = items[i], items[j]
411
+ if abs(p1["x"] - p2["x"]) < (CW[v1] + CW[v2]) / 2 + 2 and \
412
+ abs(p1["y"] - p2["y"]) < CARD_H + 2:
413
+ bad5.append((v1, v2))
414
+ chk("5. cards pairwise non-overlapping (per-node width)", not bad5, str(bad5[:5]))
415
+
416
+ # 6. Grid / row pitch
417
+ deg0 = set(meta.get("deg0", []))
418
+ rowy = {}
419
+ for v, p in nodes.items():
420
+ if v in deg0:
421
+ continue
422
+ rowy.setdefault(p["y"], []).append(v)
423
+ pitch_ok = len({b - a for a, b in zip(sorted(rowy), sorted(rowy)[1:])}) <= 1
424
+ odd = [v for v, p in nodes.items() if p["x"] % 2 or p["y"] % 2]
425
+ _6a_na = (" (manual: N/A)" if manual_mode else
426
+ " (ring: N/A)" if ring_mode else
427
+ " (comb: column rows)" if comb_mode else
428
+ " (column: main-chain rows)" if column_mode else
429
+ " (sym: all rows)" if sym_mode else "")
430
+ if column_mode:
431
+ spine = (meta.get("column") or {}).get("spine") or []
432
+ sys_ = [nodes[v]["y"] for v in spine if v in nodes]
433
+ pitch_ok = len({b - a for a, b in zip(sys_, sys_[1:])}) <= 1 if len(sys_) > 1 else True
434
+ if comb_mode:
435
+ col = (meta.get("comb") or {}).get("column") or []
436
+ cys = [nodes[v]["y"] for v in col if v in nodes]
437
+ pitch_ok = len({b - a for a, b in zip(cys, cys[1:])}) <= 1 if len(cys) > 1 else True
438
+ chk("6a. uniform row pitch (parking-lane nodes excluded)" + _6a_na,
439
+ True if (manual_mode or ring_mode) else pitch_ok, f"pitch={meta['pitch']}")
440
+ chk("6b. all coordinates on the 2px grid (even)", not odd, str(odd))
441
+
442
+ # 7. viewBox fully contains everything
443
+ out = []
444
+ for v, p in nodes.items():
445
+ if not (0 < p["x"] - CW[v] / 2 and p["x"] + CW[v] / 2 < W and
446
+ 0 < p["y"] - CARD_H / 2 and p["y"] + CARD_H / 2 < H):
447
+ out.append(v)
448
+ for pts, e in P:
449
+ if any(not (0 < px < W and 0 < py < H) for (px, py) in pts):
450
+ out.append(e["a"] + "->" + e["b"])
451
+ chk("7. all content inside the viewBox", not out, str(out))
452
+
453
+ # 8. Rendering scale (1280x800 tier)
454
+ s = min(1280 / W, 680 / H) if W and H else 0
455
+ fs_title, fs_value = meta.get("fs", [12.5, 14.0])
456
+ chk("8. 1280x800 rendering scale >= 0.85", s >= 0.85,
457
+ f"viewBox {W}x{H} -> s={s:.3f}; title {fs_title}x{s:.2f}~={fs_title * s:.1f}px")
458
+
459
+ # 9. Simple-graph regularity (<=10 nodes: uniform pitch + 24px column snap >= 60%)
460
+ # manual layout: regularity is an automatic-pipeline structural check -> N/A
461
+ # (the certificate regularity gate still grades)
462
+ # ring placement: row caliber inapplicable -> on-ring equal spacing CV +
463
+ # regular-polygon template fit error (same caliber as the certificate)
464
+ snap = sum(1 for p in nodes.values() if p["x"] % 24 == 0) / len(nodes)
465
+ simple = len(nodes) <= 10
466
+ if ring_mode:
467
+ import math as _rm
468
+ rnodes = (meta.get("ring") or {}).get("nodes") or []
469
+ rpts = [(nodes[v]["x"], nodes[v]["y"]) for v in rnodes if v in nodes]
470
+ if len(rpts) >= 3:
471
+ ds = [_rm.dist(rpts[i], rpts[(i + 1) % len(rpts)]) for i in range(len(rpts))]
472
+ md = sum(ds) / len(ds)
473
+ rsv = (sum((d - md) ** 2 for d in ds) / len(ds)) ** 0.5 / md if md else 0.0
474
+ from . import regularity as _reg
475
+ rdev = _reg.cycle_polygon_deviation(
476
+ {v: (nodes[v]["x"], nodes[v]["y"]) for v in rnodes}, rnodes)
477
+ ring_ok = rsv <= 0.05 and rdev <= 0.05
478
+ ring_detail = f"on-ring equal spacing CV={rsv:.4f}, regular-polygon deviation D={rdev:.4f} (gate <=0.05)"
479
+ else:
480
+ ring_ok, ring_detail = False, "meta.ring.nodes missing / fewer than 3 nodes"
481
+ chk("9. ring placement regularity (on-ring equal spacing CV + template fit)", ring_ok, ring_detail)
482
+ elif column_mode:
483
+ import math as _cm
484
+ spine = (meta.get("column") or {}).get("spine") or []
485
+ spts = [(nodes[v]["x"], nodes[v]["y"]) for v in spine if v in nodes]
486
+ if len(spts) >= 2:
487
+ axis = sum(p[0] for p in spts) / len(spts)
488
+ dev = max(abs(p[0] - axis) for p in spts)
489
+ col_ok = dev <= 2.0 and pitch_ok
490
+ col_detail = f"main-chain column-axis deviation {dev:.2f}px (gate <=2px), main-chain pitch uniform {pitch_ok}"
491
+ else:
492
+ col_ok, col_detail = False, "meta.column.spine missing / fewer than 2 nodes"
493
+ chk("9. column placement regularity (main-chain axis alignment + uniform pitch)", col_ok, col_detail)
494
+ elif comb_mode:
495
+ # comb-bilateral: column x on-axis <=2px + constant column pitch +
496
+ # hub-pair mirror symmetry about the column axis (<=2px)
497
+ comb = meta.get("comb") or {}
498
+ axis_x = comb.get("axis_x") or 0.0
499
+ errs = []
500
+ for cv in comb.get("column") or []:
501
+ if cv in nodes:
502
+ errs.append(abs(nodes[cv]["x"] - axis_x))
503
+ hl, hr = comb.get("hubs") or (None, None)
504
+ if hl in nodes and hr in nodes:
505
+ errs.append(abs((nodes[hl]["x"] + nodes[hr]["x"]) / 2 - axis_x))
506
+ errs.append(abs(nodes[hl]["y"] - nodes[hr]["y"]))
507
+ comb_ok = bool(errs) and max(errs) <= 2.0 and pitch_ok
508
+ chk("9. comb-bilateral placement regularity (column axis + pitch + hub mirror <=2px)",
509
+ comb_ok,
510
+ f"maximum deviation {max(errs):.2f}px, column pitch uniform {pitch_ok}"
511
+ if errs else "meta.comb missing")
512
+ elif sym_mode:
513
+ sym = meta.get("symmetry") or {}
514
+ axis_x = sym.get("axis_x") or 0.0
515
+ pairs = sym.get("mirror_pairs") or []
516
+ errs = []
517
+ for l, r in pairs:
518
+ if l in nodes and r in nodes:
519
+ errs.append(abs((nodes[l]["x"] + nodes[r]["x"]) / 2 - axis_x))
520
+ errs.append(abs(nodes[l]["y"] - nodes[r]["y"]))
521
+ sym_ok = bool(errs) and max(errs) <= 2.0
522
+ chk("9. symmetric placement regularity (mirror-axis error <=2px)", sym_ok,
523
+ f"mirror-axis error {max(errs):.2f}px" if errs else "meta.symmetry.mirror_pairs missing")
524
+ elif bowtie_mode:
525
+ sym = meta.get("symmetry") or {}
526
+ axis_x = sym.get("axis_x") or 0.0
527
+ axis_y = sym.get("axis_y") or 0.0
528
+ errs = []
529
+ for l, r in sym.get("mirror_pairs") or []:
530
+ if l in nodes and r in nodes:
531
+ errs.append(abs(nodes[l]["x"] - nodes[r]["x"]))
532
+ errs.append(abs((nodes[l]["y"] + nodes[r]["y"]) / 2 - axis_y))
533
+ sh = sym.get("shared_node")
534
+ if sh in nodes:
535
+ errs.append(abs(nodes[sh]["x"] - axis_x))
536
+ errs.append(abs(nodes[sh]["y"] - axis_y))
537
+ bowtie_ok = bool(errs) and max(errs) <= 2.0
538
+ chk("9. bowtie-vertex placement regularity (mirror-axis error <=2px)", bowtie_ok,
539
+ f"mirror-axis error {max(errs):.2f}px" if errs else "meta.symmetry missing")
540
+ elif spine_wing_mode:
541
+ # spine-wing: spine x on-axis <=2px + wings hugging the midline <=2px
542
+ # + mirror-pair error <=2px (consistent with certificate metrics in
543
+ # all three places)
544
+ sw = meta.get("spine_wing") or {}
545
+ axis_x = sw.get("axis_x") if sw.get("axis_x") is not None else 0.0
546
+ axis_y = sw.get("axis_y") if sw.get("axis_y") is not None else 0.0
547
+ errs = []
548
+ for sv in sw.get("spine") or []:
549
+ if sv in nodes:
550
+ errs.append(abs(nodes[sv]["x"] - axis_x))
551
+ for w in sw.get("wings") or []:
552
+ if w in nodes:
553
+ errs.append(abs(nodes[w]["y"] - axis_y))
554
+ for up, down in sw.get("mirror_pairs") or []:
555
+ if up in nodes and down in nodes:
556
+ errs.append(abs(nodes[up]["x"] - nodes[down]["x"]))
557
+ errs.append(abs((nodes[up]["y"] + nodes[down]["y"]) / 2 - axis_y))
558
+ sw_ok = bool(errs) and max(errs) <= 2.0
559
+ chk("9. spine-wing placement regularity (spine on-axis + midline adherence + mirror <=2px)",
560
+ sw_ok,
561
+ f"maximum deviation {max(errs):.2f}px" if errs else "meta.spine_wing missing")
562
+ else:
563
+ chk("9. simple-graph placement regularity" + (" (manual: N/A)" if manual_mode else ""),
564
+ True if manual_mode else ((not simple) or (pitch_ok and snap >= 0.6)),
565
+ f"n={len(nodes)}, 24px column snap {snap:.2f}, pitch uniform {pitch_ok}"
566
+ + ("; inapplicable to manual layouts" if manual_mode else ""))
567
+
568
+ # 10. Metadata completeness (recipe / line_style / engine)
569
+ ok10 = (meta.get("recipe") == "auto-v2" and
570
+ meta.get("line_style") in ("straight", "arc") and
571
+ (meta.get("position_engine") in ("cp-sat", "construct", "manual",
572
+ "ring-construct",
573
+ "column-construct", "sym-construct",
574
+ "bowtie-construct",
575
+ "comb-construct",
576
+ "spine-construct")))
577
+ chk("10. auto-v2 metadata complete", ok10,
578
+ f"recipe={meta.get('recipe')} line_style={meta.get('line_style')} "
579
+ f"engine={meta.get('position_engine')}")
580
+
581
+ # 11. WS3 dual crossing accounting consistency: routing theory (crossing
582
+ # pairs of the final route) vs delivered-geometry recheck
583
+ router_pairs = meta.get("router_cross_pairs")
584
+ if router_pairs is not None:
585
+ geom_pairs = len(bad) # geometric recheck result of item 1 (edge pairs)
586
+ chk("11. dual crossing accounting consistent (routing theory vs delivered-geometry recheck)",
587
+ abs(router_pairs - geom_pairs) < 0.5,
588
+ f"router={router_pairs} geometric={geom_pairs}")
589
+
590
+ # 12. F1: mutual pairs (bidirectional edge pairs) two-line minimum distance >= 4px
591
+ # (lens double arcs; merging into a double-arrowhead line is forbidden)
592
+ from .routing import min_pair_dist
593
+ pair_bad = []
594
+ pair_note = []
595
+ for i in range(len(P)):
596
+ for j in range(i + 1, len(P)):
597
+ (pi, ei), (pj, ej) = P[i], P[j]
598
+ if (ei["a"], ei["b"]) != (ej["b"], ej["a"]):
599
+ continue
600
+ md = min_pair_dist(sample_polyline(pi), sample_polyline(pj))
601
+ pair_note.append(f"{ei['a']}<->{ei['b']}:{md if md is None else round(md, 1)}px")
602
+ if md is None or md < 4.0:
603
+ pair_bad.append((f"{ei['a']}<->{ei['b']}",
604
+ md if md is None else round(md, 1)))
605
+ chk("12. mutual-pair two-line minimum distance >= 4px (lens double arcs, merging forbidden)",
606
+ not pair_bad, (str(pair_bad[:5]) if pair_bad else "; ".join(pair_note[:6])))
607
+
608
+ # 13. path_d rendering geometry = verification geometry (dense points) +
609
+ # arc G1 tangent continuity.
610
+ # Historical bug: route_svg_d unconditionally re-drew the corner point
611
+ # itself after the fillet arc, producing a tb->q->ta_next zigzag artifact
612
+ # in the rendering layer while the certificate only checked the dense
613
+ # sampled points (smooth) — delivery and verification were out of sync.
614
+ # This check parses the rendered path_d (M/L/A) directly, verifies both
615
+ # geometries bidirectionally within <=1.5px, and requires each A segment's
616
+ # tangent angle to adjacent L segments <=4 deg (rounding tolerance).
617
+ pathd_bad = []
618
+ pathd_n = 0
619
+ for pts, e in P:
620
+ d = e.get("route", {}).get("path_d")
621
+ if not d:
622
+ continue
623
+ pathd_n += 1
624
+ elems = _parse_path_d(d)
625
+ if elems is None:
626
+ pathd_bad.append((f"{e['a']}->{e['b']}", "path_d parse failed"))
627
+ continue
628
+ dp = _path_d_all_points(elems)
629
+ dense = [tuple(p) for p in pts]
630
+ dev = _max_poly_dev(dp, dense)
631
+ if dev > 1.5:
632
+ pathd_bad.append((f"{e['a']}->{e['b']}", f"render/verify geometry deviation {dev:.1f}px"))
633
+ continue
634
+ g1 = _path_d_g1_max_deg(elems)
635
+ if g1 > 4.0:
636
+ pathd_bad.append((f"{e['a']}->{e['b']}", f"arc tangent discontinuity {g1:.1f} deg"))
637
+ chk("13. path_d rendering geometry consistent with verification geometry + arc G1 tangent continuity",
638
+ not pathd_bad,
639
+ (str(pathd_bad[:5]) if pathd_bad
640
+ else f"{pathd_n} vg paths render/verify consistent (G1 <=4 deg, geometry deviation <=1.5px)"
641
+ if pathd_n else "no vg path_d routes in this graph (trivially passes)"))
642
+
643
+ report["summary"] = {
644
+ "viewBox": [W, H], "aspect": round(W / H, 3) if H else 0,
645
+ "nodes": len(nodes), "edges": len(edges),
646
+ "rows": meta["rows"], "pitch": meta["pitch"],
647
+ "scale_1280x800": round(s, 3),
648
+ "recipe": meta.get("recipe"), "line_style": meta.get("line_style"),
649
+ "position_engine": meta.get("position_engine"),
650
+ "fail": report["fail"]}
651
+ return report