/ proof-of-concept / cymroom
Sign in
proof-of-concept / cymroom
cymroom: load a .cymsim file and show every part of it in a 3D scene; the room acoustics solver is not written yet
Jeremy Magland <jmagland@flatironinstitute.org> committed commit de6cf45193a8 Browse files
22 changed files+3383−0
.gitignoreadded+3−0View file
@@ -0,0 +1,3 @@
1+node_modules/
2+dist/
3+public/*.cymsim
README.mdadded+53−0View file
@@ -0,0 +1,53 @@
1+# cymroom
2+
3+A browser companion of [cymsim](https://vault1.magland.org/proof-of-concept/cymsim) and [cymsimweb](https://vault1.magland.org/proof-of-concept/cymsimweb). Those programs simulate the struck cymbal itself and compute its sound at one built-in listener; a `.cymsim` file they write can carry the normal acceleration of every node at the audio rate, which is exactly what another program needs to compute the sound elsewhere. cymroom is that other program: it loads a `.cymsim` file, places the cymbal in a 3D scene, and will compute the sound at any listener position, with or without room walls, by solving the acoustic wave equation in air on the GPU. The solver does not exist yet; what exists is the viewer, which shows every part of the file in 3D.
4+
5+## Running
6+
7+Requires Node 20 or later and a browser with WebGL.
8+
9+ npm install
10+ npm run dev # development server
11+ npm run build # static site in dist/
12+
13+## Using the app
14+
15+Load a `.cymsim` file with the button, by dropping it on the page, or with `?file=<url>` for a served file. The file may come from the Python package or from cymsimweb; the acceleration record (`output.record_acceleration: true` there) is optional but is what makes the probe and the acceleration movie available.
16+
17+The scene shows the cymbal at true scale with a ground grid and the stored listener position. Drag to orbit, wheel to zoom, right-drag to pan. The surface color is bronze, thickness, or rest height; overlays toggle the wireframe, the boundary loop, the nodal normals, the hit markers (orange for stick, magenta for mallet, placed at each configured hit's r and phi), the listener, the grid, and the axes.
18+
19+**Animation.** The playback bar animates whichever records the file holds; the acceleration record is the default when present. The displacement movie deforms the surface (scaled by the exaggeration selector) and tints it red/blue against the movie's overall peak. The acceleration record is a movie at the audio rate: each frame tints the surface by that sample's normal acceleration, normalized per frame because the signal decays through many decades, and borrows the nearest displacement frame for the shape. Both play against wall time divided by the slowdown factor; the position slider scrubs.
20+
21+**Probe.** Click a node to see it in the Probe tab: its position, thickness and normal, and, when the file has the acceleration record, that node's acceleration waveform and spectrogram, with a Play button that plays the node as a pickup (normalized; an accelerometer glued to that point). This is the signal the future solver will use as its source, one node at a time. Without the record the probe falls back to the node's displacement from the movie frames, which is a strobe for display rather than a signal.
22+
23+**Listener.** The Listener section moves the listener (x, y, z in meters; the scene marker follows). When the file has the acceleration record, the sound at the new position is re-rendered from it with the same free-field model as cymsim's built-in listener: a Rayleigh integral over element-mean accelerations for the front path and a diffracted path over the rim (the boundary loop, subdivided to about 720 points) for the back term, each element at its own fractional delay. The gather runs on WebGPU when the browser has it and on the CPU otherwise. `tools/render-check.ts` re-renders at the file's stored position and compares with the stored signals; both agree to about 1.5e-4 relative rms on the test file, on both backends (`--gpu` runs the WGSL kernel under Dawn, using the cymsimweb checkout's `webgpu` package).
24+
25+**Audio.** The Audio tab plays and plots the stored baffled and unbaffled signals and, after the listener has moved, the re-rendered pair. Playback is normalized to 0.9 full scale.
26+
27+The other tabs show the file summary (which groups are present and their sizes), the configuration YAML, the energy log (log scale, with the nonlinearity meter max|w|/h below), and the hit table.
28+
29+## Tools
30+
31+`node tools/make-test-file.ts out.cymsim` generates a small real file (the coarse test configuration, 0.35 s, with the acceleration record) using cymsimweb's CPU loop; it requires a cymsimweb checkout beside this repository. `node tools/read-check.ts file.cymsim` loads a file headlessly and prints what it found.
32+
33+## Layout
34+
35+| path | contents |
36+|---|---|
37+| `src/config/`, `src/io/`, `src/analysis/` | copied from cymsimweb: configuration schema and YAML, the .cymsim (HDF5) reader/writer, the acceleration and displacement stores, the STFT |
38+| `src/scene.ts` | the three.js scene: cymbal mesh, overlays, picking |
39+| `src/playback.ts` | frame sources over the two records and the slowed-down player |
40+| `src/listener.ts` | listener re-rendering from the acceleration record (WebGPU gather, CPU fallback) |
41+| `src/audio/` | copied from cymsimweb: Web Audio playback, WAV encoding |
42+| `src/ui/plots.ts` | waveform, spectrogram and energy canvases |
43+| `src/main.ts` | wiring |
44+
45+The copied modules follow cymsimweb's; the format is versioned (`format_version` attribute), so the copies only need updating when the format does.
46+
47+## Planned: the solver
48+
49+The intent is a finite-difference solution of the acoustic wave equation in air on a WebGPU grid, with the room as impedance boundaries. One engine, two source modes. In *IR mode* an impulse fires at the listener and the field is recorded at the cymbal nodes; by acoustic reciprocity (the Green's function is symmetric, and impedance walls preserve that) these recordings are the node-to-listener impulse responses, and the audio is the sum over nodes of the acceleration record convolved with them. The impulse run only needs to last the length of the room response, independent of the signal duration, and the responses are reusable across strikes. In *field mode* the per-element accelerations drive the grid directly over a short window, which is the intuitive picture (waves leaving the cymbal) and a cross-check of the IR-mode audio. Both modes treat the cymbal as transparent sources, the same simplification as the built-in listener model; the strict reciprocal dual of a dipole layer records the normal pressure gradient at the nodes rather than the pressure, which distinguishes the unbaffled from the baffled reconstruction.
50+
51+A grid that reaches 20 kHz in a room-sized volume is out of reach (the cell count grows as the cube of the bandwidth), so the solver will be band-limited to a few kHz; the free-field re-render above covers the full band without walls, and the top of the band with walls will need a geometric model.
52+
53+For depicting the field, the plan is volumetric rather than a slice: the pressure grid becomes a 3D texture composited by a single ray-marching pass in the existing scene, with |p| as opacity and sign as color, so a wavefront reads as a translucent expanding shell (from the listener in IR mode, washing over the cymbal; from the cymbal in field mode). The field then appears as one more entry in the Animate selector, reusing the slowdown and scrubbing controls, with at most a gain control added.
index.htmladded+107−0View file
@@ -0,0 +1,107 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="UTF-8" />
5+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+ <title>cymroom</title>
7+ <link rel="stylesheet" href="./src/style.css" />
8+ </head>
9+ <body>
10+ <div id="app">
11+ <aside id="sidebar">
12+ <header>
13+ <h1>cymroom</h1>
14+ <span class="subtitle">a .cymsim run in a 3D acoustic scene</span>
15+ </header>
16+ <section>
17+ <div class="row buttons">
18+ <button id="load">Load .cymsim</button>
19+ <input id="file" type="file" accept=".cymsim" hidden />
20+ </div>
21+ <div id="file-summary" class="muted">No file loaded. Load a .cymsim file written by cymsim or cymsimweb, or drop one anywhere on the page.</div>
22+ </section>
23+ <section id="view-controls" hidden>
24+ <h2>View</h2>
25+ <div class="row"><label>Surface color <select id="color-mode">
26+ <option value="bronze">bronze</option>
27+ <option value="thickness">thickness</option>
28+ <option value="z0">rest height</option>
29+ </select></label></div>
30+ <div class="row"><label>Exaggeration <select id="exag">
31+ <option value="1" selected>x1</option><option value="10">x10</option><option value="50">x50</option>
32+ <option value="200">x200</option><option value="1000">x1000</option>
33+ </select></label></div>
34+ <div class="checks">
35+ <label><input type="checkbox" id="ov-wireframe" /> wireframe</label>
36+ <label><input type="checkbox" id="ov-boundary" checked /> boundary</label>
37+ <label><input type="checkbox" id="ov-normals" /> normals</label>
38+ <label><input type="checkbox" id="ov-hits" checked /> hit markers</label>
39+ <label><input type="checkbox" id="ov-listener" checked /> listener</label>
40+ <label><input type="checkbox" id="ov-grid" checked /> grid</label>
41+ <label><input type="checkbox" id="ov-axes" /> axes</label>
42+ </div>
43+ <div class="muted">Drag to orbit, wheel to zoom, right-drag to pan. Click a node to probe it.</div>
44+ </section>
45+ <section id="listener-section" hidden>
46+ <h2>Listener</h2>
47+ <div class="row listener-xyz">
48+ <label>x <input id="lis-x" type="number" step="0.05" /></label>
49+ <label>y <input id="lis-y" type="number" step="0.05" /></label>
50+ <label>z <input id="lis-z" type="number" step="0.05" /></label>
51+ <span class="muted">m</span>
52+ </div>
53+ <div id="lis-status" class="muted"></div>
54+ </section>
55+ <section>
56+ <div id="log" class="muted"></div>
57+ </section>
58+ </aside>
59+ <main id="main">
60+ <div id="scene-wrap">
61+ <canvas id="scene"></canvas>
62+ <div id="scene-empty">load a .cymsim file</div>
63+ </div>
64+ <div id="playback" hidden>
65+ <label>Animate <select id="pb-source"></select></label>
66+ <button id="pb-play" disabled>Play</button>
67+ <label>Slowdown <select id="pb-slowdown"></select></label>
68+ <input id="pb-pos" type="range" min="0" max="1000" value="0" step="1" />
69+ <span id="pb-time" class="mono">0.000 s</span>
70+ </div>
71+ </main>
72+ <aside id="inspector">
73+ <nav id="tabs">
74+ <button data-tab="summary" class="active">Summary</button>
75+ <button data-tab="config">Config</button>
76+ <button data-tab="energy">Energy</button>
77+ <button data-tab="hits">Hits</button>
78+ <button data-tab="audio">Audio</button>
79+ <button data-tab="probe">Probe</button>
80+ </nav>
81+ <div id="tab-summary" class="tab active"><div class="muted">Load a file to see its contents.</div></div>
82+ <div id="tab-config" class="tab"><pre id="config-yaml" class="mono"></pre></div>
83+ <div id="tab-energy" class="tab">
84+ <canvas id="energy-plot" class="plot"></canvas>
85+ <div class="muted">Energies on a log scale; the lower panel is the nonlinearity meter max|w|/h.</div>
86+ </div>
87+ <div id="tab-hits" class="tab"><div id="hits-table"></div></div>
88+ <div id="tab-audio" class="tab">
89+ <div class="row">
90+ <select id="audio-signal"></select>
91+ <button id="audio-play" disabled>Play</button>
92+ </div>
93+ <div id="audio-info" class="muted"></div>
94+ <canvas id="audio-wave" class="plot"></canvas>
95+ <canvas id="audio-spec" class="plot tall"></canvas>
96+ </div>
97+ <div id="tab-probe" class="tab">
98+ <div id="probe-info" class="muted">Click a node on the cymbal to probe it.</div>
99+ <div class="row"><button id="probe-play" hidden>Play pickup</button></div>
100+ <canvas id="probe-wave" class="plot" hidden></canvas>
101+ <canvas id="probe-spec" class="plot tall" hidden></canvas>
102+ </div>
103+ </aside>
104+ </div>
105+ <script type="module" src="./src/main.ts"></script>
106+ </body>
107+</html>
package-lock.jsonadded+1317−0View file
@@ -0,0 +1,1317 @@
1+{
2+ "name": "cymroom",
3+ "version": "0.1.0",
4+ "lockfileVersion": 3,
5+ "requires": true,
6+ "packages": {
7+ "": {
8+ "name": "cymroom",
9+ "version": "0.1.0",
10+ "dependencies": {
11+ "h5wasm": "^0.10.3",
12+ "js-yaml": "^4.1.0",
13+ "three": "^0.180.0"
14+ },
15+ "devDependencies": {
16+ "@types/js-yaml": "^4.0.9",
17+ "@types/node": "^24.0.0",
18+ "@types/three": "^0.180.0",
19+ "@webgpu/types": "^0.1.74",
20+ "typescript": "^5.6.0",
21+ "vite": "^6.0.0"
22+ }
23+ },
24+ "node_modules/@dimforge/rapier3d-compat": {
25+ "version": "0.12.0",
26+ "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
27+ "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
28+ "dev": true,
29+ "license": "Apache-2.0"
30+ },
31+ "node_modules/@esbuild/aix-ppc64": {
32+ "version": "0.25.12",
33+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
34+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
35+ "cpu": [
36+ "ppc64"
37+ ],
38+ "dev": true,
39+ "license": "MIT",
40+ "optional": true,
41+ "os": [
42+ "aix"
43+ ],
44+ "engines": {
45+ "node": ">=18"
46+ }
47+ },
48+ "node_modules/@esbuild/android-arm": {
49+ "version": "0.25.12",
50+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
51+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
52+ "cpu": [
53+ "arm"
54+ ],
55+ "dev": true,
56+ "license": "MIT",
57+ "optional": true,
58+ "os": [
59+ "android"
60+ ],
61+ "engines": {
62+ "node": ">=18"
63+ }
64+ },
65+ "node_modules/@esbuild/android-arm64": {
66+ "version": "0.25.12",
67+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
68+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
69+ "cpu": [
70+ "arm64"
71+ ],
72+ "dev": true,
73+ "license": "MIT",
74+ "optional": true,
75+ "os": [
76+ "android"
77+ ],
78+ "engines": {
79+ "node": ">=18"
80+ }
81+ },
82+ "node_modules/@esbuild/android-x64": {
83+ "version": "0.25.12",
84+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
85+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
86+ "cpu": [
87+ "x64"
88+ ],
89+ "dev": true,
90+ "license": "MIT",
91+ "optional": true,
92+ "os": [
93+ "android"
94+ ],
95+ "engines": {
96+ "node": ">=18"
97+ }
98+ },
99+ "node_modules/@esbuild/darwin-arm64": {
100+ "version": "0.25.12",
101+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
102+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
103+ "cpu": [
104+ "arm64"
105+ ],
106+ "dev": true,
107+ "license": "MIT",
108+ "optional": true,
109+ "os": [
110+ "darwin"
111+ ],
112+ "engines": {
113+ "node": ">=18"
114+ }
115+ },
116+ "node_modules/@esbuild/darwin-x64": {
117+ "version": "0.25.12",
118+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
119+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
120+ "cpu": [
121+ "x64"
122+ ],
123+ "dev": true,
124+ "license": "MIT",
125+ "optional": true,
126+ "os": [
127+ "darwin"
128+ ],
129+ "engines": {
130+ "node": ">=18"
131+ }
132+ },
133+ "node_modules/@esbuild/freebsd-arm64": {
134+ "version": "0.25.12",
135+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
136+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
137+ "cpu": [
138+ "arm64"
139+ ],
140+ "dev": true,
141+ "license": "MIT",
142+ "optional": true,
143+ "os": [
144+ "freebsd"
145+ ],
146+ "engines": {
147+ "node": ">=18"
148+ }
149+ },
150+ "node_modules/@esbuild/freebsd-x64": {
151+ "version": "0.25.12",
152+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
153+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
154+ "cpu": [
155+ "x64"
156+ ],
157+ "dev": true,
158+ "license": "MIT",
159+ "optional": true,
160+ "os": [
161+ "freebsd"
162+ ],
163+ "engines": {
164+ "node": ">=18"
165+ }
166+ },
167+ "node_modules/@esbuild/linux-arm": {
168+ "version": "0.25.12",
169+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
170+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
171+ "cpu": [
172+ "arm"
173+ ],
174+ "dev": true,
175+ "license": "MIT",
176+ "optional": true,
177+ "os": [
178+ "linux"
179+ ],
180+ "engines": {
181+ "node": ">=18"
182+ }
183+ },
184+ "node_modules/@esbuild/linux-arm64": {
185+ "version": "0.25.12",
186+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
187+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
188+ "cpu": [
189+ "arm64"
190+ ],
191+ "dev": true,
192+ "license": "MIT",
193+ "optional": true,
194+ "os": [
195+ "linux"
196+ ],
197+ "engines": {
198+ "node": ">=18"
199+ }
200+ },
201+ "node_modules/@esbuild/linux-ia32": {
202+ "version": "0.25.12",
203+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
204+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
205+ "cpu": [
206+ "ia32"
207+ ],
208+ "dev": true,
209+ "license": "MIT",
210+ "optional": true,
211+ "os": [
212+ "linux"
213+ ],
214+ "engines": {
215+ "node": ">=18"
216+ }
217+ },
218+ "node_modules/@esbuild/linux-loong64": {
219+ "version": "0.25.12",
220+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
221+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
222+ "cpu": [
223+ "loong64"
224+ ],
225+ "dev": true,
226+ "license": "MIT",
227+ "optional": true,
228+ "os": [
229+ "linux"
230+ ],
231+ "engines": {
232+ "node": ">=18"
233+ }
234+ },
235+ "node_modules/@esbuild/linux-mips64el": {
236+ "version": "0.25.12",
237+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
238+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
239+ "cpu": [
240+ "mips64el"
241+ ],
242+ "dev": true,
243+ "license": "MIT",
244+ "optional": true,
245+ "os": [
246+ "linux"
247+ ],
248+ "engines": {
249+ "node": ">=18"
250+ }
251+ },
252+ "node_modules/@esbuild/linux-ppc64": {
253+ "version": "0.25.12",
254+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
255+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
256+ "cpu": [
257+ "ppc64"
258+ ],
259+ "dev": true,
260+ "license": "MIT",
261+ "optional": true,
262+ "os": [
263+ "linux"
264+ ],
265+ "engines": {
266+ "node": ">=18"
267+ }
268+ },
269+ "node_modules/@esbuild/linux-riscv64": {
270+ "version": "0.25.12",
271+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
272+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
273+ "cpu": [
274+ "riscv64"
275+ ],
276+ "dev": true,
277+ "license": "MIT",
278+ "optional": true,
279+ "os": [
280+ "linux"
281+ ],
282+ "engines": {
283+ "node": ">=18"
284+ }
285+ },
286+ "node_modules/@esbuild/linux-s390x": {
287+ "version": "0.25.12",
288+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
289+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
290+ "cpu": [
291+ "s390x"
292+ ],
293+ "dev": true,
294+ "license": "MIT",
295+ "optional": true,
296+ "os": [
297+ "linux"
298+ ],
299+ "engines": {
300+ "node": ">=18"
301+ }
302+ },
303+ "node_modules/@esbuild/linux-x64": {
304+ "version": "0.25.12",
305+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
306+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
307+ "cpu": [
308+ "x64"
309+ ],
310+ "dev": true,
311+ "license": "MIT",
312+ "optional": true,
313+ "os": [
314+ "linux"
315+ ],
316+ "engines": {
317+ "node": ">=18"
318+ }
319+ },
320+ "node_modules/@esbuild/netbsd-arm64": {
321+ "version": "0.25.12",
322+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
323+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
324+ "cpu": [
325+ "arm64"
326+ ],
327+ "dev": true,
328+ "license": "MIT",
329+ "optional": true,
330+ "os": [
331+ "netbsd"
332+ ],
333+ "engines": {
334+ "node": ">=18"
335+ }
336+ },
337+ "node_modules/@esbuild/netbsd-x64": {
338+ "version": "0.25.12",
339+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
340+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
341+ "cpu": [
342+ "x64"
343+ ],
344+ "dev": true,
345+ "license": "MIT",
346+ "optional": true,
347+ "os": [
348+ "netbsd"
349+ ],
350+ "engines": {
351+ "node": ">=18"
352+ }
353+ },
354+ "node_modules/@esbuild/openbsd-arm64": {
355+ "version": "0.25.12",
356+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
357+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
358+ "cpu": [
359+ "arm64"
360+ ],
361+ "dev": true,
362+ "license": "MIT",
363+ "optional": true,
364+ "os": [
365+ "openbsd"
366+ ],
367+ "engines": {
368+ "node": ">=18"
369+ }
370+ },
371+ "node_modules/@esbuild/openbsd-x64": {
372+ "version": "0.25.12",
373+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
374+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
375+ "cpu": [
376+ "x64"
377+ ],
378+ "dev": true,
379+ "license": "MIT",
380+ "optional": true,
381+ "os": [
382+ "openbsd"
383+ ],
384+ "engines": {
385+ "node": ">=18"
386+ }
387+ },
388+ "node_modules/@esbuild/openharmony-arm64": {
389+ "version": "0.25.12",
390+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
391+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
392+ "cpu": [
393+ "arm64"
394+ ],
395+ "dev": true,
396+ "license": "MIT",
397+ "optional": true,
398+ "os": [
399+ "openharmony"
400+ ],
401+ "engines": {
402+ "node": ">=18"
403+ }
404+ },
405+ "node_modules/@esbuild/sunos-x64": {
406+ "version": "0.25.12",
407+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
408+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
409+ "cpu": [
410+ "x64"
411+ ],
412+ "dev": true,
413+ "license": "MIT",
414+ "optional": true,
415+ "os": [
416+ "sunos"
417+ ],
418+ "engines": {
419+ "node": ">=18"
420+ }
421+ },
422+ "node_modules/@esbuild/win32-arm64": {
423+ "version": "0.25.12",
424+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
425+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
426+ "cpu": [
427+ "arm64"
428+ ],
429+ "dev": true,
430+ "license": "MIT",
431+ "optional": true,
432+ "os": [
433+ "win32"
434+ ],
435+ "engines": {
436+ "node": ">=18"
437+ }
438+ },
439+ "node_modules/@esbuild/win32-ia32": {
440+ "version": "0.25.12",
441+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
442+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
443+ "cpu": [
444+ "ia32"
445+ ],
446+ "dev": true,
447+ "license": "MIT",
448+ "optional": true,
449+ "os": [
450+ "win32"
451+ ],
452+ "engines": {
453+ "node": ">=18"
454+ }
455+ },
456+ "node_modules/@esbuild/win32-x64": {
457+ "version": "0.25.12",
458+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
459+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
460+ "cpu": [
461+ "x64"
462+ ],
463+ "dev": true,
464+ "license": "MIT",
465+ "optional": true,
466+ "os": [
467+ "win32"
468+ ],
469+ "engines": {
470+ "node": ">=18"
471+ }
472+ },
473+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
474+ "version": "1.5.1",
475+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
476+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
477+ "cpu": [
478+ "x64"
479+ ],
480+ "dev": true,
481+ "libc": [
482+ "glibc"
483+ ],
484+ "license": "MIT",
485+ "optional": true,
486+ "os": [
487+ "linux"
488+ ],
489+ "engines": {
490+ "node": "^22.20 || ^24.12 || >=25"
491+ }
492+ },
493+ "node_modules/@rollup/rollup-android-arm-eabi": {
494+ "version": "4.63.5",
495+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.5.tgz",
496+ "integrity": "sha512-J25QJU+B78T4FhhBsNpLJyVWOi31mwtpcMwywHmOKH65Q9IWGA81gPj+dnwlhU8wktVriYE+tFAaQgrnJRzAZg==",
497+ "cpu": [
498+ "arm"
499+ ],
500+ "dev": true,
501+ "license": "MIT",
502+ "optional": true,
503+ "os": [
504+ "android"
505+ ]
506+ },
507+ "node_modules/@rollup/rollup-android-arm64": {
508+ "version": "4.63.5",
509+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.5.tgz",
510+ "integrity": "sha512-LDopB3zuZM5Ux9TT2luNEBJW/tYbGU2g1d+VpKk6I+gSKDb+/7sYE6M225gRQt4RbMX6MSwMsVR/phdjVUgRLg==",
511+ "cpu": [
512+ "arm64"
513+ ],
514+ "dev": true,
515+ "license": "MIT",
516+ "optional": true,
517+ "os": [
518+ "android"
519+ ]
520+ },
521+ "node_modules/@rollup/rollup-darwin-arm64": {
522+ "version": "4.63.5",
523+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.5.tgz",
524+ "integrity": "sha512-wlJEERGfeuHeBavCL2qVnNacOK43NDoZM4sjkeRPymd04OAE9T1zBqDJgmZ+CIsPTYKwdzpUC8vmOw84dwY4Tg==",
525+ "cpu": [
526+ "arm64"
527+ ],
528+ "dev": true,
529+ "license": "MIT",
530+ "optional": true,
531+ "os": [
532+ "darwin"
533+ ]
534+ },
535+ "node_modules/@rollup/rollup-darwin-x64": {
536+ "version": "4.63.5",
537+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.5.tgz",
538+ "integrity": "sha512-4nJJGg5jbo2wwPP4JP+LfEBA3bvP8rU9CLuhp7jWvq9sxEyhjQFTFdrqi+/dHEin/pd8jpT0vcehIpnZtmEdcQ==",
539+ "cpu": [
540+ "x64"
541+ ],
542+ "dev": true,
543+ "license": "MIT",
544+ "optional": true,
545+ "os": [
546+ "darwin"
547+ ]
548+ },
549+ "node_modules/@rollup/rollup-freebsd-arm64": {
550+ "version": "4.63.5",
551+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.5.tgz",
552+ "integrity": "sha512-DrZbyCDF1hneuO6jRbvZ2D7+PIBM6yIwYnJpg2vIk58T+wuFpiaGZrfUr59lDWw45bg+IrpTGLPiNi/Fk4w3Cg==",
553+ "cpu": [
554+ "arm64"
555+ ],
556+ "dev": true,
557+ "license": "MIT",
558+ "optional": true,
559+ "os": [
560+ "freebsd"
561+ ]
562+ },
563+ "node_modules/@rollup/rollup-freebsd-x64": {
564+ "version": "4.63.5",
565+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.5.tgz",
566+ "integrity": "sha512-gqfUVMJMB3mehqywxp6hTBFfgtMQykZY19+cfiaYP0toIJLb/1DZRJHVkQQGP13W4TAwfZDWeg1qBcheTRioXQ==",
567+ "cpu": [
568+ "x64"
569+ ],
570+ "dev": true,
571+ "license": "MIT",
572+ "optional": true,
573+ "os": [
574+ "freebsd"
575+ ]
576+ },
577+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
578+ "version": "4.63.5",
579+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.5.tgz",
580+ "integrity": "sha512-CFmhpvAwzSaWMlN3VN7UtmoTihlZNzoP0juQib5TQRnYUyDV8dXeWOp29sobWAT6gXl/hQgAClLlEiYozQG3OQ==",
581+ "cpu": [
582+ "arm"
583+ ],
584+ "dev": true,
585+ "libc": [
586+ "glibc"
587+ ],
588+ "license": "MIT",
589+ "optional": true,
590+ "os": [
591+ "linux"
592+ ]
593+ },
594+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
595+ "version": "4.63.5",
596+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.5.tgz",
597+ "integrity": "sha512-Uc9H8eXCOayV6JLTH5bXKMId6qbhNHa818/BgYjm4jrlq3vZquC9cqyvHBw17xy5Mnj5f+I3gFK5JcEf3hSqrw==",
598+ "cpu": [
599+ "arm"
600+ ],
601+ "dev": true,
602+ "libc": [
603+ "musl"
604+ ],
605+ "license": "MIT",
606+ "optional": true,
607+ "os": [
608+ "linux"
609+ ]
610+ },
611+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
612+ "version": "4.63.5",
613+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.5.tgz",
614+ "integrity": "sha512-VcPr/szv/1BFw112Kt//fxulXt/JPqzzidU84iW68L2DdjnOO8QFUv2zTSYBEPHD6movBD4z+bbr5y60GYM7Jw==",
615+ "cpu": [
616+ "arm64"
617+ ],
618+ "dev": true,
619+ "libc": [
620+ "glibc"
621+ ],
622+ "license": "MIT",
623+ "optional": true,
624+ "os": [
625+ "linux"
626+ ]
627+ },
628+ "node_modules/@rollup/rollup-linux-arm64-musl": {
629+ "version": "4.63.5",
630+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.5.tgz",
631+ "integrity": "sha512-BnxtJ5/91BrIHYIkGrmjz/lbMhqEHt1dPFqIxIFR+jPn0xVc/oUSCtIT089zfp5ufwGDlYz2UC+Fe1SRBpYFbQ==",
632+ "cpu": [
633+ "arm64"
634+ ],
635+ "dev": true,
636+ "libc": [
637+ "musl"
638+ ],
639+ "license": "MIT",
640+ "optional": true,
641+ "os": [
642+ "linux"
643+ ]
644+ },
645+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
646+ "version": "4.63.5",
647+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.5.tgz",
648+ "integrity": "sha512-LrYcHZwF+fAMNKHYTOQ5osWM4AZF7YF6D+XtsjDyEvljtt11twc+zHVXBLNEjxVSUnKYsOhvVz4Z213eW02COQ==",
649+ "cpu": [
650+ "loong64"
651+ ],
652+ "dev": true,
653+ "libc": [
654+ "glibc"
655+ ],
656+ "license": "MIT",
657+ "optional": true,
658+ "os": [
659+ "linux"
660+ ]
661+ },
662+ "node_modules/@rollup/rollup-linux-loong64-musl": {
663+ "version": "4.63.5",
664+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.5.tgz",
665+ "integrity": "sha512-nj7QKQePAAUpCpJHtg0pR0W/b92A9NO17JS3BAQmHDn/yhmkir2p8llrKY9TOhleKIaSzy1JhxS3T9FVld6coA==",
666+ "cpu": [
667+ "loong64"
668+ ],
669+ "dev": true,
670+ "libc": [
671+ "musl"
672+ ],
673+ "license": "MIT",
674+ "optional": true,
675+ "os": [
676+ "linux"
677+ ]
678+ },
679+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
680+ "version": "4.63.5",
681+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.5.tgz",
682+ "integrity": "sha512-5ylkX6dWMeBKge9nTU+Rxfb+ZfaCIJ9lRqIFaK0eAMcWp7OJbYnLveLgXmm0VrvuLKb8qIK+mHyH0qu88RM+iA==",
683+ "cpu": [
684+ "ppc64"
685+ ],
686+ "dev": true,
687+ "libc": [
688+ "glibc"
689+ ],
690+ "license": "MIT",
691+ "optional": true,
692+ "os": [
693+ "linux"
694+ ]
695+ },
696+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
697+ "version": "4.63.5",
698+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.5.tgz",
699+ "integrity": "sha512-oHK4ZHYFDKjZviK34I+NwgfbGxgI7ztrNxj2hPTSSNFgeq1a/lEd7dHV2fdGAuTH4Iym3RHJg+vAbWaWG4B7Zg==",
700+ "cpu": [
701+ "ppc64"
702+ ],
703+ "dev": true,
704+ "libc": [
705+ "musl"
706+ ],
707+ "license": "MIT",
708+ "optional": true,
709+ "os": [
710+ "linux"
711+ ]
712+ },
713+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
714+ "version": "4.63.5",
715+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.5.tgz",
716+ "integrity": "sha512-UcetmHZ6XOXuUByiKZyQmb55ZPr0LABr3Ec/HB9wKZn6CEAFWZkE+hsJErJ9hbPBC7nI0dKuELx7CoV6IM7TMg==",
717+ "cpu": [
718+ "riscv64"
719+ ],
720+ "dev": true,
721+ "libc": [
722+ "glibc"
723+ ],
724+ "license": "MIT",
725+ "optional": true,
726+ "os": [
727+ "linux"
728+ ]
729+ },
730+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
731+ "version": "4.63.5",
732+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.5.tgz",
733+ "integrity": "sha512-C5CmDPQBtvjVo8cgQsBs+w6WB0JLkiixhgi6hVLV11hERWdn/p0XcPU2OUcZzac9BPOFq7SbaHFa8r3SWEysCQ==",
734+ "cpu": [
735+ "riscv64"
736+ ],
737+ "dev": true,
738+ "libc": [
739+ "musl"
740+ ],
741+ "license": "MIT",
742+ "optional": true,
743+ "os": [
744+ "linux"
745+ ]
746+ },
747+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
748+ "version": "4.63.5",
749+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.5.tgz",
750+ "integrity": "sha512-lHVQHJFKsuuxLMi3MQO9XVL8Tje3JR82CzB+QDKC5NWBcsIWuwsn9uIM5e3lBhI+fF1/s63qnyYqsg65+8rV/w==",
751+ "cpu": [
752+ "s390x"
753+ ],
754+ "dev": true,
755+ "libc": [
756+ "glibc"
757+ ],
758+ "license": "MIT",
759+ "optional": true,
760+ "os": [
761+ "linux"
762+ ]
763+ },
764+ "node_modules/@rollup/rollup-linux-x64-gnu": {
765+ "version": "4.63.5",
766+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.5.tgz",
767+ "integrity": "sha512-3W9bTFcQNJn71cSJVM9RKIiZOy8DO/XLDii8Uv/Pm6WKqDRj7JV3ZfuXIEfyuy5LXpIzAbB/1M4Ukp9GKNa7nA==",
768+ "cpu": [
769+ "x64"
770+ ],
771+ "dev": true,
772+ "libc": [
773+ "glibc"
774+ ],
775+ "license": "MIT",
776+ "optional": true,
777+ "os": [
778+ "linux"
779+ ]
780+ },
781+ "node_modules/@rollup/rollup-linux-x64-musl": {
782+ "version": "4.63.5",
783+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.5.tgz",
784+ "integrity": "sha512-VDC7rRJlee/scpki96GZ27Omf6yU87s1YXwVTpjE5841faVlDYYT565rgfmoR1U0sqL7z5ivQSDjcsF6VRXyBA==",
785+ "cpu": [
786+ "x64"
787+ ],
788+ "dev": true,
789+ "libc": [
790+ "musl"
791+ ],
792+ "license": "MIT",
793+ "optional": true,
794+ "os": [
795+ "linux"
796+ ]
797+ },
798+ "node_modules/@rollup/rollup-openbsd-x64": {
799+ "version": "4.63.5",
800+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.5.tgz",
801+ "integrity": "sha512-z86Ok2p4pTdv5xqCKZsTooO7yBEiaJR/HzU3Wx8RmWsPoLppnMKROhJusQob8B3IE1ghC343kUW9rC2r+Wf3ig==",
802+ "cpu": [
803+ "x64"
804+ ],
805+ "dev": true,
806+ "license": "MIT",
807+ "optional": true,
808+ "os": [
809+ "openbsd"
810+ ]
811+ },
812+ "node_modules/@rollup/rollup-openharmony-arm64": {
813+ "version": "4.63.5",
814+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.5.tgz",
815+ "integrity": "sha512-IzQmj+xXwQFGhMAMKMQVXkMwMZN3TqkJgAE0nSsqvVwWWciP4AIPMmWRqOQ2GfX7TUDZr+xqGFcBS36CRPGw0g==",
816+ "cpu": [
817+ "arm64"
818+ ],
819+ "dev": true,
820+ "license": "MIT",
821+ "optional": true,
822+ "os": [
823+ "openharmony"
824+ ]
825+ },
826+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
827+ "version": "4.63.5",
828+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.5.tgz",
829+ "integrity": "sha512-F6qpTaPc9bwBH85kjy0/BLmLSW1uv7AoOXCoRIkg2arlgCYlWYcAbiMkvZuAcaWk9TpCRG//okznLAqLGshkMw==",
830+ "cpu": [
831+ "arm64"
832+ ],
833+ "dev": true,
834+ "license": "MIT",
835+ "optional": true,
836+ "os": [
837+ "win32"
838+ ]
839+ },
840+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
841+ "version": "4.63.5",
842+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.5.tgz",
843+ "integrity": "sha512-igoDsTFhhwECBeGbUuLeIk7t8Y1apa+cs6mDWpx2EZ0ch7oEQgzHbFUXN9euoHekCAQzXdXApAGkV6jznS7tWw==",
844+ "cpu": [
845+ "ia32"
846+ ],
847+ "dev": true,
848+ "license": "MIT",
849+ "optional": true,
850+ "os": [
851+ "win32"
852+ ]
853+ },
854+ "node_modules/@rollup/rollup-win32-x64-gnu": {
855+ "version": "4.63.5",
856+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.5.tgz",
857+ "integrity": "sha512-U3teMeMbXFmaM5D+OTJpsOXd+wV/qftIeYF9kBKL4v73641qyJmoXFtA28DQLsnmlyayEsTe72xpLHrArq6vHw==",
858+ "cpu": [
859+ "x64"
860+ ],
861+ "dev": true,
862+ "license": "MIT",
863+ "optional": true,
864+ "os": [
865+ "win32"
866+ ]
867+ },
868+ "node_modules/@rollup/rollup-win32-x64-msvc": {
869+ "version": "4.63.5",
870+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.5.tgz",
871+ "integrity": "sha512-ypfC34F3RKXvCXBglGqGMsUSMKlgwd1HX9AOAlx9RoZZ6GaI42YHVeKpzg3JG+wpBUJYTG+NNZhqbDWL8tBZkw==",
872+ "cpu": [
873+ "x64"
874+ ],
875+ "dev": true,
876+ "license": "MIT",
877+ "optional": true,
878+ "os": [
879+ "win32"
880+ ]
881+ },
882+ "node_modules/@tweenjs/tween.js": {
883+ "version": "23.1.3",
884+ "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
885+ "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
886+ "dev": true,
887+ "license": "MIT"
888+ },
889+ "node_modules/@types/estree": {
890+ "version": "1.0.9",
891+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
892+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
893+ "dev": true,
894+ "license": "MIT"
895+ },
896+ "node_modules/@types/js-yaml": {
897+ "version": "4.0.9",
898+ "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
899+ "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
900+ "dev": true,
901+ "license": "MIT"
902+ },
903+ "node_modules/@types/node": {
904+ "version": "24.13.6",
905+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.6.tgz",
906+ "integrity": "sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==",
907+ "dev": true,
908+ "license": "MIT",
909+ "dependencies": {
910+ "undici-types": "~7.18.0"
911+ }
912+ },
913+ "node_modules/@types/stats.js": {
914+ "version": "0.17.4",
915+ "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
916+ "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
917+ "dev": true,
918+ "license": "MIT"
919+ },
920+ "node_modules/@types/three": {
921+ "version": "0.180.0",
922+ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
923+ "integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
924+ "dev": true,
925+ "license": "MIT",
926+ "dependencies": {
927+ "@dimforge/rapier3d-compat": "~0.12.0",
928+ "@tweenjs/tween.js": "~23.1.3",
929+ "@types/stats.js": "*",
930+ "@types/webxr": "*",
931+ "@webgpu/types": "*",
932+ "fflate": "~0.8.2",
933+ "meshoptimizer": "~0.22.0"
934+ }
935+ },
936+ "node_modules/@types/webxr": {
937+ "version": "0.5.24",
938+ "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
939+ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
940+ "dev": true,
941+ "license": "MIT"
942+ },
943+ "node_modules/@webgpu/types": {
944+ "version": "0.1.74",
945+ "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.74.tgz",
946+ "integrity": "sha512-lgiI4hbuLcI9unnm2cL/tvCaQU45dp0xcLWJh5uB/9MBGvIA4F8XIk7nSiBeg+K4xWGYwgZKn80LmERI5CxoTA==",
947+ "dev": true,
948+ "license": "BSD-3-Clause"
949+ },
950+ "node_modules/argparse": {
951+ "version": "2.0.1",
952+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
953+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
954+ "license": "Python-2.0"
955+ },
956+ "node_modules/esbuild": {
957+ "version": "0.25.12",
958+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
959+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
960+ "dev": true,
961+ "hasInstallScript": true,
962+ "license": "MIT",
963+ "bin": {
964+ "esbuild": "bin/esbuild"
965+ },
966+ "engines": {
967+ "node": ">=18"
968+ },
969+ "optionalDependencies": {
970+ "@esbuild/aix-ppc64": "0.25.12",
971+ "@esbuild/android-arm": "0.25.12",
972+ "@esbuild/android-arm64": "0.25.12",
973+ "@esbuild/android-x64": "0.25.12",
974+ "@esbuild/darwin-arm64": "0.25.12",
975+ "@esbuild/darwin-x64": "0.25.12",
976+ "@esbuild/freebsd-arm64": "0.25.12",
977+ "@esbuild/freebsd-x64": "0.25.12",
978+ "@esbuild/linux-arm": "0.25.12",
979+ "@esbuild/linux-arm64": "0.25.12",
980+ "@esbuild/linux-ia32": "0.25.12",
981+ "@esbuild/linux-loong64": "0.25.12",
982+ "@esbuild/linux-mips64el": "0.25.12",
983+ "@esbuild/linux-ppc64": "0.25.12",
984+ "@esbuild/linux-riscv64": "0.25.12",
985+ "@esbuild/linux-s390x": "0.25.12",
986+ "@esbuild/linux-x64": "0.25.12",
987+ "@esbuild/netbsd-arm64": "0.25.12",
988+ "@esbuild/netbsd-x64": "0.25.12",
989+ "@esbuild/openbsd-arm64": "0.25.12",
990+ "@esbuild/openbsd-x64": "0.25.12",
991+ "@esbuild/openharmony-arm64": "0.25.12",
992+ "@esbuild/sunos-x64": "0.25.12",
993+ "@esbuild/win32-arm64": "0.25.12",
994+ "@esbuild/win32-ia32": "0.25.12",
995+ "@esbuild/win32-x64": "0.25.12"
996+ }
997+ },
998+ "node_modules/fdir": {
999+ "version": "6.5.0",
1000+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
1001+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
1002+ "dev": true,
1003+ "license": "MIT",
1004+ "engines": {
1005+ "node": ">=12.0.0"
1006+ },
1007+ "peerDependencies": {
1008+ "picomatch": "^3 || ^4"
1009+ },
1010+ "peerDependenciesMeta": {
1011+ "picomatch": {
1012+ "optional": true
1013+ }
1014+ }
1015+ },
1016+ "node_modules/fflate": {
1017+ "version": "0.8.3",
1018+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
1019+ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
1020+ "dev": true,
1021+ "license": "MIT"
1022+ },
1023+ "node_modules/fsevents": {
1024+ "version": "2.3.3",
1025+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1026+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1027+ "dev": true,
1028+ "hasInstallScript": true,
1029+ "license": "MIT",
1030+ "optional": true,
1031+ "os": [
1032+ "darwin"
1033+ ],
1034+ "engines": {
1035+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1036+ }
1037+ },
1038+ "node_modules/h5wasm": {
1039+ "version": "0.10.3",
1040+ "resolved": "https://registry.npmjs.org/h5wasm/-/h5wasm-0.10.3.tgz",
1041+ "integrity": "sha512-W4Jy5ExtX/VNbyD8GdOBckDuj6AL16TemppVNxZsV3rJZEWCv2sxlCzOttZLer3zkMttbDYsWHl0qt1z3Bln+Q==",
1042+ "license": "SEE LICENSE IN LICENSE.txt"
1043+ },
1044+ "node_modules/js-yaml": {
1045+ "version": "4.3.2",
1046+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
1047+ "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
1048+ "funding": [
1049+ {
1050+ "type": "github",
1051+ "url": "https://github.com/sponsors/puzrin"
1052+ },
1053+ {
1054+ "type": "github",
1055+ "url": "https://github.com/sponsors/nodeca"
1056+ }
1057+ ],
1058+ "license": "MIT",
1059+ "dependencies": {
1060+ "argparse": "^2.0.1"
1061+ },
1062+ "bin": {
1063+ "js-yaml": "bin/js-yaml.js"
1064+ }
1065+ },
1066+ "node_modules/meshoptimizer": {
1067+ "version": "0.22.0",
1068+ "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz",
1069+ "integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==",
1070+ "dev": true,
1071+ "license": "MIT"
1072+ },
1073+ "node_modules/nanoid": {
1074+ "version": "3.3.19",
1075+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
1076+ "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
1077+ "dev": true,
1078+ "funding": [
1079+ {
1080+ "type": "github",
1081+ "url": "https://github.com/sponsors/ai"
1082+ }
1083+ ],
1084+ "license": "MIT",
1085+ "bin": {
1086+ "nanoid": "bin/nanoid.cjs"
1087+ },
1088+ "engines": {
1089+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1090+ }
1091+ },
1092+ "node_modules/picocolors": {
1093+ "version": "1.1.1",
1094+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1095+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1096+ "dev": true,
1097+ "license": "ISC"
1098+ },
1099+ "node_modules/picomatch": {
1100+ "version": "4.0.7",
1101+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
1102+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
1103+ "dev": true,
1104+ "license": "MIT",
1105+ "engines": {
1106+ "node": ">=12"
1107+ },
1108+ "funding": {
1109+ "url": "https://github.com/sponsors/jonschlinkert"
1110+ }
1111+ },
1112+ "node_modules/postcss": {
1113+ "version": "8.5.28",
1114+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
1115+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
1116+ "dev": true,
1117+ "funding": [
1118+ {
1119+ "type": "opencollective",
1120+ "url": "https://opencollective.com/postcss/"
1121+ },
1122+ {
1123+ "type": "tidelift",
1124+ "url": "https://tidelift.com/funding/github/npm/postcss"
1125+ },
1126+ {
1127+ "type": "github",
1128+ "url": "https://github.com/sponsors/ai"
1129+ }
1130+ ],
1131+ "license": "MIT",
1132+ "dependencies": {
1133+ "nanoid": "^3.3.18",
1134+ "picocolors": "^1.1.1",
1135+ "source-map-js": "^1.2.1"
1136+ },
1137+ "engines": {
1138+ "node": "^10 || ^12 || >=14"
1139+ }
1140+ },
1141+ "node_modules/rollup": {
1142+ "version": "4.63.5",
1143+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.5.tgz",
1144+ "integrity": "sha512-KRWwmNLlPw5M7HcdYfm15oBv9n9LPtjzpzCIxS/phwqvPyxHSoKX6Y2YU3pxSPfy0CLquVgsx/j/hBi6OvH1Nw==",
1145+ "dev": true,
1146+ "license": "MIT",
1147+ "dependencies": {
1148+ "@types/estree": "1.0.9"
1149+ },
1150+ "bin": {
1151+ "rollup": "dist/bin/rollup"
1152+ },
1153+ "engines": {
1154+ "node": ">=18.0.0",
1155+ "npm": ">=8.0.0"
1156+ },
1157+ "optionalDependencies": {
1158+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
1159+ "@rollup/rollup-android-arm-eabi": "4.63.5",
1160+ "@rollup/rollup-android-arm64": "4.63.5",
1161+ "@rollup/rollup-darwin-arm64": "4.63.5",
1162+ "@rollup/rollup-darwin-x64": "4.63.5",
1163+ "@rollup/rollup-freebsd-arm64": "4.63.5",
1164+ "@rollup/rollup-freebsd-x64": "4.63.5",
1165+ "@rollup/rollup-linux-arm-gnueabihf": "4.63.5",
1166+ "@rollup/rollup-linux-arm-musleabihf": "4.63.5",
1167+ "@rollup/rollup-linux-arm64-gnu": "4.63.5",
1168+ "@rollup/rollup-linux-arm64-musl": "4.63.5",
1169+ "@rollup/rollup-linux-loong64-gnu": "4.63.5",
1170+ "@rollup/rollup-linux-loong64-musl": "4.63.5",
1171+ "@rollup/rollup-linux-ppc64-gnu": "4.63.5",
1172+ "@rollup/rollup-linux-ppc64-musl": "4.63.5",
1173+ "@rollup/rollup-linux-riscv64-gnu": "4.63.5",
1174+ "@rollup/rollup-linux-riscv64-musl": "4.63.5",
1175+ "@rollup/rollup-linux-s390x-gnu": "4.63.5",
1176+ "@rollup/rollup-linux-x64-gnu": "4.63.5",
1177+ "@rollup/rollup-linux-x64-musl": "4.63.5",
1178+ "@rollup/rollup-openbsd-x64": "4.63.5",
1179+ "@rollup/rollup-openharmony-arm64": "4.63.5",
1180+ "@rollup/rollup-win32-arm64-msvc": "4.63.5",
1181+ "@rollup/rollup-win32-ia32-msvc": "4.63.5",
1182+ "@rollup/rollup-win32-x64-gnu": "4.63.5",
1183+ "@rollup/rollup-win32-x64-msvc": "4.63.5",
1184+ "fsevents": "~2.3.2"
1185+ }
1186+ },
1187+ "node_modules/source-map-js": {
1188+ "version": "1.2.1",
1189+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1190+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1191+ "dev": true,
1192+ "license": "BSD-3-Clause",
1193+ "engines": {
1194+ "node": ">=0.10.0"
1195+ }
1196+ },
1197+ "node_modules/three": {
1198+ "version": "0.180.0",
1199+ "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
1200+ "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
1201+ "license": "MIT"
1202+ },
1203+ "node_modules/tinyglobby": {
1204+ "version": "0.2.17",
1205+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
1206+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
1207+ "dev": true,
1208+ "license": "MIT",
1209+ "dependencies": {
1210+ "fdir": "^6.5.0",
1211+ "picomatch": "^4.0.4"
1212+ },
1213+ "engines": {
1214+ "node": ">=12.0.0"
1215+ },
1216+ "funding": {
1217+ "url": "https://github.com/sponsors/SuperchupuDev"
1218+ }
1219+ },
1220+ "node_modules/typescript": {
1221+ "version": "5.9.3",
1222+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
1223+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1224+ "dev": true,
1225+ "license": "Apache-2.0",
1226+ "bin": {
1227+ "tsc": "bin/tsc",
1228+ "tsserver": "bin/tsserver"
1229+ },
1230+ "engines": {
1231+ "node": ">=14.17"
1232+ }
1233+ },
1234+ "node_modules/undici-types": {
1235+ "version": "7.18.2",
1236+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
1237+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
1238+ "dev": true,
1239+ "license": "MIT"
1240+ },
1241+ "node_modules/vite": {
1242+ "version": "6.4.3",
1243+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
1244+ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
1245+ "dev": true,
1246+ "license": "MIT",
1247+ "dependencies": {
1248+ "esbuild": "^0.25.0",
1249+ "fdir": "^6.4.4",
1250+ "picomatch": "^4.0.2",
1251+ "postcss": "^8.5.3",
1252+ "rollup": "^4.34.9",
1253+ "tinyglobby": "^0.2.13"
1254+ },
1255+ "bin": {
1256+ "vite": "bin/vite.js"
1257+ },
1258+ "engines": {
1259+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
1260+ },
1261+ "funding": {
1262+ "url": "https://github.com/vitejs/vite?sponsor=1"
1263+ },
1264+ "optionalDependencies": {
1265+ "fsevents": "~2.3.3"
1266+ },
1267+ "peerDependencies": {
1268+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
1269+ "jiti": ">=1.21.0",
1270+ "less": "*",
1271+ "lightningcss": "^1.21.0",
1272+ "sass": "*",
1273+ "sass-embedded": "*",
1274+ "stylus": "*",
1275+ "sugarss": "*",
1276+ "terser": "^5.16.0",
1277+ "tsx": "^4.8.1",
1278+ "yaml": "^2.4.2"
1279+ },
1280+ "peerDependenciesMeta": {
1281+ "@types/node": {
1282+ "optional": true
1283+ },
1284+ "jiti": {
1285+ "optional": true
1286+ },
1287+ "less": {
1288+ "optional": true
1289+ },
1290+ "lightningcss": {
1291+ "optional": true
1292+ },
1293+ "sass": {
1294+ "optional": true
1295+ },
1296+ "sass-embedded": {
1297+ "optional": true
1298+ },
1299+ "stylus": {
1300+ "optional": true
1301+ },
1302+ "sugarss": {
1303+ "optional": true
1304+ },
1305+ "terser": {
1306+ "optional": true
1307+ },
1308+ "tsx": {
1309+ "optional": true
1310+ },
1311+ "yaml": {
1312+ "optional": true
1313+ }
1314+ }
1315+ }
1316+ }
1317+}
package.jsonadded+25−0View file
@@ -0,0 +1,25 @@
1+{
2+ "name": "cymroom",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "description": "Companion of cymsim/cymsimweb: loads a .cymsim run, shows every part of it in a 3D scene, and (eventually) computes the sound at any listener in a room by solving the acoustic wave equation in air.",
7+ "scripts": {
8+ "dev": "vite",
9+ "build": "tsc --noEmit && vite build",
10+ "preview": "vite preview"
11+ },
12+ "devDependencies": {
13+ "@types/js-yaml": "^4.0.9",
14+ "@types/node": "^24.0.0",
15+ "@types/three": "^0.180.0",
16+ "@webgpu/types": "^0.1.74",
17+ "typescript": "^5.6.0",
18+ "vite": "^6.0.0"
19+ },
20+ "dependencies": {
21+ "h5wasm": "^0.10.3",
22+ "js-yaml": "^4.1.0",
23+ "three": "^0.180.0"
24+ }
25+}
src/analysis/acceleration.tsadded+67−0View file
@@ -0,0 +1,67 @@
1+// In-memory record of the audio-rate normal acceleration of every node, quantized to int16
2+// against one float32 scale per sample, exactly the encoding of the .cymsim file (see
3+// cymsim/docs/format.md, section 6), so that exporting copies blocks without re-encoding and
4+// loading keeps the file's blocks as they are. Recording is opt-in (output.record_acceleration):
5+// at the default mesh it costs about 0.4 GB per simulated second.
6+
7+export interface AccelerationBlock { first: number; count: number; data: Int16Array; scale: Float32Array }
8+
9+export class AccelerationStore {
10+ readonly nodes: number; readonly fs: number;
11+ readonly blocks: AccelerationBlock[] = [];
12+ nSamples = 0;
13+
14+ constructor(nodes: number, fs: number) { this.nodes = nodes; this.fs = fs; }
15+
16+ get bytes() { return this.nSamples * (2 * this.nodes + 4); }
17+
18+ /** Bytes a full record of ``nSamples`` samples takes in memory and in the file. */
19+ static estimateBytes(nSamples: number, nodes: number): number { return nSamples * (2 * nodes + 4); }
20+
21+ /** Encode ``count`` rows of float accelerations (row-major, N per row) that continue the record at ``first``. */
22+ push(rows: Float32Array, count: number, first: number) {
23+ if (count <= 0) return;
24+ if (first !== this.nSamples) throw new Error(`acceleration record is not contiguous: got sample ${first}, expected ${this.nSamples}`);
25+ const N = this.nodes, data = new Int16Array(count * N), scale = new Float32Array(count);
26+ for (let r = 0; r < count; r++) {
27+ let peak = 0;
28+ const b = r * N;
29+ for (let i = 0; i < N; i++) { const a = Math.abs(rows[b + i]); if (a > peak) peak = a; }
30+ const s = peak > 0 ? peak / 32767 : 0;
31+ scale[r] = s;
32+ if (s > 0) for (let i = 0; i < N; i++) data[b + i] = Math.round(rows[b + i] / s);
33+ }
34+ this.blocks.push({ first, count, data, scale }); this.nSamples += count;
35+ }
36+
37+ /** Append an already encoded block (from a file). */
38+ pushEncoded(data: Int16Array, scale: Float32Array) {
39+ const count = scale.length;
40+ if (count === 0) return;
41+ if (data.length !== count * this.nodes) throw new Error("encoded acceleration block has the wrong size");
42+ this.blocks.push({ first: this.nSamples, count, data, scale }); this.nSamples += count;
43+ }
44+
45+ /** Decoded accelerations [m/s^2] of samples [j0, j1), row-major. */
46+ read(j0: number, j1: number): Float32Array {
47+ j0 = Math.max(0, j0); j1 = Math.min(this.nSamples, j1);
48+ const N = this.nodes, out = new Float32Array(Math.max(0, j1 - j0) * N);
49+ for (const b of this.blocks) {
50+ const lo = Math.max(j0, b.first), hi = Math.min(j1, b.first + b.count);
51+ for (let j = lo; j < hi; j++) {
52+ const s = b.scale[j - b.first], src = (j - b.first) * N, dst = (j - j0) * N;
53+ for (let i = 0; i < N; i++) out[dst + i] = b.data[src + i] * s;
54+ }
55+ }
56+ return out;
57+ }
58+}
59+
60+/** The transverse displacement frames kept for the .cymsim file (a strobe for display, not a signal). */
61+export class DisplacementStore {
62+ readonly nodes: number; readonly rate: number;
63+ readonly times: number[] = []; readonly frames: Float32Array[] = [];
64+ constructor(nodes: number, rate: number) { this.nodes = nodes; this.rate = rate; }
65+ push(w: Float32Array, time: number) { if (w.length === this.nodes) { this.frames.push(w); this.times.push(time); } }
66+ get bytes() { return this.frames.length * this.nodes * 4; }
67+}
src/analysis/spectrogram.tsadded+97−0View file
@@ -0,0 +1,97 @@
1+// Incremental short-time Fourier transform of the listener signal: a Hann window of nfft samples
2+// advanced by hop, one power column per hop, plus the spectral centroid of each column.
3+
4+/** In-place iterative radix-2 FFT of (re, im); n must be a power of two. */
5+export function fft(re: Float32Array, im: Float32Array) {
6+ const n = re.length;
7+ for (let i = 1, j = 0; i < n; i++) {
8+ let bit = n >> 1;
9+ for (; j & bit; bit >>= 1) j ^= bit;
10+ j ^= bit;
11+ if (i < j) { [re[i], re[j]] = [re[j], re[i]]; [im[i], im[j]] = [im[j], im[i]]; }
12+ }
13+ for (let len = 2; len <= n; len <<= 1) {
14+ const ang = (-2 * Math.PI) / len, wr = Math.cos(ang), wi = Math.sin(ang);
15+ for (let i = 0; i < n; i += len) {
16+ let cr = 1, ci = 0;
17+ for (let k = 0; k < len / 2; k++) {
18+ const a = i + k, b = a + len / 2;
19+ const tr = re[b] * cr - im[b] * ci, ti = re[b] * ci + im[b] * cr;
20+ re[b] = re[a] - tr; im[b] = im[a] - ti; re[a] += tr; im[a] += ti;
21+ const nr = cr * wr - ci * wi; ci = cr * wi + ci * wr; cr = nr;
22+ }
23+ }
24+ }
25+}
26+
27+export class Stft {
28+ readonly nfft: number; readonly hop: number; readonly fs: number; readonly nBins: number;
29+ readonly columns: Float32Array[] = []; // power per bin
30+ readonly centroid: number[] = []; // Hz per column
31+ private readonly window: Float32Array;
32+ private buffer = new Float32Array(0);
33+ private consumed = 0; // samples already removed from the front of buffer
34+ private readonly re: Float32Array; private readonly im: Float32Array;
35+
36+ constructor(fs: number, nfft = 1024, hop = 256) {
37+ this.fs = fs; this.nfft = nfft; this.hop = hop; this.nBins = nfft / 2 + 1;
38+ this.window = new Float32Array(nfft);
39+ for (let i = 0; i < nfft; i++) this.window[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / nfft);
40+ this.re = new Float32Array(nfft); this.im = new Float32Array(nfft);
41+ }
42+
43+ /** Time of column c (its window centre) in seconds. */
44+ columnTime(c: number): number { return (c * this.hop + this.nfft / 2) / this.fs; }
45+ binFrequency(b: number): number { return (b * this.fs) / this.nfft; }
46+
47+ /** Append samples; returns the number of new columns. */
48+ push(samples: Float32Array): number {
49+ const joined = new Float32Array(this.buffer.length + samples.length);
50+ joined.set(this.buffer); joined.set(samples, this.buffer.length);
51+ this.buffer = joined;
52+ let n = 0, start = 0;
53+ while (start + this.nfft <= this.buffer.length) {
54+ for (let i = 0; i < this.nfft; i++) { this.re[i] = this.buffer[start + i] * this.window[i]; this.im[i] = 0; }
55+ fft(this.re, this.im);
56+ const col = new Float32Array(this.nBins);
57+ let num = 0, den = 0;
58+ for (let b = 0; b < this.nBins; b++) {
59+ col[b] = this.re[b] * this.re[b] + this.im[b] * this.im[b];
60+ num += col[b] * this.binFrequency(b); den += col[b];
61+ }
62+ this.columns.push(col); this.centroid.push(den > 0 ? num / den : 0);
63+ start += this.hop; n++;
64+ }
65+ this.buffer = this.buffer.slice(start); this.consumed += start;
66+ return n;
67+ }
68+}
69+
70+export const DEFAULT_BANDS = [60, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
71+
72+export interface BandSummary { lo: number; hi: number; onsetMs: number; peakMs: number; levelDb: number }
73+
74+/** Per-octave band envelopes: onset (first time at -20 dB of the band's own peak), peak time, level vs the loudest band. */
75+export function bandSummary(st: Stft, bands = DEFAULT_BANDS): { bands: BandSummary[]; centroidHz: { ms: number; hz: number }[] } {
76+ const nCol = st.columns.length;
77+ if (!nCol) return { bands: [], centroidHz: [] };
78+ const total = new Float64Array(nCol);
79+ for (let c = 0; c < nCol; c++) for (const p of st.columns[c]) total[c] += p;
80+ const totMax = Math.max(...total) || 1;
81+ const out: BandSummary[] = [];
82+ for (let b = 0; b + 1 < bands.length; b++) {
83+ const lo = bands[b], hi = bands[b + 1];
84+ const b0 = Math.ceil(lo / st.binFrequency(1)), b1 = Math.min(st.nBins, Math.ceil(hi / st.binFrequency(1)));
85+ if (b0 >= b1) continue;
86+ const env = new Float64Array(nCol);
87+ let mx = 0, peak = 0;
88+ for (let c = 0; c < nCol; c++) { let s = 0; for (let k = b0; k < b1; k++) s += st.columns[c][k]; env[c] = s; if (s > mx) { mx = s; peak = c; } }
89+ if (mx <= 0) continue;
90+ let onset = 0;
91+ for (let c = 0; c < nCol; c++) if (env[c] >= mx * 0.01) { onset = c; break; }
92+ out.push({ lo, hi, onsetMs: 1e3 * st.columnTime(onset), peakMs: 1e3 * st.columnTime(peak), levelDb: 10 * Math.log10(mx / totMax) });
93+ }
94+ const centroidHz = [5, 10, 20, 50, 100, 200, 500, 1000, 2000].filter((ms) => ms / 1e3 < st.columnTime(nCol - 1))
95+ .map((ms) => { let c = 0; while (c < nCol - 1 && st.columnTime(c) < ms / 1e3) c++; return { ms, hz: Math.round(st.centroid[c]) }; });
96+ return { bands: out, centroidHz };
97+}
src/audio/player.tsadded+72−0View file
@@ -0,0 +1,72 @@
1+// Playback of the part of the signal that exists so far, through Web Audio, and WAV export.
2+
3+export class Player {
4+ private ctx: AudioContext | null = null;
5+ private gain: GainNode | null = null;
6+ private source: AudioBufferSourceNode | null = null;
7+ private startedAt = 0; private offset = 0; private length = 0;
8+ private fs = 44100;
9+ playing = false;
10+ onEnded: (() => void) | null = null;
11+
12+ private ensure() {
13+ if (!this.ctx) { this.ctx = new AudioContext(); this.gain = this.ctx.createGain(); this.gain.connect(this.ctx.destination); }
14+ return this.ctx;
15+ }
16+
17+ setGainDb(db: number) { if (this.gain) this.gain.gain.value = Math.pow(10, db / 20); this.gainDb = db; }
18+ private gainDb = -12;
19+
20+ /** Play ``samples`` (already scaled to [-1, 1]) from ``fromSec``; the sound stops where the samples end. */
21+ play(samples: Float32Array, fs: number, fromSec: number) {
22+ this.stop();
23+ const ctx = this.ensure();
24+ this.setGainDb(this.gainDb);
25+ if (ctx.state === "suspended") void ctx.resume();
26+ const from = Math.min(Math.max(0, Math.floor(fromSec * fs)), samples.length);
27+ const part = samples.subarray(from);
28+ if (part.length === 0) return;
29+ const buf = ctx.createBuffer(1, part.length, fs);
30+ buf.copyToChannel(Float32Array.from(part), 0);
31+ const src = ctx.createBufferSource();
32+ src.buffer = buf; src.connect(this.gain!);
33+ src.onended = () => { if (this.source === src) { this.playing = false; this.source = null; this.onEnded?.(); } };
34+ src.start();
35+ this.source = src; this.startedAt = ctx.currentTime; this.offset = from / fs; this.length = samples.length / fs; this.fs = fs;
36+ this.playing = true;
37+ }
38+
39+ stop() {
40+ if (this.source) { const s = this.source; this.source = null; try { s.stop(); } catch { /* already stopped */ } }
41+ this.playing = false;
42+ }
43+
44+ /** Current playback position in seconds (or the last position when stopped). */
45+ position(): number {
46+ if (!this.playing || !this.ctx) return this.offset;
47+ return Math.min(this.offset + this.ctx.currentTime - this.startedAt, this.length);
48+ }
49+
50+ get sampleRate() { return this.fs; }
51+}
52+
53+/** Scale a pressure signal for playback: peak at 0.9 full scale (normalize) or 1 Pa = 0.05 FS. */
54+export function scaleForPlayback(p: Float32Array, normalize: boolean): Float32Array {
55+ let peak = 0;
56+ for (const x of p) peak = Math.max(peak, Math.abs(x));
57+ const s = normalize ? (peak > 0 ? 0.9 / peak : 0) : 0.05;
58+ const out = new Float32Array(p.length);
59+ for (let i = 0; i < p.length; i++) out[i] = Math.max(-1, Math.min(1, p[i] * s));
60+ return out;
61+}
62+
63+/** 16-bit PCM mono WAV. */
64+export function encodeWav(samples: Float32Array, fs: number): Blob {
65+ const n = samples.length, buf = new ArrayBuffer(44 + 2 * n), v = new DataView(buf);
66+ const str = (o: number, s: string) => { for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i)); };
67+ str(0, "RIFF"); v.setUint32(4, 36 + 2 * n, true); str(8, "WAVE"); str(12, "fmt "); v.setUint32(16, 16, true);
68+ v.setUint16(20, 1, true); v.setUint16(22, 1, true); v.setUint32(24, fs, true); v.setUint32(28, fs * 2, true);
69+ v.setUint16(32, 2, true); v.setUint16(34, 16, true); str(36, "data"); v.setUint32(40, 2 * n, true);
70+ for (let i = 0; i < n; i++) v.setInt16(44 + 2 * i, Math.round(Math.max(-1, Math.min(1, samples[i])) * 32767), true);
71+ return new Blob([buf], { type: "audio/wav" });
72+}
src/config/schema.tsadded+60−0View file
@@ -0,0 +1,60 @@
1+// Configuration types mirroring the Python package's dataclasses (cymsim/config.py), with the
2+// same field names, units (SI) and defaults, so that a YAML file is valid for both.
3+
4+export interface Material { E: number; nu: number; rho: number; sigma0: number; sigma1: number }
5+export interface Profile {
6+ radius: number; bell_height: number; bell_top_radius: number; bell_shoulder_radius: number;
7+ bow_height: number; bow_power: number; h_rim: number; h_shoulder: number; h_bell: number; max_slope: number;
8+}
9+export interface Imperfections {
10+ seed: number; n_dents: number; dent_depth: number; dent_sigma: number; dent_margin: number;
11+ rim_warp: number; rim_warp_modes: number[]; shoulder_ovality: number; bell_offset: number;
12+}
13+export interface MeshConfig { element_size: number; smoothing: number }
14+export interface StickExciter {
15+ kind: "stick"; mass: number; k: number; alpha: number; patch_length: number; patch_points: number;
16+ patch_angle: number; gap: number;
17+}
18+export interface MalletExciter { kind: "mallet"; mass: number; alpha: number; compression: number; radius: number; gap: number }
19+export type Exciter = StickExciter | MalletExciter;
20+export interface Hit { time: number; r: number; phi: number; velocity: number; exciter: Exciter }
21+export interface Listener { position: [number, number, number]; c_air: number; rho_air: number; back_diffraction_gain: number }
22+export interface Numerics {
23+ fs: number; duration: number; cfl_safety: number; rot_inertia_factor: number; inplane_mass_scale: number;
24+ steps_per_block: number; dtype: string; nonlinear: boolean; n_progress: number;
25+}
26+export interface Output {
27+ prefix: string; wav: boolean; npz: boolean; cascade_plot: boolean;
28+ record_acceleration: boolean; // keep the normal acceleration of every node at the audio rate (large; off by default)
29+ displacement_rate: number; // frames per second of transverse displacement kept in the .cymsim file; 0 = none
30+}
31+export interface SimulationConfig {
32+ material: Material; profile: Profile; imperfections: Imperfections; mesh: MeshConfig; hits: Hit[];
33+ listener: Listener; numerics: Numerics; output: Output;
34+}
35+
36+export const defaultMaterial = (): Material => ({ E: 1.1e11, nu: 0.34, rho: 8700, sigma0: 0.9, sigma1: 5e-4 });
37+export const defaultProfile = (): Profile => ({
38+ radius: 0.2286, bell_height: 0.013, bell_top_radius: 0.025, bell_shoulder_radius: 0.08, bow_height: 0.03,
39+ bow_power: 1.4, h_rim: 1.0e-3, h_shoulder: 1.7e-3, h_bell: 2.6e-3, max_slope: 0.4,
40+});
41+export const defaultImperfections = (): Imperfections => ({
42+ seed: 1, n_dents: 400, dent_depth: 0.25e-3, dent_sigma: 7e-3, dent_margin: 0.01, rim_warp: 0.6e-3,
43+ rim_warp_modes: [2, 3, 4, 5], shoulder_ovality: 0.03, bell_offset: 1.5e-3,
44+});
45+export const defaultMesh = (): MeshConfig => ({ element_size: 6e-3, smoothing: 1 });
46+export const defaultStick = (): StickExciter => ({
47+ kind: "stick", mass: 0.03, k: 5e8, alpha: 1.5, patch_length: 0.03, patch_points: 7, patch_angle: 0.6, gap: 1e-4,
48+});
49+export const defaultMallet = (): MalletExciter => ({ kind: "mallet", mass: 0.3, alpha: 2.5, compression: 12e-3, radius: 0.012, gap: 1e-4 });
50+export const defaultHit = (): Hit => ({ time: 0, r: 0.137, phi: 0.3, velocity: 8, exciter: defaultMallet() });
51+export const defaultListener = (): Listener => ({ position: [0.4, 0.3, 0.9], c_air: 343, rho_air: 1.2, back_diffraction_gain: 1 });
52+export const defaultNumerics = (): Numerics => ({
53+ fs: 44100, duration: 2, cfl_safety: 0.6, rot_inertia_factor: 1 / 64, inplane_mass_scale: 1, steps_per_block: 20000,
54+ dtype: "float32", nonlinear: true, n_progress: 20,
55+});
56+export const defaultOutput = (): Output => ({ prefix: "crash", wav: true, npz: true, cascade_plot: true, record_acceleration: false, displacement_rate: 60 });
57+export const defaultConfig = (): SimulationConfig => ({
58+ material: defaultMaterial(), profile: defaultProfile(), imperfections: defaultImperfections(), mesh: defaultMesh(),
59+ hits: [defaultHit()], listener: defaultListener(), numerics: defaultNumerics(), output: defaultOutput(),
60+});
src/config/yaml.tsadded+120−0View file
@@ -0,0 +1,120 @@
1+// YAML <-> SimulationConfig: parsing with defaults for omitted fields, reporting of unknown keys
2+// and wrong types, the validation rules of the Python package, and serialization in the same
3+// field order the Python package writes.
4+import yaml from "js-yaml";
5+import { defaultConfig, defaultHit, defaultMallet, defaultStick, type Exciter, type Hit, type SimulationConfig } from "./schema.ts";
6+
7+export interface Issue { path: string; message: string }
8+
9+const SECTIONS = ["material", "profile", "imperfections", "mesh", "hits", "listener", "numerics", "output"] as const;
10+
11+function isObject(x: unknown): x is Record<string, unknown> { return typeof x === "object" && x !== null && !Array.isArray(x); }
12+
13+/** Fill one flat section from raw data, reporting unknown keys and wrong types. */
14+function fillSection<T extends object>(target: T, raw: unknown, path: string, issues: Issue[]): T {
15+ if (raw === undefined || raw === null) return target;
16+ if (!isObject(raw)) { issues.push({ path, message: "expected a mapping" }); return target; }
17+ const t = target as unknown as Record<string, unknown>;
18+ for (const key of Object.keys(raw)) {
19+ if (!(key in t)) { issues.push({ path: `${path}.${key}`, message: "unknown field" }); continue; }
20+ const def = t[key], val = raw[key];
21+ if (typeof def === "number") {
22+ if (typeof val === "number" && Number.isFinite(val)) t[key] = val;
23+ else issues.push({ path: `${path}.${key}`, message: "expected a number" });
24+ } else if (typeof def === "boolean") {
25+ if (typeof val === "boolean") t[key] = val;
26+ else issues.push({ path: `${path}.${key}`, message: "expected true or false" });
27+ } else if (typeof def === "string") {
28+ if (typeof val === "string") t[key] = val;
29+ else issues.push({ path: `${path}.${key}`, message: "expected a string" });
30+ } else if (Array.isArray(def)) {
31+ if (Array.isArray(val) && val.every((x) => typeof x === "number" && Number.isFinite(x))) {
32+ if (key === "position" && val.length !== 3) issues.push({ path: `${path}.${key}`, message: "expected three numbers" });
33+ else t[key] = val.slice();
34+ } else issues.push({ path: `${path}.${key}`, message: "expected a list of numbers" });
35+ }
36+ }
37+ return target;
38+}
39+
40+function fillExciter(raw: unknown, path: string, issues: Issue[]): Exciter {
41+ if (raw === undefined) return defaultMallet();
42+ if (!isObject(raw)) { issues.push({ path, message: "expected a mapping with a 'kind' key" }); return defaultMallet(); }
43+ const kind = raw.kind;
44+ if (kind === "stick") return fillSection(defaultStick(), raw, path, issues);
45+ if (kind === "mallet") return fillSection(defaultMallet(), raw, path, issues);
46+ issues.push({ path: `${path}.kind`, message: `unknown exciter kind ${JSON.stringify(kind)}; expected 'stick' or 'mallet'` });
47+ return defaultMallet();
48+}
49+
50+function fillHit(raw: unknown, path: string, issues: Issue[]): Hit {
51+ const hit = defaultHit();
52+ if (!isObject(raw)) { issues.push({ path, message: "expected a mapping" }); return hit; }
53+ const { exciter, ...rest } = raw;
54+ const flat: Record<string, unknown> = { time: hit.time, r: hit.r, phi: hit.phi, velocity: hit.velocity };
55+ fillSection(flat, rest, path, issues);
56+ return { time: flat.time as number, r: flat.r as number, phi: flat.phi as number, velocity: flat.velocity as number,
57+ exciter: fillExciter(exciter, `${path}.exciter`, issues) };
58+}
59+
60+/** Build a complete configuration from raw (parsed YAML) data; missing fields take the defaults. */
61+export function normalizeConfig(raw: unknown): { config: SimulationConfig; issues: Issue[] } {
62+ const issues: Issue[] = [];
63+ const cfg = defaultConfig();
64+ if (raw === null || raw === undefined) return { config: cfg, issues };
65+ if (!isObject(raw)) { issues.push({ path: "", message: "the document must be a mapping" }); return { config: cfg, issues }; }
66+ for (const key of Object.keys(raw)) if (!(SECTIONS as readonly string[]).includes(key)) issues.push({ path: key, message: "unknown section" });
67+ fillSection(cfg.material, raw.material, "material", issues);
68+ fillSection(cfg.profile, raw.profile, "profile", issues);
69+ fillSection(cfg.imperfections, raw.imperfections, "imperfections", issues);
70+ fillSection(cfg.mesh, raw.mesh, "mesh", issues);
71+ fillSection(cfg.listener, raw.listener, "listener", issues);
72+ fillSection(cfg.numerics, raw.numerics, "numerics", issues);
73+ fillSection(cfg.output, raw.output, "output", issues);
74+ if (raw.hits !== undefined) {
75+ if (!Array.isArray(raw.hits)) issues.push({ path: "hits", message: "expected a list of hits" });
76+ else cfg.hits = raw.hits.map((h, i) => fillHit(h, `hits[${i}]`, issues));
77+ }
78+ return { config: cfg, issues };
79+}
80+
81+/** The validation rules of cymsim/config.py, plus a few that the web app needs. */
82+export function validateConfig(cfg: SimulationConfig): Issue[] {
83+ const issues: Issue[] = [], p = cfg.profile, n = cfg.numerics;
84+ if (cfg.hits.length === 0) issues.push({ path: "hits", message: "at least one hit is required" });
85+ if (n.dtype !== "float32" && n.dtype !== "float64") issues.push({ path: "numerics.dtype", message: "must be 'float32' or 'float64' (the web app always runs in float32)" });
86+ if (!(n.cfl_safety > 0 && n.cfl_safety <= 1)) issues.push({ path: "numerics.cfl_safety", message: "must lie in (0, 1]" });
87+ if (!(cfg.mesh.element_size > 0 && cfg.mesh.element_size <= p.radius / 2)) issues.push({ path: "mesh.element_size", message: "must be positive and well below the radius" });
88+ if (!(n.duration > 0)) issues.push({ path: "numerics.duration", message: "must be positive" });
89+ if (!(n.fs >= 8000 && n.fs <= 192000)) issues.push({ path: "numerics.fs", message: "must lie between 8000 and 192000 Hz" });
90+ if (!(cfg.output.displacement_rate >= 0)) issues.push({ path: "output.displacement_rate", message: "must be non-negative" });
91+ if (!(p.radius > 0 && p.h_rim > 0 && p.h_shoulder > 0 && p.h_bell > 0)) issues.push({ path: "profile", message: "radius and thicknesses must be positive" });
92+ if (!(p.bell_top_radius < p.bell_shoulder_radius && p.bell_shoulder_radius < p.radius)) issues.push({ path: "profile", message: "need bell_top_radius < bell_shoulder_radius < radius" });
93+ if (!(cfg.material.E > 0 && cfg.material.rho > 0 && cfg.material.nu > -1 && cfg.material.nu < 0.5)) issues.push({ path: "material", message: "E and rho must be positive and nu in (-1, 0.5)" });
94+ cfg.hits.forEach((h, i) => {
95+ if (h.time < 0) issues.push({ path: `hits[${i}].time`, message: "must be non-negative" });
96+ if (!(h.velocity > 0)) issues.push({ path: `hits[${i}].velocity`, message: "must be positive (the exciter approaches from above)" });
97+ const ex = h.exciter;
98+ const extent = ex.kind === "stick" ? ex.patch_length / 2 : ex.radius;
99+ if (h.r + extent >= p.radius - cfg.mesh.element_size) issues.push({ path: `hits[${i}].r`, message: `contact patch reaches the rim (r = ${h.r}, extent = ${extent})` });
100+ if (h.r < 0) issues.push({ path: `hits[${i}].r`, message: "must be non-negative" });
101+ if (!(ex.mass > 0)) issues.push({ path: `hits[${i}].exciter.mass`, message: "must be positive" });
102+ if (ex.kind === "stick" && !(ex.patch_points >= 1)) issues.push({ path: `hits[${i}].exciter.patch_points`, message: "must be at least 1" });
103+ if (ex.kind === "mallet" && !(ex.compression > 0)) issues.push({ path: `hits[${i}].exciter.compression`, message: "must be positive" });
104+ });
105+ return issues;
106+}
107+
108+/** Parse a YAML document into a configuration. ``config`` is null only when the YAML itself is malformed. */
109+export function parseConfigYaml(text: string): { config: SimulationConfig | null; issues: Issue[] } {
110+ let raw: unknown;
111+ try { raw = yaml.load(text); } catch (err) { return { config: null, issues: [{ path: "", message: `YAML: ${(err as Error).message}` }] }; }
112+ const { config, issues } = normalizeConfig(raw);
113+ return { config, issues: issues.concat(validateConfig(config)) };
114+}
115+
116+export function configToYaml(cfg: SimulationConfig): string {
117+ return yaml.dump(cfg, { noRefs: true, lineWidth: -1, sortKeys: false });
118+}
119+
120+export function cloneConfig(cfg: SimulationConfig): SimulationConfig { return structuredClone(cfg); }
src/io/cymsimFile.tsadded+206−0View file
@@ -0,0 +1,206 @@
1+// Reading and writing .cymsim files (HDF5) with h5wasm. The layout is specified in
2+// cymsim/docs/format.md; this module writes exactly what the Python package writes, apart from
3+// the `state` group, which holds this application's own integrator state (writer "cymsimweb").
4+// The h5wasm module is passed in by the caller: the browser uses the in-memory build ("h5wasm")
5+// and writes the file image to a Blob, the tests use "h5wasm/node" on the real file system.
6+import type { SimulationConfig } from "../config/schema.ts";
7+import { AccelerationStore } from "../analysis/acceleration.ts";
8+
9+/** cymsimweb's integrator state (opaque here; kept so a loaded file can be written back intact). */
10+export interface BackendState {
11+ kind: "cpu" | "gpu";
12+ step: number; // sub-steps done
13+ samplesDone: number;
14+ arrays: Record<string, Float32Array | Float64Array | Uint32Array>;
15+}
16+
17+export const FORMAT_VERSION = 1;
18+export const STATE_WRITER = "cymsimweb";
19+const ENERGY_COLUMNS = "time kinetic membrane bending exciters max_w_over_h";
20+const HIT_FIELDS = ["time", "peak_force", "contact_time", "rebound_velocity", "energy_in", "energy_out"] as const;
21+
22+// h5wasm's types are loose; we use the few calls we need through this minimal shape
23+export interface H5Module { File: new (path: string, mode: string) => any; ready: Promise<{ FS: any }> }
24+
25+export interface HitRecord { time: number; kind: string; peak_force: number; contact_time: number; rebound_velocity: number; energy_in: number; energy_out: number }
26+
27+/** Everything a .cymsim file holds, in memory. */
28+export interface CymsimRun {
29+ yaml: string; config: SimulationConfig;
30+ mesh: { xy: Float64Array; z0: Float64Array; thickness: Float64Array; normal: Float64Array; triangles: Int32Array | Uint32Array; area: Float64Array; boundary: Int32Array | Uint32Array; nNodes: number; nElements: number };
31+ listener: { rFront: Float64Array; rBack: Float64Array };
32+ fs: number; dt: number; substeps: number; duration: number; nSamples: number; nStepsDone: number; complete: boolean; wallTime: number; decimationSpan: number;
33+ audioBaffled: Float32Array; audioUnbaffled: Float32Array; // the two listener signals, final samples so far
34+ acceleration: AccelerationStore | null; taps: Float64Array | null; // taps: (W, m)
35+ displacement: { rate: number; times: number[]; frames: Float32Array[] } | null;
36+ energy: number[][]; // rows of six
37+ hits: HitRecord[];
38+ state: BackendState | null; // this application's state, when written by it
39+ creator: string; created: string;
40+}
41+
42+export const dtypeOf = (a: ArrayLike<number>) => a instanceof Float64Array ? "<d" : a instanceof Float32Array ? "<f" : a instanceof Int16Array ? "<h" : a instanceof Uint32Array ? "<I" : "<i";
43+
44+function attr(g: any, name: string, value: number | string | Float64Array, dtype?: string) {
45+ if (typeof value === "string") g.create_attribute(name, value);
46+ else if (typeof value === "number") g.create_attribute(name, value, null, dtype ?? (Number.isInteger(value) ? "<i" : "<d"));
47+ else g.create_attribute(name, value, [value.length], "<d");
48+}
49+
50+/** Create a resizable time-major dataset (rows, cols) and fill it from blocks without holding a copy of everything. */
51+function writeSeries(g: any, name: string, dtype: string, cols: number, rows: number, chunkRows: number, fill: (write: (r0: number, block: ArrayLike<number>) => void) => void) {
52+ const shape = cols > 0 ? [rows, cols] : [rows], chunks = cols > 0 ? [Math.max(1, Math.min(chunkRows, rows || chunkRows)), cols] : [Math.max(1, Math.min(chunkRows, rows || chunkRows))];
53+ const empty = dtype === "<h" ? new Int16Array(0) : dtype === "<d" ? new Float64Array(0) : new Float32Array(0);
54+ const ds = g.create_dataset({ name, data: empty, shape: cols > 0 ? [0, cols] : [0], dtype, maxshape: cols > 0 ? [null, cols] : [null], chunks });
55+ if (rows > 0) {
56+ ds.resize(shape);
57+ fill((r0, block) => {
58+ const n = cols > 0 ? block.length / cols : block.length;
59+ ds.write_slice(cols > 0 ? [[r0, r0 + n], [0, cols]] : [[r0, r0 + n]], block);
60+ });
61+ }
62+ return ds;
63+}
64+
65+/** Write ``run`` to ``path`` in h5wasm's file system (in memory in the browser, the real disk under Node). */
66+export function writeCymsim(h5: H5Module, path: string, run: CymsimRun, creator: string): void {
67+ const f = new h5.File(path, "w");
68+ try {
69+ const N = run.mesh.nNodes, E = run.mesh.nElements;
70+ attr(f, "format", "cymsim"); attr(f, "format_version", FORMAT_VERSION); attr(f, "creator", creator);
71+ attr(f, "created", new Date().toISOString().replace(/\.\d{3}Z$/, "+00:00"));
72+ f.create_group("config").create_dataset({ name: "yaml", data: run.yaml });
73+
74+ const mesh = f.create_group("mesh");
75+ attr(mesh, "n_nodes", N); attr(mesh, "n_elements", E);
76+ mesh.create_dataset({ name: "xy", data: run.mesh.xy, shape: [N, 2], dtype: "<d" });
77+ mesh.create_dataset({ name: "z0", data: run.mesh.z0, shape: [N], dtype: "<d" });
78+ mesh.create_dataset({ name: "thickness", data: run.mesh.thickness, shape: [N], dtype: "<d" });
79+ mesh.create_dataset({ name: "normal", data: run.mesh.normal, shape: [N, 3], dtype: "<d" });
80+ mesh.create_dataset({ name: "triangles", data: Int32Array.from(run.mesh.triangles), shape: [E, 3], dtype: "<i" });
81+ mesh.create_dataset({ name: "area", data: run.mesh.area, shape: [E], dtype: "<d" });
82+ mesh.create_dataset({ name: "boundary", data: Int32Array.from(run.mesh.boundary), shape: [run.mesh.boundary.length], dtype: "<i" });
83+
84+ const lis = f.create_group("listener"), L = run.config.listener;
85+ attr(lis, "position", Float64Array.from(L.position)); attr(lis, "c_air", L.c_air, "<d"); attr(lis, "rho_air", L.rho_air, "<d");
86+ attr(lis, "back_diffraction_gain", L.back_diffraction_gain, "<d");
87+ lis.create_dataset({ name: "r_front", data: run.listener.rFront, shape: [E], dtype: "<d" });
88+ lis.create_dataset({ name: "r_back", data: run.listener.rBack, shape: [E], dtype: "<d" });
89+
90+ const rg = f.create_group("run");
91+ attr(rg, "fs", run.fs); attr(rg, "dt", run.dt, "<d"); attr(rg, "substeps", run.substeps); attr(rg, "duration", run.duration, "<d");
92+ attr(rg, "n_samples", run.nSamples); attr(rg, "decimation_span", run.decimationSpan); attr(rg, "n_steps_done", run.nStepsDone);
93+ attr(rg, "complete", run.complete ? 1 : 0); attr(rg, "wall_time", run.wallTime, "<d");
94+
95+ const audio = f.create_group("audio");
96+ attr(audio, "fs", run.fs); attr(audio, "units", "Pa");
97+ for (const [name, data] of [["baffled", run.audioBaffled], ["unbaffled", run.audioUnbaffled]] as const) {
98+ writeSeries(audio, name, "<f", 0, data.length, 65536, (write) => write(0, data));
99+ }
100+
101+ if (run.acceleration && run.taps) {
102+ const a = run.acceleration, g = f.create_group("acceleration");
103+ attr(g, "fs", run.fs); attr(g, "component", "normal"); attr(g, "units", "m/s^2"); attr(g, "encoding", "int16 * scale[t]");
104+ attr(g, "description", "acceleration of every node along the rest-surface normal (front, +z side, positive), low-pass filtered by the kernel in 'taps' and sampled at fs; sample j is centred on t = j / fs");
105+ const rows = Math.max(1, Math.min(1024, Math.floor(16e6 / (2 * N))));
106+ writeSeries(g, "normal", "<h", N, a.nSamples, rows, (write) => { for (const b of a.blocks) write(b.first, b.data); });
107+ writeSeries(g, "scale", "<f", 0, a.nSamples, 65536, (write) => { for (const b of a.blocks) write(b.first, b.scale); });
108+ const W = run.decimationSpan, m = run.substeps;
109+ const taps = g.create_dataset({ name: "taps", data: run.taps, shape: [W, m], dtype: "<d" });
110+ attr(taps, "layout", "taps[w, kk]: weight of sub-step j0 m + kk in audio sample j0 + w - W/2 + 1");
111+ attr(taps, "kaiser_beta", 8.0, "<d"); attr(taps, "cutoff", 0.46, "<d");
112+ }
113+
114+ if (run.displacement) {
115+ const d = run.displacement, g = f.create_group("displacement");
116+ attr(g, "rate", d.rate, "<d"); attr(g, "units", "m"); attr(g, "description", "transverse displacement w of every node at the frame times");
117+ writeSeries(g, "w", "<f", N, d.frames.length, 64, (write) => d.frames.forEach((fr, i) => write(i, fr)));
118+ writeSeries(g, "time", "<d", 0, d.times.length, 4096, (write) => write(0, Float64Array.from(d.times)));
119+ }
120+
121+ const eg = f.create_group("energy");
122+ const table = writeSeries(eg, "table", "<d", 6, run.energy.length, 1024, (write) => write(0, Float64Array.from(run.energy.flat())));
123+ attr(table, "columns", ENERGY_COLUMNS);
124+
125+ const hg = f.create_group("hits"), K = run.hits.length;
126+ for (const name of HIT_FIELDS) hg.create_dataset({ name, data: Float64Array.from(run.hits, (h) => h[name]), shape: [K], dtype: "<d" });
127+ hg.create_dataset({ name: "kind", data: run.hits.map((h) => h.kind), shape: [K], dtype: "S16" });
128+
129+ if (run.state) {
130+ const s = run.state, g = f.create_group("state");
131+ attr(g, "writer", STATE_WRITER); attr(g, "n_steps_done", s.step); attr(g, "samples_done", s.samplesDone); attr(g, "backend", s.kind);
132+ for (const [name, a] of Object.entries(s.arrays)) g.create_dataset({ name, data: a, shape: [a.length], dtype: dtypeOf(a) });
133+ }
134+ } finally { f.close(); }
135+}
136+
137+const str = (v: unknown) => (typeof v === "string" ? v : Array.isArray(v) ? String(v[0]) : String(v));
138+const num = (v: unknown) => Number(Array.isArray(v) ? v[0] : v);
139+
140+/** Read a .cymsim file from h5wasm's file system. ``parse`` turns the YAML text into a configuration. */
141+export function readCymsim(h5: H5Module, path: string, parse: (yaml: string) => SimulationConfig): CymsimRun {
142+ const f = new h5.File(path, "r");
143+ try {
144+ if (str(f.get_attribute("format", true)) !== "cymsim") throw new Error("not a .cymsim file");
145+ const get = (p: string) => { const d = f.get(p); if (!d) throw new Error(`missing ${p}`); return d; };
146+ const has = (p: string) => f.get(p) !== null && f.get(p) !== undefined;
147+ const value = <T,>(p: string) => get(p).value as T;
148+ const a = (p: string, name: string) => get(p).get_attribute(name, true);
149+ const yamlText = str(value("config/yaml"));
150+ const config = parse(yamlText);
151+ const nNodes = num(a("mesh", "n_nodes")), nElements = num(a("mesh", "n_elements"));
152+ const f64 = (p: string) => Float64Array.from(value<ArrayLike<number>>(p));
153+ const f32 = (p: string) => Float32Array.from(value<ArrayLike<number>>(p));
154+ const fs = num(a("run", "fs")), substeps = num(a("run", "substeps"));
155+ let acceleration: AccelerationStore | null = null, taps: Float64Array | null = null;
156+ if (has("acceleration")) {
157+ acceleration = new AccelerationStore(nNodes, fs);
158+ const ds = get("acceleration/normal"), scale = f32("acceleration/scale"), T = ds.shape[0] as number;
159+ // read in slabs of about 64 MB so that no second copy of the whole record is needed
160+ const slab = Math.max(1, Math.floor(32e6 / nNodes));
161+ for (let r0 = 0; r0 < T; r0 += slab) {
162+ const r1 = Math.min(T, r0 + slab);
163+ const data = ds.slice([[r0, r1], [0, nNodes]]) as Int16Array;
164+ acceleration.pushEncoded(Int16Array.from(data), scale.slice(r0, r1));
165+ }
166+ taps = f64("acceleration/taps");
167+ }
168+ let displacement: CymsimRun["displacement"] = null;
169+ if (has("displacement")) {
170+ const w = get("displacement/w"), Td = w.shape[0] as number, all = w.value as Float32Array, times = Array.from(f64("displacement/time"));
171+ const frames: Float32Array[] = [];
172+ for (let i = 0; i < Td; i++) frames.push(Float32Array.from(all.subarray(i * nNodes, (i + 1) * nNodes)));
173+ displacement = { rate: num(a("displacement", "rate")), times, frames };
174+ }
175+ const et = get("energy/table"), erows = et.shape[0] as number, eflat = Array.from(et.value as ArrayLike<number>);
176+ const energy: number[][] = [];
177+ for (let i = 0; i < erows; i++) energy.push(eflat.slice(6 * i, 6 * i + 6));
178+ const K = get("hits/time").shape[0] as number, kinds = get("hits/kind").value as unknown as string[];
179+ const hits: HitRecord[] = [];
180+ for (let k = 0; k < K; k++) {
181+ const h: any = { kind: str(Array.isArray(kinds) ? kinds[k] : kinds) };
182+ for (const name of HIT_FIELDS) h[name] = Number((get(`hits/${name}`).value as ArrayLike<number>)[k]);
183+ hits.push(h);
184+ }
185+ let state: BackendState | null = null;
186+ if (has("state") && str(a("state", "writer")) === STATE_WRITER) {
187+ const g = get("state"), arrays: BackendState["arrays"] = {};
188+ for (const name of g.keys() as string[]) {
189+ const v = g.get(name).value;
190+ arrays[name] = v instanceof Float64Array || v instanceof Float32Array || v instanceof Uint32Array ? v : Float32Array.from(v as ArrayLike<number>);
191+ }
192+ state = { kind: str(a("state", "backend")) as "cpu" | "gpu", step: num(a("state", "n_steps_done")), samplesDone: num(a("state", "samples_done")), arrays };
193+ }
194+ const optional = (name: string, dflt: number) => { try { return num(a("run", name)); } catch { return dflt; } };
195+ return {
196+ yaml: yamlText, config,
197+ mesh: { xy: f64("mesh/xy"), z0: f64("mesh/z0"), thickness: f64("mesh/thickness"), normal: f64("mesh/normal"), triangles: Int32Array.from(value<ArrayLike<number>>("mesh/triangles")),
198+ area: f64("mesh/area"), boundary: Int32Array.from(value<ArrayLike<number>>("mesh/boundary")), nNodes, nElements },
199+ listener: { rFront: f64("listener/r_front"), rBack: f64("listener/r_back") },
200+ fs, dt: num(a("run", "dt")), substeps, duration: num(a("run", "duration")), nSamples: num(a("run", "n_samples")),
201+ nStepsDone: optional("n_steps_done", 0), complete: optional("complete", 0) !== 0, wallTime: optional("wall_time", 0), decimationSpan: optional("decimation_span", 32),
202+ audioBaffled: f32("audio/baffled"), audioUnbaffled: f32("audio/unbaffled"), acceleration, taps, displacement, energy, hits, state,
203+ creator: str(f.get_attribute("creator", true)), created: str(f.get_attribute("created", true)),
204+ };
205+ } finally { f.close(); }
206+}
src/listener.tsadded+254−0View file
@@ -0,0 +1,254 @@
1+// Re-render the listener signals at any position from the acceleration record, with the same
2+// free-field model as cymsim's built-in listener (cymsimweb src/model/radiation.ts): a Rayleigh
3+// integral over element-mean normal accelerations for the front path and a diffracted path over
4+// the rim for the back term, each element delayed by its distance at a 16-tap windowed-sinc
5+// fractional delay. The record already holds the audio-rate node accelerations, so this is a
6+// gather over elements per output sample; cymsim's test suite validates the same reconstruction
7+// against the built-in listener to 1.5e-4 relative rms. The gather runs on WebGPU when available
8+// (one workgroup per output sample, the record streamed through a sliding window) and on the CPU
9+// otherwise. besselI0 and fractionalDelayTaps are copied from cymsimweb.
10+import type { CymsimRun } from "./io/cymsimFile.ts";
11+
12+export const DELAY_TAPS = 16;
13+
14+/** Modified Bessel function I0 by its power series (converges quickly for x < 30). */
15+function besselI0(x: number): number {
16+ let sum = 1, term = 1;
17+ const y = (x * x) / 4;
18+ for (let k = 1; k < 60; k++) { term *= y / (k * k); sum += term; if (term < 1e-17 * sum) break; }
19+ return sum;
20+}
21+
22+/** Windowed-sinc taps that read x(j - i0 - fr), lags i0 - T/2 + 1 ... i0 + T/2 (Kaiser, beta 6, unit sum). */
23+function fractionalDelayTaps(fr: number, T = DELAY_TAPS): Float64Array {
24+ const taps = new Float64Array(T), half = T / 2, i0 = besselI0(6);
25+ let sum = 0;
26+ for (let t = 0; t < T; t++) {
27+ const k = t - half + 1, x = k - fr;
28+ const sinc = x === 0 ? 1 : Math.sin(Math.PI * x) / (Math.PI * x);
29+ const u = x / half, win = besselI0(6 * Math.sqrt(Math.max(1 - u * u, 0))) / i0;
30+ taps[t] = sinc * win; sum += taps[t];
31+ }
32+ for (let t = 0; t < T; t++) taps[t] /= sum;
33+ return taps;
34+}
35+
36+interface Geometry {
37+ E: number;
38+ coefs: Float32Array<ArrayBuffer>; // [2e] front rho A / (2 pi r), [2e+1] back with the diffraction gain
39+ delays: Uint32Array<ArrayBuffer>; // [2e], [2e+1]: integer delays in audio samples
40+ taps: Float32Array<ArrayBuffer>; // per element 32 floats: 16 front taps then 16 back taps
41+ maxLag: number; // largest lag any tap reaches back, in samples
42+}
43+
44+/** Per-element coefficients, delays and interpolator taps for a listener at ``position``. */
45+function buildGeometry(run: CymsimRun, position: [number, number, number]): Geometry {
46+ const m = run.mesh, E = m.nElements, [Lx, Ly, Lz] = position;
47+ const { c_air: c, rho_air: rho, back_diffraction_gain: g } = run.config.listener;
48+ // Diffraction ring: the boundary loop, subdivided in polar coordinates to about 720 points so
49+ // that the min-over-ring path length matches cymsimweb's analytic rim (which uses nRim = 720).
50+ const nb = m.boundary.length, sub = Math.max(1, Math.ceil(720 / nb));
51+ const rim: number[][] = [];
52+ for (let k = 0; k < nb; k++) {
53+ const a = m.boundary[k], b = m.boundary[(k + 1) % nb];
54+ const ra = Math.hypot(m.xy[2 * a], m.xy[2 * a + 1]), rb = Math.hypot(m.xy[2 * b], m.xy[2 * b + 1]);
55+ const pa = Math.atan2(m.xy[2 * a + 1], m.xy[2 * a]);
56+ let pb = Math.atan2(m.xy[2 * b + 1], m.xy[2 * b]);
57+ if (pb - pa > Math.PI) pb -= 2 * Math.PI;
58+ if (pa - pb > Math.PI) pb += 2 * Math.PI;
59+ for (let s = 0; s < sub; s++) {
60+ const t = s / sub, r = ra + t * (rb - ra), phi = pa + t * (pb - pa), z = m.z0[a] + t * (m.z0[b] - m.z0[a]);
61+ rim.push([r * Math.cos(phi), r * Math.sin(phi), z]);
62+ }
63+ }
64+ const rimToL = rim.map(([x, y, z]) => Math.hypot(x - Lx, y - Ly, z - Lz));
65+ const coefs = new Float32Array(2 * E), delays = new Uint32Array(2 * E), taps = new Float32Array(32 * E);
66+ let maxLag = 0;
67+ for (let e = 0; e < E; e++) {
68+ const a = m.triangles[3 * e], b = m.triangles[3 * e + 1], d = m.triangles[3 * e + 2];
69+ const cx = (m.xy[2 * a] + m.xy[2 * b] + m.xy[2 * d]) / 3;
70+ const cy = (m.xy[2 * a + 1] + m.xy[2 * b + 1] + m.xy[2 * d + 1]) / 3;
71+ const cz = (m.z0[a] + m.z0[b] + m.z0[d]) / 3;
72+ const rF = Math.hypot(Lx - cx, Ly - cy, Lz - cz);
73+ let rB = Infinity;
74+ for (let k = 0; k < rim.length; k++) {
75+ rB = Math.min(rB, Math.hypot(cx - rim[k][0], cy - rim[k][1], cz - rim[k][2]) + rimToL[k]);
76+ }
77+ coefs[2 * e] = (rho * m.area[e]) / (2 * Math.PI * rF);
78+ coefs[2 * e + 1] = (g * rho * m.area[e]) / (2 * Math.PI * rB);
79+ const dF = (rF / c) * run.fs, dB = (rB / c) * run.fs;
80+ delays[2 * e] = Math.floor(dF); delays[2 * e + 1] = Math.floor(dB);
81+ taps.set(fractionalDelayTaps(dF - delays[2 * e]), 32 * e);
82+ taps.set(fractionalDelayTaps(dB - delays[2 * e + 1]), 32 * e + 16);
83+ maxLag = Math.max(maxLag, delays[2 * e], delays[2 * e + 1]);
84+ }
85+ return { E, coefs, delays, taps, maxLag: maxLag + DELAY_TAPS / 2 + 1 };
86+}
87+
88+/**
89+ * Element means of the decoded record for global samples [j0, j1), sample-major (E per row);
90+ * rows for samples before 0 are zero (nothing has radiated yet).
91+ */
92+function elementMeans(run: CymsimRun, j0: number, j1: number): Float32Array<ArrayBuffer> {
93+ const acc = run.acceleration!, m = run.mesh, E = m.nElements, N = acc.nodes;
94+ const out = new Float32Array((j1 - j0) * E);
95+ const d0 = Math.max(0, j0);
96+ if (d0 >= j1) return out;
97+ const rows = acc.read(d0, j1);
98+ for (let j = d0; j < j1; j++) {
99+ const src = (j - d0) * N, dst = (j - j0) * E;
100+ for (let e = 0; e < E; e++) {
101+ const a = m.triangles[3 * e], b = m.triangles[3 * e + 1], c = m.triangles[3 * e + 2];
102+ out[dst + e] = (rows[src + a] + rows[src + b] + rows[src + c]) / 3;
103+ }
104+ }
105+ return out;
106+}
107+
108+export interface RenderedListener { front: Float32Array; back: Float32Array; position: [number, number, number]; backend: "gpu" | "cpu" }
109+export interface RenderOptions { onProgress?: (fraction: number, backend: "gpu" | "cpu") => void; shouldStop?: () => boolean }
110+
111+const GATHER_WGSL = /* wgsl */ `
112+struct U { E: u32, D: u32, chunkLen: u32, base: u32 }
113+@group(0) @binding(0) var<uniform> u: U;
114+@group(0) @binding(1) var<storage, read> coefs: array<f32>;
115+@group(0) @binding(2) var<storage, read> delays: array<u32>;
116+@group(0) @binding(3) var<storage, read> taps: array<f32>;
117+@group(0) @binding(4) var<storage, read> win: array<f32>; // (D + chunkLen) rows of E means
118+@group(0) @binding(5) var<storage, read_write> outp: array<f32>; // 2 per sample: front, back
119+
120+const WG = 256u;
121+var<workgroup> pf: array<f32, WG>;
122+var<workgroup> pb: array<f32, WG>;
123+
124+@compute @workgroup_size(WG)
125+fn gather(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_id) li: vec3<u32>) {
126+ let s = wg.x; let tid = li.x;
127+ var f = 0.0; var b = 0.0;
128+ if (s < u.chunkLen) {
129+ let jg = i32(u.base + s);
130+ for (var e = tid; e < u.E; e += WG) {
131+ let i0f = i32(delays[2u * e]); let i0b = i32(delays[2u * e + 1u]);
132+ var af = 0.0; var ab = 0.0;
133+ for (var t = 0; t < 16; t++) {
134+ let lagF = i0f + t - 7;
135+ if (lagF >= 0 && lagF <= jg) {
136+ af += taps[e * 32u + u32(t)] * win[u32(i32(u.D + s) - lagF) * u.E + e];
137+ }
138+ let lagB = i0b + t - 7;
139+ if (lagB >= 0 && lagB <= jg) {
140+ ab += taps[e * 32u + 16u + u32(t)] * win[u32(i32(u.D + s) - lagB) * u.E + e];
141+ }
142+ }
143+ f += coefs[2u * e] * af; b += coefs[2u * e + 1u] * ab;
144+ }
145+ }
146+ pf[tid] = f; pb[tid] = b;
147+ workgroupBarrier();
148+ var step = WG / 2u;
149+ while (step > 0u) {
150+ if (tid < step) { pf[tid] += pf[tid + step]; pb[tid] += pb[tid + step]; }
151+ workgroupBarrier();
152+ step = step / 2u;
153+ }
154+ if (tid == 0u && s < u.chunkLen) { outp[2u * s] = pf[0]; outp[2u * s + 1u] = pb[0]; }
155+}
156+`;
157+
158+let devicePromise: Promise<GPUDevice | null> | null = null;
159+function gpuDevice(): Promise<GPUDevice | null> {
160+ devicePromise ??= (async () => {
161+ try {
162+ const adapter = await navigator.gpu?.requestAdapter();
163+ return adapter ? await adapter.requestDevice() : null;
164+ } catch { return null; }
165+ })();
166+ return devicePromise;
167+}
168+
169+async function renderGpu(device: GPUDevice, run: CymsimRun, geom: Geometry, opts: RenderOptions): Promise<RenderedListener | null> {
170+ const T = run.acceleration!.nSamples, E = geom.E, D = geom.maxLag;
171+ const chunk = Math.max(256, Math.min(16384, Math.floor(48e6 / (4 * E)) - D));
172+ const buf = (data: Float32Array<ArrayBuffer> | Uint32Array<ArrayBuffer>) => {
173+ const b = device.createBuffer({ size: data.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
174+ device.queue.writeBuffer(b, 0, data);
175+ return b;
176+ };
177+ const coefs = buf(geom.coefs), delays = buf(geom.delays), taps = buf(geom.taps);
178+ const win = device.createBuffer({ size: (D + chunk) * E * 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
179+ const outp = device.createBuffer({ size: 2 * chunk * 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC });
180+ const stage = device.createBuffer({ size: 2 * chunk * 4, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST });
181+ const uni = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
182+ const pipe = device.createComputePipeline({
183+ layout: "auto",
184+ compute: { module: device.createShaderModule({ code: GATHER_WGSL }), entryPoint: "gather" },
185+ });
186+ const bind = device.createBindGroup({
187+ layout: pipe.getBindGroupLayout(0),
188+ entries: [uni, coefs, delays, taps, win, outp].map((b, i) => ({ binding: i, resource: { buffer: b } })),
189+ });
190+ const front = new Float32Array(T), back = new Float32Array(T);
191+ try {
192+ for (let j0 = 0; j0 < T; j0 += chunk) {
193+ if (opts.shouldStop?.()) return null;
194+ const len = Math.min(chunk, T - j0);
195+ device.queue.writeBuffer(uni, 0, new Uint32Array([E, D, len, j0]));
196+ device.queue.writeBuffer(win, 0, elementMeans(run, j0 - D, j0 + len));
197+ const enc = device.createCommandEncoder();
198+ const pass = enc.beginComputePass();
199+ pass.setPipeline(pipe); pass.setBindGroup(0, bind); pass.dispatchWorkgroups(len);
200+ pass.end();
201+ enc.copyBufferToBuffer(outp, 0, stage, 0, 2 * len * 4);
202+ device.queue.submit([enc.finish()]);
203+ await stage.mapAsync(GPUMapMode.READ, 0, 2 * len * 4);
204+ const rows = new Float32Array(stage.getMappedRange(0, 2 * len * 4)).slice();
205+ stage.unmap();
206+ for (let s = 0; s < len; s++) { front[j0 + s] = rows[2 * s]; back[j0 + s] = rows[2 * s + 1]; }
207+ opts.onProgress?.((j0 + len) / T, "gpu");
208+ }
209+ } finally {
210+ for (const b of [coefs, delays, taps, win, outp, stage, uni]) b.destroy();
211+ }
212+ return { front, back, position: [0, 0, 0], backend: "gpu" };
213+}
214+
215+async function renderCpu(run: CymsimRun, geom: Geometry, opts: RenderOptions): Promise<RenderedListener | null> {
216+ const T = run.acceleration!.nSamples, E = geom.E, D = geom.maxLag;
217+ const chunk = Math.max(256, Math.min(8192, Math.floor(16e6 / (4 * E)) - D));
218+ const front = new Float32Array(T), back = new Float32Array(T);
219+ const { coefs, delays, taps } = geom;
220+ for (let j0 = 0; j0 < T; j0 += chunk) {
221+ if (opts.shouldStop?.()) return null;
222+ const len = Math.min(chunk, T - j0);
223+ const win = elementMeans(run, j0 - D, j0 + len);
224+ for (let s = 0; s < len; s++) {
225+ const jg = j0 + s;
226+ let f = 0, b = 0;
227+ for (let e = 0; e < E; e++) {
228+ const i0f = delays[2 * e], i0b = delays[2 * e + 1];
229+ let af = 0, ab = 0;
230+ for (let t = 0; t < 16; t++) {
231+ const lagF = i0f + t - 7;
232+ if (lagF >= 0 && lagF <= jg) af += taps[32 * e + t] * win[(D + s - lagF) * E + e];
233+ const lagB = i0b + t - 7;
234+ if (lagB >= 0 && lagB <= jg) ab += taps[32 * e + 16 + t] * win[(D + s - lagB) * E + e];
235+ }
236+ f += coefs[2 * e] * af; b += coefs[2 * e + 1] * ab;
237+ }
238+ front[jg] = f; back[jg] = b;
239+ }
240+ opts.onProgress?.((j0 + len) / T, "cpu");
241+ await new Promise((r) => setTimeout(r, 0)); // let the page breathe between chunks
242+ }
243+ return { front, back, position: [0, 0, 0], backend: "cpu" };
244+}
245+
246+/** Render the baffled (front) and back signals at ``position``; null when stopped or no record. */
247+export async function renderListener(run: CymsimRun, position: [number, number, number], opts: RenderOptions = {}): Promise<RenderedListener | null> {
248+ if (!run.acceleration || run.acceleration.nSamples === 0) return null;
249+ const geom = buildGeometry(run, position);
250+ const device = await gpuDevice();
251+ const out = device ? await renderGpu(device, run, geom, opts) : await renderCpu(run, geom, opts);
252+ if (out) out.position = [...position];
253+ return out;
254+}
src/main.tsadded+365−0View file
@@ -0,0 +1,365 @@
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.
4+import { parseConfigYaml } from "./config/yaml.ts";
5+import { readCymsim, type CymsimRun, type H5Module } from "./io/cymsimFile.ts";
6+import { SceneView, type ColorMode, type OverlayName } from "./scene.ts";
7+import { FramePlayer, accelerationSource, displacementSource, type FrameSource } from "./playback.ts";
8+import { drawEnergy, drawSpectrogram, drawWaveform } from "./ui/plots.ts";
9+import { Player, scaleForPlayback } from "./audio/player.ts";
10+import { renderListener, type RenderedListener } from "./listener.ts";
11+
12+const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
13+const fmt = (x: number, digits = 3) => Number.isFinite(x) ? x.toPrecision(digits).replace(/\.?0+$/, "").replace(/\.?0+e/, "e") : "?";
14+const mb = (bytes: number) => `${(bytes / 1048576).toFixed(1)} MB`;
15+
16+let run: CymsimRun | null = null;
17+let probeNode: number | null = null;
18+let probeSignal: { data: Float32Array; fs: number } | null = null;
19+let rendered: RenderedListener | null = null;
20+let renderGen = 0;
21+const audio = new Player();
22+
23+// ---- 3D scene and playback ----------------------------------------------------------------
24+
25+const scene = new SceneView($<HTMLCanvasElement>("scene"), (node) => probe(node));
26+
27+const playBtn = $<HTMLButtonElement>("pb-play"), posSlider = $<HTMLInputElement>("pb-pos"), timeSpan = $("pb-time");
28+const 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+);
38+
39+const sourceSel = $<HTMLSelectElement>("pb-source"), slowdownSel = $<HTMLSelectElement>("pb-slowdown");
40+for (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+}
45+let sources: FrameSource[] = [];
46+function 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+}
53+sourceSel.addEventListener("change", () => selectSource(sourceSel.selectedIndex));
54+slowdownSel.addEventListener("change", () => { player.slowdown = Number(slowdownSel.value); });
55+playBtn.addEventListener("click", () => {
56+ if (player.playing) player.pause();
57+ else { player.play(); playBtn.textContent = "Pause"; }
58+});
59+posSlider.addEventListener("input", () => player.seek(Number(posSlider.value) / 1000));
60+
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)));
63+scene.setExaggeration(Number($<HTMLSelectElement>("exag").value));
64+for (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+}
68+const 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+};
73+
74+// ---- inspector tabs ------------------------------------------------------------------------
75+
76+function 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+}
83+for (const b of document.querySelectorAll<HTMLButtonElement>("#tabs button")) {
84+ b.addEventListener("click", () => activateTab(b.dataset.tab!));
85+}
86+
87+function facts(pairs: [string, string][]): string {
88+ return `<dl class="facts">${pairs.map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join("")}</dl>`;
89+}
90+
91+function 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+}
111+
112+function 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+}
120+
121+// ---- node probe ----------------------------------------------------------------------------
122+
123+function probe(node: number) {
124+ probeNode = node;
125+ scene.setProbe(node);
126+ activateTab("probe");
127+}
128+
129+function 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+}
165+
166+// ---- audio: node pickup and listener signals ------------------------------------------------
167+
168+function resetPlayButtons() {
169+ $("probe-play").textContent = "Play pickup";
170+ $("audio-play").textContent = "Play";
171+}
172+audio.onEnded = resetPlayButtons;
173+
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+});
182+
183+interface AudioSignal { id: string; label: string; data: Float32Array; info: string }
184+
185+function 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+}
200+
201+function 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+}
214+
215+function 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+}
224+
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+});
236+
237+// ---- movable listener -----------------------------------------------------------------------
238+
239+const lisInputs = (["lis-x", "lis-y", "lis-z"] as const).map((id) => $<HTMLInputElement>(id));
240+
241+function setListenerInputs(p: [number, number, number]) { lisInputs.forEach((el, i) => { el.value = String(p[i]); }); }
242+
243+function 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+}
247+
248+async 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+}
265+
266+let lisTimer = 0;
267+for (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+}
276+
277+// ---- file loading --------------------------------------------------------------------------
278+
279+// h5wasm (libhdf5 in WebAssembly) is loaded on first use; its in-memory file system holds the image
280+let h5Promise: Promise<{ h5: H5Module; FS: any }> | null = null;
281+const 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+}));
286+
287+async 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+}
336+
337+const 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+});
343+document.body.addEventListener("dragover", (e) => { e.preventDefault(); document.body.classList.add("dragover"); });
344+document.body.addEventListener("dragleave", () => document.body.classList.remove("dragover"));
345+document.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+});
351+
352+// ?file=<url> loads a served .cymsim on startup (same origin or CORS-enabled)
353+const fileUrl = new URLSearchParams(location.search).get("file");
354+if (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+}
src/playback.tsadded+84−0View file
@@ -0,0 +1,84 @@
1+// Frame playback over the records in the file, in simulated time slowed by a user factor: the
2+// displacement movie (frames at output.displacement_rate) deforms and tints the surface; the
3+// acceleration record (one frame per audio sample) tints it, normalized per frame because the
4+// signal decays through many decades, and borrows the nearest displacement frame for the shape.
5+import { AccelerationStore } from "./analysis/acceleration.ts";
6+
7+export interface Frame { w: Float32Array | null; field: Float32Array | null; scale: number }
8+
9+export interface FrameSource {
10+ kind: "displacement" | "acceleration";
11+ label: string;
12+ fps: number;
13+ nFrames: number;
14+ frame(i: number): Frame;
15+}
16+
17+export function displacementSource(d: { rate: number; times: number[]; frames: Float32Array[] }): FrameSource {
18+ let peak = 0;
19+ for (const f of d.frames) for (const w of f) { const a = Math.abs(w); if (a > peak) peak = a; }
20+ return {
21+ kind: "displacement", label: `displacement (${d.rate.toFixed(0)} fps)`, fps: d.rate, nFrames: d.frames.length,
22+ frame: (i) => ({ w: d.frames[i], field: d.frames[i], scale: peak || 1 }),
23+ };
24+}
25+
26+export function accelerationSource(a: AccelerationStore, disp: { rate: number; frames: Float32Array[] } | null): FrameSource {
27+ const peaks = new Float32Array(a.nSamples);
28+ for (const b of a.blocks) for (let r = 0; r < b.count; r++) peaks[b.first + r] = b.scale[r] * 32767;
29+ return {
30+ kind: "acceleration", label: `acceleration (${a.fs.toFixed(0)} fps)`, fps: a.fs, nFrames: a.nSamples,
31+ frame: (i) => {
32+ const w = disp && disp.frames.length > 0
33+ ? disp.frames[Math.max(0, Math.min(disp.frames.length - 1, Math.round((i / a.fs) * disp.rate)))] : null;
34+ return { w, field: a.read(i, i + 1), scale: peaks[i] || 1 };
35+ },
36+ };
37+}
38+
39+/** Advances simulated time by wall time over the slowdown factor and reports the nearest frame. */
40+export class FramePlayer {
41+ source: FrameSource | null = null;
42+ slowdown = 100;
43+ playing = false;
44+ private simTime = 0;
45+ private lastWall = 0;
46+ private lastFrame = -1;
47+ private raf = 0;
48+
49+ private onFrame: (i: number, time: number) => void;
50+ private onStop: () => void;
51+
52+ constructor(onFrame: (i: number, time: number) => void, onStop: () => void) { this.onFrame = onFrame; this.onStop = onStop; }
53+
54+ setSource(s: FrameSource | null) { this.pause(); this.source = s; this.simTime = 0; this.lastFrame = -1; this.emit(); }
55+
56+ get duration() { return this.source ? this.source.nFrames / this.source.fps : 0; }
57+ get fraction() { return this.duration > 0 ? this.simTime / this.duration : 0; }
58+
59+ play() {
60+ if (!this.source || this.playing) return;
61+ if (this.fraction >= 1) this.simTime = 0;
62+ this.playing = true;
63+ this.lastWall = performance.now();
64+ const tick = (now: number) => {
65+ if (!this.playing) return;
66+ this.simTime += (now - this.lastWall) / 1000 / this.slowdown;
67+ this.lastWall = now;
68+ if (this.simTime >= this.duration) { this.simTime = this.duration; this.emit(); this.pause(); return; }
69+ this.emit();
70+ this.raf = requestAnimationFrame(tick);
71+ };
72+ this.raf = requestAnimationFrame(tick);
73+ }
74+
75+ pause() { this.playing = false; cancelAnimationFrame(this.raf); this.onStop(); }
76+
77+ seek(fraction: number) { this.simTime = Math.max(0, Math.min(1, fraction)) * this.duration; this.lastFrame = -1; this.emit(); }
78+
79+ private emit() {
80+ if (!this.source) return;
81+ const i = Math.max(0, Math.min(this.source.nFrames - 1, Math.round(this.simTime * this.source.fps)));
82+ if (i !== this.lastFrame) { this.lastFrame = i; this.onFrame(i, this.simTime); }
83+ }
84+}
src/scene.tsadded+254−0View file
@@ -0,0 +1,254 @@
1+// The 3D scene: the cymbal built from the mesh in the .cymsim file (positions xy/z0, triangles),
2+// deformed by a displacement frame and tinted by a per-node field during playback, with
3+// toggleable overlays (wireframe, boundary loop, nodal normals, hit markers, listener, grid,
4+// axes) and node picking for the probe. cymsim's frame is z-up; everything cymsim-space lives in
5+// a group rotated so that z maps to three.js's y-up.
6+import * as THREE from "three";
7+import { OrbitControls } from "three/addons/controls/OrbitControls.js";
8+import type { CymsimRun } from "./io/cymsimFile.ts";
9+
10+export type ColorMode = "bronze" | "thickness" | "z0";
11+export type OverlayName = "wireframe" | "boundary" | "normals" | "hits" | "listener" | "grid" | "axes";
12+
13+const BRONZE = new THREE.Color(0.72, 0.55, 0.28);
14+const POS_TINT = new THREE.Color(1.0, 0.2, 0.15); // field > 0
15+const NEG_TINT = new THREE.Color(0.15, 0.35, 1.0); // field < 0
16+
17+/** Small viridis-like ramp for scalar surface colors (thickness, rest height). */
18+function ramp(t: number, out: THREE.Color): THREE.Color {
19+ const stops = [[0.27, 0.0, 0.33], [0.21, 0.36, 0.55], [0.13, 0.57, 0.55], [0.37, 0.79, 0.38], [0.99, 0.91, 0.14]];
20+ const x = Math.min(0.9999, Math.max(0, t)) * (stops.length - 1), i = Math.floor(x), f = x - i;
21+ const a = stops[i], b = stops[i + 1];
22+ return out.setRGB(a[0] + f * (b[0] - a[0]), a[1] + f * (b[1] - a[1]), a[2] + f * (b[2] - a[2]));
23+}
24+
25+export class SceneView {
26+ private renderer: THREE.WebGLRenderer;
27+ private scene = new THREE.Scene();
28+ private camera: THREE.PerspectiveCamera;
29+ private controls: OrbitControls;
30+ private world = new THREE.Group(); // cymsim frame (z up)
31+ private cymbal: THREE.Mesh | null = null;
32+ private wireframe: THREE.Mesh | null = null;
33+ private boundary: THREE.LineLoop | null = null;
34+ private normalsLines: THREE.LineSegments | null = null;
35+ private hitMarkers = new THREE.Group();
36+ private listenerMarker: THREE.Object3D | null = null;
37+ private grid: THREE.GridHelper;
38+ private axes = new THREE.AxesHelper(0.3);
39+ private probeMarker: THREE.Mesh;
40+
41+ private run: CymsimRun | null = null;
42+ private mode: ColorMode = "bronze";
43+ private exag = 1;
44+ private w: Float32Array | null = null; // current displacement frame [m]
45+ private field: Float32Array | null = null; // current tint field
46+ private fieldScale = 1;
47+ private probeNode: number | null = null;
48+ private down: { x: number; y: number } | null = null;
49+
50+ private onPick: (node: number) => void;
51+
52+ constructor(canvas: HTMLCanvasElement, onPick: (node: number) => void) {
53+ this.onPick = onPick;
54+ this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
55+ this.renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
56+ this.camera = new THREE.PerspectiveCamera(45, 1, 0.001, 100);
57+ this.camera.position.set(0.35, 0.3, 0.42);
58+ this.controls = new OrbitControls(this.camera, canvas);
59+ this.controls.enableDamping = true;
60+ this.controls.target.set(0, 0.01, 0);
61+ this.scene.background = new THREE.Color(0x14161a);
62+ this.scene.add(new THREE.HemisphereLight(0xdde4f0, 0x30281c, 1.1));
63+ const sun = new THREE.DirectionalLight(0xffffff, 1.6);
64+ sun.position.set(0.5, 1.0, 0.6);
65+ this.scene.add(sun);
66+ this.world.rotation.x = -Math.PI / 2; // cymsim z (up) -> three y (up)
67+ this.scene.add(this.world);
68+ this.world.add(this.hitMarkers);
69+ this.grid = new THREE.GridHelper(2, 20, 0x3a4150, 0x252a33);
70+ this.grid.position.y = -0.3;
71+ this.scene.add(this.grid);
72+ this.axes.visible = false;
73+ this.world.add(this.axes);
74+ this.probeMarker = new THREE.Mesh(new THREE.SphereGeometry(0.004, 12, 8), new THREE.MeshBasicMaterial({ color: 0x40ff90 }));
75+ this.probeMarker.visible = false;
76+ this.world.add(this.probeMarker);
77+
78+ new ResizeObserver(() => this.resize()).observe(canvas.parentElement as HTMLElement);
79+ this.resize();
80+ canvas.addEventListener("pointerdown", (e) => { this.down = { x: e.clientX, y: e.clientY }; });
81+ canvas.addEventListener("pointerup", (e) => {
82+ if (this.down && Math.hypot(e.clientX - this.down.x, e.clientY - this.down.y) < 5) this.pick(e);
83+ this.down = null;
84+ });
85+ this.renderer.setAnimationLoop(() => { this.controls.update(); this.renderer.render(this.scene, this.camera); });
86+ }
87+
88+ private resize() {
89+ const el = this.renderer.domElement.parentElement as HTMLElement;
90+ const w = Math.max(1, el.clientWidth), h = Math.max(1, el.clientHeight);
91+ this.renderer.setSize(w, h, false);
92+ this.camera.aspect = w / h;
93+ this.camera.updateProjectionMatrix();
94+ }
95+
96+ setRun(run: CymsimRun) {
97+ this.run = run; this.w = null; this.field = null; this.probeNode = null; this.probeMarker.visible = false;
98+ for (const o of [this.cymbal, this.wireframe, this.boundary, this.normalsLines, this.listenerMarker]) if (o) this.world.remove(o);
99+ this.hitMarkers.clear();
100+
101+ const m = run.mesh, N = m.nNodes;
102+ const geo = new THREE.BufferGeometry();
103+ geo.setAttribute("position", new THREE.BufferAttribute(new Float32Array(3 * N), 3));
104+ geo.setAttribute("color", new THREE.BufferAttribute(new Float32Array(3 * N), 3));
105+ geo.setIndex(new THREE.BufferAttribute(Uint32Array.from(m.triangles), 1));
106+ this.cymbal = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({
107+ vertexColors: true, metalness: 0.55, roughness: 0.4, side: THREE.DoubleSide,
108+ }));
109+ this.world.add(this.cymbal);
110+ this.wireframe = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ wireframe: true, color: 0x0e1013, transparent: true, opacity: 0.5 }));
111+ this.wireframe.visible = false;
112+ this.world.add(this.wireframe);
113+
114+ const bGeo = new THREE.BufferGeometry();
115+ bGeo.setAttribute("position", new THREE.BufferAttribute(new Float32Array(3 * m.boundary.length), 3));
116+ this.boundary = new THREE.LineLoop(bGeo, new THREE.LineBasicMaterial({ color: 0xd9a441 }));
117+ this.world.add(this.boundary);
118+
119+ const nGeo = new THREE.BufferGeometry(), quills = new Float32Array(6 * N), L = 0.006;
120+ for (let i = 0; i < N; i++) {
121+ const x = m.xy[2 * i], y = m.xy[2 * i + 1], z = m.z0[i];
122+ quills.set([x, y, z, x + L * m.normal[3 * i], y + L * m.normal[3 * i + 1], z + L * m.normal[3 * i + 2]], 6 * i);
123+ }
124+ nGeo.setAttribute("position", new THREE.BufferAttribute(quills, 3));
125+ this.normalsLines = new THREE.LineSegments(nGeo, new THREE.LineBasicMaterial({ color: 0x4fc3f7, transparent: true, opacity: 0.7 }));
126+ this.normalsLines.visible = false;
127+ this.world.add(this.normalsLines);
128+
129+ for (const hit of run.config.hits) {
130+ const x = hit.r * Math.cos(hit.phi), y = hit.r * Math.sin(hit.phi);
131+ const marker = new THREE.Mesh(new THREE.SphereGeometry(0.006, 16, 12),
132+ new THREE.MeshBasicMaterial({ color: hit.exciter.kind === "stick" ? 0xffa030 : 0xff50d0 }));
133+ marker.position.set(x, y, this.surfaceZ(x, y) + 0.012);
134+ this.hitMarkers.add(marker);
135+ }
136+
137+ const lp = run.config.listener.position;
138+ this.listenerMarker = new THREE.Mesh(new THREE.SphereGeometry(0.02, 20, 14), new THREE.MeshStandardMaterial({ color: 0x4fc3f7 }));
139+ this.listenerMarker.position.set(lp[0], lp[1], lp[2]);
140+ this.world.add(this.listenerMarker);
141+
142+ this.applyPositions();
143+ this.applyColors();
144+ const zs = Array.from(m.z0);
145+ this.controls.target.set(0, (Math.min(...zs) + Math.max(...zs)) / 2, 0);
146+ const R = run.config.profile.radius;
147+ this.camera.position.set(1.5 * R, 1.3 * R, 1.9 * R);
148+ this.grid.position.y = -1.3 * R;
149+ }
150+
151+ /** Rest height of the node nearest to (x, y), for placing markers on the surface. */
152+ private surfaceZ(x: number, y: number): number {
153+ const m = this.run!.mesh;
154+ let best = 0, bd = Infinity;
155+ for (let i = 0; i < m.nNodes; i++) {
156+ const d = (m.xy[2 * i] - x) ** 2 + (m.xy[2 * i + 1] - y) ** 2;
157+ if (d < bd) { bd = d; best = i; }
158+ }
159+ return m.z0[best];
160+ }
161+
162+ setColorMode(mode: ColorMode) { this.mode = mode; this.applyColors(); }
163+ setExaggeration(x: number) { this.exag = x; this.applyPositions(); }
164+
165+ /** One playback step: ``w`` deforms the surface (or null for rest), ``field`` tints it. */
166+ setFrame(w: Float32Array | null, field: Float32Array | null, fieldScale: number) {
167+ this.w = w; this.field = field; this.fieldScale = fieldScale > 0 ? fieldScale : 1;
168+ this.applyPositions(); this.applyColors();
169+ }
170+
171+ setOverlay(name: OverlayName, on: boolean) {
172+ const o: Partial<Record<OverlayName, THREE.Object3D | null>> = {
173+ wireframe: this.wireframe, boundary: this.boundary, normals: this.normalsLines,
174+ hits: this.hitMarkers, listener: this.listenerMarker, grid: this.grid, axes: this.axes,
175+ };
176+ const obj = o[name];
177+ if (obj) obj.visible = on;
178+ }
179+
180+ private applyPositions() {
181+ if (!this.run || !this.cymbal) return;
182+ const m = this.run.mesh, N = m.nNodes;
183+ const pos = this.cymbal.geometry.getAttribute("position") as THREE.BufferAttribute;
184+ const a = pos.array as Float32Array;
185+ for (let i = 0; i < N; i++) {
186+ a[3 * i] = m.xy[2 * i]; a[3 * i + 1] = m.xy[2 * i + 1];
187+ a[3 * i + 2] = m.z0[i] + (this.w ? this.exag * this.w[i] : 0);
188+ }
189+ pos.needsUpdate = true;
190+ this.cymbal.geometry.computeVertexNormals();
191+ this.cymbal.geometry.computeBoundingSphere();
192+ if (this.boundary) {
193+ const bp = this.boundary.geometry.getAttribute("position") as THREE.BufferAttribute, b = bp.array as Float32Array;
194+ m.boundary.forEach((n, k) => b.set([a[3 * n], a[3 * n + 1], a[3 * n + 2]], 3 * k));
195+ bp.needsUpdate = true;
196+ }
197+ if (this.probeNode !== null) {
198+ this.probeMarker.position.set(a[3 * this.probeNode], a[3 * this.probeNode + 1], a[3 * this.probeNode + 2]);
199+ }
200+ }
201+
202+ private applyColors() {
203+ if (!this.run || !this.cymbal) return;
204+ const m = this.run.mesh, N = m.nNodes;
205+ const attr = this.cymbal.geometry.getAttribute("color") as THREE.BufferAttribute;
206+ const c = attr.array as Float32Array, base = new THREE.Color(), tmp = new THREE.Color();
207+ let lo = 0, hi = 1;
208+ if (this.mode === "thickness") { lo = Infinity; hi = -Infinity; for (const t of m.thickness) { lo = Math.min(lo, t); hi = Math.max(hi, t); } }
209+ if (this.mode === "z0") { lo = Infinity; hi = -Infinity; for (const z of m.z0) { lo = Math.min(lo, z); hi = Math.max(hi, z); } }
210+ const span = hi > lo ? hi - lo : 1;
211+ for (let i = 0; i < N; i++) {
212+ if (this.mode === "thickness") ramp((m.thickness[i] - lo) / span, base);
213+ else if (this.mode === "z0") ramp((m.z0[i] - lo) / span, base);
214+ else base.copy(BRONZE);
215+ if (this.field) {
216+ const t = Math.max(-1, Math.min(1, this.field[i] / this.fieldScale));
217+ base.lerp(tmp.copy(t > 0 ? POS_TINT : NEG_TINT), Math.abs(t));
218+ }
219+ c[3 * i] = base.r; c[3 * i + 1] = base.g; c[3 * i + 2] = base.b;
220+ }
221+ attr.needsUpdate = true;
222+ }
223+
224+ setListenerPosition(p: [number, number, number]) { this.listenerMarker?.position.set(p[0], p[1], p[2]); }
225+
226+ setProbe(node: number | null) {
227+ this.probeNode = node;
228+ this.probeMarker.visible = node !== null;
229+ if (node !== null && this.cymbal) {
230+ const a = (this.cymbal.geometry.getAttribute("position") as THREE.BufferAttribute).array as Float32Array;
231+ this.probeMarker.position.set(a[3 * node], a[3 * node + 1], a[3 * node + 2]);
232+ }
233+ }
234+
235+ private pick(e: PointerEvent) {
236+ if (!this.cymbal) return;
237+ const rect = this.renderer.domElement.getBoundingClientRect();
238+ const ndc = new THREE.Vector2(((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1);
239+ const ray = new THREE.Raycaster();
240+ ray.setFromCamera(ndc, this.camera);
241+ const hit = ray.intersectObject(this.cymbal, false)[0];
242+ if (!hit || !hit.face) return;
243+ const pos = this.cymbal.geometry.getAttribute("position") as THREE.BufferAttribute;
244+ const v = new THREE.Vector3();
245+ let best = hit.face.a, bd = Infinity;
246+ for (const idx of [hit.face.a, hit.face.b, hit.face.c]) {
247+ v.fromBufferAttribute(pos, idx);
248+ this.cymbal.localToWorld(v);
249+ const d = v.distanceToSquared(hit.point);
250+ if (d < bd) { bd = d; best = idx; }
251+ }
252+ this.onPick(best);
253+ }
254+}
src/style.cssadded+55−0View file
@@ -0,0 +1,55 @@
1+:root {
2+ --bg: #14161a; --panel: #1c1f25; --border: #2c313a; --text: #d8dce3; --muted: #8b93a1;
3+ --accent: #d9a441; --accent2: #4fc3f7;
4+ font-family: system-ui, sans-serif; font-size: 14px;
5+}
6+* { box-sizing: border-box; }
7+[hidden] { display: none !important; }
8+body { margin: 0; background: var(--bg); color: var(--text); }
9+#app { display: grid; grid-template-columns: 270px 1fr 380px; height: 100vh; }
10+
11+#sidebar, #inspector { background: var(--panel); overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 14px; }
12+#sidebar { border-right: 1px solid var(--border); }
13+#inspector { border-left: 1px solid var(--border); }
14+header h1 { margin: 0; font-size: 20px; color: var(--accent); }
15+header .subtitle { color: var(--muted); font-size: 12px; }
16+h2 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin: 0 0 6px; }
17+section { display: flex; flex-direction: column; gap: 8px; }
18+.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
19+.row label { display: flex; gap: 6px; align-items: center; }
20+.checks { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 10px; }
21+.checks label { display: flex; gap: 6px; align-items: center; font-size: 13px; }
22+.muted { color: var(--muted); font-size: 12px; line-height: 1.45; }
23+.mono { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px; }
24+
25+button { background: #262b33; color: var(--text); border: 1px solid var(--border); border-radius: 5px; padding: 5px 12px; cursor: pointer; }
26+button:hover:not(:disabled) { border-color: var(--accent); }
27+button:disabled { opacity: 0.45; cursor: default; }
28+select, input[type="range"] { background: #262b33; color: var(--text); border: 1px solid var(--border); border-radius: 4px; padding: 3px 6px; }
29+input[type="number"] { background: #262b33; color: var(--text); border: 1px solid var(--border); border-radius: 4px; padding: 3px 4px; width: 62px; }
30+
31+#main { display: flex; flex-direction: column; min-width: 0; }
32+#scene-wrap { position: relative; flex: 1; min-height: 0; }
33+#scene { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
34+#scene-empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: var(--muted); pointer-events: none; }
35+#playback { display: flex; gap: 10px; align-items: center; padding: 8px 12px; border-top: 1px solid var(--border); background: var(--panel); }
36+#playback label { display: flex; gap: 6px; align-items: center; }
37+#pb-pos { flex: 1; }
38+#pb-time { min-width: 72px; text-align: right; }
39+
40+#tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); }
41+#tabs button { border: none; border-radius: 5px 5px 0 0; background: none; color: var(--muted); padding: 6px 10px; }
42+#tabs button.active { background: #262b33; color: var(--text); }
43+.tab { display: none; flex-direction: column; gap: 10px; }
44+.tab.active { display: flex; }
45+#config-yaml { white-space: pre-wrap; margin: 0; line-height: 1.4; }
46+canvas.plot { width: 100%; height: 180px; background: #101216; border: 1px solid var(--border); border-radius: 4px; }
47+canvas.plot.tall { height: 240px; }
48+table { border-collapse: collapse; width: 100%; font-size: 12px; }
49+th, td { border-bottom: 1px solid var(--border); padding: 4px 6px; text-align: right; }
50+th:first-child, td:first-child { text-align: left; }
51+th { color: var(--muted); font-weight: 500; }
52+dl.facts { display: grid; grid-template-columns: auto 1fr; gap: 3px 10px; margin: 0; font-size: 12px; }
53+dl.facts dt { color: var(--muted); }
54+dl.facts dd { margin: 0; font-family: ui-monospace, Menlo, Consolas, monospace; }
55+body.dragover::after { content: "drop the .cymsim file"; position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(20, 22, 26, 0.85); font-size: 20px; color: var(--accent); z-index: 10; pointer-events: none; }
src/ui/plots.tsadded+107−0View file
@@ -0,0 +1,107 @@
1+// Static canvas plots for the inspector: a waveform, a spectrogram of a finished signal, and the
2+// energy log. Each draw sizes the backing store to the canvas's CSS box at the device pixel ratio.
3+import { Stft } from "../analysis/spectrogram.ts";
4+
5+function setup(canvas: HTMLCanvasElement): { ctx: CanvasRenderingContext2D; w: number; h: number } {
6+ const dpr = Math.min(2, window.devicePixelRatio || 1);
7+ const w = Math.max(50, canvas.clientWidth), h = Math.max(50, canvas.clientHeight);
8+ canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr);
9+ const ctx = canvas.getContext("2d")!;
10+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
11+ ctx.fillStyle = "#101216"; ctx.fillRect(0, 0, w, h);
12+ ctx.font = "10px system-ui"; ctx.textBaseline = "top";
13+ return { ctx, w, h };
14+}
15+
16+/** Inferno-like colormap for spectrogram power, t in [0, 1]. */
17+function heat(t: number): string {
18+ const s = [[0, 0, 4], [40, 11, 84], [101, 21, 110], [159, 42, 99], [212, 72, 66], [245, 125, 21], [250, 193, 39], [252, 255, 164]];
19+ const x = Math.min(0.9999, Math.max(0, t)) * (s.length - 1), i = Math.floor(x), f = x - i;
20+ const c = (k: number) => Math.round(s[i][k] + f * (s[i + 1][k] - s[i][k]));
21+ return `rgb(${c(0)},${c(1)},${c(2)})`;
22+}
23+
24+export function drawWaveform(canvas: HTMLCanvasElement, samples: ArrayLike<number>, fs: number, unit: string) {
25+ const { ctx, w, h } = setup(canvas);
26+ const n = samples.length;
27+ if (n === 0) return;
28+ let peak = 0;
29+ for (let i = 0; i < n; i++) { const a = Math.abs(samples[i]); if (a > peak) peak = a; }
30+ peak = peak || 1;
31+ const mid = h / 2, cols = Math.floor(w);
32+ ctx.strokeStyle = "#2c313a"; ctx.beginPath(); ctx.moveTo(0, mid); ctx.lineTo(w, mid); ctx.stroke();
33+ ctx.fillStyle = "#4fc3f7";
34+ for (let c = 0; c < cols; c++) {
35+ const i0 = Math.floor((c / cols) * n), i1 = Math.max(i0 + 1, Math.floor(((c + 1) / cols) * n));
36+ let lo = Infinity, hi = -Infinity;
37+ for (let i = i0; i < i1; i++) { const v = samples[i]; if (v < lo) lo = v; if (v > hi) hi = v; }
38+ const y0 = mid - (hi / peak) * (mid - 12), y1 = mid - (lo / peak) * (mid - 12);
39+ ctx.fillRect(c, y0, 1, Math.max(1, y1 - y0));
40+ }
41+ ctx.fillStyle = "#8b93a1";
42+ ctx.fillText(`peak ${peak.toExponential(2)} ${unit}`, 4, 2);
43+ ctx.fillText(`${(n / fs).toFixed(3)} s`, w - 50, 2);
44+}
45+
46+export function drawSpectrogram(canvas: HTMLCanvasElement, samples: Float32Array, fs: number, rangeDb = 80) {
47+ const { ctx, w, h } = setup(canvas);
48+ const nfft = Math.min(1024, 1 << Math.floor(Math.log2(Math.max(64, samples.length / 4))));
49+ const st = new Stft(fs, nfft, Math.max(32, nfft / 4));
50+ st.push(samples);
51+ const C = st.columns.length, B = st.nBins;
52+ if (C === 0) { ctx.fillStyle = "#8b93a1"; ctx.fillText("signal too short for a spectrogram", 4, 2); return; }
53+ let pmax = 0;
54+ for (const col of st.columns) for (const p of col) if (p > pmax) pmax = p;
55+ pmax = pmax || 1;
56+ const axis = 26, plotW = w - 2, plotH = h - axis;
57+ for (let c = 0; c < C; c++) {
58+ const x = (c / C) * plotW, cw = Math.max(1, plotW / C);
59+ const col = st.columns[c];
60+ for (let b = 0; b < B; b++) {
61+ const t = 1 + (10 * Math.log10(col[b] / pmax + 1e-30)) / rangeDb;
62+ if (t <= 0) continue;
63+ const y = plotH - ((b + 1) / B) * plotH;
64+ ctx.fillStyle = heat(t);
65+ ctx.fillRect(x, y, cw, Math.max(1, plotH / B));
66+ }
67+ }
68+ ctx.fillStyle = "#8b93a1";
69+ ctx.fillText("0 Hz", 4, h - 12);
70+ ctx.fillText(`${(fs / 2 / 1000).toFixed(1)} kHz (top) · ${rangeDb} dB range`, 40, h - 12);
71+}
72+
73+const ENERGY_SERIES = [
74+ { col: 1, name: "kinetic", color: "#4fc3f7" }, { col: 2, name: "membrane", color: "#d9a441" },
75+ { col: 3, name: "bending", color: "#ff50d0" }, { col: 4, name: "exciters", color: "#40ff90" },
76+];
77+
78+/** Energy log: energies on a log scale (60 dB below the maximum), max|w|/h in a lower panel. */
79+export function drawEnergy(canvas: HTMLCanvasElement, rows: number[][]) {
80+ const { ctx, w, h } = setup(canvas);
81+ if (rows.length < 2) { ctx.fillStyle = "#8b93a1"; ctx.fillText("no energy log", 4, 2); return; }
82+ const t1 = rows[rows.length - 1][0] || 1;
83+ const topH = Math.round(h * 0.62), botY = topH + 14, botH = h - botY - 4;
84+ let emax = 0, wmax = 0;
85+ for (const r of rows) { for (const s of ENERGY_SERIES) emax = Math.max(emax, r[s.col]); wmax = Math.max(wmax, r[5]); }
86+ emax = emax || 1; wmax = wmax || 1;
87+ const decades = 6;
88+ const xOf = (t: number) => 2 + (t / t1) * (w - 4);
89+ const yOf = (e: number) => 12 + (1 - Math.max(0, 1 + Math.log10(e / emax + 1e-30) / decades)) * (topH - 14);
90+ for (const s of ENERGY_SERIES) {
91+ ctx.strokeStyle = s.color; ctx.beginPath();
92+ rows.forEach((r, i) => { const x = xOf(r[0]), y = yOf(r[s.col]); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); });
93+ ctx.stroke();
94+ }
95+ ctx.strokeStyle = "#e0e4ea"; ctx.beginPath();
96+ rows.forEach((r, i) => {
97+ const x = xOf(r[0]), y = botY + (1 - r[5] / wmax) * botH;
98+ if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
99+ });
100+ ctx.stroke();
101+ ctx.fillStyle = "#8b93a1";
102+ let lx = 4;
103+ for (const s of ENERGY_SERIES) { ctx.fillStyle = s.color; ctx.fillText(s.name, lx, 0); lx += ctx.measureText(s.name).width + 10; }
104+ ctx.fillStyle = "#8b93a1";
105+ ctx.fillText(`top: energy [J], log, ${decades} decades below ${emax.toExponential(1)}`, lx + 6, 0);
106+ ctx.fillText(`max|w|/h (peak ${wmax.toFixed(2)}) · 0 to ${t1.toFixed(2)} s`, 4, botY - 12);
107+}
tools/make-test-file.tsadded+51−0View file
@@ -0,0 +1,51 @@
1+// Generate a real .cymsim file with cymsimweb's CPU loop (coarse fixture config, 0.35 s, with
2+// the acceleration record and displacement frames), for trying the viewer without a browser run.
3+// Requires a cymsimweb checkout beside this repository: node tools/make-test-file.ts out.cymsim
4+import fs from "node:fs";
5+import { buildSetup } from "../../cymsimweb/src/model/setup.ts";
6+import { CpuSimulator } from "../../cymsimweb/src/cpu/simulator.ts";
7+import { AccelerationStore, DisplacementStore } from "../../cymsimweb/src/analysis/acceleration.ts";
8+import { writeCymsim, type CymsimRun, type H5Module } from "../../cymsimweb/src/io/cymsimFile.ts";
9+import { configToYaml } from "../../cymsimweb/src/config/yaml.ts";
10+import { dispEveryFor } from "../../cymsimweb/src/model/simulator.ts";
11+
12+const h5: H5Module = (await import("h5wasm/node")).default as unknown as H5Module;
13+await h5.ready;
14+
15+const fixture = JSON.parse(fs.readFileSync(new URL("../../cymsimweb/tests/fixtures/python_reference.json", import.meta.url), "utf8"));
16+const cfg = structuredClone(fixture.config);
17+cfg.output.record_acceleration = true;
18+cfg.output.displacement_rate = 1000;
19+cfg.numerics.duration = 0.35;
20+
21+const setup = await buildSetup(cfg), N = setup.mesh.n;
22+const nSamples = Math.round(0.35 * setup.fs);
23+const dispEvery = dispEveryFor(setup.fs, cfg.output.displacement_rate);
24+const sim = new CpuSimulator(setup, { recordAcceleration: true, dispEvery });
25+const acc = new AccelerationStore(N, setup.fs), disp = new DisplacementStore(N, setup.fs / dispEvery);
26+const front: number[] = [], back: number[] = [];
27+const energy: number[][] = [];
28+for (let done = 0; done < nSamples; done += 1000) {
29+ const r = await sim.runChunk(Math.min(1000, nSamples - done));
30+ front.push(...r.pressureFront); back.push(...r.pressureBack);
31+ acc.push(r.acceleration!, r.pressureFront.length, r.firstSample);
32+ r.dispFrames.forEach((w, i) => disp.push(w, r.dispSamples[i] / setup.fs));
33+ const d = await sim.diagnostics();
34+ energy.push([d.time, d.kinetic, d.membrane, d.bending, d.exciters, d.maxWoverH]);
35+}
36+const d = await sim.diagnostics(), state = await sim.exportState();
37+const { mesh, shell } = setup;
38+const run: CymsimRun = {
39+ yaml: configToYaml(cfg), config: cfg,
40+ mesh: { xy: mesh.xy, z0: shell.z0Node, thickness: shell.hNode, normal: shell.normalNode, triangles: Int32Array.from(mesh.tri), area: mesh.area, boundary: Int32Array.from(mesh.boundary), nNodes: N, nElements: mesh.e },
41+ listener: { rFront: setup.radiation.rFront, rBack: setup.radiation.rBack },
42+ fs: setup.fs, dt: setup.dt, substeps: setup.substeps, duration: cfg.numerics.duration, nSamples: setup.nSamples, nStepsDone: state.step, complete: true, wallTime: 1.0, decimationSpan: 32,
43+ audioBaffled: Float32Array.from(front), audioUnbaffled: Float32Array.from(front.map((f, i) => f - back[i])),
44+ acceleration: acc, taps: setup.radiation.taps, displacement: { rate: disp.rate, times: disp.times, frames: disp.frames },
45+ energy,
46+ hits: d.hits.map((h, k) => ({ time: setup.patches[k].time, kind: setup.patches[k].hit.exciter.kind, peak_force: h.peakForce, contact_time: h.contactTime, rebound_velocity: h.velocity, energy_in: 9.6, energy_out: 0.5 * setup.patches[k].mass * h.velocity ** 2 })),
47+ state, creator: "cymsimweb test", created: "",
48+};
49+const out = process.argv[2] ?? "test.cymsim";
50+writeCymsim(h5, out, run, "cymsimweb testgen");
51+console.log(`${out}: ${(fs.statSync(out).size / 1048576).toFixed(1)} MB, ${N} nodes, ${nSamples} samples, ${disp.frames.length} displacement frames`);
tools/read-check.tsadded+27−0View file
@@ -0,0 +1,27 @@
1+// Smoke test: load a .cymsim file through the reader and exercise the playback sources.
2+// Usage: node tools/read-check.ts file.cymsim
3+import { readCymsim, type H5Module } from "../src/io/cymsimFile.ts";
4+import { parseConfigYaml } from "../src/config/yaml.ts";
5+import { displacementSource, accelerationSource } from "../src/playback.ts";
6+
7+const h5: H5Module = (await import("h5wasm/node")).default as unknown as H5Module;
8+await h5.ready;
9+const run = readCymsim(h5, process.argv[2], (y) => { const r = parseConfigYaml(y); if (!r.config) throw new Error("bad yaml"); return r.config; });
10+console.log("nodes", run.mesh.nNodes, "elements", run.mesh.nElements, "boundary", run.mesh.boundary.length);
11+console.log("audio", run.audioUnbaffled.length, "at", run.fs, "hits", run.hits.length, "energy rows", run.energy.length);
12+console.log("displacement frames", run.displacement?.frames.length, "rate", run.displacement?.rate);
13+console.log("acceleration samples", run.acceleration?.nSamples);
14+
15+const ds = displacementSource(run.displacement!);
16+const f0 = ds.frame(100);
17+console.log("disp frame 100: scale", f0.scale, "w[50]", f0.w![50], "finite", Number.isFinite(f0.field![50]));
18+
19+const as = accelerationSource(run.acceleration!, run.displacement);
20+const f1 = as.frame(5000);
21+console.log("acc frame 5000: scale", f1.scale.toExponential(3), "field[50]", f1.field![50].toExponential(3), "w present", f1.w !== null);
22+
23+// probe signal for node 50, as main.ts extracts it
24+const a = run.acceleration!, N = a.nodes, sig = new Float32Array(a.nSamples);
25+for (const b of a.blocks) for (let r = 0; r < b.count; r++) sig[b.first + r] = b.data[r * N + 50] * b.scale[r];
26+let peak = 0; for (const v of sig) peak = Math.max(peak, Math.abs(v));
27+console.log("probe node 50: peak |a| =", peak.toExponential(3), "m/s^2");
tools/render-check.tsadded+37−0View file
@@ -0,0 +1,37 @@
1+// Validation: re-render the listener at the file's stored position from the acceleration record
2+// and compare with the stored signals. The front (baffled) path uses the same model and taps as
3+// cymsimweb's built-in listener, so it should agree to ~1e-3 relative rms; the back path takes
4+// the rim from the boundary nodes instead of a 720-point analytic ring, so the unbaffled signal
5+// differs a little more. Usage: node tools/render-check.ts file.cymsim
6+import { readCymsim, type H5Module } from "../src/io/cymsimFile.ts";
7+import { parseConfigYaml } from "../src/config/yaml.ts";
8+import { renderListener } from "../src/listener.ts";
9+
10+// With --gpu, run the WGSL kernel under Dawn (cymsimweb's `webgpu` package, sibling checkout).
11+if (process.argv.includes("--gpu")) {
12+ const { createRequire } = await import("node:module");
13+ const req = createRequire(new URL("../../cymsimweb/tests/", import.meta.url));
14+ const { create, globals } = req("webgpu");
15+ Object.assign(globalThis, globals);
16+ Object.defineProperty(globalThis, "navigator", { value: { gpu: create([]) }, configurable: true });
17+}
18+
19+const h5: H5Module = (await import("h5wasm/node")).default as unknown as H5Module;
20+await h5.ready;
21+const run = readCymsim(h5, process.argv[2], (y) => { const r = parseConfigYaml(y); if (!r.config) throw new Error("bad yaml"); return r.config; });
22+if (!run.acceleration) throw new Error("no acceleration record");
23+
24+const out = await renderListener(run, [...run.config.listener.position]);
25+if (!out) throw new Error("render stopped");
26+console.log("backend:", out.backend);
27+
28+const rel = (a: Float32Array, b: Float32Array) => {
29+ let d = 0, r = 0;
30+ const n = Math.min(a.length, b.length);
31+ for (let i = 0; i < n; i++) { d += (a[i] - b[i]) ** 2; r += b[i] ** 2; }
32+ return Math.sqrt(d / (r || 1));
33+};
34+const unbaffled = Float32Array.from(out.front, (f, i) => f - out.back[i]);
35+console.log("baffled relative rms vs stored:", rel(out.front, run.audioBaffled).toExponential(2));
36+console.log("unbaffled relative rms vs stored:", rel(unbaffled, run.audioUnbaffled).toExponential(2));
37+process.exit(0); // Dawn's threads keep the event loop alive
tsconfig.jsonadded+19−0View file
@@ -0,0 +1,19 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "bundler",
6+ "strict": true,
7+ "noUnusedLocals": true,
8+ "noUnusedParameters": true,
9+ "noFallthroughCasesInSwitch": true,
10+ "isolatedModules": true,
11+ "verbatimModuleSyntax": true,
12+ "skipLibCheck": true,
13+ "allowImportingTsExtensions": true,
14+ "noEmit": true,
15+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
16+ "types": ["vite/client", "@webgpu/types"]
17+ },
18+ "include": ["src"]
19+}
vite.config.tsadded+3−0View file
@@ -0,0 +1,3 @@
1+import { defineConfig } from "vite";
2+
3+export default defineConfig({ base: "./", build: { target: "es2022" } });
↑↓ move↵openescclose