/ proof-of-concept / steelsim41
Sign in
proof-of-concept / steelsim41
steelsim41 / src / main.ts
724 lines · 28.8 KBBlameHistoryRaw
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, 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;
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();
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();
121function setEditable(enabled: boolean): void {
122 form.setEnabled(enabled);
123 editor.setEnabled(enabled);
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))));
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));
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;
198function levelMode(): LevelMode {
199 return levelSel.value as LevelMode;
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";
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";
236 tb.append(seg, labeled("Color", colorSel), labeled("Exaggeration ×", exagInput), labeled("Color range (mm, 0 = auto)", rangeInput), labeled("Show profile", shapeCheck), hint);
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 gainSlider.min = "-40"; gainSlider.max = "60"; gainSlider.step = "1"; gainSlider.value = "20";
293 gainSlider.style.width = "120px";
294 gainSlider.style.flex = "none";
295 const restart = () => {
296 updateLevelReadout();
297 const pos = player.position();
298 if (pos !== null && sim) player.play(sim.recordedAudio(), sim.fs, pos, levelMode(), gainDb());
299 };
300 gainSlider.addEventListener("input", restart);
301 levelSel.addEventListener("change", restart);
302 tb.append(btnPlay, btnLive, btnDesign, scrub, timeLabel, labeled("Level", levelSel), labeled("Gain", gainSlider), gainLabel, peakLabel, btnWav);
303 updateLevelReadout();
304 updateLiveButton();
307function updateLiveButton(): void {
308 btnLive.classList.toggle("on", live && !previewMode);
309 btnLive.disabled = !sim || (live && !previewMode);
310 btnDesign.classList.toggle("on", previewMode);
311 btnDesign.disabled = previewMode;
314function buildSpecToolbar(): void {
315 const tb = $("specToolbar");
316 const win = document.createElement("select");
317 for (const n of [512, 1024, 2048, 4096, 8192]) {
318 const o = document.createElement("option");
319 o.value = String(n); o.textContent = String(n);
320 win.appendChild(o);
321 }
322 win.value = "2048";
323 const hop = numInput(512, 64);
324 const fmax = numInput(5000, 500);
325 const floor = numInput(80, 5);
326 const logf = document.createElement("input");
327 logf.type = "checkbox";
328 const apply = () => {
329 if (!sim) return;
330 spec.configure(
331 { 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 },
332 sim.recordedAudio(), sim.samples,
333 );
334 };
335 for (const el of [win, hop, fmax, floor, logf]) el.addEventListener("change", apply);
336 tb.append(document.createTextNode("Spectrogram"), labeled("Window", win), labeled("Hop", hop), labeled("Max Hz", fmax), labeled("dB range", floor), labeled("Log freq.", logf));
339function numInput(v: number, step: number): HTMLInputElement {
340 const i = document.createElement("input");
341 i.type = "number"; i.value = String(v); i.step = String(step);
342 return i;
344function labeled(text: string, el: HTMLElement): HTMLLabelElement {
345 const l = document.createElement("label");
346 l.append(text, el);
347 return l;
350// ---------- Design ----------
352/** Re-evaluates the current design at the current spacing and refreshes everything that depends on it. */
353function rebuildPlate(): void {
354 const design = editor.design;
355 plate = null;
356 plateError = editor.error;
357 if (design) {
358 try {
359 plate = evaluatePlate(design, params.spacing);
360 } catch (e) {
361 plateError = e instanceof DesignError ? e.message : String(e);
362 }
363 }
364 form.setPlate(plate);
365 editor.setStatus(plateError, plate ? designSummary(plate) : "");
366 showPreview();
367 updateButtons();
370function fmtMm(v: number): string {
371 return `${(v * 1e3).toFixed(2)} mm`;
374function designSummary(pl: Plate): string {
375 const g = pl.grid;
376 const hr = thicknessRange(pl);
377 const { mass } = massProperties(pl);
378 const { peakToPeak, maxSlope } = profileStats(pl);
379 const rows: [string, string][] = [
380 ["Design", pl.name],
381 ["Bounding box", `${fmtMm(pl.Lx)} × ${fmtMm(pl.Ly)} (${(pl.Lx / 0.0254).toFixed(1)} × ${(pl.Ly / 0.0254).toFixed(1)} in)`],
382 ["Material", `E ${(pl.mat.E / 1e9).toFixed(0)} GPa, ν ${pl.mat.nu}, ρ ${pl.mat.rho} kg/m³`],
383 ["Thickness", hr.min === hr.max ? fmtMm(hr.min) : `${fmtMm(hr.min)} to ${fmtMm(hr.max)} (mean ${fmtMm(hr.mean)})`],
384 ["Mass", `${(mass * 1e3).toFixed(0)} g`],
385 ["Grid", `${g.nx} × ${g.ny}, ${countInside(g)} nodes in the plate`],
386 ["Profile", peakToPeak > 0 ? `${fmtMm(peakToPeak)} peak to peak, largest slope ${maxSlope.toFixed(3)}` : "flat"],
387 ];
388 if (pl.mount) {
389 const mp = mountPatch(pl);
390 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`]);
391 } else {
392 rows.push(["Mount", "none (free plate, inertia relief)"]);
393 }
394 rows.push(["Losses", `σ₀ ${pl.loss.sigma0} 1/s, σ₁ ${pl.loss.sigma1} m²/s, in-plane ${pl.loss.sigmaIn} 1/s`]);
395 let html = rows.map(([a, b]) => `<div class="row"><span>${a}</span><span>${b}</span></div>`).join("");
396 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>`;
397 return html;
400// ---------- Simulation control ----------
402async function start(): Promise<void> {
403 if (!device || !plate) return;
404 player.stop();
405 running = false;
406 await new Promise((r) => setTimeout(r, 0));
407 sim?.destroy();
408 spec.reset();
409 live = true;
410 paused = false;
411 btnPause.textContent = "Pause";
412 params.duration = duration;
413 try {
414 sim = new Simulation(device, plate, params);
415 } catch (e) {
416 $("info").innerHTML = `<div class="err">Could not start: ${String(e)}</div>`;
417 return;
418 }
419 const hr = thicknessRange(plate);
420 renderer?.setField(sim.grid, sim.w0, sim.nw, sim.hn, sim.w0Max, [hr.min, hr.max]);
421 previewMode = false;
422 movieBuf?.destroy();
423 movieBuf = device.createBuffer({ size: Math.max(16, sim.grid.n * 4), usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
424 movieBufFrame = -1;
425 scrub.max = "0"; scrub.value = "0";
426 lastStatsTime = performance.now(); lastStatsSteps = 0; stepsPerSec = 0;
427 setEditable(false);
428 updateLiveButton();
429 void runLoop();
432async function runLoop(): Promise<void> {
433 if (!sim || running) return;
434 running = true;
435 updateButtons();
436 try {
437 while (sim.phase !== "done" && !paused) {
438 await sim.runBatch();
439 spec.update(sim.recordedAudio(), sim.samples);
440 scrub.max = String(Math.max(0, sim.frames.length - 1));
441 if (live) scrub.value = scrub.max;
442 updateLevelReadout();
443 const now = performance.now();
444 if (now - lastStatsTime > 500) {
445 stepsPerSec = ((sim.totalSteps - lastStatsSteps) * 1000) / (now - lastStatsTime);
446 lastStatsTime = now; lastStatsSteps = sim.totalSteps;
447 }
448 }
449 } catch (e) {
450 console.error(e);
451 $("info").innerHTML = `<div class="err">Simulation error: ${String(e)}</div>`;
452 }
453 running = false;
454 if (sim.phase === "done") setEditable(true);
455 updateButtons();
458// ---------- Render loop ----------
460let frameErrorShown = false;
461let lastRenderKey = "";
462let lastInfoHtml = "";
464function frame(): void {
465 requestAnimationFrame(frame);
466 try {
467 frameBody();
468 } catch (e) {
469 if (!frameErrorShown) { frameErrorShown = true; console.error("frame exception:", e); }
470 }
473function markers(pl: Plate): void {
474 if (!renderer) return;
475 const [sx, sy] = strikePoint(params, pl);
476 renderer.strike = { x: sx, y: sy, r: params.contactRadius };
477 renderer.mount = pl.mount ? { x: pl.mount.x, y: pl.mount.y, r: pl.mount.radius } : null;
480function frameBody(): void {
481 if (!device || !renderer) return;
482 renderer.mode = viewMode.value;
483 renderer.colorMode = colorSel.value as ColorMode;
484 renderer.exaggeration = parseFloat(exagInput.value) || 1;
485 renderer.colorRange = (parseFloat(rangeInput.value) || 0) * 1e-3;
486 renderer.showShape = shapeCheck.checked;
487 const canvas = $<HTMLCanvasElement>("view");
488 const cam = renderer.camera;
489 const viewKey = [
490 renderer.mode, renderer.colorMode, renderer.exaggeration, renderer.colorRange, renderer.showShape,
491 cam.yaw.toFixed(4), cam.pitch.toFixed(4), cam.dist.toFixed(4),
492 canvas.clientWidth, canvas.clientHeight, window.devicePixelRatio,
493 params.strikeFracX, params.strikeFracY, params.contactRadius,
494 ];
495 if (previewMode || !sim) {
496 // The stress-free shape of the design that the next Start will use.
497 ensurePreview(device);
498 const renderKey = ["preview", ...viewKey, preview?.plate.name, preview?.plate.grid.n].join("|");
499 if (renderKey !== lastRenderKey) {
500 lastRenderKey = renderKey;
501 renderPreview(device);
502 }
503 if (sim) {
504 spec.draw($("spec"), sim.fs, Math.max(duration, sim.samples / sim.fs), null);
505 updateInfo(sim.status());
506 }
507 return;
508 }
509 const pos = player.position();
510 if (pos !== null) {
511 scrubFrame = Math.min(sim.frames.length - 1, Math.floor(pos * sim.fps));
512 scrub.value = String(scrubFrame);
513 }
514 const st = sim.status();
515 // Render only when something visible changed, so an idle or paused page does no work.
516 const renderKey = [...viewKey, live, scrubFrame, sim.frames.length, st.totalSteps, st.phase].join("|");
517 if (renderKey !== lastRenderKey) {
518 lastRenderKey = renderKey;
519 renderView(device, st);
520 }
521 spec.draw($("spec"), sim.fs, Math.max(duration, sim.samples / sim.fs), pos ?? (live ? null : scrubFrame / sim.fps));
522 updateInfo(st);
525/** Builds the preview buffers from the current plate when they are missing or stale. */
526function ensurePreview(dev: GPUDevice): void {
527 if (preview && !preview.stale) return;
528 if (!plate) return;
529 if (preview) for (const b of [preview.w0, preview.zero, preview.nw, preview.hn]) b.destroy();
530 const grid = plate.grid;
531 const shape = Float32Array.from(plate.w0);
532 let wmax = 0;
533 for (let k = 0; k < shape.length; k++) wmax = Math.max(wmax, Math.abs(shape[k]));
534 const mk = (data: Float32Array<ArrayBuffer>) => {
535 const b = dev.createBuffer({ size: Math.max(16, data.byteLength), usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
536 dev.queue.writeBuffer(b, 0, data);
537 return b;
538 };
539 preview = { plate, w0: mk(shape), zero: mk(new Float32Array(grid.n)), nw: mk(nodeWeights(grid)), hn: mk(Float32Array.from(plate.h)), wmax, stale: false };
540 const hr = thicknessRange(plate);
541 renderer?.setField(grid, preview.zero, preview.nw, preview.hn, 0, [hr.min, hr.max]);
542 lastRenderKey = "";
545/** Marks the preview stale (the design or the spacing changed) and shows it; the next frame rebuilds it. */
546function showPreview(): void {
547 if (preview) preview.stale = true;
548 previewMode = true;
549 updateLiveButton();
550 if (!sim) updateIdleInfo();
553function renderPreview(dev: GPUDevice): void {
554 if (!renderer || !preview) return;
555 // The renderer's "initial shape" buffer is zero here, and the shape itself is the displayed field.
556 if (renderer.fieldGrid !== preview.plate.grid) {
557 const hr = thicknessRange(preview.plate);
558 renderer.setField(preview.plate.grid, preview.zero, preview.nw, preview.hn, 0, [hr.min, hr.max]);
559 }
560 markers(preview.plate);
561 const enc = dev.createCommandEncoder();
562 renderer.render(enc, preview.w0, preview.wmax);
563 timeLabel.textContent = "design";
564 dev.pushErrorScope("validation");
565 dev.queue.submit([enc.finish()]);
566 void dev.popErrorScope().then((e) => { if (e && !frameErrorShown) { frameErrorShown = true; console.error("frame validation error:", e.message); } });
569function updateIdleInfo(): void {
570 const html = plate
571 ? `<p class="sub">The view shows the design's stress-free shape.</p>`
572 : `<p class="err">${plateError ?? "No design."}</p>`;
573 if (html !== lastInfoHtml) {
574 lastInfoHtml = html;
575 $("info").innerHTML = html;
576 }
579function renderView(dev: GPUDevice, st: SimStatus): void {
580 if (!sim || !renderer) return;
581 if (renderer.fieldGrid !== sim.grid) {
582 const hr = thicknessRange(sim.plate);
583 renderer.setField(sim.grid, sim.w0, sim.nw, sim.hn, sim.w0Max, [hr.min, hr.max]);
584 }
585 markers(sim.plate);
586 const enc = dev.createCommandEncoder();
587 if (live || sim.frames.length === 0) {
588 sim.encodeDisplay(enc);
589 renderer.render(enc, sim.displayW, st.wmax);
590 timeLabel.textContent = `${st.t.toFixed(3)} s`;
591 } else {
592 const f = Math.max(0, Math.min(sim.frames.length - 1, scrubFrame));
593 if (movieBufFrame !== f) {
594 dev.queue.writeBuffer(movieBuf!, 0, sim.frames[f]);
595 movieBufFrame = f;
596 }
597 let wmax = 0;
598 const fr = sim.frames[f];
599 for (let k = 0; k < fr.length; k += 7) wmax = Math.max(wmax, Math.abs(fr[k]));
600 renderer.render(enc, movieBuf!, wmax);
601 timeLabel.textContent = `${(f / sim.fps).toFixed(3)} s`;
602 }
603 dev.pushErrorScope("validation");
604 dev.queue.submit([enc.finish()]);
605 void dev.popErrorScope().then((e) => { if (e && !frameErrorShown) { frameErrorShown = true; console.error("frame validation error:", e.message); } });
608function updateInfo(st: SimStatus): void {
609 if (!sim) return;
610 const g = sim.grid;
611 const spl = st.peakPressure > 0 ? 20 * Math.log10(st.peakPressure / 2e-5) : -Infinity;
612 const rows: [string, string][] = [
613 ["Design", sim.plate.name],
614 ["Phase", st.phase === "done" ? "done" : "ringing (recording)"],
615 ["Time", `${st.t.toFixed(3)} s`],
616 ["Grid", `${g.nx} × ${g.ny} nodes (${countInside(g)} in the plate), ${(g.hx * 1e3).toFixed(2)} × ${(g.hy * 1e3).toFixed(2)} mm`],
617 ["Time step", `${(sim.dt * 1e6).toFixed(3)} µs (${sim.stepsPerSample} per sample)`],
618 ["Speed", `${(stepsPerSec / 1e3).toFixed(0)} k steps/s (${(stepsPerSec * sim.dt).toFixed(3)}× real time)`],
619 ["Kinetic energy", `${st.ke.toExponential(2)} J`],
620 ["Max |w|", `${(st.wmax * 1e3).toFixed(3)} mm`],
621 ["Audio", `${(st.samples / sim.fs).toFixed(2)} s, ${st.frames} frames`],
622 ["Peak pressure", st.peakPressure > 0 ? `${st.peakPressure.toFixed(3)} Pa (${spl.toFixed(1)} dB SPL)` : "–"],
623 ["Stick", `${(st.stick.x * 1e3).toFixed(2)} mm, ${st.stick.v.toFixed(2)} m/s`],
624 ["Contact force", `${st.stick.force.toFixed(1)} N (max ${st.stick.maxForce.toFixed(1)} N)`],
625 ];
626 let html = rows.map(([a, b]) => `<div class="row"><span>${a}</span><span>${b}</span></div>`).join("");
627 if (st.delayClamped) html += `<div class="warn">The listener is so close that some propagation delays were clamped.</div>`;
628 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>`;
629 if (html !== lastInfoHtml) {
630 lastInfoHtml = html;
631 $("info").innerHTML = html;
632 }
635// ---------- Self-test ----------
637let selfTestResult: { done: boolean; passed?: boolean; cases?: SelfTestCase[]; error?: string } = { done: false };
639async function selfTest(): Promise<void> {
640 if (!device) { selfTestResult = { done: true, passed: false, error: "no GPU device" }; return; }
641 $("info").innerHTML = `<p class="sub">Running the GPU-versus-CPU self-test...</p>`;
642 try {
643 const cases = await runSelfTest(device);
644 const passed = cases.every(selfTestPassed);
645 selfTestResult = { done: true, passed, cases };
646 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>`);
647 $("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>`;
648 } catch (e) {
649 selfTestResult = { done: true, passed: false, error: String(e) };
650 $("info").innerHTML = `<div class="err">Self-test error: ${String(e)}</div>`;
651 }
654// ---------- Init ----------
656async function init(): Promise<void> {
657 editor = new DesignEditor($("design"));
658 form = new ParamForm($("params"), params);
659 form.onChange = (key, isLive) => {
660 if (key === "spacing") { rebuildPlate(); return; }
661 if (!isLive) { showPreview(); return; }
662 if (!sim) return;
663 if (key.startsWith("listener")) sim.setListener(params.listenerDist, params.listenerAzimuth, params.listenerElevation);
664 };
665 editor.onDesign = () => rebuildPlate();
666 buildSplitter();
667 buildControls();
668 buildViewToolbar();
669 buildMovieToolbar();
670 buildSpecToolbar();
671 const q = new URLSearchParams(location.search);
672 const set = form.applyUrl(q);
673 if (set.includes("duration")) {
674 duration = params.duration;
675 durInput.value = String(duration);
676 }
677 const presetId = q.get("preset");
678 const preset = presetId ? PRESETS.find((p) => p.id === presetId) : null;
679 if (preset) editor.setScript(preset.source, preset.id);
680 else editor.compileNow();
681 exposeHooks();
682 if (!("gpu" in navigator)) {
683 showNoGpu("WebGPU is not available in this browser. Use a recent Chrome or Edge, or Firefox or Safari with WebGPU enabled.");
684 return;
685 }
686 const adapter = (await navigator.gpu.requestAdapter({ powerPreference: "high-performance" })) ?? (await navigator.gpu.requestAdapter());
687 if (!adapter) {
688 showNoGpu("No WebGPU adapter was found.");
689 return;
690 }
691 device = await adapter.requestDevice();
692 device.lost.then((info) => showNoGpu(`WebGPU device lost: ${info.message}`));
693 device.addEventListener("uncapturederror", (ev) => console.error("WebGPU error:", (ev as GPUUncapturedErrorEvent).error.message));
694 renderer = new Renderer(device, $("view"));
695 updateIdleInfo();
696 updateButtons();
697 requestAnimationFrame(frame);
698 if (q.get("selftest") === "1") await selfTest();
699 else if (q.get("autostart") === "1") void start();
702/** Hooks for automated tests, on window.steelsim. */
703function exposeHooks(): void {
704 (window as unknown as { steelsim: unknown }).steelsim = {
705 status: () => (sim ? { ...sim.status(), dt: sim.dt, stepsPerSample: sim.stepsPerSample, nx: sim.grid.nx, ny: sim.grid.ny, stepsPerSec } : null),
706 audio: () => (sim ? Array.from(sim.recordedAudio()) : null),
707 frames: () => sim?.frames.length ?? 0,
708 design: () => (plate ? { name: plate.name, nodes: countInside(plate.grid), mounted: !!plate.mount, error: null } : { name: null, nodes: 0, mounted: false, error: plateError }),
709 setScript: (source: string) => editor.setScript(source),
710 start: () => void start(),
711 selftest: () => selfTestResult,
712 noGpu: () => { const e = $("nogpu"); return e.hidden ? null : e.textContent; },
713 };
716function showNoGpu(msg: string): void {
717 const el = $("nogpu");
718 el.hidden = false;
719 el.textContent = msg;
720 btnStart.disabled = true;
721 selfTestResult = { done: true, passed: false, error: msg };
724void init();
↑↓ move↵openescclose