1// Lists this repository's submissions and animates the exported HDF5 displacement fields of the disk as a 3D surface.
2import * as THREE from "three";
3import { OrbitControls } from "three/addons/controls/OrbitControls.js";
4import h5wasm from "https://cdn.jsdelivr.net/npm/h5wasm@0.7/dist/esm/hdf5_hl.js";
6const manifest = await (await fetch("manifest.json")).json();
7const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
9// ---------------------------------------------------------------- submissions table
10const subsDiv = document.getElementById("submissions");
11const fieldSolutions = [];
12for (const [cid, ch] of Object.entries(manifest.challenges)) {
13 const h3 = document.createElement("h3");
14 h3.textContent = cid;
15 subsDiv.appendChild(h3);
16 for (const sub of ch.submissions) {
17 const p = document.createElement("p");
18 p.innerHTML = `<b>${esc(sub.submission_id)}</b> by ${esc(sub.submitter.name)}${sub.date ? " (" + esc(sub.date) + ")" : ""}. ${esc(sub.description || "")}` +
19 (sub.code_url ? ` <a href="${esc(sub.code_url)}">code</a>` : "");
20 subsDiv.appendChild(p);
21 const rows = sub.solutions.map((s) => {
22 if (s.fields && s.fields.available !== false) fieldSolutions.push({ label: `${sub.submission_id}/${s.solution_id}`, path: s.fields.path, challenge: cid });
23 const fieldsLink = s.fields ? (s.fields.available === false ? ' <span class="muted">(fields stored in LFS, not published here)</span>' : ` <a href="${esc(s.fields.path)}">fields</a>`) : "";
24 return `<tr><td>${esc(s.solution_id)}</td><td>${esc(s.method || "")}<br><span class="muted"><code>${esc(JSON.stringify(s.parameters || {}))}</code></span></td>` +
25 `<td class="num">${s.timing_seconds ?? ""}</td><td class="muted">${esc((s.more_accurate_than || []).join(", "))}</td>` +
26 `<td><div class="cplayer" data-src="${esc(s.output)}"></div><br><a href="${esc(s.output)}">wav</a>${fieldsLink}</td></tr>`;
27 }).join("");
28 const wrap = document.createElement("div");
29 wrap.className = "wrap";
30 wrap.innerHTML = `<table><tr><th>Solution</th><th>Method</th><th>Time (s)</th><th>Claimed more accurate than</th><th>Output</th></tr>${rows}</table>`;
31 subsDiv.appendChild(wrap);
32 }
33}
35// ---------------------------------------------------------------- experiments
36const expDiv = document.getElementById("experiments");
37for (const e of manifest.experiments || []) {
38 const m = e.meta || {};
39 const p = document.createElement("p");
40 p.innerHTML = `<b>${esc(e.id)}</b>. ${esc(m.description || m.note || "")}` +
41 (m.strikes ? ` <span class="muted">Strikes: ${esc(m.strikes.map((s) => `${s.force} N over ${(s.duration * 1e3).toFixed(1)} ms at ${s.t} s, x = ${(s.x * 1e3).toFixed(1)} mm, y = ${(s.y * 1e3).toFixed(1)} mm`).join("; "))}.</span>` : "") +
42 (m.parameters ? ` <span class="muted"><code>${esc(JSON.stringify(m.parameters))}</code></span>` : "") +
43 `<br><div class="cplayer" data-src="${esc(e.output)}"></div> <a href="${esc(e.output)}">wav</a>`;
44 expDiv.appendChild(p);
45}
46if (!(manifest.experiments || []).length) expDiv.innerHTML = '<p class="muted">None yet.</p>';
48const gains = manifest.playback_gain_db || {};
49const firstGain = Object.values(gains).find((g) => typeof g === "number");
50ChallengePlayer.controls(document.getElementById("playback"), { gainDb: typeof firstGain === "number" ? firstGain : 20, limiter: true });
51ChallengePlayer.mount();
53// ---------------------------------------------------------------- 3D viewer
54const view = document.getElementById("view");
55const status = document.getElementById("fieldStatus");
56const select = document.getElementById("fieldSelect");
57const playBtn = document.getElementById("playBtn");
58const speed = document.getElementById("speed");
59const speedLabel = document.getElementById("speedLabel");
60const exag = document.getElementById("exag");
61const exagLabel = document.getElementById("exagLabel");
62const timeSlider = document.getElementById("timeSlider");
63const derigid = document.getElementById("derigid");
64const timeLabel = document.getElementById("timeLabel");
66const renderer = new THREE.WebGLRenderer({ antialias: true });
67renderer.setPixelRatio(window.devicePixelRatio || 1);
68view.appendChild(renderer.domElement);
69const scene = new THREE.Scene();
70const dark = matchMedia("(prefers-color-scheme: dark)").matches;
71scene.background = new THREE.Color(dark ? 0x151515 : 0xffffff);
72const camera = new THREE.PerspectiveCamera(40, 2, 0.01, 100);
73camera.position.set(0.45, -0.6, 0.4);
74camera.up.set(0, 0, 1);
75const controls = new OrbitControls(camera, renderer.domElement);
76controls.target.set(0, 0, 0);
77scene.add(new THREE.AmbientLight(0xffffff, 0.6));
78const dl = new THREE.DirectionalLight(0xffffff, 0.9);
79dl.position.set(0.5, -0.8, 1.0);
80scene.add(dl);
82let field = null; // {x, y, t, u, nx, ny, nt, umax, emax, plane, vidx, verts, params}
83let mesh = null;
84let frame = 0;
85let playing = false;
86let lastTs = null;
87let simTime = 0;
89function resize() {
90 const w = view.clientWidth, h = view.clientHeight;
91 renderer.setSize(w, h, false);
92 camera.aspect = w / h;
93 camera.updateProjectionMatrix();
94}
95window.addEventListener("resize", () => { resize(); render(); });
96resize();
98function buildMesh() {
99 if (mesh) { scene.remove(mesh); mesh.geometry.dispose(); mesh.material.dispose(); }
100 const { x, y, nx, ny, vidx, verts, params } = field;
101 // vertices: the grid points inside the disk; triangles: cells whose four corners are all inside
102 const pos = new Float32Array(verts.length * 3);
103 for (let k = 0; k < verts.length; k++) {
104 const [i, j] = verts[k];
105 pos[3 * k] = x[i]; pos[3 * k + 1] = y[j]; pos[3 * k + 2] = 0;
106 }
107 const idx = [];
108 for (let j = 0; j < ny - 1; j++) for (let i = 0; i < nx - 1; i++) {
109 const a = vidx[j * nx + i], b = vidx[j * nx + i + 1], c = vidx[(j + 1) * nx + i], d = vidx[(j + 1) * nx + i + 1];
110 if (a < 0 || b < 0 || c < 0 || d < 0) continue;
111 idx.push(a, b, d, a, d, c);
112 }
113 const geo = new THREE.BufferGeometry();
114 geo.setAttribute("position", new THREE.BufferAttribute(pos, 3));
115 geo.setAttribute("color", new THREE.BufferAttribute(new Float32Array(verts.length * 3), 3));
116 geo.setIndex(idx);
117 const mat = new THREE.MeshStandardMaterial({ vertexColors: true, side: THREE.DoubleSide, metalness: 0.1, roughness: 0.7 });
118 mesh = new THREE.Mesh(geo, mat);
119 scene.add(mesh);
120 // the rim at rest, for reference
121 const R = params.disk_radius || Math.max(...x);
122 const circ = [];
123 for (let k = 0; k <= 128; k++) circ.push(new THREE.Vector3(R * Math.cos(2 * Math.PI * k / 128), R * Math.sin(2 * Math.PI * k / 128), 0));
124 mesh.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(circ), new THREE.LineBasicMaterial({ color: dark ? 0x555555 : 0xbbbbbb })));
125 // strike markers
126 for (const st of (params.strikes || [])) {
127 const marker = new THREE.Mesh(new THREE.SphereGeometry(0.005, 12, 12), new THREE.MeshBasicMaterial({ color: 0xd04040 }));
128 marker.position.set(st.x, st.y, 0.004);
129 mesh.add(marker);
130 }
131}
133function updateSurface() {
134 if (!field || !mesh) return;
135 const { u, nx, ny, x, y, plane, verts } = field;
136 const rigidOff = derigid.checked;
137 const umax = rigidOff ? field.emax : field.umax;
138 const pos = mesh.geometry.attributes.position;
139 const col = mesh.geometry.attributes.color;
140 const scale = umax > 0 ? (0.03 * Math.pow(10, parseFloat(exag.value)) / umax) * 0.1 : 0; // exaggerated height
141 const base = frame * nx * ny;
142 const c0 = plane[3 * frame], gx = plane[3 * frame + 1], gy = plane[3 * frame + 2];
143 for (let k = 0; k < verts.length; k++) {
144 const [i, j] = verts[k];
145 let v = u[base + j * nx + i];
146 if (rigidOff) v -= c0 + gx * x[i] + gy * y[j];
147 pos.setZ(k, v * scale);
148 const s = umax > 0 ? Math.max(-1, Math.min(1, v / umax)) : 0;
149 col.setXYZ(k, s > 0 ? 1 : 1 + s * 0.8, 1 - Math.abs(s) * 0.7, s < 0 ? 1 : 1 - s * 0.8);
150 }
151 pos.needsUpdate = true;
152 col.needsUpdate = true;
153 mesh.geometry.computeVertexNormals();
154 timeSlider.value = String(frame);
155 timeLabel.textContent = `${(field.t[frame] * 1e3).toFixed(0)} ms`;
156}
158function render() { renderer.render(scene, camera); }
159controls.addEventListener("change", render);
161function solve3(A, b) {
162 // Gaussian elimination for the 3 x 3 normal equations of the plane fit
163 const M = A.map((row, i) => [...row, b[i]]);
164 for (let c = 0; c < 3; c++) {
165 let p = c;
166 for (let r = c + 1; r < 3; r++) if (Math.abs(M[r][c]) > Math.abs(M[p][c])) p = r;
167 [M[c], M[p]] = [M[p], M[c]];
168 if (Math.abs(M[c][c]) < 1e-300) return [0, 0, 0];
169 for (let r = 0; r < 3; r++) if (r !== c) {
170 const f = M[r][c] / M[c][c];
171 for (let k = c; k < 4; k++) M[r][k] -= f * M[c][k];
172 }
173 }
174 return [M[0][3] / M[0][0], M[1][3] / M[1][1], M[2][3] / M[2][2]];
175}
177async function loadField(entry) {
178 const path = entry.path;
179 status.textContent = `Loading ${path} (this may take a while, the file is large)…`;
180 playing = false;
181 playBtn.textContent = "Play";
182 try {
183 const { FS } = await h5wasm.ready;
184 const resp = await fetch(path);
185 const buf = new Uint8Array(await resp.arrayBuffer());
186 if (buf.length < 200 && new TextDecoder().decode(buf).startsWith("version https://git-lfs")) {
187 status.textContent = `${path} is a git LFS pointer; the object was not published with this site.`;
188 return;
189 }
190 const name = "current.h5";
191 try { FS.unlink(name); } catch (_) {}
192 FS.writeFile(name, buf);
193 const file = new h5wasm.File(name, "r");
194 const x = Float64Array.from(file.get("x").value);
195 const y = Float64Array.from(file.get("y").value);
196 const t = Float64Array.from(file.get("t").value);
197 const u = Float32Array.from(file.get("u").value);
198 file.close();
199 const nx = x.length, ny = y.length, nt = t.length;
200 // grid points inside the disk are those with finite values in the first frame
201 const vidx = new Int32Array(nx * ny).fill(-1);
202 const verts = [];
203 for (let j = 0; j < ny; j++) for (let i = 0; i < nx; i++) {
204 if (Number.isFinite(u[j * nx + i])) { vidx[j * nx + i] = verts.length; verts.push([i, j]); }
205 }
206 // best-fit plane per frame over the inside points (rigid translation and rotations of the free disk)
207 let s1 = 0, sx = 0, sy = 0, sxx = 0, sxy = 0, syy = 0;
208 for (const [i, j] of verts) { s1 += 1; sx += x[i]; sy += y[j]; sxx += x[i] * x[i]; sxy += x[i] * y[j]; syy += y[j] * y[j]; }
209 const A = [[s1, sx, sy], [sx, sxx, sxy], [sy, sxy, syy]];
210 const plane = new Float64Array(nt * 3);
211 let umax = 0, emax = 0;
212 for (let k = 0; k < nt; k++) {
213 const base = k * nx * ny;
214 let b0 = 0, bx = 0, by = 0;
215 for (const [i, j] of verts) { const v = u[base + j * nx + i]; b0 += v; bx += v * x[i]; by += v * y[j]; }
216 const [c0, gx, gy] = solve3(A, [b0, bx, by]);
217 plane[3 * k] = c0; plane[3 * k + 1] = gx; plane[3 * k + 2] = gy;
218 for (const [i, j] of verts) {
219 const v = u[base + j * nx + i];
220 const e = v - c0 - gx * x[i] - gy * y[j];
221 if (Math.abs(v) > umax) umax = Math.abs(v);
222 if (Math.abs(e) > emax) emax = Math.abs(e);
223 }
224 }
225 const params = (manifest.parameters || {})[entry.challenge] || {};
226 field = { x, y, t, u, nx, ny, nt, umax, emax, plane, vidx, verts, params };
227 timeSlider.max = String(field.nt - 1);
228 frame = 0;
229 simTime = 0;
230 buildMesh();
231 updateSurface();
232 render();
233 status.textContent = `${path}: ${verts.length} grid points inside the disk × ${field.nt} frames, max |u| = ${(umax * 1e3).toFixed(2)} mm including rigid motion, ${(emax * 1e3).toFixed(3)} mm elastic`;
234 } catch (e) {
235 status.textContent = `Could not load ${path}: ${e}`;
236 }
237}
239function tick(ts) {
240 if (!playing || !field) return;
241 if (lastTs !== null) {
242 const factor = Math.pow(10, parseFloat(speed.value));
243 simTime += ((ts - lastTs) / 1000) * factor;
244 const tEnd = field.t[field.nt - 1];
245 if (simTime > tEnd) simTime = 0;
246 const dt = field.t[1] - field.t[0];
247 frame = Math.min(field.nt - 1, Math.round(simTime / dt));
248 updateSurface();
249 render();
250 }
251 lastTs = ts;
252 requestAnimationFrame(tick);
253}
255playBtn.addEventListener("click", () => {
256 playing = !playing;
257 playBtn.textContent = playing ? "Pause" : "Play";
258 lastTs = null;
259 if (playing) requestAnimationFrame(tick);
260});
261timeSlider.addEventListener("input", () => {
262 if (!field) return;
263 frame = parseInt(timeSlider.value, 10);
264 simTime = field.t[frame];
265 updateSurface();
266 render();
267});
268const updSpeed = () => { speedLabel.textContent = `${Math.pow(10, parseFloat(speed.value)).toPrecision(2)} × real time`; };
269const updExag = () => { exagLabel.textContent = `×${Math.pow(10, parseFloat(exag.value)).toPrecision(2)}`; updateSurface(); render(); };
270speed.addEventListener("input", updSpeed);
271exag.addEventListener("input", updExag);
272derigid.addEventListener("change", () => { updateSurface(); render(); });
273updSpeed();
274exagLabel.textContent = `×${Math.pow(10, parseFloat(exag.value)).toPrecision(2)}`;
275select.addEventListener("change", () => loadField(fieldSolutions[parseInt(select.value, 10)]));
277fieldSolutions.forEach((f, k) => {
278 const o = document.createElement("option");
279 o.value = String(k);
280 o.textContent = f.label;
281 select.appendChild(o);
282});
283render();
284if (fieldSolutions.length) loadField(fieldSolutions[0]);
285else status.textContent = "No solution in this repository exports a displacement field that is published with this site.";