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/anchors.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Standard per-box anchor set (card t_2c2496ca): 16 discrete connection
|
|
3
|
+
points per box for the interactive layer (hover display / endpoint snapping /
|
|
4
|
+
explicit edge-to-anchor assignment).
|
|
5
|
+
|
|
6
|
+
Caliber (jacky 2026-11-08 verbal spec, 16-point symmetric formulation):
|
|
7
|
+
|
|
8
|
+
4 edge midpoints + 4 corner points + 2 golden-section points per edge
|
|
9
|
+
(fractions 0.382 / 0.618) = 16 points.
|
|
10
|
+
|
|
11
|
+
Inset: corner points and golden points are NOT placed on the geometric
|
|
12
|
+
corner tips / raw border — all 16 points live on the border rectangle
|
|
13
|
+
inset by ``ANCHOR_INSET`` px on every side. The 3px inset reuses the
|
|
14
|
+
existing border-inner-offset caliber of the fan-slot clamp
|
|
15
|
+
(``routing.fan_spread`` clamps slots inside the border at +/- (half - 3)),
|
|
16
|
+
so the anchor set never touches the rounded frame corner (render rx=7)
|
|
17
|
+
or the exact geometric tip.
|
|
18
|
+
|
|
19
|
+
Coordinate convention: box center (cx, cy) + half extents (hw, hh).
|
|
20
|
+
Edge order and golden-point direction are fixed and deterministic
|
|
21
|
+
(clockwise, g1 = 0.382 from the edge's first endpoint in clockwise
|
|
22
|
+
direction, g2 = 0.618):
|
|
23
|
+
|
|
24
|
+
top (n): left -> right right (e): top -> bottom
|
|
25
|
+
bottom (s): right -> left left (w): bottom -> top
|
|
26
|
+
|
|
27
|
+
This module is a pure function library — nothing in the static layout /
|
|
28
|
+
routing pipeline imports it (the auto-routing anchor planning keeps its
|
|
29
|
+
own caliber unchanged; the 21-input sweep stays byte-identical). The
|
|
30
|
+
interactive JS layer mirrors the same formulas on live node geometry
|
|
31
|
+
(``_interactive_js.stdAnchors``); ``tests/test_standard_anchors.py``
|
|
32
|
+
pins the Python side and the probe cross-checks JS vs Python equality.
|
|
33
|
+
"""
|
|
34
|
+
import math
|
|
35
|
+
|
|
36
|
+
ANCHOR_INSET = 3.0 # px, same -3 inner offset as the fan-slot clamp
|
|
37
|
+
GOLDEN_FRACS = (0.382, 0.618)
|
|
38
|
+
|
|
39
|
+
# Fixed deterministic order (probe hooks and export rely on the indices):
|
|
40
|
+
# 0..3 edge midpoints, 4..7 corners, 8..15 golden points.
|
|
41
|
+
ANCHOR_IDS = ("n", "e", "s", "w",
|
|
42
|
+
"ne", "se", "sw", "nw",
|
|
43
|
+
"n-g1", "n-g2", "e-g1", "e-g2",
|
|
44
|
+
"s-g1", "s-g2", "w-g1", "w-g2")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _r6(v):
|
|
48
|
+
"""Round to 1e-6 to keep JSON / probe comparisons stable."""
|
|
49
|
+
return round(v + 0.0, 6)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def standard_anchors(cx, cy, hw, hh, inset=ANCHOR_INSET):
|
|
53
|
+
"""The 16 standard anchor points of one box.
|
|
54
|
+
|
|
55
|
+
Returns a list of ``[id, x, y]`` in the fixed ANCHOR_IDS order.
|
|
56
|
+
All points sit on the rectangle inset by ``inset`` px (half extents
|
|
57
|
+
``hw - inset`` / ``hh - inset``); with the default inset the
|
|
58
|
+
midpoints are 3px inside the true border midpoint and the corners
|
|
59
|
+
sit on the inset rectangle's corner (never the geometric tip).
|
|
60
|
+
"""
|
|
61
|
+
if hw <= inset or hh <= inset:
|
|
62
|
+
raise ValueError("box half extents must exceed the anchor inset")
|
|
63
|
+
a = hw - inset
|
|
64
|
+
b = hh - inset
|
|
65
|
+
g1, g2 = GOLDEN_FRACS
|
|
66
|
+
out = [
|
|
67
|
+
["n", cx, cy - b],
|
|
68
|
+
["e", cx + a, cy],
|
|
69
|
+
["s", cx, cy + b],
|
|
70
|
+
["w", cx - a, cy],
|
|
71
|
+
["ne", cx + a, cy - b],
|
|
72
|
+
["se", cx + a, cy + b],
|
|
73
|
+
["sw", cx - a, cy + b],
|
|
74
|
+
["nw", cx - a, cy - b],
|
|
75
|
+
# top edge, left -> right
|
|
76
|
+
["n-g1", cx - a + 2 * a * g1, cy - b],
|
|
77
|
+
["n-g2", cx - a + 2 * a * g2, cy - b],
|
|
78
|
+
# right edge, top -> bottom
|
|
79
|
+
["e-g1", cx + a, cy - b + 2 * b * g1],
|
|
80
|
+
["e-g2", cx + a, cy - b + 2 * b * g2],
|
|
81
|
+
# bottom edge, right -> left
|
|
82
|
+
["s-g1", cx + a - 2 * a * g1, cy + b],
|
|
83
|
+
["s-g2", cx + a - 2 * a * g2, cy + b],
|
|
84
|
+
# left edge, bottom -> top
|
|
85
|
+
["w-g1", cx - a, cy + b - 2 * b * g1],
|
|
86
|
+
["w-g2", cx - a, cy + b - 2 * b * g2],
|
|
87
|
+
]
|
|
88
|
+
return [[p[0], _r6(p[1]), _r6(p[2])] for p in out]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def nearest_anchor(cx, cy, hw, hh, px, py, inset=ANCHOR_INSET):
|
|
92
|
+
"""Nearest standard anchor of one box to an arbitrary point.
|
|
93
|
+
|
|
94
|
+
Returns ``[id, x, y]``. Deterministic tie-break: lower ANCHOR_IDS
|
|
95
|
+
index wins (fixed order = fixed winner).
|
|
96
|
+
"""
|
|
97
|
+
pts = standard_anchors(cx, cy, hw, hh, inset)
|
|
98
|
+
best = None
|
|
99
|
+
for p in pts:
|
|
100
|
+
d = math.hypot(p[1] - px, p[2] - py)
|
|
101
|
+
if best is None or d < best[0] - 1e-9:
|
|
102
|
+
best = (d, p)
|
|
103
|
+
return list(best[1])
|
stdraw/bowtielayout.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Bowtie-vertex (two rings sharing a single node) symmetric placement
|
|
3
|
+
(2026-11-06 jacky aesthetics v3, t_7fe64f08).
|
|
4
|
+
|
|
5
|
+
> jacky hand-laid the wage-price-spiral diagram: the shared node sits at the
|
|
6
|
+
> exact center of the figure, the remaining nodes of the two rings occupy
|
|
7
|
+
> four symmetric positions, and the whole figure is axis-symmetric up/down
|
|
8
|
+
> and left/right — "topology should decide the layout".
|
|
9
|
+
|
|
10
|
+
Trigger detection (deterministic hard-coded rules; mutually exclusive with
|
|
11
|
+
symlayout's shared-**edge** bowtie topology):
|
|
12
|
+
- Enumerate undirected simple rings (length >= 3, deduplicated by
|
|
13
|
+
canonical form; declared loops are **not** used — the declaration table
|
|
14
|
+
mixes in 2-node pseudo-rings and misses derived rings); exactly **2**
|
|
15
|
+
rings, the two ring node sets share exactly **1** node, and the union of
|
|
16
|
+
the two rings = all non-isolated nodes (no trigger when dangling nodes
|
|
17
|
+
exist — conservative fallback).
|
|
18
|
+
- Corpus hits: {eroding-goals, shifting-the-burden, wage-price-spiral}
|
|
19
|
+
(a directed-isomorphism family). No false positives: 08/10/17 also have
|
|
20
|
+
exactly 2 simple rings but share 2 nodes (shared-edge type, outside the
|
|
21
|
+
decision domain of symlayout's criteria, handled by existing patterns).
|
|
22
|
+
|
|
23
|
+
Placement (place_bowtie_vertex, cross-checked against the relative geometry
|
|
24
|
+
of jacky's hand-laid sample):
|
|
25
|
+
- The shared node sits at the canvas center (layout-layer axis x=0, y=0;
|
|
26
|
+
the delivery layer translates everything uniformly to center);
|
|
27
|
+
- Each ring occupies one half-plane (first ring above / second ring below;
|
|
28
|
+
ring order broken deterministically by kind R<B<other -> canonical
|
|
29
|
+
order); the remaining nodes of each ring sit on a horizontal row in the
|
|
30
|
+
same half-plane at mirrored left/right positions (triangle = +-D at
|
|
31
|
+
equal height, outer edge a horizontal straight line — matching the
|
|
32
|
+
sample's "top edge nearly horizontal");
|
|
33
|
+
- Left/right assignment = walk the ring from the shared node in the
|
|
34
|
+
"directed-edge-following" direction; the successor goes left, the next
|
|
35
|
+
one right (measured on the sample: wages left / cost right,
|
|
36
|
+
real_income left / demand right, same rule for both rings);
|
|
37
|
+
- Both rings share D and H -> the whole figure is strictly mirrored
|
|
38
|
+
up/down (an idealization of the sample's vertical mirror axis x~=293,
|
|
39
|
+
deviation 0px <= the 2px acceptance gate).
|
|
40
|
+
|
|
41
|
+
Routing (constructively injected by place_bowtie_vertex, not via the
|
|
42
|
+
candidate pool):
|
|
43
|
+
- All straight lines (line-style consistency hard rule: all 6 edges are
|
|
44
|
+
lines, no mixing of straight/arc/bend);
|
|
45
|
+
- Outer edges (ring edges that do not pass through the shared node) =
|
|
46
|
+
horizontal straight lines; the 4 spokes = straight lines;
|
|
47
|
+
- The two spoke anchors on the same side of the shared node spread
|
|
48
|
+
symmetrically +-SPREAD about the edge midpoint (multiple outgoing lines
|
|
49
|
+
of one node must not stack at a single point — 2026-11-06
|
|
50
|
+
connection-point convention hard rule 3).
|
|
51
|
+
|
|
52
|
+
Trial gate: reuses layout_auto._constructed_gates (box press / clearance /
|
|
53
|
+
anchor smoothness / card gap / inter-loop separation / crossings <= 2); any
|
|
54
|
+
failure silently falls back to the existing pattern chain (negative cases
|
|
55
|
+
byte-for-byte unchanged).
|
|
56
|
+
"""
|
|
57
|
+
import math
|
|
58
|
+
|
|
59
|
+
from . import columnlayout
|
|
60
|
+
from .routing import border_point, seg_cross
|
|
61
|
+
|
|
62
|
+
SYM_GAP = 36 # breathing-room target clearance (same tier as SYM_GAP/SIDE_GAP)
|
|
63
|
+
MAX_BOWTIE_N = 12 # trigger upper bound: small graphs
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class InfeasibleBowtieVertex(Exception):
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _adjacency(edges):
|
|
71
|
+
adj = {v: set() for a, b in edges for v in (a, b)}
|
|
72
|
+
for a, b in edges:
|
|
73
|
+
adj[a].add(b)
|
|
74
|
+
adj[b].add(a)
|
|
75
|
+
return adj
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _canon(seq):
|
|
79
|
+
best = None
|
|
80
|
+
for s in (list(seq), list(seq)[::-1]):
|
|
81
|
+
for i in range(len(s)):
|
|
82
|
+
rot = s[i:] + s[:i]
|
|
83
|
+
if best is None or rot < best:
|
|
84
|
+
best = rot
|
|
85
|
+
return tuple(best)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def detect_bowtie_vertex(graph, why=None):
|
|
89
|
+
"""Bowtie-vertex detection. Returns a plan dict or None.
|
|
90
|
+
|
|
91
|
+
plan = {"shared": shared node, "loops": [(kind, [c1..ck]), ...] (upper
|
|
92
|
+
ring first), "deg0": [isolated nodes]}
|
|
93
|
+
|
|
94
|
+
why: optional dict — receives a deterministic one-line reason for the
|
|
95
|
+
outcome (mode decision trace, certificate mode_decision_trace field).
|
|
96
|
+
"""
|
|
97
|
+
nodes = list(graph.node_ids())
|
|
98
|
+
edges = [(e.a, e.b) for e in graph.edges]
|
|
99
|
+
adj = _adjacency(edges)
|
|
100
|
+
core = [v for v in nodes if adj[v]]
|
|
101
|
+
deg0 = [v for v in nodes if not adj[v]]
|
|
102
|
+
if len(core) < 5 or len(core) > MAX_BOWTIE_N:
|
|
103
|
+
if why is not None:
|
|
104
|
+
why["reason"] = (f"core n={len(core)} "
|
|
105
|
+
f"(need 5..{MAX_BOWTIE_N})")
|
|
106
|
+
return None
|
|
107
|
+
cycles = columnlayout._enumerate_cycles(core, adj)
|
|
108
|
+
if len(cycles) != 2:
|
|
109
|
+
if why is not None:
|
|
110
|
+
why["reason"] = (f"{len(cycles)} simple loops enumerated "
|
|
111
|
+
"(need exactly 2)")
|
|
112
|
+
return None
|
|
113
|
+
s0, s1 = set(cycles[0]), set(cycles[1])
|
|
114
|
+
shared_set = s0 & s1
|
|
115
|
+
if len(shared_set) != 1:
|
|
116
|
+
if why is not None:
|
|
117
|
+
why["reason"] = (f"the 2 simple loops share {len(shared_set)} "
|
|
118
|
+
"nodes (need exactly 1 — shared-2 is the "
|
|
119
|
+
"shared-edge bowtie topology)")
|
|
120
|
+
return None
|
|
121
|
+
if s0 | s1 != set(core):
|
|
122
|
+
if why is not None:
|
|
123
|
+
why["reason"] = ("dangling core nodes outside the two rings — "
|
|
124
|
+
"conservatively no trigger")
|
|
125
|
+
return None
|
|
126
|
+
if why is not None:
|
|
127
|
+
why["reason"] = ("exactly 2 simple loops sharing exactly 1 node "
|
|
128
|
+
f"(shared={sorted(shared_set)[0]}), rings cover all "
|
|
129
|
+
f"{len(core)} core nodes")
|
|
130
|
+
shared = sorted(shared_set)[0]
|
|
131
|
+
eset = set(edges)
|
|
132
|
+
|
|
133
|
+
def _oriented(seq):
|
|
134
|
+
"""Ring order -> orientation starting at shared with more directed-edge follows; ties take the smaller canonical form."""
|
|
135
|
+
n = len(seq)
|
|
136
|
+
i = seq.index(shared)
|
|
137
|
+
|
|
138
|
+
def follows(o):
|
|
139
|
+
return sum(1 for k in range(n)
|
|
140
|
+
if (o[k], o[(k + 1) % n]) in eset)
|
|
141
|
+
o1 = [seq[(i + k) % n] for k in range(n)]
|
|
142
|
+
o2 = [seq[(i - k) % n] for k in range(n)]
|
|
143
|
+
c1, c2 = follows(o1), follows(o2)
|
|
144
|
+
if c1 != c2:
|
|
145
|
+
return o1 if c1 > c2 else o2
|
|
146
|
+
return o1 if _canon(o1) <= _canon(o2) else o2
|
|
147
|
+
|
|
148
|
+
kind_rank = {"R": 0, "B": 1}
|
|
149
|
+
entries = []
|
|
150
|
+
for seq in cycles:
|
|
151
|
+
kind = None
|
|
152
|
+
for lp in graph.loops:
|
|
153
|
+
if set(lp.nodes) == set(seq):
|
|
154
|
+
kind = getattr(lp, "kind", None)
|
|
155
|
+
break
|
|
156
|
+
entries.append((kind_rank.get(kind, 2), _canon(seq), kind,
|
|
157
|
+
_oriented(seq)[1:]))
|
|
158
|
+
entries.sort(key=lambda t: (t[0], t[1]))
|
|
159
|
+
loops = [(kind, rest) for _r, _c, kind, rest in entries]
|
|
160
|
+
return {"shared": shared, "loops": loops, "deg0": deg0}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def place_bowtie_vertex(plan, widths, card_h, cfg, edges):
|
|
164
|
+
"""Bowtie-vertex symmetric placement -> ({v: x}, {v: y}, meta, constructed_routes).
|
|
165
|
+
|
|
166
|
+
constructed_routes: {(a,b): route dict} — all-straight construction
|
|
167
|
+
(horizontal outer edges + direct spokes, shared-side anchors spread
|
|
168
|
+
symmetrically at +-SPREAD), constructively injected.
|
|
169
|
+
"""
|
|
170
|
+
shared = plan["shared"]
|
|
171
|
+
loops = plan["loops"]
|
|
172
|
+
ws = widths[shared]
|
|
173
|
+
# D: distance of the left/right vertices from the axis — sum of the two cards' half-widths + clearance (max over rings)
|
|
174
|
+
D = max((widths[seq[0]] + widths[seq[-1]]) / 2 + SYM_GAP
|
|
175
|
+
for _k, seq in loops)
|
|
176
|
+
D = int(round(D / 2) * 2)
|
|
177
|
+
# H: half-distance between ring row and shared row — breathing clearance
|
|
178
|
+
# >= SYM_GAP and >= D (near-square; sample radius ~196 / x ~135-150 / y ~123-145)
|
|
179
|
+
H = max(2 * card_h + SYM_GAP, D)
|
|
180
|
+
H = int(round(H / 2) * 2)
|
|
181
|
+
|
|
182
|
+
pos = {shared: 0}
|
|
183
|
+
y = {shared: 0}
|
|
184
|
+
for idx, (_kind, seq) in enumerate(loops):
|
|
185
|
+
s = -1 if idx == 0 else 1 # first ring upper half-plane, second lower
|
|
186
|
+
k = len(seq)
|
|
187
|
+
for j, v in enumerate(seq):
|
|
188
|
+
if k > 1:
|
|
189
|
+
x = -D + (2.0 * D) * j / (k - 1)
|
|
190
|
+
else:
|
|
191
|
+
x = 0.0
|
|
192
|
+
pos[v] = int(round(x / 2) * 2)
|
|
193
|
+
y[v] = s * H
|
|
194
|
+
|
|
195
|
+
if plan["deg0"]:
|
|
196
|
+
total = sum(widths[v] for v in plan["deg0"]) + 18 * (len(plan["deg0"]) + 1)
|
|
197
|
+
cur = -total / 2
|
|
198
|
+
for v in plan["deg0"]:
|
|
199
|
+
pos[v] = int(round((cur + widths[v] / 2) / 2) * 2)
|
|
200
|
+
y[v] = cfg.park_y
|
|
201
|
+
cur += widths[v] + 18
|
|
202
|
+
|
|
203
|
+
def _np(v):
|
|
204
|
+
return (pos[v], y[v], widths[v], card_h)
|
|
205
|
+
|
|
206
|
+
constructed = {}
|
|
207
|
+
eset = set(edges)
|
|
208
|
+
# Shared-node spokes: anchor = the shared card's side midpoint +-SPREAD (symmetric spread for the two rings; one
|
|
209
|
+
# node's multiple outgoing lines never stack — 2026-11-06 connection-point convention hard rule 3). Measured on
|
|
210
|
+
# the bowtie family: all 4 spokes are inter-loop pairs (bidirectional) — one line per pair would make both
|
|
211
|
+
# directions coincide point-for-point and necessarily FAIL the inter-loop separation gate. Inter-loop corridor:
|
|
212
|
+
# one parallel line per direction, anchors offset ±PAIR_T along the box edge tangent (U line = leftward travel,
|
|
213
|
+
# L line = reverse; same rule for both rings -> mirror-consistent), total side spread <= hh-3 (clamped inside
|
|
214
|
+
PAIR_T = 12
|
|
215
|
+
spoke_dirs = {} # endv -> [(a,b), ...] directed edges on that spoke
|
|
216
|
+
for _kind, seq in loops:
|
|
217
|
+
for endv in (seq[0], seq[-1]):
|
|
218
|
+
spoke_dirs[endv] = [e for e in ((shared, endv), (endv, shared))
|
|
219
|
+
if e in eset]
|
|
220
|
+
side_mutual = {side: any(len(spoke_dirs.get(v, [])) == 2
|
|
221
|
+
for _k, seq in loops for v in (seq[0], seq[-1])
|
|
222
|
+
if (1 if pos[v] > 0 else -1) == side)
|
|
223
|
+
for side in (-1, 1)}
|
|
224
|
+
spoke_anchor = {}
|
|
225
|
+
for endv, dirs in spoke_dirs.items():
|
|
226
|
+
side = -1 if pos[endv] < 0 else 1
|
|
227
|
+
s = -1 if any(endv in seq for _k, seq in loops[:1]) else 1
|
|
228
|
+
spread = 7 if side_mutual[side] else 14
|
|
229
|
+
spoke_anchor[endv] = [side * ws / 2, s * spread]
|
|
230
|
+
# t_0dedbfab: on a side hosting BOTH rings' mutual pairs, the per-ring
|
|
231
|
+
# slot pairs interleave when spread < PAIR_T (upper pair = {-spread-12,
|
|
232
|
+
# -spread+12}, lower pair = {spread-12, spread+12}; for spread 7 the
|
|
233
|
+
# merged slot order is -19, -5, +5, +19 while the per-ring assignment
|
|
234
|
+
# put the upper ring's incoming line at +5 BELOW the lower ring's
|
|
235
|
+
# outgoing line at -5 -- an incoming line landing below an outgoing line
|
|
236
|
+
# that travels into the same half-plane crosses it mid-air at the shared
|
|
237
|
+
# border; measured on shifting-the-burden problem box, visual_crossings
|
|
238
|
+
# 1 -> 0 after this fix). Assign the merged slot offsets sorted ascending
|
|
239
|
+
# in (upper-ring U, upper-ring L, lower-ring U, lower-ring L) order; for
|
|
240
|
+
# spread >= PAIR_T sides the sorted merge reproduces the per-ring
|
|
241
|
+
# assignment byte-for-byte (the pairs do not interleave), so non-mutual
|
|
242
|
+
# bowtie figures keep their delivered geometry (eroding-goals /
|
|
243
|
+
# wage-price-spiral sweep-verified byte-identical).
|
|
244
|
+
pair_slots = {}
|
|
245
|
+
_pside_endvs = {}
|
|
246
|
+
for endv, dirs in spoke_dirs.items():
|
|
247
|
+
if len(dirs) == 2:
|
|
248
|
+
_side = -1 if pos[endv] < 0 else 1
|
|
249
|
+
_pside_endvs.setdefault(_side, []).append(endv)
|
|
250
|
+
for _side, _endvs in _pside_endvs.items():
|
|
251
|
+
if len(_endvs) != 2:
|
|
252
|
+
continue
|
|
253
|
+
_spread = 7 if side_mutual[_side] else 14
|
|
254
|
+
_offs = sorted([-_spread - PAIR_T, -_spread + PAIR_T,
|
|
255
|
+
_spread - PAIR_T, _spread + PAIR_T])
|
|
256
|
+
_up = (_endvs[0] if any(_endvs[0] in seq for _k, seq in loops[:1])
|
|
257
|
+
else _endvs[1])
|
|
258
|
+
_low = _endvs[1] if _up == _endvs[0] else _endvs[0]
|
|
259
|
+
pair_slots[_up] = (_offs[0], _offs[1])
|
|
260
|
+
pair_slots[_low] = (_offs[2], _offs[3])
|
|
261
|
+
hh3 = card_h / 2 - 3
|
|
262
|
+
for endv, dirs in spoke_dirs.items():
|
|
263
|
+
if not dirs:
|
|
264
|
+
continue
|
|
265
|
+
nb = _np(endv)
|
|
266
|
+
base_sa = spoke_anchor[endv]
|
|
267
|
+
base_cb = border_point(nb[0], nb[1], nb[2] / 2, nb[3] / 2,
|
|
268
|
+
base_sa[0] - nb[0], base_sa[1] - nb[1])
|
|
269
|
+
on_x = abs(abs(base_cb[0] - nb[0]) - nb[2] / 2) <= \
|
|
270
|
+
abs(abs(base_cb[1] - nb[1]) - nb[3] / 2)
|
|
271
|
+
if len(dirs) == 2:
|
|
272
|
+
# Inter-loop corridor: U line (-t) = leftward travel direction; L line (+t) = reverse. Card-side anchor =
|
|
273
|
+
# the corridor center anchor truly spread ±PAIR_T along the owning box edge tangent (parallel separation
|
|
274
|
+
# >= 4px gate); when the card end is on a horizontal edge, pair "upper->near / lower->far" — the lower
|
|
275
|
+
# line starts lower and, if steeper, would flip above the upper line (crossing observed in practice);
|
|
276
|
+
# far-end pairing keeps the gap monotone; same rule for both rings -> up/down and left/right mirrored.
|
|
277
|
+
leftward = dirs[0] if pos[dirs[0][0]] > pos[dirs[0][1]] else dirs[1]
|
|
278
|
+
backward = dirs[1] if leftward == dirs[0] else dirs[0]
|
|
279
|
+
if endv in pair_slots:
|
|
280
|
+
_ou, _ol = pair_slots[endv]
|
|
281
|
+
else:
|
|
282
|
+
_ou, _ol = base_sa[1] - PAIR_T, base_sa[1] + PAIR_T
|
|
283
|
+
sa_u = [base_sa[0], max(-hh3, min(hh3, _ou))]
|
|
284
|
+
sa_l = [base_sa[0], max(-hh3, min(hh3, _ol))]
|
|
285
|
+
on_x = abs(abs(base_cb[0] - nb[0]) - nb[2] / 2) <= \
|
|
286
|
+
abs(abs(base_cb[1] - nb[1]) - nb[3] / 2)
|
|
287
|
+
if on_x:
|
|
288
|
+
half = nb[3] / 2 - 3
|
|
289
|
+
y_l = max(nb[1] - half, min(nb[1] + half, base_cb[1] - PAIR_T))
|
|
290
|
+
y_h = max(nb[1] - half, min(nb[1] + half, base_cb[1] + PAIR_T))
|
|
291
|
+
cb_u = [base_cb[0], y_l] # shifted ±t: always parallel, never crossing
|
|
292
|
+
cb_l = [base_cb[0], y_h]
|
|
293
|
+
else:
|
|
294
|
+
half = nb[2] / 2 - 3
|
|
295
|
+
x_a = max(nb[0] - half, min(nb[0] + half,
|
|
296
|
+
base_cb[0] - PAIR_T))
|
|
297
|
+
x_b = max(nb[0] - half, min(nb[0] + half,
|
|
298
|
+
base_cb[0] + PAIR_T))
|
|
299
|
+
# Anti-crossing pairing: upper/lower lines must keep their vertical order throughout — upper->near in
|
|
300
|
+
# the upper half-plane, upper->far in the lower (uniform deterministic "swap-if-intersecting" rule)
|
|
301
|
+
cb_a, cb_b = [x_a, base_cb[1]], [x_b, base_cb[1]]
|
|
302
|
+
if seg_cross(sa_u, cb_a, sa_l, cb_b):
|
|
303
|
+
cb_a, cb_b = cb_b, cb_a
|
|
304
|
+
cb_u, cb_l = cb_a, cb_b
|
|
305
|
+
constructed[leftward] = {
|
|
306
|
+
"kind": "line",
|
|
307
|
+
"points": ([sa_u, cb_u] if leftward[0] == shared
|
|
308
|
+
else [cb_u, sa_u])}
|
|
309
|
+
constructed[backward] = {
|
|
310
|
+
"kind": "line",
|
|
311
|
+
"points": ([sa_l, cb_l] if backward[0] == shared
|
|
312
|
+
else [cb_l, sa_l])}
|
|
313
|
+
else:
|
|
314
|
+
e = dirs[0]
|
|
315
|
+
a, b = e
|
|
316
|
+
pa = base_sa if a == shared else base_cb
|
|
317
|
+
pb = base_cb if a == shared else base_sa
|
|
318
|
+
constructed[e] = {"kind": "line", "points": [pa, pb]}
|
|
319
|
+
# Outer edges (ring edges not passing through the shared node): horizontal straight line (k=2) or same-chord straight line (k>2)
|
|
320
|
+
for _kind, seq in loops:
|
|
321
|
+
for j in range(len(seq) - 1):
|
|
322
|
+
a, b = seq[j], seq[j + 1]
|
|
323
|
+
for e in ((a, b), (b, a)):
|
|
324
|
+
if e not in eset:
|
|
325
|
+
continue
|
|
326
|
+
na, nb = _np(a), _np(b)
|
|
327
|
+
pa = border_point(na[0], na[1], na[2] / 2, na[3] / 2,
|
|
328
|
+
nb[0] - na[0], nb[1] - na[1])
|
|
329
|
+
pb = border_point(nb[0], nb[1], nb[2] / 2, nb[3] / 2,
|
|
330
|
+
na[0] - nb[0], na[1] - nb[1])
|
|
331
|
+
constructed[e] = {"kind": "line", "points": [pa, pb]}
|
|
332
|
+
break
|
|
333
|
+
mirror_pairs = []
|
|
334
|
+
if len(loops) == 2 and len(loops[0][1]) == len(loops[1][1]):
|
|
335
|
+
mirror_pairs = [[loops[0][1][j], loops[1][1][j]]
|
|
336
|
+
for j in range(len(loops[0][1]))]
|
|
337
|
+
meta = {
|
|
338
|
+
"placement": "bowtie-vertex",
|
|
339
|
+
"symmetry": {
|
|
340
|
+
"axis": "vertical",
|
|
341
|
+
"axis_x": 0,
|
|
342
|
+
"axis_y": 0,
|
|
343
|
+
"shared_node": shared,
|
|
344
|
+
"loops": [[shared] + list(seq) for _k, seq in loops],
|
|
345
|
+
"mirror_pairs": mirror_pairs,
|
|
346
|
+
"gap": SYM_GAP,
|
|
347
|
+
"line_style_rule": "all-straight (horizontal outer edges + spokes)",
|
|
348
|
+
},
|
|
349
|
+
}
|
|
350
|
+
return pos, y, meta, constructed
|