/ proof-of-concept / steelsim21
Sign in
proof-of-concept / steelsim21
steelsim21 / src / main.ts
739 lines · 29.1 KBBlameHistoryRaw
1// UI wiring: parameters, run loop, live and movie views, playback, downloads.
3import { countInside, defaultParams, makeGrid, makeMaterial, t60, type BowShape, type Excitation, type PhysParams, type PlateShape } from "./params.ts";
4import { Simulation } from "./sim/simulation.ts";
5import { Renderer, type ViewMode } from "./render/renderer.ts";
6import { Spectrogram } from "./audio/spectrogram.ts";
7import { Player } from "./audio/player.ts";
8import { encodeWav } from "./audio/wav.ts";
9import { levelGain, peakDbfs, type LevelMode } from "./audio/level.ts";
11const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
13// ---------- Parameter form ----------
15interface FieldSpec {
16 key: keyof PhysParams;
17 label: string;
18 unit: string;
19 scale: number; // UI value = SI value / scale
20 step?: number;
21 live?: boolean; // may be changed while a simulation runs
24const groups: { title: string; open: boolean; fields: FieldSpec[] }[] = [
25 {
26 title: "Plate",
27 open: true,
28 fields: [
29 { key: "Lx", label: "Width", unit: "in", scale: 0.0254, step: 0.5 },
30 { key: "Ly", label: "Height", unit: "in", scale: 0.0254, step: 0.5 },
31 { key: "h", label: "Thickness", unit: "mm", scale: 1e-3, step: 0.01 },
32 { key: "E", label: "Young's modulus", unit: "GPa", scale: 1e9, step: 1 },
33 { key: "nu", label: "Poisson ratio", unit: "", scale: 1, step: 0.01 },
34 { key: "rho", label: "Density", unit: "kg/m³", scale: 1, step: 10 },
35 ],
36 },
37 {
38 title: "Excitation: push and release",
39 open: true,
40 fields: [
41 { key: "pushAmount", label: "Push amount", unit: "mm", scale: 1e-3, step: 0.5 },
42 { key: "fingerRadius", label: "Finger radius", unit: "mm", scale: 1e-3, step: 1 },
43 { key: "rampTime", label: "Push ramp", unit: "s", scale: 1, step: 0.01 },
44 { key: "holdMax", label: "Max settle time", unit: "s", scale: 1, step: 0.1 },
45 { key: "releaseTime", label: "Release time", unit: "ms", scale: 1e-3, step: 1 },
46 { key: "settleSigma0", label: "Settle damping σ₀", unit: "1/s", scale: 1, step: 10 },
47 { key: "holeInset", label: "Hole inset", unit: "mm", scale: 1e-3, step: 1 },
48 ],
49 },
50 {
51 title: "Excitation: drumstick strike",
52 open: false,
53 fields: [
54 { key: "strikeFracX", label: "Strike x (fraction of width)", unit: "", scale: 1, step: 0.05 },
55 { key: "strikeFracY", label: "Strike y (fraction of height)", unit: "", scale: 1, step: 0.05 },
56 { key: "strikeVelocity", label: "Stick speed", unit: "m/s", scale: 1, step: 0.5 },
57 { key: "stickMass", label: "Stick mass", unit: "g", scale: 1e-3, step: 5 },
58 { key: "contactStiffness", label: "Contact stiffness K", unit: "N/mᵅ", scale: 1, step: 1e8 },
59 { key: "contactExponent", label: "Contact exponent α", unit: "", scale: 1, step: 0.1 },
60 { key: "contactRadius", label: "Tip radius", unit: "mm", scale: 1e-3, step: 1 },
61 ],
62 },
63 {
64 title: "Initial shape",
65 open: true,
66 fields: [{ key: "bowAmplitude", label: "Bow (center vs edges)", unit: "mm", scale: 1e-3, step: 0.5 }],
67 },
68 {
69 title: "Damping",
70 open: true,
71 fields: [
72 { key: "sigma0", label: "σ₀ (freq. independent)", unit: "1/s", scale: 1, step: 0.1, live: true },
73 { key: "sigma1", label: "σ₁ (freq. dependent)", unit: "m²/s", scale: 1, step: 1e-5, live: true },
74 { key: "sigma2", label: "σ₂ (k⁴, 0 = off)", unit: "×10⁻⁹ m⁴/s", scale: 1e-9, step: 1, live: true },
75 { key: "sigmaIn", label: "In-plane loss", unit: "1/s", scale: 1, step: 5, live: true },
76 ],
77 },
78 {
79 title: "Listener",
80 open: true,
81 fields: [
82 { key: "listenerDist", label: "Distance", unit: "m", scale: 1, step: 0.1, live: true },
83 { key: "listenerAzimuth", label: "Azimuth", unit: "deg", scale: 1, step: 5, live: true },
84 { key: "listenerElevation", label: "Elevation", unit: "deg", scale: 1, step: 5, live: true },
85 ],
86 },
87 {
88 title: "Numerics",
89 open: false,
90 fields: [
91 { key: "spacing", label: "Grid spacing", unit: "mm", scale: 1e-3, step: 0.5 },
92 { key: "fs", label: "Audio sample rate", unit: "Hz", scale: 1, step: 100 },
93 { key: "fps", label: "Movie frame rate", unit: "fps", scale: 1, step: 10 },
94 { key: "settleKE", label: "Settled below KE", unit: "J", scale: 1, step: 1e-9 },
95 ],
96 },
97];
99const inputs = new Map<keyof PhysParams, HTMLInputElement | HTMLSelectElement>();
100let params: PhysParams = { ...defaultParams };
102function buildForm(): void {
103 const root = $("params");
104 const exRow = document.createElement("div");
105 exRow.className = "field";
106 const exLabel = document.createElement("label");
107 exLabel.textContent = "Excitation";
108 const exSel = document.createElement("select");
109 for (const [v, t] of [["push", "Push corner and release"], ["strike", "Drumstick strike"]]) {
110 const o = document.createElement("option");
111 o.value = v; o.textContent = t;
112 exSel.appendChild(o);
113 }
114 exSel.value = params.excitation;
115 exSel.addEventListener("change", () => { params.excitation = exSel.value as Excitation; });
116 exRow.append(exLabel, exSel, document.createElement("span"));
117 root.appendChild(exRow);
118 inputs.set("excitation", exSel);
119 for (const g of groups) {
120 const det = document.createElement("details");
121 det.open = g.open;
122 const sum = document.createElement("summary");
123 sum.textContent = g.title;
124 det.appendChild(sum);
125 for (const f of g.fields) {
126 if (f.key === "Lx") {
127 const r0 = document.createElement("div");
128 r0.className = "field";
129 const l0 = document.createElement("label");
130 l0.textContent = "Shape";
131 const sel = document.createElement("select");
132 for (const [v, t] of [["rectangle", "Rectangle"], ["ellipse", "Ellipse (inscribed)"]]) {
133 const o = document.createElement("option");
134 o.value = v; o.textContent = t;
135 sel.appendChild(o);
136 }
137 sel.value = params.shape;
138 sel.addEventListener("change", () => { params.shape = sel.value as PlateShape; updateGridNote(); });
139 r0.append(l0, sel, document.createElement("span"));
140 det.appendChild(r0);
141 inputs.set("shape", sel);
142 }
143 const row = document.createElement("div");
144 row.className = "field";
145 const label = document.createElement("label");
146 label.textContent = f.label;
147 const inp = document.createElement("input");
148 inp.type = "number";
149 inp.step = String(f.step ?? 1);
150 inp.value = fmt((params[f.key] as number) / f.scale);
151 inp.addEventListener("change", () => onFieldChange(f, inp));
152 const unit = document.createElement("span");
153 unit.className = "unit";
154 unit.textContent = f.unit;
155 row.append(label, inp, unit);
156 det.appendChild(row);
157 inputs.set(f.key, inp);
158 if (f.key === "listenerElevation") {
159 const r2 = document.createElement("div");
160 r2.className = "field";
161 const l2 = document.createElement("label");
162 l2.textContent = "Radiation model";
163 const sel = document.createElement("select");
164 for (const [v, t] of [["rayleigh", "Rayleigh (baffled)"], ["dipole", "Dipole (unbaffled, approx.)"]]) {
165 const o = document.createElement("option");
166 o.value = v; o.textContent = t;
167 sel.appendChild(o);
168 }
169 sel.value = params.radiation;
170 sel.addEventListener("change", () => {
171 params.radiation = sel.value as PhysParams["radiation"];
172 applyListener();
173 });
174 r2.append(l2, sel, document.createElement("span"));
175 det.appendChild(r2);
176 inputs.set("radiation", sel);
177 }
178 if (f.key === "bowAmplitude") {
179 const r2 = document.createElement("div");
180 r2.className = "field";
181 const l2 = document.createElement("label");
182 l2.textContent = "Bow shape";
183 const sel = document.createElement("select");
184 for (const [v, t] of [["width", "Curved across the width"], ["height", "Curved across the height"], ["dish", "Dish (doubly curved)"], ["none", "Flat"]]) {
185 const o = document.createElement("option");
186 o.value = v; o.textContent = t;
187 sel.appendChild(o);
188 }
189 sel.value = params.bowShape;
190 sel.addEventListener("change", () => { params.bowShape = sel.value as BowShape; });
191 r2.append(l2, sel, document.createElement("span"));
192 det.appendChild(r2);
193 inputs.set("bowShape", sel);
194 const note = document.createElement("p");
195 note.className = "sub";
196 note.textContent = "Real sheet metal is never flat. A bow of a few mm makes the sheet a shallow shell with an asymmetric stiffness; push with a negative amount to push against the bow.";
197 det.appendChild(note);
198 }
199 if (f.key === "sigmaIn") {
200 const note = document.createElement("p");
201 note.className = "sub";
202 note.id = "dampNote";
203 det.appendChild(note);
204 }
205 if (f.key === "rho") {
206 const note = document.createElement("p");
207 note.className = "sub";
208 note.id = "gridNote";
209 det.appendChild(note);
210 }
211 }
212 root.appendChild(det);
213 }
214 updateDampingNote();
215 updateGridNote();
218function updateGridNote(): void {
219 const g = makeGrid(params);
220 const inside = countInside(g);
221 const shape = params.shape === "ellipse" ? `ellipse of ${(params.Lx / 0.0254).toFixed(1)} by ${(params.Ly / 0.0254).toFixed(1)} in, edge stepped in whole cells` : "rectangle";
222 $("gridNote").textContent = `${shape}; ${g.nx} × ${g.ny} grid, ${inside} nodes in the sheet.`;
225function fmt(v: number): string {
226 if (v === 0) return "0";
227 const a = Math.abs(v);
228 if (a >= 1e5 || a < 1e-3) return v.toExponential(3);
229 return String(+v.toPrecision(6));
232function onFieldChange(f: FieldSpec, inp: HTMLInputElement): void {
233 const v = parseFloat(inp.value);
234 if (!isFinite(v)) return;
235 (params as unknown as Record<string, number>)[f.key] = v * f.scale;
236 if (f.key === "Lx" || f.key === "Ly" || f.key === "spacing") updateGridNote();
237 if (f.key === "sigma0" || f.key === "sigma1" || f.key === "sigma2" || f.key === "sigmaIn") {
238 updateDampingNote();
239 sim?.setDamping(params.sigma0, params.sigma1, params.sigmaIn, params.sigma2);
240 }
241 if (f.key.startsWith("listener")) applyListener();
244function applyListener(): void {
245 sim?.setListener(params.listenerDist, params.listenerAzimuth, params.listenerElevation, params.radiation);
248function updateDampingNote(): void {
249 const m = makeMaterial(params);
250 const f = (hz: number) => t60(m, params, 2 * Math.PI * hz).toFixed(2);
251 $("dampNote").textContent = `T60: ${f(100)} s at 100 Hz, ${f(1000)} s at 1 kHz, ${f(5000)} s at 5 kHz, ${f(10000)} s at 10 kHz`;
254function setFormEnabled(enabled: boolean): void {
255 for (const g of groups) for (const f of g.fields) if (!f.live) inputs.get(f.key)!.disabled = !enabled;
256 inputs.get("bowShape")!.disabled = !enabled;
257 inputs.get("excitation")!.disabled = !enabled;
258 inputs.get("shape")!.disabled = !enabled;
261// ---------- State ----------
263let device: GPUDevice | null = null;
264let sim: Simulation | null = null;
265let renderer: Renderer | null = null;
266let running = false;
267let paused = false;
268let live = true;
269let scrubFrame = 0;
270let movieBuf: GPUBuffer | null = null;
271let movieBufFrame = -1;
272let spec = new Spectrogram({ windowSize: 2048, hop: 512, fmax: 5000, dbFloor: 80, logFrequency: false });
273const player = new Player();
274let lastStatsTime = 0, lastStatsSteps = 0, stepsPerSec = 0;
275let duration = params.duration;
277// ---------- Controls ----------
279const btnStart = mkButton("Start", "primary");
280const btnPause = mkButton("Pause");
281const btnStop = mkButton("Stop");
282const btnContinue = mkButton("Continue");
283const durInput = document.createElement("input");
285function mkButton(text: string, cls = ""): HTMLButtonElement {
286 const b = document.createElement("button");
287 b.textContent = text;
288 b.className = cls;
289 return b;
292function buildControls(): void {
293 const c = $("controls");
294 const durRow = document.createElement("div");
295 durRow.className = "field";
296 durRow.style.width = "100%";
297 const l = document.createElement("label");
298 l.textContent = "Recorded duration";
299 durInput.type = "number";
300 durInput.step = "0.5";
301 durInput.min = "0.1";
302 durInput.value = String(duration);
303 durInput.addEventListener("change", () => {
304 const v = parseFloat(durInput.value);
305 if (isFinite(v) && v > 0) {
306 duration = v;
307 params.duration = v;
308 sim?.setDuration(v);
309 updateButtons();
310 }
311 });
312 const u = document.createElement("span");
313 u.className = "unit";
314 u.textContent = "s";
315 durRow.append(l, durInput, u);
316 c.append(durRow, btnStart, btnPause, btnStop, btnContinue);
317 btnStop.addEventListener("click", () => {
318 if (!sim) return;
319 sim.stop();
320 paused = false;
321 btnPause.textContent = "Pause";
322 if (!running) {
323 setFormEnabled(true);
324 updateButtons();
325 }
326 });
327 btnStart.addEventListener("click", () => void start());
328 btnPause.addEventListener("click", () => {
329 paused = !paused;
330 btnPause.textContent = paused ? "Resume" : "Pause";
331 if (!paused) void runLoop();
332 });
333 btnContinue.addEventListener("click", () => {
334 if (!sim) return;
335 sim.setDuration(duration);
336 if (sim.phase !== "done") void runLoop();
337 updateButtons();
338 });
339 updateButtons();
342function updateButtons(): void {
343 const s = sim?.status();
344 btnPause.disabled = !sim || s?.phase === "done" || (!running && !paused);
345 btnStop.disabled = !sim || s?.phase === "done";
346 btnContinue.disabled = !sim || running || !sim.canContinue();
349// ---------- View toolbars ----------
351const viewMode = { value: "2d" as ViewMode };
352const scrub = document.createElement("input");
353const timeLabel = document.createElement("span");
354const btnLive = mkButton("Live");
355const btnPlay = mkButton("Play");
356const btnWav = mkButton("Download .wav");
357const levelSel = document.createElement("select");
358const gainSlider = document.createElement("input");
359const gainLabel = document.createElement("span");
360const peakLabel = document.createElement("span");
362function gainDb(): number {
363 return parseFloat(gainSlider.value) || 0;
366function updateLevelReadout(): void {
367 gainLabel.textContent = `${gainDb() >= 0 ? "+" : ""}${gainDb()} dB`;
368 gainSlider.disabled = levelSel.value !== "absolute";
369 if (!sim || sim.samples === 0) { peakLabel.textContent = ""; return; }
370 const audio = sim.processedAudio();
371 const db = peakDbfs(audio, levelGain(audio, sim.fs, levelSel.value as LevelMode, gainDb()));
372 peakLabel.textContent = isFinite(db) ? `peak ${db.toFixed(1)} dBFS` : "";
373 peakLabel.className = db > 0 ? "warn" : "sub";
374 peakLabel.style.margin = "0";
375 if (db > 0) peakLabel.textContent += " (clipped)";
377let exagInput: HTMLInputElement;
378let rangeInput: HTMLInputElement;
379let shapeCheck: HTMLInputElement;
381function buildViewToolbar(): void {
382 const tb = $("viewToolbar");
383 const seg = document.createElement("span");
384 seg.className = "seg";
385 const b2 = mkButton("2D", "on"), b3 = mkButton("3D");
386 b2.addEventListener("click", () => { viewMode.value = "2d"; b2.classList.add("on"); b3.classList.remove("on"); });
387 b3.addEventListener("click", () => { viewMode.value = "3d"; b3.classList.add("on"); b2.classList.remove("on"); });
388 seg.append(b2, b3);
389 exagInput = numInput(4, 0.5);
390 rangeInput = numInput(0, 0.5);
391 const hint = document.createElement("span");
392 hint.className = "sub";
393 hint.style.margin = "0";
394 hint.textContent = "3D: drag to orbit, wheel to zoom";
395 shapeCheck = document.createElement("input");
396 shapeCheck.type = "checkbox";
397 tb.append(seg, labeled("Exaggeration ×", exagInput), labeled("Color range (mm, 0 = auto)", rangeInput), labeled("3D: add initial shape", shapeCheck), hint);
400function buildMovieToolbar(): void {
401 const tb = $("movieToolbar");
402 scrub.type = "range";
403 scrub.min = "0"; scrub.max = "0"; scrub.value = "0";
404 scrub.addEventListener("input", () => {
405 live = false;
406 scrubFrame = parseInt(scrub.value);
407 if (player.playing) player.stop();
408 updateLiveButton();
409 });
410 timeLabel.className = "time";
411 btnLive.addEventListener("click", () => { live = true; updateLiveButton(); });
412 // Playback drives the movie; afterwards the view returns to where it was (live or scrubbed).
413 let liveBeforePlay = true;
414 const endPlayback = () => {
415 btnPlay.textContent = "Play";
416 if (liveBeforePlay) {
417 live = true;
418 updateLiveButton();
419 }
420 };
421 btnPlay.addEventListener("click", () => {
422 if (player.playing) { player.stop(); endPlayback(); return; }
423 if (!sim || sim.samples === 0) return;
424 liveBeforePlay = live;
425 const offset = live ? 0 : scrubFrame / sim.fps;
426 live = false;
427 player.play(sim.processedAudio(), sim.fs, offset, levelSel.value as LevelMode, gainDb());
428 btnPlay.textContent = "Stop";
429 updateLiveButton();
430 });
431 player.onEnded = endPlayback;
432 scrub.addEventListener("input", () => { liveBeforePlay = false; });
433 btnLive.addEventListener("click", () => { liveBeforePlay = true; });
434 btnWav.addEventListener("click", () => {
435 if (!sim || sim.samples === 0) return;
436 const blob = encodeWav(sim.processedAudio(), sim.fs, levelSel.value as LevelMode, gainDb());
437 const a = document.createElement("a");
438 a.href = URL.createObjectURL(blob);
439 a.download = `sheet_${sim.samples}samples_${sim.fs}Hz.wav`;
440 a.click();
441 setTimeout(() => URL.revokeObjectURL(a.href), 5000);
442 });
443 for (const [v, t] of [["absolute", "Absolute (0 dBFS = 1 Pa at 0 dB)"], ["loudness", "Loudness (RMS, soft clip)"], ["peak", "Peak"]]) {
444 const o = document.createElement("option");
445 o.value = v; o.textContent = t;
446 levelSel.appendChild(o);
447 }
448 gainSlider.type = "range";
449 gainSlider.min = "-40"; gainSlider.max = "60"; gainSlider.step = "1"; gainSlider.value = "20";
450 gainSlider.style.width = "120px";
451 gainSlider.style.flex = "none";
452 const restart = () => {
453 updateLevelReadout();
454 const pos = player.position();
455 if (pos !== null && sim) player.play(sim.processedAudio(), sim.fs, pos, levelSel.value as LevelMode, gainDb());
456 };
457 gainSlider.addEventListener("input", restart);
458 levelSel.addEventListener("change", restart);
459 tb.append(btnPlay, btnLive, scrub, timeLabel, labeled("Level", levelSel), labeled("Gain", gainSlider), gainLabel, peakLabel, btnWav);
460 updateLevelReadout();
461 updateLiveButton();
464function updateLiveButton(): void {
465 btnLive.classList.toggle("on", live);
466 btnLive.disabled = live;
469function buildSpecToolbar(): void {
470 const tb = $("specToolbar");
471 const win = document.createElement("select");
472 for (const n of [512, 1024, 2048, 4096, 8192]) {
473 const o = document.createElement("option");
474 o.value = String(n); o.textContent = String(n);
475 win.appendChild(o);
476 }
477 win.value = "2048";
478 const hop = numInput(512, 64);
479 const fmax = numInput(5000, 500);
480 const floor = numInput(80, 5);
481 const logf = document.createElement("input");
482 logf.type = "checkbox";
483 const apply = () => {
484 if (!sim) return;
485 spec.configure(
486 { 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 },
487 sim.processedAudio(), sim.samples,
488 );
489 };
490 for (const el of [win, hop, fmax, floor, logf]) el.addEventListener("change", apply);
491 tb.append(
492 document.createTextNode("Spectrogram"),
493 labeled("Window", win), labeled("Hop", hop), labeled("Max Hz", fmax), labeled("dB range", floor), labeled("Log freq.", logf),
494 );
497function numInput(v: number, step: number): HTMLInputElement {
498 const i = document.createElement("input");
499 i.type = "number"; i.value = String(v); i.step = String(step);
500 return i;
502function labeled(text: string, el: HTMLElement): HTMLLabelElement {
503 const l = document.createElement("label");
504 l.append(text, el);
505 return l;
508// ---------- Simulation control ----------
510async function start(): Promise<void> {
511 if (!device) return;
512 player.stop();
513 running = false;
514 await new Promise((r) => setTimeout(r, 0));
515 sim?.destroy();
516 spec.reset();
517 live = true;
518 paused = false;
519 btnPause.textContent = "Pause";
520 params.duration = duration;
521 sim = new Simulation(device, params);
522 renderer = new Renderer(device, $("view"), sim.grid, params, sim.w0, sim.nw);
523 movieBuf?.destroy();
524 movieBuf = device.createBuffer({ size: Math.max(16, sim.grid.n * 4), usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
525 movieBufFrame = -1;
526 scrub.max = "0"; scrub.value = "0";
527 lastStatsTime = performance.now(); lastStatsSteps = 0; stepsPerSec = 0;
528 setFormEnabled(false);
529 updateLiveButton();
530 void runLoop();
533async function runLoop(): Promise<void> {
534 if (!sim || running) return;
535 running = true;
536 updateButtons();
537 try {
538 while (sim.phase !== "done" && !paused) {
539 await sim.runBatch();
540 const audio = sim.processedAudio();
541 spec.update(audio, sim.samples);
542 scrub.max = String(Math.max(0, sim.frames.length - 1));
543 if (live) scrub.value = scrub.max;
544 updateLevelReadout();
545 const now = performance.now();
546 if (now - lastStatsTime > 500) {
547 stepsPerSec = ((sim.totalSteps - lastStatsSteps) * 1000) / (now - lastStatsTime);
548 lastStatsTime = now; lastStatsSteps = sim.totalSteps;
549 }
550 }
551 } catch (e) {
552 console.error(e);
553 $("info").innerHTML = `<div class="err">Simulation error: ${String(e)}</div>`;
554 }
555 running = false;
556 if (sim.phase === "done") setFormEnabled(true);
557 updateButtons();
560// ---------- Render loop ----------
562let frameErrorShown = false;
563function frame(): void {
564 requestAnimationFrame(frame);
565 try {
566 frameBody();
567 } catch (e) {
568 if (!frameErrorShown) { frameErrorShown = true; console.error("frame exception:", e); }
569 }
572let lastRenderKey = "";
573function frameBody(): void {
574 if (!device || !sim || !renderer) return;
575 const dev = device;
576 renderer.mode = viewMode.value;
577 renderer.exaggeration = parseFloat(exagInput.value) || 1;
578 renderer.colorRange = (parseFloat(rangeInput.value) || 0) * 1e-3;
579 renderer.showShape = shapeCheck.checked;
580 const pos = player.position();
581 if (pos !== null) {
582 scrubFrame = Math.min(sim.frames.length - 1, Math.floor(pos * sim.fps));
583 scrub.value = String(scrubFrame);
584 }
585 const st = sim.status();
586 // Render only when something visible changed, so an idle or paused page does no work.
587 const canvas = $<HTMLCanvasElement>("view");
588 const cam = renderer.camera;
589 const renderKey = [
590 renderer.mode, renderer.exaggeration, renderer.colorRange, renderer.showShape,
591 cam.yaw.toFixed(4), cam.pitch.toFixed(4), cam.dist.toFixed(4),
592 live, scrubFrame, sim.frames.length, st.totalSteps, st.phase,
593 canvas.clientWidth, canvas.clientHeight, window.devicePixelRatio,
594 ].join("|");
595 if (renderKey !== lastRenderKey) {
596 lastRenderKey = renderKey;
597 renderView(dev, st);
598 }
599 spec.draw($("spec"), sim.fs, Math.max(duration, sim.samples / sim.fs), pos ?? (live ? null : scrubFrame / sim.fps));
600 updateInfo(st);
603function renderView(dev: GPUDevice, st: ReturnType<Simulation["status"]>): void {
604 if (!sim || !renderer) return;
605 const enc = dev.createCommandEncoder();
606 if (live || sim.frames.length === 0) {
607 sim.encodeDisplay(enc);
608 renderer.render(enc, sim.displayW, st.wmax);
609 timeLabel.textContent = `${st.t >= 0 ? "" : "prep "}${st.t.toFixed(3)} s`;
610 } else {
611 const f = Math.max(0, Math.min(sim.frames.length - 1, scrubFrame));
612 if (movieBufFrame !== f) {
613 dev.queue.writeBuffer(movieBuf!, 0, sim.frames[f]);
614 movieBufFrame = f;
615 }
616 let wmax = 0;
617 const fr = sim.frames[f];
618 for (let k = 0; k < fr.length; k += 7) wmax = Math.max(wmax, Math.abs(fr[k]));
619 renderer.render(enc, movieBuf!, wmax);
620 timeLabel.textContent = `${(f / sim.fps).toFixed(3)} s`;
621 }
622 dev.pushErrorScope("validation");
623 dev.queue.submit([enc.finish()]);
624 void dev.popErrorScope().then((e) => { if (e && !frameErrorShown) { frameErrorShown = true; console.error("frame validation error:", e.message); } });
627function updateInfo(st: ReturnType<Simulation["status"]>): void {
628 if (!sim) return;
629 const g = sim.grid;
630 const spl = st.peakPressure > 0 ? 20 * Math.log10(st.peakPressure / 2e-5) : -Infinity;
631 const phase = { push: "pushing", hold: "settling (held)", recording: "ringing (recording)", done: "done" }[st.phase];
632 const stickHook = (window as unknown as { steelsim?: { stick?: unknown } }).steelsim;
633 if (stickHook) stickHook.stick = st.stick;
634 const rows: [string, string][] = [
635 ["Phase", phase],
636 ["Time", `${st.t.toFixed(3)} s`],
637 ["Grid", `${g.nx} × ${g.ny} nodes${g.mask ? ` (${countInside(g)} in the sheet)` : ""}, ${(g.hx * 1e3).toFixed(2)} × ${(g.hy * 1e3).toFixed(2)} mm`],
638 ["Time step", `${(sim.dt * 1e6).toFixed(3)} µs (${sim.stepsPerSample} per sample)`],
639 ["Speed", `${(stepsPerSec / 1e3).toFixed(0)} k steps/s (${(stepsPerSec * sim.dt).toFixed(3)}× real time)`],
640 ["Kinetic energy", `${st.ke.toExponential(2)} J`],
641 ["Max |w|", `${(st.wmax * 1e3).toFixed(3)} mm`],
642 ["Audio", `${(st.samples / sim.fs).toFixed(2)} s, ${st.frames} frames`],
643 ["Peak pressure", st.peakPressure > 0 ? `${st.peakPressure.toFixed(3)} Pa (${spl.toFixed(1)} dB SPL)` : "–"],
644 ];
645 if (st.stick) {
646 rows.push(["Stick", `${(st.stick.x * 1e3).toFixed(2)} mm, ${st.stick.v.toFixed(2)} m/s`]);
647 rows.push(["Contact force", `${st.stick.force.toFixed(1)} N (max ${st.stick.maxForce.toFixed(1)} N)`]);
648 }
649 let html = rows.map(([a, b]) => `<div class="row"><span>${a}</span><span>${b}</span></div>`).join("");
650 if (st.delayClamped) html += `<div class="warn">Listener is so close that some propagation delays were clamped.</div>`;
651 if (!isFinite(st.wmax) || st.wmax > 1) html += `<div class="err">The solution has diverged. Reduce the time step by increasing the grid spacing, or reduce the push.</div>`;
652 if (html !== lastInfoHtml) {
653 lastInfoHtml = html;
654 $("info").innerHTML = html;
655 }
657let lastInfoHtml = "";
659// ---------- Init ----------
661async function init(): Promise<void> {
662 buildForm();
663 buildControls();
664 buildViewToolbar();
665 buildMovieToolbar();
666 buildSpecToolbar();
667 if (!("gpu" in navigator)) {
668 showNoGpu("WebGPU is not available in this browser. Use a recent Chrome or Edge (or Firefox / Safari with WebGPU enabled).");
669 return;
670 }
671 const adapter = (await navigator.gpu.requestAdapter({ powerPreference: "high-performance" })) ?? (await navigator.gpu.requestAdapter());
672 if (!adapter) {
673 showNoGpu("No WebGPU adapter was found.");
674 return;
675 }
676 device = await adapter.requestDevice();
677 device.lost.then((info) => showNoGpu(`WebGPU device lost: ${info.message}`));
678 device.addEventListener("uncapturederror", (ev) => console.error("WebGPU error:", (ev as GPUUncapturedErrorEvent).error.message));
679 const g = makeGrid(params);
680 $("info").innerHTML = `<div class="row"><span>Grid (default)</span><span>${g.nx} × ${g.ny} nodes</span></div><p class="sub">Press Start to run.</p>`;
681 requestAnimationFrame(frame);
682 applyUrlParams();
685/** URL parameters: any PhysParams key in SI units, plus autostart=1. Exposes the simulation on window for tests. */
686function applyUrlParams(): void {
687 const q = new URLSearchParams(location.search);
688 for (const [k, v] of q) {
689 if (k in params && k !== "radiation" && k !== "bowShape" && k !== "excitation" && k !== "shape") {
690 const num = parseFloat(v);
691 if (isFinite(num)) {
692 (params as unknown as Record<string, number>)[k] = num;
693 const inp = inputs.get(k as keyof PhysParams);
694 const spec = groups.flatMap((g) => g.fields).find((f) => f.key === k);
695 if (inp && spec) (inp as HTMLInputElement).value = fmt(num / spec.scale);
696 }
697 }
698 if (k === "radiation" && (v === "rayleigh" || v === "dipole")) params.radiation = v;
699 if (k === "excitation" && (v === "push" || v === "strike")) {
700 params.excitation = v;
701 (inputs.get("excitation") as HTMLSelectElement).value = v;
702 }
703 if (k === "bowShape" && ["none", "width", "height", "dish"].includes(v)) {
704 params.bowShape = v as BowShape;
705 (inputs.get("bowShape") as HTMLSelectElement).value = v;
706 }
707 if (k === "shape" && (v === "rectangle" || v === "ellipse")) {
708 params.shape = v;
709 (inputs.get("shape") as HTMLSelectElement).value = v;
710 }
711 if (k === "duration") {
712 duration = params.duration;
713 durInput.value = String(duration);
714 }
715 }
716 updateDampingNote();
717 updateGridNote();
718 (window as unknown as { steelsim: unknown }).steelsim = {
719 status: () => (sim ? { ...sim.status(), dt: sim.dt, stepsPerSample: sim.stepsPerSample, nx: sim.grid.nx, ny: sim.grid.ny, stepsPerSec } : null),
720 audio: () => (sim ? Array.from(sim.processedAudio()) : null),
721 frame: (k: number) => (sim ? Array.from(sim.frames[k] ?? []) : null),
722 frames: () => sim?.frames.length ?? 0,
723 state: async () => { if (!sim) return null; const st = await sim.readState(); return { pos: Array.from(st.pos), vel: Array.from(st.vel) }; },
724 pause: () => { paused = true; },
725 canvasInfo: () => { const c = $<HTMLCanvasElement>("view"); return { cw: c.clientWidth, ch: c.clientHeight, w: c.width, h: c.height, mode: viewMode.value }; },
726 setView: (m: ViewMode) => { viewMode.value = m; },
727 scrub: (k: number) => { live = false; scrubFrame = k; scrub.value = String(k); updateLiveButton(); },
728 };
729 if (q.get("autostart") === "1") void start();
732function showNoGpu(msg: string): void {
733 const el = $("nogpu");
734 el.hidden = false;
735 el.textContent = msg;
736 btnStart.disabled = true;
739void init();
↑↓ move↵openescclose