de6cf45cymroom: load a .cymsim file and show every part of it in a 3D scene; the room acoustics solver is not written yetJeremy Magland 1// Wiring: load a .cymsim file (button or drop), show every part of it — the mesh and overlays in
2// the 3D scene, the displacement movie and the acceleration record as animations on it, a node
3// probe, and the summary / config / energy / hits panels in the inspector.
4import { parseConfigYaml } from "./config/yaml.ts";
5import { readCymsim, type CymsimRun, type H5Module } from "./io/cymsimFile.ts";
6import { SceneView, type ColorMode, type OverlayName } from "./scene.ts";
7import { FramePlayer, accelerationSource, displacementSource, type FrameSource } from "./playback.ts";
8import { drawEnergy, drawSpectrogram, drawWaveform } from "./ui/plots.ts";
9import { Player, scaleForPlayback } from "./audio/player.ts";
10import { renderListener, type RenderedListener } from "./listener.ts";
12const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
13const fmt = (x: number, digits = 3) => Number.isFinite(x) ? x.toPrecision(digits).replace(/\.?0+$/, "").replace(/\.?0+e/, "e") : "?";
14const mb = (bytes: number) => `${(bytes / 1048576).toFixed(1)} MB`;
16let run: CymsimRun | null = null;
17let probeNode: number | null = null;
18let probeSignal: { data: Float32Array; fs: number } | null = null;
19let rendered: RenderedListener | null = null;
20let renderGen = 0;
21const audio = new Player();
23// ---- 3D scene and playback ----------------------------------------------------------------
25const scene = new SceneView($<HTMLCanvasElement>("scene"), (node) => probe(node));
27const playBtn = $<HTMLButtonElement>("pb-play"), posSlider = $<HTMLInputElement>("pb-pos"), timeSpan = $("pb-time");
28const player = new FramePlayer(
29 (i, time) => {
30 if (!player.source) return;
31 const f = player.source.frame(i);
32 scene.setFrame(f.w, f.field, f.scale);
33 posSlider.value = String(Math.round(player.fraction * 1000));
34 timeSpan.textContent = `${time.toFixed(3)} s`;
35 },
36 () => { playBtn.textContent = "Play"; },
37);
39const sourceSel = $<HTMLSelectElement>("pb-source"), slowdownSel = $<HTMLSelectElement>("pb-slowdown");
40for (const s of [1, 5, 10, 50, 100, 500, 1000, 2000, 5000, 10000]) {
41 const o = document.createElement("option");
42 o.value = String(s); o.textContent = `x${s}`;
43 slowdownSel.append(o);
44}
45let sources: FrameSource[] = [];
46function selectSource(k: number) {
47 const s = sources[k] ?? null;
48 player.setSource(s);
49 playBtn.disabled = !s;
50 if (s) { slowdownSel.value = String(s.kind === "acceleration" ? 2000 : 10); player.slowdown = Number(slowdownSel.value); }
51 timeSpan.textContent = "0.000 s"; posSlider.value = "0";
52}
53sourceSel.addEventListener("change", () => selectSource(sourceSel.selectedIndex));
54slowdownSel.addEventListener("change", () => { player.slowdown = Number(slowdownSel.value); });
55playBtn.addEventListener("click", () => {
56 if (player.playing) player.pause();
57 else { player.play(); playBtn.textContent = "Pause"; }
58});
59posSlider.addEventListener("input", () => player.seek(Number(posSlider.value) / 1000));
61$<HTMLSelectElement>("color-mode").addEventListener("change", (e) => scene.setColorMode((e.target as HTMLSelectElement).value as ColorMode));
62$<HTMLSelectElement>("exag").addEventListener("change", (e) => scene.setExaggeration(Number((e.target as HTMLSelectElement).value)));
63scene.setExaggeration(Number($<HTMLSelectElement>("exag").value));
64for (const name of ["wireframe", "boundary", "normals", "hits", "listener", "grid", "axes"] as OverlayName[]) {
65 const box = $<HTMLInputElement>(`ov-${name}`);
66 box.addEventListener("change", () => scene.setOverlay(name, box.checked));
67}
68const applyOverlays = () => {
69 for (const name of ["wireframe", "boundary", "normals", "hits", "listener", "grid", "axes"] as OverlayName[]) {
70 scene.setOverlay(name, $<HTMLInputElement>(`ov-${name}`).checked);
71 }
72};
74// ---- inspector tabs ------------------------------------------------------------------------
76function activateTab(name: string) {
77 for (const b of document.querySelectorAll<HTMLButtonElement>("#tabs button")) b.classList.toggle("active", b.dataset.tab === name);
78 for (const t of document.querySelectorAll<HTMLElement>(".tab")) t.classList.toggle("active", t.id === `tab-${name}`);
79 if (name === "energy" && run) drawEnergy($<HTMLCanvasElement>("energy-plot"), run.energy);
80 if (name === "probe" && probeNode !== null) drawProbe();
81 if (name === "audio" && run) drawAudioSignal();
82}
83for (const b of document.querySelectorAll<HTMLButtonElement>("#tabs button")) {
84 b.addEventListener("click", () => activateTab(b.dataset.tab!));
85}
87function facts(pairs: [string, string][]): string {
88 return `<dl class="facts">${pairs.map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join("")}</dl>`;
89}
91function showSummary(r: CymsimRun, name: string, bytes: number) {
92 const pairs: [string, string][] = [
93 ["file", `${name} (${mb(bytes)})`],
94 ["creator", r.creator], ["created", r.created],
95 ["duration", `${fmt(r.duration)} s configured, ${fmt(r.audioUnbaffled.length / r.fs)} s stored${r.complete ? "" : " (partial)"}`],
96 ["audio", `${r.audioUnbaffled.length} samples at ${r.fs} Hz`],
97 ["time step", `${r.dt.toExponential(3)} s, ${r.substeps} sub-steps per sample`],
98 ["mesh", `${r.mesh.nNodes} nodes, ${r.mesh.nElements} triangles, ${r.mesh.boundary.length} boundary nodes`],
99 ["radius", `${fmt(r.config.profile.radius * 1000)} mm, element size ${fmt(r.config.mesh.element_size * 1000)} mm`],
100 ["listener", `(${r.config.listener.position.map((x) => fmt(x)).join(", ")}) m`],
101 ["hits", `${r.hits.length} recorded, ${r.config.hits.length} configured`],
102 ["energy log", `${r.energy.length} rows`],
103 ["displacement", r.displacement ? `${r.displacement.frames.length} frames at ${fmt(r.displacement.rate)} fps` : "none"],
104 ["acceleration", r.acceleration ? `${r.acceleration.nSamples} samples x ${r.acceleration.nodes} nodes (${mb(r.acceleration.bytes)})` : "none"],
105 ["state", r.state ? `${r.state.kind}, step ${r.state.step}` : "none or not cymsimweb's"],
106 ["wall time", `${fmt(r.wallTime)} s`],
107 ];
108 $("tab-summary").innerHTML = facts(pairs);
109 $("file-summary").innerHTML = facts(pairs.slice(0, 4));
110}
112function showHits(r: CymsimRun) {
113 if (r.hits.length === 0) { $("hits-table").innerHTML = `<div class="muted">no hits recorded yet</div>`; return; }
114 const rows = r.hits.map((h) =>
115 `<tr><td>${h.kind}</td><td>${fmt(h.time)}</td><td>${fmt(h.peak_force)}</td><td>${fmt(h.contact_time * 1000)}</td>` +
116 `<td>${fmt(h.rebound_velocity)}</td><td>${fmt(h.energy_in)}</td><td>${fmt(h.energy_out)}</td></tr>`).join("");
117 $("hits-table").innerHTML =
118 `<table><tr><th>kind</th><th>t [s]</th><th>peak [N]</th><th>contact [ms]</th><th>rebound [m/s]</th><th>E in [J]</th><th>E out [J]</th></tr>${rows}</table>`;
119}
121// ---- node probe ----------------------------------------------------------------------------
123function probe(node: number) {
124 probeNode = node;
125 scene.setProbe(node);
126 activateTab("probe");
127}
129function drawProbe() {
130 if (!run || probeNode === null) return;
131 const i = probeNode, m = run.mesh;
132 const wave = $<HTMLCanvasElement>("probe-wave"), spec = $<HTMLCanvasElement>("probe-spec");
133 const info: [string, string][] = [
134 ["node", String(i)],
135 ["position", `(${fmt(m.xy[2 * i] * 1000)}, ${fmt(m.xy[2 * i + 1] * 1000)}, ${fmt(m.z0[i] * 1000)}) mm`],
136 ["r", `${fmt(Math.hypot(m.xy[2 * i], m.xy[2 * i + 1]) * 1000)} mm`],
137 ["thickness", `${fmt(m.thickness[i] * 1000)} mm`],
138 ["normal", `(${fmt(m.normal[3 * i])}, ${fmt(m.normal[3 * i + 1])}, ${fmt(m.normal[3 * i + 2])})`],
139 ];
140 const playBtn2 = $<HTMLButtonElement>("probe-play");
141 if (run.acceleration) {
142 const a = run.acceleration, N = a.nodes, sig = new Float32Array(a.nSamples);
143 for (const b of a.blocks) for (let r = 0; r < b.count; r++) sig[b.first + r] = b.data[r * N + i] * b.scale[r];
144 info.push(["signal", `normal acceleration, ${a.nSamples} samples at ${a.fs} Hz`]);
145 $("probe-info").innerHTML = facts(info);
146 probeSignal = { data: sig, fs: a.fs };
147 playBtn2.hidden = false;
148 wave.hidden = false; spec.hidden = false;
149 drawWaveform(wave, sig, a.fs, "m/s²");
150 drawSpectrogram(spec, sig, a.fs);
151 } else if (run.displacement && run.displacement.frames.length > 1) {
152 const d = run.displacement, sig = Float32Array.from(d.frames, (f) => f[i]);
153 info.push(["signal", `displacement from the movie frames, ${sig.length} at ${fmt(d.rate)} fps (a strobe, not a signal; no acceleration record in this file)`]);
154 $("probe-info").innerHTML = facts(info);
155 probeSignal = null; playBtn2.hidden = true;
156 wave.hidden = false; spec.hidden = true;
157 drawWaveform(wave, sig, d.rate, "m");
158 } else {
159 info.push(["signal", "none: the file has neither an acceleration record nor displacement frames"]);
160 $("probe-info").innerHTML = facts(info);
161 probeSignal = null; playBtn2.hidden = true;
162 wave.hidden = true; spec.hidden = true;
163 }
164}
166// ---- audio: node pickup and listener signals ------------------------------------------------
168function resetPlayButtons() {
169 $("probe-play").textContent = "Play pickup";
170 $("audio-play").textContent = "Play";
171}
172audio.onEnded = resetPlayButtons;
174$("probe-play").addEventListener("click", () => {
175 const btn = $<HTMLButtonElement>("probe-play");
176 if (audio.playing && btn.textContent === "Stop") { audio.stop(); resetPlayButtons(); return; }
177 if (!probeSignal) return;
178 audio.stop(); resetPlayButtons();
179 audio.play(scaleForPlayback(probeSignal.data, true), probeSignal.fs, 0);
180 btn.textContent = "Stop";
181});
183interface AudioSignal { id: string; label: string; data: Float32Array; info: string }
185function audioSignals(): AudioSignal[] {
186 if (!run) return [];
187 const out: AudioSignal[] = [
188 { id: "stored-unbaffled", label: "stored unbaffled", data: run.audioUnbaffled, info: `built-in listener at (${run.config.listener.position.map((x) => fmt(x)).join(", ")}) m, front minus diffracted back` },
189 { id: "stored-baffled", label: "stored baffled", data: run.audioBaffled, info: "built-in listener, Rayleigh integral (front only)" },
190 ];
191 if (rendered) {
192 const pos = `(${rendered.position.map((x) => fmt(x)).join(", ")}) m`, via = `re-rendered from the acceleration record on the ${rendered.backend}`;
193 const unbaffled = new Float32Array(rendered.front.length);
194 for (let i = 0; i < unbaffled.length; i++) unbaffled[i] = rendered.front[i] - rendered.back[i];
195 out.push({ id: "rendered-unbaffled", label: `moved listener, unbaffled`, data: unbaffled, info: `listener at ${pos}, ${via}` });
196 out.push({ id: "rendered-baffled", label: `moved listener, baffled`, data: rendered.front, info: `listener at ${pos}, ${via}` });
197 }
198 return out;
199}
201function refreshAudioTab(prefer?: string) {
202 const sel = $<HTMLSelectElement>("audio-signal"), keep = prefer ?? sel.value;
203 const signals = audioSignals();
204 sel.innerHTML = "";
205 for (const s of signals) {
206 const o = document.createElement("option");
207 o.value = s.id; o.textContent = s.label;
208 sel.append(o);
209 }
210 if (signals.some((s) => s.id === keep)) sel.value = keep;
211 $<HTMLButtonElement>("audio-play").disabled = signals.length === 0 || (signals.find((s) => s.id === sel.value)?.data.length ?? 0) === 0;
212 if (document.querySelector("#tab-audio.active")) drawAudioSignal();
213}
215function drawAudioSignal() {
216 if (!run) return;
217 const sel = $<HTMLSelectElement>("audio-signal");
218 const s = audioSignals().find((x) => x.id === sel.value);
219 if (!s) return;
220 $("audio-info").textContent = `${s.info} · ${s.data.length} samples at ${run.fs} Hz · playback normalized to 0.9 full scale`;
221 drawWaveform($<HTMLCanvasElement>("audio-wave"), s.data, run.fs, "Pa");
222 drawSpectrogram($<HTMLCanvasElement>("audio-spec"), s.data, run.fs);
223}
225$("audio-signal").addEventListener("change", () => { refreshAudioTab(); drawAudioSignal(); });
226$("audio-play").addEventListener("click", () => {
227 const btn = $<HTMLButtonElement>("audio-play");
228 if (audio.playing && btn.textContent === "Stop") { audio.stop(); resetPlayButtons(); return; }
229 if (!run) return;
230 const s = audioSignals().find((x) => x.id === $<HTMLSelectElement>("audio-signal").value);
231 if (!s || s.data.length === 0) return;
232 audio.stop(); resetPlayButtons();
233 audio.play(scaleForPlayback(s.data, true), run.fs, 0);
234 btn.textContent = "Stop";
235});
237// ---- movable listener -----------------------------------------------------------------------
239const lisInputs = (["lis-x", "lis-y", "lis-z"] as const).map((id) => $<HTMLInputElement>(id));
241function setListenerInputs(p: [number, number, number]) { lisInputs.forEach((el, i) => { el.value = String(p[i]); }); }
243function listenerFromInputs(): [number, number, number] | null {
244 const v = lisInputs.map((el) => Number(el.value));
245 return v.every((x) => Number.isFinite(x)) ? (v as [number, number, number]) : null;
246}
248async function rerenderListener(pos: [number, number, number]) {
249 if (!run) return;
250 const status = $("lis-status"), gen = ++renderGen;
251 if (!run.acceleration || run.acceleration.nSamples === 0) {
252 status.textContent = "no acceleration record in this file, so the sound at a moved listener cannot be re-rendered; the stored signals are for the stored position";
253 return;
254 }
255 status.textContent = "rendering...";
256 const out = await renderListener(run, pos, {
257 onProgress: (f, backend) => { if (gen === renderGen) status.textContent = `rendering on the ${backend}... ${Math.round(f * 100)}%`; },
258 shouldStop: () => gen !== renderGen,
259 });
260 if (gen !== renderGen || !out) return;
261 rendered = out;
262 status.textContent = `rendered at (${pos.map((x) => fmt(x)).join(", ")}) m on the ${out.backend}; see the Audio tab`;
263 refreshAudioTab("rendered-unbaffled");
264}
266let lisTimer = 0;
267for (const el of lisInputs) {
268 el.addEventListener("input", () => {
269 const pos = listenerFromInputs();
270 if (!pos) return;
271 scene.setListenerPosition(pos);
272 clearTimeout(lisTimer);
273 lisTimer = window.setTimeout(() => void rerenderListener(pos), 500);
274 });
275}
277// ---- file loading --------------------------------------------------------------------------
279// h5wasm (libhdf5 in WebAssembly) is loaded on first use; its in-memory file system holds the image
280let h5Promise: Promise<{ h5: H5Module; FS: any }> | null = null;
281const loadH5 = () => (h5Promise ??= import("h5wasm").then(async (m) => {
282 const h5 = m.default as unknown as H5Module;
283 const { FS } = await h5.ready;
284 return { h5, FS };
285}));
287async function loadFile(name: string, bytes: ArrayBuffer, size: number) {
288 const log = $("log");
289 log.textContent = `reading ${name}...`;
290 try {
291 const { h5, FS } = await loadH5();
292 const path = "/load.cymsim";
293 FS.writeFile(path, new Uint8Array(bytes));
294 try {
295 run = readCymsim(h5, path, (y) => {
296 const r = parseConfigYaml(y);
297 if (!r.config) throw new Error(r.issues[0]?.message ?? "bad YAML");
298 return r.config;
299 });
300 } finally { try { FS.unlink(path); } catch { /* not written */ } }
301 } catch (err) {
302 log.textContent = `failed to load ${name}: ${(err as Error).message}`;
303 return;
304 }
305 log.textContent = "";
306 probeNode = null; probeSignal = null; rendered = null; renderGen++;
307 audio.stop(); resetPlayButtons();
308 $("scene-empty").hidden = true;
309 $("view-controls").hidden = false;
310 $("playback").hidden = false;
311 scene.setRun(run);
312 applyOverlays();
313 scene.setColorMode($<HTMLSelectElement>("color-mode").value as ColorMode);
314 showSummary(run, name, size);
315 $("config-yaml").textContent = run.yaml;
316 showHits(run);
317 $("probe-info").innerHTML = `<div class="muted">Click a node on the cymbal to probe it.</div>`;
318 $<HTMLButtonElement>("probe-play").hidden = true;
319 $<HTMLCanvasElement>("probe-wave").hidden = true;
320 $<HTMLCanvasElement>("probe-spec").hidden = true;
321 $("listener-section").hidden = false;
322 setListenerInputs([...run.config.listener.position]);
323 $("lis-status").textContent = run.acceleration
324 ? "move the listener to re-render its sound from the acceleration record"
325 : "no acceleration record in this file, so a moved listener cannot be re-rendered";
326 refreshAudioTab("stored-unbaffled");
327 activateTab("summary");
328 sources = [];
329 if (run.acceleration && run.acceleration.nSamples > 0) sources.push(accelerationSource(run.acceleration, run.displacement));
330 if (run.displacement && run.displacement.frames.length > 0) sources.push(displacementSource(run.displacement));
331 sourceSel.innerHTML = "";
332 for (const s of sources) { const o = document.createElement("option"); o.textContent = s.label; sourceSel.append(o); }
333 if (sources.length === 0) { const o = document.createElement("option"); o.textContent = "nothing to animate"; sourceSel.append(o); }
334 selectSource(0);
335}
337const fromFile = async (f: File) => loadFile(f.name, await f.arrayBuffer(), f.size);
338$("load").addEventListener("click", () => $<HTMLInputElement>("file").click());
339$<HTMLInputElement>("file").addEventListener("change", (e) => {
340 const f = (e.target as HTMLInputElement).files?.[0];
341 if (f) void fromFile(f);
342});
343document.body.addEventListener("dragover", (e) => { e.preventDefault(); document.body.classList.add("dragover"); });
344document.body.addEventListener("dragleave", () => document.body.classList.remove("dragover"));
345document.body.addEventListener("drop", (e) => {
346 e.preventDefault();
347 document.body.classList.remove("dragover");
348 const f = e.dataTransfer?.files?.[0];
349 if (f) void fromFile(f);
350});
352// ?file=<url> loads a served .cymsim on startup (same origin or CORS-enabled)
353const fileUrl = new URLSearchParams(location.search).get("file");
354if (fileUrl) {
355 void (async () => {
356 try {
357 const resp = await fetch(fileUrl);
358 if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
359 const bytes = await resp.arrayBuffer();
360 await loadFile(fileUrl.split("/").pop() ?? fileUrl, bytes, bytes.byteLength);
361 } catch (err) {
362 $("log").textContent = `failed to fetch ${fileUrl}: ${(err as Error).message}`;
363 }
364 })();
365}