/* Interactive 3D view of a challenge geometry, without dependencies (raw WebGL). * * Usage:
... SceneViewer.mount(); * * scene.json: { * "units": "m", * "surfaces": [{"name": ..., "vertices": [[x,y,z], ...], * "faces": [[a,b,c], ...] // or, for a structured grid of vertices in row-major order, * "grid": [n1, n2], "periodic": true, // faces are generated; periodic closes the second index * "scalar": {"name": ..., "values": [...]}}], // optional per-vertex color * "points": [{"name": ..., "position": [x,y,z], "radius": r, "far": true}], // "far" points are outside the default framing * "lines": [{"from": [x,y,z], "to": [x,y,z]}], * "up": "z" * } * Left drag rotates, wheel zooms, right or shift drag pans; touch: one finger rotates, two fingers zoom and pan. */ (function () { "use strict"; const VS = ` attribute vec3 aPos; attribute vec3 aNrm; attribute float aVal; uniform mat4 uProj, uView; uniform vec3 uOffset; uniform float uScale, uZ; varying vec3 vNrm; varying float vVal; varying vec3 vPosV; void main() { vec3 p = (aPos * vec3(1.0, 1.0, uZ)) * uScale + uOffset; vec4 pv = uView * vec4(p, 1.0); vPosV = pv.xyz; vNrm = mat3(uView) * normalize(aNrm * vec3(1.0, 1.0, 1.0 / max(uZ, 1e-6))); vVal = aVal; gl_Position = uProj * pv; }`; const FS = ` precision mediump float; varying vec3 vNrm; varying float vVal; varying vec3 vPosV; uniform vec3 uColor; uniform float uUseMap; uniform float uAlpha; vec3 cmap(float t) { // viridis, 6 anchors 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), c3 = vec3(0.164, 0.471, 0.558), c4 = vec3(0.128, 0.567, 0.551), c5 = vec3(0.993, 0.906, 0.144); t = clamp(t, 0.0, 1.0) * 5.0; 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); if (t < 4.0) return mix(c3, c4, t - 3.0); return mix(c4, c5, t - 4.0); } void main() { vec3 n = normalize(vNrm); vec3 v = normalize(-vPosV); if (dot(n, v) < 0.0) n = -n; vec3 l1 = normalize(vec3(0.4, 0.5, 1.0)), l2 = normalize(vec3(-0.6, -0.3, 0.4)); float diff = 0.25 + 0.6 * max(dot(n, l1), 0.0) + 0.25 * max(dot(n, l2), 0.0); float spec = pow(max(dot(reflect(-l1, n), v), 0.0), 40.0) * 0.25; vec3 base = mix(uColor, cmap(vVal), uUseMap); gl_FragColor = vec4(base * diff + spec, uAlpha); }`; function compile(gl, type, src) { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s)); return s; } function program(gl) { const p = gl.createProgram(); gl.attachShader(p, compile(gl, gl.VERTEX_SHADER, VS)); gl.attachShader(p, compile(gl, gl.FRAGMENT_SHADER, FS)); gl.linkProgram(p); if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p)); return p; } // --- small matrix helpers (column-major 4x4) function perspective(fovy, aspect, near, far) { const f = 1 / Math.tan(fovy / 2), nf = 1 / (near - far); return [f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, (far + near) * nf, -1, 0, 0, 2 * far * near * nf, 0]; } function lookAt(eye, target, up) { let z = sub(eye, target); z = norm(z); let x = norm(cross(up, z)); const y = cross(z, x); return [x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0, -dot(x, eye), -dot(y, eye), -dot(z, eye), 1]; } function mulMV(m, v) { // 4x4 times [x,y,z,1] 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], 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]]; } const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; const scl = (a, s) => [a[0] * s, a[1] * s, a[2] * s]; const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; 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]]; const norm = (a) => { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; }; // --- geometry function gridFaces(n1, n2, periodic) { const f = []; const m2 = periodic ? n2 : n2 - 1; for (let i = 0; i < n1 - 1; i++) for (let j = 0; j < m2; j++) { const j1 = (j + 1) % n2, a = i * n2 + j, b = i * n2 + j1, c = (i + 1) * n2 + j, d = (i + 1) * n2 + j1; f.push([a, b, d], [a, d, c]); } return f; } function normals(verts, faces, zs) { const n = new Float32Array(verts.length * 3); for (const [a, b, c] of faces) { const pa = verts[a], pb = verts[b], pc = verts[c]; 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]; const fn = cross(e1, e2); for (const k of [a, b, c]) { n[3 * k] += fn[0]; n[3 * k + 1] += fn[1]; n[3 * k + 2] += fn[2]; } } for (let k = 0; k < verts.length; k++) { const l = Math.hypot(n[3 * k], n[3 * k + 1], n[3 * k + 2]) || 1; n[3 * k] /= l; n[3 * k + 1] /= l; n[3 * k + 2] /= l; } return n; } function sphere(nu, nv) { const v = [], f = []; 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)]); } } return { vertices: v, faces: gridFaces(nu + 1, nv, true) }; } function edges(faces) { const set = new Set(), out = []; for (const [a, b, c] of faces) for (const [p, q] of [[a, b], [b, c], [c, a]]) { const key = p < q ? p * 4294967296 + q : q * 4294967296 + p; if (!set.has(key)) { set.add(key); out.push(p, q); } } return new Uint32Array(out); } function makeMesh(gl, verts, faces, values) { const pos = new Float32Array(verts.length * 3); verts.forEach((p, k) => { pos[3 * k] = p[0]; pos[3 * k + 1] = p[1]; pos[3 * k + 2] = p[2]; }); const idx = new Uint32Array(faces.length * 3); faces.forEach((t, k) => { idx[3 * k] = t[0]; idx[3 * k + 1] = t[1]; idx[3 * k + 2] = t[2]; }); let val = new Float32Array(verts.length), useMap = 0, vmin = 0, vmax = 1; if (values) { vmin = Math.min(...values); vmax = Math.max(...values); const d = vmax > vmin ? vmax - vmin : 1; values.forEach((x, k) => { val[k] = (x - vmin) / d; }); useMap = 1; } 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; }; 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), edges: null, nEdges: 0, useMap, vmin, vmax, zs: 1 }; 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); }; m.ensureEdges = () => { if (!m.edges) { const e = edges(faces); m.edges = buf(e, gl.ELEMENT_ARRAY_BUFFER); m.nEdges = e.length; } }; return m; } 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); } function mountOne(el) { const src = el.dataset.src; el.classList.add("cscene-box"); el.innerHTML = '
'; 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"); const gl = canvas.getContext("webgl", { antialias: true, alpha: false }); if (!gl) { note.textContent = "WebGL is not available in this browser."; return; } gl.getExtension("OES_element_index_uint"); 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; }); function setup(scene) { const prog = program(gl); gl.useProgram(prog); const loc = (n) => gl.getUniformLocation(prog, n); const aPos = gl.getAttribLocation(prog, "aPos"), aNrm = gl.getAttribLocation(prog, "aNrm"), aVal = gl.getAttribLocation(prog, "aVal"); const meshes = (scene.surfaces || []).map((s) => { const faces = s.faces || gridFaces(s.grid[0], s.grid[1], !!s.periodic); const m = makeMesh(gl, s.vertices, faces, s.scalar ? s.scalar.values : null); m.name = s.name || ""; m.scalarName = s.scalar ? s.scalar.name : null; return m; }); const sph = sphere(12, 18); const ball = makeMesh(gl, sph.vertices, sph.faces, null); 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] })); const lines = (scene.lines || []).map((l) => [l.from, l.to]); // bounds of the near scene (surfaces and non-far points) and of everything const bounds = (includeFar) => { let lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity]; 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]); } }; meshes.forEach((m) => m.verts.forEach(take)); points.forEach((p) => { if (includeFar || !p.far) take(p.pos); }); if (lo[0] === Infinity) { lo = [-1, -1, -1]; hi = [1, 1, 1]; } const c = scl(add(lo, hi), 0.5), size = Math.max(hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]) || 1; return { c, size }; }; const near = bounds(false); const cam = { target: near.c.slice(), dist: near.size * 1.6, az: -0.9, el: 0.45, fov: 0.6 }; const state = { wire: false, far: false, map: false, zs: 1 }; 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; }; // controls const ctrl = []; const check = (label, key, onchange) => { const id = "cs" + Math.random().toString(36).slice(2, 8); const l = document.createElement("label"); l.innerHTML = ` ${label}`; l.querySelector("input").addEventListener("change", (e) => { state[key] = e.target.checked; if (onchange) onchange(); draw(); }); bar.appendChild(l); ctrl.push(l); }; check("wireframe", "wire"); const legend = meshes.find((m) => m.useMap); let lg = null; if (legend) { lg = document.createElement("span"); lg.className = "cscene-legend"; lg.style.display = "none"; lg.innerHTML = `${legend.scalarName || "value"}: ${fmt(legend.vmin)} ${fmt(legend.vmax)}`; check("color by " + (legend.scalarName || "value"), "map", () => { lg.style.display = state.map ? "" : "none"; }); bar.appendChild(lg); } if (points.some((p) => p.far)) check("show far points (" + points.filter((p) => p.far).map((p) => p.name).join(", ") + ")", "far", reset); const zsel = document.createElement("label"); zsel.innerHTML = 'z scale '; zsel.querySelector("select").addEventListener("change", (e) => { state.zs = parseFloat(e.target.value); meshes.forEach((m) => m.setZ(state.zs)); draw(); }); bar.appendChild(zsel); const rb = document.createElement("button"); rb.textContent = "reset view"; rb.addEventListener("click", () => { reset(); draw(); }); bar.appendChild(rb); note.textContent = "Drag to rotate, wheel to zoom, right-drag or shift-drag to pan." + (scene.units ? " Units: " + scene.units + "." : "") + (scene.note ? " " + scene.note : ""); // interaction let drag = null; canvas.addEventListener("contextmenu", (e) => e.preventDefault()); canvas.addEventListener("pointerdown", (e) => { drag = { x: e.clientX, y: e.clientY, pan: e.button === 2 || e.button === 1 || e.shiftKey }; canvas.setPointerCapture(e.pointerId); }); canvas.addEventListener("pointerup", () => { drag = null; }); canvas.addEventListener("pointermove", (e) => { if (!drag) return; const dx = e.clientX - drag.x, dy = e.clientY - drag.y; drag.x = e.clientX; drag.y = e.clientY; 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)); } draw(); }); canvas.addEventListener("wheel", (e) => { e.preventDefault(); cam.dist *= Math.exp(e.deltaY * 0.0015); draw(); }, { passive: false }); let pinch = null; 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 }); canvas.addEventListener("touchmove", (e) => { if (e.touches.length === 2 && pinch) { e.preventDefault(); const d = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY); const x = (e.touches[0].clientX + e.touches[1].clientX) / 2, y = (e.touches[0].clientY + e.touches[1].clientY) / 2; cam.dist *= pinch.d / d; pan(x - pinch.x, y - pinch.y); pinch = { d, x, y }; drag = null; draw(); } }, { passive: false }); canvas.addEventListener("touchend", () => { pinch = null; }); 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)); } function pan(dx, dy) { const e = eye(), f = norm(sub(cam.target, e)), r = norm(cross(f, [0, 0, 1])), u = cross(r, f); const k = cam.dist * Math.tan(cam.fov / 2) * 2 / canvas.clientHeight; cam.target = add(cam.target, add(scl(r, -dx * k), scl(u, dy * k))); } function bind(m) { gl.bindBuffer(gl.ARRAY_BUFFER, m.pos); gl.enableVertexAttribArray(aPos); gl.vertexAttribPointer(aPos, 3, gl.FLOAT, false, 0, 0); gl.bindBuffer(gl.ARRAY_BUFFER, m.nrm); gl.enableVertexAttribArray(aNrm); gl.vertexAttribPointer(aNrm, 3, gl.FLOAT, false, 0, 0); gl.bindBuffer(gl.ARRAY_BUFFER, m.val); gl.enableVertexAttribArray(aVal); gl.vertexAttribPointer(aVal, 1, gl.FLOAT, false, 0, 0); } function draw() { const dpr = window.devicePixelRatio || 1, w = wrap.clientWidth, h = wrap.clientHeight; 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); } gl.viewport(0, 0, canvas.width, canvas.height); gl.clearColor(0.97, 0.97, 0.98, 1); gl.enable(gl.DEPTH_TEST); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); const e = eye(), view = lookAt(e, cam.target, [0, 0, 1]), proj = perspective(cam.fov, w / h, cam.dist * 0.01, cam.dist * 100); gl.uniformMatrix4fv(loc("uProj"), false, proj); gl.uniformMatrix4fv(loc("uView"), false, view); gl.uniform1f(loc("uAlpha"), 1); // surfaces gl.uniform3f(loc("uOffset"), 0, 0, 0); gl.uniform1f(loc("uScale"), 1); gl.uniform1f(loc("uZ"), state.zs); for (const m of meshes) { bind(m); gl.uniform1f(loc("uUseMap"), state.map ? m.useMap : 0); gl.uniform3f(loc("uColor"), 0.80, 0.62, 0.30); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, m.idx); gl.enable(gl.POLYGON_OFFSET_FILL); gl.polygonOffset(1, 1); gl.drawElements(gl.TRIANGLES, m.n, gl.UNSIGNED_INT, 0); gl.disable(gl.POLYGON_OFFSET_FILL); 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); } } // points as spheres (a minimum screen-relative radius keeps small footprints visible) bind(ball); gl.uniform1f(loc("uUseMap"), 0); gl.uniform1f(loc("uZ"), 1); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ball.idx); for (const p of points) { if (p.far && !state.far) continue; const r = Math.max(p.r, cam.dist * 0.006); gl.uniform3f(loc("uColor"), p.color[0], p.color[1], p.color[2]); gl.uniform3f(loc("uOffset"), p.pos[0], p.pos[1], p.pos[2] * state.zs); gl.uniform1f(loc("uScale"), r); gl.drawElements(gl.TRIANGLES, ball.n, gl.UNSIGNED_INT, 0); } // lines and axes const segs = []; 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); const L = near.size * 0.35; 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])); if (segs.length) { 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]; }); const b = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, b); gl.bufferData(gl.ARRAY_BUFFER, arr, gl.STREAM_DRAW); 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); 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); gl.drawArrays(gl.LINES, 0, segs.length); gl.enableVertexAttribArray(aNrm); gl.enableVertexAttribArray(aVal); gl.deleteBuffer(b); } // labels 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] })); 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]) }); labels.innerHTML = ""; for (const it of items) { const c = mulMV(proj, mulMV(view, it.pos)); if (c[3] <= 0) continue; const sx = (c[0] / c[3] * 0.5 + 0.5) * w, sy = (0.5 - c[1] / c[3] * 0.5) * h; if (sx < -50 || sx > w + 50 || sy < -20 || sy > h + 20) continue; 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); } } const same = (a, b) => a[0] === b[0] && a[1] === b[1] && a[2] === b[2]; new ResizeObserver(() => draw()).observe(wrap); draw(); } } const CSS = ` .cscene-box { margin: 0.6em 0 1em; } .cscene-bar { display: flex; flex-wrap: wrap; gap: 0.6em 1.2em; align-items: center; font-size: 0.9em; margin-bottom: 0.3em; } .cscene-bar button { font: inherit; } .cscene-canvas { position: relative; width: 100%; height: 480px; max-height: 70vh; border: 1px solid #ccd; border-radius: 4px; overflow: hidden; background: #f7f7f9; } .cscene-canvas canvas { width: 100%; height: 100%; display: block; touch-action: none; cursor: grab; } .cscene-labels { position: absolute; inset: 0; pointer-events: none; } .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; } .cscene-grad { display: inline-block; width: 90px; height: 10px; vertical-align: middle; margin: 0 4px; background: linear-gradient(to right, #440154, #482878, #3e4a89, #31688e, #26828e, #1f9e89, #35b779, #6ece58, #b5de2b, #fde725); } .cscene-note { font-size: 0.85em; margin-top: 0.3em; } `; window.SceneViewer = { mount() { if (!document.getElementById("cscene-css")) { const s = document.createElement("style"); s.id = "cscene-css"; s.textContent = CSS; document.head.appendChild(s); } document.querySelectorAll(".cscene[data-src]").forEach(mountOne); }, }; })();