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
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Spine-wing (central spine + midline wings) placement (2026-11-06 jacky's
|
|
3
|
+
final call, B-limited edition, t_8f211f74).
|
|
4
|
+
|
|
5
|
+
> jacky hand-laid the classic-cld-growth-and-burden diagram: the central
|
|
6
|
+
> spine adopters (top) / rate (bottom) sits strictly vertical on the canvas
|
|
7
|
+
> centerline; left wings word/potential and the right cluster crowding sit
|
|
8
|
+
> on the same horizontal midline (y exactly equal); quickfix upper-right /
|
|
9
|
+
> sideeffect lower-right are near-mirrored (x differs by 6px); fundamental
|
|
10
|
+
> sits lower-right inside, on the same row as rate. The second form of
|
|
11
|
+
> "topology should decide the layout" — a hub-pair spine + a horizontal
|
|
12
|
+
> wing band + mirrored clusters beyond the wings.
|
|
13
|
+
|
|
14
|
+
Trigger detection (deterministic topological metrics, written into the
|
|
15
|
+
docs/layout-modes.md priority chain; this module is the source of truth):
|
|
16
|
+
- **Spine**: enumerate directed edges and take the pair with the most
|
|
17
|
+
common neighbors of the endpoints as the hub pair (ties: more
|
|
18
|
+
declared-loop co-occurrences -> smaller canonical (u,v)). The
|
|
19
|
+
"rate->adopters" diffusion hub pair of a classic CLD wins naturally
|
|
20
|
+
(measured 3 common neighbors in classic-cld; at most 2 in the rest of
|
|
21
|
+
the corpus).
|
|
22
|
+
- **Wings**: the common neighbors of the hub pair — each forms a 3-ring
|
|
23
|
+
with the hub pair (left wings word in R1 / potential in B1, right wing
|
|
24
|
+
crowding in B2), pressed onto the midline horizontal band.
|
|
25
|
+
- **Trigger conditions (all must hold):
|
|
26
|
+
(1) 3 <= |wings| <= 4 (the midline wing band needs >= 3 slots to be a
|
|
27
|
+
"band"; > 4 overloads the hub pair);
|
|
28
|
+
(2) 1 <= |rest| <= 6 AND all adjacencies of rest are within
|
|
29
|
+
spine∪wings∪rest (secondary clusters hang only on wings/hub —
|
|
30
|
+
double-axis symmetry constructibility; limits-to-growth has an
|
|
31
|
+
isomorphic spine but rest=0, a pure growth core, no trigger);
|
|
32
|
+
(3) spine length ratio = |spine| / n <= 0.5 (the hub is a compact
|
|
33
|
+
node pair; long-chain shapes belong to the column-chain criteria);
|
|
34
|
+
(4) rest is pairable: sorted by "restricted neighborhood key
|
|
35
|
+
(adjacency set against wings∪spine) -> canonical form", then
|
|
36
|
+
paired two by two with at most 1 leftover (mirror
|
|
37
|
+
constructibility — quickfix/sideeffect pair up, fundamental is
|
|
38
|
+
the leftover inside).
|
|
39
|
+
- Corpus hits (measured on a full 19-diagram sweep): only
|
|
40
|
+
classic-cld-growth-and-burden. Pure-ring family (arms-race etc.,
|
|
41
|
+
maxCN<=1), the three bowtie-vertex diagrams (maxCN=1), the two
|
|
42
|
+
bowtie-pair diagrams (maxCN=2), and the column family (maxCN<=2) all
|
|
43
|
+
miss — see the card evidence list.
|
|
44
|
+
|
|
45
|
+
Placement (place_spine_wing, constructed constraints — cross-checked
|
|
46
|
+
against the sample's relative geometry, not global objective-function
|
|
47
|
+
terms):
|
|
48
|
+
- Spine: the hub pair is strictly vertical along layout axis x=0, edge
|
|
49
|
+
head (adopters) on top at y=-Hs and tail (rate) below at y=+Hs, with
|
|
50
|
+
Hs = 2*(card_h+GAP) (=160 @card_h 44, matching the sample's spine
|
|
51
|
+
half-distance 160);
|
|
52
|
+
- Midline wings: all wings sit at y=0 on the midline. The rest
|
|
53
|
+
cluster's anchor wing (the one with the most adjacencies to rest;
|
|
54
|
+
crowding in the sample) takes the nearest right slot; the other wings
|
|
55
|
+
are sorted by (loop kind R<B<other -> canonical form) and placed
|
|
56
|
+
left, inside-out (sample: word inside / potential outside);
|
|
57
|
+
- Rest mirror pairs: the first of each pair goes to the upper wing row
|
|
58
|
+
y=-(card_h+GAP), the second to the lower wing row y=+(card_h+GAP)
|
|
59
|
+
(=±80, idealized from the sample's ±82), stacked in one column at
|
|
60
|
+
whole pitches right of the anchor wing; a leftover goes lower-right
|
|
61
|
+
inside at y=+2*(card_h+GAP) (=+160, on rate's row; sample
|
|
62
|
+
fundamental y=532 vs rate 534, the same row band);
|
|
63
|
+
- All coordinates are even (2px grid); uniform translation to center at
|
|
64
|
+
the delivery layer is handled by layout_auto.
|
|
65
|
+
|
|
66
|
+
Routing (constructed-constraint injection, not via the candidate pool):
|
|
67
|
+
- The spine edge (tail->head) is constructed as a vertical straight
|
|
68
|
+
line (bottom-edge midpoint of the top card -> top-edge midpoint of
|
|
69
|
+
the bottom card);
|
|
70
|
+
- All other edges go through the same Router candidate pool (straight
|
|
71
|
+
and arc lines as equals); line styles inside the symmetric band are
|
|
72
|
+
pressed toward straight lines by the Router tier costs (vg+120/
|
|
73
|
+
poly+40) — cross-checked against the all-straight sample;
|
|
74
|
+
- Inter-loop pairs (crowding<->quickfix, crowding<->fundamental) are
|
|
75
|
+
separated by the Router inter-loop corridor mechanism (>= 4px gate).
|
|
76
|
+
|
|
77
|
+
Trial gate: reuses layout_auto._constructed_gates (box press / clearance /
|
|
78
|
+
anchor smoothness / card gap / inter-loop separation / crossings <= 2); any
|
|
79
|
+
failure silently falls back to the existing pattern chain (negative cases
|
|
80
|
+
byte-for-byte unchanged).
|
|
81
|
+
"""
|
|
82
|
+
import math
|
|
83
|
+
|
|
84
|
+
from . import columnlayout
|
|
85
|
+
from .routing import border_point
|
|
86
|
+
|
|
87
|
+
SYM_GAP = 36 # breathing-room target clearance (same tier as bowtie/symmetric)
|
|
88
|
+
MAX_SPINE_WING_N = 12 # trigger upper bound: small graphs
|
|
89
|
+
MIN_WINGS, MAX_WINGS = 3, 4
|
|
90
|
+
MAX_REST = 6
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class InfeasibleSpineWing(Exception):
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _adjacency(edges):
|
|
98
|
+
adj = {v: set() for a, b in edges for v in (a, b)}
|
|
99
|
+
for a, b in edges:
|
|
100
|
+
adj[a].add(b)
|
|
101
|
+
adj[b].add(a)
|
|
102
|
+
return adj
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _canon(seq):
|
|
106
|
+
return columnlayout._canon(seq)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def detect_spine_wing(graph, why=None):
|
|
110
|
+
"""Spine-wing detection. Returns a plan dict or None.
|
|
111
|
+
|
|
112
|
+
plan = {"spine_tail", "spine_head", "wings": {"left": [...], "anchor": w},
|
|
113
|
+
"rest_pairs": [[up, down], ...], "rest_single": [v] or [],
|
|
114
|
+
"deg0": [isolated nodes]}
|
|
115
|
+
|
|
116
|
+
why: optional dict — receives a deterministic one-line reason for the
|
|
117
|
+
outcome (mode decision trace, certificate mode_decision_trace field).
|
|
118
|
+
"""
|
|
119
|
+
nodes = list(graph.node_ids())
|
|
120
|
+
edges = [(e.a, e.b) for e in graph.edges]
|
|
121
|
+
adj = _adjacency(edges)
|
|
122
|
+
core = [v for v in nodes if adj[v]]
|
|
123
|
+
deg0 = [v for v in nodes if not adj[v]]
|
|
124
|
+
if len(core) < 6 or len(core) > MAX_SPINE_WING_N:
|
|
125
|
+
if why is not None:
|
|
126
|
+
why["reason"] = (f"core n={len(core)} "
|
|
127
|
+
f"(need 6..{MAX_SPINE_WING_N})")
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
# Spine candidates: the directed edge whose endpoints have the most common neighbors (deterministic tie-break chain)
|
|
131
|
+
loop_sets = []
|
|
132
|
+
for lp in graph.loops:
|
|
133
|
+
loop_sets.append((getattr(lp, "kind", None), set(lp.nodes)))
|
|
134
|
+
best = None
|
|
135
|
+
for e in sorted(set(edges)):
|
|
136
|
+
u, v = e
|
|
137
|
+
cn = adj[u] & adj[v]
|
|
138
|
+
if not (MIN_WINGS <= len(cn) <= MAX_WINGS):
|
|
139
|
+
continue
|
|
140
|
+
co_loops = sum(1 for _k, s in loop_sets if {u, v} <= s)
|
|
141
|
+
key = (-len(cn), -co_loops, _canon((u, v)))
|
|
142
|
+
if best is None or key < best[0]:
|
|
143
|
+
best = (key, u, v, cn)
|
|
144
|
+
if best is None:
|
|
145
|
+
if why is not None:
|
|
146
|
+
why["reason"] = ("no hub-pair candidate: no directed edge whose "
|
|
147
|
+
f"endpoints share {MIN_WINGS}..{MAX_WINGS} "
|
|
148
|
+
"common neighbors")
|
|
149
|
+
return None
|
|
150
|
+
_key, u, v, wings_set = best
|
|
151
|
+
wings = sorted(wings_set)
|
|
152
|
+
rest = sorted(set(core) - {u, v} - wings_set)
|
|
153
|
+
# (1) wing band + (2) secondary clusters (a pure growth core with empty rest does not trigger — limits-to-growth)
|
|
154
|
+
if not (1 <= len(rest) <= MAX_REST):
|
|
155
|
+
if why is not None:
|
|
156
|
+
why["reason"] = (f"rest cluster n={len(rest)} "
|
|
157
|
+
f"(need 1..{MAX_REST}; 0 = pure growth core, "
|
|
158
|
+
"e.g. limits-to-growth — no burden side)")
|
|
159
|
+
return None
|
|
160
|
+
# (3) spine length ratio: hub-pair nodes / core nodes <= 0.5 — a compact hub pair
|
|
161
|
+
# (long-chain shapes go to column-chain; 2-node hub pairs measured 0.22-0.33 corpus-wide)
|
|
162
|
+
if len({u, v}) / max(1, len(core)) > 0.5:
|
|
163
|
+
if why is not None:
|
|
164
|
+
why["reason"] = (f"spine length ratio "
|
|
165
|
+
f"{len({u, v}) / max(1, len(core)):.2f} > 0.5 "
|
|
166
|
+
"(long-chain shapes go to column-chain)")
|
|
167
|
+
return None
|
|
168
|
+
# (2)' double-axis constructibility: rest hangs only on spine∪wings∪rest (no outsiders)
|
|
169
|
+
allowed = {u, v} | wings_set | set(rest)
|
|
170
|
+
if not all(adj[r] <= allowed for r in rest):
|
|
171
|
+
if why is not None:
|
|
172
|
+
why["reason"] = ("rest adjacency reaches outside "
|
|
173
|
+
"spine∪wings∪rest (double-axis "
|
|
174
|
+
"constructibility fails)")
|
|
175
|
+
return None
|
|
176
|
+
# (4) rest mirror constructibility: first pair rest nodes sharing a declared loop
|
|
177
|
+
# (sample quickfix/sideeffect both in R2 become a mirror pair, while
|
|
178
|
+
# fundamental shares a loop only with the anchor wing -> leftover inside);
|
|
179
|
+
# the rest pair by "restricted neighborhood key (against wings∪spine) -> canonical form", leftovers <= 1.
|
|
180
|
+
def _restrict_key(r):
|
|
181
|
+
return (tuple(sorted(adj[r] & (wings_set | {u, v}))),
|
|
182
|
+
_canon((r,)))
|
|
183
|
+
shared_loop = {}
|
|
184
|
+
for i, r1 in enumerate(rest):
|
|
185
|
+
for r2 in rest[i + 1:]:
|
|
186
|
+
sl = [set(lp.nodes) for lp in graph.loops
|
|
187
|
+
if r1 in set(lp.nodes) and r2 in set(lp.nodes)]
|
|
188
|
+
if sl:
|
|
189
|
+
shared_loop[(r1, r2)] = tuple(sorted(
|
|
190
|
+
s for s in sl for x in (r1, r2) if x in s))
|
|
191
|
+
pairs, used = [], set()
|
|
192
|
+
for (r1, r2) in sorted(shared_loop,
|
|
193
|
+
key=lambda p: (_canon(p),)):
|
|
194
|
+
if r1 in used or r2 in used:
|
|
195
|
+
continue
|
|
196
|
+
pairs.append((r1, r2))
|
|
197
|
+
used |= {r1, r2}
|
|
198
|
+
rest_left = sorted((r for r in rest if r not in used),
|
|
199
|
+
key=_restrict_key)
|
|
200
|
+
for i in range(0, len(rest_left) - 1, 2):
|
|
201
|
+
pairs.append((rest_left[i], rest_left[i + 1]))
|
|
202
|
+
used |= {rest_left[i], rest_left[i + 1]}
|
|
203
|
+
single = sorted((r for r in rest if r not in used), key=_restrict_key)
|
|
204
|
+
if len(pairs) > 3 or len(single) > 1:
|
|
205
|
+
if why is not None:
|
|
206
|
+
why["reason"] = (f"rest mirror pairing infeasible: "
|
|
207
|
+
f"pairs={len(pairs)} (max 3), "
|
|
208
|
+
f"leftovers={len(single)} (max 1)")
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
# Wing assignment: the rest cluster's anchor wing (most adjacencies to rest; ties -> smaller canon) -> right side
|
|
212
|
+
def _canon1(w):
|
|
213
|
+
return _canon((w,))
|
|
214
|
+
anchor = sorted(wings, key=lambda w: (-len(adj[w] & set(rest)),
|
|
215
|
+
_canon1(w)))[0]
|
|
216
|
+
left_wings = [w for w in wings if w != anchor]
|
|
217
|
+
|
|
218
|
+
def _wing_kind(w):
|
|
219
|
+
kind_rank = {"R": 0, "B": 1}
|
|
220
|
+
k = 2
|
|
221
|
+
for lp in graph.loops:
|
|
222
|
+
s = set(lp.nodes)
|
|
223
|
+
if w in s and {u, v} <= s:
|
|
224
|
+
kind = getattr(lp, "kind", None)
|
|
225
|
+
if kind is not None:
|
|
226
|
+
k = kind_rank.get(kind, 2)
|
|
227
|
+
break
|
|
228
|
+
return k
|
|
229
|
+
left_wings.sort(key=lambda w: (_wing_kind(w), _canon1(w)))
|
|
230
|
+
# Pair first goes to the upper wing, second to the lower; a leftover goes lower-inside (sample fundamental)
|
|
231
|
+
rest_pairs = [[p[0], p[1]] for p in pairs]
|
|
232
|
+
if why is not None:
|
|
233
|
+
why["reason"] = (f"hub pair {u}->{v} with W={len(wings_set)} common "
|
|
234
|
+
f"neighbors, rest n={len(rest)} mirror-pairable "
|
|
235
|
+
f"({len(pairs)} pairs / {len(single)} leftover)")
|
|
236
|
+
return {"spine_tail": u, "spine_head": v,
|
|
237
|
+
"wings_left": left_wings, "wing_anchor": anchor,
|
|
238
|
+
"rest_pairs": rest_pairs, "rest_single": single,
|
|
239
|
+
"deg0": deg0}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def place_spine_wing(plan, widths, card_h, cfg, edges):
|
|
243
|
+
"""Spine-wing constructed placement -> ({v: x}, {v: y}, meta, constructed_routes).
|
|
244
|
+
|
|
245
|
+
Constructed constraints (relative geometry cross-checked against jacky's
|
|
246
|
+
hand-laid classic-cld sample; see the module docstring).
|
|
247
|
+
"""
|
|
248
|
+
u, v = plan["spine_tail"], plan["spine_head"]
|
|
249
|
+
GAP = SYM_GAP
|
|
250
|
+
row = card_h + GAP # wing row pitch (=80 @card_h 44)
|
|
251
|
+
Hs = 2 * row # spine half-distance (=160, sample 160)
|
|
252
|
+
|
|
253
|
+
def _r2(x):
|
|
254
|
+
return int(round(x / 2) * 2)
|
|
255
|
+
|
|
256
|
+
pos, y = {}, {}
|
|
257
|
+
pos[v], y[v] = 0, -Hs # head on top (sample adopters)
|
|
258
|
+
pos[u], y[u] = 0, Hs # tail at the bottom (sample rate)
|
|
259
|
+
|
|
260
|
+
# Right-side anchor wing (sample crowding x=+134 ~= half spine width + half wing width + 36)
|
|
261
|
+
anchor = plan["wing_anchor"]
|
|
262
|
+
x_anchor = (widths[u] + widths[anchor]) / 2 + GAP
|
|
263
|
+
pos[anchor], y[anchor] = _r2(x_anchor), 0
|
|
264
|
+
|
|
265
|
+
# Left wings: inside-out (sample: word inside / potential outside)
|
|
266
|
+
prev_half = widths[u] / 2
|
|
267
|
+
cur = -(prev_half + GAP)
|
|
268
|
+
for w in plan["wings_left"]:
|
|
269
|
+
xw = cur - widths[w] / 2
|
|
270
|
+
pos[w], y[w] = _r2(xw), 0
|
|
271
|
+
cur = xw - widths[w] / 2 - GAP
|
|
272
|
+
|
|
273
|
+
# Rest mirror pairs: whole-pitch stack right of the anchor wing at ±row (sample
|
|
274
|
+
# y=±82~=±row; x near-mirrored, idealized to an exact column, 0px deviation)
|
|
275
|
+
pair_col_x = x_anchor + widths[anchor] / 2 + GAP
|
|
276
|
+
mirror_pairs = []
|
|
277
|
+
for up, down in plan["rest_pairs"]:
|
|
278
|
+
pair_x = pair_col_x + widths[up] / 2 # column set by the first member's width
|
|
279
|
+
pos[up], y[up] = _r2(pair_x), -row
|
|
280
|
+
pos[down], y[down] = _r2(pair_x), row
|
|
281
|
+
mirror_pairs.append([up, down])
|
|
282
|
+
# Leftover: lower-right inside (sample fundamental on rate's row y=+2row; x keeps
|
|
283
|
+
# >= GAP clearance from the spine bottom card — sample x=594 vs rate right edge 460)
|
|
284
|
+
if plan["rest_single"]:
|
|
285
|
+
s = plan["rest_single"][0]
|
|
286
|
+
xs = widths[u] / 2 + GAP + widths[s] / 2
|
|
287
|
+
pos[s], y[s] = _r2(xs), 2 * row
|
|
288
|
+
|
|
289
|
+
if plan["deg0"]:
|
|
290
|
+
total = sum(widths[w] for w in plan["deg0"]) + 18 * (len(plan["deg0"]) + 1)
|
|
291
|
+
cur0 = -total / 2
|
|
292
|
+
for w in plan["deg0"]:
|
|
293
|
+
pos[w] = int(round((cur0 + widths[w] / 2) / 2) * 2)
|
|
294
|
+
y[w] = cfg.park_y
|
|
295
|
+
cur0 += widths[w] + 18
|
|
296
|
+
|
|
297
|
+
# Constructed routes: the spine edge as a vertical straight line (not via the candidate pool); other edges via the Router
|
|
298
|
+
constructed = {}
|
|
299
|
+
eset = set(edges)
|
|
300
|
+
for e in ((u, v), (v, u)):
|
|
301
|
+
if e in eset:
|
|
302
|
+
a, b = e
|
|
303
|
+
na = (pos[a], y[a], widths[a], card_h)
|
|
304
|
+
nb = (pos[b], y[b], widths[b], card_h)
|
|
305
|
+
pa = border_point(na[0], na[1], na[2] / 2, na[3] / 2,
|
|
306
|
+
nb[0] - na[0], nb[1] - na[1])
|
|
307
|
+
pb = border_point(nb[0], nb[1], nb[2] / 2, nb[3] / 2,
|
|
308
|
+
na[0] - nb[0], na[1] - nb[1])
|
|
309
|
+
constructed[e] = {"kind": "line", "points": [pa, pb]}
|
|
310
|
+
break
|
|
311
|
+
|
|
312
|
+
meta = {
|
|
313
|
+
"placement": "spine-wing",
|
|
314
|
+
"spine_wing": {
|
|
315
|
+
"axis_x": 0,
|
|
316
|
+
"axis_y": 0,
|
|
317
|
+
"spine": [v, u],
|
|
318
|
+
"wings_left": list(plan["wings_left"]),
|
|
319
|
+
"wing_anchor": anchor,
|
|
320
|
+
"wings": [w for w in plan["wings_left"]] + [anchor],
|
|
321
|
+
"mirror_pairs": mirror_pairs,
|
|
322
|
+
"rest_single": list(plan["rest_single"]),
|
|
323
|
+
"row_pitch": row,
|
|
324
|
+
"spine_half": Hs,
|
|
325
|
+
"gap": GAP,
|
|
326
|
+
"line_style_rule": "spine vertical straight + router lines/arcs",
|
|
327
|
+
},
|
|
328
|
+
}
|
|
329
|
+
return pos, y, meta, constructed
|
stdraw/symlayout.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Bowtie-pair (two triangles sharing an edge) symmetric placement
|
|
3
|
+
(2026-11-06 jacky layout aesthetics v2, t_c6ab760d).
|
|
4
|
+
|
|
5
|
+
> #11 learning-flywheel "could be symmetric yet leans left" — symmetric
|
|
6
|
+
> structures should be explicitly symmetric.
|
|
7
|
+
|
|
8
|
+
Trigger detection (deterministic local structure, checked before ring
|
|
9
|
+
detection):
|
|
10
|
+
The core graph is exactly "two triangles sharing one edge" (bowtie):
|
|
11
|
+
4 nodes, 5 edges, degree sequence {3,3,2,2}, with the two degree-3
|
|
12
|
+
nodes adjacent (shared-edge endpoints) and the two degree-2 nodes
|
|
13
|
+
non-adjacent (left/right apexes). Corpus hits: learning-flywheel /
|
|
14
|
+
fixes-that-fail (a directed-isomorphic pair -> the same layout
|
|
15
|
+
template).
|
|
16
|
+
|
|
17
|
+
Placement (place_bowtie):
|
|
18
|
+
- Shared-edge endpoints A (upper row) / B (lower row) sit on the
|
|
19
|
+
vertical axis; the two apexes mirror left (-D) / right (+D) on the
|
|
20
|
+
middle row — explicit geometric symmetry;
|
|
21
|
+
- Line-style consistency hard rule: the axis edge A->B is a straight
|
|
22
|
+
line; all 4 side edges are constructed as mirrored arcs (ctrl offset
|
|
23
|
+
symmetrically outward) — "line styles stay consistent within one
|
|
24
|
+
symmetric structure (no straight/arc/bend mixing)".
|
|
25
|
+
|
|
26
|
+
Centering normalization: the bounding-box center of A/B/apex is the
|
|
27
|
+
canvas center (layout-layer axis x=0; the delivery layer translates
|
|
28
|
+
uniformly to center — see layout_auto's centering post-process).
|
|
29
|
+
"""
|
|
30
|
+
import math
|
|
31
|
+
|
|
32
|
+
SYM_GAP = 36 # breathing-room target clearance
|
|
33
|
+
ARC_K_FALLBACK = 0.22 # side-arc k fallback tier (unreachable when side_geom is empty; defensive)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class InfeasibleBowtie(Exception):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _adjacency(edges):
|
|
41
|
+
adj = {v: set() for a, b in edges for v in (a, b)}
|
|
42
|
+
for a, b in edges:
|
|
43
|
+
adj[a].add(b)
|
|
44
|
+
adj[b].add(a)
|
|
45
|
+
return adj
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def detect_bowtie(graph, why=None):
|
|
49
|
+
"""Bowtie detection. Returns {"A","B","L","R","deg0"} or None.
|
|
50
|
+
|
|
51
|
+
why: optional dict — receives a deterministic one-line reason for the
|
|
52
|
+
outcome (mode decision trace, certificate mode_decision_trace field).
|
|
53
|
+
"""
|
|
54
|
+
nodes = list(graph.node_ids())
|
|
55
|
+
edges = [(e.a, e.b) for e in graph.edges]
|
|
56
|
+
adj = _adjacency(edges)
|
|
57
|
+
core = [v for v in nodes if adj.get(v)]
|
|
58
|
+
deg0 = [v for v in nodes if not adj.get(v)]
|
|
59
|
+
if len(core) != 4 or len(edges) != 5:
|
|
60
|
+
if why is not None:
|
|
61
|
+
why["reason"] = (f"core n={len(core)}, edges m={len(edges)} "
|
|
62
|
+
"(need exactly 4 core nodes / 5 edges)")
|
|
63
|
+
return None
|
|
64
|
+
d3 = sorted(v for v in core if len(adj[v]) == 3)
|
|
65
|
+
d2 = sorted(v for v in core if len(adj[v]) == 2)
|
|
66
|
+
if len(d3) != 2 or len(d2) != 2:
|
|
67
|
+
if why is not None:
|
|
68
|
+
why["reason"] = ("degree sequence not {3,3,2,2} "
|
|
69
|
+
f"(deg-3: {len(d3)}, deg-2: {len(d2)})")
|
|
70
|
+
return None
|
|
71
|
+
a, b = d3
|
|
72
|
+
l, r = d2
|
|
73
|
+
if b not in adj[a] or r in adj[l]:
|
|
74
|
+
if why is not None:
|
|
75
|
+
why["reason"] = ("the two degree-3 nodes are not adjacent, or the "
|
|
76
|
+
"degree-2 pair is adjacent (not a shared-edge bowtie)")
|
|
77
|
+
return None
|
|
78
|
+
# Structural self-check: exactly two triangles = {a,b,l} and {a,b,r}, sharing edge a-b
|
|
79
|
+
if not (l in adj[a] and l in adj[b] and r in adj[a] and r in adj[b]):
|
|
80
|
+
if why is not None:
|
|
81
|
+
why["reason"] = ("triangles {{a,b,l}} / {{a,b,r}} not complete "
|
|
82
|
+
"(not two triangles sharing edge a-b)")
|
|
83
|
+
return None
|
|
84
|
+
if why is not None:
|
|
85
|
+
why["reason"] = ("two triangles sharing edge "
|
|
86
|
+
f"{a}-{b} (deg sequence {{3,3,2,2}})")
|
|
87
|
+
# A = upper-row node: deterministic (id order; a/b sit at swap-symmetric positions, geometry invariant)
|
|
88
|
+
return {"A": a, "B": b, "L": l, "R": r, "deg0": deg0}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
from .routing import border_point as _border_point
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _quad_points(p0, ctrl, p1, seg=24):
|
|
95
|
+
return [[(1 - t) ** 2 * p0[0] + 2 * (1 - t) * t * ctrl[0] + t ** 2 * p1[0],
|
|
96
|
+
(1 - t) ** 2 * p0[1] + 2 * (1 - t) * t * ctrl[1] + t ** 2 * p1[1]]
|
|
97
|
+
for t in (i / seg for i in range(seg + 1))]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _own_press(pts, na, nb):
|
|
101
|
+
"""Whether the arc's intermediate sample points enter either end's own box
|
|
102
|
+
(with 1px margin; same criteria as check2).
|
|
103
|
+
|
|
104
|
+
Diagnostic aid (normally not triggered under the ctrl-translation
|
|
105
|
+
convention; kept for debugging recomputation).
|
|
106
|
+
"""
|
|
107
|
+
from .routing import dist_point_rect
|
|
108
|
+
for p in pts[1:-1]:
|
|
109
|
+
for bx, by, bw, bh in (na, nb):
|
|
110
|
+
if dist_point_rect(p[0], p[1], bx, by, bw / 2 + 1, bh / 2 + 1) <= 0:
|
|
111
|
+
return True
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _natural_border(cx, cy, hw, hh, dx, dy):
|
|
116
|
+
"""The ray's natural intersection with the box edge (no band clamping; used to compute the anchor clamping delta)."""
|
|
117
|
+
if abs(dx) < 1e-9 and abs(dy) < 1e-9:
|
|
118
|
+
return [cx, cy]
|
|
119
|
+
if abs(dx) < 1e-9:
|
|
120
|
+
return [cx, cy + (hh if dy > 0 else -hh)]
|
|
121
|
+
if abs(dy) < 1e-9:
|
|
122
|
+
return [cx + (hw if dx > 0 else -hw), cy]
|
|
123
|
+
t = min(hw / abs(dx), hh / abs(dy))
|
|
124
|
+
return [cx + dx * t, cy + dy * t]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _clamp_band(p, nx, ny, nw, nh):
|
|
128
|
+
"""Anchor clamped to the owning edge's midpoint ± 1/4 edge-length band (corners excluded; connection-point delivery convention)."""
|
|
129
|
+
hw, hh = nw / 2.0, nh / 2.0
|
|
130
|
+
dxo, dyo = p[0] - nx, p[1] - ny
|
|
131
|
+
on_x = abs(abs(dxo) - hw) <= abs(abs(dyo) - hh)
|
|
132
|
+
if on_x:
|
|
133
|
+
return [p[0], ny + max(-hh / 2, min(hh / 2, dyo))]
|
|
134
|
+
return [nx + max(-hw / 2, min(hw / 2, dxo)), p[1]]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def place_bowtie(plan, widths, card_h, cfg, edges):
|
|
138
|
+
"""Bowtie symmetric placement -> ({v: x}, {v: y}, meta, constructed_routes).
|
|
139
|
+
|
|
140
|
+
constructed_routes: {(a,b): route dict} — axis edge as a straight line
|
|
141
|
+
+ 4 side edges as mirrored arcs, constructively injected (not via the
|
|
142
|
+
candidate pool), guaranteeing mirror symmetry and line-style
|
|
143
|
+
consistency.
|
|
144
|
+
"""
|
|
145
|
+
A, B, L, R = plan["A"], plan["B"], plan["L"], plan["R"]
|
|
146
|
+
pitch = cfg.pitch
|
|
147
|
+
yA = cfg.top
|
|
148
|
+
yB = cfg.top + pitch
|
|
149
|
+
yMid = cfg.top + pitch / 2
|
|
150
|
+
wmax = max(widths.values())
|
|
151
|
+
# D: apex distance from the axis — axis card half-width + clearance + apex half-width; also L/R apex mutual distance >= SYM_GAP
|
|
152
|
+
D = max(widths[A] / 2 + SYM_GAP + max(widths[L], widths[R]) / 2,
|
|
153
|
+
(SYM_GAP + (widths[L] + widths[R]) / 2) / 2,
|
|
154
|
+
wmax / 2 + SYM_GAP / 2)
|
|
155
|
+
D = int(round(D / 2) * 2)
|
|
156
|
+
|
|
157
|
+
pos = {A: 0, B: 0, L: -D, R: D}
|
|
158
|
+
y = {A: int(yA), B: int(yB), L: int(round(yMid / 2) * 2),
|
|
159
|
+
R: int(round(yMid / 2) * 2)}
|
|
160
|
+
|
|
161
|
+
if plan["deg0"]:
|
|
162
|
+
total = sum(widths[v] for v in plan["deg0"]) + 18 * (len(plan["deg0"]) + 1)
|
|
163
|
+
cur = -total / 2
|
|
164
|
+
for v in plan["deg0"]:
|
|
165
|
+
pos[v] = int(round((cur + widths[v] / 2) / 2) * 2)
|
|
166
|
+
y[v] = cfg.park_y
|
|
167
|
+
cur += widths[v] + 18
|
|
168
|
+
|
|
169
|
+
def _np(v):
|
|
170
|
+
return (pos[v], y[v], widths[v], card_h)
|
|
171
|
+
|
|
172
|
+
constructed = {}
|
|
173
|
+
eset = {(a, b) for a, b in edges}
|
|
174
|
+
# Axis edge: straight line (A->B / B->A per the graph's directed edges; anchors face the peer's box edge)
|
|
175
|
+
for u, v in ((A, B), (B, A)):
|
|
176
|
+
if (u, v) not in eset:
|
|
177
|
+
continue
|
|
178
|
+
dy_u = 1 if y[v] > y[u] else -1
|
|
179
|
+
dy_v = 1 if y[u] > y[v] else -1
|
|
180
|
+
pa = _border_point(pos[u], y[u], widths[u] / 2, card_h / 2, 0, dy_u)
|
|
181
|
+
pb = _border_point(pos[v], y[v], widths[v] / 2, card_h / 2, 0, dy_v)
|
|
182
|
+
constructed[(u, v)] = {"kind": "line", "points": [pa, pb]}
|
|
183
|
+
# Side edges: mirrored arcs (apex <-> axis node; ctrl offset outward, mirrors share |ctrl|).
|
|
184
|
+
# t_7fe64f08 arc parameterization: k takes the adaptive median tier of the 4 side chord lengths —
|
|
185
|
+
# consistent curvature within one symmetric structure ("same-ring arc consistency" hard rule).
|
|
186
|
+
# Connection-point convention: the arc is shaped on **natural anchors** (the old ray convention)
|
|
187
|
+
# first, then mapped to clamped anchors — ctrl via chord coordinate transform (alpha ratio +
|
|
188
|
+
# beta perpendicular kept): shape and departure tangent preserved, never flipping into its own box.
|
|
189
|
+
side_geom = []
|
|
190
|
+
for apex, s in ((L, -1), (R, 1)):
|
|
191
|
+
for axis_v in (A, B):
|
|
192
|
+
for e in ((apex, axis_v), (axis_v, apex)):
|
|
193
|
+
if e not in eset:
|
|
194
|
+
continue
|
|
195
|
+
a, b = e
|
|
196
|
+
na, nb = _np(a), _np(b)
|
|
197
|
+
dx, dy = nb[0] - na[0], nb[1] - na[1]
|
|
198
|
+
pa_n = _natural_border(na[0], na[1], na[2] / 2, na[3] / 2,
|
|
199
|
+
dx, dy)
|
|
200
|
+
pb_n = _natural_border(nb[0], nb[1], nb[2] / 2, nb[3] / 2,
|
|
201
|
+
-dx, -dy)
|
|
202
|
+
pa_c = _clamp_band(pa_n, na[0], na[1], na[2], na[3])
|
|
203
|
+
pb_c = _clamp_band(pb_n, nb[0], nb[1], nb[2], nb[3])
|
|
204
|
+
dist = math.hypot(pb_n[0] - pa_n[0], pb_n[1] - pa_n[1])
|
|
205
|
+
side_geom.append((e, pa_n, pb_n, pa_c, pb_c, dist, s))
|
|
206
|
+
from .routing import adaptive_arc_k
|
|
207
|
+
k_uni = ARC_K_FALLBACK
|
|
208
|
+
if side_geom:
|
|
209
|
+
dists = sorted(g[5] for g in side_geom)
|
|
210
|
+
med = dists[len(dists) // 2] if len(dists) % 2 else \
|
|
211
|
+
(dists[len(dists) // 2 - 1] + dists[len(dists) // 2]) / 2
|
|
212
|
+
k_uni = adaptive_arc_k(med)
|
|
213
|
+
for e, pa, pb, pa_c, pb_c, dist, s in side_geom:
|
|
214
|
+
vx, vy = pb[0] - pa[0], pb[1] - pa[1]
|
|
215
|
+
ux, uy = -vy / dist, vx / dist
|
|
216
|
+
# Outward normal: chosen vs. the "apex -> axis" direction so ctrl moves away from the vertical axis
|
|
217
|
+
mx, my = (pa[0] + pb[0]) / 2, (pa[1] + pb[1]) / 2
|
|
218
|
+
cx = mx + ux * k_uni * dist * s
|
|
219
|
+
cy = my + uy * k_uni * dist * s
|
|
220
|
+
if (cx - 0) * s < (mx - 0) * s: # ctrl must sit further out than the midpoint
|
|
221
|
+
cx = mx - ux * k_uni * dist * s
|
|
222
|
+
cy = my - uy * k_uni * dist * s
|
|
223
|
+
# Map chord coordinates onto the clamped anchors: ctrl = pa_c + alpha*(pb_c-pa_c) + beta_vec
|
|
224
|
+
wx, wy = cx - pa[0], cy - pa[1]
|
|
225
|
+
alpha = (wx * vx + wy * vy) / (dist * dist)
|
|
226
|
+
betax = wx - alpha * vx
|
|
227
|
+
betay = wy - alpha * vy
|
|
228
|
+
v2x, v2y = pb_c[0] - pa_c[0], pb_c[1] - pa_c[1]
|
|
229
|
+
cx2 = pa_c[0] + alpha * v2x + betax
|
|
230
|
+
cy2 = pa_c[1] + alpha * v2y + betay
|
|
231
|
+
constructed[e] = {"kind": "arc",
|
|
232
|
+
"points": _quad_points(pa_c, [cx2, cy2], pb_c),
|
|
233
|
+
"ctrl": [cx2, cy2]}
|
|
234
|
+
meta = {
|
|
235
|
+
"placement": "symmetric",
|
|
236
|
+
"symmetry": {
|
|
237
|
+
"axis": "vertical",
|
|
238
|
+
"axis_x": 0,
|
|
239
|
+
"shared_edge": [A, B],
|
|
240
|
+
"mirror_pairs": [[L, R]],
|
|
241
|
+
"gap": SYM_GAP,
|
|
242
|
+
"line_style_rule": "axis-straight + mirrored-arcs",
|
|
243
|
+
},
|
|
244
|
+
}
|
|
245
|
+
return pos, y, meta, constructed
|
stdraw/textmetrics.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Bounding-box ground-truth layer (1, layout side): measure card text width with the real font; conservative padding.
|
|
3
|
+
|
|
4
|
+
Background (root cause of the v0.2 quality rework): the old text_px estimated width as character count x font size,
|
|
5
|
+
which inevitably deviates from actual browser rendering -- bounds measured too small -> wires hug/press the box while the certificate still passes (same-source distortion).
|
|
6
|
+
|
|
7
|
+
Principles of this layer:
|
|
8
|
+
- Measuring font = rendering font (render.py font-family prefers Microsoft YaHei);
|
|
9
|
+
- Worst case: titles measured at the bold upper bound; take the larger of "measured" vs "analytic";
|
|
10
|
+
- Conservative padding: >= 12px on each side, plus a 6% stretch margin;
|
|
11
|
+
- When PIL is unavailable, fall back to a conservative analytic formula (CJK 1.10x fs / others 0.62x fs, times a 1.08 safety factor).
|
|
12
|
+
|
|
13
|
+
The CI side (the second layer) is rendercheck.py comparing actual DOM bounds after real rendering -- both layers are indispensable.
|
|
14
|
+
"""
|
|
15
|
+
import os
|
|
16
|
+
|
|
17
|
+
# same rendering font as render.py (worst case measured with bold)
|
|
18
|
+
_FONT_CANDIDATES = [
|
|
19
|
+
"C:/Windows/Fonts/msyhbd.ttc", # Microsoft YaHei Bold (widest)
|
|
20
|
+
"C:/Windows/Fonts/msyh.ttc", # Microsoft YaHei
|
|
21
|
+
"C:/Windows/Fonts/simhei.ttf", # SimHei fallback
|
|
22
|
+
]
|
|
23
|
+
_FALLBACK_PATHS = [
|
|
24
|
+
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", # Linux CI fallback
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
PAD_SIDE_MIN = 12 # minimum per-side padding px
|
|
28
|
+
SAFETY = 1.06 # measurement stretch safety factor
|
|
29
|
+
|
|
30
|
+
_cache = {}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _load_font(px: int):
|
|
34
|
+
"""Load a font by pixel size (cached); returns None on failure."""
|
|
35
|
+
key = px
|
|
36
|
+
if key in _cache:
|
|
37
|
+
return _cache[key]
|
|
38
|
+
font = None
|
|
39
|
+
try:
|
|
40
|
+
from PIL import ImageFont
|
|
41
|
+
for path in _FONT_CANDIDATES + _FALLBACK_PATHS:
|
|
42
|
+
if os.path.exists(path):
|
|
43
|
+
try:
|
|
44
|
+
font = ImageFont.truetype(path, px)
|
|
45
|
+
break
|
|
46
|
+
except Exception:
|
|
47
|
+
continue
|
|
48
|
+
except Exception:
|
|
49
|
+
font = None
|
|
50
|
+
_cache[key] = font
|
|
51
|
+
return font
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _analytic(s: str, fs: float) -> float:
|
|
55
|
+
"""Conservative analytic formula when no font file is available (safety factor tightened vs the old text_px)."""
|
|
56
|
+
w = 0.0
|
|
57
|
+
for ch in s:
|
|
58
|
+
w += fs * 1.10 if ord(ch) > 0x2E7F else fs * 0.62
|
|
59
|
+
return w
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def text_width(s: str, fs: float, bold: bool = False) -> float:
|
|
63
|
+
"""Rendered width of a single line of text (px, conservative upper bound). Empty string returns 0."""
|
|
64
|
+
if not s:
|
|
65
|
+
return 0.0
|
|
66
|
+
est = _analytic(s, fs)
|
|
67
|
+
font = _load_font(max(8, int(round(fs))))
|
|
68
|
+
if font is not None:
|
|
69
|
+
try:
|
|
70
|
+
m = font.getlength(s) * (1.06 if bold else 1.0)
|
|
71
|
+
return max(m, est) * SAFETY
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
return est * SAFETY
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def card_size(label: str, value: str, fs_title: float, fs_value: float,
|
|
78
|
+
pad: float = None, min_w: int = 96, min_h: int = 44,
|
|
79
|
+
value_pad: float = None):
|
|
80
|
+
"""Planned card bounds: worst-case width of the two text lines + conservative padding, on a 2px grid.
|
|
81
|
+
|
|
82
|
+
Returns (w, h). Height is generous per line count (title line + value line); a single line still keeps the visual height.
|
|
83
|
+
|
|
84
|
+
value_pad: extra per-side padding px reserved for the second (value) text
|
|
85
|
+
line, on top of the historical pad-4 baseline. Default None keeps the
|
|
86
|
+
historical behavior byte-for-byte (zero drift); callers that render long
|
|
87
|
+
ticker/price strings (e.g. "7741.T 22,395.00 JPY" measured ~120px vs a
|
|
88
|
+
108px card) opt in via AutoConfig.value_pad_extra / CLI --value-pad.
|
|
89
|
+
"""
|
|
90
|
+
pad_side = pad if pad is not None else PAD_SIDE_MIN + 6
|
|
91
|
+
value_pad_side = (pad_side - 4) if value_pad is None else value_pad
|
|
92
|
+
w = max(text_width(label, fs_title, bold=True) + 2 * pad_side,
|
|
93
|
+
text_width(value, fs_value, bold=True) + 2 * value_pad_side,
|
|
94
|
+
min_w)
|
|
95
|
+
two_line = bool(value)
|
|
96
|
+
h = max(min_h, 46 if two_line else 44)
|
|
97
|
+
return int((w + 1) // 2 * 2), h
|