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/layoutdiff.py
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Layout diff (aesthetics-collection loop, t_229b61ef): three-layer comparison of the auto layout vs the manual layout.
|
|
3
|
+
|
|
4
|
+
Human drags -> machine diff -> AI distills aesthetic rules -> feeds back into the objective function; this module is the "machine diff" stage,
|
|
5
|
+
outputting both machine-readable (JSON) and human-readable (stdout table) formats:
|
|
6
|
+
|
|
7
|
+
1) Node level: per-node displacement vector (dx,dy,|d|) + direction classification (toward loop center / toward column alignment /
|
|
8
|
+
toward symmetry; |d| < 4px counts as "not moved");
|
|
9
|
+
2) Structural level: before/after table of key metrics -- crossings / card presses (1px accounting) / row alignment rate /
|
|
10
|
+
minimum card gap / spacing CV / average edge length / symmetry-axis error / loop cohesion
|
|
11
|
+
(auto vs manual columns + delta);
|
|
12
|
+
3) Semantic level (when --cld/.json carries a graph): loops and roles (on-ring / branch) of moved nodes,
|
|
13
|
+
whether they end up closer to their main ring after the move; per-loop cohesion changes.
|
|
14
|
+
|
|
15
|
+
Accounting (all reproducible; definitions are also written into the output definitions block):
|
|
16
|
+
- Edge geometry: polylines take route.points; arcs (arc+ctrl) are discretized into 24 quadratic Bezier segments;
|
|
17
|
+
- Crossings: for edge pairs sharing no endpoint, any segment intersection counts as 1 pair (same accounting as certificate crossings);
|
|
18
|
+
- Card presses: edge sample points (4px step) entering unrelated card rectangles (+1px margin), deduplicated by edge x card;
|
|
19
|
+
- Row alignment rate: grouped by y difference <= 4px, in-group count / n (metrics.ROW_EPS accounting);
|
|
20
|
+
- Spacing CV: std/mean of nearest-neighbor center distances (same accounting as the certificate);
|
|
21
|
+
- Symmetry-axis error: vertical axis x = mean(x_i); for node i, the distance from its mirror point (2a-x_i, y_i) to the nearest
|
|
22
|
+
other node is err_i; symmetry-axis error = mean(err_i) (smaller means more mirror symmetry);
|
|
23
|
+
- Loop cohesion: average pairwise distance of on-ring nodes / ring diameter (largest pairwise distance); smaller means more compact.
|
|
24
|
+
"""
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import math
|
|
28
|
+
import os
|
|
29
|
+
import sys
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── Geometry primitives (independent recomputation, same accounting as metrics.py/routing.py) ──
|
|
33
|
+
EPS = 1e-9
|
|
34
|
+
ROW_EPS = 4
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _seg_cross(p1, p2, p3, p4):
|
|
38
|
+
def orient(a, b, c):
|
|
39
|
+
v = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
|
|
40
|
+
return 0 if abs(v) < EPS else (1 if v > 0 else -1)
|
|
41
|
+
d1, d2 = orient(p3, p4, p1), orient(p3, p4, p2)
|
|
42
|
+
d3, d4 = orient(p1, p2, p3), orient(p1, p2, p4)
|
|
43
|
+
if d1 != d2 and d3 != d4 and d1 != 0 and d3 != 0:
|
|
44
|
+
return True
|
|
45
|
+
if d1 == 0 and d2 == 0 and d3 == 0 and d4 == 0:
|
|
46
|
+
def on(a, b, c):
|
|
47
|
+
return (min(a[0], b[0]) - EPS <= c[0] <= max(a[0], b[0]) + EPS and
|
|
48
|
+
min(a[1], b[1]) - EPS <= c[1] <= max(a[1], b[1]) + EPS)
|
|
49
|
+
if on(p3, p4, p1) or on(p3, p4, p2) or on(p1, p2, p3) or on(p1, p2, p4):
|
|
50
|
+
near = [q for q in (p3, p4) for p in (p1, p2)
|
|
51
|
+
if abs(q[0] - p[0]) < EPS and abs(q[1] - p[1]) < EPS]
|
|
52
|
+
return len(near) == 0
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _dist_point_rect(px, py, cx, cy, hw, hh):
|
|
57
|
+
dx = max(abs(px - cx) - hw, 0.0)
|
|
58
|
+
dy = max(abs(py - cy) - hh, 0.0)
|
|
59
|
+
return math.hypot(dx, dy)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _quad_points(p0, ctrl, p1, n=24):
|
|
63
|
+
out = []
|
|
64
|
+
for k in range(n + 1):
|
|
65
|
+
t = k / n
|
|
66
|
+
u = 1 - t
|
|
67
|
+
out.append((u * u * p0[0] + 2 * u * t * ctrl[0] + t * t * p1[0],
|
|
68
|
+
u * u * p0[1] + 2 * u * t * ctrl[1] + t * t * p1[1]))
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _sample(pts, step=4.0):
|
|
73
|
+
out = []
|
|
74
|
+
for i in range(len(pts) - 1):
|
|
75
|
+
(x1, y1), (x2, y2) = pts[i], pts[i + 1]
|
|
76
|
+
d = math.hypot(x2 - x1, y2 - y1)
|
|
77
|
+
n = max(1, int(d / step))
|
|
78
|
+
for k in range(n):
|
|
79
|
+
t = k / n
|
|
80
|
+
out.append((x1 + (x2 - x1) * t, y1 + (y2 - y1) * t))
|
|
81
|
+
out.append(tuple(pts[-1]))
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ── Layout loading and metrics ──
|
|
86
|
+
def _load_layout(path):
|
|
87
|
+
with open(path, encoding="utf-8") as f:
|
|
88
|
+
lay = json.load(f)
|
|
89
|
+
if "nodes" not in lay or "edges" not in lay:
|
|
90
|
+
raise SystemExit(f"{path}: not a layout.json (missing nodes/edges)")
|
|
91
|
+
return lay
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _edge_polys(lay):
|
|
95
|
+
"""Edge list [(a,b)] and discretized polylines (arcs as 24 Bezier segments)."""
|
|
96
|
+
edges, polys = [], []
|
|
97
|
+
for e in lay["edges"]:
|
|
98
|
+
r = e.get("route", {})
|
|
99
|
+
pts = [tuple(p) for p in r.get("points", [])]
|
|
100
|
+
if r.get("arc") and r.get("ctrl") and len(pts) >= 2:
|
|
101
|
+
pts = _quad_points(pts[0], tuple(r["ctrl"]), pts[-1], 24)
|
|
102
|
+
edges.append((e["a"], e["b"]))
|
|
103
|
+
polys.append(pts)
|
|
104
|
+
return edges, polys
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _symmetry_err(pos):
|
|
108
|
+
"""Symmetry-axis error: vertical axis x=mean(x); mean_i min_{j!=i} dist(mirror_i, p_j)."""
|
|
109
|
+
if len(pos) < 2:
|
|
110
|
+
return 0.0
|
|
111
|
+
axis = sum(p[0] for p in pos.values()) / len(pos)
|
|
112
|
+
errs = []
|
|
113
|
+
for v, (x, y) in pos.items():
|
|
114
|
+
mx = 2 * axis - x
|
|
115
|
+
best = min(math.hypot(mx - q[0], y - q[1])
|
|
116
|
+
for w, q in pos.items() if w != v)
|
|
117
|
+
errs.append(best)
|
|
118
|
+
return sum(errs) / len(errs)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _node_sym_err(pos, v):
|
|
122
|
+
axis = sum(p[0] for p in pos.values()) / len(pos)
|
|
123
|
+
x, y = pos[v]
|
|
124
|
+
mx = 2 * axis - x
|
|
125
|
+
return min(math.hypot(mx - q[0], y - q[1]) for w, q in pos.items() if w != v)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def layout_metrics(lay, graph=None):
|
|
129
|
+
"""Key metrics of a single layout (independent recomputation with certificate accounting + symmetry/cohesion extensions)."""
|
|
130
|
+
nodes = lay["nodes"]
|
|
131
|
+
card_h = lay.get("meta", {}).get("cardH", 44)
|
|
132
|
+
ids = list(nodes)
|
|
133
|
+
pos = {v: (nodes[v]["x"], nodes[v]["y"]) for v in ids}
|
|
134
|
+
boxes = {v: (nodes[v]["x"], nodes[v]["y"],
|
|
135
|
+
nodes[v].get("w", 140) / 2, card_h / 2) for v in ids}
|
|
136
|
+
edges, polys = _edge_polys(lay)
|
|
137
|
+
|
|
138
|
+
crossings = 0
|
|
139
|
+
for i in range(len(edges)):
|
|
140
|
+
for j in range(i + 1, len(edges)):
|
|
141
|
+
a1, b1 = edges[i]
|
|
142
|
+
a2, b2 = edges[j]
|
|
143
|
+
if a1 in (a2, b2) or b1 in (a2, b2):
|
|
144
|
+
continue
|
|
145
|
+
hit = False
|
|
146
|
+
for s1 in zip(polys[i][:-1], polys[i][1:]):
|
|
147
|
+
for s2 in zip(polys[j][:-1], polys[j][1:]):
|
|
148
|
+
if _seg_cross(s1[0], s1[1], s2[0], s2[1]):
|
|
149
|
+
hit = True
|
|
150
|
+
break
|
|
151
|
+
if hit:
|
|
152
|
+
break
|
|
153
|
+
if hit:
|
|
154
|
+
crossings += 1
|
|
155
|
+
|
|
156
|
+
occ = 0
|
|
157
|
+
for i, (a, b) in enumerate(edges):
|
|
158
|
+
pts = _sample(polys[i])
|
|
159
|
+
for v in ids:
|
|
160
|
+
if v in (a, b):
|
|
161
|
+
continue
|
|
162
|
+
cx, cy, hw, hh = boxes[v]
|
|
163
|
+
if any(_dist_point_rect(px, py, cx, cy, hw + 1, hh + 1) <= 0
|
|
164
|
+
for (px, py) in pts):
|
|
165
|
+
occ += 1
|
|
166
|
+
|
|
167
|
+
total_len = 0.0
|
|
168
|
+
for pts in polys:
|
|
169
|
+
total_len += sum(math.dist(pts[k], pts[k + 1])
|
|
170
|
+
for k in range(len(pts) - 1))
|
|
171
|
+
avg_len = total_len / len(polys) if polys else 0.0
|
|
172
|
+
|
|
173
|
+
ysorted = sorted(p[1] for p in pos.values())
|
|
174
|
+
aligned, i = 0, 0
|
|
175
|
+
while i < len(ysorted):
|
|
176
|
+
j = i
|
|
177
|
+
while j + 1 < len(ysorted) and ysorted[j + 1] - ysorted[j] <= ROW_EPS:
|
|
178
|
+
j += 1
|
|
179
|
+
if j > i:
|
|
180
|
+
aligned += j - i + 1
|
|
181
|
+
i = j + 1
|
|
182
|
+
row_rate = aligned / len(ids) if ids else 0.0
|
|
183
|
+
|
|
184
|
+
gaps = []
|
|
185
|
+
for a in range(len(ids)):
|
|
186
|
+
for b in range(a + 1, len(ids)):
|
|
187
|
+
va, vb = boxes[ids[a]], boxes[ids[b]]
|
|
188
|
+
dx = abs(va[0] - vb[0]) - (va[2] + vb[2])
|
|
189
|
+
dy = abs(va[1] - vb[1]) - (va[3] + vb[3])
|
|
190
|
+
gaps.append(math.hypot(max(dx, 0.0), max(dy, 0.0)))
|
|
191
|
+
min_gap = min(gaps) if gaps else 0.0
|
|
192
|
+
|
|
193
|
+
nn = []
|
|
194
|
+
for a in ids:
|
|
195
|
+
nn.append(min(math.dist(pos[a], pos[b]) for b in ids if b != a))
|
|
196
|
+
mean_nn = sum(nn) / len(nn) if nn else 0.0
|
|
197
|
+
cv = (math.sqrt(sum((d - mean_nn) ** 2 for d in nn) / len(nn)) / mean_nn
|
|
198
|
+
if nn and mean_nn else 0.0)
|
|
199
|
+
|
|
200
|
+
m = {
|
|
201
|
+
"crossings": crossings,
|
|
202
|
+
"edge_card_press_1px": occ,
|
|
203
|
+
"row_align_rate": round(row_rate, 3),
|
|
204
|
+
"min_card_gap": round(min_gap, 1),
|
|
205
|
+
"spacing_cv": round(cv, 3),
|
|
206
|
+
"avg_edge_length": round(avg_len, 1),
|
|
207
|
+
"symmetry_axis_err_px": round(_symmetry_err(pos), 1),
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
# loop cohesion (when a graph is given): average pairwise distance of on-ring nodes / ring diameter
|
|
211
|
+
if graph is not None:
|
|
212
|
+
loops = {}
|
|
213
|
+
for lp in graph.loops:
|
|
214
|
+
members = [v for v in lp.nodes if v in pos]
|
|
215
|
+
if len(members) < 2:
|
|
216
|
+
continue
|
|
217
|
+
ds = [math.dist(pos[a], pos[b])
|
|
218
|
+
for ii, a in enumerate(members) for b in members[ii + 1:]]
|
|
219
|
+
dia = max(ds)
|
|
220
|
+
loops[lp.id] = {
|
|
221
|
+
"cohesion": round((sum(ds) / len(ds)) / dia, 3) if dia > EPS else None,
|
|
222
|
+
"mean_pairwise_px": round(sum(ds) / len(ds), 1),
|
|
223
|
+
"diameter_px": round(dia, 1),
|
|
224
|
+
}
|
|
225
|
+
m["loops"] = loops
|
|
226
|
+
return m
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ── Three-layer diff ──
|
|
230
|
+
def diff_layouts(auto, manual, graph=None, auto_path="", manual_path=""):
|
|
231
|
+
an, mn = auto["nodes"], manual["nodes"]
|
|
232
|
+
missing = [v for v in an if v not in mn]
|
|
233
|
+
extra = [v for v in mn if v not in an]
|
|
234
|
+
if missing or extra:
|
|
235
|
+
raise SystemExit(f"layout node sets do not match: missing {missing}, extra {extra}")
|
|
236
|
+
apos = {v: (an[v]["x"], an[v]["y"]) for v in an}
|
|
237
|
+
mpos = {v: (mn[v]["x"], mn[v]["y"]) for v in mn}
|
|
238
|
+
|
|
239
|
+
loops_of = {}
|
|
240
|
+
if graph is not None:
|
|
241
|
+
for lp in graph.loops:
|
|
242
|
+
for v in lp.nodes:
|
|
243
|
+
loops_of.setdefault(v, []).append(lp.id)
|
|
244
|
+
|
|
245
|
+
# column-alignment reference: an x shared by >= 3 nodes (within +/-2px) in manual counts as a column
|
|
246
|
+
def _columns(pos):
|
|
247
|
+
xs = sorted(p[0] for p in pos.values())
|
|
248
|
+
cols, cur = [], [xs[0]]
|
|
249
|
+
for x in xs[1:]:
|
|
250
|
+
if x - cur[-1] <= 2:
|
|
251
|
+
cur.append(x)
|
|
252
|
+
else:
|
|
253
|
+
cols.append(cur)
|
|
254
|
+
cur = [x]
|
|
255
|
+
cols.append(cur)
|
|
256
|
+
return [sum(c) / len(c) for c in cols if len(c) >= 3]
|
|
257
|
+
|
|
258
|
+
mcols = _columns(mpos)
|
|
259
|
+
|
|
260
|
+
node_level = []
|
|
261
|
+
for v in sorted(an):
|
|
262
|
+
dx = mpos[v][0] - apos[v][0]
|
|
263
|
+
dy = mpos[v][1] - apos[v][1]
|
|
264
|
+
d = math.hypot(dx, dy)
|
|
265
|
+
item = {"node": v, "dx": dx, "dy": dy, "|d|": round(d, 1),
|
|
266
|
+
"tags": []}
|
|
267
|
+
if d < 4:
|
|
268
|
+
item["tags"] = ["not moved (<4px)"]
|
|
269
|
+
node_level.append(item)
|
|
270
|
+
continue
|
|
271
|
+
# toward loop center: the angle between the displacement and (loop center (auto) - original position) is small (projection ratio >= 0.6)
|
|
272
|
+
for lid in loops_of.get(v, []):
|
|
273
|
+
members = [w for w in
|
|
274
|
+
next(l for l in graph.loops if l.id == lid).nodes
|
|
275
|
+
if w in apos]
|
|
276
|
+
c = (sum(apos[w][0] for w in members) / len(members),
|
|
277
|
+
sum(apos[w][1] for w in members) / len(members))
|
|
278
|
+
vec = (c[0] - apos[v][0], c[1] - apos[v][1])
|
|
279
|
+
proj = (dx * vec[0] + dy * vec[1]) / (d * math.hypot(*vec)) \
|
|
280
|
+
if math.hypot(*vec) > EPS else 0.0
|
|
281
|
+
if proj >= 0.6:
|
|
282
|
+
item["tags"].append(f"toward loop center ({lid})")
|
|
283
|
+
elif proj <= -0.6:
|
|
284
|
+
item["tags"].append(f"away from loop center ({lid})")
|
|
285
|
+
# toward column alignment: manual x falls into some column (within +/-2px) while auto x does not
|
|
286
|
+
if any(abs(mpos[v][0] - col) <= 2 for col in mcols) and \
|
|
287
|
+
not any(abs(apos[v][0] - col) <= 2 for col in mcols):
|
|
288
|
+
item["tags"].append("toward column alignment")
|
|
289
|
+
# toward symmetry: the node's symmetry error drops significantly (below 70%)
|
|
290
|
+
if _node_sym_err(mpos, v) < 0.7 * _node_sym_err(apos, v):
|
|
291
|
+
item["tags"].append("toward symmetric position")
|
|
292
|
+
if not item["tags"]:
|
|
293
|
+
item["tags"] = ["free displacement"]
|
|
294
|
+
node_level.append(item)
|
|
295
|
+
|
|
296
|
+
ma, mm = layout_metrics(auto, graph), layout_metrics(manual, graph)
|
|
297
|
+
structural = {}
|
|
298
|
+
for k in ("crossings", "edge_card_press_1px", "row_align_rate",
|
|
299
|
+
"min_card_gap", "spacing_cv", "avg_edge_length",
|
|
300
|
+
"symmetry_axis_err_px"):
|
|
301
|
+
structural[k] = {"auto": ma[k], "manual": mm[k],
|
|
302
|
+
"delta": round(mm[k] - ma[k], 3)}
|
|
303
|
+
|
|
304
|
+
semantic = None
|
|
305
|
+
if graph is not None:
|
|
306
|
+
sem_nodes = []
|
|
307
|
+
for it in node_level:
|
|
308
|
+
v = it["node"]
|
|
309
|
+
if it["tags"] == ["not moved (<4px)"] or not loops_of.get(v):
|
|
310
|
+
if it["tags"] != ["not moved (<4px)"] and not loops_of.get(v):
|
|
311
|
+
sem_nodes.append({"node": v, "role": "branch",
|
|
312
|
+
"loops": []})
|
|
313
|
+
continue
|
|
314
|
+
entries = []
|
|
315
|
+
for lid in loops_of[v]:
|
|
316
|
+
members = [w for w in
|
|
317
|
+
next(l for l in graph.loops if l.id == lid).nodes
|
|
318
|
+
if w in apos]
|
|
319
|
+
peers_a = [w for w in members if w != v]
|
|
320
|
+
peers_m = peers_a
|
|
321
|
+
da = (sum(math.dist(apos[v], apos[w]) for w in peers_a)
|
|
322
|
+
/ len(peers_a)) if peers_a else 0.0
|
|
323
|
+
dm = (sum(math.dist(mpos[v], mpos[w]) for w in peers_m)
|
|
324
|
+
/ len(peers_m)) if peers_m else 0.0
|
|
325
|
+
entries.append({"loop": lid,
|
|
326
|
+
"closer_to_loop_center_after": dm < da,
|
|
327
|
+
"dist_auto_px": round(da, 1),
|
|
328
|
+
"dist_manual_px": round(dm, 1)})
|
|
329
|
+
sem_nodes.append({"node": v, "role": "on-ring",
|
|
330
|
+
"loops": loops_of[v], "loop_moves": entries})
|
|
331
|
+
semantic = {
|
|
332
|
+
"nodes": sem_nodes,
|
|
333
|
+
"loops": {
|
|
334
|
+
lid: {"cohesion_auto": ma["loops"].get(lid, {}).get("cohesion"),
|
|
335
|
+
"cohesion_manual": mm["loops"].get(lid, {}).get("cohesion"),
|
|
336
|
+
"mean_pairwise_delta_px": round(
|
|
337
|
+
(mm["loops"].get(lid, {}).get("mean_pairwise_px", 0) -
|
|
338
|
+
ma["loops"].get(lid, {}).get("mean_pairwise_px", 0)), 1)}
|
|
339
|
+
for lid in ma.get("loops", {})}
|
|
340
|
+
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
"tool": "stdraw layout-diff",
|
|
345
|
+
"auto": auto_path,
|
|
346
|
+
"manual": manual_path,
|
|
347
|
+
"node_level": node_level,
|
|
348
|
+
"structural": structural,
|
|
349
|
+
"semantic": semantic,
|
|
350
|
+
"definitions": {
|
|
351
|
+
"moved": "|d| >= 4px; everything else counts as not moved",
|
|
352
|
+
"crossings": "edge pairs sharing no endpoint count 1 per any segment intersection (same caliber as the certificate); "
|
|
353
|
+
"arcs discretized as 24 quadratic Bezier segments",
|
|
354
|
+
"edge_card_press_1px": "edge sample points (4px) entering unrelated card rectangles (+1px), deduplicated per edge x card",
|
|
355
|
+
"row_align_rate": "grouped counting with y difference <= 4px / n",
|
|
356
|
+
"symmetry_axis_err_px": "axis x=mean(x_i); mean_i min_{j!=i} dist(mirror_i, p_j), "
|
|
357
|
+
"smaller means more mirror-symmetric",
|
|
358
|
+
"loop_cohesion": "average pairwise distance of on-ring nodes / ring diameter (smaller means more cohesive)",
|
|
359
|
+
"delta": "manual - auto",
|
|
360
|
+
},
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _human(diff):
|
|
365
|
+
out = []
|
|
366
|
+
out.append(f"layout diff: {diff['auto']} (auto) vs {diff['manual']} (manual)")
|
|
367
|
+
out.append("")
|
|
368
|
+
out.append("-- node level (displacement vectors + orientation) --")
|
|
369
|
+
for it in diff["node_level"]:
|
|
370
|
+
out.append(f" {it['node']:<24} dx={it['dx']:>5} dy={it['dy']:>5} "
|
|
371
|
+
f"|d|={it['|d|']:>6} {'; '.join(it['tags'])}")
|
|
372
|
+
out.append("")
|
|
373
|
+
out.append("-- structural level (auto -> manual, delta = manual - auto) --")
|
|
374
|
+
for k, v in diff["structural"].items():
|
|
375
|
+
out.append(f" {k:<22} {v['auto']:>10} → {v['manual']:>10} Δ={v['delta']}")
|
|
376
|
+
if diff.get("semantic"):
|
|
377
|
+
out.append("")
|
|
378
|
+
out.append("-- semantic level (loop membership and cohesion) --")
|
|
379
|
+
for n in diff["semantic"]["nodes"]:
|
|
380
|
+
if not n.get("loops") and n["role"] != "branch":
|
|
381
|
+
continue
|
|
382
|
+
moves = "; ".join(
|
|
383
|
+
f"{m['loop']}:{m['dist_auto_px']}→{m['dist_manual_px']}px"
|
|
384
|
+
f"({'closer' if m['closer_to_loop_center_after'] else 'farther'})"
|
|
385
|
+
for m in n.get("loop_moves", [])) or "-"
|
|
386
|
+
out.append(f" {n['node']:<24} [{n['role']}] {moves}")
|
|
387
|
+
for lid, lp in diff["semantic"]["loops"].items():
|
|
388
|
+
out.append(f" loop {lid}: cohesion {lp['cohesion_auto']} -> "
|
|
389
|
+
f"{lp['cohesion_manual']}, mean pairwise distance delta="
|
|
390
|
+
f"{lp['mean_pairwise_delta_px']}px")
|
|
391
|
+
return "\n".join(out)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def main(argv=None):
|
|
395
|
+
ap = argparse.ArgumentParser(
|
|
396
|
+
prog="stdraw layout-diff",
|
|
397
|
+
description="three-layer diff of the auto layout vs the manual layout (data source of the aesthetics-collection loop)")
|
|
398
|
+
ap.add_argument("auto", help="automatic layout layout.json")
|
|
399
|
+
ap.add_argument("manual", help="manual layout layout.json (or an exported subset)")
|
|
400
|
+
ap.add_argument("--cld", default=None,
|
|
401
|
+
help="graph source (.cld/.json), enables the semantic layer (loop membership / cohesion)")
|
|
402
|
+
ap.add_argument("--out", default="layout-diff.json",
|
|
403
|
+
help="machine-readable JSON output path (default ./layout-diff.json; "
|
|
404
|
+
"empty string skips writing)")
|
|
405
|
+
a = ap.parse_args(argv)
|
|
406
|
+
|
|
407
|
+
graph = None
|
|
408
|
+
if a.cld:
|
|
409
|
+
from .__main__ import load_graph
|
|
410
|
+
graph = load_graph(a.cld)
|
|
411
|
+
|
|
412
|
+
auto = _load_layout(a.auto)
|
|
413
|
+
manual = _load_layout(a.manual)
|
|
414
|
+
d = diff_layouts(auto, manual, graph, a.auto, a.manual)
|
|
415
|
+
print(_human(d))
|
|
416
|
+
if a.out:
|
|
417
|
+
with open(a.out, "w", encoding="utf-8") as f:
|
|
418
|
+
json.dump(d, f, ensure_ascii=False, indent=1)
|
|
419
|
+
print(f"\nmachine-readable JSON -> {a.out}")
|
|
420
|
+
return 0
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
if __name__ == "__main__":
|
|
424
|
+
sys.exit(main())
|
stdraw/loops.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Loop semantic audit (lint layer) -- unique capability: catches "declared ring not closed" and "polarity mislabeled".
|
|
3
|
+
|
|
4
|
+
Consumes only the Graph model (does not parse HTML or any other host format).
|
|
5
|
+
|
|
6
|
+
Verifies three things:
|
|
7
|
+
1. Closure of declared rings: using only that ring's tagged edges, can the walk start at the first node, pass through all ring nodes, and return to it;
|
|
8
|
+
2. R/B polarity edge count: parity of negative-polarity tagged edges <-> declared kind (R = even / reinforcing, B = odd / balancing) consistency;
|
|
9
|
+
3. Independent enumeration: all simple loops actually present in the graph (DFS depth cap), compared against the declared set --
|
|
10
|
+
declared-but-nonexistent and existing-but-undeclared are both reported honestly.
|
|
11
|
+
|
|
12
|
+
Outputs a report dict (can go into the certificate JSON); the lint verdict does not affect layout, only the quality layer.
|
|
13
|
+
"""
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
from typing import Dict, List
|
|
16
|
+
|
|
17
|
+
from .model import Graph
|
|
18
|
+
|
|
19
|
+
MAX_DFS_DEPTH = 12 # DFS depth cap for declared-ring closure
|
|
20
|
+
MAX_ENUM_DEPTH = 10 # length cap for independently enumerated loops
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def loops_audit(graph: Graph) -> dict:
|
|
24
|
+
out_adj = defaultdict(list) # a -> [(b, polarity)]
|
|
25
|
+
edge_loops: Dict[tuple, List[str]] = {}
|
|
26
|
+
for e in graph.edges:
|
|
27
|
+
pol = "-" if e.polarity == "-" else "+"
|
|
28
|
+
out_adj[e.a].append((e.b, pol))
|
|
29
|
+
edge_loops.setdefault((e.a, e.b), []).extend(e.loops)
|
|
30
|
+
|
|
31
|
+
declared = {lp.id: lp for lp in graph.loops}
|
|
32
|
+
report = {"loops": [], "problems": [], "extra_cycles": []}
|
|
33
|
+
|
|
34
|
+
# ── 1+2. Audit each declared ring ──
|
|
35
|
+
for lid, lp in declared.items():
|
|
36
|
+
sub = defaultdict(list)
|
|
37
|
+
neg = 0
|
|
38
|
+
for e in graph.edges:
|
|
39
|
+
if lid in e.loops:
|
|
40
|
+
sub[e.a].append((e.b, e.polarity))
|
|
41
|
+
if e.polarity == "-":
|
|
42
|
+
neg += 1
|
|
43
|
+
start = lp.nodes[0] if lp.nodes else None
|
|
44
|
+
found = None
|
|
45
|
+
|
|
46
|
+
def dfs(cur, path, seen_edges):
|
|
47
|
+
nonlocal found
|
|
48
|
+
if found or len(path) > MAX_DFS_DEPTH:
|
|
49
|
+
return
|
|
50
|
+
for (nxt, _pol) in sub[cur]:
|
|
51
|
+
if (cur, nxt) in seen_edges:
|
|
52
|
+
continue
|
|
53
|
+
if nxt == start and len(path) >= 2:
|
|
54
|
+
found = path + [start]
|
|
55
|
+
return
|
|
56
|
+
if nxt in path:
|
|
57
|
+
continue
|
|
58
|
+
dfs(nxt, path + [nxt], seen_edges | {(cur, nxt)})
|
|
59
|
+
|
|
60
|
+
if start:
|
|
61
|
+
dfs(start, [start], set())
|
|
62
|
+
closed = found is not None
|
|
63
|
+
parity_kind = "B" if neg % 2 else "R"
|
|
64
|
+
kind_ok = (not lp.kind) or (lp.kind == parity_kind)
|
|
65
|
+
item = {
|
|
66
|
+
"id": lid, "label": lp.label, "declared_kind": lp.kind,
|
|
67
|
+
"nodes": lp.nodes, "closed": closed,
|
|
68
|
+
"walk": found,
|
|
69
|
+
"neg_edges": neg, "parity_kind": parity_kind,
|
|
70
|
+
"kind_consistent": kind_ok,
|
|
71
|
+
}
|
|
72
|
+
if not closed:
|
|
73
|
+
report["problems"].append(
|
|
74
|
+
f"loop {lid} ({lp.label}) not closed: tagged edges cannot walk from {start} back to itself")
|
|
75
|
+
if not kind_ok:
|
|
76
|
+
report["problems"].append(
|
|
77
|
+
f"loop {lid} ({lp.label}) declares {lp.kind} but polarity parity is actually {parity_kind}"
|
|
78
|
+
f" (negative edges {neg})")
|
|
79
|
+
report["loops"].append(item)
|
|
80
|
+
|
|
81
|
+
# case where a declared ring has no tagged edges
|
|
82
|
+
for lid, lp in declared.items():
|
|
83
|
+
tagged = sum(1 for e in graph.edges if lid in e.loops)
|
|
84
|
+
if tagged == 0:
|
|
85
|
+
report["problems"].append(f"loop {lid} ({lp.label}) has no edges tagged with it")
|
|
86
|
+
|
|
87
|
+
# ── 3. Independently enumerate all simple loops ──
|
|
88
|
+
# loops are stored as canonical directed tuples (path order with the smallest node rotated to the front): a frozenset would fold
|
|
89
|
+
# "different directed loops with the same node set" into one, and its iteration order drifts with PYTHONHASHSEED,
|
|
90
|
+
# which once caused neg_edges/kind to swap between two renders of loops_audit.json (t_6b2efc40).
|
|
91
|
+
cycles = set()
|
|
92
|
+
|
|
93
|
+
def canon_cycle(path):
|
|
94
|
+
return min(tuple(path[i:] + path[:i]) for i in range(len(path)))
|
|
95
|
+
|
|
96
|
+
def walk(cur, start, path, seen_e):
|
|
97
|
+
if len(path) > MAX_ENUM_DEPTH:
|
|
98
|
+
return
|
|
99
|
+
for (nxt, _pol) in out_adj[cur]:
|
|
100
|
+
if (cur, nxt) in seen_e:
|
|
101
|
+
continue
|
|
102
|
+
if nxt == start and len(path) >= 2:
|
|
103
|
+
cycles.add(canon_cycle(path))
|
|
104
|
+
continue
|
|
105
|
+
if nxt in path:
|
|
106
|
+
continue
|
|
107
|
+
walk(nxt, start, path + [nxt], seen_e | {(cur, nxt)})
|
|
108
|
+
|
|
109
|
+
for n in list(out_adj):
|
|
110
|
+
walk(n, n, [n], set())
|
|
111
|
+
|
|
112
|
+
def cycle_kind(cyc):
|
|
113
|
+
negs = 0
|
|
114
|
+
for i in range(len(cyc)):
|
|
115
|
+
a, b = cyc[i], cyc[(i + 1) % len(cyc)]
|
|
116
|
+
pols = [e.polarity for e in graph.edges
|
|
117
|
+
if (e.a, e.b) in ((a, b), (b, a))]
|
|
118
|
+
if pols and "-" in pols:
|
|
119
|
+
negs += 1
|
|
120
|
+
return "B" if negs % 2 else "R", negs
|
|
121
|
+
|
|
122
|
+
declared_node_sets = [frozenset(lp.nodes) for lp in graph.loops]
|
|
123
|
+
for cyc in sorted(cycles): # tuple order = deterministic total order, no tie ambiguity
|
|
124
|
+
kind, negs = cycle_kind(cyc)
|
|
125
|
+
cset = frozenset(cyc)
|
|
126
|
+
match = next((lp.id for lp, s in zip(graph.loops, declared_node_sets)
|
|
127
|
+
if s == cset), None)
|
|
128
|
+
report["extra_cycles"].append({
|
|
129
|
+
"nodes": sorted(cyc), "kind": kind, "neg_edges": negs,
|
|
130
|
+
"matches_declared": match})
|
|
131
|
+
if match is None:
|
|
132
|
+
report["problems"].append(
|
|
133
|
+
f"undeclared cycle {'->'.join(cyc)} ({kind}, negative edges {negs})")
|
|
134
|
+
|
|
135
|
+
report["summary"] = {
|
|
136
|
+
"declared": len(declared),
|
|
137
|
+
"closed": sum(1 for it in report["loops"] if it["closed"]),
|
|
138
|
+
"kind_consistent": sum(1 for it in report["loops"] if it["kind_consistent"]),
|
|
139
|
+
"enumerated": len(cycles),
|
|
140
|
+
"undeclared": sum(1 for c in report["extra_cycles"]
|
|
141
|
+
if c["matches_declared"] is None),
|
|
142
|
+
"problems": len(report["problems"]),
|
|
143
|
+
}
|
|
144
|
+
report["pass"] = report["summary"]["problems"] == 0
|
|
145
|
+
return report
|