// Application: design editor, parameter form, run loop, live and movie // views, spectrogram, playback, WAV download, and the test hooks on // window.steelsim. import { type Plate, type SimParams, countInside, defaultParams, massProperties, mountPatch, nodeWeights, strikePoint, thicknessRange } from "./params.ts"; import { DesignError, evaluatePlate, profileStats } from "./design.ts"; import { PRESETS } from "./presets.ts"; import { Simulation, type SimStatus } from "./sim/simulation.ts"; import { Renderer, type ColorMode, type ViewMode } from "./render/renderer.ts"; import { Spectrogram } from "./audio/spectrogram.ts"; import { Player } from "./audio/player.ts"; import { encodeWav } from "./audio/wav.ts"; import { levelGain, peakDbfs, type LevelMode } from "./audio/level.ts"; import { ParamForm } from "./ui/form.ts"; import { DesignEditor } from "./ui/editor.ts"; import { runSelfTest, selfTestPassed, type SelfTestCase } from "./selftest.ts"; const $ = (id: string) => document.getElementById(id) as T; /** Slope above which the shallow-shell treatment of the profile is doubtful. */ const SLOPE_LIMIT = 0.1; // ---------- State ---------- const params: SimParams = { ...defaultParams }; let form: ParamForm; let editor: DesignEditor; let device: GPUDevice | null = null; let sim: Simulation | null = null; let renderer: Renderer | null = null; let running = false; let paused = false; let live = true; let scrubFrame = 0; let movieBuf: GPUBuffer | null = null; let movieBufFrame = -1; /** The design evaluated at the current spacing, or null while the script has an error. */ let plate: Plate | null = null; let plateError: string | null = null; /** Show the design (its stress-free shape) instead of the simulation. */ let previewMode = true; let preview: { plate: Plate; w0: GPUBuffer; zero: GPUBuffer; nw: GPUBuffer; hn: GPUBuffer; wmax: number; stale: boolean } | null = null; const spec = new Spectrogram({ windowSize: 2048, hop: 512, fmax: 5000, dbFloor: 80, logFrequency: false }); const player = new Player(); let lastStatsTime = 0, lastStatsSteps = 0, stepsPerSec = 0; let duration = params.duration; // ---------- Controls ---------- const btnStart = mkButton("Start", "primary"); const btnPause = mkButton("Pause"); const btnStop = mkButton("Stop"); const btnContinue = mkButton("Continue"); const durInput = document.createElement("input"); function mkButton(text: string, cls = ""): HTMLButtonElement { const b = document.createElement("button"); b.textContent = text; b.className = cls; return b; } function buildControls(): void { const c = $("controls"); const durRow = document.createElement("div"); durRow.className = "field"; durRow.style.width = "100%"; const l = document.createElement("label"); l.textContent = "Recorded duration"; durInput.type = "number"; durInput.step = "0.5"; durInput.min = "0.1"; durInput.value = String(duration); durInput.addEventListener("change", () => { const v = parseFloat(durInput.value); if (isFinite(v) && v > 0) { duration = v; params.duration = v; sim?.setDuration(v); form.updateNotes(); updateButtons(); } }); const u = document.createElement("span"); u.className = "unit"; u.textContent = "s"; durRow.append(l, durInput, u); c.append(durRow, btnStart, btnPause, btnStop, btnContinue); btnStart.addEventListener("click", () => void start()); btnPause.addEventListener("click", () => { paused = !paused; btnPause.textContent = paused ? "Resume" : "Pause"; if (!paused) void runLoop(); }); btnStop.addEventListener("click", () => { if (!sim) return; sim.stop(); paused = false; btnPause.textContent = "Pause"; if (!running) { setEditable(true); updateButtons(); } }); btnContinue.addEventListener("click", () => { if (!sim) return; sim.setDuration(duration); if (sim.phase !== "done") void runLoop(); updateButtons(); }); updateButtons(); } function updateButtons(): void { const s = sim?.status(); btnStart.disabled = !device || !plate; btnPause.disabled = !sim || s?.phase === "done" || (!running && !paused); btnStop.disabled = !sim || s?.phase === "done"; btnContinue.disabled = !sim || running || !sim.canContinue(); } function setEditable(enabled: boolean): void { form.setEnabled(enabled); editor.setEnabled(enabled); } // ---------- View toolbars ---------- const viewMode = { value: "2d" as ViewMode }; const colorSel = document.createElement("select"); const scrub = document.createElement("input"); const timeLabel = document.createElement("span"); const btnLive = mkButton("Live"); const btnDesign = mkButton("Design"); const btnPlay = mkButton("Play"); const btnWav = mkButton("Download .wav"); const levelSel = document.createElement("select"); const gainSlider = document.createElement("input"); const gainLabel = document.createElement("span"); const peakLabel = document.createElement("span"); let exagInput: HTMLInputElement; let rangeInput: HTMLInputElement; let shapeCheck: HTMLInputElement; function gainDb(): number { return parseFloat(gainSlider.value) || 0; } function levelMode(): LevelMode { return levelSel.value as LevelMode; } function updateLevelReadout(): void { gainLabel.textContent = `${gainDb() >= 0 ? "+" : ""}${gainDb()} dB`; gainSlider.disabled = levelMode() !== "absolute"; if (!sim || sim.samples === 0) { peakLabel.textContent = ""; return; } const audio = sim.recordedAudio(); const db = peakDbfs(audio, levelGain(audio, sim.fs, levelMode(), gainDb())); peakLabel.textContent = isFinite(db) ? `peak ${db.toFixed(1)} dBFS${db > 0 ? " (clipped)" : ""}` : ""; peakLabel.className = db > 0 ? "warn" : "sub"; peakLabel.style.margin = "0"; } function buildViewToolbar(): void { const tb = $("viewToolbar"); const seg = document.createElement("span"); seg.className = "seg"; const b2 = mkButton("2D", "on"), b3 = mkButton("3D"); b2.addEventListener("click", () => { viewMode.value = "2d"; b2.classList.add("on"); b3.classList.remove("on"); }); b3.addEventListener("click", () => { viewMode.value = "3d"; b3.classList.add("on"); b2.classList.remove("on"); }); seg.append(b2, b3); for (const [v, t] of [["field", "Height / displacement"], ["thickness", "Thickness"]]) { const o = document.createElement("option"); o.value = v; o.textContent = t; colorSel.appendChild(o); } exagInput = numInput(1, 0.5); rangeInput = numInput(0, 0.5); shapeCheck = document.createElement("input"); shapeCheck.type = "checkbox"; shapeCheck.checked = true; shapeCheck.title = "Show the total shape (design profile plus vibration); unticked, only the vibration"; const hint = document.createElement("span"); hint.className = "sub"; hint.style.margin = "0"; hint.textContent = "3D: drag to orbit, wheel to zoom"; tb.append(seg, labeled("Color", colorSel), labeled("Exaggeration ×", exagInput), labeled("Color range (mm, 0 = auto)", rangeInput), labeled("Show profile", shapeCheck), hint); } function buildMovieToolbar(): void { const tb = $("movieToolbar"); scrub.type = "range"; scrub.min = "0"; scrub.max = "0"; scrub.value = "0"; timeLabel.className = "time"; // Playback drives the movie; afterwards the view returns to where it was (live or scrubbed). let liveBeforePlay = true; const endPlayback = () => { btnPlay.textContent = "Play"; if (liveBeforePlay) { live = true; updateLiveButton(); } }; scrub.addEventListener("input", () => { live = false; liveBeforePlay = false; previewMode = false; scrubFrame = parseInt(scrub.value); if (player.playing) player.stop(); updateLiveButton(); }); btnLive.addEventListener("click", () => { live = true; liveBeforePlay = true; previewMode = false; updateLiveButton(); }); btnDesign.title = "Show the design's stress-free shape instead of the simulation"; btnDesign.addEventListener("click", () => { showPreview(); updateLiveButton(); }); btnPlay.addEventListener("click", () => { if (player.playing) { player.stop(); endPlayback(); return; } if (!sim || sim.samples === 0) return; liveBeforePlay = live; const offset = live ? 0 : scrubFrame / sim.fps; live = false; previewMode = false; player.play(sim.recordedAudio(), sim.fs, offset, levelMode(), gainDb()); btnPlay.textContent = "Stop"; updateLiveButton(); }); player.onEnded = endPlayback; btnWav.addEventListener("click", () => { if (!sim || sim.samples === 0) return; const blob = encodeWav(sim.recordedAudio(), sim.fs, levelMode(), gainDb()); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); const name = sim.plate.name.replace(/[^a-z0-9]+/gi, "_").replace(/^_+|_+$/g, "").toLowerCase() || "cymbal"; a.download = `${name}_${(sim.samples / sim.fs).toFixed(1)}s_${sim.fs}Hz.wav`; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000); }); for (const [v, t] of [["absolute", "Absolute (0 dBFS = 1 Pa at 0 dB)"], ["loudness", "Loudness (RMS, soft clip)"], ["peak", "Peak"]]) { const o = document.createElement("option"); o.value = v; o.textContent = t; levelSel.appendChild(o); } gainSlider.type = "range"; gainSlider.min = "-40"; gainSlider.max = "60"; gainSlider.step = "1"; gainSlider.value = "20"; gainSlider.style.width = "120px"; gainSlider.style.flex = "none"; const restart = () => { updateLevelReadout(); const pos = player.position(); if (pos !== null && sim) player.play(sim.recordedAudio(), sim.fs, pos, levelMode(), gainDb()); }; gainSlider.addEventListener("input", restart); levelSel.addEventListener("change", restart); tb.append(btnPlay, btnLive, btnDesign, scrub, timeLabel, labeled("Level", levelSel), labeled("Gain", gainSlider), gainLabel, peakLabel, btnWav); updateLevelReadout(); updateLiveButton(); } function updateLiveButton(): void { btnLive.classList.toggle("on", live && !previewMode); btnLive.disabled = !sim || (live && !previewMode); btnDesign.classList.toggle("on", previewMode); btnDesign.disabled = previewMode; } function buildSpecToolbar(): void { const tb = $("specToolbar"); const win = document.createElement("select"); for (const n of [512, 1024, 2048, 4096, 8192]) { const o = document.createElement("option"); o.value = String(n); o.textContent = String(n); win.appendChild(o); } win.value = "2048"; const hop = numInput(512, 64); const fmax = numInput(5000, 500); const floor = numInput(80, 5); const logf = document.createElement("input"); logf.type = "checkbox"; const apply = () => { if (!sim) return; spec.configure( { 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 }, sim.recordedAudio(), sim.samples, ); }; for (const el of [win, hop, fmax, floor, logf]) el.addEventListener("change", apply); tb.append(document.createTextNode("Spectrogram"), labeled("Window", win), labeled("Hop", hop), labeled("Max Hz", fmax), labeled("dB range", floor), labeled("Log freq.", logf)); } function numInput(v: number, step: number): HTMLInputElement { const i = document.createElement("input"); i.type = "number"; i.value = String(v); i.step = String(step); return i; } function labeled(text: string, el: HTMLElement): HTMLLabelElement { const l = document.createElement("label"); l.append(text, el); return l; } // ---------- Design ---------- /** Re-evaluates the current design at the current spacing and refreshes everything that depends on it. */ function rebuildPlate(): void { const design = editor.design; plate = null; plateError = editor.error; if (design) { try { plate = evaluatePlate(design, params.spacing); } catch (e) { plateError = e instanceof DesignError ? e.message : String(e); } } form.setPlate(plate); editor.setStatus(plateError, plate ? designSummary(plate) : ""); showPreview(); updateButtons(); } function fmtMm(v: number): string { return `${(v * 1e3).toFixed(2)} mm`; } function designSummary(pl: Plate): string { const g = pl.grid; const hr = thicknessRange(pl); const { mass } = massProperties(pl); const { peakToPeak, maxSlope } = profileStats(pl); const rows: [string, string][] = [ ["Design", pl.name], ["Bounding box", `${fmtMm(pl.Lx)} × ${fmtMm(pl.Ly)} (${(pl.Lx / 0.0254).toFixed(1)} × ${(pl.Ly / 0.0254).toFixed(1)} in)`], ["Material", `E ${(pl.mat.E / 1e9).toFixed(0)} GPa, ν ${pl.mat.nu}, ρ ${pl.mat.rho} kg/m³`], ["Thickness", hr.min === hr.max ? fmtMm(hr.min) : `${fmtMm(hr.min)} to ${fmtMm(hr.max)} (mean ${fmtMm(hr.mean)})`], ["Mass", `${(mass * 1e3).toFixed(0)} g`], ["Grid", `${g.nx} × ${g.ny}, ${countInside(g)} nodes in the plate`], ["Profile", peakToPeak > 0 ? `${fmtMm(peakToPeak)} peak to peak, largest slope ${maxSlope.toFixed(3)}` : "flat"], ]; if (pl.mount) { const mp = mountPatch(pl); 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`]); } else { rows.push(["Mount", "none (free plate, inertia relief)"]); } rows.push(["Losses", `σ₀ ${pl.loss.sigma0} 1/s, σ₁ ${pl.loss.sigma1} m²/s, in-plane ${pl.loss.sigmaIn} 1/s`]); let html = rows.map(([a, b]) => `
${a}${b}
`).join(""); if (maxSlope > SLOPE_LIMIT) html += `
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.
`; return html; } // ---------- Simulation control ---------- async function start(): Promise { if (!device || !plate) return; player.stop(); running = false; await new Promise((r) => setTimeout(r, 0)); sim?.destroy(); spec.reset(); live = true; paused = false; btnPause.textContent = "Pause"; params.duration = duration; try { sim = new Simulation(device, plate, params); } catch (e) { $("info").innerHTML = `
Could not start: ${String(e)}
`; return; } const hr = thicknessRange(plate); renderer?.setField(sim.grid, sim.w0, sim.nw, sim.hn, sim.w0Max, [hr.min, hr.max]); previewMode = false; movieBuf?.destroy(); movieBuf = device.createBuffer({ size: Math.max(16, sim.grid.n * 4), usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST }); movieBufFrame = -1; scrub.max = "0"; scrub.value = "0"; lastStatsTime = performance.now(); lastStatsSteps = 0; stepsPerSec = 0; setEditable(false); updateLiveButton(); void runLoop(); } async function runLoop(): Promise { if (!sim || running) return; running = true; updateButtons(); try { while (sim.phase !== "done" && !paused) { await sim.runBatch(); spec.update(sim.recordedAudio(), sim.samples); scrub.max = String(Math.max(0, sim.frames.length - 1)); if (live) scrub.value = scrub.max; updateLevelReadout(); const now = performance.now(); if (now - lastStatsTime > 500) { stepsPerSec = ((sim.totalSteps - lastStatsSteps) * 1000) / (now - lastStatsTime); lastStatsTime = now; lastStatsSteps = sim.totalSteps; } } } catch (e) { console.error(e); $("info").innerHTML = `
Simulation error: ${String(e)}
`; } running = false; if (sim.phase === "done") setEditable(true); updateButtons(); } // ---------- Render loop ---------- let frameErrorShown = false; let lastRenderKey = ""; let lastInfoHtml = ""; function frame(): void { requestAnimationFrame(frame); try { frameBody(); } catch (e) { if (!frameErrorShown) { frameErrorShown = true; console.error("frame exception:", e); } } } function markers(pl: Plate): void { if (!renderer) return; const [sx, sy] = strikePoint(params, pl); renderer.strike = { x: sx, y: sy, r: params.contactRadius }; renderer.mount = pl.mount ? { x: pl.mount.x, y: pl.mount.y, r: pl.mount.radius } : null; } function frameBody(): void { if (!device || !renderer) return; renderer.mode = viewMode.value; renderer.colorMode = colorSel.value as ColorMode; renderer.exaggeration = parseFloat(exagInput.value) || 1; renderer.colorRange = (parseFloat(rangeInput.value) || 0) * 1e-3; renderer.showShape = shapeCheck.checked; const canvas = $("view"); const cam = renderer.camera; const viewKey = [ renderer.mode, renderer.colorMode, renderer.exaggeration, renderer.colorRange, renderer.showShape, cam.yaw.toFixed(4), cam.pitch.toFixed(4), cam.dist.toFixed(4), canvas.clientWidth, canvas.clientHeight, window.devicePixelRatio, params.strikeFracX, params.strikeFracY, params.contactRadius, ]; if (previewMode || !sim) { // The stress-free shape of the design that the next Start will use. ensurePreview(device); const renderKey = ["preview", ...viewKey, preview?.plate.name, preview?.plate.grid.n].join("|"); if (renderKey !== lastRenderKey) { lastRenderKey = renderKey; renderPreview(device); } if (sim) { spec.draw($("spec"), sim.fs, Math.max(duration, sim.samples / sim.fs), null); updateInfo(sim.status()); } return; } const pos = player.position(); if (pos !== null) { scrubFrame = Math.min(sim.frames.length - 1, Math.floor(pos * sim.fps)); scrub.value = String(scrubFrame); } const st = sim.status(); // Render only when something visible changed, so an idle or paused page does no work. const renderKey = [...viewKey, live, scrubFrame, sim.frames.length, st.totalSteps, st.phase].join("|"); if (renderKey !== lastRenderKey) { lastRenderKey = renderKey; renderView(device, st); } spec.draw($("spec"), sim.fs, Math.max(duration, sim.samples / sim.fs), pos ?? (live ? null : scrubFrame / sim.fps)); updateInfo(st); } /** Builds the preview buffers from the current plate when they are missing or stale. */ function ensurePreview(dev: GPUDevice): void { if (preview && !preview.stale) return; if (!plate) return; if (preview) for (const b of [preview.w0, preview.zero, preview.nw, preview.hn]) b.destroy(); const grid = plate.grid; const shape = Float32Array.from(plate.w0); let wmax = 0; for (let k = 0; k < shape.length; k++) wmax = Math.max(wmax, Math.abs(shape[k])); const mk = (data: Float32Array) => { const b = dev.createBuffer({ size: Math.max(16, data.byteLength), usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST }); dev.queue.writeBuffer(b, 0, data); return b; }; preview = { plate, w0: mk(shape), zero: mk(new Float32Array(grid.n)), nw: mk(nodeWeights(grid)), hn: mk(Float32Array.from(plate.h)), wmax, stale: false }; const hr = thicknessRange(plate); renderer?.setField(grid, preview.zero, preview.nw, preview.hn, 0, [hr.min, hr.max]); lastRenderKey = ""; } /** Marks the preview stale (the design or the spacing changed) and shows it; the next frame rebuilds it. */ function showPreview(): void { if (preview) preview.stale = true; previewMode = true; updateLiveButton(); if (!sim) updateIdleInfo(); } function renderPreview(dev: GPUDevice): void { if (!renderer || !preview) return; // The renderer's "initial shape" buffer is zero here, and the shape itself is the displayed field. if (renderer.fieldGrid !== preview.plate.grid) { const hr = thicknessRange(preview.plate); renderer.setField(preview.plate.grid, preview.zero, preview.nw, preview.hn, 0, [hr.min, hr.max]); } markers(preview.plate); const enc = dev.createCommandEncoder(); renderer.render(enc, preview.w0, preview.wmax); timeLabel.textContent = "design"; dev.pushErrorScope("validation"); dev.queue.submit([enc.finish()]); void dev.popErrorScope().then((e) => { if (e && !frameErrorShown) { frameErrorShown = true; console.error("frame validation error:", e.message); } }); } function updateIdleInfo(): void { const html = plate ? `

