// Wiring: load a .cymsim file (button or drop), show every part of it — the mesh and overlays in // the 3D scene, the displacement movie and the acceleration record as animations on it, a node // probe, and the summary / config / energy / hits panels in the inspector. import { parseConfigYaml } from "./config/yaml.ts"; import { readCymsim, type CymsimRun, type H5Module } from "./io/cymsimFile.ts"; import { SceneView, type ColorMode, type OverlayName } from "./scene.ts"; import { FramePlayer, accelerationSource, displacementSource, type FrameSource } from "./playback.ts"; import { drawEnergy, drawSpectrogram, drawWaveform } from "./ui/plots.ts"; import { Player, scaleForPlayback } from "./audio/player.ts"; import { renderListener, type RenderedListener } from "./listener.ts"; const $ = (id: string) => document.getElementById(id) as T; const fmt = (x: number, digits = 3) => Number.isFinite(x) ? x.toPrecision(digits).replace(/\.?0+$/, "").replace(/\.?0+e/, "e") : "?"; const mb = (bytes: number) => `${(bytes / 1048576).toFixed(1)} MB`; let run: CymsimRun | null = null; let probeNode: number | null = null; let probeSignal: { data: Float32Array; fs: number } | null = null; let rendered: RenderedListener | null = null; let renderGen = 0; const audio = new Player(); // ---- 3D scene and playback ---------------------------------------------------------------- const scene = new SceneView($("scene"), (node) => probe(node)); const playBtn = $("pb-play"), posSlider = $("pb-pos"), timeSpan = $("pb-time"); const player = new FramePlayer( (i, time) => { if (!player.source) return; const f = player.source.frame(i); scene.setFrame(f.w, f.field, f.scale); posSlider.value = String(Math.round(player.fraction * 1000)); timeSpan.textContent = `${time.toFixed(3)} s`; }, () => { playBtn.textContent = "Play"; }, ); const sourceSel = $("pb-source"), slowdownSel = $("pb-slowdown"); for (const s of [1, 5, 10, 50, 100, 500, 1000, 2000, 5000, 10000]) { const o = document.createElement("option"); o.value = String(s); o.textContent = `x${s}`; slowdownSel.append(o); } let sources: FrameSource[] = []; function selectSource(k: number) { const s = sources[k] ?? null; player.setSource(s); playBtn.disabled = !s; if (s) { slowdownSel.value = String(s.kind === "acceleration" ? 2000 : 10); player.slowdown = Number(slowdownSel.value); } timeSpan.textContent = "0.000 s"; posSlider.value = "0"; } sourceSel.addEventListener("change", () => selectSource(sourceSel.selectedIndex)); slowdownSel.addEventListener("change", () => { player.slowdown = Number(slowdownSel.value); }); playBtn.addEventListener("click", () => { if (player.playing) player.pause(); else { player.play(); playBtn.textContent = "Pause"; } }); posSlider.addEventListener("input", () => player.seek(Number(posSlider.value) / 1000)); $("color-mode").addEventListener("change", (e) => scene.setColorMode((e.target as HTMLSelectElement).value as ColorMode)); $("exag").addEventListener("change", (e) => scene.setExaggeration(Number((e.target as HTMLSelectElement).value))); scene.setExaggeration(Number($("exag").value)); for (const name of ["wireframe", "boundary", "normals", "hits", "listener", "grid", "axes"] as OverlayName[]) { const box = $(`ov-${name}`); box.addEventListener("change", () => scene.setOverlay(name, box.checked)); } const applyOverlays = () => { for (const name of ["wireframe", "boundary", "normals", "hits", "listener", "grid", "axes"] as OverlayName[]) { scene.setOverlay(name, $(`ov-${name}`).checked); } }; // ---- inspector tabs ------------------------------------------------------------------------ function activateTab(name: string) { for (const b of document.querySelectorAll("#tabs button")) b.classList.toggle("active", b.dataset.tab === name); for (const t of document.querySelectorAll(".tab")) t.classList.toggle("active", t.id === `tab-${name}`); if (name === "energy" && run) drawEnergy($("energy-plot"), run.energy); if (name === "probe" && probeNode !== null) drawProbe(); if (name === "audio" && run) drawAudioSignal(); } for (const b of document.querySelectorAll("#tabs button")) { b.addEventListener("click", () => activateTab(b.dataset.tab!)); } function facts(pairs: [string, string][]): string { return `
${pairs.map(([k, v]) => `
${k}
${v}
`).join("")}
`; } function showSummary(r: CymsimRun, name: string, bytes: number) { const pairs: [string, string][] = [ ["file", `${name} (${mb(bytes)})`], ["creator", r.creator], ["created", r.created], ["duration", `${fmt(r.duration)} s configured, ${fmt(r.audioUnbaffled.length / r.fs)} s stored${r.complete ? "" : " (partial)"}`], ["audio", `${r.audioUnbaffled.length} samples at ${r.fs} Hz`], ["time step", `${r.dt.toExponential(3)} s, ${r.substeps} sub-steps per sample`], ["mesh", `${r.mesh.nNodes} nodes, ${r.mesh.nElements} triangles, ${r.mesh.boundary.length} boundary nodes`], ["radius", `${fmt(r.config.profile.radius * 1000)} mm, element size ${fmt(r.config.mesh.element_size * 1000)} mm`], ["listener", `(${r.config.listener.position.map((x) => fmt(x)).join(", ")}) m`], ["hits", `${r.hits.length} recorded, ${r.config.hits.length} configured`], ["energy log", `${r.energy.length} rows`], ["displacement", r.displacement ? `${r.displacement.frames.length} frames at ${fmt(r.displacement.rate)} fps` : "none"], ["acceleration", r.acceleration ? `${r.acceleration.nSamples} samples x ${r.acceleration.nodes} nodes (${mb(r.acceleration.bytes)})` : "none"], ["state", r.state ? `${r.state.kind}, step ${r.state.step}` : "none or not cymsimweb's"], ["wall time", `${fmt(r.wallTime)} s`], ]; $("tab-summary").innerHTML = facts(pairs); $("file-summary").innerHTML = facts(pairs.slice(0, 4)); } function showHits(r: CymsimRun) { if (r.hits.length === 0) { $("hits-table").innerHTML = `
no hits recorded yet
`; return; } const rows = r.hits.map((h) => `${h.kind}${fmt(h.time)}${fmt(h.peak_force)}${fmt(h.contact_time * 1000)}` + `${fmt(h.rebound_velocity)}${fmt(h.energy_in)}${fmt(h.energy_out)}`).join(""); $("hits-table").innerHTML = `${rows}
kindt [s]peak [N]contact [ms]rebound [m/s]E in [J]E out [J]
`; } // ---- node probe ---------------------------------------------------------------------------- function probe(node: number) { probeNode = node; scene.setProbe(node); activateTab("probe"); } function drawProbe() { if (!run || probeNode === null) return; const i = probeNode, m = run.mesh; const wave = $("probe-wave"), spec = $("probe-spec"); const info: [string, string][] = [ ["node", String(i)], ["position", `(${fmt(m.xy[2 * i] * 1000)}, ${fmt(m.xy[2 * i + 1] * 1000)}, ${fmt(m.z0[i] * 1000)}) mm`], ["r", `${fmt(Math.hypot(m.xy[2 * i], m.xy[2 * i + 1]) * 1000)} mm`], ["thickness", `${fmt(m.thickness[i] * 1000)} mm`], ["normal", `(${fmt(m.normal[3 * i])}, ${fmt(m.normal[3 * i + 1])}, ${fmt(m.normal[3 * i + 2])})`], ]; const playBtn2 = $("probe-play"); if (run.acceleration) { const a = run.acceleration, N = a.nodes, sig = new Float32Array(a.nSamples); 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]; info.push(["signal", `normal acceleration, ${a.nSamples} samples at ${a.fs} Hz`]); $("probe-info").innerHTML = facts(info); probeSignal = { data: sig, fs: a.fs }; playBtn2.hidden = false; wave.hidden = false; spec.hidden = false; drawWaveform(wave, sig, a.fs, "m/s²"); drawSpectrogram(spec, sig, a.fs); } else if (run.displacement && run.displacement.frames.length > 1) { const d = run.displacement, sig = Float32Array.from(d.frames, (f) => f[i]); 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)`]); $("probe-info").innerHTML = facts(info); probeSignal = null; playBtn2.hidden = true; wave.hidden = false; spec.hidden = true; drawWaveform(wave, sig, d.rate, "m"); } else { info.push(["signal", "none: the file has neither an acceleration record nor displacement frames"]); $("probe-info").innerHTML = facts(info); probeSignal = null; playBtn2.hidden = true; wave.hidden = true; spec.hidden = true; } } // ---- audio: node pickup and listener signals ------------------------------------------------ function resetPlayButtons() { $("probe-play").textContent = "Play pickup"; $("audio-play").textContent = "Play"; } audio.onEnded = resetPlayButtons; $("probe-play").addEventListener("click", () => { const btn = $("probe-play"); if (audio.playing && btn.textContent === "Stop") { audio.stop(); resetPlayButtons(); return; } if (!probeSignal) return; audio.stop(); resetPlayButtons(); audio.play(scaleForPlayback(probeSignal.data, true), probeSignal.fs, 0); btn.textContent = "Stop"; }); interface AudioSignal { id: string; label: string; data: Float32Array; info: string } function audioSignals(): AudioSignal[] { if (!run) return []; const out: AudioSignal[] = [ { 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` }, { id: "stored-baffled", label: "stored baffled", data: run.audioBaffled, info: "built-in listener, Rayleigh integral (front only)" }, ]; if (rendered) { const pos = `(${rendered.position.map((x) => fmt(x)).join(", ")}) m`, via = `re-rendered from the acceleration record on the ${rendered.backend}`; const unbaffled = new Float32Array(rendered.front.length); for (let i = 0; i < unbaffled.length; i++) unbaffled[i] = rendered.front[i] - rendered.back[i]; out.push({ id: "rendered-unbaffled", label: `moved listener, unbaffled`, data: unbaffled, info: `listener at ${pos}, ${via}` }); out.push({ id: "rendered-baffled", label: `moved listener, baffled`, data: rendered.front, info: `listener at ${pos}, ${via}` }); } return out; } function refreshAudioTab(prefer?: string) { const sel = $("audio-signal"), keep = prefer ?? sel.value; const signals = audioSignals(); sel.innerHTML = ""; for (const s of signals) { const o = document.createElement("option"); o.value = s.id; o.textContent = s.label; sel.append(o); } if (signals.some((s) => s.id === keep)) sel.value = keep; $("audio-play").disabled = signals.length === 0 || (signals.find((s) => s.id === sel.value)?.data.length ?? 0) === 0; if (document.querySelector("#tab-audio.active")) drawAudioSignal(); } function drawAudioSignal() { if (!run) return; const sel = $("audio-signal"); const s = audioSignals().find((x) => x.id === sel.value); if (!s) return; $("audio-info").textContent = `${s.info} · ${s.data.length} samples at ${run.fs} Hz · playback normalized to 0.9 full scale`; drawWaveform($("audio-wave"), s.data, run.fs, "Pa"); drawSpectrogram($("audio-spec"), s.data, run.fs); } $("audio-signal").addEventListener("change", () => { refreshAudioTab(); drawAudioSignal(); }); $("audio-play").addEventListener("click", () => { const btn = $("audio-play"); if (audio.playing && btn.textContent === "Stop") { audio.stop(); resetPlayButtons(); return; } if (!run) return; const s = audioSignals().find((x) => x.id === $("audio-signal").value); if (!s || s.data.length === 0) return; audio.stop(); resetPlayButtons(); audio.play(scaleForPlayback(s.data, true), run.fs, 0); btn.textContent = "Stop"; }); // ---- movable listener ----------------------------------------------------------------------- const lisInputs = (["lis-x", "lis-y", "lis-z"] as const).map((id) => $(id)); function setListenerInputs(p: [number, number, number]) { lisInputs.forEach((el, i) => { el.value = String(p[i]); }); } function listenerFromInputs(): [number, number, number] | null { const v = lisInputs.map((el) => Number(el.value)); return v.every((x) => Number.isFinite(x)) ? (v as [number, number, number]) : null; } async function rerenderListener(pos: [number, number, number]) { if (!run) return; const status = $("lis-status"), gen = ++renderGen; if (!run.acceleration || run.acceleration.nSamples === 0) { 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"; return; } status.textContent = "rendering..."; const out = await renderListener(run, pos, { onProgress: (f, backend) => { if (gen === renderGen) status.textContent = `rendering on the ${backend}... ${Math.round(f * 100)}%`; }, shouldStop: () => gen !== renderGen, }); if (gen !== renderGen || !out) return; rendered = out; status.textContent = `rendered at (${pos.map((x) => fmt(x)).join(", ")}) m on the ${out.backend}; see the Audio tab`; refreshAudioTab("rendered-unbaffled"); } let lisTimer = 0; for (const el of lisInputs) { el.addEventListener("input", () => { const pos = listenerFromInputs(); if (!pos) return; scene.setListenerPosition(pos); clearTimeout(lisTimer); lisTimer = window.setTimeout(() => void rerenderListener(pos), 500); }); } // ---- file loading -------------------------------------------------------------------------- // h5wasm (libhdf5 in WebAssembly) is loaded on first use; its in-memory file system holds the image let h5Promise: Promise<{ h5: H5Module; FS: any }> | null = null; const loadH5 = () => (h5Promise ??= import("h5wasm").then(async (m) => { const h5 = m.default as unknown as H5Module; const { FS } = await h5.ready; return { h5, FS }; })); async function loadFile(name: string, bytes: ArrayBuffer, size: number) { const log = $("log"); log.textContent = `reading ${name}...`; try { const { h5, FS } = await loadH5(); const path = "/load.cymsim"; FS.writeFile(path, new Uint8Array(bytes)); try { run = readCymsim(h5, path, (y) => { const r = parseConfigYaml(y); if (!r.config) throw new Error(r.issues[0]?.message ?? "bad YAML"); return r.config; }); } finally { try { FS.unlink(path); } catch { /* not written */ } } } catch (err) { log.textContent = `failed to load ${name}: ${(err as Error).message}`; return; } log.textContent = ""; probeNode = null; probeSignal = null; rendered = null; renderGen++; audio.stop(); resetPlayButtons(); $("scene-empty").hidden = true; $("view-controls").hidden = false; $("playback").hidden = false; scene.setRun(run); applyOverlays(); scene.setColorMode($("color-mode").value as ColorMode); showSummary(run, name, size); $("config-yaml").textContent = run.yaml; showHits(run); $("probe-info").innerHTML = `
Click a node on the cymbal to probe it.
`; $("probe-play").hidden = true; $("probe-wave").hidden = true; $("probe-spec").hidden = true; $("listener-section").hidden = false; setListenerInputs([...run.config.listener.position]); $("lis-status").textContent = run.acceleration ? "move the listener to re-render its sound from the acceleration record" : "no acceleration record in this file, so a moved listener cannot be re-rendered"; refreshAudioTab("stored-unbaffled"); activateTab("summary"); sources = []; if (run.acceleration && run.acceleration.nSamples > 0) sources.push(accelerationSource(run.acceleration, run.displacement)); if (run.displacement && run.displacement.frames.length > 0) sources.push(displacementSource(run.displacement)); sourceSel.innerHTML = ""; for (const s of sources) { const o = document.createElement("option"); o.textContent = s.label; sourceSel.append(o); } if (sources.length === 0) { const o = document.createElement("option"); o.textContent = "nothing to animate"; sourceSel.append(o); } selectSource(0); } const fromFile = async (f: File) => loadFile(f.name, await f.arrayBuffer(), f.size); $("load").addEventListener("click", () => $("file").click()); $("file").addEventListener("change", (e) => { const f = (e.target as HTMLInputElement).files?.[0]; if (f) void fromFile(f); }); document.body.addEventListener("dragover", (e) => { e.preventDefault(); document.body.classList.add("dragover"); }); document.body.addEventListener("dragleave", () => document.body.classList.remove("dragover")); document.body.addEventListener("drop", (e) => { e.preventDefault(); document.body.classList.remove("dragover"); const f = e.dataTransfer?.files?.[0]; if (f) void fromFile(f); }); // ?file= loads a served .cymsim on startup (same origin or CORS-enabled) const fileUrl = new URLSearchParams(location.search).get("file"); if (fileUrl) { void (async () => { try { const resp = await fetch(fileUrl); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const bytes = await resp.arrayBuffer(); await loadFile(fileUrl.split("/").pop() ?? fileUrl, bytes, bytes.byteLength); } catch (err) { $("log").textContent = `failed to fetch ${fileUrl}: ${(err as Error).message}`; } })(); }