d8f9e54Clean rebuild of the steel sheet simulatorJeremy Magland 1// Application: parameter form, run loop, live and movie views, spectrogram,
2// playback, WAV download, and the test hooks on window.steelsim.
8cb683aInitial shape as a list of features, with random configurationsJeremy Magland 4import { defaultParams, type Grid, makeGrid, nodeWeights, type PhysParams } from "./params.ts";
5import { encodeFeatures, initialShape } from "./shape.ts";
d8f9e54Clean rebuild of the steel sheet simulatorJeremy Magland 6import { Simulation, type SimStatus } from "./sim/simulation.ts";
7import { Renderer, type ViewMode } from "./render/renderer.ts";
8import { Spectrogram } from "./audio/spectrogram.ts";
9import { Player } from "./audio/player.ts";
10import { encodeWav } from "./audio/wav.ts";
11import { levelGain, peakDbfs, type LevelMode } from "./audio/level.ts";
12import { ParamForm } from "./ui/form.ts";
13import { runSelfTest, selfTestPassed, type SelfTestCase } from "./selftest.ts";
15const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
17// ---------- State ----------
19const params: PhysParams = { ...defaultParams };
20let form: ParamForm;
21let device: GPUDevice | null = null;
22let sim: Simulation | null = null;
23let renderer: Renderer | null = null;
24let running = false;
25let paused = false;
26let live = true;
27let scrubFrame = 0;
28let movieBuf: GPUBuffer | null = null;
29let movieBufFrame = -1;
8cb683aInitial shape as a list of features, with random configurationsJeremy Magland 30/** Show the stress-free initial shape of the current parameters instead of the simulation. */
31let previewMode = true;
32let preview: { grid: Grid; w0: GPUBuffer; zero: GPUBuffer; nw: GPUBuffer; wmax: number; stale: boolean } | null = null;
d8f9e54Clean rebuild of the steel sheet simulatorJeremy Magland 33const spec = new Spectrogram({ windowSize: 2048, hop: 512, fmax: 5000, dbFloor: 80, logFrequency: false });
34const player = new Player();
35let lastStatsTime = 0, lastStatsSteps = 0, stepsPerSec = 0;
36let duration = params.duration;
38// ---------- Controls ----------
40const btnStart = mkButton("Start", "primary");
41const btnPause = mkButton("Pause");
42const btnStop = mkButton("Stop");
43const btnContinue = mkButton("Continue");
8cb683aInitial shape as a list of features, with random configurationsJeremy Magland 44const btnLink = mkButton("Copy link");
d8f9e54Clean rebuild of the steel sheet simulatorJeremy Magland 45const durInput = document.createElement("input");
47function mkButton(text: string, cls = ""): HTMLButtonElement {
48 const b = document.createElement("button");
49 b.textContent = text;
50 b.className = cls;
51 return b;
52}
54function buildControls(): void {
55 const c = $("controls");
56 const durRow = document.createElement("div");
57 durRow.className = "field";
58 durRow.style.width = "100%";
59 const l = document.createElement("label");
60 l.textContent = "Recorded duration";
61 durInput.type = "number";
62 durInput.step = "0.5";
63 durInput.min = "0.1";
64 durInput.value = String(duration);
65 durInput.addEventListener("change", () => {
66 const v = parseFloat(durInput.value);
67 if (isFinite(v) && v > 0) {
68 duration = v;
69 params.duration = v;
70 sim?.setDuration(v);
73 }
74 });
75 const u = document.createElement("span");
76 u.className = "unit";
77 u.textContent = "s";
78 durRow.append(l, durInput, u);
8cb683aInitial shape as a list of features, with random configurationsJeremy Magland 79 c.append(durRow, btnStart, btnPause, btnStop, btnContinue, btnLink);
d8f9e54Clean rebuild of the steel sheet simulatorJeremy Magland 80 btnStart.addEventListener("click", () => void start());
8cb683aInitial shape as a list of features, with random configurationsJeremy Magland 81 btnLink.title = "Copy a link that opens the simulator with the current parameters";
82 btnLink.addEventListener("click", () => void copyLink());
d8f9e54Clean rebuild of the steel sheet simulatorJeremy Magland 83 btnPause.addEventListener("click", () => {
84 paused = !paused;
85 btnPause.textContent = paused ? "Resume" : "Pause";
86 if (!paused) void runLoop();
87 });
88 btnStop.addEventListener("click", () => {
89 if (!sim) return;
90 sim.stop();
91 paused = false;
92 btnPause.textContent = "Pause";
93 if (!running) {
94 form.setEnabled(true);
95 updateButtons();
96 }
97 });
98 btnContinue.addEventListener("click", () => {
99 if (!sim) return;
100 sim.setDuration(duration);
101 if (sim.phase !== "done") void runLoop();
102 updateButtons();
103 });
104 updateButtons();
105}
107function updateButtons(): void {
108 const s = sim?.status();
109 btnPause.disabled = !sim || s?.phase === "done" || (!running && !paused);
110 btnStop.disabled = !sim || s?.phase === "done";
111 btnContinue.disabled = !sim || running || !sim.canContinue();
112}
8cb683aInitial shape as a list of features, with random configurationsJeremy Magland 114/** The URL of the current parameters (those that differ from the defaults, in SI). */
115function currentLink(): string {
116 const q = form.toQuery();
117 return `${location.origin}${location.pathname}${q ? "?" + q : ""}`;
118}
120/** Copies the current link to the clipboard and puts it in the address bar. */
121async function copyLink(): Promise<void> {
122 const url = currentLink();
123 history.replaceState(null, "", url);
124 let ok = false;
125 try {
126 await navigator.clipboard.writeText(url);
127 ok = true;
128 } catch {
129 window.prompt("Copy this link:", url);
130 }
131 if (ok) {
132 btnLink.textContent = "Copied";
133 setTimeout(() => (btnLink.textContent = "Copy link"), 1500);
134 }
135}
d8f9e54Clean rebuild of the steel sheet simulatorJeremy Magland 137// ---------- View toolbars ----------
139const viewMode = { value: "2d" as ViewMode };
140const scrub = document.createElement("input");
141const timeLabel = document.createElement("span");
142const btnLive = mkButton("Live");
143const btnPlay = mkButton("Play");
144const btnWav = mkButton("Download .wav");
145const levelSel = document.createElement("select");
146const gainSlider = document.createElement("input");
147const gainLabel = document.createElement("span");
148const peakLabel = document.createElement("span");
149let exagInput: HTMLInputElement;
150let rangeInput: HTMLInputElement;
151let shapeCheck: HTMLInputElement;
153function gainDb(): number {
154 return parseFloat(gainSlider.value) || 0;
155}
156function levelMode(): LevelMode {
157 return levelSel.value as LevelMode;
158}
160function updateLevelReadout(): void {
161 gainLabel.textContent = `${gainDb() >= 0 ? "+" : ""}${gainDb()} dB`;
162 gainSlider.disabled = levelMode() !== "absolute";
163 if (!sim || sim.samples === 0) { peakLabel.textContent = ""; return; }
164 const audio = sim.recordedAudio();
165 const db = peakDbfs(audio, levelGain(audio, sim.fs, levelMode(), gainDb()));
166 peakLabel.textContent = isFinite(db) ? `peak ${db.toFixed(1)} dBFS${db > 0 ? " (clipped)" : ""}` : "";
167 peakLabel.className = db > 0 ? "warn" : "sub";
168 peakLabel.style.margin = "0";
169}
171function buildViewToolbar(): void {
172 const tb = $("viewToolbar");
173 const seg = document.createElement("span");
174 seg.className = "seg";
175 const b2 = mkButton("2D", "on"), b3 = mkButton("3D");
176 b2.addEventListener("click", () => { viewMode.value = "2d"; b2.classList.add("on"); b3.classList.remove("on"); });
177 b3.addEventListener("click", () => { viewMode.value = "3d"; b3.classList.add("on"); b2.classList.remove("on"); });
178 seg.append(b2, b3);
179 exagInput = numInput(4, 0.5);
180 rangeInput = numInput(0, 0.5);
181 shapeCheck = document.createElement("input");
182 shapeCheck.type = "checkbox";
8cb683aInitial shape as a list of features, with random configurationsJeremy Magland 183 shapeCheck.checked = true;
184 shapeCheck.title = "Show the total shape (initial shape plus deviation); unticked, only the deviation from the initial shape";
d8f9e54Clean rebuild of the steel sheet simulatorJeremy Magland 185 const hint = document.createElement("span");
186 hint.className = "sub";
187 hint.style.margin = "0";
188 hint.textContent = "3D: drag to orbit, wheel to zoom";
8cb683aInitial shape as a list of features, with random configurationsJeremy Magland 189 tb.append(seg, labeled("Exaggeration ×", exagInput), labeled("Color range (mm, 0 = auto)", rangeInput), labeled("Show initial shape", shapeCheck), hint);
192function buildMovieToolbar(): void {
193 const tb = $("movieToolbar");
194 scrub.type = "range";
195 scrub.min = "0"; scrub.max = "0"; scrub.value = "0";
196 timeLabel.className = "time";
197 // Playback drives the movie; afterwards the view returns to where it was (live or scrubbed).
198 let liveBeforePlay = true;
199 const endPlayback = () => {
200 btnPlay.textContent = "Play";
201 if (liveBeforePlay) {
202 live = true;
203 updateLiveButton();
204 }
205 };
206 scrub.addEventListener("input", () => {
207 live = false;
208 liveBeforePlay = false;
8cb683aInitial shape as a list of features, with random configurationsJeremy Magland 209 previewMode = false;
d8f9e54Clean rebuild of the steel sheet simulatorJeremy Magland 210 scrubFrame = parseInt(scrub.value);
211 if (player.playing) player.stop();
212 updateLiveButton();
213 });
8cb683aInitial shape as a list of features, with random configurationsJeremy Magland 214 btnLive.addEventListener("click", () => { live = true; liveBeforePlay = true; previewMode = false; updateLiveButton(); });
d8f9e54Clean rebuild of the steel sheet simulatorJeremy Magland 215 btnPlay.addEventListener("click", () => {
216 if (player.playing) { player.stop(); endPlayback(); return; }
217 if (!sim || sim.samples === 0) return;
218 liveBeforePlay = live;
219 const offset = live ? 0 : scrubFrame / sim.fps;
220 live = false;
8cb683aInitial shape as a list of features, with random configurationsJeremy Magland 221 previewMode = false;
d8f9e54Clean rebuild of the steel sheet simulatorJeremy Magland 222 player.play(sim.recordedAudio(), sim.fs, offset, levelMode(), gainDb());
223 btnPlay.textContent = "Stop";
224 updateLiveButton();
225 });
226 player.onEnded = endPlayback;
227 btnWav.addEventListener("click", () => {
228 if (!sim || sim.samples === 0) return;
229 const blob = encodeWav(sim.recordedAudio(), sim.fs, levelMode(), gainDb());
230 const a = document.createElement("a");
231 a.href = URL.createObjectURL(blob);
232 a.download = `sheet_${sim.samples}samples_${sim.fs}Hz.wav`;
233 a.click();
234 setTimeout(() => URL.revokeObjectURL(a.href), 5000);
235 });
236 for (const [v, t] of [["absolute", "Absolute (0 dBFS = 1 Pa at 0 dB)"], ["loudness", "Loudness (RMS, soft clip)"], ["peak", "Peak"]]) {
237 const o = document.createElement("option");
238 o.value = v; o.textContent = t;
239 levelSel.appendChild(o);
240 }
241 gainSlider.type = "range";
242 gainSlider.min = "-40"; gainSlider.max = "60"; gainSlider.step = "1"; gainSlider.value = "20";
243 gainSlider.style.width = "120px";
244 gainSlider.style.flex = "none";
245 const restart = () => {
246 updateLevelReadout();
247 const pos = player.position();
248 if (pos !== null && sim) player.play(sim.recordedAudio(), sim.fs, pos, levelMode(), gainDb());
249 };
250 gainSlider.addEventListener("input", restart);
251 levelSel.addEventListener("change", restart);
252 tb.append(btnPlay, btnLive, scrub, timeLabel, labeled("Level", levelSel), labeled("Gain", gainSlider), gainLabel, peakLabel, btnWav);
253 updateLevelReadout();
254 updateLiveButton();
255}
257function updateLiveButton(): void {
258 btnLive.classList.toggle("on", live);
259 btnLive.disabled = live;
260}
262function buildSpecToolbar(): void {
263 const tb = $("specToolbar");
264 const win = document.createElement("select");
265 for (const n of [512, 1024, 2048, 4096, 8192]) {
266 const o = document.createElement("option");
267 o.value = String(n); o.textContent = String(n);
268 win.appendChild(o);
269 }
270 win.value = "2048";
271 const hop = numInput(512, 64);
272 const fmax = numInput(5000, 500);
273 const floor = numInput(80, 5);
274 const logf = document.createElement("input");
275 logf.type = "checkbox";
276 const apply = () => {
277 if (!sim) return;
278 spec.configure(
279 { windowSize: parseInt(win.value), hop: Math.max(16, parseInt(hop.value)), fmax: Math.min(sim.fs / 2, parseFloat(fmax.value)), dbFloor: parseFloat(floor.value), logFrequency: logf.checked },
280 sim.recordedAudio(), sim.samples,
281 );
282 };
283 for (const el of [win, hop, fmax, floor, logf]) el.addEventListener("change", apply);
284 tb.append(document.createTextNode("Spectrogram"), labeled("Window", win), labeled("Hop", hop), labeled("Max Hz", fmax), labeled("dB range", floor), labeled("Log freq.", logf));
285}
287function numInput(v: number, step: number): HTMLInputElement {
288 const i = document.createElement("input");
289 i.type = "number"; i.value = String(v); i.step = String(step);
290 return i;
291}
292function labeled(text: string, el: HTMLElement): HTMLLabelElement {
293 const l = document.createElement("label");
294 l.append(text, el);
295 return l;
296}
298// ---------- Simulation control ----------
300async function start(): Promise<void> {
301 if (!device) return;
302 player.stop();
303 running = false;
304 await new Promise((r) => setTimeout(r, 0));
305 sim?.destroy();
306 spec.reset();
307 live = true;
308 paused = false;
309 btnPause.textContent = "Pause";
310 params.duration = duration;
311 sim = new Simulation(device, params);
8cb683aInitial shape as a list of features, with random configurationsJeremy Magland 312 renderer?.setField(sim.grid, sim.w0, sim.nw, sim.w0Max);
313 previewMode = false;
315 movieBuf = device.createBuffer({ size: Math.max(16, sim.grid.n * 4), usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
316 movieBufFrame = -1;
317 scrub.max = "0"; scrub.value = "0";
318 lastStatsTime = performance.now(); lastStatsSteps = 0; stepsPerSec = 0;
319 form.setEnabled(false);
320 updateLiveButton();
321 void runLoop();
322}
324async function runLoop(): Promise<void> {
325 if (!sim || running) return;
326 running = true;
327 updateButtons();
328 try {
329 while (sim.phase !== "done" && !paused) {
330 await sim.runBatch();
331 spec.update(sim.recordedAudio(), sim.samples);
332 scrub.max = String(Math.max(0, sim.frames.length - 1));
333 if (live) scrub.value = scrub.max;
334 updateLevelReadout();
335 const now = performance.now();
336 if (now - lastStatsTime > 500) {
337 stepsPerSec = ((sim.totalSteps - lastStatsSteps) * 1000) / (now - lastStatsTime);
338 lastStatsTime = now; lastStatsSteps = sim.totalSteps;
339 }
340 }
341 } catch (e) {
342 console.error(e);
343 $("info").innerHTML = `<div class="err">Simulation error: ${String(e)}</div>`;
344 }
345 running = false;
346 if (sim.phase === "done") form.setEnabled(true);
347 updateButtons();
348}
350// ---------- Render loop ----------
352let frameErrorShown = false;
353let lastRenderKey = "";
354let lastInfoHtml = "";
356function frame(): void {
357 requestAnimationFrame(frame);
358 try {
359 frameBody();
360 } catch (e) {
361 if (!frameErrorShown) { frameErrorShown = true; console.error("frame exception:", e); }
362 }
363}
365function frameBody(): void {
8cb683aInitial shape as a list of features, with random configurationsJeremy Magland 366 if (!device || !renderer) return;
368 renderer.exaggeration = parseFloat(exagInput.value) || 1;
369 renderer.colorRange = (parseFloat(rangeInput.value) || 0) * 1e-3;
370 renderer.showShape = shapeCheck.checked;
8cb683aInitial shape as a list of features, with random configurationsJeremy Magland 371 const canvas = $<HTMLCanvasElement>("view");
372 const cam = renderer.camera;
373 const viewKey = [
374 renderer.mode, renderer.exaggeration, renderer.colorRange, renderer.showShape,
375 cam.yaw.toFixed(4), cam.pitch.toFixed(4), cam.dist.toFixed(4),
376 canvas.clientWidth, canvas.clientHeight, window.devicePixelRatio,
377 ];
378 if (previewMode || !sim) {
379 // The resting shape of the sheet that the next Start will use.
380 ensurePreview(device);
381 const renderKey = ["preview", ...viewKey, params.excitation, params.strikeFracX, params.strikeFracY, params.holeInset].join("|");
382 if (renderKey !== lastRenderKey) {
383 lastRenderKey = renderKey;
384 renderPreview(device);
385 }
386 if (sim) {
387 spec.draw($("spec"), sim.fs, Math.max(duration, sim.samples / sim.fs), null);
388 updateInfo(sim.status());
389 }
390 return;
391 }
393 if (pos !== null) {
394 scrubFrame = Math.min(sim.frames.length - 1, Math.floor(pos * sim.fps));
395 scrub.value = String(scrubFrame);
396 }
397 const st = sim.status();
398 // Render only when something visible changed, so an idle or paused page does no work.
8cb683aInitial shape as a list of features, with random configurationsJeremy Magland 399 const renderKey = [...viewKey, live, scrubFrame, sim.frames.length, st.totalSteps, st.phase].join("|");
d8f9e54Clean rebuild of the steel sheet simulatorJeremy Magland 400 if (renderKey !== lastRenderKey) {
401 lastRenderKey = renderKey;
402 renderView(device, st);
403 }
404 spec.draw($("spec"), sim.fs, Math.max(duration, sim.samples / sim.fs), pos ?? (live ? null : scrubFrame / sim.fps));
405 updateInfo(st);
406}
8cb683aInitial shape as a list of features, with random configurationsJeremy Magland 408/** Builds the preview buffers from the current parameters when they are missing or stale. */
409function ensurePreview(dev: GPUDevice): void {
410 if (preview && !preview.stale) return;
411 if (preview) for (const b of [preview.w0, preview.zero, preview.nw]) b.destroy();
412 const grid = makeGrid(params);
413 const shape = initialShape(params, grid);
414 let wmax = 0;
415 for (let k = 0; k < shape.length; k++) wmax = Math.max(wmax, Math.abs(shape[k]));
416 const mk = (data: Float32Array<ArrayBuffer>) => {
417 const b = dev.createBuffer({ size: Math.max(16, data.byteLength), usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
418 dev.queue.writeBuffer(b, 0, data);
419 return b;
420 };
421 preview = { grid, w0: mk(shape), zero: mk(new Float32Array(grid.n)), nw: mk(nodeWeights(grid)), wmax, stale: false };
422 renderer?.setField(grid, preview.zero, preview.nw);
423 lastRenderKey = "";
424}
426/** Marks the preview stale (parameters changed) and shows it; the next frame rebuilds it. */
427function showPreview(): void {
428 if (preview) preview.stale = true;
429 previewMode = true;
430 if (!sim) updateIdleInfo();
431}
433function renderPreview(dev: GPUDevice): void {
434 if (!renderer || !preview) return;
435 // The renderer's "initial shape" buffer is zero here, and the shape itself is the displayed field.
436 if (renderer.fieldGrid !== preview.grid) renderer.setField(preview.grid, preview.zero, preview.nw);
437 const enc = dev.createCommandEncoder();
438 renderer.render(enc, preview.w0, preview.wmax);
439 timeLabel.textContent = "initial shape";
440 dev.pushErrorScope("validation");
441 dev.queue.submit([enc.finish()]);
442 void dev.popErrorScope().then((e) => { if (e && !frameErrorShown) { frameErrorShown = true; console.error("frame validation error:", e.message); } });
443}
445function updateIdleInfo(): void {
446 const { grid, inside } = form.gridInfo();
447 const html = `<div class="row"><span>Grid</span><span>${grid.nx} × ${grid.ny} nodes${grid.mask ? ` (${inside} in the sheet)` : ""}</span></div><p class="sub">The view shows the stress-free initial shape. Press Start to run.</p>`;
448 if (html !== lastInfoHtml) {
449 lastInfoHtml = html;
450 $("info").innerHTML = html;
451 }
452}
d8f9e54Clean rebuild of the steel sheet simulatorJeremy Magland 454function renderView(dev: GPUDevice, st: SimStatus): void {
455 if (!sim || !renderer) return;
8cb683aInitial shape as a list of features, with random configurationsJeremy Magland 456 if (renderer.fieldGrid !== sim.grid) renderer.setField(sim.grid, sim.w0, sim.nw, sim.w0Max);
d8f9e54Clean rebuild of the steel sheet simulatorJeremy Magland 457 const enc = dev.createCommandEncoder();
458 if (live || sim.frames.length === 0) {
459 sim.encodeDisplay(enc);
460 renderer.render(enc, sim.displayW, st.wmax);
461 timeLabel.textContent = `${st.t >= 0 ? "" : "prep "}${st.t.toFixed(3)} s`;
462 } else {
463 const f = Math.max(0, Math.min(sim.frames.length - 1, scrubFrame));
464 if (movieBufFrame !== f) {
465 dev.queue.writeBuffer(movieBuf!, 0, sim.frames[f]);
466 movieBufFrame = f;
467 }
468 let wmax = 0;
469 const fr = sim.frames[f];
470 for (let k = 0; k < fr.length; k += 7) wmax = Math.max(wmax, Math.abs(fr[k]));
471 renderer.render(enc, movieBuf!, wmax);
472 timeLabel.textContent = `${(f / sim.fps).toFixed(3)} s`;
473 }
474 dev.pushErrorScope("validation");
475 dev.queue.submit([enc.finish()]);
476 void dev.popErrorScope().then((e) => { if (e && !frameErrorShown) { frameErrorShown = true; console.error("frame validation error:", e.message); } });
477}
479function updateInfo(st: SimStatus): void {
480 if (!sim) return;
481 const g = sim.grid;
482 const { inside } = form.gridInfo();
483 const spl = st.peakPressure > 0 ? 20 * Math.log10(st.peakPressure / 2e-5) : -Infinity;
484 const phase = { push: "pushing", hold: "settling (held)", recording: "ringing (recording)", done: "done" }[st.phase];
485 const rows: [string, string][] = [
486 ["Phase", phase],
487 ["Time", `${st.t.toFixed(3)} s`],
488 ["Grid", `${g.nx} × ${g.ny} nodes${g.mask ? ` (${inside} in the sheet)` : ""}, ${(g.hx * 1e3).toFixed(2)} × ${(g.hy * 1e3).toFixed(2)} mm`],
489 ["Time step", `${(sim.dt * 1e6).toFixed(3)} µs (${sim.stepsPerSample} per sample)`],
490 ["Speed", `${(stepsPerSec / 1e3).toFixed(0)} k steps/s (${(stepsPerSec * sim.dt).toFixed(3)}× real time)`],
491 ["Kinetic energy", `${st.ke.toExponential(2)} J`],
492 ["Max |w|", `${(st.wmax * 1e3).toFixed(3)} mm`],
493 ["Audio", `${(st.samples / sim.fs).toFixed(2)} s, ${st.frames} frames`],
494 ["Peak pressure", st.peakPressure > 0 ? `${st.peakPressure.toFixed(3)} Pa (${spl.toFixed(1)} dB SPL)` : "–"],
495 ];
496 if (st.stick) {
a2a1b68Drumstick by default, hit sequences, 170 GPa steelJeremy Magland 497 if (st.hits && st.hits.total > 1) rows.push(["Hits", `${st.hits.done} of ${st.hits.total}`]);
d8f9e54Clean rebuild of the steel sheet simulatorJeremy Magland 498 rows.push(["Stick", `${(st.stick.x * 1e3).toFixed(2)} mm, ${st.stick.v.toFixed(2)} m/s`]);
499 rows.push(["Contact force", `${st.stick.force.toFixed(1)} N (max ${st.stick.maxForce.toFixed(1)} N)`]);
500 }
501 let html = rows.map(([a, b]) => `<div class="row"><span>${a}</span><span>${b}</span></div>`).join("");
502 if (st.delayClamped) html += `<div class="warn">The listener is so close that some propagation delays were clamped.</div>`;
503 if (!isFinite(st.wmax) || st.wmax > 1) html += `<div class="err">The solution has diverged. Increase the grid spacing (which lengthens the time step) or reduce the excitation.</div>`;
504 if (html !== lastInfoHtml) {
505 lastInfoHtml = html;
506 $("info").innerHTML = html;
507 }
508}
510// ---------- Self-test ----------
512let selfTestResult: { done: boolean; passed?: boolean; cases?: SelfTestCase[]; error?: string } = { done: false };
514async function selfTest(): Promise<void> {
515 if (!device) { selfTestResult = { done: true, passed: false, error: "no GPU device" }; return; }
516 $("info").innerHTML = `<p class="sub">Running the GPU-versus-CPU self-test...</p>`;
517 try {
518 const cases = await runSelfTest(device);
519 const passed = cases.every(selfTestPassed);
520 selfTestResult = { done: true, passed, cases };
521 const rows = cases.map((c) => `<div class="row"><span>${c.name}</span><span class="${selfTestPassed(c) ? "" : "err"}">acc ${c.accError.u.toExponential(1)} / ${c.accError.v.toExponential(1)} / ${c.accError.w.toExponential(1)}, pos ${c.positionError.toExponential(1)}</span></div>`);
522 $("info").innerHTML = `<div class="${passed ? "row" : "err"}">Self-test ${passed ? "passed" : "FAILED"}</div>${rows.join("")}<p class="sub">Maximum relative errors of the u, v, w accelerations and of the compensated w update, GPU step versus float64 reference.</p>`;
523 } catch (e) {
524 selfTestResult = { done: true, passed: false, error: String(e) };
525 $("info").innerHTML = `<div class="err">Self-test error: ${String(e)}</div>`;
526 }
527}
529// ---------- Init ----------
531async function init(): Promise<void> {
532 form = new ParamForm($("params"), params);
533 form.onChange = (key, isLive) => {
8cb683aInitial shape as a list of features, with random configurationsJeremy Magland 534 if (!isLive) { showPreview(); return; }
535 if (!sim) return;
d8f9e54Clean rebuild of the steel sheet simulatorJeremy Magland 536 if (key === "sigma0" || key === "sigma1" || key === "sigmaIn") sim.setDamping(params.sigma0, params.sigma1, params.sigmaIn);
537 if (key.startsWith("listener")) sim.setListener(params.listenerDist, params.listenerAzimuth, params.listenerElevation);
538 };
539 buildControls();
540 buildViewToolbar();
541 buildMovieToolbar();
542 buildSpecToolbar();
543 const q = new URLSearchParams(location.search);
544 const set = form.applyUrl(q);
545 if (set.includes("duration")) {
546 duration = params.duration;
547 durInput.value = String(duration);
548 }
549 exposeHooks();
550 if (!("gpu" in navigator)) {
551 showNoGpu("WebGPU is not available in this browser. Use a recent Chrome or Edge, or Firefox or Safari with WebGPU enabled.");
552 return;
553 }
554 const adapter = (await navigator.gpu.requestAdapter({ powerPreference: "high-performance" })) ?? (await navigator.gpu.requestAdapter());
555 if (!adapter) {
556 showNoGpu("No WebGPU adapter was found.");
557 return;
558 }
559 device = await adapter.requestDevice();
560 device.lost.then((info) => showNoGpu(`WebGPU device lost: ${info.message}`));
561 device.addEventListener("uncapturederror", (ev) => console.error("WebGPU error:", (ev as GPUUncapturedErrorEvent).error.message));
8cb683aInitial shape as a list of features, with random configurationsJeremy Magland 562 renderer = new Renderer(device, $("view"), params);
563 updateIdleInfo();
565 if (q.get("selftest") === "1") await selfTest();
566 else if (q.get("autostart") === "1") void start();
567}
569/** Hooks for automated tests, on window.steelsim. */
570function exposeHooks(): void {
571 (window as unknown as { steelsim: unknown }).steelsim = {
572 status: () => (sim ? { ...sim.status(), dt: sim.dt, stepsPerSample: sim.stepsPerSample, nx: sim.grid.nx, ny: sim.grid.ny, stepsPerSec } : null),
573 audio: () => (sim ? Array.from(sim.recordedAudio()) : null),
574 frames: () => sim?.frames.length ?? 0,
8cb683aInitial shape as a list of features, with random configurationsJeremy Magland 575 features: () => encodeFeatures(params.features),
576 link: () => currentLink(),
578 noGpu: () => { const e = $("nogpu"); return e.hidden ? null : e.textContent; },
579 };
580}
582function showNoGpu(msg: string): void {
583 const el = $("nogpu");
584 el.hidden = false;
585 el.textContent = msg;
586 btnStart.disabled = true;
587 selfTestResult = { done: true, passed: false, error: msg };
588}
590void init();