1// Application: design editor, parameter form, run loop, live and movie
2// views, spectrogram, playback, WAV download, and the test hooks on
3// window.steelsim.
5import { type Plate, type SimParams, countInside, defaultParams, listenerPoint, massProperties, mountPatch, nodeWeights, strikePoint, thicknessRange } from "./params.ts";
6import { DesignError, evaluatePlate, profileStats } from "./design.ts";
7import { PRESETS } from "./presets.ts";
8import { Simulation, type SimStatus } from "./sim/simulation.ts";
9import { Renderer, type ColorMode, type ViewMode } from "./render/renderer.ts";
10import { Spectrogram } from "./audio/spectrogram.ts";
11import { Player } from "./audio/player.ts";
12import { encodeWav } from "./audio/wav.ts";
13import { levelGain, peakDbfs, type LevelMode } from "./audio/level.ts";
14import { ParamForm } from "./ui/form.ts";
15import { DesignEditor } from "./ui/editor.ts";
16import { runSelfTest, selfTestPassed, type SelfTestCase } from "./selftest.ts";
18const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
20/** Slope above which the shallow-shell treatment of the profile is doubtful. */
21const SLOPE_LIMIT = 0.1;
23// ---------- State ----------
25const params: SimParams = { ...defaultParams };
26let form: ParamForm;
27let editor: DesignEditor;
28let device: GPUDevice | null = null;
29let sim: Simulation | null = null;
30let renderer: Renderer | null = null;
31let running = false;
32let paused = false;
33let live = true;
34let scrubFrame = 0;
35let movieBuf: GPUBuffer | null = null;
36let movieBufFrame = -1;
37/** The design evaluated at the current spacing, or null while the script has an error. */
38let plate: Plate | null = null;
39let plateError: string | null = null;
40/** Show the design (its stress-free shape) instead of the simulation. */
41let previewMode = true;
42let preview: { plate: Plate; w0: GPUBuffer; zero: GPUBuffer; nw: GPUBuffer; hn: GPUBuffer; wmax: number; stale: boolean } | null = null;
43const spec = new Spectrogram({ windowSize: 2048, hop: 512, fmax: 5000, dbFloor: 80, logFrequency: false });
44const player = new Player();
45let lastStatsTime = 0, lastStatsSteps = 0, stepsPerSec = 0;
46let duration = params.duration;
48// ---------- Controls ----------
50const btnStart = mkButton("Start", "primary");
51const btnPause = mkButton("Pause");
52const btnStop = mkButton("Stop");
53const btnContinue = mkButton("Continue");
54const durInput = document.createElement("input");
56function mkButton(text: string, cls = ""): HTMLButtonElement {
57 const b = document.createElement("button");
58 b.textContent = text;
59 b.className = cls;
60 return b;
61}
63function buildControls(): void {
64 const c = $("controls");
65 const durRow = document.createElement("div");
66 durRow.className = "field";
67 const l = document.createElement("label");
68 l.textContent = "Recorded duration";
69 durInput.type = "number";
70 durInput.step = "0.5";
71 durInput.min = "0.1";
72 durInput.value = String(duration);
73 durInput.addEventListener("change", () => {
74 const v = parseFloat(durInput.value);
75 if (isFinite(v) && v > 0) {
76 duration = v;
77 params.duration = v;
78 sim?.setDuration(v);
79 form.updateNotes();
80 updateButtons();
81 }
82 });
83 const u = document.createElement("span");
84 u.className = "unit";
85 u.textContent = "s";
86 durRow.append(l, durInput, u);
87 c.append(durRow, btnStart, btnPause, btnStop, btnContinue);
88 btnStart.addEventListener("click", () => void start());
89 btnPause.addEventListener("click", () => {
90 paused = !paused;
91 btnPause.textContent = paused ? "Resume" : "Pause";
92 if (!paused) void runLoop();
93 });
94 btnStop.addEventListener("click", () => {
95 if (!sim) return;
96 sim.stop();
97 paused = false;
98 btnPause.textContent = "Pause";
99 if (!running) {
100 setEditable(true);
101 updateButtons();
102 }
103 });
104 btnContinue.addEventListener("click", () => {
105 if (!sim) return;
106 sim.setDuration(duration);
107 if (sim.phase !== "done") void runLoop();
108 updateButtons();
109 });
110 updateButtons();
111}
113function updateButtons(): void {
114 const s = sim?.status();
115 btnStart.disabled = !device || !plate;
116 btnPause.disabled = !sim || s?.phase === "done" || (!running && !paused);
117 btnStop.disabled = !sim || s?.phase === "done";
118 btnContinue.disabled = !sim || running || !sim.canContinue();
119}
121function setEditable(enabled: boolean): void {
122 form.setEnabled(enabled);
123 editor.setEnabled(enabled);
124}
126// ---------- Splitter ----------
128const SIDEBAR_KEY = "steelsim41.sidebarWidth";
129const SIDEBAR_DEFAULT = 420;
130const SIDEBAR_MIN = 280;
131/** Leaves at least this much for the view, so the panel cannot swallow the page. */
132const MAIN_MIN = 320;
134function sidebarWidth(w: number): number {
135 return Math.round(Math.max(SIDEBAR_MIN, Math.min(w, Math.max(SIDEBAR_MIN, window.innerWidth - MAIN_MIN))));
136}
138/** Drag handle between the panel and the view; the width is remembered across visits. */
139function buildSplitter(): void {
140 const bar = $("splitter");
141 const sidebar = $("sidebar");
142 const setWidth = (w: number, remember: boolean) => {
143 const px = sidebarWidth(w);
144 sidebar.style.width = `${px}px`;
145 if (remember) try { localStorage.setItem(SIDEBAR_KEY, String(px)); } catch { /* storage may be unavailable */ }
146 };
147 let stored = NaN;
148 try { stored = parseFloat(localStorage.getItem(SIDEBAR_KEY) ?? ""); } catch { /* ignore */ }
149 setWidth(isFinite(stored) ? stored : SIDEBAR_DEFAULT, false);
150 let dragging = false;
151 bar.addEventListener("pointerdown", (e) => {
152 dragging = true;
153 bar.setPointerCapture(e.pointerId);
154 bar.classList.add("dragging");
155 document.body.classList.add("resizing");
156 e.preventDefault();
157 });
158 bar.addEventListener("pointermove", (e) => {
159 if (!dragging) return;
160 setWidth(e.clientX - sidebar.getBoundingClientRect().left, false);
161 });
162 const end = (e: PointerEvent) => {
163 if (!dragging) return;
164 dragging = false;
165 bar.releasePointerCapture(e.pointerId);
166 bar.classList.remove("dragging");
167 document.body.classList.remove("resizing");
168 setWidth(sidebar.getBoundingClientRect().width, true);
169 };
170 bar.addEventListener("pointerup", end);
171 bar.addEventListener("pointercancel", end);
172 bar.addEventListener("dblclick", () => setWidth(SIDEBAR_DEFAULT, true));
173 // A narrower window may leave the stored width too wide for the view.
174 window.addEventListener("resize", () => setWidth(sidebar.getBoundingClientRect().width, false));
175}
177// ---------- View toolbars ----------
179const viewMode = { value: "3d" as ViewMode };
180const colorSel = document.createElement("select");
181const scrub = document.createElement("input");
182const timeLabel = document.createElement("span");
183const btnLive = mkButton("Live");
184const btnDesign = mkButton("Design");
185const btnPlay = mkButton("Play");
186const btnWav = mkButton("Download .wav");
187const levelSel = document.createElement("select");
188const gainSlider = document.createElement("input");
189const gainLabel = document.createElement("span");
190const peakLabel = document.createElement("span");
191let exagInput: HTMLInputElement;
192let rangeInput: HTMLInputElement;
193let shapeCheck: HTMLInputElement;
195function gainDb(): number {
196 return parseFloat(gainSlider.value) || 0;
197}
198function levelMode(): LevelMode {
199 return levelSel.value as LevelMode;
200}
202function updateLevelReadout(): void {
203 gainLabel.textContent = `${gainDb() >= 0 ? "+" : ""}${gainDb()} dB`;
204 gainSlider.disabled = levelMode() !== "absolute";
205 if (!sim || sim.samples === 0) { peakLabel.textContent = ""; return; }
206 const audio = sim.recordedAudio();
207 const db = peakDbfs(audio, levelGain(audio, sim.fs, levelMode(), gainDb()));
208 peakLabel.textContent = isFinite(db) ? `peak ${db.toFixed(1)} dBFS${db > 0 ? " (clipped)" : ""}` : "";
209 peakLabel.className = db > 0 ? "warn" : "sub";
210 peakLabel.style.margin = "0";
211}
213function buildViewToolbar(): void {
214 const tb = $("viewToolbar");
215 const seg = document.createElement("span");
216 seg.className = "seg";
217 const b2 = mkButton("2D"), b3 = mkButton("3D", "on");
218 b2.addEventListener("click", () => { viewMode.value = "2d"; b2.classList.add("on"); b3.classList.remove("on"); });
219 b3.addEventListener("click", () => { viewMode.value = "3d"; b3.classList.add("on"); b2.classList.remove("on"); });
220 seg.append(b2, b3);
221 for (const [v, t] of [["field", "Height / displacement"], ["thickness", "Thickness"]]) {
222 const o = document.createElement("option");
223 o.value = v; o.textContent = t;
224 colorSel.appendChild(o);
225 }
226 exagInput = numInput(1, 0.5);
227 rangeInput = numInput(0, 0.5);
228 shapeCheck = document.createElement("input");
229 shapeCheck.type = "checkbox";
230 shapeCheck.checked = true;
231 shapeCheck.title = "Show the total shape (design profile plus vibration); unticked, only the vibration";
232 const hint = document.createElement("span");
233 hint.className = "sub";
234 hint.style.margin = "0";
235 hint.textContent = "3D: drag to orbit, wheel to zoom, double-click to reframe";
236 tb.append(seg, labeled("Color", colorSel), labeled("Exaggeration ×", exagInput), labeled("Color range (mm, 0 = auto)", rangeInput), labeled("Show profile", shapeCheck), hint);
237}
239function buildMovieToolbar(): void {
240 const tb = $("movieToolbar");
241 scrub.type = "range";
242 scrub.min = "0"; scrub.max = "0"; scrub.value = "0";
243 timeLabel.className = "time";
244 // Playback drives the movie; afterwards the view returns to where it was (live or scrubbed).
245 let liveBeforePlay = true;
246 const endPlayback = () => {
247 btnPlay.textContent = "Play";
248 if (liveBeforePlay) {
249 live = true;
250 updateLiveButton();
251 }
252 };
253 scrub.addEventListener("input", () => {
254 live = false;
255 liveBeforePlay = false;
256 previewMode = false;
257 scrubFrame = parseInt(scrub.value);
258 if (player.playing) player.stop();
259 updateLiveButton();
260 });
261 btnLive.addEventListener("click", () => { live = true; liveBeforePlay = true; previewMode = false; updateLiveButton(); });
262 btnDesign.title = "Show the design's stress-free shape instead of the simulation";
263 btnDesign.addEventListener("click", () => { showPreview(); updateLiveButton(); });
264 btnPlay.addEventListener("click", () => {
265 if (player.playing) { player.stop(); endPlayback(); return; }
266 if (!sim || sim.samples === 0) return;
267 liveBeforePlay = live;
268 const offset = live ? 0 : scrubFrame / sim.fps;
269 live = false;
270 previewMode = false;
271 player.play(sim.recordedAudio(), sim.fs, offset, levelMode(), gainDb());
272 btnPlay.textContent = "Stop";
273 updateLiveButton();
274 });
275 player.onEnded = endPlayback;
276 btnWav.addEventListener("click", () => {
277 if (!sim || sim.samples === 0) return;
278 const blob = encodeWav(sim.recordedAudio(), sim.fs, levelMode(), gainDb());
279 const a = document.createElement("a");
280 a.href = URL.createObjectURL(blob);
281 const name = sim.plate.name.replace(/[^a-z0-9]+/gi, "_").replace(/^_+|_+$/g, "").toLowerCase() || "cymbal";
282 a.download = `${name}_${(sim.samples / sim.fs).toFixed(1)}s_${sim.fs}Hz.wav`;
283 a.click();
284 setTimeout(() => URL.revokeObjectURL(a.href), 5000);
285 });
286 for (const [v, t] of [["absolute", "Absolute (0 dBFS = 1 Pa at 0 dB)"], ["loudness", "Loudness (RMS, soft clip)"], ["peak", "Peak"]]) {
287 const o = document.createElement("option");
288 o.value = v; o.textContent = t;
289 levelSel.appendChild(o);
290 }
291 gainSlider.type = "range";
292 // A hard strike heard from 0.3 m peaks near 40 Pa, so absolute level needs a large cut to stay under full scale.
293 gainSlider.min = "-60"; gainSlider.max = "60"; gainSlider.step = "1"; gainSlider.value = "-34";
294 gainSlider.style.width = "120px";
295 gainSlider.style.flex = "none";
296 const restart = () => {
297 updateLevelReadout();
298 const pos = player.position();
299 if (pos !== null && sim) player.play(sim.recordedAudio(), sim.fs, pos, levelMode(), gainDb());
300 };
301 gainSlider.addEventListener("input", restart);
302 levelSel.addEventListener("change", restart);
303 tb.append(btnPlay, btnLive, btnDesign, scrub, timeLabel, labeled("Level", levelSel), labeled("Gain", gainSlider), gainLabel, peakLabel, btnWav);
304 updateLevelReadout();
305 updateLiveButton();
306}
308function updateLiveButton(): void {
309 btnLive.classList.toggle("on", live && !previewMode);
310 btnLive.disabled = !sim || (live && !previewMode);
311 btnDesign.classList.toggle("on", previewMode);
312 btnDesign.disabled = previewMode;
313}
315function buildSpecToolbar(): void {
316 const tb = $("specToolbar");
317 const win = document.createElement("select");
318 for (const n of [512, 1024, 2048, 4096, 8192]) {
319 const o = document.createElement("option");
320 o.value = String(n); o.textContent = String(n);
321 win.appendChild(o);
322 }
323 win.value = "2048";
324 const hop = numInput(512, 64);
325 const fmax = numInput(5000, 500);
326 const floor = numInput(80, 5);
327 const logf = document.createElement("input");
328 logf.type = "checkbox";
329 const apply = () => {
330 if (!sim) return;
331 spec.configure(
332 { 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 },
333 sim.recordedAudio(), sim.samples,
334 );
335 };
336 for (const el of [win, hop, fmax, floor, logf]) el.addEventListener("change", apply);
337 tb.append(document.createTextNode("Spectrogram"), labeled("Window", win), labeled("Hop", hop), labeled("Max Hz", fmax), labeled("dB range", floor), labeled("Log freq.", logf));
338}
340function numInput(v: number, step: number): HTMLInputElement {
341 const i = document.createElement("input");
342 i.type = "number"; i.value = String(v); i.step = String(step);
343 return i;
344}
345function labeled(text: string, el: HTMLElement): HTMLLabelElement {
346 const l = document.createElement("label");
347 l.append(text, el);
348 return l;
349}
351// ---------- Design ----------
353/** Re-evaluates the current design at the current spacing and refreshes everything that depends on it. */
354function rebuildPlate(): void {
355 const design = editor.design;
356 plate = null;
357 plateError = editor.error;
358 if (design) {
359 try {
360 plate = evaluatePlate(design, params.spacing);
361 } catch (e) {
362 plateError = e instanceof DesignError ? e.message : String(e);
363 }
364 }
365 form.setPlate(plate);
366 editor.setStatus(plateError, plate ? designSummary(plate) : "");
367 showPreview();
368 updateButtons();
369}
371function fmtMm(v: number): string {
372 return `${(v * 1e3).toFixed(2)} mm`;
373}
375function designSummary(pl: Plate): string {
376 const g = pl.grid;
377 const hr = thicknessRange(pl);
378 const { mass } = massProperties(pl);
379 const { peakToPeak, maxSlope } = profileStats(pl);
380 const rows: [string, string][] = [
381 ["Design", pl.name],
382 ["Bounding box", `${fmtMm(pl.Lx)} × ${fmtMm(pl.Ly)} (${(pl.Lx / 0.0254).toFixed(1)} × ${(pl.Ly / 0.0254).toFixed(1)} in)`],
383 ["Material", `E ${(pl.mat.E / 1e9).toFixed(0)} GPa, ν ${pl.mat.nu}, ρ ${pl.mat.rho} kg/m³`],
384 ["Thickness", hr.min === hr.max ? fmtMm(hr.min) : `${fmtMm(hr.min)} to ${fmtMm(hr.max)} (mean ${fmtMm(hr.mean)})`],
385 ["Mass", `${(mass * 1e3).toFixed(0)} g`],
386 ["Grid", `${g.nx} × ${g.ny}, ${countInside(g)} nodes in the plate`],
387 ["Profile", peakToPeak > 0 ? `${fmtMm(peakToPeak)} peak to peak, largest slope ${maxSlope.toFixed(3)}` : "flat"],
388 ];
389 if (pl.mount) {
390 const mp = mountPatch(pl);
391 rows.push(["Mount", `${mp?.nodes.length ?? 0} nodes within ${fmtMm(pl.mount.radius)} of (${fmtMm(pl.mount.x)}, ${fmtMm(pl.mount.y)}); ${pl.mount.stiffness.toExponential(1)} N/m, ${pl.mount.damping} N s/m`]);
392 } else {
393 rows.push(["Mount", "none (free plate, inertia relief)"]);
394 }
395 rows.push(["Losses", `σ₀ ${pl.loss.sigma0} 1/s, σ₁ ${pl.loss.sigma1} m²/s, in-plane ${pl.loss.sigmaIn} 1/s`]);
396 let html = rows.map(([a, b]) => `<div class="row"><span>${a}</span><span>${b}</span></div>`).join("");
397 if (maxSlope > SLOPE_LIMIT) html += `<div class="warn">The largest slope of the profile, ${maxSlope.toFixed(2)}, is beyond the shallow-shell range of about ${SLOPE_LIMIT}; the bell or the bow is steeper than the model treats accurately.</div>`;
398 return html;
399}
401// ---------- Simulation control ----------
403async function start(): Promise<void> {
404 if (!device || !plate) return;
405 player.stop();
406 running = false;
407 await new Promise((r) => setTimeout(r, 0));
408 sim?.destroy();
409 spec.reset();
410 live = true;
411 paused = false;
412 btnPause.textContent = "Pause";
413 params.duration = duration;
414 try {
415 sim = new Simulation(device, plate, params);
416 } catch (e) {
417 $("info").innerHTML = `<div class="err">Could not start: ${String(e)}</div>`;
418 return;
419 }
420 const hr = thicknessRange(plate);
421 renderer?.setField(sim.grid, sim.w0, sim.nw, sim.hn, sim.w0Max, [hr.min, hr.max]);
422 previewMode = false;
423 movieBuf?.destroy();
424 movieBuf = device.createBuffer({ size: Math.max(16, sim.grid.n * 4), usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
425 movieBufFrame = -1;
426 scrub.max = "0"; scrub.value = "0";
427 lastStatsTime = performance.now(); lastStatsSteps = 0; stepsPerSec = 0;
428 setEditable(false);
429 updateLiveButton();
430 void runLoop();
431}
433async function runLoop(): Promise<void> {
434 if (!sim || running) return;
435 running = true;
436 updateButtons();
437 try {
438 while (sim.phase !== "done" && !paused) {
439 await sim.runBatch();
440 spec.update(sim.recordedAudio(), sim.samples);
441 scrub.max = String(Math.max(0, sim.frames.length - 1));
442 if (live) scrub.value = scrub.max;
443 updateLevelReadout();
444 const now = performance.now();
445 if (now - lastStatsTime > 500) {
446 stepsPerSec = ((sim.totalSteps - lastStatsSteps) * 1000) / (now - lastStatsTime);
447 lastStatsTime = now; lastStatsSteps = sim.totalSteps;
448 }
449 }
450 } catch (e) {
451 console.error(e);
452 $("info").innerHTML = `<div class="err">Simulation error: ${String(e)}</div>`;
453 }
454 running = false;
455 if (sim.phase === "done") setEditable(true);
456 updateButtons();
457}
459// ---------- Render loop ----------
461let frameErrorShown = false;
462let lastRenderKey = "";
463let lastInfoHtml = "";
465function frame(): void {
466 requestAnimationFrame(frame);
467 try {
468 frameBody();
469 } catch (e) {
470 if (!frameErrorShown) { frameErrorShown = true; console.error("frame exception:", e); }
471 }
472}
474function markers(pl: Plate): void {
475 if (!renderer) return;
476 const [sx, sy] = strikePoint(params, pl);
477 renderer.strike = { x: sx, y: sy, r: params.contactRadius };
478 renderer.mount = pl.mount ? { x: pl.mount.x, y: pl.mount.y, r: pl.mount.radius } : null;
479 renderer.listener = listenerPoint(params.listenerDist, params.listenerAzimuth, params.listenerElevation);
480}
482function frameBody(): void {
483 if (!device || !renderer) return;
484 renderer.mode = viewMode.value;
485 renderer.colorMode = colorSel.value as ColorMode;
486 renderer.exaggeration = parseFloat(exagInput.value) || 1;
487 renderer.colorRange = (parseFloat(rangeInput.value) || 0) * 1e-3;
488 renderer.showShape = shapeCheck.checked;
489 const canvas = $<HTMLCanvasElement>("view");
490 const cam = renderer.camera;
491 const viewKey = [
492 renderer.mode, renderer.colorMode, renderer.exaggeration, renderer.colorRange, renderer.showShape,
493 cam.yaw.toFixed(4), cam.pitch.toFixed(4), cam.dist.toFixed(4),
494 canvas.clientWidth, canvas.clientHeight, window.devicePixelRatio,
495 params.strikeFracX, params.strikeFracY, params.contactRadius,
496 params.listenerDist, params.listenerAzimuth, params.listenerElevation,
497 ];
498 if (previewMode || !sim) {
499 // The stress-free shape of the design that the next Start will use.
500 ensurePreview(device);
501 const renderKey = ["preview", ...viewKey, preview?.plate.name, preview?.plate.grid.n].join("|");
502 if (renderKey !== lastRenderKey) {
503 lastRenderKey = renderKey;
504 renderPreview(device);
505 }
506 if (sim) {
507 spec.draw($("spec"), sim.fs, Math.max(duration, sim.samples / sim.fs), null);
508 updateInfo(sim.status());
509 }
510 return;
511 }
512 const pos = player.position();
513 if (pos !== null) {
514 scrubFrame = Math.min(sim.frames.length - 1, Math.floor(pos * sim.fps));
515 scrub.value = String(scrubFrame);
516 }
517 const st = sim.status();
518 // Render only when something visible changed, so an idle or paused page does no work.
519 const renderKey = [...viewKey, live, scrubFrame, sim.frames.length, st.totalSteps, st.phase].join("|");
520 if (renderKey !== lastRenderKey) {
521 lastRenderKey = renderKey;
522 renderView(device, st);
523 }
524 spec.draw($("spec"), sim.fs, Math.max(duration, sim.samples / sim.fs), pos ?? (live ? null : scrubFrame / sim.fps));
525 updateInfo(st);
526}
528/** Builds the preview buffers from the current plate when they are missing or stale. */
529function ensurePreview(dev: GPUDevice): void {
530 if (preview && !preview.stale) return;
531 if (!plate) return;
532 if (preview) for (const b of [preview.w0, preview.zero, preview.nw, preview.hn]) b.destroy();
533 const grid = plate.grid;
534 const shape = Float32Array.from(plate.w0);
535 let wmax = 0;
536 for (let k = 0; k < shape.length; k++) wmax = Math.max(wmax, Math.abs(shape[k]));
537 const mk = (data: Float32Array<ArrayBuffer>) => {
538 const b = dev.createBuffer({ size: Math.max(16, data.byteLength), usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
539 dev.queue.writeBuffer(b, 0, data);
540 return b;
541 };
542 preview = { plate, w0: mk(shape), zero: mk(new Float32Array(grid.n)), nw: mk(nodeWeights(grid)), hn: mk(Float32Array.from(plate.h)), wmax, stale: false };
543 const hr = thicknessRange(plate);
544 renderer?.setField(grid, preview.zero, preview.nw, preview.hn, 0, [hr.min, hr.max]);
545 lastRenderKey = "";
546}
548/** Marks the preview stale (the design or the spacing changed) and shows it; the next frame rebuilds it. */
549function showPreview(): void {
550 if (preview) preview.stale = true;
551 previewMode = true;
552 updateLiveButton();
553 if (!sim) updateIdleInfo();
554}
556function renderPreview(dev: GPUDevice): void {
557 if (!renderer || !preview) return;
558 // The renderer's "initial shape" buffer is zero here, and the shape itself is the displayed field.
559 if (renderer.fieldGrid !== preview.plate.grid) {
560 const hr = thicknessRange(preview.plate);
561 renderer.setField(preview.plate.grid, preview.zero, preview.nw, preview.hn, 0, [hr.min, hr.max]);
562 }
563 markers(preview.plate);
564 const enc = dev.createCommandEncoder();
565 renderer.render(enc, preview.w0, preview.wmax);
566 timeLabel.textContent = "design";
567 dev.pushErrorScope("validation");
568 dev.queue.submit([enc.finish()]);
569 void dev.popErrorScope().then((e) => { if (e && !frameErrorShown) { frameErrorShown = true; console.error("frame validation error:", e.message); } });
570}
572function updateIdleInfo(): void {
573 const html = plate
574 ? `<p class="sub">The view shows the design's stress-free shape.</p>`
575 : `<p class="err">${plateError ?? "No design."}</p>`;
576 if (html !== lastInfoHtml) {
577 lastInfoHtml = html;
578 $("info").innerHTML = html;
579 }
580}
582function renderView(dev: GPUDevice, st: SimStatus): void {
583 if (!sim || !renderer) return;
584 if (renderer.fieldGrid !== sim.grid) {
585 const hr = thicknessRange(sim.plate);
586 renderer.setField(sim.grid, sim.w0, sim.nw, sim.hn, sim.w0Max, [hr.min, hr.max]);
587 }
588 markers(sim.plate);
589 const enc = dev.createCommandEncoder();
590 if (live || sim.frames.length === 0) {
591 sim.encodeDisplay(enc);
592 renderer.render(enc, sim.displayW, st.wmax);
593 timeLabel.textContent = `${st.t.toFixed(3)} s`;
594 } else {
595 const f = Math.max(0, Math.min(sim.frames.length - 1, scrubFrame));
596 if (movieBufFrame !== f) {
597 dev.queue.writeBuffer(movieBuf!, 0, sim.frames[f]);
598 movieBufFrame = f;
599 }
600 let wmax = 0;
601 const fr = sim.frames[f];
602 for (let k = 0; k < fr.length; k += 7) wmax = Math.max(wmax, Math.abs(fr[k]));
603 renderer.render(enc, movieBuf!, wmax);
604 timeLabel.textContent = `${(f / sim.fps).toFixed(3)} s`;
605 }
606 dev.pushErrorScope("validation");
607 dev.queue.submit([enc.finish()]);
608 void dev.popErrorScope().then((e) => { if (e && !frameErrorShown) { frameErrorShown = true; console.error("frame validation error:", e.message); } });
609}
611function updateInfo(st: SimStatus): void {
612 if (!sim) return;
613 const g = sim.grid;
614 const spl = st.peakPressure > 0 ? 20 * Math.log10(st.peakPressure / 2e-5) : -Infinity;
615 const rows: [string, string][] = [
616 ["Design", sim.plate.name],
617 ["Phase", st.phase === "done" ? "done" : "ringing (recording)"],
618 ["Time", `${st.t.toFixed(3)} s`],
619 ["Grid", `${g.nx} × ${g.ny} nodes (${countInside(g)} in the plate), ${(g.hx * 1e3).toFixed(2)} × ${(g.hy * 1e3).toFixed(2)} mm`],
620 ["Time step", `${(sim.dt * 1e6).toFixed(3)} µs (${sim.stepsPerSample} per sample)`],
621 ["Speed", `${(stepsPerSec / 1e3).toFixed(0)} k steps/s (${(stepsPerSec * sim.dt).toFixed(3)}× real time)`],
622 ["Kinetic energy", `${st.ke.toExponential(2)} J`],
623 ["Max |w|", `${(st.wmax * 1e3).toFixed(3)} mm`],
624 ["Audio", `${(st.samples / sim.fs).toFixed(2)} s, ${st.frames} frames`],
625 ["Peak pressure", st.peakPressure > 0 ? `${st.peakPressure.toFixed(3)} Pa (${spl.toFixed(1)} dB SPL)` : "–"],
626 ["Stick", st.stick.withdrawn ? "withdrawn after the hit" : `${(st.stick.x * 1e3).toFixed(2)} mm, ${st.stick.v.toFixed(2)} m/s`],
627 ["Contact force", `${st.stick.force.toFixed(1)} N (max ${st.stick.maxForce.toFixed(1)} N)`],
628 ];
629 let html = rows.map(([a, b]) => `<div class="row"><span>${a}</span><span>${b}</span></div>`).join("");
630 if (st.delayClamped) html += `<div class="warn">The listener is so close that some propagation delays were clamped.</div>`;
631 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 strike.</div>`;
632 if (html !== lastInfoHtml) {
633 lastInfoHtml = html;
634 $("info").innerHTML = html;
635 }
636}
638// ---------- Self-test ----------
640let selfTestResult: { done: boolean; passed?: boolean; cases?: SelfTestCase[]; error?: string } = { done: false };
642async function selfTest(): Promise<void> {
643 if (!device) { selfTestResult = { done: true, passed: false, error: "no GPU device" }; return; }
644 $("info").innerHTML = `<p class="sub">Running the GPU-versus-CPU self-test...</p>`;
645 try {
646 const cases = await runSelfTest(device);
647 const passed = cases.every(selfTestPassed);
648 selfTestResult = { done: true, passed, cases };
649 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>`);
650 $("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>`;
651 } catch (e) {
652 selfTestResult = { done: true, passed: false, error: String(e) };
653 $("info").innerHTML = `<div class="err">Self-test error: ${String(e)}</div>`;
654 }
655}
657// ---------- Init ----------
659async function init(): Promise<void> {
660 editor = new DesignEditor($("design"));
661 form = new ParamForm($("params"), params);
662 form.onChange = (key, isLive) => {
663 if (key === "spacing") { rebuildPlate(); return; }
664 if (!isLive) { showPreview(); return; }
665 if (key.startsWith("listener")) sim?.setListener(params.listenerDist, params.listenerAzimuth, params.listenerElevation);
666 };
667 editor.onDesign = () => rebuildPlate();
668 buildSplitter();
669 buildControls();
670 buildViewToolbar();
671 buildMovieToolbar();
672 buildSpecToolbar();
673 const q = new URLSearchParams(location.search);
674 const set = form.applyUrl(q);
675 if (set.includes("duration")) {
676 duration = params.duration;
677 durInput.value = String(duration);
678 }
679 const presetId = q.get("preset");
680 const preset = presetId ? PRESETS.find((p) => p.id === presetId) : null;
681 if (preset) editor.setScript(preset.source, preset.id);
682 else editor.compileNow();
683 exposeHooks();
684 if (!("gpu" in navigator)) {
685 showNoGpu("WebGPU is not available in this browser. Use a recent Chrome or Edge, or Firefox or Safari with WebGPU enabled.");
686 return;
687 }
688 const adapter = (await navigator.gpu.requestAdapter({ powerPreference: "high-performance" })) ?? (await navigator.gpu.requestAdapter());
689 if (!adapter) {
690 showNoGpu("No WebGPU adapter was found.");
691 return;
692 }
693 device = await adapter.requestDevice();
694 device.lost.then((info) => showNoGpu(`WebGPU device lost: ${info.message}`));
695 device.addEventListener("uncapturederror", (ev) => console.error("WebGPU error:", (ev as GPUUncapturedErrorEvent).error.message));
696 renderer = new Renderer(device, $("view"));
697 updateIdleInfo();
698 updateButtons();
699 requestAnimationFrame(frame);
700 if (q.get("selftest") === "1") await selfTest();
701 else if (q.get("autostart") === "1") void start();
702}
704/** Hooks for automated tests, on window.steelsim. */
705function exposeHooks(): void {
706 (window as unknown as { steelsim: unknown }).steelsim = {
707 status: () => (sim ? { ...sim.status(), dt: sim.dt, stepsPerSample: sim.stepsPerSample, nx: sim.grid.nx, ny: sim.grid.ny, stepsPerSec } : null),
708 audio: () => (sim ? Array.from(sim.recordedAudio()) : null),
709 frames: () => sim?.frames.length ?? 0,
710 design: () => (plate ? { name: plate.name, nodes: countInside(plate.grid), mounted: !!plate.mount, error: null } : { name: null, nodes: 0, mounted: false, error: plateError }),
711 setScript: (source: string) => editor.setScript(source),
712 start: () => void start(),
713 selftest: () => selfTestResult,
714 noGpu: () => { const e = $("nogpu"); return e.hidden ? null : e.textContent; },
715 };
716}
718function showNoGpu(msg: string): void {
719 const el = $("nogpu");
720 el.hidden = false;
721 el.textContent = msg;
722 btnStart.disabled = true;
723 selfTestResult = { done: true, passed: false, error: msg };
724}
726void init();