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/delivery.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Delivery-contract metadata for stdraw coordinate outputs.
|
|
3
|
+
|
|
4
|
+
stdraw's deliverable is the interactive demo page (demo-2d.html: drag
|
|
5
|
+
touch-up layer + embedded quality certificate), not bare coordinates.
|
|
6
|
+
Every coordinate dict produced by the layout pipelines (ortho-v1 /
|
|
7
|
+
auto-v2) therefore carries an explicit delivery contract:
|
|
8
|
+
|
|
9
|
+
"delivery" "coordinates-only" | "stdraw-cli"
|
|
10
|
+
"interactive" false | true
|
|
11
|
+
"engine_version" stdraw.__version__ of the producing engine
|
|
12
|
+
"delivery_warning" one-line warning (coordinates-only mode only)
|
|
13
|
+
|
|
14
|
+
The contract is additive metadata: hosts that only read known keys
|
|
15
|
+
(nodes/edges/viewBox/meta) keep working unchanged. Hosts that build
|
|
16
|
+
their own static renderer from the coordinates lose live drag
|
|
17
|
+
re-routing, waypoint handles, the export->recompute loop and the honest
|
|
18
|
+
quality certificate -- the warning says so at the point of consumption.
|
|
19
|
+
|
|
20
|
+
Layering:
|
|
21
|
+
- pipeline cores (planar.PlanarLayout.run / layout_auto.layout) stamp
|
|
22
|
+
coordinates-only metadata;
|
|
23
|
+
- the CLI re-stamps to "stdraw-cli" / interactive=true when the full
|
|
24
|
+
deliverable set (demo-2d.html + certificate.json) was produced, and
|
|
25
|
+
omits the warning;
|
|
26
|
+
- the ortho CLI strips the metadata before writing layout.json: the
|
|
27
|
+
ortho-v1 layout.json artifact is a byte-frozen production pin
|
|
28
|
+
(tests/baselines/, ci_checks gate A) and must stay byte-stable; its
|
|
29
|
+
delivery contract travels on certificate.json instead (the
|
|
30
|
+
"interactive" / "drag_layer" fields).
|
|
31
|
+
|
|
32
|
+
Consumers who truly need raw coordinates can silence the warning via
|
|
33
|
+
stdraw.recipes.run(..., suppress_delivery_warning=True) -- an explicit,
|
|
34
|
+
transparent opt-out.
|
|
35
|
+
"""
|
|
36
|
+
from . import __version__
|
|
37
|
+
|
|
38
|
+
DELIVERY_KEYS = ("delivery", "interactive", "engine_version",
|
|
39
|
+
"delivery_warning")
|
|
40
|
+
|
|
41
|
+
DELIVERY_WARNING = (
|
|
42
|
+
"coordinates-only output: this file is layout coordinates, not the "
|
|
43
|
+
"stdraw deliverable. The stdraw deliverable is demo-2d.html (drag "
|
|
44
|
+
"touch-up layer + embedded quality certificate); produce it with "
|
|
45
|
+
"`stdraw layout <input.cld>` and embed that page (see README, "
|
|
46
|
+
"Agent Integration). Building a static renderer from these "
|
|
47
|
+
"coordinates loses live drag re-routing, waypoint handles, the "
|
|
48
|
+
"export->recompute loop and the quality certificate.")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def stamp_coordinates_only(v4: dict, warning: bool = True) -> dict:
|
|
52
|
+
"""Stamp the coordinates-only delivery contract onto a layout dict.
|
|
53
|
+
|
|
54
|
+
Additive: delivery keys are appended after the existing keys; existing
|
|
55
|
+
keys and their order are untouched (backward compatible, deterministic).
|
|
56
|
+
"""
|
|
57
|
+
v4["delivery"] = "coordinates-only"
|
|
58
|
+
v4["interactive"] = False
|
|
59
|
+
v4["engine_version"] = __version__
|
|
60
|
+
if warning:
|
|
61
|
+
v4["delivery_warning"] = DELIVERY_WARNING
|
|
62
|
+
else:
|
|
63
|
+
v4.pop("delivery_warning", None)
|
|
64
|
+
return v4
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def stamp_cli_deliverable(v4: dict) -> dict:
|
|
68
|
+
"""Re-stamp a layout dict produced alongside the full CLI deliverable
|
|
69
|
+
(demo-2d.html + certificate.json): interactive, no warning."""
|
|
70
|
+
v4["delivery"] = "stdraw-cli"
|
|
71
|
+
v4["interactive"] = True
|
|
72
|
+
v4["engine_version"] = __version__
|
|
73
|
+
v4.pop("delivery_warning", None)
|
|
74
|
+
return v4
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def strip_delivery_metadata(v4: dict) -> dict:
|
|
78
|
+
"""Remove delivery-contract keys (ortho CLI freeze-compat path)."""
|
|
79
|
+
for k in DELIVERY_KEYS:
|
|
80
|
+
v4.pop(k, None)
|
|
81
|
+
return v4
|
stdraw/dsl.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""stdraw DSL v0.1 (draft) parser — the minimal closed loop.
|
|
3
|
+
|
|
4
|
+
Syntax (line-oriented, # starts a comment, blank lines ignored):
|
|
5
|
+
|
|
6
|
+
graph "graph name"
|
|
7
|
+
var <id> "<label>" [@value "<value text>"] [@group <gid>] [@w <extra px>]
|
|
8
|
+
<a> -> <+|-> <b> [@loops <id1>,<id2>]
|
|
9
|
+
loop <id> "<label>" "<R|B>" [<n1>, <n2>, ...]
|
|
10
|
+
group <gid> "<group label>"
|
|
11
|
+
|
|
12
|
+
- Variables must be declared before use; polarity edge pairs are the input to
|
|
13
|
+
layout and certificates;
|
|
14
|
+
- Loop declarations state semantic loops, validated by loops.lint for closure
|
|
15
|
+
+ R/B polarity edge counting;
|
|
16
|
+
- Groups are optional and do not participate in layout (tension handling is
|
|
17
|
+
discussed in the model.py module docstring).
|
|
18
|
+
|
|
19
|
+
Parsing is deterministic: the same text always yields the same Graph.
|
|
20
|
+
"""
|
|
21
|
+
import re
|
|
22
|
+
from .model import Graph, Node, Edge, Loop, Group
|
|
23
|
+
|
|
24
|
+
_VAR_RE = re.compile(
|
|
25
|
+
r'^var\s+(\w+)\s+"([^"]*)"(?:\s+@value\s+"([^"]*)")?'
|
|
26
|
+
r'(?:\s+@group\s+(\w+))?(?:\s+@w\s+(\d+))?\s*$')
|
|
27
|
+
_EDGE_RE = re.compile(
|
|
28
|
+
r'^(\w+)\s*->\s*([+\-])\s*(\w+)(?:\s+@loops\s+([\w,\s]+))?\s*$')
|
|
29
|
+
_LOOP_RE = re.compile(
|
|
30
|
+
r'^loop\s+(\w+)\s+"([^"]*)"\s+"?([RB])"?\s*\[([^\]]*)\]\s*$')
|
|
31
|
+
_GROUP_RE = re.compile(r'^group\s+(\w+)\s+"([^"]*)"\s*$')
|
|
32
|
+
_GRAPH_RE = re.compile(r'^graph\s+"([^"]*)"\s*$')
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse(text: str) -> Graph:
|
|
36
|
+
g = Graph(nodes=[], edges=[], loops=[], groups=[], name="")
|
|
37
|
+
for lineno, raw in enumerate(text.splitlines(), 1):
|
|
38
|
+
line = raw.split("#", 1)[0].strip()
|
|
39
|
+
if not line:
|
|
40
|
+
continue
|
|
41
|
+
m = _GRAPH_RE.match(line)
|
|
42
|
+
if m:
|
|
43
|
+
g.name = m.group(1)
|
|
44
|
+
continue
|
|
45
|
+
m = _VAR_RE.match(line)
|
|
46
|
+
if m:
|
|
47
|
+
g.nodes.append(Node(id=m.group(1), label=m.group(2),
|
|
48
|
+
value=m.group(3) or "",
|
|
49
|
+
group=m.group(4) or None,
|
|
50
|
+
extra_w=int(m.group(5) or 0)))
|
|
51
|
+
continue
|
|
52
|
+
m = _EDGE_RE.match(line)
|
|
53
|
+
if m:
|
|
54
|
+
loops = [x.strip() for x in (m.group(4) or "").split(",") if x.strip()]
|
|
55
|
+
g.edges.append(Edge(a=m.group(1), b=m.group(3),
|
|
56
|
+
polarity=m.group(2), loops=loops))
|
|
57
|
+
continue
|
|
58
|
+
m = _LOOP_RE.match(line)
|
|
59
|
+
if m:
|
|
60
|
+
ns = [x.strip() for x in m.group(4).split(",") if x.strip()]
|
|
61
|
+
g.loops.append(Loop(id=m.group(1), label=m.group(2),
|
|
62
|
+
nodes=ns, kind=m.group(3)))
|
|
63
|
+
continue
|
|
64
|
+
m = _GROUP_RE.match(line)
|
|
65
|
+
if m:
|
|
66
|
+
g.groups.append(Group(id=m.group(1), label=m.group(2)))
|
|
67
|
+
continue
|
|
68
|
+
raise ValueError(f"DSL line {lineno} cannot be parsed: {raw.strip()!r}")
|
|
69
|
+
problems = g.validate()
|
|
70
|
+
if problems:
|
|
71
|
+
raise ValueError("DSL structural validation failed:\n " + "\n ".join(problems))
|
|
72
|
+
return g
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def dumps(g: Graph) -> str:
|
|
76
|
+
"""Graph -> DSL text (round-trip consistent: parse(dumps(g)) == g)."""
|
|
77
|
+
out = []
|
|
78
|
+
if g.name:
|
|
79
|
+
out.append(f'graph "{g.name}"')
|
|
80
|
+
out.append("")
|
|
81
|
+
for n in g.nodes:
|
|
82
|
+
parts = [f'var {n.id} "{n.label}"']
|
|
83
|
+
if n.value:
|
|
84
|
+
parts.append(f'@value "{n.value}"')
|
|
85
|
+
if n.group:
|
|
86
|
+
parts.append(f"@group {n.group}")
|
|
87
|
+
if n.extra_w:
|
|
88
|
+
parts.append(f"@w {n.extra_w}")
|
|
89
|
+
out.append(" ".join(parts))
|
|
90
|
+
out.append("")
|
|
91
|
+
for e in g.edges:
|
|
92
|
+
tail = f" @loops {','.join(e.loops)}" if e.loops else ""
|
|
93
|
+
out.append(f"{e.a} -> {e.polarity} {e.b}{tail}")
|
|
94
|
+
out.append("")
|
|
95
|
+
for lp in g.loops:
|
|
96
|
+
out.append(f'loop {lp.id} "{lp.label}" "{lp.kind}" [{", ".join(lp.nodes)}]')
|
|
97
|
+
for gr in g.groups:
|
|
98
|
+
out.append(f'group {gr.id} "{gr.label}"')
|
|
99
|
+
return "\n".join(out) + "\n"
|
stdraw/layout3d.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""3D placement layer (the "lowest-tier" delivery form): nodes arranged in regular solids.
|
|
3
|
+
|
|
4
|
+
Five-solid slot generation + deterministic assignment; arbitrary-angle rotation, depth shading, wireframe reference.
|
|
5
|
+
|
|
6
|
+
Solids: sphere (Fibonacci sphere) / cube (lattice points) / tetra (tetrahedron face mesh) /
|
|
7
|
+
cone (concentric rings shrinking to the apex). Assignment: greedy nearest neighbor + pairwise-swap optimization minimizing total edge length
|
|
8
|
+
(deterministic: slot order, assignment order, and swap order all fixed; no RNG).
|
|
9
|
+
|
|
10
|
+
Chords not intersecting in space is guaranteed by dimensional redundancy; projection crossings are resolved by the camera in the user's hands --
|
|
11
|
+
the 3D layer makes no 2D crossing promise (honest tiering).
|
|
12
|
+
"""
|
|
13
|
+
import math
|
|
14
|
+
from typing import Dict, List, Tuple
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ── Slot generation (all deterministic, unit coordinates, uniformly scaled after generation) ──────────
|
|
18
|
+
def sphere_slots(n: int) -> List[Tuple[float, float, float]]:
|
|
19
|
+
pts = []
|
|
20
|
+
golden = math.pi * (3 - math.sqrt(5))
|
|
21
|
+
for i in range(n):
|
|
22
|
+
y = 1 - (i / max(n - 1, 1)) * 2
|
|
23
|
+
r = math.sqrt(max(0.0, 1 - y * y))
|
|
24
|
+
th = golden * i
|
|
25
|
+
pts.append((math.cos(th) * r, y, math.sin(th) * r))
|
|
26
|
+
return pts
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def cube_slots(n: int) -> List[Tuple[float, float, float]]:
|
|
30
|
+
m = max(2, math.ceil(n ** (1 / 3)))
|
|
31
|
+
pts = []
|
|
32
|
+
for z in range(m):
|
|
33
|
+
for y in range(m):
|
|
34
|
+
for x in range(m):
|
|
35
|
+
pts.append(((x / (m - 1)) * 2 - 1,
|
|
36
|
+
(y / (m - 1)) * 2 - 1,
|
|
37
|
+
(z / (m - 1)) * 2 - 1))
|
|
38
|
+
return pts[:n]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_TETRA_V = [(1, 1, 1), (1, -1, -1), (-1, 1, -1), (-1, -1, 1)]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def tetra_slots(n: int) -> List[Tuple[float, float, float]]:
|
|
45
|
+
"""Tetrahedron face mesh: barycentric layer sweep (each layer lies on the 4 faces, min component = 0)."""
|
|
46
|
+
pts = []
|
|
47
|
+
s = 3
|
|
48
|
+
while len(pts) < n and s < 60:
|
|
49
|
+
layer = []
|
|
50
|
+
for i in range(s + 1):
|
|
51
|
+
for j in range(s + 1 - i):
|
|
52
|
+
for k in range(s + 1 - i - j):
|
|
53
|
+
l = s - i - j - k
|
|
54
|
+
b = (i, j, k, l)
|
|
55
|
+
if min(b) == 0: # on a face
|
|
56
|
+
x = sum(c * v[0] for c, v in zip(b, _TETRA_V)) / s
|
|
57
|
+
y = sum(c * v[1] for c, v in zip(b, _TETRA_V)) / s
|
|
58
|
+
z = sum(c * v[2] for c, v in zip(b, _TETRA_V)) / s
|
|
59
|
+
layer.append((x, y, z))
|
|
60
|
+
layer.sort() # deterministic order
|
|
61
|
+
pts.extend(layer)
|
|
62
|
+
s += 1
|
|
63
|
+
# fallback: truncate when full; if short, top up with a Fibonacci sphere (recorded honestly)
|
|
64
|
+
if len(pts) >= n:
|
|
65
|
+
return pts[:n]
|
|
66
|
+
extra = sphere_slots(n - len(pts))
|
|
67
|
+
return pts + extra
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def cone_slots(n: int) -> List[Tuple[float, float, float]]:
|
|
71
|
+
"""Cone: 1 apex point + concentric rings going down (radius scaling up linearly, uniform angles within a ring)."""
|
|
72
|
+
pts = [(0.0, 1.0, 0.0)]
|
|
73
|
+
if n == 1:
|
|
74
|
+
return pts
|
|
75
|
+
rings = max(1, round(math.sqrt(n - 1)))
|
|
76
|
+
per = max(1, (n - 1) // rings)
|
|
77
|
+
t = 0
|
|
78
|
+
placed = 1
|
|
79
|
+
while placed < n:
|
|
80
|
+
frac = (t + 1) / rings
|
|
81
|
+
r = frac
|
|
82
|
+
y = 1 - frac * 2
|
|
83
|
+
cnt = min(per, n - placed) if placed + per < n else n - placed
|
|
84
|
+
cnt = max(1, cnt)
|
|
85
|
+
for k in range(cnt):
|
|
86
|
+
ang = 2 * math.pi * k / cnt + 0.5 * t # inter-layer offset (deterministic)
|
|
87
|
+
pts.append((math.cos(ang) * r, y, math.sin(ang) * r))
|
|
88
|
+
placed += cnt
|
|
89
|
+
t += 1
|
|
90
|
+
return pts[:n]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
SOLIDS = {"sphere": sphere_slots, "cube": cube_slots,
|
|
94
|
+
"tetra": tetra_slots, "cone": cone_slots}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ── Deterministic assignment: greedy nearest neighbor + pairwise-swap optimization ────────────────────
|
|
98
|
+
def assign(nodes: List[str], slots: List[Tuple[float, float, float]],
|
|
99
|
+
edges: List[Tuple[str, str]]) -> Dict[str, Tuple[float, float, float]]:
|
|
100
|
+
"""Nodes -> slots. Greedy (fixed node order) + all-pairs swap passes (swap only on positive gain, fixed order)."""
|
|
101
|
+
avail = list(range(len(slots)))
|
|
102
|
+
m = {v: None for v in nodes}
|
|
103
|
+
# coarse ordering across components: node order by adjacency-chain BFS order (deterministic), isolated nodes last
|
|
104
|
+
adj: Dict[str, List[str]] = {v: [] for v in nodes}
|
|
105
|
+
for a, b in edges:
|
|
106
|
+
adj.setdefault(a, []).append(b)
|
|
107
|
+
adj.setdefault(b, []).append(a)
|
|
108
|
+
order, seen = [], set()
|
|
109
|
+
for v in nodes:
|
|
110
|
+
if v in seen or not adj[v]:
|
|
111
|
+
continue
|
|
112
|
+
stack = [v]
|
|
113
|
+
while stack:
|
|
114
|
+
u = stack.pop()
|
|
115
|
+
if u in seen:
|
|
116
|
+
continue
|
|
117
|
+
seen.add(u)
|
|
118
|
+
order.append(u)
|
|
119
|
+
for w in sorted(adj[u], reverse=True):
|
|
120
|
+
if w not in seen:
|
|
121
|
+
stack.append(w)
|
|
122
|
+
order += [v for v in nodes if v not in seen]
|
|
123
|
+
|
|
124
|
+
def edge_len_sum():
|
|
125
|
+
return sum(math.dist(m[a], m[b]) for a, b in edges
|
|
126
|
+
if m.get(a) and m.get(b))
|
|
127
|
+
|
|
128
|
+
for v in order:
|
|
129
|
+
if not avail:
|
|
130
|
+
break
|
|
131
|
+
# greedy: pick the slot minimizing the increment in assigned edge length (ties resolved toward the smaller index, deterministic)
|
|
132
|
+
best, best_cost = None, None
|
|
133
|
+
for si in avail:
|
|
134
|
+
c = 0.0
|
|
135
|
+
for w in adj[v]:
|
|
136
|
+
if m.get(w) is not None:
|
|
137
|
+
c += math.dist(slots[si], m[w])
|
|
138
|
+
if best_cost is None or c < best_cost - 1e-12:
|
|
139
|
+
best, best_cost = si, c
|
|
140
|
+
m[v] = slots[best]
|
|
141
|
+
avail.remove(best)
|
|
142
|
+
# pairwise-swap optimization (at most 4 passes)
|
|
143
|
+
node_list = order
|
|
144
|
+
for _ in range(4):
|
|
145
|
+
improved = False
|
|
146
|
+
for i in range(len(node_list)):
|
|
147
|
+
for j in range(i + 1, len(node_list)):
|
|
148
|
+
a, b = node_list[i], node_list[j]
|
|
149
|
+
m[a], m[b] = m[b], m[a]
|
|
150
|
+
if edge_len_sum() < edge_len_sum() - 1e-9:
|
|
151
|
+
improved = True
|
|
152
|
+
else:
|
|
153
|
+
m[a], m[b] = m[b], m[a]
|
|
154
|
+
if not improved:
|
|
155
|
+
break
|
|
156
|
+
return m
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def layout3d(graph_dict: dict, solid: str = "sphere", scale: float = 300.0):
|
|
160
|
+
"""graph_dict: {"nodes":[{id,label}], "edges":[{a,b}]} -> {"positions": {...}}"""
|
|
161
|
+
gen = SOLIDS[solid]
|
|
162
|
+
ids = [n["id"] for n in graph_dict["nodes"]]
|
|
163
|
+
edges = [(e["a"], e["b"]) for e in graph_dict["edges"]]
|
|
164
|
+
slots = gen(len(ids))
|
|
165
|
+
m = assign(ids, slots, edges)
|
|
166
|
+
return {"solid": solid, "scale": scale,
|
|
167
|
+
"positions": {v: [round(c * scale, 1) for c in m[v]] for v in ids}}
|