The view shows the design's stress-free shape. Press Start to strike it.

` : `

${plateError ?? "No design."}

`; if (html !== lastInfoHtml) { lastInfoHtml = html; $("info").innerHTML = html; } } function renderView(dev: GPUDevice, st: SimStatus): void { if (!sim || !renderer) return; if (renderer.fieldGrid !== sim.grid) { const hr = thicknessRange(sim.plate); renderer.setField(sim.grid, sim.w0, sim.nw, sim.hn, sim.w0Max, [hr.min, hr.max]); } markers(sim.plate); const enc = dev.createCommandEncoder(); if (live || sim.frames.length === 0) { sim.encodeDisplay(enc); renderer.render(enc, sim.displayW, st.wmax); timeLabel.textContent = `${st.t.toFixed(3)} s`; } else { const f = Math.max(0, Math.min(sim.frames.length - 1, scrubFrame)); if (movieBufFrame !== f) { dev.queue.writeBuffer(movieBuf!, 0, sim.frames[f]); movieBufFrame = f; } let wmax = 0; const fr = sim.frames[f]; for (let k = 0; k < fr.length; k += 7) wmax = Math.max(wmax, Math.abs(fr[k])); renderer.render(enc, movieBuf!, wmax); timeLabel.textContent = `${(f / sim.fps).toFixed(3)} s`; } dev.pushErrorScope("validation"); dev.queue.submit([enc.finish()]); void dev.popErrorScope().then((e) => { if (e && !frameErrorShown) { frameErrorShown = true; console.error("frame validation error:", e.message); } }); } function updateInfo(st: SimStatus): void { if (!sim) return; const g = sim.grid; const spl = st.peakPressure > 0 ? 20 * Math.log10(st.peakPressure / 2e-5) : -Infinity; const rows: [string, string][] = [ ["Design", sim.plate.name], ["Phase", st.phase === "done" ? "done" : "ringing (recording)"], ["Time", `${st.t.toFixed(3)} s`], ["Grid", `${g.nx} × ${g.ny} nodes (${countInside(g)} in the plate), ${(g.hx * 1e3).toFixed(2)} × ${(g.hy * 1e3).toFixed(2)} mm`], ["Time step", `${(sim.dt * 1e6).toFixed(3)} µs (${sim.stepsPerSample} per sample)`], ["Speed", `${(stepsPerSec / 1e3).toFixed(0)} k steps/s (${(stepsPerSec * sim.dt).toFixed(3)}× real time)`], ["Kinetic energy", `${st.ke.toExponential(2)} J`], ["Max |w|", `${(st.wmax * 1e3).toFixed(3)} mm`], ["Audio", `${(st.samples / sim.fs).toFixed(2)} s, ${st.frames} frames`], ["Peak pressure", st.peakPressure > 0 ? `${st.peakPressure.toFixed(3)} Pa (${spl.toFixed(1)} dB SPL)` : "–"], ["Stick", `${(st.stick.x * 1e3).toFixed(2)} mm, ${st.stick.v.toFixed(2)} m/s`], ["Contact force", `${st.stick.force.toFixed(1)} N (max ${st.stick.maxForce.toFixed(1)} N)`], ]; let html = rows.map(([a, b]) => `
${a}${b}
`).join(""); if (st.delayClamped) html += `
The listener is so close that some propagation delays were clamped.
`; if (!isFinite(st.wmax) || st.wmax > 1) html += `
The solution has diverged. Increase the grid spacing (which lengthens the time step) or reduce the strike.
`; if (html !== lastInfoHtml) { lastInfoHtml = html; $("info").innerHTML = html; } } // ---------- Self-test ---------- let selfTestResult: { done: boolean; passed?: boolean; cases?: SelfTestCase[]; error?: string } = { done: false }; async function selfTest(): Promise { if (!device) { selfTestResult = { done: true, passed: false, error: "no GPU device" }; return; } $("info").innerHTML = `

