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/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> <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:]))
|