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/render3d.py ADDED
@@ -0,0 +1,165 @@
1
+ # -*- coding: utf-8 -*-
2
+ """3D rendering layer: layout3d results -> standalone HTML (three.js r128,
3
+ zero network dependency).
4
+
5
+ three.js is referenced relatively via assets/three.min.js (in the spirit of
6
+ zero inline dependencies: shared with the viewer from one in-repo assets
7
+ directory, avoiding re-inlining 600KB into every demo).
8
+ Visual spec: nodes = double-sided rendered sphere sprites + depth-based
9
+ shading falloff, chord edges = two-color polarity tubes ('+' amber / '−' sky,
10
+ all solid) bowed outward (single-arc Bezier hugging the spherical shell) with
11
+ a cone arrowhead at every edge end (the tube body is trimmed back near the
12
+ tip so the cone owns the endpoint), solid wireframe reference, ground grid,
13
+ OrbitControls.
14
+ """
15
+ import html
16
+ import json
17
+
18
+
19
+ def render_3d_html(graph_dict: dict, layouts: dict, graph_name: str = "",
20
+ cert_note: str = "") -> str:
21
+ """layouts: {solid: layout3d result dict}. One button per solid to switch."""
22
+ data = json.dumps({"name": graph_name,
23
+ "nodes": graph_dict["nodes"],
24
+ "edges": graph_dict["edges"],
25
+ "layouts": {k: v["positions"] for k, v in layouts.items()}},
26
+ ensure_ascii=False)
27
+ solids = list(layouts.keys())
28
+ buttons = "".join(
29
+ f'<button onclick="setSolid(\'{s}\')" id="btn-{s}">{s}</button>'
30
+ for s in solids)
31
+ return '''<!doctype html><html lang="zh"><meta charset="utf-8">
32
+ <title>stdraw 3D · ''' + html.escape(graph_name) + '''</title>
33
+ <style>
34
+ body{margin:0;font-family:Microsoft YaHei,sans-serif;background:#0b1220;color:#e2e8f0}
35
+ #bar{position:fixed;top:10px;left:10px;z-index:10;display:flex;gap:6px;align-items:center}
36
+ button{background:#1e293b;color:#e2e8f0;border:1px solid #334155;border-radius:6px;padding:4px 12px;cursor:pointer}
37
+ button.on{background:#2563eb;border-color:#2563eb}
38
+ #info{position:fixed;bottom:10px;left:10px;font-size:12px;color:#94a3b8;z-index:10}
39
+ #legend{position:fixed;bottom:10px;right:10px;font-size:13px;z-index:10}
40
+ #c{position:fixed;inset:0}
41
+ </style>
42
+ <div id="bar"><b>stdraw 3D · ''' + html.escape(graph_name) + '''</b> ''' + buttons + '''</div>
43
+ <div id="legend"><span style="color:#fbbf24">━ +</span>&nbsp;&nbsp;<span style="color:#7dd3fc">━ −</span></div>
44
+ <div id="c"></div>
45
+ <div id="info">drag to rotate / wheel to zoom / right-drag to pan · deterministic placement (same graph, same layout) · ''' + html.escape(cert_note) + '''</div>
46
+ <script src="../assets/three.min.js"></script>
47
+ <script src="../assets/OrbitControls.js"></script>
48
+ <script>
49
+ const DATA = ''' + data + ''';
50
+ const scene = new THREE.Scene();
51
+ scene.background = new THREE.Color(0x0b1220);
52
+ scene.fog = new THREE.Fog(0x0b1220, 900, 2200);
53
+ const camera = new THREE.PerspectiveCamera(50, innerWidth/innerHeight, 1, 5000);
54
+ const renderer = new THREE.WebGLRenderer({antialias:true, preserveDrawingBuffer:true});
55
+ renderer.setSize(innerWidth, innerHeight);
56
+ document.getElementById('c').appendChild(renderer.domElement);
57
+ const controls = new THREE.OrbitControls(camera, renderer.domElement);
58
+ camera.position.set(0, 240, 1250);
59
+ controls.target.set(0, 0, 0);
60
+ controls.update();
61
+ controls.autoRotate = false;
62
+ scene.add(new THREE.GridHelper(1600, 32, 0x1e293b, 0x16233b));
63
+ scene.add(new THREE.AmbientLight(0xffffff, 0.7));
64
+ const dl = new THREE.DirectionalLight(0xffffff, 0.6); dl.position.set(400, 600, 300); scene.add(dl);
65
+
66
+ let group = null;
67
+ function wireframeBox(solid, size){
68
+ const g = new THREE.Group();
69
+ let geo = null;
70
+ if (solid === 'sphere') geo = new THREE.SphereGeometry(size, 16, 12);
71
+ else if (solid === 'cube') geo = new THREE.BoxGeometry(size*1.15, size*1.15, size*1.15);
72
+ else if (solid === 'tetra'){
73
+ const s = size*1.6;
74
+ const v = [new THREE.Vector3(s,s,s), new THREE.Vector3(s,-s,-s),
75
+ new THREE.Vector3(-s,s,-s), new THREE.Vector3(-s,-s,s)];
76
+ geo = new THREE.BufferGeometry().setFromPoints(
77
+ [v[0],v[1],v[2],v[0],v[2],v[3],v[0],v[3],v[1],v[1],v[2],v[3]].map(p=>p));
78
+ } else if (solid === 'cone'){
79
+ geo = new THREE.ConeGeometry(size*0.9, size*2.4, 24, 6, true);
80
+ geo.translate(0, -size*0.2, 0);
81
+ }
82
+ if (geo){
83
+ const wf = new THREE.LineSegments(new THREE.EdgesGeometry(geo, 12),
84
+ new THREE.LineBasicMaterial({color:0x334155, transparent:true, opacity:0.9}));
85
+ g.add(wf);
86
+ }
87
+ return g;
88
+ }
89
+ function draw(solid){
90
+ if (group) scene.remove(group);
91
+ group = new THREE.Group();
92
+ const pos = DATA.layouts[solid];
93
+ const size = Math.max(...Object.values(pos).flat().map(Math.abs));
94
+ group.add(wireframeBox(solid, size));
95
+ const idx = {}; DATA.nodes.forEach((n,i)=>idx[n.id]=i);
96
+ // nodes: sphere sprites + label sprites (depth shading adjusts opacity by camera distance in the render loop)
97
+ const sprites = [];
98
+ DATA.nodes.forEach(n=>{
99
+ const p = pos[n.id];
100
+ const m = new THREE.Mesh(new THREE.SphereGeometry(14, 16, 12),
101
+ new THREE.MeshBasicMaterial({color:0xf59e0b, transparent:true, opacity:0.95}));
102
+ m.position.set(p[0], p[1], p[2]);
103
+ group.add(m);
104
+ const cv = document.createElement('canvas');
105
+ cv.width = 256; cv.height = 64;
106
+ const ctx = cv.getContext('2d');
107
+ ctx.font = '28px Microsoft YaHei';
108
+ ctx.fillStyle = '#f1f5f9';
109
+ ctx.textAlign = 'center';
110
+ ctx.fillText(n.label, 128, 40);
111
+ const sp = new THREE.Sprite(new THREE.SpriteMaterial({map:new THREE.CanvasTexture(cv), transparent:true}));
112
+ sp.scale.set(150, 38, 1);
113
+ sp.position.set(p[0], p[1] + 30, p[2]);
114
+ group.add(sp); sprites.push(sp);
115
+ });
116
+ // edges: two-color polarity tubes (jacky 2026-11-09 caliber: color = the
117
+ // edge's own sign, all solid) + a cone arrowhead at every edge end; the tube
118
+ // body is trimmed back near the tip so the cone owns the endpoint and the
119
+ // direction stays legible when zoomed out. Bright shades of the 2D palette
120
+ // (#d97706/#0284c7) for the dark scene background.
121
+ const TUBE_RAD = 3.2, CONE_RAD = 8.0, CONE_LEN = 30;
122
+ window.__stdraw3dEdges = [];
123
+ DATA.edges.forEach(e=>{
124
+ const a = new THREE.Vector3(...pos[e.a]);
125
+ const b = new THREE.Vector3(...pos[e.b]);
126
+ const mid = a.clone().add(b).multiplyScalar(0.5);
127
+ const out = mid.length() < 1 ? a.clone().normalize() : mid.clone().normalize();
128
+ const ctrl = mid.add(out.multiplyScalar(mid.length()*0.18 + 20));
129
+ const curve = new THREE.QuadraticBezierCurve3(a, ctrl, b);
130
+ const polCol = e.p === '-' ? 0x7dd3fc : 0xfbbf24; // '−' sky / '+' amber
131
+ const polHex = e.p === '-' ? '#7dd3fc' : '#fbbf24';
132
+ const bodyPts = curve.getPoints(24).slice(0, 22); // t=0..0.875: tube stops before the cone zone
133
+ const body = new THREE.CatmullRomCurve3(bodyPts);
134
+ group.add(new THREE.Mesh(new THREE.TubeGeometry(body, 24, TUBE_RAD, 8, false),
135
+ new THREE.MeshBasicMaterial({color: polCol})));
136
+ const tangent = curve.getTangent(1).normalize();
137
+ const cone = new THREE.Mesh(new THREE.ConeGeometry(CONE_RAD, CONE_LEN, 12),
138
+ new THREE.MeshBasicMaterial({color: polCol}));
139
+ cone.position.copy(b.clone().sub(tangent.clone().multiplyScalar(CONE_LEN/2)));
140
+ cone.quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0), tangent);
141
+ group.add(cone);
142
+ window.__stdraw3dEdges.push({a:e.a, b:e.b, pol:e.p, color:polHex,
143
+ tube:true, cone:true, dir:[tangent.x, tangent.y, tangent.z]});
144
+ });
145
+ scene.add(group);
146
+ document.querySelectorAll('#bar button').forEach(b=>b.classList.toggle('on', b.id==='btn-'+solid));
147
+ }
148
+ function setSolid(s){ draw(s); }
149
+ addEventListener('resize', ()=>{
150
+ camera.aspect = innerWidth/innerHeight; camera.updateProjectionMatrix();
151
+ renderer.setSize(innerWidth, innerHeight);
152
+ });
153
+ (function loop(){
154
+ requestAnimationFrame(loop);
155
+ if (group) group.children.forEach(ch=>{ // depth shading: distant elements fade
156
+ if (ch.material && ch.material.opacity !== undefined){
157
+ const d = camera.position.distanceTo(ch.position instanceof THREE.Vector3 ? ch.position : new THREE.Vector3());
158
+ const f = Math.max(0.25, Math.min(1, 1.25 - d/1800));
159
+ if (ch.material.transparent) ch.material.opacity = (ch instanceof THREE.Sprite ? 0.95 : 0.9) * f;
160
+ }
161
+ });
162
+ controls.update(); renderer.render(scene, camera);
163
+ })();
164
+ draw(''' + repr(solids[0]) + ''');
165
+ </script></html>'''
stdraw/rendercheck.py ADDED
@@ -0,0 +1,90 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Bounding-box ground-truth layer (1, CI side): after real rendering, measure actual DOM bounds with a headless browser.
3
+
4
+ Together with textmetrics (conservative measurement on the layout side) this forms two-layer verification -- neither layer may be missing when claiming completion:
5
+ - Layout side: plan bounds with worst-case measurement using the real font + conservative padding (textmetrics.card_size);
6
+ - CI side: this module measures DOM `getBBox()` per card after real rendering and compares against the planned bounds;
7
+ actual text width exceeding the usable width inside the box (box width - 2*PAD_SIDE_MIN) fails --
8
+ the certificate now measures rendered ground truth rather than an estimate.
9
+
10
+ Usage:
11
+ python -m stdraw.rendercheck <outdir> # measure outdir/demo-2d.html vs layout.json
12
+ Exit codes: 0 all pass; 1 overflow found; 2 playwright unavailable (honest skip, counted as neither pass nor failure).
13
+
14
+ Dependency: `pip install stdraw[render-check]` (playwright; first run needs playwright install chromium).
15
+ """
16
+ import json
17
+ import sys
18
+
19
+ from .textmetrics import PAD_SIDE_MIN
20
+
21
+
22
+ def check_dom_bounds(html_path: str, layout: dict, tol: float = 0.5):
23
+ """Open the demo in headless chromium via playwright; measure text getBBox per card against the bounds.
24
+
25
+ Returns (problems, checked_n). problems: [{node, text_w, avail_w}].
26
+ """
27
+ try:
28
+ from playwright.sync_api import sync_playwright
29
+ except Exception:
30
+ return None, 0
31
+ meta = layout["meta"]
32
+ fs_title = meta.get("fs", [12.5, 14.0])[0]
33
+ problems = []
34
+ try:
35
+ with sync_playwright() as p:
36
+ browser = p.chromium.launch()
37
+ page = browser.new_page()
38
+ page.goto(f"file://{html_path}")
39
+ page.wait_for_timeout(600)
40
+ data = page.evaluate("""() => {
41
+ const out = [];
42
+ document.querySelectorAll('svg g').forEach(g => {
43
+ const rect = g.querySelector('rect');
44
+ const text = g.querySelector('text');
45
+ if (rect && text) {
46
+ const tb = text.getBBox();
47
+ out.push({text: text.textContent,
48
+ textW: tb.width,
49
+ boxX: +rect.getAttribute('x'),
50
+ boxW: +rect.getAttribute('width')});
51
+ }
52
+ });
53
+ return out;
54
+ }""")
55
+ browser.close()
56
+ except Exception:
57
+ return None, 0
58
+ id2n = {v: p_ for v, p_ in layout["nodes"].items()}
59
+ # label text in the demo -> node (layout key order matches rendering)
60
+ labels = {nd.get("label", ""): v for v, nd in
61
+ (layout.get("_labels") or {}).items()}
62
+ for row in data:
63
+ avail = row["boxW"] - 2 * PAD_SIDE_MIN
64
+ if row["textW"] > avail + tol:
65
+ problems.append({"node": row["text"], "text_w": round(row["textW"], 1),
66
+ "avail_w": round(avail, 1)})
67
+ return problems, len(data)
68
+
69
+
70
+ def main(argv):
71
+ if len(argv) < 1:
72
+ print("usage: python -m stdraw.rendercheck <outdir>", file=sys.stderr)
73
+ return 2
74
+ import os
75
+ outdir = argv[0]
76
+ with open(os.path.join(outdir, "layout.json"), encoding="utf-8") as f:
77
+ layout = json.load(f)
78
+ problems, n = check_dom_bounds(os.path.abspath(
79
+ os.path.join(outdir, "demo-2d.html")), layout)
80
+ if problems is None:
81
+ print("SKIP: playwright unavailable (pip install stdraw[render-check] to enable)")
82
+ return 2
83
+ print(f"{n} cards DOM-measured, overflows {len(problems)}")
84
+ for pr in problems:
85
+ print(" OVERFLOW", pr)
86
+ return 1 if problems else 0
87
+
88
+
89
+ if __name__ == "__main__":
90
+ sys.exit(main(sys.argv[1:]))