Running the GPU-versus-CPU self-test...

`; try { const cases = await runSelfTest(device); const passed = cases.every(selfTestPassed); selfTestResult = { done: true, passed, cases }; const rows = cases.map((c) => `
${c.name}acc ${c.accError.u.toExponential(1)} / ${c.accError.v.toExponential(1)} / ${c.accError.w.toExponential(1)}, pos ${c.positionError.toExponential(1)}
`); $("info").innerHTML = `
Self-test ${passed ? "passed" : "FAILED"}
${rows.join("")}

Maximum relative errors of the u, v, w accelerations and of the compensated w update, GPU step versus float64 reference.

`; } catch (e) { selfTestResult = { done: true, passed: false, error: String(e) }; $("info").innerHTML = `
Self-test error: ${String(e)}
`; } } // ---------- Init ---------- async function init(): Promise { editor = new DesignEditor($("design")); form = new ParamForm($("params"), params); form.onChange = (key, isLive) => { if (key === "spacing") { rebuildPlate(); return; } if (!isLive) { showPreview(); return; } if (!sim) return; if (key.startsWith("listener")) sim.setListener(params.listenerDist, params.listenerAzimuth, params.listenerElevation); }; editor.onDesign = () => rebuildPlate(); buildControls(); buildViewToolbar(); buildMovieToolbar(); buildSpecToolbar(); const q = new URLSearchParams(location.search); const set = form.applyUrl(q); if (set.includes("duration")) { duration = params.duration; durInput.value = String(duration); } const presetId = q.get("preset"); const preset = presetId ? PRESETS.find((p) => p.id === presetId) : null; if (preset) editor.setScript(preset.source, preset.id); else editor.compileNow(); exposeHooks(); if (!("gpu" in navigator)) { showNoGpu("WebGPU is not available in this browser. Use a recent Chrome or Edge, or Firefox or Safari with WebGPU enabled."); return; } const adapter = (await navigator.gpu.requestAdapter({ powerPreference: "high-performance" })) ?? (await navigator.gpu.requestAdapter()); if (!adapter) { showNoGpu("No WebGPU adapter was found."); return; } device = await adapter.requestDevice(); device.lost.then((info) => showNoGpu(`WebGPU device lost: ${info.message}`)); device.addEventListener("uncapturederror", (ev) => console.error("WebGPU error:", (ev as GPUUncapturedErrorEvent).error.message)); renderer = new Renderer(device, $("view")); updateIdleInfo(); updateButtons(); requestAnimationFrame(frame); if (q.get("selftest") === "1") await selfTest(); else if (q.get("autostart") === "1") void start(); } /** Hooks for automated tests, on window.steelsim. */ function exposeHooks(): void { (window as unknown as { steelsim: unknown }).steelsim = { status: () => (sim ? { ...sim.status(), dt: sim.dt, stepsPerSample: sim.stepsPerSample, nx: sim.grid.nx, ny: sim.grid.ny, stepsPerSec } : null), audio: () => (sim ? Array.from(sim.recordedAudio()) : null), frames: () => sim?.frames.length ?? 0, design: () => (plate ? { name: plate.name, nodes: countInside(plate.grid), mounted: !!plate.mount, error: null } : { name: null, nodes: 0, mounted: false, error: plateError }), setScript: (source: string) => editor.setScript(source), start: () => void start(), selftest: () => selfTestResult, noGpu: () => { const e = $("nogpu"); return e.hidden ? null : e.textContent; }, }; } function showNoGpu(msg: string): void { const el = $("nogpu"); el.hidden = false; el.textContent = msg; btnStart.disabled = true; selfTestResult = { done: true, passed: false, error: msg }; } void init();