/** * The app: problem controls, the solve pipeline (near quadrature, optional * self-test, main problem, energy balance, field on three planes), and the * views. There are two problems: the room (a point source inside a rounded * cube with impedance walls, the interior equation) and the cymbal (a listener * near a rigid cymbal in free space, the exterior equation with lambda = 0, * solved with the source at the listener). The operator is kept between solves * when only the wall admittance or the source changes, since its near weights * depend only on k and the surface. */ import { roundedCube, type Surface } from './surface.ts'; import { cymbal, CymbalShape, CYMBAL_DEFAULTS, insideTest, ringLayout } from './cymbal.ts'; import { ALPHA, energyBalance, green, inside, maskedPoints, relError, roomRhs, selftestPoints, selftestRhs, slicePoints, type CVec, type Slice, } from './problem.ts'; import { BOX, boxSlices, C_SOUND, farFieldPower, LISTENER, RADIUS, selftestPoints as cymbalPoints, selftestSources, sourceSum } from './cymbalProblem.ts'; import { leggauss } from './gauss.ts'; import { adapterDescription, Gpu } from './gpu/device.ts'; import { Operator } from './gpu/operator.ts'; import { colorLimits, type Field, type Mode, type SliceField } from './view/field.ts'; import { SliceView } from './view/slices.ts'; import { RoomView } from './view/room3d.ts'; import { drawColorbar, DIVERGING, SEQUENTIAL } from './view/colormaps.ts'; import { drawConvergence } from './view/convergence.ts'; import { fieldGif } from './view/exportGif.ts'; const $ = (id: string): T => { const el = document.getElementById(id); if (!el) throw new Error(`no #${id}`); return el as T; }; const A = 1.0; const EXTERIOR_SOURCE = [1.7, 0.6, -0.5]; type Problem = 'room' | 'cymbal'; interface Params { problem: Problem; // the room k: number; beta: number; m: number; p: number; // the cymbal: frequency (Hz), target patch size (cm), rim thickness (mm) freq: number; h: number; tRim: number; // both norder: number; x0: [number, number, number]; eps: number; ngrid: number; selftest: boolean; } interface Preset { label: string; values: Partial; } const PRESETS: Record> = { room: { quick: { label: 'quick (1,176 points)', values: { k: 6, beta: 0.2, m: 2, norder: 6 } }, small: { label: 'small (4,704 points)', values: { k: 10, beta: 0.2, m: 4, norder: 6 } }, medium: { label: 'medium (17,496 points)', values: { k: 10, beta: 0.2, m: 6, norder: 8 } }, large: { label: 'large (48,600 points)', values: { k: 40, beta: 0.05, m: 10, norder: 8 } }, }, cymbal: { quick: { label: 'quick (4,800 points, 20 mm rim)', values: { freq: 1000, h: 10, tRim: 20, norder: 4 } }, small: { label: 'small (19,012 points)', values: { freq: 1000, h: 6, tRim: 10, norder: 6 } }, medium: { label: 'medium (35,964 points)', values: { freq: 3000, h: 4, tRim: 10, norder: 8 } }, large: { label: 'large (42,444 points)', values: { freq: 8000, h: 3, tRim: 10, norder: 8 } }, }, }; const COMMON: Omit = { k: 10, beta: 0.2, m: 4, p: 8, freq: 1000, h: 6, tRim: 10, norder: 6, eps: 1e-5, ngrid: 101, selftest: false }; const DEFAULTS: Record = { room: { ...COMMON, problem: 'room', x0: [-0.35, 0.2, 0.1] }, cymbal: { ...COMMON, ...PRESETS.cymbal.small.values, problem: 'cymbal', x0: [...LISTENER] }, }; const ui = { problem: $('problem'), preset: $('preset'), k: $('k'), beta: $('beta'), m: $('m'), freq: $('freq'), h: $('h'), tRim: $('tRim'), norder: $('norder'), p: $('p'), x0: [$('x0'), $('y0'), $('z0')], x0label: $('x0label'), x0name: $('x0name'), eps: $('eps'), ngrid: $('ngrid'), selftest: $('selftest'), solve: $('solve'), cancel: $('cancel'), derived: $('derived'), progress: $('progress'), status: $('status'), err: $('err'), mode: $('mode'), levelOption: $('levelOption'), animate: $('animate'), showWalls: $('showWalls'), wallsName: $('wallsName'), showPlanes: [$('showXY'), $('showXZ'), $('showYZ')], exportGif: $('exportGif'), hint: $('hint'), results: $('results'), conv: $('conv'), empty: $('empty'), cbar: $('cbar'), cbarHi: $('cbarHi'), cbarLo: $('cbarLo'), hover: $('hover'), device: $('device'), }; const fmtBytes = (b: number) => (b >= 2 ** 30 ? `${(b / 2 ** 30).toFixed(2)} GB` : `${(b / 2 ** 20).toFixed(0)} MB`); const fmtS = (s: number) => (s < 10 ? `${s.toFixed(2)} s` : `${s.toFixed(1)} s`); const cymbalShape = (p: Params) => new CymbalShape({ ...CYMBAL_DEFAULTS, tRim: p.tRim / 1000 }); const wavenumber = (p: Params) => (p.problem === 'room' ? p.k : (2 * Math.PI * p.freq) / C_SOUND); // ------------------------------------------------------------------ params function readParams(): Params { return { problem: ui.problem.value as Problem, k: Number(ui.k.value), beta: Number(ui.beta.value), m: Math.round(Number(ui.m.value)), p: Number(ui.p.value), freq: Number(ui.freq.value), h: Number(ui.h.value), tRim: Number(ui.tRim.value), norder: Number(ui.norder.value), x0: ui.x0.map((e) => Number(e.value)) as [number, number, number], eps: Number(ui.eps.value), ngrid: Number(ui.ngrid.value), selftest: ui.selftest.checked, }; } function writeParams(p: Params) { if (ui.problem.value !== p.problem) { ui.problem.value = p.problem; } applyProblem(p.problem); ui.k.value = String(p.k); ui.beta.value = String(p.beta); ui.m.value = String(p.m); ui.p.value = String(p.p); ui.freq.value = String(p.freq); ui.h.value = String(p.h); ui.tRim.value = String(p.tRim); ui.norder.value = String(p.norder); p.x0.forEach((v, i) => (ui.x0[i].value = String(+v.toFixed(3)))); ui.eps.value = String(p.eps); if (![...ui.eps.options].some((o) => o.value === ui.eps.value)) ui.eps.value = '1e-5'; ui.ngrid.value = String(p.ngrid); ui.selftest.checked = p.selftest; matchPreset(); } /** Show the controls and labels of one problem, and its named cases. */ function applyProblem(problem: Problem) { const room = problem === 'room'; document.querySelectorAll('.room-only').forEach((el) => el.classList.toggle('hidden', !room)); document.querySelectorAll('.cymbal-only').forEach((el) => el.classList.toggle('hidden', room)); if (ui.preset.dataset.problem !== problem) { ui.preset.replaceChildren( ...Object.entries(PRESETS[problem]).map(([v, pr]) => new Option(pr.label, v)), new Option('custom', 'custom'), ); ui.preset.dataset.problem = problem; } ui.x0name.textContent = room ? 'source (x, y, z)' : 'listener (x, y, z) in m'; ui.x0label.title = room ? 'Source position; must be inside the room. You can also click a slice.' : 'Listener position, where the source is placed (reciprocity); must be outside the cymbal. You can also click a slice.'; ui.wallsName.textContent = room ? 'walls' : 'cymbal'; ui.levelOption.textContent = room ? 'level (dB re median)' : 'level (dB re free field)'; ui.hint.textContent = room ? 'drag to turn · scroll to zoom · the nearer walls are cut away' : 'drag to turn · scroll to zoom'; } function presetMatches(p: Params, v: Partial): boolean { return Object.entries(v).every(([key, val]) => p[key as keyof Params] === val); } function matchPreset() { const p = readParams(); const name = Object.entries(PRESETS[p.problem]).find(([, pr]) => presetMatches(p, pr.values))?.[0]; ui.preset.value = name ?? 'custom'; } function validate(p: Params): string | null { if (p.problem === 'room') { if (!(p.k > 0 && p.k <= 80)) return 'the wavenumber must be between 0 and 80'; if (!(p.beta > 0)) return 'the wall admittance must be positive (absorbing walls)'; if (!(p.m >= 1 && p.m <= 16)) return 'patches per face edge must be between 1 and 16'; if (!inside(p.x0, A, p.p, 0.04)) return 'the source must be inside the room, a little away from the walls'; return null; } if (!(p.freq >= 100 && p.freq <= 16000)) return 'the frequency must be between 100 Hz and 16 kHz'; if (!(p.h >= 2 && p.h <= 20)) return 'the patch size must be between 2 and 20 cm'; if (!(p.tRim >= 4 && p.tRim <= 40)) return 'the rim thickness must be between 4 and 40 mm'; if (!p.x0.every((v) => Math.abs(v) <= 3)) return 'the listener must be within 3 m of the cymbal'; if (insideTest(cymbalShape(p), 0.01)(p.x0)) return 'the listener must be outside the cymbal, at least 1 cm from it'; return null; } // ------------------------------------------------------------------ derived let deviceLimit = Infinity; function describe(p: Params): { html: string; blocking: boolean } { const npols = (p.norder + 1) ** 2; const rfac = p.norder <= 6 ? 2.0 : 1.25; let npts: number; let perWl: number; let perNode: number; let size: string; if (p.problem === 'room') { npts = 6 * p.m * p.m * npols; const wl = (2 * Math.PI) / p.k; perWl = ((p.norder + 1) * p.m * wl) / (2 * A); // near patches per node, measured on the named cases: about 9 at rfac 2, 5.5 at rfac 1.25, capped by the patch count perNode = Math.min(6 * p.m * p.m, rfac >= 2 ? 9.5 : 5.8); size = `room ${((2 * A) / wl).toFixed(1)} wavelengths wide`; } else { const L = ringLayout(cymbalShape(p), p.h / 100); const npatches = 2 * (L.mC ** 2 + L.counts.reduce((a, b) => a + b, 0)) + L.nrim; npts = npatches * npols; const wl = C_SOUND / p.freq; perWl = ((p.norder + 1) * wl) / (p.h / 100); // measured on the named cases: about 15 near patches per node at rfac 2 and 5 at rfac 1.25 (the thin body) perNode = rfac >= 2 ? 15 : 5.5; size = `cymbal ${((2 * RADIUS) / wl).toFixed(1)} wavelengths across`; } const wbytes = npts * perNode * 6 * npols * 8; const parts = [ `${npts.toLocaleString()} points`, size, `${perWl.toFixed(1)} nodes per wavelength`, `near weights ≈ ${fmtBytes(wbytes)}`, ]; if (wbytes > 1.3 * deviceLimit) parts.push(`probably more than this GPU allows (${fmtBytes(deviceLimit)})`); if (perWl < 5) parts.push('under-resolved: expect large errors'); let blocking = false; const err = validate(p); if (err) { parts.push(`${err}`); blocking = true; } return { html: parts.join(' · '), blocking }; } function updateDerived() { const d = describe(readParams()); ui.derived.innerHTML = d.html; if (!running) ui.solve.disabled = d.blocking; } // ------------------------------------------------------------------ status let running = false; let cancelled = false; class Cancelled extends Error {} function setStatus(text: string, fraction?: number) { ui.status.textContent = text; if (fraction === undefined) ui.progress.removeAttribute('value'); else ui.progress.value = fraction; } function checkCancel() { if (cancelled) throw new Cancelled('cancelled'); } // ------------------------------------------------------------------ results type Row = [string, string, string?]; function showResults(rows: Row[]) { const tb = document.createElement('tbody'); for (const [a, b, cls] of rows) { const tr = document.createElement('tr'); const td1 = document.createElement('td'); td1.textContent = a; const td2 = document.createElement('td'); td2.textContent = b; if (cls) td2.className = cls; tr.append(td1, td2); tb.append(tr); } ui.results.replaceChildren(tb); } const quality = (e: number, tol: number) => (e < 10 * tol ? '' : e < 1e-2 ? 'warn' : 'bad'); /** Pause so that the page can repaint the status line before a long CPU step. */ const yieldToPage = () => new Promise((r) => setTimeout(r, 0)); // ------------------------------------------------------------------ main async function start() { if (!navigator.gpu) throw new Error('this browser has no WebGPU. Chrome and Edge 113+, Safari 26+, and Firefox 141+ on Windows have it; on Linux, Chrome may need chrome://flags/#enable-unsafe-webgpu.'); const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' }); if (!adapter) throw new Error('WebGPU found no adapter on this machine.'); const lim = adapter.limits; const device = await adapter.requestDevice({ requiredLimits: { maxStorageBufferBindingSize: lim.maxStorageBufferBindingSize, maxBufferSize: lim.maxBufferSize }, }); device.lost.then((info) => { if (info.reason !== 'destroyed') { ui.err.textContent = `The GPU device was lost (${info.message}). This can happen when one dispatch runs too long; reload the page to continue.`; } }); deviceLimit = Math.min(device.limits.maxStorageBufferBindingSize, device.limits.maxBufferSize); const software = /swiftshader/i.test(adapterDescription(adapter)); ui.device.textContent = `WebGPU adapter: ${adapterDescription(adapter)}; largest buffer ${fmtBytes(deviceLimit)}.` + (software ? ' This is a software adapter, so only the quick cases are practical.' : ''); const gpu = new Gpu(device, adapter); const slices = new SliceView( $('slices'), (x) => { const p = readParams(); if (validate({ ...p, x0: x })) return; x.forEach((v, i) => (ui.x0[i].value = String(+v.toFixed(3)))); updateDerived(); if (!running) void solve(); }, (t) => (ui.hover.textContent = t), ); const room = new RoomView(device, $('room')); let op: Operator | null = null; let opKey = ''; let surf: Surface | null = null; let field: Field | null = null; let conv: { label: string; res: number[]; color: string }[] = []; let lastEps = 1e-5; let fieldTitle = ''; /** The three planes as a GIF, in the current display mode. */ const buildGif = () => (field ? fieldGif(field, ui.mode.value as Mode, { title: fieldTitle }) : null); const redrawConv = () => drawConvergence(ui.conv, conv, lastEps); const updateColorbar = () => { const mode = ui.mode.value as Mode; const diverging = mode === 'pressure' || (field?.level.diverging ?? false); drawColorbar(ui.cbar, diverging ? DIVERGING : SEQUENTIAL); if (!field) { ui.cbarHi.textContent = ui.cbarLo.textContent = ''; } else if (mode === 'pressure') { ui.cbarHi.textContent = `+${field.umax.toPrecision(2)}`; ui.cbarLo.textContent = `−${field.umax.toPrecision(2)}`; } else { ui.cbarHi.textContent = `+${field.level.max} dB`; ui.cbarLo.textContent = `${field.level.min} dB`; } }; async function solve() { const p = readParams(); const bad = validate(p); if (bad) { ui.err.textContent = bad; return; } running = true; cancelled = false; ui.solve.disabled = true; ui.cancel.disabled = false; ui.err.textContent = ''; lastEps = p.eps; const rows: Row[] = []; // the previous solution no longer matches the controls, so take it down field = null; ui.exportGif.disabled = true; slices.clear(); room.clear(); ui.empty.textContent = 'Solving…'; ui.empty.style.display = ''; showResults([['solving…', '']]); updateColorbar(); conv = []; redrawConv(); try { const t0 = performance.now(); const isRoom = p.problem === 'room'; const k = wavenumber(p); const key = isRoom ? `room|${p.k}|${p.m}|${p.norder}|${p.p}` : `cymbal|${p.freq}|${p.h}|${p.norder}|${p.tRim}`; const lam = isRoom ? -p.beta : 0; const shape = isRoom ? null : cymbalShape(p); let setupS = 0; if (!op || key !== opKey) { op?.destroy(); op = null; opKey = ''; setStatus('building the surface…'); await yieldToPage(); surf = isRoom ? roundedCube(A, p.p, p.m, p.norder) : cymbal(shape!, p.h / 100, p.norder); const o = new Operator(gpu, surf, k, ALPHA, lam, isRoom ? 0 : 1, { progress: (stage, f) => { checkCancel(); setStatus(`${stage}: ${(100 * f).toFixed(0)}%`, f); }, }); setStatus('near quadrature…', 0); const info = await o.setup(); op = o; opKey = key; setupS = info.setupSeconds; } const o = op!; const s = surf!; o.lam = lam; const info = o.info; rows.push(['points', s.npts.toLocaleString()]); rows.push(['near pairs · quadtree leaves', `${info.npairs.toLocaleString()} · ${info.nleaves.toLocaleString()}`]); rows.push(['near weights', fmtBytes(info.weightsBytes)]); rows.push(['near quadrature (setup)', setupS ? fmtS(setupS) : `reused (${fmtS(info.setupSeconds)} earlier)`]); const npts = s.npts; const xs = s.srcvals.subarray(0, 3 * npts); const nrm = s.srcvals.subarray(9 * npts, 12 * npts); conv = []; if (p.selftest) { const series = { label: 'self-test', res: [] as number[], color: '#8b949e' }; conv.push(series); let rhs: CVec; let exact: (x: Float64Array) => CVec; let xt: Float64Array; if (isRoom) { rhs = selftestRhs(xs, nrm, EXTERIOR_SOURCE, k, lam); exact = (x) => green(x, EXTERIOR_SOURCE, k); xt = selftestPoints(50, A); } else { setStatus('self-test: the data of the interior sources…'); await yieldToPage(); const { x0s, ws } = selftestSources(shape!); rhs = sourceSum(xs, x0s, ws, k, nrm); exact = (x) => sourceSum(x, x0s, ws, k); xt = cymbalPoints(shape!, 50); } const sol = await o.solve(rhs, p.eps, 400, (it, r) => { checkCancel(); series.res.push(r); redrawConv(); setStatus(`self-test GMRES: iteration ${it}, residual ${r.toExponential(1)}`); }); setStatus('self-test: evaluating…'); const ut = await o.eval(xt, sol.x, sol.phi1, 'self-test field'); const ub = await o.evalOnSurface(sol.x, sol.phi1); const e1 = relError(ut, exact(xt)); const e2 = relError(ub, exact(xs)); rows.push([isRoom ? 'self-test error, interior · walls' : 'self-test error, around · on the cymbal', `${e1.toExponential(1)} · ${e2.toExponential(1)}`, quality(Math.max(e1, e2), p.eps)]); } const series = { label: isRoom ? 'room problem' : 'cymbal problem', res: [] as number[], color: '#2f81f7' }; conv.push(series); const sol = await o.solve(roomRhs(xs, nrm, p.x0, k, lam), p.eps, 400, (it, r) => { checkCancel(); series.res.push(r); redrawConv(); setStatus(`GMRES: iteration ${it}, residual ${r.toExponential(1)}`); }); rows.push(['GMRES iterations · residual', `${sol.niter} · ${sol.rres.toExponential(1)}`]); rows.push(['solve', `${fmtS(sol.seconds)} (${((1000 * sol.seconds) / sol.niter).toFixed(0)} ms per iteration)`]); // energy balance setStatus('energy balance…'); await yieldToPage(); const v0 = await o.eval(Float64Array.from(p.x0), sol.x, sol.phi1, 'field at the source'); const vw = await o.evalOnSurface(sol.x, sol.phi1); const gw = green(xs, p.x0, k); const uWall = new Float64Array(2 * npts); for (let i = 0; i < 2 * npts; i++) uWall[i] = gw[i] + vw[i]; if (isRoom) { const eb = energyBalance(k, lam, [v0[0], v0[1]], s.wts, uWall); rows.push(['power: source · walls', `${eb.pSrc.toFixed(6)} · ${eb.pWall.toFixed(6)}`]); rows.push(['energy mismatch', eb.mismatch.toExponential(1), quality(eb.mismatch, p.eps)]); } else { const pSrc = k / (4 * Math.PI) + v0[1]; // the far field pattern costs (number of directions) x (number of nodes) on the CPU const L = Math.ceil(k * 0.6) + 20; if ((L + 1) * (2 * L + 2) * npts < 2e8) { const pFar = farFieldPower(k, p.x0, xs, nrm, s.wts, sol.x, sol.phi1, ALPHA, leggauss); const mismatch = Math.abs(pSrc - pFar) / pSrc; rows.push(['power: source · to infinity', `${pSrc.toFixed(6)} · ${pFar.toFixed(6)}`]); rows.push(['energy mismatch', mismatch.toExponential(1), quality(mismatch, p.eps)]); } else { rows.push(['power from the source', `${pSrc.toFixed(6)} (the far field check is skipped at this size)`]); } } // the field on the three planes const tf = performance.now(); const sl: Slice[] = isRoom ? slicePoints(p.ngrid, p.x0, A, p.p).slices : boxSlices(p.ngrid, p.x0, shape!); const sfs: SliceField[] = []; for (const [si, slice] of sl.entries()) { const { pts, index } = maskedPoints(slice.pts, slice.mask); const v = await o.eval(pts, sol.x, sol.phi1, `field on the ${slice.name}-plane (${si + 1} of 3)`); const g = green(pts, p.x0, k); const u = new Float64Array(2 * slice.mask.length).fill(NaN); const ref = isRoom ? undefined : new Float64Array(slice.mask.length).fill(NaN); for (let q = 0; q < index.length; q++) { u[2 * index[q]] = g[2 * q] + v[2 * q]; u[2 * index[q] + 1] = g[2 * q + 1] + v[2 * q + 1]; if (ref) ref[index[q]] = Math.hypot(g[2 * q], g[2 * q + 1]); } sfs.push({ slice, u, ref }); } const nfield = sl.reduce((acc, x) => acc + x.mask.reduce((a, b) => a + b, 0), 0); rows.push(['field on the planes', `${fmtS((performance.now() - tf) / 1000)} (${nfield.toLocaleString()} points)`]); rows.push(['total', fmtS((performance.now() - t0) / 1000)]); const lims = colorLimits(sfs, p.x0, k); if (isRoom) { field = { k, x0: [...p.x0], ngrid: p.ngrid, slices: sfs, uWall, freeFieldLevel: false, ...lims, level: { min: -30, max: 15, diverging: false, label: 're median' }, scene: { center: [0, 0, 0], radius: 1, cutaway: true }, }; } else { const [r, z] = shape!.crossSection(); const c = BOX.map(([lo, hi]) => (lo + hi) / 2) as [number, number, number]; field = { k, x0: [...p.x0], ngrid: p.ngrid, slices: sfs, uWall, freeFieldLevel: true, ...lims, level: { min: -15, max: 15, diverging: true, label: 're free field' }, scene: { center: c, radius: 0.45, cutaway: false }, outline: { r, z, radius: RADIUS }, }; } fieldTitle = isRoom ? `Point source at (${p.x0.join(', ')}) in a room, k = ${p.k}, wall admittance ${p.beta}` : `Listener at (${p.x0.join(', ')}) m near a rigid cymbal, ${p.freq} Hz; u(x) is the pressure at the listener due to a source at x`; slices.setField(field); room.setField(s, field); ui.exportGif.disabled = false; ui.empty.style.display = 'none'; updateColorbar(); showResults(rows); redrawConv(); setStatus(`done in ${fmtS((performance.now() - t0) / 1000)}`, 1); } catch (e) { ui.empty.textContent = 'No solution: press Solve to compute the field.'; if (e instanceof Cancelled) { setStatus('cancelled', 0); // a cancelled setup leaves no usable operator if (op && !op.info) { op.destroy(); op = null; opKey = ''; } } else { console.error(e instanceof Error ? `${e.message}\n${e.stack}` : String(e)); ui.err.textContent = e instanceof Error ? e.message : String(e); setStatus('failed', 0); op?.destroy(); op = null; opKey = ''; } if (rows.length) showResults(rows); } finally { running = false; ui.cancel.disabled = true; updateDerived(); } } ui.solve.addEventListener('click', () => void solve()); ui.exportGif.addEventListener('click', () => { const p = readParams(); setStatus('writing the GIF…'); setTimeout(() => { const gif = buildGif(); if (!gif) return; const url = URL.createObjectURL(new Blob([gif as Uint8Array], { type: 'image/gif' })); const a = document.createElement('a'); a.href = url; const what = p.problem === 'room' ? `room-k${p.k}` : `cymbal-${p.freq}Hz`; a.download = `fmm3dbie-${what}-${ui.mode.value}.gif`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 10_000); setStatus(`wrote ${a.download} (${(gif.length / 2 ** 20).toFixed(1)} MB)`, 1); }, 0); }); ui.cancel.addEventListener('click', () => { cancelled = true; setStatus('cancelling…'); }); ui.problem.addEventListener('change', () => { const problem = ui.problem.value as Problem; const d = DEFAULTS[problem]; writeParams({ ...d, x0: [...d.x0], ...(software ? PRESETS[problem].quick.values : {}) }); updateDerived(); }); ui.preset.addEventListener('change', () => { const pr = PRESETS[readParams().problem][ui.preset.value]; if (pr) writeParams({ ...readParams(), ...pr.values }); updateDerived(); }); for (const el of [ui.k, ui.beta, ui.m, ui.freq, ui.h, ui.tRim, ui.norder, ui.p, ...ui.x0, ui.eps, ui.ngrid, ui.selftest]) { el.addEventListener('input', () => { matchPreset(); updateDerived(); }); } ui.mode.addEventListener('change', () => { updateColorbar(); room.invalidate(); }); for (const el of [ui.showWalls, ...ui.showPlanes]) el.addEventListener('change', () => room.invalidate()); ui.x0.forEach((el) => el.addEventListener('keydown', (e) => e.key === 'Enter' && !running && void solve())); // the frame loop: one period of the pressure every 2 s let phase = 0; let last = performance.now(); const frame = (t: number) => { const dt = (t - last) / 1000; last = t; const animating = ui.animate.checked && ui.mode.value === 'pressure' && field !== null; if (animating) phase = (phase + dt * Math.PI) % (2 * Math.PI); const mode = ui.mode.value as Mode; const ph = mode === 'pressure' ? phase : 0; if (field && (animating || slicesDirty)) { slices.draw(mode, ph); slicesDirty = false; } // ?noroom skips the 3D view: in headless Chrome, presenting to a WebGPU canvas breaks later async calls if (!noRoom) { // the checkboxes by plane name, in the order of the field's planes const byName = { xy: ui.showPlanes[0].checked, xz: ui.showPlanes[1].checked, yz: ui.showPlanes[2].checked }; const planes = (field?.slices.map((sf) => byName[sf.slice.name]) ?? [false, false, false]) as [boolean, boolean, boolean]; room.draw(mode, ph, { walls: ui.showWalls.checked, planes }, animating); } requestAnimationFrame(frame); }; let slicesDirty = true; // (the test hook __app__.showRoom() turns the view on after a solve, for screenshots) let noRoom = location.search.includes('noroom'); ui.mode.addEventListener('change', () => (slicesDirty = true)); ui.animate.addEventListener('change', () => (slicesDirty = true)); requestAnimationFrame(frame); // ?problem=cymbal selects the cymbal at load, ?case= a named case, ?autosolve solves at once const query = new URLSearchParams(location.search); const initial: Problem = query.get('problem') === 'cymbal' ? 'cymbal' : 'room'; const d0 = DEFAULTS[initial]; writeParams({ ...d0, x0: [...d0.x0], ...(software ? PRESETS[initial].quick.values : {}) }); updateDerived(); updateColorbar(); redrawConv(); const named = PRESETS[initial][query.get('case') ?? '']; if (named) { writeParams({ ...readParams(), ...named.values }); updateDerived(); } setStatus(software ? 'ready (software adapter: choose the quick case)' : 'ready', 0); if (query.has('autosolve')) void solve(); (window as unknown as { __app__: unknown }).__app__ = { solve, get field() { return field; }, showRoom() { noRoom = false; room.invalidate(); }, buildGif, }; } start().catch((e) => { console.error(e); ui.err.textContent = e instanceof Error ? e.message : String(e); setStatus('unavailable', 0); }); export type { CVec };