/ challenges-old / challenge-tools
Sign in
challenges-old / challenge-tools
Interactive 3D geometry view: viewer.js (raw WebGL) renders a challenge's scene.json on its results page
Jeremy Magland <jmagland@flatironinstitute.org> committed commit f99e1d076509 parent 68a49aa Browse files
4 changed files+371−9
README.mdmodified+2−0View file
@@ -33,6 +33,8 @@ A directory `challenges/<challenge-id>/` with a `README.md` (the specification)
3333 }
3434 ```
3535
36+A challenge directory may also hold a `scene.json`, an interactive 3D view of the geometry that `build` copies to the site and embeds on the challenge page with `tools/challenge_tools/viewer.js` (raw WebGL, no dependencies; drag to rotate, wheel to zoom, right-drag to pan). Its format: `surfaces`, each with `vertices` (a list of `[x, y, z]`) and either `faces` (triangles as vertex indices) or `grid: [n1, n2]` for vertices listed in row-major order over a structured grid (`periodic: true` closes the second index), optionally with a per-vertex `scalar` (`name`, `values`) drawn with a colormap; `points`, each with `name`, `position`, optional `radius`, `color` and `far: true` for points outside the default framing such as a listener; `lines` with `from` and `to`; and `units` and `note` strings. The challenge repository generates the file from its own parameters; the tools do not know any geometry.
37+
3638 `fields` is optional and describes the grid of the optional field export: datasets named after `axes` plus a dataset `u` of the given shape. `reference_solution` overrides the automatic reference with a `repo/submission/solution` key.
3739
3840 ## Scoring and reference selection
tests/test_tools.pymodified+19−0View file
@@ -97,3 +97,22 @@ def test_fields_check_axes(tmp_path):
9797 ptr.write_text("version https://git-lfs.github.com/spec/v1\noid sha256:abc\nsize 1\n")
9898 assert is_lfs_pointer(ptr)
9999 assert check_fields_file(ptr, ch) == []
100+
101+
102+def test_build_embeds_scene(tmp_path):
103+ import json
104+ from challenge_tools.build import build
105+
106+ cdir = tmp_path / "challenges" / "demo-v1"
107+ cdir.mkdir(parents=True)
108+ (cdir / "README.md").write_text("# demo")
109+ json.dump({"challenge_id": "demo-v1", "output": {"sample_rate_hz": 48000, "num_samples": 10, "channels": 1, "sample_format": "float32", "signal_scale": 1.0},
110+ "metrics": {"primary": "waveform_l2", "waveform_l2": {}}, "reference_solution": None}, open(cdir / "challenge.json", "w"))
111+ json.dump({"surfaces": [{"vertices": [[0, 0, 0], [1, 0, 0], [0, 1, 0]], "faces": [[0, 1, 2]]}]}, open(cdir / "scene.json", "w"))
112+ out = tmp_path / "site"
113+ build(tmp_path / "challenges", [], out)
114+ assert (out / "demo-v1" / "scene.json").exists()
115+ page = (out / "demo-v1" / "index.html").read_text()
116+ assert "scene.json" in page and "viewer.js" in page
117+ assert "3D model" in (out / "index.html").read_text()
118+ assert "SceneViewer" in (out / "viewer.js").read_text()
tools/challenge_tools/build.pymodified+27−9View file
@@ -141,7 +141,7 @@ def _link(url, text):
141141 return f'<a href="{html.escape(url)}">{html.escape(text)}</a>'
142142
143143
144-def render_challenge_page(res: dict, challenge: dict, links: dict) -> str:
144+def render_challenge_page(res: dict, challenge: dict, links: dict, scene: bool = False) -> str:
145145 names = res["metrics"]
146146 primary = res["primary_metric"]
147147 head = "".join(f"<th>{html.escape(n)}{' (primary)' if n == primary else ''}</th>" for n in names)
@@ -203,6 +203,7 @@ def render_challenge_page(res: dict, challenge: dict, links: dict) -> str:
203203 <h1>{html.escape(res["challenge_id"])}</h1>
204204 <p>{html.escape(res.get("title") or "")}. {"<a href='" + html.escape(spec_link) + "'>Challenge specification</a>. " if spec_link else ""}<a href="results.json">results.json</a>. Generated {html.escape(res["generated"])}.</p>
205205 <p>Errors are relative to the reference solution <b>{html.escape(res["reference"] or "none")}</b>, chosen by rule <i>{html.escape(sel["rule"])}</i>. Rows are sorted by the primary metric. Submissions are named as <i>repository/submission</i>.</p>
206+{"<h3 id='geometry'>Geometry</h3><div class='cscene' data-src='scene.json'></div>" if scene else ""}
206207 <div id="playback"></div>
207208 <div class="wrap"><table>
208209 <tr><th>Submission</th><th>Solution</th><th>Method</th>{head}<th>Time (s)</th><th>Hardware</th><th>Claimed more accurate than</th><th>Output</th></tr>
@@ -217,6 +218,7 @@ def render_challenge_page(res: dict, challenge: dict, links: dict) -> str:
217218 </main>
218219 <script src="../player.js?v={player_version()}"></script>
219220 <script>ChallengePlayer.controls(document.getElementById("playback"), {{gainDb: {playback_gain}, limiter: true}}); ChallengePlayer.mount();</script>
221+{"<script src='../viewer.js?v=" + asset_version("viewer.js") + "'></script><script>SceneViewer.mount();</script>" if scene else ""}
220222 </body></html>
221223 """
222224
@@ -224,7 +226,8 @@ def render_challenge_page(res: dict, challenge: dict, links: dict) -> str:
224226 def render_index(results: list[dict], sources: list[Source], links: dict) -> str:
225227 items = "".join(
226228 f'<li><a href="{html.escape(r["challenge_id"])}/">{html.escape(r["challenge_id"])}</a>: {html.escape(r.get("title") or "")} '
227- f'<span class="muted">({len(r["solutions"])} solutions, reference {html.escape(r["reference"] or "none")})</span></li>'
229+ f'<span class="muted">({len(r["solutions"])} solutions, reference {html.escape(r["reference"] or "none")})</span>'
230+ f'{" <a href=\"" + html.escape(r["challenge_id"]) + "/#geometry\">3D model</a>" if r.get("scene") else ""}</li>'
228231 for r in results
229232 )
230233 srcs = "".join(
@@ -263,22 +266,37 @@ def build(challenges_dir: Path, sources: list[Source], out: Path, challenge_repo
263266 with open(cdir / "results.json", "w") as f:
264267 json.dump(res, f, indent=1)
265268 links = {"spec": f"{challenge_repo_url}/blob/main/challenges/{cid}/README.md" if challenge_repo_url else None}
266- (cdir / "index.html").write_text(render_challenge_page(res, ch, links))
269+ scene = challenges_dir / cid / "scene.json"
270+ if scene.exists():
271+ shutil.copyfile(scene, cdir / "scene.json")
272+ res["scene"] = "scene.json"
273+ (cdir / "index.html").write_text(render_challenge_page(res, ch, links, scene=scene.exists()))
267274 results.append(res)
268275 (out / "index.html").write_text(render_index(results, sources, {"challenge_repo": challenge_repo_url}))
269276 (out / "player.js").write_text(player_js())
277+ (out / "viewer.js").write_text(asset_text("viewer.js"))
270278 return results
271279
272280
273-def player_version() -> str:
274- """Short hash of the player script, used as a cache-busting query on the script tag."""
281+def asset_text(name: str) -> str:
282+ """Contents of a static asset shipped with the package (player.js, viewer.js)."""
283+ return (Path(__file__).parent / name).read_text()
284+
285+
286+def asset_version(name: str) -> str:
287+ """Short hash of an asset, used as a cache-busting query on its script tag."""
275288 import hashlib
276289
277- return hashlib.sha1(player_js().encode()).hexdigest()[:10]
290+ return hashlib.sha1(asset_text(name).encode()).hexdigest()[:10]
291+
292+
293+def player_version() -> str:
294+ return asset_version("player.js")
278295
279296
280297 def player_js() -> str:
281- """The Web Audio player shipped with every site (also copied by solution repositories' site builders)."""
282- from importlib import resources
298+ return asset_text("player.js")
299+
283300
284- return resources.files("challenge_tools").joinpath("player.js").read_text()
301+def viewer_js() -> str:
302+ return asset_text("viewer.js")
tools/challenge_tools/viewer.jsadded+323−0View file
@@ -0,0 +1,323 @@
1+/* Interactive 3D view of a challenge geometry, without dependencies (raw WebGL).
2+ *
3+ * Usage: <div class="cscene" data-src="scene.json"></div> ... SceneViewer.mount();
4+ *
5+ * scene.json: {
6+ * "units": "m",
7+ * "surfaces": [{"name": ..., "vertices": [[x,y,z], ...],
8+ * "faces": [[a,b,c], ...] // or, for a structured grid of vertices in row-major order,
9+ * "grid": [n1, n2], "periodic": true, // faces are generated; periodic closes the second index
10+ * "scalar": {"name": ..., "values": [...]}}], // optional per-vertex color
11+ * "points": [{"name": ..., "position": [x,y,z], "radius": r, "far": true}], // "far" points are outside the default framing
12+ * "lines": [{"from": [x,y,z], "to": [x,y,z]}],
13+ * "up": "z"
14+ * }
15+ * Left drag rotates, wheel zooms, right or shift drag pans; touch: one finger rotates, two fingers zoom and pan.
16+ */
17+(function () {
18+ "use strict";
19+
20+ const VS = `
21+ attribute vec3 aPos; attribute vec3 aNrm; attribute float aVal;
22+ uniform mat4 uProj, uView; uniform vec3 uOffset; uniform float uScale, uZ;
23+ varying vec3 vNrm; varying float vVal; varying vec3 vPosV;
24+ void main() {
25+ vec3 p = (aPos * vec3(1.0, 1.0, uZ)) * uScale + uOffset;
26+ vec4 pv = uView * vec4(p, 1.0);
27+ vPosV = pv.xyz; vNrm = mat3(uView) * normalize(aNrm * vec3(1.0, 1.0, 1.0 / max(uZ, 1e-6))); vVal = aVal;
28+ gl_Position = uProj * pv;
29+ }`;
30+ const FS = `
31+ precision mediump float;
32+ varying vec3 vNrm; varying float vVal; varying vec3 vPosV;
33+ uniform vec3 uColor; uniform float uUseMap; uniform float uAlpha;
34+ vec3 cmap(float t) { // viridis, 6 anchors
35+ vec3 c0 = vec3(0.267, 0.005, 0.329), c1 = vec3(0.283, 0.141, 0.458), c2 = vec3(0.254, 0.265, 0.530),
36+ c3 = vec3(0.164, 0.471, 0.558), c4 = vec3(0.128, 0.567, 0.551), c5 = vec3(0.993, 0.906, 0.144);
37+ t = clamp(t, 0.0, 1.0) * 5.0;
38+ if (t < 1.0) return mix(c0, c1, t); if (t < 2.0) return mix(c1, c2, t - 1.0); if (t < 3.0) return mix(c2, c3, t - 2.0);
39+ if (t < 4.0) return mix(c3, c4, t - 3.0); return mix(c4, c5, t - 4.0);
40+ }
41+ void main() {
42+ vec3 n = normalize(vNrm); vec3 v = normalize(-vPosV);
43+ if (dot(n, v) < 0.0) n = -n;
44+ vec3 l1 = normalize(vec3(0.4, 0.5, 1.0)), l2 = normalize(vec3(-0.6, -0.3, 0.4));
45+ float diff = 0.25 + 0.6 * max(dot(n, l1), 0.0) + 0.25 * max(dot(n, l2), 0.0);
46+ float spec = pow(max(dot(reflect(-l1, n), v), 0.0), 40.0) * 0.25;
47+ vec3 base = mix(uColor, cmap(vVal), uUseMap);
48+ gl_FragColor = vec4(base * diff + spec, uAlpha);
49+ }`;
50+
51+ function compile(gl, type, src) {
52+ const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s);
53+ if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
54+ return s;
55+ }
56+ function program(gl) {
57+ const p = gl.createProgram();
58+ gl.attachShader(p, compile(gl, gl.VERTEX_SHADER, VS)); gl.attachShader(p, compile(gl, gl.FRAGMENT_SHADER, FS));
59+ gl.linkProgram(p);
60+ if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));
61+ return p;
62+ }
63+
64+ // --- small matrix helpers (column-major 4x4)
65+ function perspective(fovy, aspect, near, far) {
66+ const f = 1 / Math.tan(fovy / 2), nf = 1 / (near - far);
67+ return [f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, (far + near) * nf, -1, 0, 0, 2 * far * near * nf, 0];
68+ }
69+ function lookAt(eye, target, up) {
70+ let z = sub(eye, target); z = norm(z);
71+ let x = norm(cross(up, z)); const y = cross(z, x);
72+ return [x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0,
73+ -dot(x, eye), -dot(y, eye), -dot(z, eye), 1];
74+ }
75+ function mulMV(m, v) { // 4x4 times [x,y,z,1]
76+ return [m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12], m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13],
77+ m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14], m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15]];
78+ }
79+ const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
80+ const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
81+ const scl = (a, s) => [a[0] * s, a[1] * s, a[2] * s];
82+ const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
83+ const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
84+ const norm = (a) => { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; };
85+
86+ // --- geometry
87+ function gridFaces(n1, n2, periodic) {
88+ const f = [];
89+ const m2 = periodic ? n2 : n2 - 1;
90+ for (let i = 0; i < n1 - 1; i++) for (let j = 0; j < m2; j++) {
91+ const j1 = (j + 1) % n2, a = i * n2 + j, b = i * n2 + j1, c = (i + 1) * n2 + j, d = (i + 1) * n2 + j1;
92+ f.push([a, b, d], [a, d, c]);
93+ }
94+ return f;
95+ }
96+ function normals(verts, faces, zs) {
97+ const n = new Float32Array(verts.length * 3);
98+ for (const [a, b, c] of faces) {
99+ const pa = verts[a], pb = verts[b], pc = verts[c];
100+ const e1 = [pb[0] - pa[0], pb[1] - pa[1], (pb[2] - pa[2]) * zs], e2 = [pc[0] - pa[0], pc[1] - pa[1], (pc[2] - pa[2]) * zs];
101+ const fn = cross(e1, e2);
102+ for (const k of [a, b, c]) { n[3 * k] += fn[0]; n[3 * k + 1] += fn[1]; n[3 * k + 2] += fn[2]; }
103+ }
104+ for (let k = 0; k < verts.length; k++) {
105+ const l = Math.hypot(n[3 * k], n[3 * k + 1], n[3 * k + 2]) || 1;
106+ n[3 * k] /= l; n[3 * k + 1] /= l; n[3 * k + 2] /= l;
107+ }
108+ return n;
109+ }
110+ function sphere(nu, nv) {
111+ const v = [], f = [];
112+ for (let i = 0; i <= nu; i++) { const th = Math.PI * i / nu; for (let j = 0; j < nv; j++) { const ph = 2 * Math.PI * j / nv; v.push([Math.sin(th) * Math.cos(ph), Math.sin(th) * Math.sin(ph), Math.cos(th)]); } }
113+ return { vertices: v, faces: gridFaces(nu + 1, nv, true) };
114+ }
115+ function edges(faces) {
116+ const set = new Set(), out = [];
117+ for (const [a, b, c] of faces) for (const [p, q] of [[a, b], [b, c], [c, a]]) {
118+ const key = p < q ? p * 4294967296 + q : q * 4294967296 + p;
119+ if (!set.has(key)) { set.add(key); out.push(p, q); }
120+ }
121+ return new Uint32Array(out);
122+ }
123+
124+ function makeMesh(gl, verts, faces, values) {
125+ const pos = new Float32Array(verts.length * 3);
126+ verts.forEach((p, k) => { pos[3 * k] = p[0]; pos[3 * k + 1] = p[1]; pos[3 * k + 2] = p[2]; });
127+ const idx = new Uint32Array(faces.length * 3);
128+ faces.forEach((t, k) => { idx[3 * k] = t[0]; idx[3 * k + 1] = t[1]; idx[3 * k + 2] = t[2]; });
129+ let val = new Float32Array(verts.length), useMap = 0, vmin = 0, vmax = 1;
130+ if (values) {
131+ vmin = Math.min(...values); vmax = Math.max(...values);
132+ const d = vmax > vmin ? vmax - vmin : 1;
133+ values.forEach((x, k) => { val[k] = (x - vmin) / d; });
134+ useMap = 1;
135+ }
136+ const buf = (data, target) => { const b = gl.createBuffer(); gl.bindBuffer(target || gl.ARRAY_BUFFER, b); gl.bufferData(target || gl.ARRAY_BUFFER, data, gl.STATIC_DRAW); return b; };
137+ const m = { n: idx.length, verts, faces, pos: buf(pos), nrm: buf(normals(verts, faces, 1)), val: buf(val), idx: buf(idx, gl.ELEMENT_ARRAY_BUFFER),
138+ edges: null, nEdges: 0, useMap, vmin, vmax, zs: 1 };
139+ m.setZ = (zs) => { if (zs === m.zs) return; m.zs = zs; gl.bindBuffer(gl.ARRAY_BUFFER, m.nrm); gl.bufferData(gl.ARRAY_BUFFER, normals(verts, faces, zs), gl.STATIC_DRAW); };
140+ m.ensureEdges = () => { if (!m.edges) { const e = edges(faces); m.edges = buf(e, gl.ELEMENT_ARRAY_BUFFER); m.nEdges = e.length; } };
141+ return m;
142+ }
143+
144+ function fmt(x) { const a = Math.abs(x); return a >= 100 || a === 0 ? x.toFixed(0) : a >= 10 ? x.toFixed(1) : a >= 1 ? x.toFixed(2) : x.toPrecision(3); }
145+
146+ function mountOne(el) {
147+ const src = el.dataset.src;
148+ el.classList.add("cscene-box");
149+ el.innerHTML = '<div class="cscene-bar"></div><div class="cscene-canvas"><canvas></canvas><div class="cscene-labels"></div></div><div class="cscene-note muted"></div>';
150+ const bar = el.querySelector(".cscene-bar"), wrap = el.querySelector(".cscene-canvas"), canvas = el.querySelector("canvas"), labels = el.querySelector(".cscene-labels"), note = el.querySelector(".cscene-note");
151+ const gl = canvas.getContext("webgl", { antialias: true, alpha: false });
152+ if (!gl) { note.textContent = "WebGL is not available in this browser."; return; }
153+ gl.getExtension("OES_element_index_uint");
154+ fetch(src).then((r) => { if (!r.ok) throw new Error(r.status + " " + r.statusText); return r.json(); }).then((scene) => setup(scene)).catch((e) => { note.textContent = "Could not load " + src + ": " + e.message; });
155+
156+ function setup(scene) {
157+ const prog = program(gl);
158+ gl.useProgram(prog);
159+ const loc = (n) => gl.getUniformLocation(prog, n);
160+ const aPos = gl.getAttribLocation(prog, "aPos"), aNrm = gl.getAttribLocation(prog, "aNrm"), aVal = gl.getAttribLocation(prog, "aVal");
161+ const meshes = (scene.surfaces || []).map((s) => {
162+ const faces = s.faces || gridFaces(s.grid[0], s.grid[1], !!s.periodic);
163+ const m = makeMesh(gl, s.vertices, faces, s.scalar ? s.scalar.values : null);
164+ m.name = s.name || ""; m.scalarName = s.scalar ? s.scalar.name : null;
165+ return m;
166+ });
167+ const sph = sphere(12, 18);
168+ const ball = makeMesh(gl, sph.vertices, sph.faces, null);
169+ const points = (scene.points || []).map((p) => ({ name: p.name || "", pos: p.position, r: p.radius || 0, far: !!p.far, color: p.color || [0.85, 0.2, 0.15] }));
170+ const lines = (scene.lines || []).map((l) => [l.from, l.to]);
171+
172+ // bounds of the near scene (surfaces and non-far points) and of everything
173+ const bounds = (includeFar) => {
174+ let lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
175+ const take = (p) => { for (let k = 0; k < 3; k++) { lo[k] = Math.min(lo[k], p[k]); hi[k] = Math.max(hi[k], p[k]); } };
176+ meshes.forEach((m) => m.verts.forEach(take));
177+ points.forEach((p) => { if (includeFar || !p.far) take(p.pos); });
178+ if (lo[0] === Infinity) { lo = [-1, -1, -1]; hi = [1, 1, 1]; }
179+ const c = scl(add(lo, hi), 0.5), size = Math.max(hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]) || 1;
180+ return { c, size };
181+ };
182+ const near = bounds(false);
183+ const cam = { target: near.c.slice(), dist: near.size * 1.6, az: -0.9, el: 0.45, fov: 0.6 };
184+ const state = { wire: false, far: false, map: false, zs: 1 };
185+ const reset = () => { const b = bounds(state.far); cam.target = b.c.slice(); cam.dist = b.size * 1.6; cam.az = -0.9; cam.el = 0.45; };
186+
187+ // controls
188+ const ctrl = [];
189+ const check = (label, key, onchange) => {
190+ const id = "cs" + Math.random().toString(36).slice(2, 8);
191+ const l = document.createElement("label"); l.innerHTML = `<input type="checkbox" id="${id}"> ${label}`;
192+ l.querySelector("input").addEventListener("change", (e) => { state[key] = e.target.checked; if (onchange) onchange(); draw(); });
193+ bar.appendChild(l); ctrl.push(l);
194+ };
195+ check("wireframe", "wire");
196+ const legend = meshes.find((m) => m.useMap);
197+ let lg = null;
198+ if (legend) {
199+ lg = document.createElement("span"); lg.className = "cscene-legend"; lg.style.display = "none";
200+ lg.innerHTML = `${legend.scalarName || "value"}: ${fmt(legend.vmin)} <span class="cscene-grad"></span> ${fmt(legend.vmax)}`;
201+ check("color by " + (legend.scalarName || "value"), "map", () => { lg.style.display = state.map ? "" : "none"; });
202+ bar.appendChild(lg);
203+ }
204+ if (points.some((p) => p.far)) check("show far points (" + points.filter((p) => p.far).map((p) => p.name).join(", ") + ")", "far", reset);
205+ const zsel = document.createElement("label"); zsel.innerHTML = 'z scale <select><option>1</option><option>2</option><option>4</option><option>8</option></select>';
206+ zsel.querySelector("select").addEventListener("change", (e) => { state.zs = parseFloat(e.target.value); meshes.forEach((m) => m.setZ(state.zs)); draw(); });
207+ bar.appendChild(zsel);
208+ const rb = document.createElement("button"); rb.textContent = "reset view"; rb.addEventListener("click", () => { reset(); draw(); }); bar.appendChild(rb);
209+ note.textContent = "Drag to rotate, wheel to zoom, right-drag or shift-drag to pan." + (scene.units ? " Units: " + scene.units + "." : "") + (scene.note ? " " + scene.note : "");
210+
211+ // interaction
212+ let drag = null;
213+ canvas.addEventListener("contextmenu", (e) => e.preventDefault());
214+ canvas.addEventListener("pointerdown", (e) => { drag = { x: e.clientX, y: e.clientY, pan: e.button === 2 || e.button === 1 || e.shiftKey }; canvas.setPointerCapture(e.pointerId); });
215+ canvas.addEventListener("pointerup", () => { drag = null; });
216+ canvas.addEventListener("pointermove", (e) => {
217+ if (!drag) return;
218+ const dx = e.clientX - drag.x, dy = e.clientY - drag.y; drag.x = e.clientX; drag.y = e.clientY;
219+ if (drag.pan) pan(dx, dy); else { cam.az -= dx * 0.008; cam.el = Math.max(-1.5, Math.min(1.5, cam.el + dy * 0.008)); }
220+ draw();
221+ });
222+ canvas.addEventListener("wheel", (e) => { e.preventDefault(); cam.dist *= Math.exp(e.deltaY * 0.0015); draw(); }, { passive: false });
223+ let pinch = null;
224+ canvas.addEventListener("touchstart", (e) => { if (e.touches.length === 2) pinch = { d: Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY), x: (e.touches[0].clientX + e.touches[1].clientX) / 2, y: (e.touches[0].clientY + e.touches[1].clientY) / 2 }; }, { passive: true });
225+ canvas.addEventListener("touchmove", (e) => {
226+ if (e.touches.length === 2 && pinch) {
227+ e.preventDefault();
228+ const d = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
229+ const x = (e.touches[0].clientX + e.touches[1].clientX) / 2, y = (e.touches[0].clientY + e.touches[1].clientY) / 2;
230+ cam.dist *= pinch.d / d; pan(x - pinch.x, y - pinch.y); pinch = { d, x, y }; drag = null; draw();
231+ }
232+ }, { passive: false });
233+ canvas.addEventListener("touchend", () => { pinch = null; });
234+ function eye() { return add(cam.target, scl([Math.cos(cam.el) * Math.cos(cam.az), Math.cos(cam.el) * Math.sin(cam.az), Math.sin(cam.el)], cam.dist)); }
235+ function pan(dx, dy) {
236+ const e = eye(), f = norm(sub(cam.target, e)), r = norm(cross(f, [0, 0, 1])), u = cross(r, f);
237+ const k = cam.dist * Math.tan(cam.fov / 2) * 2 / canvas.clientHeight;
238+ cam.target = add(cam.target, add(scl(r, -dx * k), scl(u, dy * k)));
239+ }
240+
241+ function bind(m) {
242+ gl.bindBuffer(gl.ARRAY_BUFFER, m.pos); gl.enableVertexAttribArray(aPos); gl.vertexAttribPointer(aPos, 3, gl.FLOAT, false, 0, 0);
243+ gl.bindBuffer(gl.ARRAY_BUFFER, m.nrm); gl.enableVertexAttribArray(aNrm); gl.vertexAttribPointer(aNrm, 3, gl.FLOAT, false, 0, 0);
244+ gl.bindBuffer(gl.ARRAY_BUFFER, m.val); gl.enableVertexAttribArray(aVal); gl.vertexAttribPointer(aVal, 1, gl.FLOAT, false, 0, 0);
245+ }
246+ function draw() {
247+ const dpr = window.devicePixelRatio || 1, w = wrap.clientWidth, h = wrap.clientHeight;
248+ if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) { canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); }
249+ gl.viewport(0, 0, canvas.width, canvas.height);
250+ gl.clearColor(0.97, 0.97, 0.98, 1); gl.enable(gl.DEPTH_TEST); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
251+ const e = eye(), view = lookAt(e, cam.target, [0, 0, 1]), proj = perspective(cam.fov, w / h, cam.dist * 0.01, cam.dist * 100);
252+ gl.uniformMatrix4fv(loc("uProj"), false, proj); gl.uniformMatrix4fv(loc("uView"), false, view);
253+ gl.uniform1f(loc("uAlpha"), 1);
254+ // surfaces
255+ gl.uniform3f(loc("uOffset"), 0, 0, 0); gl.uniform1f(loc("uScale"), 1); gl.uniform1f(loc("uZ"), state.zs);
256+ for (const m of meshes) {
257+ bind(m); gl.uniform1f(loc("uUseMap"), state.map ? m.useMap : 0); gl.uniform3f(loc("uColor"), 0.80, 0.62, 0.30);
258+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, m.idx);
259+ gl.enable(gl.POLYGON_OFFSET_FILL); gl.polygonOffset(1, 1);
260+ gl.drawElements(gl.TRIANGLES, m.n, gl.UNSIGNED_INT, 0);
261+ gl.disable(gl.POLYGON_OFFSET_FILL);
262+ if (state.wire) { m.ensureEdges(); gl.uniform1f(loc("uUseMap"), 0); gl.uniform3f(loc("uColor"), 0.15, 0.15, 0.2); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, m.edges); gl.drawElements(gl.LINES, m.nEdges, gl.UNSIGNED_INT, 0); }
263+ }
264+ // points as spheres (a minimum screen-relative radius keeps small footprints visible)
265+ bind(ball); gl.uniform1f(loc("uUseMap"), 0); gl.uniform1f(loc("uZ"), 1); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ball.idx);
266+ for (const p of points) {
267+ if (p.far && !state.far) continue;
268+ const r = Math.max(p.r, cam.dist * 0.006);
269+ gl.uniform3f(loc("uColor"), p.color[0], p.color[1], p.color[2]);
270+ gl.uniform3f(loc("uOffset"), p.pos[0], p.pos[1], p.pos[2] * state.zs); gl.uniform1f(loc("uScale"), r);
271+ gl.drawElements(gl.TRIANGLES, ball.n, gl.UNSIGNED_INT, 0);
272+ }
273+ // lines and axes
274+ const segs = [];
275+ for (const [a, b] of lines) if (state.far || !(points.some((p) => p.far && (same(p.pos, a) || same(p.pos, b))))) segs.push(a, b);
276+ const L = near.size * 0.35;
277+ segs.push(near.c, add(near.c, [L, 0, 0]), near.c, add(near.c, [0, L, 0]), near.c, add(near.c, [0, 0, L / state.zs]));
278+ if (segs.length) {
279+ const arr = new Float32Array(segs.length * 3); segs.forEach((p, k) => { arr[3 * k] = p[0]; arr[3 * k + 1] = p[1]; arr[3 * k + 2] = p[2]; });
280+ const b = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, b); gl.bufferData(gl.ARRAY_BUFFER, arr, gl.STREAM_DRAW);
281+ gl.vertexAttribPointer(aPos, 3, gl.FLOAT, false, 0, 0); gl.disableVertexAttribArray(aNrm); gl.vertexAttrib3f(aNrm, 0, 0, 1); gl.disableVertexAttribArray(aVal); gl.vertexAttrib1f(aVal, 0);
282+ gl.uniform3f(loc("uOffset"), 0, 0, 0); gl.uniform1f(loc("uScale"), 1); gl.uniform1f(loc("uZ"), state.zs); gl.uniform3f(loc("uColor"), 0.35, 0.35, 0.4);
283+ gl.drawArrays(gl.LINES, 0, segs.length);
284+ gl.enableVertexAttribArray(aNrm); gl.enableVertexAttribArray(aVal); gl.deleteBuffer(b);
285+ }
286+ // labels
287+ const items = points.filter((p) => state.far || !p.far).map((p) => ({ name: p.name, pos: [p.pos[0], p.pos[1], p.pos[2] * state.zs] }));
288+ items.push({ name: "x", pos: add(near.c, [L, 0, 0]) }, { name: "y", pos: add(near.c, [0, L, 0]) }, { name: "z", pos: add(near.c, [0, 0, L]) });
289+ labels.innerHTML = "";
290+ for (const it of items) {
291+ const c = mulMV(proj, mulMV(view, it.pos));
292+ if (c[3] <= 0) continue;
293+ const sx = (c[0] / c[3] * 0.5 + 0.5) * w, sy = (0.5 - c[1] / c[3] * 0.5) * h;
294+ if (sx < -50 || sx > w + 50 || sy < -20 || sy > h + 20) continue;
295+ const d = document.createElement("div"); d.className = "cscene-label"; d.textContent = it.name; d.style.left = sx + "px"; d.style.top = sy + "px"; labels.appendChild(d);
296+ }
297+ }
298+ const same = (a, b) => a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
299+ new ResizeObserver(() => draw()).observe(wrap);
300+ draw();
301+ }
302+ }
303+
304+ const CSS = `
305+ .cscene-box { margin: 0.6em 0 1em; }
306+ .cscene-bar { display: flex; flex-wrap: wrap; gap: 0.6em 1.2em; align-items: center; font-size: 0.9em; margin-bottom: 0.3em; }
307+ .cscene-bar button { font: inherit; }
308+ .cscene-canvas { position: relative; width: 100%; height: 480px; max-height: 70vh; border: 1px solid #ccd; border-radius: 4px; overflow: hidden; background: #f7f7f9; }
309+ .cscene-canvas canvas { width: 100%; height: 100%; display: block; touch-action: none; cursor: grab; }
310+ .cscene-labels { position: absolute; inset: 0; pointer-events: none; }
311+ .cscene-label { position: absolute; transform: translate(6px, -50%); font-size: 0.8em; color: #223; background: rgba(255,255,255,0.75); padding: 0 3px; border-radius: 2px; white-space: nowrap; }
312+ .cscene-grad { display: inline-block; width: 90px; height: 10px; vertical-align: middle; margin: 0 4px;
313+ background: linear-gradient(to right, #440154, #482878, #3e4a89, #31688e, #26828e, #1f9e89, #35b779, #6ece58, #b5de2b, #fde725); }
314+ .cscene-note { font-size: 0.85em; margin-top: 0.3em; }
315+ `;
316+
317+ window.SceneViewer = {
318+ mount() {
319+ if (!document.getElementById("cscene-css")) { const s = document.createElement("style"); s.id = "cscene-css"; s.textContent = CSS; document.head.appendChild(s); }
320+ document.querySelectorAll(".cscene[data-src]").forEach(mountOne);
321+ },
322+ };
323+})();
↑↓ move↵openescclose