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.
@@ -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