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