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/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}}