cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playback
45 changed files+4635−0
.github/workflows/deploy.ymladded+33−0View file
@@ -0,0 +1,33 @@
1+name: deploy
2+on:
3+ push:
4+ branches: [main]
5+ workflow_dispatch:
6+
7+permissions:
8+ contents: read
9+ pages: write
10+ id-token: write
11+
12+concurrency:
13+ group: pages
14+ cancel-in-progress: false
15+
16+jobs:
17+ build-deploy:
18+ runs-on: ubuntu-latest
19+ steps:
20+ - uses: actions/checkout@v4
21+ - uses: actions/setup-node@v4
22+ with:
23+ node-version: 24
24+ - run: npm ci
25+ # Mesh, element, shell, CPU-loop, config and analysis tests (no GPU needed).
26+ - run: npm run test:cpu
27+ - run: npm run build
28+ - uses: actions/configure-pages@v5
29+ - uses: actions/upload-pages-artifact@v3
30+ with:
31+ path: dist
32+ - id: deployment
33+ uses: actions/deploy-pages@v4
.gitignoreadded+2−0View file
@@ -0,0 +1,2 @@
1+node_modules/
2+dist/
README.mdadded+40−0View file
@@ -0,0 +1,40 @@
1+# cymsimweb
2+
3+A browser companion of [cymsim](https://vault1.magland.org/proof-of-concept/cymsim), the Python package for simulating struck cymbals. It solves the same model with the same discretization (a Marguerre-von Karman shallow shell on a triangulated disk, constant-strain membrane triangles and Discrete Kirchhoff bending triangles, explicit leapfrog in time, rigid exciters on power-law contact springs, a Rayleigh-integral listener), but the time loop runs on the GPU through WebGPU, and the configuration is edited in the page. The set-up (mesh, element stiffnesses, stability bound, rigid modes, contact patches) is computed in TypeScript in float64 once per run; the sub-steps run in float32 on the GPU, two compute dispatches per sub-step. The method and the differences from the Python package are described in [THEORY.md](THEORY.md).
4+
5+## Running
6+
7+Requires Node 20 or later and, for the GPU time loop, a browser with WebGPU (Chrome or Edge 113+; Firefox and Safari with WebGPU enabled). Without WebGPU the same loop runs on the CPU in JavaScript, which is fine for the coarse preview preset and impractical for the full-resolution ones.
8+
9+ npm install
10+ npm run dev # development server
11+ npm run build # static site in dist/
12+ npm test # CPU tests, then the GPU kernels under Dawn (see below)
13+
14+## Using the app
15+
16+The sidebar holds the preset selector, YAML import and download, the run controls, and three tabs: the YAML editor, a form with the same fields, and the set-up summary of the last run. The YAML schema is that of the Python package (the same file runs in both), and the four presets are its example configurations. Edits in the form rewrite the YAML and edits in the YAML rebuild the form; unknown fields, wrong types and out-of-range values are listed under the editor, and a run does not start while issues remain. Omitted fields take the defaults, which describe an 18 inch medium-thin B20 crash.
17+
18+Start builds the set-up and begins the run. The main area shows the cymbal as a lit height field (drag to orbit, wheel to zoom, the exaggeration slider scales the displacement), the spectrogram of the listener signal growing as chunks of audio arrive, and a playback bar. Play plays the part of the signal that exists so far from the slider position and stops where the signal ends; press again to hear newer material. The signal selector switches between the unbaffled estimate (front minus diffracted back, the default) and the baffled Rayleigh integral; the WAV download writes the selected one at 16 bits, normalized to 0.9 full scale or at 1 Pa = 0.05 full scale. Under the views, the energy table (kinetic, membrane, bending, exciters, and the nonlinearity meter max|w|/h) and the hit table (peak force, contact time, exciter velocity) update about once a second, and the log reports the set-up and the run.
19+
20+URL parameters: `?preset=<id>` selects a preset (`crash_mallet`, `crash_stick`, `hit_train`, `quick_cpu`), `backend=cpu` forces the CPU loop, `autostart=1` starts on load.
21+
22+## Speed
23+
24+At the default resolution (6 mm elements, about 4700 nodes, 54 sub-steps per audio sample) one second of sound is about 2.4 million sub-steps, or 4.8 million compute dispatches. On a discrete GPU this takes tens of seconds; on an integrated GPU several minutes. The chunk size adapts to keep about 0.4 s of wall time per chunk, so the spectrogram and the playback frontier advance a few times per second regardless of the hardware.
25+
26+## Tests
27+
28+`npm run test:cpu` runs the node:test files: the mesher (manifold, quality, and exact agreement with the Python mesher), the DKT element against the Python element, the shell assembly (mass, stability bound, rigid modes and element checks against Python), the CPU time loop against a Python run (dent-free geometry, float64: peak force, contact time, rebound velocity and energies to 1e-4 in the linear case, and the listener pressure to a few percent rms), the YAML layer, and the STFT. `npm run test:gpu` runs the same WGSL kernels under Node with Google Dawn (the `webgpu` package, software backend) and compares them with the CPU loop. It is a plain script rather than a node:test file because Dawn aborts under the test runner. The reference numbers in `tests/fixtures/python_reference.json` were produced by the Python package.
29+
30+## Layout
31+
32+| path | contents |
33+|---|---|
34+| `src/config/` | configuration types, defaults, YAML parsing and validation, presets |
35+| `src/model/` | seeded RNG, cymbal geometry, mesher, DKT element, shell assembly, element forces and energies, exciters, radiation, set-up |
36+| `src/cpu/` | float64 reference time loop, also the fallback without WebGPU |
37+| `src/gpu/` | WGSL kernels and the WebGPU driver |
38+| `src/analysis/`, `src/audio/` | incremental STFT; Web Audio playback and WAV export |
39+| `src/ui/`, `src/run.ts`, `src/main.ts` | editor, form, plate and spectrogram views, readouts, the run loop, wiring |
40+| `tests/` | node:test files, the Dawn check, and the Python fixture |
THEORY.mdadded+69−0View file
@@ -0,0 +1,69 @@
1+# cymsimweb: the method, and how it differs from the Python package
2+
3+The equations, the geometry, the finite elements and the time integration are those of the cymsim Python package and are written out in its [docs/model.md](https://vault1.magland.org/proof-of-concept/cymsim/blob/main/docs/model.md). This document covers what the web application does differently: the arrangement of one sub-step as two GPU dispatches, the projection of the rigid-body modes with a one-step lag, the radiation evaluated at the audio rate, and the buffer layout and ordering argument of the WebGPU kernels. Section 6 records how the two implementations were checked against each other.
4+
5+## 1. What is the same
6+
7+The mesh is produced by the same algorithm (concentric rings, angular stitching, Laplacian smoothing) in float64 and agrees with the Python mesher to round-off. The DKT bending stiffness is the same element, with the derivatives of the rotation shape functions written out instead of obtained by automatic differentiation; the membrane strains use the CST gradients of the nodal displacements and of the nodal heights; the lumped mass, the inflated rotational inertia, the six M-orthonormal rigid modes, the Irons element bound on the stable time step and the number of sub-steps per audio sample are computed as in Python. The contact law, the patches, the release just above the moving surface, the two loss terms and the leapfrog update are the same. The imperfections are drawn from a different generator (xoshiro128** rather than numpy's PCG64), so a given seed produces a different dent pattern than in Python; the statistics are the same, and a dent-free geometry is identical in both.
8+
9+## 2. The internal force without automatic differentiation
10+
11+The Python package obtains the internal force as the gradient of the summed element energies by reverse-mode differentiation. Here the gradient is written out. With the membrane strains
12+
13+$$
14+\varepsilon_{xx} = u_{,x} + \tfrac12 w_{,x}^2 + z_{0,x} w_{,x}, \quad
15+\varepsilon_{yy} = v_{,y} + \tfrac12 w_{,y}^2 + z_{0,y} w_{,y}, \quad
16+\gamma_{xy} = u_{,y} + v_{,x} + w_{,x} w_{,y} + z_{0,x} w_{,y} + z_{0,y} w_{,x},
17+$$
18+
19+the stress resultants $N = C \varepsilon$ and the CST operators $G_x, G_y$ (so that $\partial_x \phi = \sum_i G_{x,i} \phi_i$), the force on node $i$ of an element of area $A$ is
20+
21+$$
22+f_{u_i} = -A (N_{xx} G_{x,i} + N_{xy} G_{y,i}), \qquad
23+f_{v_i} = -A (N_{yy} G_{y,i} + N_{xy} G_{x,i}),
24+$$
25+$$
26+f_{w_i} = -A \big( N_{xx} a_x G_{x,i} + N_{yy} a_y G_{y,i} + N_{xy} (a_y G_{x,i} + a_x G_{y,i}) \big), \qquad a_x = w_{,x} + z_{0,x}, \; a_y = w_{,y} + z_{0,y},
27+$$
28+
29+plus $-K_e U_e$ on the nine bending degrees of freedom, where the linear reference run drops the $w_{,x}, w_{,y}$ terms inside $a_x, a_y$ and inside the strains. The $\sigma_1$ term is added per element as $-2 \sigma_1 (\rho h A)_e G_i^{\mathsf T} (G_e \dot w_e)$. These per-element forces are written to a buffer and summed per node through a precomputed node-to-element adjacency; no atomics are needed and the sum is deterministic. The CPU reference loop (`src/cpu/simulator.ts`) evaluates exactly these expressions in float64, and the shell tests check that the assembled force is the negative gradient of the energy.
30+
31+## 3. One sub-step as two dispatches
32+
33+A sub-step needs three global synchronizations in the Python arrangement: after the element forces, after the velocity update (to compute the six rigid-mode coefficients), and after the projection. On the GPU each synchronization is a dispatch boundary, and with two to five million sub-steps per second of sound the dispatch count matters. We use two dispatches by applying the rigid-mode projection with a one-step lag.
34+
35+**Element pass** (one thread per element, plus one thread per hit): reads $q$ and $v$, writes the fifteen forces of the element; accumulates the element's radiated acceleration (section 4); the hit threads evaluate the contact forces of their patch points from $q$ and the exciter's face height, applying the release at the hit's step.
36+
37+**Node pass** (one thread per node, workgroups of 256): sums the element forces of the node and the contact forces of the patch points it belongs to; removes from $v$ and from $q$ the rigid content that the previous step's update introduced, using the twelve coefficients $c_k = r_k^{\mathsf T} M v$ and $d_k = r_k^{\mathsf T} M q$ that the previous node pass summed; performs the leapfrog update $v' = (v + \Delta t M^{-1} f)/(1 + 2 \sigma_0 \Delta t)$, $q' = q + \Delta t v'$; and contributes $r_k^{\mathsf T} M v'$ and $r_k^{\mathsf T} M q'$ to a per-workgroup partial sum (a shared-memory tree reduction) for the next step. The first thread also advances the exciters, $v_s' = v_s + \Delta t F / m$, $z_s' = z_s + \Delta t v_s'$.
38+
39+Because the projection is applied one step late, the state carries at any time the rigid content of exactly one step's increment: a rigid velocity of order $F \Delta t / M_{shell}$ and a rigid displacement of order $\Delta t$ times that, both removed at the next step. Projecting $q$ as well as $v$ matters: without it the rigid displacement accumulates over a contact (about $10^{-5}$ m over a mallet stroke at coarse resolution, which changed the peak contact force by $7 \times 10^{-4}$); with it the linear reference run agrees with Python to $10^{-5}$ in peak force and rebound velocity and $10^{-4}$ in the energies.
40+
41+The partial sums of two consecutive steps live in two halves of one buffer selected by the parity of the step, so the node pass reads one half while writing the other. The step counter is kept in two slots: the element pass copies slot A to slot B, the node pass reads slot B and writes A + 1 to slot A. Every kernel thus reads a slot that no thread of the same dispatch writes, and no uniform has to be updated per dispatch; a chunk of thousands of sub-steps is encoded as one compute pass with only pipeline switches and dispatches.
42+
43+## 4. Radiation at the audio rate
44+
45+The Python package scatters every element's acceleration into retarded-time buffers at sub-step resolution and decimates the result to the audio rate with a polyphase FIR. A scatter with per-element delays needs atomics or a per-element history far too long to keep at sub-step resolution (about 300 MB at the default resolution), so the web application reverses the order: each element's acceleration is first decimated to the audio rate, then delayed and summed.
46+
47+The decimation is a Blackman-windowed sinc with cutoff $0.45 f_s$ spanning eight audio samples, applied through eight accumulator slots per element: at sub-step phase $k$ of audio sample $j_0$ the element adds its acceleration times the tap $h_w(k)$ to slot $w$, which belongs to audio sample $j_0 + w - 3$; when a sample is complete its slot is written to a per-element history ring (of length a little more than the longest delay in audio samples) and the slots shift. A triangular window over two samples was tried first and was found insufficient in the linear reference run, whose sharp contact puts energy above the audio band. The gather is one dispatch of a single workgroup per audio sample: every element's history is read at $j - \lfloor d_e \rfloor$ and $j - \lfloor d_e \rfloor - 1$ with the fractional delay applied by linear interpolation, for the front path and for the path over the rim, and the two sums are reduced in shared memory and written to the chunk's output buffer. Linear interpolation of a fractional delay attenuates the top of the band slightly (up to a few dB at 20 kHz for a half-sample fraction, spread over the elements' different fractions), which we accept for an interactive tool.
48+
49+The acceleration that radiates is the difference of consecutive *projected* transverse velocities. With the lagged projection, the difference of raw velocities would contain the full rigid acceleration $f_{rigid}/M$ at every step, since each step adds it and the next removes it; a plate that appears to accelerate as a whole radiates like a monopole, which is exactly the component that the front and back terms of an unbaffled plate are meant to cancel. This error was invisible in the nonlinear run and a factor 4.6 in the rms pressure of the linear reference run, where the cancellation is 25:1. With the projected differences the two implementations agree to 0.1 percent in rms in both runs.
50+
51+## 5. Buffers and precision
52+
53+The GPU keeps eleven storage buffers, at most eight in any one pipeline (the default WebGPU limit): per-element data (CST operators, area, slopes, membrane stiffness, damping factor and the 81 entries of $K_e$, 96 floats per element, with the decimation taps at the tail), element node indices, the state (q, v, the radiated nodal acceleration and the previous projected velocity), the element forces, per-node data (inverse mass, mass and the six rigid modes, 40 floats per node), the adjacency in CSR form, the hits (static patch data and the exciter state, one float and one integer buffer), the synchronization buffer (step counters and partial sums), the radiation state (accumulator slots and history) and the radiation coefficients. Everything on the GPU is float32. The set-up is float64 and is packed once. Diagnostics (energies, exciter state) are read back between chunks; the plate view reads $w$ from the state buffer directly in its vertex shader without a readback.
54+
55+Float32 is adequate for the leapfrog at these step sizes: the displacement increment per step is about $10^{-3}$ of the displacement, so the update loses a few bits per step in $q$ without a visible effect on the energy balance, and the run is chaotic anyway once max|w|/h exceeds a few units, so no float32 trajectory tracks a float64 one for long. What is checked is the deterministic part (section 6).
56+
57+## 6. Verification
58+
59+The tests compare three implementations pairwise on a coarse dent-free 18 inch profile (30 mm elements, 227 nodes, ten sub-steps per sample) with a 9.6 J mallet stroke, in the nonlinear and in the linear (reference) setting.
60+
61+TypeScript set-up against Python: the mesh is identical to round-off; the DKT stiffness agrees with the autodiff element to $10^{-9}$; mass, stability bound, limiting element, element stiffnesses, lumped masses and rigid modes agree to $10^{-8}$ or better; the contact patch (nodes, weights, offsets, stiffness, free contact time) agrees to $10^{-6}$.
62+
63+CPU loop against Python (float64 both): in the linear run the peak force and rebound velocity agree to $10^{-5}$, the energies and max|w|/h to $10^{-4}$, the rms of the unbaffled pressure to 0.1 percent; in the nonlinear run, where the trajectory is sensitive, the same quantities agree to $10^{-3}$ and the pressure to 0.2 percent rms with the peaks at the same sample.
64+
65+GPU kernels against the CPU loop (float32 against float64, under Dawn): peak force to $10^{-4}$, contact time to the step, energies to $10^{-3}$ and the pressure to $10^{-3}$ relative rms in the linear run; $10^{-3}$, $10^{-2}$ and $10^{-2}$ in a short nonlinear run.
66+
67+## 7. Limitations
68+
69+The limitations of the model are those listed in the Python documentation. Specific to the web application: the imperfections of a seed differ from Python's; the decimation and the fractional delays are approximations described in section 4; the plate view shows $w$ scaled by a user-chosen factor and is not a measurement; and the CPU fallback is a hundred times slower than a discrete GPU. The set-up runs on the main thread and freezes the page for a second or two at full resolution.
index.htmladded+75−0View file
@@ -0,0 +1,75 @@
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>cymsimweb</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>cymsimweb</h1>
14+ <span class="subtitle">struck-cymbal simulation on WebGPU</span>
15+ </header>
16+ <section class="run">
17+ <div class="row">
18+ <label>Preset <select id="preset"></select></label>
19+ </div>
20+ <div class="row buttons">
21+ <button id="import">Import YAML</button>
22+ <input id="file" type="file" accept=".yaml,.yml,text/yaml" hidden />
23+ <button id="download-yaml">Download YAML</button>
24+ </div>
25+ <div class="row buttons">
26+ <button id="start" class="primary">Start</button>
27+ <button id="pause" disabled>Pause</button>
28+ <button id="stop" disabled>Stop</button>
29+ </div>
30+ <div class="row"><progress id="progress" value="0" max="1"></progress><span id="progress-text"></span></div>
31+ <div id="backend" class="muted"></div>
32+ </section>
33+ <div class="tabs">
34+ <button data-tab="yaml" class="active">YAML</button>
35+ <button data-tab="form">Form</button>
36+ <button data-tab="summary">Set-up</button>
37+ </div>
38+ <div id="tab-yaml" class="tab"><div id="editor"></div></div>
39+ <div id="tab-form" class="tab hidden"><div id="form"></div></div>
40+ <div id="tab-summary" class="tab hidden"><div id="summary"></div></div>
41+ <ul id="issues"></ul>
42+ </aside>
43+ <main>
44+ <section id="plate-section">
45+ <div class="toolbar">
46+ <span>Plate</span>
47+ <label>exaggeration <input id="exag" type="range" min="0" max="3" step="0.1" value="1.3" /><span id="exag-text">x20</span></label>
48+ <button id="reset-view">Reset view</button>
49+ <span id="plate-info" class="muted"></span>
50+ </div>
51+ <canvas id="plate"></canvas>
52+ </section>
53+ <section id="spec-section">
54+ <div class="toolbar">
55+ <span>Spectrogram</span>
56+ <label>signal <select id="signal"><option value="unbaffled">unbaffled</option><option value="rayleigh">Rayleigh (baffled)</option></select></label>
57+ <span id="spec-info" class="muted"></span>
58+ </div>
59+ <canvas id="spectrogram"></canvas>
60+ </section>
61+ <section id="playback">
62+ <button id="play" disabled>Play</button>
63+ <button id="stop-audio" disabled>Stop</button>
64+ <input id="position" type="range" min="0" max="1" step="0.001" value="0" />
65+ <span id="time">0.000 s</span>
66+ <label>gain <input id="gain" type="range" min="-40" max="0" step="1" value="-12" /><span id="gain-text">-12 dB</span></label>
67+ <label><input id="normalize" type="checkbox" checked /> normalize</label>
68+ <button id="download-wav" disabled>Download WAV</button>
69+ </section>
70+ <section id="readouts"></section>
71+ </main>
72+ </div>
73+ <script type="module" src="./src/main.ts"></script>
74+ </body>
75+</html>
package-lock.jsonadded+1392−0View file
@@ -0,0 +1,1392 @@
1+{
2+ "name": "cymsimweb",
3+ "version": "0.1.0",
4+ "lockfileVersion": 3,
5+ "requires": true,
6+ "packages": {
7+ "": {
8+ "name": "cymsimweb",
9+ "version": "0.1.0",
10+ "dependencies": {
11+ "@codemirror/commands": "^6.11.0",
12+ "@codemirror/language": "^6.12.4",
13+ "@codemirror/legacy-modes": "^6.5.4",
14+ "@codemirror/state": "^6.7.4",
15+ "@codemirror/view": "^6.43.11",
16+ "@lezer/highlight": "^1.2.3",
17+ "js-yaml": "^4.1.0"
18+ },
19+ "devDependencies": {
20+ "@types/js-yaml": "^4.0.9",
21+ "@types/node": "^24.0.0",
22+ "@webgpu/types": "^0.1.60",
23+ "typescript": "^5.6.0",
24+ "vite": "^6.0.0",
25+ "webgpu": "^0.4.0"
26+ }
27+ },
28+ "node_modules/@codemirror/commands": {
29+ "version": "6.11.1",
30+ "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz",
31+ "integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==",
32+ "license": "MIT",
33+ "dependencies": {
34+ "@codemirror/language": "^6.0.0",
35+ "@codemirror/state": "^6.7.0",
36+ "@codemirror/view": "^6.27.0",
37+ "@lezer/common": "^1.1.0"
38+ }
39+ },
40+ "node_modules/@codemirror/language": {
41+ "version": "6.12.4",
42+ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
43+ "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
44+ "license": "MIT",
45+ "dependencies": {
46+ "@codemirror/state": "^6.0.0",
47+ "@codemirror/view": "^6.23.0",
48+ "@lezer/common": "^1.5.0",
49+ "@lezer/highlight": "^1.0.0",
50+ "@lezer/lr": "^1.0.0",
51+ "style-mod": "^4.0.0"
52+ }
53+ },
54+ "node_modules/@codemirror/legacy-modes": {
55+ "version": "6.5.4",
56+ "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.4.tgz",
57+ "integrity": "sha512-/cZr6qZyl08iYNLGsJ862CXXNI51LryRFRE40ejgoIjXZz0C1rGkD3/Ek5jM/8w1ceRjqtt4qx/KLMh4zBTgew==",
58+ "license": "MIT",
59+ "dependencies": {
60+ "@codemirror/language": "^6.0.0"
61+ }
62+ },
63+ "node_modules/@codemirror/state": {
64+ "version": "6.7.6",
65+ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz",
66+ "integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==",
67+ "license": "MIT",
68+ "dependencies": {
69+ "@marijn/find-cluster-break": "^1.0.0"
70+ }
71+ },
72+ "node_modules/@codemirror/view": {
73+ "version": "6.43.13",
74+ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.13.tgz",
75+ "integrity": "sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==",
76+ "license": "MIT",
77+ "dependencies": {
78+ "@codemirror/state": "^6.7.0",
79+ "crelt": "^1.0.6",
80+ "style-mod": "^4.1.0",
81+ "w3c-keyname": "^2.2.4"
82+ }
83+ },
84+ "node_modules/@esbuild/aix-ppc64": {
85+ "version": "0.25.12",
86+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
87+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
88+ "cpu": [
89+ "ppc64"
90+ ],
91+ "dev": true,
92+ "license": "MIT",
93+ "optional": true,
94+ "os": [
95+ "aix"
96+ ],
97+ "engines": {
98+ "node": ">=18"
99+ }
100+ },
101+ "node_modules/@esbuild/android-arm": {
102+ "version": "0.25.12",
103+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
104+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
105+ "cpu": [
106+ "arm"
107+ ],
108+ "dev": true,
109+ "license": "MIT",
110+ "optional": true,
111+ "os": [
112+ "android"
113+ ],
114+ "engines": {
115+ "node": ">=18"
116+ }
117+ },
118+ "node_modules/@esbuild/android-arm64": {
119+ "version": "0.25.12",
120+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
121+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
122+ "cpu": [
123+ "arm64"
124+ ],
125+ "dev": true,
126+ "license": "MIT",
127+ "optional": true,
128+ "os": [
129+ "android"
130+ ],
131+ "engines": {
132+ "node": ">=18"
133+ }
134+ },
135+ "node_modules/@esbuild/android-x64": {
136+ "version": "0.25.12",
137+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
138+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
139+ "cpu": [
140+ "x64"
141+ ],
142+ "dev": true,
143+ "license": "MIT",
144+ "optional": true,
145+ "os": [
146+ "android"
147+ ],
148+ "engines": {
149+ "node": ">=18"
150+ }
151+ },
152+ "node_modules/@esbuild/darwin-arm64": {
153+ "version": "0.25.12",
154+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
155+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
156+ "cpu": [
157+ "arm64"
158+ ],
159+ "dev": true,
160+ "license": "MIT",
161+ "optional": true,
162+ "os": [
163+ "darwin"
164+ ],
165+ "engines": {
166+ "node": ">=18"
167+ }
168+ },
169+ "node_modules/@esbuild/darwin-x64": {
170+ "version": "0.25.12",
171+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
172+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
173+ "cpu": [
174+ "x64"
175+ ],
176+ "dev": true,
177+ "license": "MIT",
178+ "optional": true,
179+ "os": [
180+ "darwin"
181+ ],
182+ "engines": {
183+ "node": ">=18"
184+ }
185+ },
186+ "node_modules/@esbuild/freebsd-arm64": {
187+ "version": "0.25.12",
188+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
189+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
190+ "cpu": [
191+ "arm64"
192+ ],
193+ "dev": true,
194+ "license": "MIT",
195+ "optional": true,
196+ "os": [
197+ "freebsd"
198+ ],
199+ "engines": {
200+ "node": ">=18"
201+ }
202+ },
203+ "node_modules/@esbuild/freebsd-x64": {
204+ "version": "0.25.12",
205+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
206+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
207+ "cpu": [
208+ "x64"
209+ ],
210+ "dev": true,
211+ "license": "MIT",
212+ "optional": true,
213+ "os": [
214+ "freebsd"
215+ ],
216+ "engines": {
217+ "node": ">=18"
218+ }
219+ },
220+ "node_modules/@esbuild/linux-arm": {
221+ "version": "0.25.12",
222+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
223+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
224+ "cpu": [
225+ "arm"
226+ ],
227+ "dev": true,
228+ "license": "MIT",
229+ "optional": true,
230+ "os": [
231+ "linux"
232+ ],
233+ "engines": {
234+ "node": ">=18"
235+ }
236+ },
237+ "node_modules/@esbuild/linux-arm64": {
238+ "version": "0.25.12",
239+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
240+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
241+ "cpu": [
242+ "arm64"
243+ ],
244+ "dev": true,
245+ "license": "MIT",
246+ "optional": true,
247+ "os": [
248+ "linux"
249+ ],
250+ "engines": {
251+ "node": ">=18"
252+ }
253+ },
254+ "node_modules/@esbuild/linux-ia32": {
255+ "version": "0.25.12",
256+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
257+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
258+ "cpu": [
259+ "ia32"
260+ ],
261+ "dev": true,
262+ "license": "MIT",
263+ "optional": true,
264+ "os": [
265+ "linux"
266+ ],
267+ "engines": {
268+ "node": ">=18"
269+ }
270+ },
271+ "node_modules/@esbuild/linux-loong64": {
272+ "version": "0.25.12",
273+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
274+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
275+ "cpu": [
276+ "loong64"
277+ ],
278+ "dev": true,
279+ "license": "MIT",
280+ "optional": true,
281+ "os": [
282+ "linux"
283+ ],
284+ "engines": {
285+ "node": ">=18"
286+ }
287+ },
288+ "node_modules/@esbuild/linux-mips64el": {
289+ "version": "0.25.12",
290+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
291+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
292+ "cpu": [
293+ "mips64el"
294+ ],
295+ "dev": true,
296+ "license": "MIT",
297+ "optional": true,
298+ "os": [
299+ "linux"
300+ ],
301+ "engines": {
302+ "node": ">=18"
303+ }
304+ },
305+ "node_modules/@esbuild/linux-ppc64": {
306+ "version": "0.25.12",
307+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
308+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
309+ "cpu": [
310+ "ppc64"
311+ ],
312+ "dev": true,
313+ "license": "MIT",
314+ "optional": true,
315+ "os": [
316+ "linux"
317+ ],
318+ "engines": {
319+ "node": ">=18"
320+ }
321+ },
322+ "node_modules/@esbuild/linux-riscv64": {
323+ "version": "0.25.12",
324+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
325+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
326+ "cpu": [
327+ "riscv64"
328+ ],
329+ "dev": true,
330+ "license": "MIT",
331+ "optional": true,
332+ "os": [
333+ "linux"
334+ ],
335+ "engines": {
336+ "node": ">=18"
337+ }
338+ },
339+ "node_modules/@esbuild/linux-s390x": {
340+ "version": "0.25.12",
341+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
342+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
343+ "cpu": [
344+ "s390x"
345+ ],
346+ "dev": true,
347+ "license": "MIT",
348+ "optional": true,
349+ "os": [
350+ "linux"
351+ ],
352+ "engines": {
353+ "node": ">=18"
354+ }
355+ },
356+ "node_modules/@esbuild/linux-x64": {
357+ "version": "0.25.12",
358+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
359+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
360+ "cpu": [
361+ "x64"
362+ ],
363+ "dev": true,
364+ "license": "MIT",
365+ "optional": true,
366+ "os": [
367+ "linux"
368+ ],
369+ "engines": {
370+ "node": ">=18"
371+ }
372+ },
373+ "node_modules/@esbuild/netbsd-arm64": {
374+ "version": "0.25.12",
375+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
376+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
377+ "cpu": [
378+ "arm64"
379+ ],
380+ "dev": true,
381+ "license": "MIT",
382+ "optional": true,
383+ "os": [
384+ "netbsd"
385+ ],
386+ "engines": {
387+ "node": ">=18"
388+ }
389+ },
390+ "node_modules/@esbuild/netbsd-x64": {
391+ "version": "0.25.12",
392+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
393+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
394+ "cpu": [
395+ "x64"
396+ ],
397+ "dev": true,
398+ "license": "MIT",
399+ "optional": true,
400+ "os": [
401+ "netbsd"
402+ ],
403+ "engines": {
404+ "node": ">=18"
405+ }
406+ },
407+ "node_modules/@esbuild/openbsd-arm64": {
408+ "version": "0.25.12",
409+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
410+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
411+ "cpu": [
412+ "arm64"
413+ ],
414+ "dev": true,
415+ "license": "MIT",
416+ "optional": true,
417+ "os": [
418+ "openbsd"
419+ ],
420+ "engines": {
421+ "node": ">=18"
422+ }
423+ },
424+ "node_modules/@esbuild/openbsd-x64": {
425+ "version": "0.25.12",
426+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
427+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
428+ "cpu": [
429+ "x64"
430+ ],
431+ "dev": true,
432+ "license": "MIT",
433+ "optional": true,
434+ "os": [
435+ "openbsd"
436+ ],
437+ "engines": {
438+ "node": ">=18"
439+ }
440+ },
441+ "node_modules/@esbuild/openharmony-arm64": {
442+ "version": "0.25.12",
443+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
444+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
445+ "cpu": [
446+ "arm64"
447+ ],
448+ "dev": true,
449+ "license": "MIT",
450+ "optional": true,
451+ "os": [
452+ "openharmony"
453+ ],
454+ "engines": {
455+ "node": ">=18"
456+ }
457+ },
458+ "node_modules/@esbuild/sunos-x64": {
459+ "version": "0.25.12",
460+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
461+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
462+ "cpu": [
463+ "x64"
464+ ],
465+ "dev": true,
466+ "license": "MIT",
467+ "optional": true,
468+ "os": [
469+ "sunos"
470+ ],
471+ "engines": {
472+ "node": ">=18"
473+ }
474+ },
475+ "node_modules/@esbuild/win32-arm64": {
476+ "version": "0.25.12",
477+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
478+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
479+ "cpu": [
480+ "arm64"
481+ ],
482+ "dev": true,
483+ "license": "MIT",
484+ "optional": true,
485+ "os": [
486+ "win32"
487+ ],
488+ "engines": {
489+ "node": ">=18"
490+ }
491+ },
492+ "node_modules/@esbuild/win32-ia32": {
493+ "version": "0.25.12",
494+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
495+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
496+ "cpu": [
497+ "ia32"
498+ ],
499+ "dev": true,
500+ "license": "MIT",
501+ "optional": true,
502+ "os": [
503+ "win32"
504+ ],
505+ "engines": {
506+ "node": ">=18"
507+ }
508+ },
509+ "node_modules/@esbuild/win32-x64": {
510+ "version": "0.25.12",
511+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
512+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
513+ "cpu": [
514+ "x64"
515+ ],
516+ "dev": true,
517+ "license": "MIT",
518+ "optional": true,
519+ "os": [
520+ "win32"
521+ ],
522+ "engines": {
523+ "node": ">=18"
524+ }
525+ },
526+ "node_modules/@lezer/common": {
527+ "version": "1.5.2",
528+ "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
529+ "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
530+ "license": "MIT"
531+ },
532+ "node_modules/@lezer/highlight": {
533+ "version": "1.2.3",
534+ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
535+ "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
536+ "license": "MIT",
537+ "dependencies": {
538+ "@lezer/common": "^1.3.0"
539+ }
540+ },
541+ "node_modules/@lezer/lr": {
542+ "version": "1.4.10",
543+ "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
544+ "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
545+ "license": "MIT",
546+ "dependencies": {
547+ "@lezer/common": "^1.0.0"
548+ }
549+ },
550+ "node_modules/@marijn/find-cluster-break": {
551+ "version": "1.0.4",
552+ "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz",
553+ "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
554+ "license": "MIT"
555+ },
556+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
557+ "version": "1.5.1",
558+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
559+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
560+ "cpu": [
561+ "x64"
562+ ],
563+ "dev": true,
564+ "libc": [
565+ "glibc"
566+ ],
567+ "license": "MIT",
568+ "optional": true,
569+ "os": [
570+ "linux"
571+ ],
572+ "engines": {
573+ "node": "^22.20 || ^24.12 || >=25"
574+ }
575+ },
576+ "node_modules/@rollup/rollup-android-arm-eabi": {
577+ "version": "4.63.4",
578+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.4.tgz",
579+ "integrity": "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==",
580+ "cpu": [
581+ "arm"
582+ ],
583+ "dev": true,
584+ "license": "MIT",
585+ "optional": true,
586+ "os": [
587+ "android"
588+ ]
589+ },
590+ "node_modules/@rollup/rollup-android-arm64": {
591+ "version": "4.63.4",
592+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.4.tgz",
593+ "integrity": "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==",
594+ "cpu": [
595+ "arm64"
596+ ],
597+ "dev": true,
598+ "license": "MIT",
599+ "optional": true,
600+ "os": [
601+ "android"
602+ ]
603+ },
604+ "node_modules/@rollup/rollup-darwin-arm64": {
605+ "version": "4.63.4",
606+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.4.tgz",
607+ "integrity": "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==",
608+ "cpu": [
609+ "arm64"
610+ ],
611+ "dev": true,
612+ "license": "MIT",
613+ "optional": true,
614+ "os": [
615+ "darwin"
616+ ]
617+ },
618+ "node_modules/@rollup/rollup-darwin-x64": {
619+ "version": "4.63.4",
620+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.4.tgz",
621+ "integrity": "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==",
622+ "cpu": [
623+ "x64"
624+ ],
625+ "dev": true,
626+ "license": "MIT",
627+ "optional": true,
628+ "os": [
629+ "darwin"
630+ ]
631+ },
632+ "node_modules/@rollup/rollup-freebsd-arm64": {
633+ "version": "4.63.4",
634+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.4.tgz",
635+ "integrity": "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==",
636+ "cpu": [
637+ "arm64"
638+ ],
639+ "dev": true,
640+ "license": "MIT",
641+ "optional": true,
642+ "os": [
643+ "freebsd"
644+ ]
645+ },
646+ "node_modules/@rollup/rollup-freebsd-x64": {
647+ "version": "4.63.4",
648+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.4.tgz",
649+ "integrity": "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==",
650+ "cpu": [
651+ "x64"
652+ ],
653+ "dev": true,
654+ "license": "MIT",
655+ "optional": true,
656+ "os": [
657+ "freebsd"
658+ ]
659+ },
660+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
661+ "version": "4.63.4",
662+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.4.tgz",
663+ "integrity": "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==",
664+ "cpu": [
665+ "arm"
666+ ],
667+ "dev": true,
668+ "libc": [
669+ "glibc"
670+ ],
671+ "license": "MIT",
672+ "optional": true,
673+ "os": [
674+ "linux"
675+ ]
676+ },
677+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
678+ "version": "4.63.4",
679+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.4.tgz",
680+ "integrity": "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==",
681+ "cpu": [
682+ "arm"
683+ ],
684+ "dev": true,
685+ "libc": [
686+ "musl"
687+ ],
688+ "license": "MIT",
689+ "optional": true,
690+ "os": [
691+ "linux"
692+ ]
693+ },
694+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
695+ "version": "4.63.4",
696+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.4.tgz",
697+ "integrity": "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==",
698+ "cpu": [
699+ "arm64"
700+ ],
701+ "dev": true,
702+ "libc": [
703+ "glibc"
704+ ],
705+ "license": "MIT",
706+ "optional": true,
707+ "os": [
708+ "linux"
709+ ]
710+ },
711+ "node_modules/@rollup/rollup-linux-arm64-musl": {
712+ "version": "4.63.4",
713+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.4.tgz",
714+ "integrity": "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==",
715+ "cpu": [
716+ "arm64"
717+ ],
718+ "dev": true,
719+ "libc": [
720+ "musl"
721+ ],
722+ "license": "MIT",
723+ "optional": true,
724+ "os": [
725+ "linux"
726+ ]
727+ },
728+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
729+ "version": "4.63.4",
730+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.4.tgz",
731+ "integrity": "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==",
732+ "cpu": [
733+ "loong64"
734+ ],
735+ "dev": true,
736+ "libc": [
737+ "glibc"
738+ ],
739+ "license": "MIT",
740+ "optional": true,
741+ "os": [
742+ "linux"
743+ ]
744+ },
745+ "node_modules/@rollup/rollup-linux-loong64-musl": {
746+ "version": "4.63.4",
747+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.4.tgz",
748+ "integrity": "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==",
749+ "cpu": [
750+ "loong64"
751+ ],
752+ "dev": true,
753+ "libc": [
754+ "musl"
755+ ],
756+ "license": "MIT",
757+ "optional": true,
758+ "os": [
759+ "linux"
760+ ]
761+ },
762+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
763+ "version": "4.63.4",
764+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.4.tgz",
765+ "integrity": "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==",
766+ "cpu": [
767+ "ppc64"
768+ ],
769+ "dev": true,
770+ "libc": [
771+ "glibc"
772+ ],
773+ "license": "MIT",
774+ "optional": true,
775+ "os": [
776+ "linux"
777+ ]
778+ },
779+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
780+ "version": "4.63.4",
781+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.4.tgz",
782+ "integrity": "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==",
783+ "cpu": [
784+ "ppc64"
785+ ],
786+ "dev": true,
787+ "libc": [
788+ "musl"
789+ ],
790+ "license": "MIT",
791+ "optional": true,
792+ "os": [
793+ "linux"
794+ ]
795+ },
796+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
797+ "version": "4.63.4",
798+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.4.tgz",
799+ "integrity": "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==",
800+ "cpu": [
801+ "riscv64"
802+ ],
803+ "dev": true,
804+ "libc": [
805+ "glibc"
806+ ],
807+ "license": "MIT",
808+ "optional": true,
809+ "os": [
810+ "linux"
811+ ]
812+ },
813+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
814+ "version": "4.63.4",
815+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.4.tgz",
816+ "integrity": "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==",
817+ "cpu": [
818+ "riscv64"
819+ ],
820+ "dev": true,
821+ "libc": [
822+ "musl"
823+ ],
824+ "license": "MIT",
825+ "optional": true,
826+ "os": [
827+ "linux"
828+ ]
829+ },
830+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
831+ "version": "4.63.4",
832+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.4.tgz",
833+ "integrity": "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==",
834+ "cpu": [
835+ "s390x"
836+ ],
837+ "dev": true,
838+ "libc": [
839+ "glibc"
840+ ],
841+ "license": "MIT",
842+ "optional": true,
843+ "os": [
844+ "linux"
845+ ]
846+ },
847+ "node_modules/@rollup/rollup-linux-x64-gnu": {
848+ "version": "4.63.4",
849+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.4.tgz",
850+ "integrity": "sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==",
851+ "cpu": [
852+ "x64"
853+ ],
854+ "dev": true,
855+ "libc": [
856+ "glibc"
857+ ],
858+ "license": "MIT",
859+ "optional": true,
860+ "os": [
861+ "linux"
862+ ]
863+ },
864+ "node_modules/@rollup/rollup-linux-x64-musl": {
865+ "version": "4.63.4",
866+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.4.tgz",
867+ "integrity": "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==",
868+ "cpu": [
869+ "x64"
870+ ],
871+ "dev": true,
872+ "libc": [
873+ "musl"
874+ ],
875+ "license": "MIT",
876+ "optional": true,
877+ "os": [
878+ "linux"
879+ ]
880+ },
881+ "node_modules/@rollup/rollup-openbsd-x64": {
882+ "version": "4.63.4",
883+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.4.tgz",
884+ "integrity": "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==",
885+ "cpu": [
886+ "x64"
887+ ],
888+ "dev": true,
889+ "license": "MIT",
890+ "optional": true,
891+ "os": [
892+ "openbsd"
893+ ]
894+ },
895+ "node_modules/@rollup/rollup-openharmony-arm64": {
896+ "version": "4.63.4",
897+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.4.tgz",
898+ "integrity": "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==",
899+ "cpu": [
900+ "arm64"
901+ ],
902+ "dev": true,
903+ "license": "MIT",
904+ "optional": true,
905+ "os": [
906+ "openharmony"
907+ ]
908+ },
909+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
910+ "version": "4.63.4",
911+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.4.tgz",
912+ "integrity": "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==",
913+ "cpu": [
914+ "arm64"
915+ ],
916+ "dev": true,
917+ "license": "MIT",
918+ "optional": true,
919+ "os": [
920+ "win32"
921+ ]
922+ },
923+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
924+ "version": "4.63.4",
925+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.4.tgz",
926+ "integrity": "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==",
927+ "cpu": [
928+ "ia32"
929+ ],
930+ "dev": true,
931+ "license": "MIT",
932+ "optional": true,
933+ "os": [
934+ "win32"
935+ ]
936+ },
937+ "node_modules/@rollup/rollup-win32-x64-gnu": {
938+ "version": "4.63.4",
939+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.4.tgz",
940+ "integrity": "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==",
941+ "cpu": [
942+ "x64"
943+ ],
944+ "dev": true,
945+ "license": "MIT",
946+ "optional": true,
947+ "os": [
948+ "win32"
949+ ]
950+ },
951+ "node_modules/@rollup/rollup-win32-x64-msvc": {
952+ "version": "4.63.4",
953+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.4.tgz",
954+ "integrity": "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==",
955+ "cpu": [
956+ "x64"
957+ ],
958+ "dev": true,
959+ "license": "MIT",
960+ "optional": true,
961+ "os": [
962+ "win32"
963+ ]
964+ },
965+ "node_modules/@types/estree": {
966+ "version": "1.0.9",
967+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
968+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
969+ "dev": true,
970+ "license": "MIT"
971+ },
972+ "node_modules/@types/js-yaml": {
973+ "version": "4.0.9",
974+ "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
975+ "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
976+ "dev": true,
977+ "license": "MIT"
978+ },
979+ "node_modules/@types/node": {
980+ "version": "24.13.6",
981+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.6.tgz",
982+ "integrity": "sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==",
983+ "dev": true,
984+ "license": "MIT",
985+ "dependencies": {
986+ "undici-types": "~7.18.0"
987+ }
988+ },
989+ "node_modules/@webgpu/types": {
990+ "version": "0.1.74",
991+ "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.74.tgz",
992+ "integrity": "sha512-lgiI4hbuLcI9unnm2cL/tvCaQU45dp0xcLWJh5uB/9MBGvIA4F8XIk7nSiBeg+K4xWGYwgZKn80LmERI5CxoTA==",
993+ "dev": true,
994+ "license": "BSD-3-Clause"
995+ },
996+ "node_modules/argparse": {
997+ "version": "2.0.1",
998+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
999+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
1000+ "license": "Python-2.0"
1001+ },
1002+ "node_modules/crelt": {
1003+ "version": "1.0.7",
1004+ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
1005+ "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
1006+ "license": "MIT"
1007+ },
1008+ "node_modules/debug": {
1009+ "version": "4.4.3",
1010+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
1011+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
1012+ "dev": true,
1013+ "license": "MIT",
1014+ "dependencies": {
1015+ "ms": "^2.1.3"
1016+ },
1017+ "engines": {
1018+ "node": ">=6.0"
1019+ },
1020+ "peerDependenciesMeta": {
1021+ "supports-color": {
1022+ "optional": true
1023+ }
1024+ }
1025+ },
1026+ "node_modules/esbuild": {
1027+ "version": "0.25.12",
1028+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
1029+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
1030+ "dev": true,
1031+ "hasInstallScript": true,
1032+ "license": "MIT",
1033+ "bin": {
1034+ "esbuild": "bin/esbuild"
1035+ },
1036+ "engines": {
1037+ "node": ">=18"
1038+ },
1039+ "optionalDependencies": {
1040+ "@esbuild/aix-ppc64": "0.25.12",
1041+ "@esbuild/android-arm": "0.25.12",
1042+ "@esbuild/android-arm64": "0.25.12",
1043+ "@esbuild/android-x64": "0.25.12",
1044+ "@esbuild/darwin-arm64": "0.25.12",
1045+ "@esbuild/darwin-x64": "0.25.12",
1046+ "@esbuild/freebsd-arm64": "0.25.12",
1047+ "@esbuild/freebsd-x64": "0.25.12",
1048+ "@esbuild/linux-arm": "0.25.12",
1049+ "@esbuild/linux-arm64": "0.25.12",
1050+ "@esbuild/linux-ia32": "0.25.12",
1051+ "@esbuild/linux-loong64": "0.25.12",
1052+ "@esbuild/linux-mips64el": "0.25.12",
1053+ "@esbuild/linux-ppc64": "0.25.12",
1054+ "@esbuild/linux-riscv64": "0.25.12",
1055+ "@esbuild/linux-s390x": "0.25.12",
1056+ "@esbuild/linux-x64": "0.25.12",
1057+ "@esbuild/netbsd-arm64": "0.25.12",
1058+ "@esbuild/netbsd-x64": "0.25.12",
1059+ "@esbuild/openbsd-arm64": "0.25.12",
1060+ "@esbuild/openbsd-x64": "0.25.12",
1061+ "@esbuild/openharmony-arm64": "0.25.12",
1062+ "@esbuild/sunos-x64": "0.25.12",
1063+ "@esbuild/win32-arm64": "0.25.12",
1064+ "@esbuild/win32-ia32": "0.25.12",
1065+ "@esbuild/win32-x64": "0.25.12"
1066+ }
1067+ },
1068+ "node_modules/fdir": {
1069+ "version": "6.5.0",
1070+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
1071+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
1072+ "dev": true,
1073+ "license": "MIT",
1074+ "engines": {
1075+ "node": ">=12.0.0"
1076+ },
1077+ "peerDependencies": {
1078+ "picomatch": "^3 || ^4"
1079+ },
1080+ "peerDependenciesMeta": {
1081+ "picomatch": {
1082+ "optional": true
1083+ }
1084+ }
1085+ },
1086+ "node_modules/fsevents": {
1087+ "version": "2.3.3",
1088+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1089+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1090+ "dev": true,
1091+ "hasInstallScript": true,
1092+ "license": "MIT",
1093+ "optional": true,
1094+ "os": [
1095+ "darwin"
1096+ ],
1097+ "engines": {
1098+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1099+ }
1100+ },
1101+ "node_modules/js-yaml": {
1102+ "version": "4.3.2",
1103+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
1104+ "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
1105+ "funding": [
1106+ {
1107+ "type": "github",
1108+ "url": "https://github.com/sponsors/puzrin"
1109+ },
1110+ {
1111+ "type": "github",
1112+ "url": "https://github.com/sponsors/nodeca"
1113+ }
1114+ ],
1115+ "license": "MIT",
1116+ "dependencies": {
1117+ "argparse": "^2.0.1"
1118+ },
1119+ "bin": {
1120+ "js-yaml": "bin/js-yaml.js"
1121+ }
1122+ },
1123+ "node_modules/ms": {
1124+ "version": "2.1.3",
1125+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1126+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1127+ "dev": true,
1128+ "license": "MIT"
1129+ },
1130+ "node_modules/nanoid": {
1131+ "version": "3.3.19",
1132+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
1133+ "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
1134+ "dev": true,
1135+ "funding": [
1136+ {
1137+ "type": "github",
1138+ "url": "https://github.com/sponsors/ai"
1139+ }
1140+ ],
1141+ "license": "MIT",
1142+ "bin": {
1143+ "nanoid": "bin/nanoid.cjs"
1144+ },
1145+ "engines": {
1146+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1147+ }
1148+ },
1149+ "node_modules/picocolors": {
1150+ "version": "1.1.1",
1151+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1152+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1153+ "dev": true,
1154+ "license": "ISC"
1155+ },
1156+ "node_modules/picomatch": {
1157+ "version": "4.0.7",
1158+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
1159+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
1160+ "dev": true,
1161+ "license": "MIT",
1162+ "engines": {
1163+ "node": ">=12"
1164+ },
1165+ "funding": {
1166+ "url": "https://github.com/sponsors/jonschlinkert"
1167+ }
1168+ },
1169+ "node_modules/postcss": {
1170+ "version": "8.5.28",
1171+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
1172+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
1173+ "dev": true,
1174+ "funding": [
1175+ {
1176+ "type": "opencollective",
1177+ "url": "https://opencollective.com/postcss/"
1178+ },
1179+ {
1180+ "type": "tidelift",
1181+ "url": "https://tidelift.com/funding/github/npm/postcss"
1182+ },
1183+ {
1184+ "type": "github",
1185+ "url": "https://github.com/sponsors/ai"
1186+ }
1187+ ],
1188+ "license": "MIT",
1189+ "dependencies": {
1190+ "nanoid": "^3.3.18",
1191+ "picocolors": "^1.1.1",
1192+ "source-map-js": "^1.2.1"
1193+ },
1194+ "engines": {
1195+ "node": "^10 || ^12 || >=14"
1196+ }
1197+ },
1198+ "node_modules/rollup": {
1199+ "version": "4.63.4",
1200+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz",
1201+ "integrity": "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==",
1202+ "dev": true,
1203+ "license": "MIT",
1204+ "dependencies": {
1205+ "@types/estree": "1.0.9"
1206+ },
1207+ "bin": {
1208+ "rollup": "dist/bin/rollup"
1209+ },
1210+ "engines": {
1211+ "node": ">=18.0.0",
1212+ "npm": ">=8.0.0"
1213+ },
1214+ "optionalDependencies": {
1215+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
1216+ "@rollup/rollup-android-arm-eabi": "4.63.4",
1217+ "@rollup/rollup-android-arm64": "4.63.4",
1218+ "@rollup/rollup-darwin-arm64": "4.63.4",
1219+ "@rollup/rollup-darwin-x64": "4.63.4",
1220+ "@rollup/rollup-freebsd-arm64": "4.63.4",
1221+ "@rollup/rollup-freebsd-x64": "4.63.4",
1222+ "@rollup/rollup-linux-arm-gnueabihf": "4.63.4",
1223+ "@rollup/rollup-linux-arm-musleabihf": "4.63.4",
1224+ "@rollup/rollup-linux-arm64-gnu": "4.63.4",
1225+ "@rollup/rollup-linux-arm64-musl": "4.63.4",
1226+ "@rollup/rollup-linux-loong64-gnu": "4.63.4",
1227+ "@rollup/rollup-linux-loong64-musl": "4.63.4",
1228+ "@rollup/rollup-linux-ppc64-gnu": "4.63.4",
1229+ "@rollup/rollup-linux-ppc64-musl": "4.63.4",
1230+ "@rollup/rollup-linux-riscv64-gnu": "4.63.4",
1231+ "@rollup/rollup-linux-riscv64-musl": "4.63.4",
1232+ "@rollup/rollup-linux-s390x-gnu": "4.63.4",
1233+ "@rollup/rollup-linux-x64-gnu": "4.63.4",
1234+ "@rollup/rollup-linux-x64-musl": "4.63.4",
1235+ "@rollup/rollup-openbsd-x64": "4.63.4",
1236+ "@rollup/rollup-openharmony-arm64": "4.63.4",
1237+ "@rollup/rollup-win32-arm64-msvc": "4.63.4",
1238+ "@rollup/rollup-win32-ia32-msvc": "4.63.4",
1239+ "@rollup/rollup-win32-x64-gnu": "4.63.4",
1240+ "@rollup/rollup-win32-x64-msvc": "4.63.4",
1241+ "fsevents": "~2.3.2"
1242+ }
1243+ },
1244+ "node_modules/source-map-js": {
1245+ "version": "1.2.1",
1246+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1247+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1248+ "dev": true,
1249+ "license": "BSD-3-Clause",
1250+ "engines": {
1251+ "node": ">=0.10.0"
1252+ }
1253+ },
1254+ "node_modules/style-mod": {
1255+ "version": "4.1.4",
1256+ "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz",
1257+ "integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==",
1258+ "license": "MIT"
1259+ },
1260+ "node_modules/tinyglobby": {
1261+ "version": "0.2.17",
1262+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
1263+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
1264+ "dev": true,
1265+ "license": "MIT",
1266+ "dependencies": {
1267+ "fdir": "^6.5.0",
1268+ "picomatch": "^4.0.4"
1269+ },
1270+ "engines": {
1271+ "node": ">=12.0.0"
1272+ },
1273+ "funding": {
1274+ "url": "https://github.com/sponsors/SuperchupuDev"
1275+ }
1276+ },
1277+ "node_modules/typescript": {
1278+ "version": "5.9.3",
1279+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
1280+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1281+ "dev": true,
1282+ "license": "Apache-2.0",
1283+ "bin": {
1284+ "tsc": "bin/tsc",
1285+ "tsserver": "bin/tsserver"
1286+ },
1287+ "engines": {
1288+ "node": ">=14.17"
1289+ }
1290+ },
1291+ "node_modules/undici-types": {
1292+ "version": "7.18.2",
1293+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
1294+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
1295+ "dev": true,
1296+ "license": "MIT"
1297+ },
1298+ "node_modules/vite": {
1299+ "version": "6.4.3",
1300+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
1301+ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
1302+ "dev": true,
1303+ "license": "MIT",
1304+ "dependencies": {
1305+ "esbuild": "^0.25.0",
1306+ "fdir": "^6.4.4",
1307+ "picomatch": "^4.0.2",
1308+ "postcss": "^8.5.3",
1309+ "rollup": "^4.34.9",
1310+ "tinyglobby": "^0.2.13"
1311+ },
1312+ "bin": {
1313+ "vite": "bin/vite.js"
1314+ },
1315+ "engines": {
1316+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
1317+ },
1318+ "funding": {
1319+ "url": "https://github.com/vitejs/vite?sponsor=1"
1320+ },
1321+ "optionalDependencies": {
1322+ "fsevents": "~2.3.3"
1323+ },
1324+ "peerDependencies": {
1325+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
1326+ "jiti": ">=1.21.0",
1327+ "less": "*",
1328+ "lightningcss": "^1.21.0",
1329+ "sass": "*",
1330+ "sass-embedded": "*",
1331+ "stylus": "*",
1332+ "sugarss": "*",
1333+ "terser": "^5.16.0",
1334+ "tsx": "^4.8.1",
1335+ "yaml": "^2.4.2"
1336+ },
1337+ "peerDependenciesMeta": {
1338+ "@types/node": {
1339+ "optional": true
1340+ },
1341+ "jiti": {
1342+ "optional": true
1343+ },
1344+ "less": {
1345+ "optional": true
1346+ },
1347+ "lightningcss": {
1348+ "optional": true
1349+ },
1350+ "sass": {
1351+ "optional": true
1352+ },
1353+ "sass-embedded": {
1354+ "optional": true
1355+ },
1356+ "stylus": {
1357+ "optional": true
1358+ },
1359+ "sugarss": {
1360+ "optional": true
1361+ },
1362+ "terser": {
1363+ "optional": true
1364+ },
1365+ "tsx": {
1366+ "optional": true
1367+ },
1368+ "yaml": {
1369+ "optional": true
1370+ }
1371+ }
1372+ },
1373+ "node_modules/w3c-keyname": {
1374+ "version": "2.2.8",
1375+ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
1376+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
1377+ "license": "MIT"
1378+ },
1379+ "node_modules/webgpu": {
1380+ "version": "0.4.0",
1381+ "resolved": "https://registry.npmjs.org/webgpu/-/webgpu-0.4.0.tgz",
1382+ "integrity": "sha512-F5pimn3Aoi0zWjuRdiVs5TnrUwSzD2lESBohsIUsqyitWkGRQlXU2fhV6ycXlQTa1bvAf3sjqiUpBEpmSQ5ptA==",
1383+ "dev": true,
1384+ "hasInstallScript": true,
1385+ "license": "MIT",
1386+ "dependencies": {
1387+ "@webgpu/types": "^0.1.69",
1388+ "debug": "^4.4.0"
1389+ }
1390+ }
1391+ }
1392+}
package.jsonadded+32−0View file
@@ -0,0 +1,32 @@
1+{
2+ "name": "cymsimweb",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "description": "Browser companion of cymsim: nonlinear finite-element simulation of struck cymbals on WebGPU, with a YAML/form config editor, live spectrogram and playback.",
7+ "scripts": {
8+ "dev": "vite",
9+ "build": "tsc --noEmit && vite build",
10+ "preview": "vite preview",
11+ "test": "npm run test:cpu && npm run test:gpu",
12+ "test:cpu": "node --test tests/*.test.ts",
13+ "test:gpu": "node tests/gpu.check.ts"
14+ },
15+ "devDependencies": {
16+ "@types/js-yaml": "^4.0.9",
17+ "@types/node": "^24.0.0",
18+ "@webgpu/types": "^0.1.60",
19+ "typescript": "^5.6.0",
20+ "vite": "^6.0.0",
21+ "webgpu": "^0.4.0"
22+ },
23+ "dependencies": {
24+ "@codemirror/commands": "^6.11.0",
25+ "@codemirror/language": "^6.12.4",
26+ "@codemirror/legacy-modes": "^6.5.4",
27+ "@codemirror/state": "^6.7.4",
28+ "@codemirror/view": "^6.43.11",
29+ "@lezer/highlight": "^1.2.3",
30+ "js-yaml": "^4.1.0"
31+ }
32+}
src/analysis/spectrogram.tsadded+68−0View file
@@ -0,0 +1,68 @@
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+}
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/presets.tsadded+10−0View file
@@ -0,0 +1,10 @@
1+// Built-in configurations: the example YAML files of the cymsim Python package, verbatim.
2+
3+export interface Preset { id: string; name: string; yaml: string }
4+
5+export const PRESETS: Preset[] = [
6+ { id: "crash_mallet", name: "Crash, mallet stroke", yaml: "# Soft mallet, gong-like stroke on an 18 inch medium-thin crash (the prototype's default run).\n# The mallet injects only low frequencies; everything above a few hundred Hz comes from the\n# nonlinear cascade. Units are SI throughout (m, kg, s, Pa). See docs/model.md.\nmaterial:\n E: 110000000000.0\n nu: 0.34\n rho: 8700.0\n sigma0: 0.9\n sigma1: 0.0005\nprofile:\n radius: 0.2286\n bell_height: 0.013\n bell_top_radius: 0.025\n bell_shoulder_radius: 0.08\n bow_height: 0.03\n bow_power: 1.4\n h_rim: 0.001\n h_shoulder: 0.0017\n h_bell: 0.0026\n max_slope: 0.4\nimperfections:\n seed: 1\n n_dents: 400\n dent_depth: 0.00025\n dent_sigma: 0.007\n dent_margin: 0.01\n rim_warp: 0.0006\n rim_warp_modes:\n - 2\n - 3\n - 4\n - 5\n shoulder_ovality: 0.03\n bell_offset: 0.0015\nmesh:\n element_size: 0.006\n smoothing: 1\nhits:\n- time: 0.0\n r: 0.13716\n phi: 0.3\n velocity: 8.0\n exciter:\n kind: mallet\n mass: 0.3\n alpha: 2.5\n compression: 0.012\n radius: 0.012\n gap: 0.0001\nlistener:\n position:\n - 0.4\n - 0.3\n - 0.9\n c_air: 343.0\n rho_air: 1.2\n back_diffraction_gain: 1.0\nnumerics:\n fs: 44100\n duration: 2.0\n cfl_safety: 0.6\n rot_inertia_factor: 0.015625\n inplane_mass_scale: 1.0\n steps_per_block: 20000\n dtype: float32\n nonlinear: true\n n_progress: 20\noutput:\n prefix: crash_mallet\n wav: true\n npz: true\n cascade_plot: true\n" },
7+ { id: "crash_stick", name: "Crash, stick shoulder hit", yaml: "# Hard stick shoulder crash near the rim: Hertz contact (alpha = 1.5) along a 3 cm line.\nmaterial:\n E: 110000000000.0\n nu: 0.34\n rho: 8700.0\n sigma0: 0.9\n sigma1: 0.0005\nprofile:\n radius: 0.2286\n bell_height: 0.013\n bell_top_radius: 0.025\n bell_shoulder_radius: 0.08\n bow_height: 0.03\n bow_power: 1.4\n h_rim: 0.001\n h_shoulder: 0.0017\n h_bell: 0.0026\n max_slope: 0.4\nimperfections:\n seed: 1\n n_dents: 400\n dent_depth: 0.00025\n dent_sigma: 0.007\n dent_margin: 0.01\n rim_warp: 0.0006\n rim_warp_modes:\n - 2\n - 3\n - 4\n - 5\n shoulder_ovality: 0.03\n bell_offset: 0.0015\nmesh:\n element_size: 0.006\n smoothing: 1\nhits:\n- time: 0.0\n r: 0.19430999999999998\n phi: 0.3\n velocity: 7.0\n exciter:\n kind: stick\n mass: 0.03\n k: 500000000.0\n alpha: 1.5\n patch_length: 0.03\n patch_points: 7\n patch_angle: 0.6\n gap: 0.0001\nlistener:\n position:\n - 0.4\n - 0.3\n - 0.9\n c_air: 343.0\n rho_air: 1.2\n back_diffraction_gain: 1.0\nnumerics:\n fs: 44100\n duration: 2.0\n cfl_safety: 0.6\n rot_inertia_factor: 0.015625\n inplane_mass_scale: 1.0\n steps_per_block: 20000\n dtype: float32\n nonlinear: true\n n_progress: 20\noutput:\n prefix: crash_stick\n wav: true\n npz: true\n cascade_plot: true\n" },
8+ { id: "hit_train", name: "Train of four hits", yaml: "# A train of four hits: a mallet stroke, two stick crashes on the ringing cymbal, and a second\n# mallet stroke. Each hit has its own exciter, released at its time just above the moving surface.\nmaterial:\n E: 110000000000.0\n nu: 0.34\n rho: 8700.0\n sigma0: 0.9\n sigma1: 0.0005\nprofile:\n radius: 0.2286\n bell_height: 0.013\n bell_top_radius: 0.025\n bell_shoulder_radius: 0.08\n bow_height: 0.03\n bow_power: 1.4\n h_rim: 0.001\n h_shoulder: 0.0017\n h_bell: 0.0026\n max_slope: 0.4\nimperfections:\n seed: 1\n n_dents: 400\n dent_depth: 0.00025\n dent_sigma: 0.007\n dent_margin: 0.01\n rim_warp: 0.0006\n rim_warp_modes:\n - 2\n - 3\n - 4\n - 5\n shoulder_ovality: 0.03\n bell_offset: 0.0015\nmesh:\n element_size: 0.006\n smoothing: 1\nhits:\n- time: 0.0\n r: 0.137\n phi: 0.3\n velocity: 8.0\n exciter:\n kind: mallet\n mass: 0.3\n alpha: 2.5\n compression: 0.012\n radius: 0.012\n gap: 0.0001\n- time: 0.8\n r: 0.194\n phi: 2.0\n velocity: 7.0\n exciter:\n kind: stick\n mass: 0.03\n k: 500000000.0\n alpha: 1.5\n patch_length: 0.03\n patch_points: 7\n patch_angle: 0.6\n gap: 0.0001\n- time: 1.4\n r: 0.194\n phi: 2.3\n velocity: 5.0\n exciter:\n kind: stick\n mass: 0.03\n k: 500000000.0\n alpha: 1.5\n patch_length: 0.03\n patch_points: 7\n patch_angle: 0.6\n gap: 0.0001\n- time: 2.0\n r: 0.137\n phi: -1.0\n velocity: 6.0\n exciter:\n kind: mallet\n mass: 0.3\n alpha: 2.5\n compression: 0.012\n radius: 0.012\n gap: 0.0001\nlistener:\n position:\n - 0.4\n - 0.3\n - 0.9\n c_air: 343.0\n rho_air: 1.2\n back_diffraction_gain: 1.0\nnumerics:\n fs: 44100\n duration: 3.0\n cfl_safety: 0.6\n rot_inertia_factor: 0.015625\n inplane_mass_scale: 1.0\n steps_per_block: 20000\n dtype: float32\n nonlinear: true\n n_progress: 20\noutput:\n prefix: train\n wav: true\n npz: true\n cascade_plot: true\n" },
9+ { id: "quick_cpu", name: "Quick preview (coarse mesh)", yaml: "# Coarse, short run that finishes in about a minute on a CPU. Element size 20 mm resolves only\n# the low modes; use it to exercise the pipeline, not for listening.\nmaterial:\n E: 110000000000.0\n nu: 0.34\n rho: 8700.0\n sigma0: 0.9\n sigma1: 0.0005\nprofile:\n radius: 0.2286\n bell_height: 0.013\n bell_top_radius: 0.025\n bell_shoulder_radius: 0.08\n bow_height: 0.03\n bow_power: 1.4\n h_rim: 0.001\n h_shoulder: 0.0017\n h_bell: 0.0026\n max_slope: 0.4\nimperfections:\n seed: 1\n n_dents: 100\n dent_depth: 0.00025\n dent_sigma: 0.007\n dent_margin: 0.01\n rim_warp: 0.0006\n rim_warp_modes:\n - 2\n - 3\n - 4\n - 5\n shoulder_ovality: 0.03\n bell_offset: 0.0015\nmesh:\n element_size: 0.02\n smoothing: 1\nhits:\n- time: 0.0\n r: 0.13716\n phi: 0.3\n velocity: 8.0\n exciter:\n kind: mallet\n mass: 0.3\n alpha: 2.5\n compression: 0.012\n radius: 0.012\n gap: 0.0001\nlistener:\n position:\n - 0.4\n - 0.3\n - 0.9\n c_air: 343.0\n rho_air: 1.2\n back_diffraction_gain: 1.0\nnumerics:\n fs: 44100\n duration: 0.3\n cfl_safety: 0.6\n rot_inertia_factor: 0.015625\n inplane_mass_scale: 1.0\n steps_per_block: 2000\n dtype: float32\n nonlinear: true\n n_progress: 20\noutput:\n prefix: quick\n wav: true\n npz: true\n cascade_plot: true\n" },
10+];
src/config/schema.tsadded+56−0View file
@@ -0,0 +1,56 @@
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 { prefix: string; wav: boolean; npz: boolean; cascade_plot: boolean }
27+export interface SimulationConfig {
28+ material: Material; profile: Profile; imperfections: Imperfections; mesh: MeshConfig; hits: Hit[];
29+ listener: Listener; numerics: Numerics; output: Output;
30+}
31+
32+export const defaultMaterial = (): Material => ({ E: 1.1e11, nu: 0.34, rho: 8700, sigma0: 0.9, sigma1: 5e-4 });
33+export const defaultProfile = (): Profile => ({
34+ radius: 0.2286, bell_height: 0.013, bell_top_radius: 0.025, bell_shoulder_radius: 0.08, bow_height: 0.03,
35+ bow_power: 1.4, h_rim: 1.0e-3, h_shoulder: 1.7e-3, h_bell: 2.6e-3, max_slope: 0.4,
36+});
37+export const defaultImperfections = (): Imperfections => ({
38+ seed: 1, n_dents: 400, dent_depth: 0.25e-3, dent_sigma: 7e-3, dent_margin: 0.01, rim_warp: 0.6e-3,
39+ rim_warp_modes: [2, 3, 4, 5], shoulder_ovality: 0.03, bell_offset: 1.5e-3,
40+});
41+export const defaultMesh = (): MeshConfig => ({ element_size: 6e-3, smoothing: 1 });
42+export const defaultStick = (): StickExciter => ({
43+ kind: "stick", mass: 0.03, k: 5e8, alpha: 1.5, patch_length: 0.03, patch_points: 7, patch_angle: 0.6, gap: 1e-4,
44+});
45+export const defaultMallet = (): MalletExciter => ({ kind: "mallet", mass: 0.3, alpha: 2.5, compression: 12e-3, radius: 0.012, gap: 1e-4 });
46+export const defaultHit = (): Hit => ({ time: 0, r: 0.137, phi: 0.3, velocity: 8, exciter: defaultMallet() });
47+export const defaultListener = (): Listener => ({ position: [0.4, 0.3, 0.9], c_air: 343, rho_air: 1.2, back_diffraction_gain: 1 });
48+export const defaultNumerics = (): Numerics => ({
49+ fs: 44100, duration: 2, cfl_safety: 0.6, rot_inertia_factor: 1 / 64, inplane_mass_scale: 1, steps_per_block: 20000,
50+ dtype: "float32", nonlinear: true, n_progress: 20,
51+});
52+export const defaultOutput = (): Output => ({ prefix: "crash", wav: true, npz: true, cascade_plot: true });
53+export const defaultConfig = (): SimulationConfig => ({
54+ material: defaultMaterial(), profile: defaultProfile(), imperfections: defaultImperfections(), mesh: defaultMesh(),
55+ hits: [defaultHit()], listener: defaultListener(), numerics: defaultNumerics(), output: defaultOutput(),
56+});
src/config/yaml.tsadded+119−0View file
@@ -0,0 +1,119 @@
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 (!(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" });
91+ 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" });
92+ 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)" });
93+ cfg.hits.forEach((h, i) => {
94+ if (h.time < 0) issues.push({ path: `hits[${i}].time`, message: "must be non-negative" });
95+ if (!(h.velocity > 0)) issues.push({ path: `hits[${i}].velocity`, message: "must be positive (the exciter approaches from above)" });
96+ const ex = h.exciter;
97+ const extent = ex.kind === "stick" ? ex.patch_length / 2 : ex.radius;
98+ 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})` });
99+ if (h.r < 0) issues.push({ path: `hits[${i}].r`, message: "must be non-negative" });
100+ if (!(ex.mass > 0)) issues.push({ path: `hits[${i}].exciter.mass`, message: "must be positive" });
101+ if (ex.kind === "stick" && !(ex.patch_points >= 1)) issues.push({ path: `hits[${i}].exciter.patch_points`, message: "must be at least 1" });
102+ if (ex.kind === "mallet" && !(ex.compression > 0)) issues.push({ path: `hits[${i}].exciter.compression`, message: "must be positive" });
103+ });
104+ return issues;
105+}
106+
107+/** Parse a YAML document into a configuration. ``config`` is null only when the YAML itself is malformed. */
108+export function parseConfigYaml(text: string): { config: SimulationConfig | null; issues: Issue[] } {
109+ let raw: unknown;
110+ try { raw = yaml.load(text); } catch (err) { return { config: null, issues: [{ path: "", message: `YAML: ${(err as Error).message}` }] }; }
111+ const { config, issues } = normalizeConfig(raw);
112+ return { config, issues: issues.concat(validateConfig(config)) };
113+}
114+
115+export function configToYaml(cfg: SimulationConfig): string {
116+ return yaml.dump(cfg, { noRefs: true, lineWidth: -1, sortKeys: false });
117+}
118+
119+export function cloneConfig(cfg: SimulationConfig): SimulationConfig { return structuredClone(cfg); }
src/cpu/simulator.tsadded+161−0View file
@@ -0,0 +1,161 @@
1+// Float64 reference time loop with exactly the structure of the GPU kernels: an element pass
2+// (forces, sigma1 term, radiation accumulation), a hit pass (contact forces, release), a node
3+// pass (gather, contact, lagged rigid-mode projection, leapfrog update, partial sums for the
4+// next projection), and the exciter update. See THEORY.md.
5+import { elementForces, strainEnergies } from "../model/forces.ts";
6+import { HOLD_HEIGHT } from "../model/exciter.ts";
7+import { DECIMATION_SPAN } from "../model/radiation.ts";
8+import type { Setup } from "../model/setup.ts";
9+import type { ChunkResult, Diagnostics, SimulatorBackend } from "../model/simulator.ts";
10+
11+export class CpuSimulator implements SimulatorBackend {
12+ readonly kind = "cpu" as const;
13+ private n = 0; // sub-step counter
14+ private readonly q: Float64Array; private readonly v: Float64Array; private readonly nodeAcc: Float64Array;
15+ private readonly vwPrev: Float64Array; // projected transverse velocity of the previous step (for the radiated acceleration)
16+ private readonly ef: Float64Array; private readonly fc: Float64Array;
17+ private readonly zs: Float64Array; private readonly vs: Float64Array; private readonly Fk: Float64Array; private readonly F: Float64Array;
18+ private readonly Fmax: Float64Array; private readonly ncont: Uint32Array;
19+ private partialsPrev = new Float64Array(12); private partialsNew = new Float64Array(12); // [0,6): Rm.(M v), [6,12): Rm.(M q)
20+ private readonly acc: Float64Array; // (E, DECIMATION_SPAN) decimation accumulators
21+ private readonly history: Float64Array;
22+ private pending: { j: number; front: number; back: number }[] = [];
23+ samplesDone = 0;
24+
25+ readonly setup: Setup;
26+
27+ constructor(setup: Setup) {
28+ this.setup = setup;
29+ const N = setup.mesh.n, E = setup.mesh.e, K = setup.schedule.K, P = setup.schedule.P;
30+ this.q = new Float64Array(5 * N); this.v = new Float64Array(5 * N); this.nodeAcc = new Float64Array(N); this.vwPrev = new Float64Array(N);
31+ this.ef = new Float64Array(15 * E); this.fc = new Float64Array(N);
32+ this.zs = new Float64Array(K).fill(HOLD_HEIGHT); this.vs = new Float64Array(K);
33+ this.Fk = new Float64Array(K * P); this.F = new Float64Array(K); this.Fmax = new Float64Array(K); this.ncont = new Uint32Array(K);
34+ this.acc = new Float64Array(E * DECIMATION_SPAN); this.history = new Float64Array(E * setup.radiation.ringLength);
35+ }
36+
37+ private step() {
38+ const { shell: sh, mesh, schedule: sc, radiation: rad, dt, substeps: m } = this.setup;
39+ const { tri } = mesh, N = mesh.n, E = mesh.e, n = this.n;
40+ const sigma0 = this.setup.config.material.sigma0;
41+ // ---- element pass
42+ elementForces(sh, this.q, this.v, this.ef);
43+ if (n >= 1) {
44+ const s = n - 1, j0 = Math.floor(s / m), kk = s - j0 * m, W = DECIMATION_SPAN, taps = rad.taps;
45+ if (kk === 0 && j0 > 0) this.finalizeSample(j0 - W / 2); // slot 0 = sample j0 - (W/2 - 1) of the previous j0
46+ for (let e = 0; e < E; e++) {
47+ const a = (this.nodeAcc[tri[3 * e]] + this.nodeAcc[tri[3 * e + 1]] + this.nodeAcc[tri[3 * e + 2]]) / 3;
48+ for (let w = 0; w < W; w++) this.acc[e * W + w] += a * taps[w * m + kk];
49+ }
50+ }
51+ // ---- hit pass
52+ this.fc.fill(0);
53+ for (let k = 0; k < sc.K; k++) {
54+ let wmax = -Infinity;
55+ const wp = new Float64Array(sc.P);
56+ for (let p = 0; p < sc.P; p++) {
57+ const b = (k * sc.P + p) * 3;
58+ let w = sc.offsets[k * sc.P + p];
59+ for (let i = 0; i < 3; i++) w += sc.weights[b + i] * this.q[5 * sc.nodes[b + i] + 2];
60+ wp[p] = w; wmax = Math.max(wmax, w);
61+ }
62+ if (n === sc.releaseStep[k]) { this.zs[k] = wmax + sc.gap[k]; this.vs[k] = -sc.velocity[k]; }
63+ const active = n >= sc.releaseStep[k];
64+ let Ftot = 0;
65+ for (let p = 0; p < sc.P; p++) {
66+ const delta = Math.max(wp[p] - this.zs[k], 0);
67+ const f = active ? sc.kPoint[k] * Math.pow(delta, sc.alpha[k]) : 0;
68+ this.Fk[k * sc.P + p] = f; Ftot += f;
69+ if (f > 0) { const b = (k * sc.P + p) * 3; for (let i = 0; i < 3; i++) this.fc[sc.nodes[b + i]] -= f * sc.weights[b + i]; }
70+ }
71+ this.F[k] = Ftot;
72+ }
73+ // ---- node pass
74+ const { M, Minv, Rm, adjOffsets, adjEntries } = sh, L = 5 * N, c = this.partialsPrev;
75+ this.partialsNew.fill(0);
76+ const f = new Float64Array(5);
77+ for (let nd = 0; nd < N; nd++) {
78+ f.fill(0);
79+ for (let a = adjOffsets[nd]; a < adjOffsets[nd + 1]; a++) {
80+ const ent = adjEntries[a], e = (ent / 3) | 0, i = ent % 3;
81+ for (let d = 0; d < 5; d++) f[d] += this.ef[15 * e + 5 * i + d];
82+ }
83+ f[2] += this.fc[nd];
84+ for (let d = 0; d < 5; d++) {
85+ const idx = 5 * nd + d;
86+ // lagged projection: remove the rigid content that the previous step's update introduced
87+ let vd = this.v[idx], qd = this.q[idx];
88+ for (let k = 0; k < 6; k++) { const r = Rm[k * L + idx]; vd -= c[k] * r; qd -= c[6 + k] * r; }
89+ const v1 = (vd + dt * f[d] * Minv[idx]) / (1 + 2 * sigma0 * dt);
90+ // radiated acceleration from consecutive projected velocities, so that the rigid increment
91+ // that the lagged projection removes one step later never radiates
92+ if (d === 2) { this.nodeAcc[nd] = (vd - this.vwPrev[nd]) / dt; this.vwPrev[nd] = vd; }
93+ const q1 = qd + dt * v1;
94+ this.q[idx] = q1; this.v[idx] = v1;
95+ for (let k = 0; k < 6; k++) { const r = Rm[k * L + idx] * M[idx]; this.partialsNew[k] += r * v1; this.partialsNew[6 + k] += r * q1; }
96+ }
97+ }
98+ [this.partialsPrev, this.partialsNew] = [this.partialsNew, this.partialsPrev];
99+ // ---- exciters
100+ for (let k = 0; k < sc.K; k++) {
101+ this.vs[k] += (dt * this.F[k]) / sc.mass[k]; this.zs[k] += dt * this.vs[k];
102+ this.Fmax[k] = Math.max(this.Fmax[k], this.F[k]); if (this.F[k] > 0) this.ncont[k]++;
103+ }
104+ this.n++;
105+ }
106+
107+ /** Slot 0 (audio sample j) of the accumulators is complete: store it, shift the slots, evaluate the listener pressure. */
108+ private finalizeSample(j: number) {
109+ const { radiation: rad, mesh } = this.setup, E = mesh.e, D = rad.ringLength, W = DECIMATION_SPAN;
110+ for (let e = 0; e < E; e++) {
111+ if (j >= 0) this.history[e * D + (j % D)] = this.acc[e * W];
112+ for (let w = 0; w < W - 1; w++) this.acc[e * W + w] = this.acc[e * W + w + 1];
113+ this.acc[e * W + W - 1] = 0;
114+ }
115+ if (j < 0) return;
116+ let front = 0, back = 0;
117+ for (let e = 0; e < E; e++) {
118+ const jf = j - rad.i0F[e], jb = j - rad.i0B[e];
119+ if (jf >= 1) front += rad.coefF[e] * (this.history[e * D + (jf % D)] * (1 - rad.frF[e]) + this.history[e * D + ((jf - 1) % D)] * rad.frF[e]);
120+ if (jb >= 1) back += rad.coefB[e] * (this.history[e * D + (jb % D)] * (1 - rad.frB[e]) + this.history[e * D + ((jb - 1) % D)] * rad.frB[e]);
121+ }
122+ this.pending.push({ j, front, back });
123+ }
124+
125+ async runChunk(nSamples: number): Promise<ChunkResult> {
126+ const m = this.setup.substeps;
127+ for (let s = 0; s < nSamples * m; s++) this.step();
128+ this.samplesDone += nSamples;
129+ const out = this.pending; this.pending = [];
130+ const firstSample = out.length ? out[0].j : this.samplesDone;
131+ return { firstSample, pressureFront: Float32Array.from(out, (o) => o.front), pressureBack: Float32Array.from(out, (o) => o.back) };
132+ }
133+
134+ async diagnostics(): Promise<Diagnostics> {
135+ const { shell: sh, schedule: sc, dt } = this.setup, N = sh.mesh.n;
136+ let ke = 0, woh = 0;
137+ for (let i = 0; i < 5 * N; i++) ke += 0.5 * sh.M[i] * this.v[i] * this.v[i];
138+ for (let nd = 0; nd < N; nd++) woh = Math.max(woh, Math.abs(this.q[5 * nd + 2] / sh.hNode[nd]));
139+ const { membrane, bending } = strainEnergies(sh, this.q);
140+ let ex = 0;
141+ for (let k = 0; k < sc.K; k++) ex += 0.5 * sc.mass[k] * this.vs[k] * this.vs[k];
142+ return { time: this.n * dt, kinetic: ke, membrane, bending, exciters: ex, maxWoverH: woh,
143+ hits: Array.from({ length: sc.K }, (_, k) => ({ peakForce: this.Fmax[k], contactTime: this.ncont[k] * dt, velocity: this.vs[k], height: this.zs[k] })) };
144+ }
145+
146+ /** M-weighted rigid-mode content of the current velocity (the lagged projection leaves one step's increment). */
147+ rigidContent(): number {
148+ const { shell: sh } = this.setup, L = 5 * sh.mesh.n;
149+ let worst = 0;
150+ for (let k = 0; k < 6; k++) { let c = 0; for (let i = 0; i < L; i++) c += sh.Rm[k * L + i] * sh.M[i] * this.v[i]; worst = Math.max(worst, Math.abs(c)); }
151+ return worst;
152+ }
153+
154+ async displacement(): Promise<Float32Array> {
155+ const N = this.setup.mesh.n, w = new Float32Array(N);
156+ for (let nd = 0; nd < N; nd++) w[nd] = this.q[5 * nd + 2];
157+ return w;
158+ }
159+
160+ dispose() {}
161+}
src/gpu/shaders.tsadded+241−0View file
@@ -0,0 +1,241 @@
1+// WGSL kernels of the time loop. Three entry points: the element pass (forces, sigma1 term,
2+// contact forces of the hits, decimation accumulators of the radiated acceleration), the node
3+// pass (gather, contact, lagged rigid-mode projection, leapfrog update, partial sums, exciter
4+// update) and the gather pass (retarded-time sum at the listener once per audio sample). Sizes
5+// and constants are baked into the source. See THEORY.md for the buffer layouts and the ordering
6+// argument behind the two step counters.
7+
8+export interface ShaderConstants {
9+ N: number; E: number; K: number; P: number; M: number; W: number; D: number; G: number;
10+ dt: number; sigma0: number; nl: number;
11+}
12+
13+export const ELEM_STRIDE = 96; // Gx[3] Gy[3] A z0x z0y C11 C12 C33 cd Ke[81] pad[2]
14+export const NODE_STRIDE = 40; // Minv[5] M[5] Rm[6][5]
15+export const HIT_HEADER = 12; // kPoint alpha mass gap velocity zs vs F Fmax pad[3]
16+export const HITU_HEADER = 4; // releaseStep ncont pad[2]
17+export const SYNC_HEADER = 4; // stepA stepB pad[2], then partials[2][G][12]
18+export const WORKGROUP_NODES = 256;
19+
20+function f(x: number): string {
21+ const s = x.toPrecision(9);
22+ return /[.e]/.test(s) ? s : s + ".0";
23+}
24+
25+export function shaderSource(c: ShaderConstants): string {
26+ const HF = HIT_HEADER + 5 * c.P, HU = HITU_HEADER + 3 * c.P;
27+ return /* wgsl */ `
28+const N: u32 = ${c.N}u; const E: u32 = ${c.E}u; const K: u32 = ${c.K}u; const P: u32 = ${c.P}u;
29+const M: u32 = ${c.M}u; const W: u32 = ${c.W}u; const D: u32 = ${c.D}u; const G: u32 = ${c.G}u;
30+const DT: f32 = ${f(c.dt)}; const DAMP0: f32 = ${f(1 / (1 + 2 * c.sigma0 * c.dt))}; const NL: f32 = ${f(c.nl)};
31+const EF: u32 = ${ELEM_STRIDE}u; const NF: u32 = ${NODE_STRIDE}u; const HF: u32 = ${HF}u; const HU: u32 = ${HU}u;
32+const OFF_V: u32 = 5u * N; const OFF_ACC: u32 = 10u * N; const OFF_VWP: u32 = 11u * N;
33+const OFF_HIST: u32 = E * W; const OFF_TAPS: u32 = EF * E; const OFF_PART: u32 = ${SYNC_HEADER}u;
34+
35+struct Chunk { base: i32, size: u32 }
36+
37+// ------------------------------------------------------------------ element pass
38+@group(0) @binding(0) var<storage, read> elemF: array<f32>;
39+@group(0) @binding(1) var<storage, read> elemU: array<u32>;
40+@group(0) @binding(2) var<storage, read> stateR: array<f32>;
41+@group(0) @binding(3) var<storage, read_write> elemForce: array<f32>;
42+@group(0) @binding(4) var<storage, read_write> hitsF: array<f32>;
43+@group(0) @binding(5) var<storage, read> hitsUR: array<u32>;
44+@group(0) @binding(6) var<storage, read_write> syncE: array<f32>;
45+@group(0) @binding(7) var<storage, read_write> rad: array<f32>;
46+
47+@compute @workgroup_size(64)
48+fn elementPass(@builtin(global_invocation_id) gid: vec3<u32>) {
49+ let id = gid.x;
50+ let step = bitcast<u32>(syncE[0]);
51+ if (id == 0u) { syncE[1] = syncE[0]; }
52+ if (id < E) {
53+ let e = id;
54+ let b = e * EF;
55+ let n0 = elemU[3u * e]; let n1 = elemU[3u * e + 1u]; let n2 = elemU[3u * e + 2u];
56+ var qe: array<f32, 15>;
57+ for (var d = 0u; d < 5u; d++) { qe[d] = stateR[5u * n0 + d]; qe[5u + d] = stateR[5u * n1 + d]; qe[10u + d] = stateR[5u * n2 + d]; }
58+ let gx = vec3<f32>(elemF[b], elemF[b + 1u], elemF[b + 2u]);
59+ let gy = vec3<f32>(elemF[b + 3u], elemF[b + 4u], elemF[b + 5u]);
60+ let A = elemF[b + 6u]; let z0x = elemF[b + 7u]; let z0y = elemF[b + 8u];
61+ let C11 = elemF[b + 9u]; let C12 = elemF[b + 10u]; let C33 = elemF[b + 11u]; let cd = elemF[b + 12u];
62+ let uu = vec3<f32>(qe[0], qe[5], qe[10]); let vv = vec3<f32>(qe[1], qe[6], qe[11]); let ww = vec3<f32>(qe[2], qe[7], qe[12]);
63+ let ux = dot(gx, uu); let uy = dot(gy, uu); let vx = dot(gx, vv); let vy = dot(gy, vv); let wx = dot(gx, ww); let wy = dot(gy, ww);
64+ let ax = NL * wx + z0x; let ay = NL * wy + z0y;
65+ let exx = ux + NL * 0.5 * wx * wx + z0x * wx;
66+ let eyy = vy + NL * 0.5 * wy * wy + z0y * wy;
67+ let gxy = uy + vx + NL * wx * wy + z0x * wy + z0y * wx;
68+ let Nxx = C11 * exx + C12 * eyy; let Nyy = C12 * exx + C11 * eyy; let Nxy = C33 * gxy;
69+ let vw = vec3<f32>(stateR[OFF_V + 5u * n0 + 2u], stateR[OFF_V + 5u * n1 + 2u], stateR[OFF_V + 5u * n2 + 2u]);
70+ let vwx = dot(gx, vw); let vwy = dot(gy, vw);
71+ var out: array<f32, 15>;
72+ for (var i = 0u; i < 3u; i++) {
73+ let g_x = gx[i]; let g_y = gy[i];
74+ out[5u * i] = -A * (Nxx * g_x + Nxy * g_y);
75+ out[5u * i + 1u] = -A * (Nyy * g_y + Nxy * g_x);
76+ out[5u * i + 2u] = -A * (Nxx * ax * g_x + Nyy * ay * g_y + Nxy * (ay * g_x + ax * g_y)) - cd * (g_x * vwx + g_y * vwy);
77+ out[5u * i + 3u] = 0.0; out[5u * i + 4u] = 0.0;
78+ }
79+ var U: array<f32, 9>;
80+ for (var i = 0u; i < 3u; i++) { U[3u * i] = qe[5u * i + 2u]; U[3u * i + 1u] = qe[5u * i + 3u]; U[3u * i + 2u] = qe[5u * i + 4u]; }
81+ for (var i = 0u; i < 9u; i++) {
82+ var s = 0.0;
83+ for (var j = 0u; j < 9u; j++) { s += elemF[b + 13u + 9u * i + j] * U[j]; }
84+ let slot = 5u * (i / 3u) + 2u + (i % 3u);
85+ out[slot] -= s;
86+ }
87+ for (var i = 0u; i < 15u; i++) { elemForce[15u * e + i] = out[i]; }
88+ // decimation accumulators of the radiated acceleration (of the previous step)
89+ if (step >= 1u) {
90+ let s = step - 1u; let j0 = s / M; let kk = s - j0 * M;
91+ if (kk == 0u && j0 > 0u) {
92+ let j = i32(j0) - i32(W / 2u);
93+ if (j >= 0) { rad[OFF_HIST + e * D + (u32(j) % D)] = rad[e * W]; }
94+ for (var w = 0u; w < W - 1u; w++) { rad[e * W + w] = rad[e * W + w + 1u]; }
95+ rad[e * W + W - 1u] = 0.0;
96+ }
97+ let a = (stateR[OFF_ACC + n0] + stateR[OFF_ACC + n1] + stateR[OFF_ACC + n2]) / 3.0;
98+ for (var w = 0u; w < W; w++) { rad[e * W + w] += a * elemF[OFF_TAPS + w * M + kk]; }
99+ }
100+ } else if (id < E + K) {
101+ // contact forces of hit k at this step
102+ let k = id - E; let hf = k * HF; let hu = k * HU;
103+ var wp: array<f32, ${c.P}>;
104+ var wmax = -1.0e30;
105+ for (var p = 0u; p < P; p++) {
106+ var w = hitsF[hf + ${HIT_HEADER}u + 3u * P + p];
107+ for (var i = 0u; i < 3u; i++) { w += hitsF[hf + ${HIT_HEADER}u + 3u * p + i] * stateR[5u * hitsUR[hu + ${HITU_HEADER}u + 3u * p + i] + 2u]; }
108+ wp[p] = w; wmax = max(wmax, w);
109+ }
110+ var zs = hitsF[hf + 5u];
111+ if (step == hitsUR[hu]) { zs = wmax + hitsF[hf + 3u]; hitsF[hf + 5u] = zs; hitsF[hf + 6u] = -hitsF[hf + 4u]; }
112+ let isActive = step >= hitsUR[hu];
113+ let kPoint = hitsF[hf]; let alpha = hitsF[hf + 1u];
114+ var F = 0.0;
115+ for (var p = 0u; p < P; p++) {
116+ let delta = max(wp[p] - zs, 0.0);
117+ var fp = 0.0;
118+ if (isActive && delta > 0.0) { fp = kPoint * pow(delta, alpha); }
119+ hitsF[hf + ${HIT_HEADER}u + 4u * P + p] = fp; F += fp;
120+ }
121+ hitsF[hf + 7u] = F;
122+ }
123+}
124+
125+// ------------------------------------------------------------------ node pass
126+@group(1) @binding(0) var<storage, read_write> state: array<f32>;
127+@group(1) @binding(1) var<storage, read> elemForceR: array<f32>;
128+@group(1) @binding(2) var<storage, read> nodeF: array<f32>;
129+@group(1) @binding(3) var<storage, read> adj: array<u32>;
130+@group(1) @binding(4) var<storage, read_write> hitsFN: array<f32>;
131+@group(1) @binding(5) var<storage, read_write> hitsU: array<u32>;
132+@group(1) @binding(6) var<storage, read_write> sync: array<f32>;
133+
134+var<workgroup> red: array<f32, ${12 * WORKGROUP_NODES}>;
135+
136+@compute @workgroup_size(${WORKGROUP_NODES})
137+fn nodePass(@builtin(global_invocation_id) gid: vec3<u32>, @builtin(local_invocation_id) lid: vec3<u32>, @builtin(workgroup_id) wid: vec3<u32>) {
138+ let nd = gid.x; let t = lid.x;
139+ let step = bitcast<u32>(sync[1]);
140+ let parity = step & 1u; let prevp = (step + 1u) & 1u;
141+ var c: array<f32, 12>;
142+ for (var k = 0u; k < 12u; k++) {
143+ var s = 0.0;
144+ for (var g = 0u; g < G; g++) { s += sync[OFF_PART + (prevp * G + g) * 12u + k]; }
145+ c[k] = s;
146+ }
147+ var part: array<f32, 12>;
148+ for (var k = 0u; k < 12u; k++) { part[k] = 0.0; }
149+ if (nd < N) {
150+ var fo: array<f32, 5>;
151+ for (var d = 0u; d < 5u; d++) { fo[d] = 0.0; }
152+ let a0 = adj[nd]; let a1 = adj[nd + 1u];
153+ for (var a = a0; a < a1; a++) {
154+ let ent = adj[N + 1u + a]; let e = ent / 3u; let i = ent - 3u * e;
155+ for (var d = 0u; d < 5u; d++) { fo[d] += elemForceR[15u * e + 5u * i + d]; }
156+ }
157+ for (var k = 0u; k < K; k++) {
158+ for (var p = 0u; p < P; p++) {
159+ for (var i = 0u; i < 3u; i++) {
160+ if (hitsU[k * HU + ${HITU_HEADER}u + 3u * p + i] == nd) {
161+ fo[2] -= hitsFN[k * HF + ${HIT_HEADER}u + 4u * P + p] * hitsFN[k * HF + ${HIT_HEADER}u + 3u * p + i];
162+ }
163+ }
164+ }
165+ }
166+ let nb = nd * NF;
167+ for (var d = 0u; d < 5u; d++) {
168+ let idx = 5u * nd + d;
169+ var vd = state[OFF_V + idx]; var qd = state[idx];
170+ for (var k = 0u; k < 6u; k++) { let r = nodeF[nb + 10u + 5u * k + d]; vd -= c[k] * r; qd -= c[6u + k] * r; }
171+ let v1 = (vd + DT * fo[d] * nodeF[nb + d]) * DAMP0;
172+ if (d == 2u) { state[OFF_ACC + nd] = (vd - state[OFF_VWP + nd]) / DT; state[OFF_VWP + nd] = vd; }
173+ let q1 = qd + DT * v1;
174+ state[idx] = q1; state[OFF_V + idx] = v1;
175+ let mm = nodeF[nb + 5u + d];
176+ for (var k = 0u; k < 6u; k++) { let r = nodeF[nb + 10u + 5u * k + d] * mm; part[k] += r * v1; part[6u + k] += r * q1; }
177+ }
178+ }
179+ for (var k = 0u; k < 12u; k++) { red[t * 12u + k] = part[k]; }
180+ workgroupBarrier();
181+ for (var s = ${WORKGROUP_NODES / 2}u; s > 0u; s = s >> 1u) {
182+ if (t < s) { for (var k = 0u; k < 12u; k++) { red[t * 12u + k] += red[(t + s) * 12u + k]; } }
183+ workgroupBarrier();
184+ }
185+ if (t == 0u) { for (var k = 0u; k < 12u; k++) { sync[OFF_PART + (parity * G + wid.x) * 12u + k] = red[k]; } }
186+ if (nd == 0u) {
187+ for (var k = 0u; k < K; k++) {
188+ let hf = k * HF; let hu = k * HU;
189+ let F = hitsFN[hf + 7u];
190+ let vs = hitsFN[hf + 6u] + DT * F / hitsFN[hf + 2u];
191+ hitsFN[hf + 6u] = vs; hitsFN[hf + 5u] = hitsFN[hf + 5u] + DT * vs;
192+ hitsFN[hf + 8u] = max(hitsFN[hf + 8u], F);
193+ if (F > 0.0) { hitsU[hu + 1u] = hitsU[hu + 1u] + 1u; }
194+ }
195+ sync[0] = bitcast<f32>(step + 1u);
196+ }
197+}
198+
199+// ------------------------------------------------------------------ gather pass (listener)
200+@group(2) @binding(0) var<storage, read> syncG: array<f32>;
201+@group(2) @binding(1) var<storage, read> radR: array<f32>;
202+@group(2) @binding(2) var<storage, read> radCoef: array<f32>;
203+@group(2) @binding(3) var<storage, read_write> pressure: array<f32>;
204+@group(2) @binding(4) var<uniform> chunk: Chunk;
205+
206+var<workgroup> redF: array<f32, 256>;
207+var<workgroup> redB: array<f32, 256>;
208+
209+@compute @workgroup_size(256)
210+fn gatherPass(@builtin(local_invocation_id) lid: vec3<u32>) {
211+ let t = lid.x;
212+ let step = bitcast<u32>(syncG[0]);
213+ let j = i32((step - 1u) / M) - i32(W / 2u);
214+ var fs = 0.0; var bs = 0.0;
215+ if (j >= 0) {
216+ for (var e = t; e < E; e += 256u) {
217+ let rc = 6u * e;
218+ let jf = j - i32(radCoef[rc + 2u]); let jb = j - i32(radCoef[rc + 4u]);
219+ if (jf >= 1) {
220+ let fr = radCoef[rc + 3u];
221+ fs += radCoef[rc] * (radR[OFF_HIST + e * D + (u32(jf) % D)] * (1.0 - fr) + radR[OFF_HIST + e * D + (u32(jf - 1) % D)] * fr);
222+ }
223+ if (jb >= 1) {
224+ let fr = radCoef[rc + 5u];
225+ bs += radCoef[rc + 1u] * (radR[OFF_HIST + e * D + (u32(jb) % D)] * (1.0 - fr) + radR[OFF_HIST + e * D + (u32(jb - 1) % D)] * fr);
226+ }
227+ }
228+ }
229+ redF[t] = fs; redB[t] = bs;
230+ workgroupBarrier();
231+ for (var s = 128u; s > 0u; s = s >> 1u) {
232+ if (t < s) { redF[t] += redF[t + s]; redB[t] += redB[t + s]; }
233+ workgroupBarrier();
234+ }
235+ if (t == 0u && j >= 0) {
236+ let idx = j - chunk.base;
237+ if (idx >= 0 && u32(idx) < chunk.size) { pressure[u32(idx)] = redF[0]; pressure[chunk.size + u32(idx)] = redB[0]; }
238+ }
239+}
240+`;
241+}
src/gpu/simulator.tsadded+192−0View file
@@ -0,0 +1,192 @@
1+// WebGPU time loop: packs the set-up into storage buffers, compiles the three kernels and runs
2+// the sub-steps of a chunk of audio samples as one command buffer. Works in a browser and under
3+// Node with the `webgpu` (Dawn) package, which the tests use.
4+import { HOLD_HEIGHT } from "../model/exciter.ts";
5+import { strainEnergies } from "../model/forces.ts";
6+import { DECIMATION_SPAN } from "../model/radiation.ts";
7+import type { Setup } from "../model/setup.ts";
8+import type { ChunkResult, Diagnostics, SimulatorBackend } from "../model/simulator.ts";
9+import { ELEM_STRIDE, HIT_HEADER, HITU_HEADER, NODE_STRIDE, SYNC_HEADER, WORKGROUP_NODES, shaderSource } from "./shaders.ts";
10+
11+// evaluated lazily: under Node the WebGPU globals are installed after this module is imported
12+const storageUsage = () => GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC;
13+
14+export class GpuSimulator implements SimulatorBackend {
15+ readonly kind = "gpu" as const;
16+ readonly setup: Setup; readonly device: GPUDevice;
17+ samplesDone = 0;
18+ private step = 0;
19+ private readonly G: number; private readonly HF: number; private readonly HU: number;
20+ private readonly buffers: Record<string, GPUBuffer> = {};
21+ private readonly pipelines: Record<string, GPUComputePipeline> = {};
22+ private readonly bindGroups: Record<string, GPUBindGroup> = {};
23+ private readonly chunkCapacity: number;
24+ private readonly stagingPressure: GPUBuffer; private readonly stagingState: GPUBuffer; private readonly stagingHits: GPUBuffer; private readonly stagingHitsU: GPUBuffer;
25+
26+ constructor(device: GPUDevice, setup: Setup, chunkCapacity = 8192) {
27+ this.device = device; this.setup = setup; this.chunkCapacity = chunkCapacity;
28+ const { mesh, shell: sh, schedule: sc, radiation: rad, substeps: M, dt } = setup;
29+ const N = mesh.n, E = mesh.e, K = sc.K, P = sc.P, W = DECIMATION_SPAN, D = rad.ringLength;
30+ this.G = Math.ceil(N / WORKGROUP_NODES);
31+ this.HF = HIT_HEADER + 5 * P; this.HU = HITU_HEADER + 3 * P;
32+ const G = this.G, HF = this.HF, HU = this.HU;
33+
34+ // ---- pack the set-up
35+ const elemF = new Float32Array(ELEM_STRIDE * E + W * M);
36+ for (let e = 0; e < E; e++) {
37+ const b = ELEM_STRIDE * e;
38+ for (let i = 0; i < 3; i++) { elemF[b + i] = mesh.Gx[3 * e + i]; elemF[b + 3 + i] = mesh.Gy[3 * e + i]; }
39+ elemF[b + 6] = mesh.area[e]; elemF[b + 7] = sh.z0x[e]; elemF[b + 8] = sh.z0y[e];
40+ elemF[b + 9] = sh.Cm[3 * e]; elemF[b + 10] = sh.Cm[3 * e + 1]; elemF[b + 11] = sh.Cm[3 * e + 2];
41+ elemF[b + 12] = 2 * setup.config.material.sigma1 * sh.rhoHA[e];
42+ for (let i = 0; i < 81; i++) elemF[b + 13 + i] = sh.Ke[81 * e + i];
43+ }
44+ elemF.set(rad.taps, ELEM_STRIDE * E);
45+ const elemU = new Uint32Array(3 * E); elemU.set(mesh.tri);
46+ const nodeF = new Float32Array(NODE_STRIDE * N);
47+ for (let n = 0; n < N; n++) {
48+ const b = NODE_STRIDE * n;
49+ for (let d = 0; d < 5; d++) { nodeF[b + d] = sh.Minv[5 * n + d]; nodeF[b + 5 + d] = sh.M[5 * n + d]; }
50+ for (let k = 0; k < 6; k++) for (let d = 0; d < 5; d++) nodeF[b + 10 + 5 * k + d] = sh.Rm[(k * N + n) * 5 + d];
51+ }
52+ const adj = new Uint32Array(N + 1 + sh.adjEntries.length); adj.set(sh.adjOffsets); adj.set(sh.adjEntries, N + 1);
53+ const hitsF = new Float32Array(HF * K), hitsU = new Uint32Array(HU * K);
54+ for (let k = 0; k < K; k++) {
55+ const hf = HF * k, hu = HU * k;
56+ hitsF[hf] = sc.kPoint[k]; hitsF[hf + 1] = sc.alpha[k]; hitsF[hf + 2] = sc.mass[k]; hitsF[hf + 3] = sc.gap[k];
57+ hitsF[hf + 4] = sc.velocity[k]; hitsF[hf + 5] = HOLD_HEIGHT; hitsF[hf + 6] = 0; hitsF[hf + 7] = 0; hitsF[hf + 8] = 0;
58+ for (let p = 0; p < P; p++) {
59+ for (let i = 0; i < 3; i++) { hitsF[hf + HIT_HEADER + 3 * p + i] = sc.weights[(k * P + p) * 3 + i]; hitsU[hu + HITU_HEADER + 3 * p + i] = sc.nodes[(k * P + p) * 3 + i]; }
60+ hitsF[hf + HIT_HEADER + 3 * P + p] = sc.offsets[k * P + p];
61+ }
62+ hitsU[hu] = sc.releaseStep[k]; hitsU[hu + 1] = 0;
63+ }
64+ const radCoef = new Float32Array(6 * E);
65+ for (let e = 0; e < E; e++) {
66+ radCoef[6 * e] = rad.coefF[e]; radCoef[6 * e + 1] = rad.coefB[e]; radCoef[6 * e + 2] = rad.i0F[e];
67+ radCoef[6 * e + 3] = rad.frF[e]; radCoef[6 * e + 4] = rad.i0B[e]; radCoef[6 * e + 5] = rad.frB[e];
68+ }
69+ const mk = (name: string, data: Float32Array | Uint32Array, usage = storageUsage()) => {
70+ const buf = device.createBuffer({ label: name, size: Math.max(16, data.byteLength), usage });
71+ device.queue.writeBuffer(buf, 0, data.buffer, data.byteOffset, data.byteLength);
72+ this.buffers[name] = buf;
73+ return buf;
74+ };
75+ mk("elemF", elemF); mk("elemU", elemU); mk("nodeF", nodeF); mk("adj", adj); mk("radCoef", radCoef);
76+ mk("hitsF", hitsF); mk("hitsU", hitsU);
77+ mk("state", new Float32Array(12 * N)); mk("elemForce", new Float32Array(15 * E));
78+ mk("sync", new Float32Array(SYNC_HEADER + 2 * G * 12)); mk("rad", new Float32Array(E * W + E * D));
79+ mk("pressure", new Float32Array(2 * chunkCapacity));
80+ const chunkBuf = device.createBuffer({ label: "chunk", size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
81+ this.buffers.chunk = chunkBuf;
82+ const staging = (size: number) => device.createBuffer({ size, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST });
83+ this.stagingPressure = staging(8 * chunkCapacity); this.stagingState = staging(12 * N * 4);
84+ this.stagingHits = staging(Math.max(16, HF * K * 4)); this.stagingHitsU = staging(Math.max(16, HU * K * 4));
85+
86+ // ---- pipelines
87+ const module = device.createShaderModule({ code: shaderSource({ N, E, K, P, M, W, D, G, dt, sigma0: setup.config.material.sigma0, nl: sh.nl }) });
88+ const bgl = (entries: [number, "read-only-storage" | "storage" | "uniform"][]) => device.createBindGroupLayout({
89+ entries: entries.map(([binding, type]) => ({ binding, visibility: GPUShaderStage.COMPUTE, buffer: { type } })),
90+ });
91+ const L0 = bgl([[0, "read-only-storage"], [1, "read-only-storage"], [2, "read-only-storage"], [3, "storage"], [4, "storage"], [5, "read-only-storage"], [6, "storage"], [7, "storage"]]);
92+ const L1 = bgl([[0, "storage"], [1, "read-only-storage"], [2, "read-only-storage"], [3, "read-only-storage"], [4, "storage"], [5, "storage"], [6, "storage"]]);
93+ const L2 = bgl([[0, "read-only-storage"], [1, "read-only-storage"], [2, "read-only-storage"], [3, "storage"], [4, "uniform"]]);
94+ const empty = device.createBindGroupLayout({ entries: [] });
95+ const mkPipe = (entryPoint: string, layouts: GPUBindGroupLayout[]) => device.createComputePipeline({
96+ label: entryPoint, layout: device.createPipelineLayout({ bindGroupLayouts: layouts }), compute: { module, entryPoint },
97+ });
98+ this.pipelines.element = mkPipe("elementPass", [L0]);
99+ this.pipelines.node = mkPipe("nodePass", [empty, L1]);
100+ this.pipelines.gather = mkPipe("gatherPass", [empty, empty, L2]);
101+ const B = this.buffers;
102+ const bg = (layout: GPUBindGroupLayout, bufs: GPUBuffer[]) => device.createBindGroup({ layout, entries: bufs.map((buffer, binding) => ({ binding, resource: { buffer } })) });
103+ this.bindGroups.element = bg(L0, [B.elemF, B.elemU, B.state, B.elemForce, B.hitsF, B.hitsU, B.sync, B.rad]);
104+ this.bindGroups.node = bg(L1, [B.state, B.elemForce, B.nodeF, B.adj, B.hitsF, B.hitsU, B.sync]);
105+ this.bindGroups.gather = bg(L2, [B.sync, B.rad, B.radCoef, B.pressure, B.chunk]);
106+ this.bindGroups.empty = device.createBindGroup({ layout: empty, entries: [] });
107+ }
108+
109+ /** Audio sample completed by the gather pass at sub-step n, or -1 when none is due. */
110+ private sampleDueAt(n: number): number {
111+ const M = this.setup.substeps, W = DECIMATION_SPAN;
112+ if (n < 1 || (n - 1) % M !== 0) return -1;
113+ const j0 = (n - 1) / M;
114+ if (j0 === 0) return -1;
115+ const j = j0 - W / 2;
116+ return j >= 0 ? j : -1;
117+ }
118+
119+ async runChunk(nSamples: number): Promise<ChunkResult> {
120+ if (nSamples > this.chunkCapacity) throw new Error("chunk larger than the pressure buffer");
121+ const { device, setup } = this, M = setup.substeps, E = setup.mesh.e, K = setup.schedule.K;
122+ const n0 = this.step, n1 = n0 + nSamples * M;
123+ let base = -1, count = 0;
124+ for (let n = n0; n < n1; n++) { const j = this.sampleDueAt(n); if (j >= 0) { if (base < 0) base = j; count++; } }
125+ device.queue.writeBuffer(this.buffers.chunk, 0, new Int32Array([base < 0 ? 0 : base, Math.max(count, 1), 0, 0]));
126+ const enc = device.createCommandEncoder();
127+ enc.clearBuffer(this.buffers.pressure);
128+ const pass = enc.beginComputePass();
129+ const wgE = Math.ceil((E + K) / 64), wgN = this.G;
130+ for (let n = n0; n < n1; n++) {
131+ pass.setPipeline(this.pipelines.element); pass.setBindGroup(0, this.bindGroups.element); pass.dispatchWorkgroups(wgE);
132+ if (this.sampleDueAt(n) >= 0) {
133+ pass.setPipeline(this.pipelines.gather); pass.setBindGroup(0, this.bindGroups.empty); pass.setBindGroup(1, this.bindGroups.empty);
134+ pass.setBindGroup(2, this.bindGroups.gather); pass.dispatchWorkgroups(1);
135+ }
136+ pass.setPipeline(this.pipelines.node); pass.setBindGroup(0, this.bindGroups.empty); pass.setBindGroup(1, this.bindGroups.node); pass.dispatchWorkgroups(wgN);
137+ }
138+ pass.end();
139+ enc.copyBufferToBuffer(this.buffers.pressure, 0, this.stagingPressure, 0, 8 * this.chunkCapacity);
140+ device.queue.submit([enc.finish()]);
141+ this.step = n1; this.samplesDone += nSamples;
142+ await this.stagingPressure.mapAsync(GPUMapMode.READ);
143+ const all = new Float32Array(this.stagingPressure.getMappedRange().slice(0));
144+ this.stagingPressure.unmap();
145+ const size = Math.max(count, 1);
146+ return { firstSample: base < 0 ? this.samplesDone : base, pressureFront: all.slice(0, count), pressureBack: all.slice(size, size + count) };
147+ }
148+
149+ private async read(buffer: GPUBuffer, staging: GPUBuffer, bytes: number): Promise<ArrayBuffer> {
150+ const enc = this.device.createCommandEncoder();
151+ enc.copyBufferToBuffer(buffer, 0, staging, 0, bytes);
152+ this.device.queue.submit([enc.finish()]);
153+ await staging.mapAsync(GPUMapMode.READ);
154+ const out = staging.getMappedRange().slice(0);
155+ staging.unmap();
156+ return out;
157+ }
158+
159+ async diagnostics(): Promise<Diagnostics> {
160+ const { setup } = this, sh = setup.shell, N = setup.mesh.n, sc = setup.schedule, HF = this.HF, HU = this.HU;
161+ const st = new Float32Array(await this.read(this.buffers.state, this.stagingState, 12 * N * 4));
162+ const hf = new Float32Array(await this.read(this.buffers.hitsF, this.stagingHits, Math.max(16, HF * sc.K * 4)));
163+ const hu = new Uint32Array(await this.read(this.buffers.hitsU, this.stagingHitsU, Math.max(16, HU * sc.K * 4)));
164+ let ke = 0, woh = 0;
165+ for (let i = 0; i < 5 * N; i++) ke += 0.5 * sh.M[i] * st[5 * N + i] * st[5 * N + i];
166+ for (let n = 0; n < N; n++) woh = Math.max(woh, Math.abs(st[5 * n + 2] / sh.hNode[n]));
167+ const { membrane, bending } = strainEnergies(sh, st.subarray(0, 5 * N));
168+ let ex = 0;
169+ const hits = [];
170+ for (let k = 0; k < sc.K; k++) {
171+ ex += 0.5 * hf[HF * k + 2] * hf[HF * k + 6] ** 2;
172+ hits.push({ peakForce: hf[HF * k + 8], contactTime: hu[HU * k + 1] * setup.dt, velocity: hf[HF * k + 6], height: hf[HF * k + 5] });
173+ }
174+ return { time: this.step * setup.dt, kinetic: ke, membrane, bending, exciters: ex, maxWoverH: woh, hits };
175+ }
176+
177+ async displacement(): Promise<Float32Array> {
178+ const N = this.setup.mesh.n;
179+ const st = new Float32Array(await this.read(this.buffers.state, this.stagingState, 12 * N * 4));
180+ const w = new Float32Array(N);
181+ for (let n = 0; n < N; n++) w[n] = st[5 * n + 2];
182+ return w;
183+ }
184+
185+ /** The state buffer (q at offset 0, 5 floats per node), for a renderer that reads w directly. */
186+ get stateBuffer(): GPUBuffer { return this.buffers.state; }
187+
188+ dispose() {
189+ for (const b of Object.values(this.buffers)) b.destroy();
190+ this.stagingPressure.destroy(); this.stagingState.destroy(); this.stagingHits.destroy(); this.stagingHitsU.destroy();
191+ }
192+}
src/main.tsadded+208−0View file
@@ -0,0 +1,208 @@
1+import { Stft } from "./analysis/spectrogram.ts";
2+import { Player, encodeWav, scaleForPlayback } from "./audio/player.ts";
3+import { PRESETS } from "./config/presets.ts";
4+import type { SimulationConfig } from "./config/schema.ts";
5+import { configToYaml, parseConfigYaml, type Issue } from "./config/yaml.ts";
6+import { CpuSimulator } from "./cpu/simulator.ts";
7+import { GpuSimulator } from "./gpu/simulator.ts";
8+import { DECIMATION_SPAN } from "./model/radiation.ts";
9+import { buildSetup, type Setup } from "./model/setup.ts";
10+import type { ChunkResult, Diagnostics, SimulatorBackend } from "./model/simulator.ts";
11+import { Runner } from "./run.ts";
12+import { YamlEditor } from "./ui/editor.ts";
13+import { ConfigForm } from "./ui/form.ts";
14+import { PlateView } from "./ui/plateView.ts";
15+import { Readouts, formatSummary } from "./ui/readouts.ts";
16+import { SpectrogramView } from "./ui/spectrogramView.ts";
17+
18+const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
19+const params = new URLSearchParams(location.search);
20+
21+// ---------------------------------------------------------------- WebGPU device
22+let device: GPUDevice | null = null;
23+if (params.get("backend") !== "cpu" && "gpu" in navigator) {
24+ try {
25+ const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" });
26+ device = (await adapter?.requestDevice()) ?? null;
27+ } catch (err) { console.warn("WebGPU unavailable", err); }
28+}
29+$("backend").textContent = device ? "backend: WebGPU compute (time loop on the GPU)" : "backend: CPU fallback (no WebGPU); use a coarse mesh and a short duration";
30+
31+// ---------------------------------------------------------------- config editing (YAML <-> form)
32+let config: SimulationConfig | null = null;
33+let issues: Issue[] = [];
34+const issuesEl = $<HTMLUListElement>("issues");
35+function showIssues() {
36+ issuesEl.replaceChildren();
37+ if (!issues.length) { const li = document.createElement("li"); li.className = "ok"; li.textContent = "configuration OK"; issuesEl.appendChild(li); return; }
38+ for (const i of issues) { const li = document.createElement("li"); li.innerHTML = `<code>${i.path || "document"}</code>: ${i.message}`; issuesEl.appendChild(li); }
39+}
40+const presetSel = $<HTMLSelectElement>("preset");
41+for (const p of PRESETS) { const o = document.createElement("option"); o.value = p.id; o.textContent = p.name; presetSel.appendChild(o); }
42+const initialPreset = PRESETS.find((p) => p.id === params.get("preset")) ?? PRESETS[0];
43+presetSel.value = initialPreset.id;
44+
45+let form: ConfigForm;
46+function fromYaml(text: string) {
47+ const r = parseConfigYaml(text);
48+ issues = r.issues;
49+ if (r.config) { config = r.config; form.setConfig(config); }
50+ showIssues();
51+}
52+const editor = new YamlEditor($("editor"), initialPreset.yaml, fromYaml);
53+form = new ConfigForm($("form"), parseConfigYaml(initialPreset.yaml).config!, (cfg) => {
54+ config = cfg;
55+ editor.setText(configToYaml(cfg));
56+ issues = parseConfigYaml(editor.text).issues; showIssues();
57+});
58+fromYaml(initialPreset.yaml);
59+presetSel.onchange = () => { const p = PRESETS.find((x) => x.id === presetSel.value)!; editor.setText(p.yaml); fromYaml(p.yaml); };
60+$("import").onclick = () => $<HTMLInputElement>("file").click();
61+$<HTMLInputElement>("file").onchange = async (ev) => {
62+ const file = (ev.target as HTMLInputElement).files?.[0];
63+ if (!file) return;
64+ const text = await file.text();
65+ editor.setText(text); fromYaml(text);
66+ (ev.target as HTMLInputElement).value = "";
67+};
68+function download(blob: Blob, name: string) {
69+ const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = name; a.click();
70+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
71+}
72+$("download-yaml").onclick = () => download(new Blob([editor.text], { type: "text/yaml" }), `${config?.output.prefix ?? "cymbal"}.yaml`);
73+for (const b of document.querySelectorAll<HTMLButtonElement>(".tabs button")) {
74+ b.onclick = () => {
75+ for (const x of document.querySelectorAll<HTMLButtonElement>(".tabs button")) x.classList.toggle("active", x === b);
76+ for (const t of document.querySelectorAll<HTMLElement>(".tab")) t.classList.toggle("hidden", t.id !== `tab-${b.dataset.tab}`);
77+ };
78+}
79+
80+// ---------------------------------------------------------------- views
81+const plate = new PlateView($("plate"), device);
82+const specView = new SpectrogramView($("spectrogram"));
83+const readouts = new Readouts($("readouts"));
84+const player = new Player();
85+const exag = $<HTMLInputElement>("exag");
86+const applyExag = () => { plate.exaggeration = Math.pow(10, Number(exag.value)); $("exag-text").textContent = `x${plate.exaggeration.toFixed(0)}`; plate.render(); };
87+exag.oninput = applyExag; applyExag();
88+$("reset-view").onclick = () => plate.resetView();
89+window.addEventListener("resize", () => { plate.render(); specView.draw(); });
90+
91+// ---------------------------------------------------------------- run state
92+let setup: Setup | null = null;
93+let runner: Runner | null = null;
94+let backend: SimulatorBackend | null = null;
95+let front = new Float32Array(0), back = new Float32Array(0), frontier = 0, pushed = 0;
96+let stft: Stft | null = null;
97+const signalSel = $<HTMLSelectElement>("signal");
98+const signal = () => {
99+ const out = new Float32Array(frontier);
100+ if (signalSel.value === "rayleigh") out.set(front.subarray(0, frontier));
101+ else for (let i = 0; i < frontier; i++) out[i] = front[i] - back[i];
102+ return out;
103+};
104+function rebuildSpectrogram() {
105+ if (!setup) return;
106+ stft = new Stft(setup.fs, 1024, 256); pushed = 0;
107+ specView.reset(stft, setup.config.numerics.duration);
108+ pushSpectrogram();
109+}
110+function pushSpectrogram() {
111+ if (!stft || !setup) return;
112+ if (frontier > pushed) { const s = signal(); stft.push(s.subarray(pushed, frontier)); pushed = frontier; }
113+ specView.frontierTime = frontier / setup.fs;
114+ specView.update();
115+ const c = stft.centroid.length ? stft.centroid[stft.centroid.length - 1] : 0;
116+ $("spec-info").textContent = `${stft.columns.length} columns, centroid ${c.toFixed(0)} Hz`;
117+}
118+signalSel.onchange = rebuildSpectrogram;
119+
120+const startBtn = $<HTMLButtonElement>("start"), pauseBtn = $<HTMLButtonElement>("pause"), stopBtn = $<HTMLButtonElement>("stop");
121+const playBtn = $<HTMLButtonElement>("play"), stopAudioBtn = $<HTMLButtonElement>("stop-audio"), wavBtn = $<HTMLButtonElement>("download-wav");
122+const progress = $<HTMLProgressElement>("progress");
123+
124+async function start() {
125+ if (!config || issues.length) { readouts.log("fix the configuration issues first"); return; }
126+ startBtn.disabled = true; presetSel.disabled = true;
127+ readouts.clearLog();
128+ const cfg = structuredClone(config);
129+ try {
130+ readouts.log("building the set-up ...");
131+ setup = await buildSetup(cfg, async (phase) => { readouts.log(` ${phase}`); await new Promise((r) => setTimeout(r, 0)); });
132+ } catch (err) {
133+ readouts.log(`set-up failed: ${(err as Error).message}`); startBtn.disabled = false; presetSel.disabled = false; return;
134+ }
135+ $("summary").textContent = formatSummary(setup.summary);
136+ readouts.log(`set-up done in ${setup.summary.setupSeconds.toFixed(1)} s: ${setup.summary.nodes} nodes, ${setup.summary.substeps} sub-steps per sample, ${setup.summary.nSteps} steps`);
137+ if (setup.summary.slopeWarning) readouts.log(`warning: max slope ${setup.summary.maxSlope.toFixed(2)} exceeds the shallow-shell bound ${cfg.profile.max_slope}`);
138+ backend?.dispose();
139+ backend = device ? new GpuSimulator(device, setup, 8192) : new CpuSimulator(setup);
140+ plate.setMesh(setup.mesh, setup.shell.z0Node, setup.shell.hE.reduce((a, b) => Math.min(a, b), Infinity), backend instanceof GpuSimulator ? backend.stateBuffer : null);
141+ $("plate-info").textContent = `${setup.mesh.n} nodes, ${plate.usesWebGPU ? "WebGPU" : "2D"} view`;
142+ front = new Float32Array(setup.nSamples); back = new Float32Array(setup.nSamples); frontier = 0;
143+ rebuildSpectrogram();
144+ player.stop(); playBtn.disabled = true; wavBtn.disabled = true;
145+ pauseBtn.disabled = false; stopBtn.disabled = false; pauseBtn.textContent = "Pause";
146+ const kinds = setup.patches.map((p) => p.hit.exciter.kind);
147+ let lastPlate = 0;
148+ runner = new Runner(backend, setup, {
149+ onChunk(res: ChunkResult) {
150+ const n = res.pressureFront.length;
151+ if (n && setup) {
152+ front.set(res.pressureFront.subarray(0, Math.min(n, setup.nSamples - res.firstSample)), res.firstSample);
153+ back.set(res.pressureBack.subarray(0, Math.min(n, setup.nSamples - res.firstSample)), res.firstSample);
154+ frontier = Math.min(setup.nSamples, res.firstSample + n);
155+ }
156+ pushSpectrogram();
157+ playBtn.disabled = frontier === 0; wavBtn.disabled = frontier === 0;
158+ if (performance.now() - lastPlate > 100) {
159+ lastPlate = performance.now();
160+ if (backend instanceof GpuSimulator) plate.render(); else void backend!.displacement().then((w) => plate.setDisplacement(w));
161+ }
162+ },
163+ onDiagnostics(d: Diagnostics) {
164+ readouts.setDiagnostics(d, kinds);
165+ if (!Number.isFinite(d.kinetic + d.membrane + d.bending)) { readouts.log("energy is not finite: the run has blown up (lower numerics.cfl_safety)"); runner?.stop(); }
166+ },
167+ onProgress(done, wall) {
168+ if (!setup) return;
169+ progress.value = done / setup.nSamples;
170+ const audio = done / setup.fs;
171+ $("progress-text").textContent = `${audio.toFixed(2)} / ${setup.config.numerics.duration.toFixed(2)} s (${wall.toFixed(0)} s wall, ${(audio / Math.max(wall, 1e-3)).toFixed(3)}x real time)`;
172+ },
173+ onDone(stopped) {
174+ readouts.log(stopped ? "stopped" : `done: ${frontier} samples of pressure (the last ${DECIMATION_SPAN / 2} samples of the decimation window are not produced)`);
175+ startBtn.disabled = false; presetSel.disabled = false; pauseBtn.disabled = true; stopBtn.disabled = true;
176+ void backend?.diagnostics().then((d) => readouts.setDiagnostics(d, kinds));
177+ },
178+ });
179+ void runner.run();
180+}
181+startBtn.onclick = () => void start();
182+pauseBtn.onclick = () => { if (!runner) return; if (runner.isPaused) { runner.unpause(); pauseBtn.textContent = "Pause"; } else { runner.pause(); pauseBtn.textContent = "Continue"; } };
183+stopBtn.onclick = () => runner?.stop();
184+
185+// ---------------------------------------------------------------- playback of the completed portion
186+const position = $<HTMLInputElement>("position"), timeEl = $("time"), gain = $<HTMLInputElement>("gain"), normalize = $<HTMLInputElement>("normalize");
187+gain.oninput = () => { player.setGainDb(Number(gain.value)); $("gain-text").textContent = `${gain.value} dB`; };
188+function tick() {
189+ if (player.playing && setup) {
190+ const t = player.position();
191+ position.value = String(t / setup.config.numerics.duration); timeEl.textContent = `${t.toFixed(3)} s`;
192+ specView.cursorTime = t; specView.draw();
193+ }
194+ requestAnimationFrame(tick);
195+}
196+requestAnimationFrame(tick);
197+playBtn.onclick = () => {
198+ if (!setup || frontier === 0) return;
199+ player.setGainDb(Number(gain.value));
200+ player.play(scaleForPlayback(signal(), normalize.checked), setup.fs, Number(position.value) * setup.config.numerics.duration);
201+ stopAudioBtn.disabled = false;
202+};
203+player.onEnded = () => { stopAudioBtn.disabled = true; specView.cursorTime = null; specView.draw(); };
204+stopAudioBtn.onclick = () => { player.stop(); stopAudioBtn.disabled = true; specView.cursorTime = null; specView.draw(); };
205+position.oninput = () => { if (setup) { const t = Number(position.value) * setup.config.numerics.duration; timeEl.textContent = `${t.toFixed(3)} s`; specView.cursorTime = t; specView.draw(); } };
206+wavBtn.onclick = () => { if (setup) download(encodeWav(scaleForPlayback(signal(), normalize.checked), setup.fs), `${setup.config.output.prefix}_${signalSel.value}.wav`); };
207+
208+if (params.get("autostart") === "1") void start();
src/model/dkt.tsadded+64−0View file
@@ -0,0 +1,64 @@
1+// The Discrete Kirchhoff Triangle (Batoz, Bathe, Ho 1980): 9 x 9 bending stiffness for nodal
2+// DOFs (w, theta_x, theta_y) x 3 with theta_x = dw/dy, theta_y = -dw/dx. This is the same
3+// element as cymsim/elements.py, with the derivatives of the rotation shape functions written
4+// out instead of obtained by automatic differentiation.
5+
6+const GAUSS = [[1 / 6, 1 / 6], [2 / 3, 1 / 6], [1 / 6, 2 / 3]];
7+
8+/** Quadratic shape functions N1..N6 and their (xi, eta) derivatives at (xi, eta). */
9+function shapes(xi: number, eta: number) {
10+ const lam = 1 - xi - eta;
11+ const N = [lam * (2 * lam - 1), xi * (2 * xi - 1), eta * (2 * eta - 1), 4 * xi * eta, 4 * eta * lam, 4 * xi * lam];
12+ const Nxi = [-(4 * lam - 1), 4 * xi - 1, 0, 4 * eta, -4 * eta, 4 * (lam - xi)];
13+ const Neta = [-(4 * lam - 1), 0, 4 * eta - 1, 4 * xi, 4 * (lam - eta), -4 * xi];
14+ return { N, Nxi, Neta };
15+}
16+
17+/** Row vectors Hx, Hy (length 9) as linear combinations of the six shape values N. */
18+function H(N: number[], a: number[], b: number[], c: number[], d: number[], e: number[]): [number[], number[]] {
19+ const [N1, N2, N3, N4, N5, N6] = N;
20+ const [a4, a5, a6] = a, [b4, b5, b6] = b, [c4, c5, c6] = c, [d4, d5, d6] = d, [e4, e5, e6] = e;
21+ const Hx = [1.5 * (a6 * N6 - a5 * N5), b5 * N5 + b6 * N6, N1 - c5 * N5 - c6 * N6,
22+ 1.5 * (a4 * N4 - a6 * N6), b6 * N6 + b4 * N4, N2 - c4 * N4 - c6 * N6,
23+ 1.5 * (a5 * N5 - a4 * N4), b4 * N4 + b5 * N5, N3 - c4 * N4 - c5 * N5];
24+ const Hy = [1.5 * (d6 * N6 - d5 * N5), -N1 + e5 * N5 + e6 * N6, -b5 * N5 - b6 * N6,
25+ 1.5 * (d4 * N4 - d6 * N6), -N2 + e4 * N4 + e6 * N6, -b6 * N6 - b4 * N4,
26+ 1.5 * (d5 * N5 - d4 * N4), -N3 + e4 * N4 + e5 * N5, -b4 * N4 - b5 * N5];
27+ return [Hx, Hy];
28+}
29+
30+/** DKT stiffness (row-major 9 x 9) of the triangle with nodes (xe[i], ye[i]), rigidity D, Poisson ratio nu. */
31+export function dktStiffness(xe: ArrayLike<number>, ye: ArrayLike<number>, D: number, nu: number): Float64Array {
32+ const I = [1, 2, 0], J = [2, 0, 1]; // sides k = 4, 5, 6 <-> ij = 23, 31, 12
33+ const a: number[] = [], b: number[] = [], c: number[] = [], d: number[] = [], e: number[] = [];
34+ for (let k = 0; k < 3; k++) {
35+ const xij = xe[I[k]] - xe[J[k]], yij = ye[I[k]] - ye[J[k]], l2 = xij * xij + yij * yij;
36+ a.push(-xij / l2); b.push((0.75 * xij * yij) / l2); c.push((0.25 * xij * xij - 0.5 * yij * yij) / l2);
37+ d.push(-yij / l2); e.push((0.25 * yij * yij - 0.5 * xij * xij) / l2);
38+ }
39+ const x21 = xe[1] - xe[0], x31 = xe[2] - xe[0], y21 = ye[1] - ye[0], y31 = ye[2] - ye[0];
40+ const det = x21 * y31 - x31 * y21;
41+ const xi_x = y31 / det, xi_y = -x31 / det, eta_x = -y21 / det, eta_y = x21 / det;
42+ const Db = [[D, D * nu, 0], [D * nu, D, 0], [0, 0, D * 0.5 * (1 - nu)]];
43+ const Ke = new Float64Array(81);
44+ for (const [xi, eta] of GAUSS) {
45+ const { Nxi, Neta } = shapes(xi, eta);
46+ const [Hx_xi, Hy_xi] = H(Nxi, a, b, c, d, e);
47+ const [Hx_eta, Hy_eta] = H(Neta, a, b, c, d, e);
48+ const B: number[][] = [[], [], []];
49+ for (let i = 0; i < 9; i++) {
50+ const Hx_x = Hx_xi[i] * xi_x + Hx_eta[i] * eta_x, Hx_y = Hx_xi[i] * xi_y + Hx_eta[i] * eta_y;
51+ const Hy_x = Hy_xi[i] * xi_x + Hy_eta[i] * eta_x, Hy_y = Hy_xi[i] * xi_y + Hy_eta[i] * eta_y;
52+ B[0][i] = Hx_x; B[1][i] = Hy_y; B[2][i] = Hx_y + Hy_x;
53+ }
54+ const w = (0.5 * det) / 3;
55+ for (let i = 0; i < 9; i++) {
56+ for (let j = 0; j < 9; j++) {
57+ let s = 0;
58+ for (let k = 0; k < 3; k++) for (let l = 0; l < 3; l++) s += B[k][i] * Db[k][l] * B[l][j];
59+ Ke[9 * i + j] += w * s;
60+ }
61+ }
62+ }
63+ return Ke;
64+}
src/model/exciter.tsadded+83−0View file
@@ -0,0 +1,83 @@
1+// Exciters and contact patches, as in cymsim/exciter.py: a rigid mass on P power-law springs
2+// F = k delta^alpha along a line (stick) or on a small disk (mallet), released at its hit time
3+// just above the moving surface. All hits are stacked into arrays padded to the largest patch.
4+import type { Hit } from "../config/schema.ts";
5+import type { CymbalProfile } from "./geometry.ts";
6+import { planeResiduals } from "./linalg.ts";
7+import { locate, type Mesh } from "./mesh.ts";
8+
9+export const HOLD_HEIGHT = 1e3;
10+
11+export interface ContactPatch {
12+ hit: Hit; x: Float64Array; y: Float64Array; nodes: Uint32Array; weights: Float64Array; offsets: Float64Array;
13+ kTotal: number; alpha: number; mass: number; gap: number; velocity: number; time: number;
14+}
15+
16+export function patchEnergy(p: ContactPatch): number { return 0.5 * p.mass * p.velocity * p.velocity; }
17+export function maxCompression(p: ContactPatch): number { return Math.pow(((p.alpha + 1) * patchEnergy(p)) / p.kTotal, 1 / (p.alpha + 1)); }
18+
19+/** Contact time against a rigid wall, 2 c(alpha) d_max / v with c = int_0^1 dx / sqrt(1 - x^(alpha+1)). */
20+export function freeContactTime(p: ContactPatch): number {
21+ // substitute x = 1 - s^2 to remove the endpoint singularity, then Simpson on s in [0, 1]
22+ const a1 = p.alpha + 1, n = 2000;
23+ const f = (s: number) => (s === 0 ? 2 / Math.sqrt(a1) : (2 * s) / Math.sqrt(1 - Math.pow(1 - s * s, a1)));
24+ let sum = f(0) + f(1);
25+ for (let i = 1; i < n; i++) sum += (i % 2 ? 4 : 2) * f(i / n);
26+ const c = sum / (3 * n);
27+ return (2 * c * maxCompression(p)) / p.velocity;
28+}
29+
30+export function buildPatch(hit: Hit, mesh: Mesh, profile: CymbalProfile): ContactPatch {
31+ const ex = hit.exciter;
32+ const xs = hit.r * Math.cos(hit.phi), ys = hit.r * Math.sin(hit.phi);
33+ let px: number[], py: number[], kTotal: number, alpha: number;
34+ if (ex.kind === "stick") {
35+ const ang = hit.phi + ex.patch_angle, P = Math.max(1, Math.floor(ex.patch_points));
36+ px = []; py = [];
37+ for (let i = 0; i < P; i++) {
38+ const xi = (P === 1 ? 0 : -0.5 + i / (P - 1)) * ex.patch_length;
39+ px.push(xs + xi * Math.cos(ang)); py.push(ys + xi * Math.sin(ang));
40+ }
41+ kTotal = ex.k; alpha = ex.alpha;
42+ } else {
43+ alpha = ex.alpha;
44+ kTotal = ((alpha + 1) * 0.5 * ex.mass * hit.velocity ** 2) / ex.compression ** (alpha + 1);
45+ px = [xs]; py = [ys];
46+ for (let k = 0; k < 6; k++) { const th = (k * Math.PI) / 3; px.push(xs + ex.radius * Math.cos(th)); py.push(ys + ex.radius * Math.sin(th)); }
47+ }
48+ const P = px.length, nodes = new Uint32Array(3 * P), weights = new Float64Array(3 * P), z0p = new Float64Array(P);
49+ for (let p = 0; p < P; p++) {
50+ const loc = locate(mesh, px[p], py[p]);
51+ nodes.set(loc.nodes, 3 * p); weights.set(loc.weights, 3 * p);
52+ z0p[p] = profile.z0(px[p], py[p]);
53+ }
54+ const offsets = planeResiduals(px.map((x) => x - xs), py.map((y) => y - ys), z0p);
55+ return { hit, x: Float64Array.from(px), y: Float64Array.from(py), nodes, weights, offsets, kTotal, alpha, mass: ex.mass,
56+ gap: ex.gap, velocity: hit.velocity, time: hit.time };
57+}
58+
59+export interface HitSchedule {
60+ patches: ContactPatch[]; K: number; P: number;
61+ nodes: Uint32Array; weights: Float64Array; offsets: Float64Array; // (K, P, 3), (K, P, 3), (K, P)
62+ kPoint: Float64Array; alpha: Float64Array; mass: Float64Array; gap: Float64Array; velocity: Float64Array;
63+ releaseStep: Uint32Array;
64+}
65+
66+export function buildSchedule(patches: ContactPatch[], dt: number): HitSchedule {
67+ const K = patches.length, P = Math.max(...patches.map((p) => p.x.length));
68+ const nodes = new Uint32Array(K * P * 3), weights = new Float64Array(K * P * 3), offsets = new Float64Array(K * P).fill(-1e6);
69+ patches.forEach((p, k) => {
70+ const n = p.x.length;
71+ nodes.set(p.nodes, (k * P) * 3); weights.set(p.weights, (k * P) * 3); offsets.set(p.offsets, k * P);
72+ void n;
73+ });
74+ return {
75+ patches, K, P, nodes, weights, offsets,
76+ kPoint: Float64Array.from(patches, (p) => p.kTotal / p.x.length),
77+ alpha: Float64Array.from(patches, (p) => p.alpha),
78+ mass: Float64Array.from(patches, (p) => p.mass),
79+ gap: Float64Array.from(patches, (p) => p.gap),
80+ velocity: Float64Array.from(patches, (p) => p.velocity),
81+ releaseStep: Uint32Array.from(patches, (p) => Math.round(p.time / dt)),
82+ };
83+}
src/model/forces.tsadded+120−0View file
@@ -0,0 +1,120 @@
1+// Element forces and energies: the analytic gradient of the Marguerre-von Karman membrane
2+// energy plus the DKT bending force, per element, in the node-major order (u, v, w, thx, thy) x 3.
3+// The same expressions are written in WGSL in gpu/shaders.ts; this float64 version is the
4+// reference for the tests and the CPU fallback.
5+import type { Shell } from "./shell.ts";
6+
7+/** Per-element forces f_e = -dU_e/dq_e (15 values) including the sigma1 damping term on w; writes out[15 e ..]. */
8+export function elementForces(sh: Shell, q: Float64Array, v: Float64Array, out: Float64Array) {
9+ const { mesh, nl, z0x, z0y, Cm, Ke, rhoHA } = sh, { tri, area, Gx, Gy } = mesh;
10+ const sigma1 = sh.material.sigma1;
11+ const qe = new Float64Array(15);
12+ for (let e = 0; e < mesh.e; e++) {
13+ const n0 = tri[3 * e], n1 = tri[3 * e + 1], n2 = tri[3 * e + 2];
14+ for (let d = 0; d < 5; d++) { qe[d] = q[5 * n0 + d]; qe[5 + d] = q[5 * n1 + d]; qe[10 + d] = q[5 * n2 + d]; }
15+ const g0x = Gx[3 * e], g1x = Gx[3 * e + 1], g2x = Gx[3 * e + 2], g0y = Gy[3 * e], g1y = Gy[3 * e + 1], g2y = Gy[3 * e + 2];
16+ const ux = g0x * qe[0] + g1x * qe[5] + g2x * qe[10], uy = g0y * qe[0] + g1y * qe[5] + g2y * qe[10];
17+ const vx = g0x * qe[1] + g1x * qe[6] + g2x * qe[11], vy = g0y * qe[1] + g1y * qe[6] + g2y * qe[11];
18+ const wx = g0x * qe[2] + g1x * qe[7] + g2x * qe[12], wy = g0y * qe[2] + g1y * qe[7] + g2y * qe[12];
19+ const ax = nl * wx + z0x[e], ay = nl * wy + z0y[e];
20+ const exx = ux + nl * 0.5 * wx * wx + z0x[e] * wx, eyy = vy + nl * 0.5 * wy * wy + z0y[e] * wy;
21+ const gxy = uy + vx + nl * wx * wy + z0x[e] * wy + z0y[e] * wx;
22+ const C11 = Cm[3 * e], C12 = Cm[3 * e + 1], C33 = Cm[3 * e + 2];
23+ const Nxx = C11 * exx + C12 * eyy, Nyy = C12 * exx + C11 * eyy, Nxy = C33 * gxy;
24+ const A = area[e];
25+ // sigma1: 2 sigma1 (rho h A) G^T (G w_dot)
26+ const vwx = g0x * v[5 * n0 + 2] + g1x * v[5 * n1 + 2] + g2x * v[5 * n2 + 2];
27+ const vwy = g0y * v[5 * n0 + 2] + g1y * v[5 * n1 + 2] + g2y * v[5 * n2 + 2];
28+ const cd = 2 * sigma1 * rhoHA[e];
29+ const gxs = [g0x, g1x, g2x], gys = [g0y, g1y, g2y];
30+ for (let i = 0; i < 3; i++) {
31+ const gx = gxs[i], gy = gys[i];
32+ out[15 * e + 5 * i] = -A * (Nxx * gx + Nxy * gy);
33+ out[15 * e + 5 * i + 1] = -A * (Nyy * gy + Nxy * gx);
34+ out[15 * e + 5 * i + 2] = -A * (Nxx * ax * gx + Nyy * ay * gy + Nxy * (ay * gx + ax * gy)) - cd * (gx * vwx + gy * vwy);
35+ out[15 * e + 5 * i + 3] = 0; out[15 * e + 5 * i + 4] = 0;
36+ }
37+ // bending: -Ke U on the (w, thx, thy) slots
38+ const slot = [2, 3, 4, 7, 8, 9, 12, 13, 14];
39+ for (let i = 0; i < 9; i++) {
40+ let s = 0;
41+ for (let j = 0; j < 9; j++) s += Ke[81 * e + 9 * i + j] * qe[slot[j]];
42+ out[15 * e + slot[i]] -= s;
43+ }
44+ }
45+}
46+
47+/** Membrane and bending strain energies summed over the elements. */
48+export function strainEnergies(sh: Shell, q: ArrayLike<number>): { membrane: number; bending: number } {
49+ const { mesh, nl, z0x, z0y, Cm, Ke } = sh, { tri, area, Gx, Gy } = mesh;
50+ let Em = 0, Eb = 0;
51+ const U = new Float64Array(9);
52+ for (let e = 0; e < mesh.e; e++) {
53+ const ns = [tri[3 * e], tri[3 * e + 1], tri[3 * e + 2]];
54+ let ux = 0, uy = 0, vx = 0, vy = 0, wx = 0, wy = 0;
55+ for (let i = 0; i < 3; i++) {
56+ const gx = Gx[3 * e + i], gy = Gy[3 * e + i], b = 5 * ns[i];
57+ ux += gx * q[b]; uy += gy * q[b]; vx += gx * q[b + 1]; vy += gy * q[b + 1]; wx += gx * q[b + 2]; wy += gy * q[b + 2];
58+ U[3 * i] = q[b + 2]; U[3 * i + 1] = q[b + 3]; U[3 * i + 2] = q[b + 4];
59+ }
60+ const exx = ux + nl * 0.5 * wx * wx + z0x[e] * wx, eyy = vy + nl * 0.5 * wy * wy + z0y[e] * wy;
61+ const gxy = uy + vx + nl * wx * wy + z0x[e] * wy + z0y[e] * wx;
62+ const C11 = Cm[3 * e], C12 = Cm[3 * e + 1], C33 = Cm[3 * e + 2];
63+ Em += 0.5 * area[e] * (C11 * (exx * exx + eyy * eyy) + 2 * C12 * exx * eyy + C33 * gxy * gxy);
64+ let s = 0;
65+ for (let i = 0; i < 9; i++) for (let j = 0; j < 9; j++) s += U[i] * Ke[81 * e + 9 * i + j] * U[j];
66+ Eb += 0.5 * s;
67+ }
68+ return { membrane: Em, bending: Eb };
69+}
70+
71+/** Assembled internal force f_int(q) (no damping), for tests and checks. */
72+export function internalForce(sh: Shell, q: Float64Array): Float64Array {
73+ const N = sh.mesh.n;
74+ const ef = new Float64Array(15 * sh.mesh.e), f = new Float64Array(5 * N);
75+ elementForces(sh, q, new Float64Array(5 * N), ef);
76+ for (let n = 0; n < N; n++) {
77+ for (let a = sh.adjOffsets[n]; a < sh.adjOffsets[n + 1]; a++) {
78+ const ent = sh.adjEntries[a], e = (ent / 3) | 0, i = ent % 3;
79+ for (let d = 0; d < 5; d++) f[5 * n + d] += ef[15 * e + 5 * i + d];
80+ }
81+ }
82+ return f;
83+}
84+
85+export interface ShellChecks { rigidTiltResidual: number; constantCurvatureError: number; zeroModes: [number, number]; rigidModeRatio: number }
86+
87+/** The element and rigid-mode self-tests of the Python package (float64). */
88+export function shellChecks(sh: Shell): ShellChecks {
89+ const { mesh, Ke, De } = sh, { tri, xy, area } = mesh;
90+ let res = 0, kmax = 0, err = 0;
91+ for (let e = 0; e < mesh.e; e++) {
92+ const x = [0, 1, 2].map((i) => xy[2 * tri[3 * e + i]]), y = [0, 1, 2].map((i) => xy[2 * tri[3 * e + i] + 1]);
93+ const Ut = y.flatMap((yi) => [yi, 1, 0]), Uc = x.flatMap((xi) => [0.5 * xi * xi, 0, -xi]);
94+ let Eb = 0;
95+ for (let i = 0; i < 9; i++) {
96+ let st = 0, sc = 0;
97+ for (let j = 0; j < 9; j++) { const k = Ke[81 * e + 9 * i + j]; st += k * Ut[j]; sc += k * Uc[j]; kmax = Math.max(kmax, Math.abs(k)); }
98+ res = Math.max(res, Math.abs(st)); Eb += 0.5 * Uc[i] * sc;
99+ }
100+ err = Math.max(err, Math.abs(Eb / (0.5 * De[e] * area[e]) - 1));
101+ }
102+ // linearized force on the rigid modes (central difference of the cubic force is exact to O(eps^2))
103+ const N = mesh.n, L = 5 * N, eps = 1e-5;
104+ const rand = new Float64Array(L);
105+ let seed = 12345;
106+ for (let i = 0; i < L; i++) { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; rand[i] = seed / 4294967296 - 0.5; }
107+ const lin = (d: Float64Array) => {
108+ const fp = internalForce(sh, d.map((x) => eps * x)), fm = internalForce(sh, d.map((x) => -eps * x));
109+ let m = 0; for (let i = 0; i < L; i++) m = Math.max(m, Math.abs(fp[i] - fm[i]) / (2 * eps));
110+ return m;
111+ };
112+ let worst = 0;
113+ for (let k = 0; k < 6; k++) {
114+ const r = sh.Rm.subarray(k * L, (k + 1) * L);
115+ let nr = 0, nn = 0; for (let i = 0; i < L; i++) { nr += r[i] * r[i]; nn += rand[i] * rand[i]; }
116+ const scaled = rand.map((x) => (x * Math.sqrt(nr)) / Math.sqrt(nn));
117+ worst = Math.max(worst, lin(Float64Array.from(r)) / lin(scaled));
118+ }
119+ return { rigidTiltResidual: res / kmax, constantCurvatureError: err, zeroModes: sh.zeroModes, rigidModeRatio: worst };
120+}
src/model/geometry.tsadded+78−0View file
@@ -0,0 +1,78 @@
1+// Cymbal mid-surface height z0(x, y) and thickness h(x, y): bow, bell, rim warp and seeded
2+// hammer dents, as in cymsim/geometry.py. The slope that enters the strains is the CST
3+// gradient of the nodal heights (see shell.ts), so no analytic gradient is needed here.
4+import type { Imperfections, Profile } from "../config/schema.ts";
5+import { Rng } from "./rng.ts";
6+
7+export class CymbalProfile {
8+ readonly dentX: Float64Array; readonly dentY: Float64Array; readonly dentAmp: Float64Array; readonly dentSigma: Float64Array;
9+ readonly warpM: Float64Array; readonly warpA: Float64Array; readonly warpPhase: Float64Array;
10+ readonly ovalPhase: number; readonly bellCx: number; readonly bellCy: number;
11+
12+ readonly profile: Profile; readonly imperfections: Imperfections;
13+
14+ constructor(profile: Profile, imperfections: Imperfections) {
15+ this.profile = profile; this.imperfections = imperfections;
16+ const p = profile, im = imperfections, rng = new Rng(im.seed);
17+ const r0 = p.bell_shoulder_radius + im.dent_margin, r1 = p.radius - im.dent_margin;
18+ const n = Math.max(0, Math.floor(im.n_dents));
19+ this.dentX = new Float64Array(n); this.dentY = new Float64Array(n);
20+ this.dentAmp = new Float64Array(n); this.dentSigma = new Float64Array(n);
21+ for (let i = 0; i < n; i++) {
22+ const rr = Math.sqrt(rng.uniform(r0 * r0, r1 * r1)), tt = rng.uniform(0, 2 * Math.PI);
23+ this.dentX[i] = rr * Math.cos(tt); this.dentY[i] = rr * Math.sin(tt);
24+ }
25+ for (let i = 0; i < n; i++) this.dentAmp[i] = im.dent_depth * rng.sign() * rng.uniform(0.5, 1.5);
26+ for (let i = 0; i < n; i++) this.dentSigma[i] = im.dent_sigma * rng.uniform(0.7, 1.3);
27+ const modes = im.rim_warp_modes.map((m) => Math.floor(m));
28+ this.warpM = Float64Array.from(modes);
29+ this.warpA = Float64Array.from(modes.map((m) => (im.rim_warp * 2) / m));
30+ this.warpPhase = Float64Array.from(modes.map(() => rng.uniform(0, 2 * Math.PI)));
31+ this.ovalPhase = rng.uniform(0, 2 * Math.PI);
32+ const bo = rng.uniform(0, 2 * Math.PI);
33+ this.bellCx = im.bell_offset * Math.cos(bo); this.bellCy = im.bell_offset * Math.sin(bo);
34+ }
35+
36+ private shoulderRadius(phi: number): number {
37+ return this.profile.bell_shoulder_radius * (1 + this.imperfections.shoulder_ovality * Math.cos(2 * phi + this.ovalPhase));
38+ }
39+
40+ /** 1 on the bell plateau, 0 beyond the shoulder, C1 cosine ramp in between. */
41+ private bellBlend(rBell: number, rSh: number): number {
42+ const top = this.profile.bell_top_radius;
43+ const t = Math.min(Math.max((rBell - top) / (rSh - top), 0), 1);
44+ return 0.5 * (1 + Math.cos(Math.PI * t));
45+ }
46+
47+ private bowCoordinate(x: number, y: number) {
48+ const r = Math.sqrt(x * x + y * y + 1e-30);
49+ const phi = Math.atan2(y, r < 1e-6 ? 1e-6 : x);
50+ const rSh = this.shoulderRadius(phi);
51+ const rBell = Math.sqrt((x - this.bellCx) ** 2 + (y - this.bellCy) ** 2 + 1e-30);
52+ const s = Math.min(Math.max((this.profile.radius - r) / (this.profile.radius - rSh), 0), 1);
53+ return { s, phi, rBell, rSh };
54+ }
55+
56+ /** Mid-surface height [m]. */
57+ z0(x: number, y: number): number {
58+ const p = this.profile;
59+ const { s, phi, rBell, rSh } = this.bowCoordinate(x, y);
60+ const u = Math.max(1 - s, 1e-12);
61+ let z = p.bow_height * (1 - Math.pow(u, p.bow_power)) + p.bell_height * this.bellBlend(rBell, rSh);
62+ let warp = 0;
63+ for (let k = 0; k < this.warpM.length; k++) warp += this.warpA[k] * Math.cos(this.warpM[k] * phi + this.warpPhase[k]);
64+ z += u * warp;
65+ for (let i = 0; i < this.dentX.length; i++) {
66+ const dx = x - this.dentX[i], dy = y - this.dentY[i], s2 = this.dentSigma[i] * this.dentSigma[i];
67+ z += this.dentAmp[i] * Math.exp(-(dx * dx + dy * dy) / (2 * s2));
68+ }
69+ return z;
70+ }
71+
72+ /** Thickness [m]: linear taper along the bow, smooth step up into the bell. */
73+ thickness(x: number, y: number): number {
74+ const p = this.profile;
75+ const { s, rBell, rSh } = this.bowCoordinate(x, y);
76+ return p.h_rim + (p.h_shoulder - p.h_rim) * s + (p.h_bell - p.h_shoulder) * this.bellBlend(rBell, rSh);
77+ }
78+}
src/model/linalg.tsadded+65−0View file
@@ -0,0 +1,65 @@
1+// Small dense linear algebra: symmetric eigenvalues by cyclic Jacobi, and a least-squares plane fit.
2+
3+/** Eigenvalues (ascending) of a symmetric n x n matrix given row-major; the input is copied. */
4+export function symmetricEigenvalues(A: Float64Array | number[], n: number): Float64Array {
5+ const a = Float64Array.from(A);
6+ for (let sweep = 0; sweep < 60; sweep++) {
7+ let off = 0;
8+ for (let p = 0; p < n; p++) for (let q = p + 1; q < n; q++) off += a[p * n + q] * a[p * n + q];
9+ let diag = 0;
10+ for (let p = 0; p < n; p++) diag += a[p * n + p] * a[p * n + p];
11+ if (off <= 1e-30 * (diag + 1e-300)) break;
12+ for (let p = 0; p < n; p++) {
13+ for (let q = p + 1; q < n; q++) {
14+ const apq = a[p * n + q];
15+ if (apq === 0) continue;
16+ const app = a[p * n + p], aqq = a[q * n + q];
17+ const theta = (aqq - app) / (2 * apq);
18+ const t = Math.sign(theta || 1) / (Math.abs(theta) + Math.sqrt(theta * theta + 1));
19+ const c = 1 / Math.sqrt(t * t + 1), s = t * c;
20+ for (let k = 0; k < n; k++) {
21+ const akp = a[k * n + p], akq = a[k * n + q];
22+ a[k * n + p] = c * akp - s * akq; a[k * n + q] = s * akp + c * akq;
23+ }
24+ for (let k = 0; k < n; k++) {
25+ const apk = a[p * n + k], aqk = a[q * n + k];
26+ a[p * n + k] = c * apk - s * aqk; a[q * n + k] = s * apk + c * aqk;
27+ }
28+ }
29+ }
30+ }
31+ const ev = new Float64Array(n);
32+ for (let i = 0; i < n; i++) ev[i] = a[i * n + i];
33+ return ev.sort();
34+}
35+
36+/** Solve the 3 x 3 system A x = b (row-major A) by Gaussian elimination with pivoting. */
37+export function solve3(A: number[], b: number[]): number[] {
38+ const M = [A.slice(0, 3).concat(b[0]), A.slice(3, 6).concat(b[1]), A.slice(6, 9).concat(b[2])];
39+ for (let c = 0; c < 3; c++) {
40+ let piv = c;
41+ for (let r = c + 1; r < 3; r++) if (Math.abs(M[r][c]) > Math.abs(M[piv][c])) piv = r;
42+ [M[c], M[piv]] = [M[piv], M[c]];
43+ if (Math.abs(M[c][c]) < 1e-300) throw new Error("singular 3x3 system");
44+ for (let r = 0; r < 3; r++) {
45+ if (r === c) continue;
46+ const f = M[r][c] / M[c][c];
47+ for (let k = c; k < 4; k++) M[r][k] -= f * M[c][k];
48+ }
49+ }
50+ return [M[0][3] / M[0][0], M[1][3] / M[1][1], M[2][3] / M[2][2]];
51+}
52+
53+/** Least-squares fit z = a x + b y + c through points (x_i, y_i, z_i); returns the residuals z - fit. */
54+export function planeResiduals(x: ArrayLike<number>, y: ArrayLike<number>, z: ArrayLike<number>): Float64Array {
55+ const n = x.length;
56+ const AtA = new Array(9).fill(0), Atb = [0, 0, 0];
57+ for (let i = 0; i < n; i++) {
58+ const row = [x[i], y[i], 1];
59+ for (let a = 0; a < 3; a++) { Atb[a] += row[a] * z[i]; for (let b = 0; b < 3; b++) AtA[3 * a + b] += row[a] * row[b]; }
60+ }
61+ const coef = solve3(AtA, Atb);
62+ const res = new Float64Array(n);
63+ for (let i = 0; i < n; i++) res[i] = z[i] - (coef[0] * x[i] + coef[1] * y[i] + coef[2]);
64+ return res;
65+}
src/model/mesh.tsadded+127−0View file
@@ -0,0 +1,127 @@
1+// Triangular mesh of the projected disk, as in cymsim/mesh.py: nodes on concentric rings,
2+// annuli stitched by an angular merge, optional Laplacian smoothing, and the constant
3+// gradient operators of the linear (CST) interpolation on every triangle.
4+
5+export interface Mesh {
6+ n: number; e: number;
7+ xy: Float64Array; // (N, 2)
8+ tri: Uint32Array; // (E, 3) counter-clockwise
9+ boundary: Uint32Array; // rim node indices in angular order
10+ area: Float64Array; // (E,)
11+ Gx: Float64Array; // (E, 3): d phi/dx = sum_i Gx[e, i] phi_i
12+ Gy: Float64Array;
13+ centroid: Float64Array; // (E, 2)
14+}
15+
16+function stitch(aIdx: Uint32Array, aAng: Float64Array, bIdx: Uint32Array, bAng: Float64Array, out: number[]) {
17+ const na = aIdx.length, nb = bIdx.length, twoPi = 2 * Math.PI;
18+ const bAngU = Array.from(bAng, (t) => (((t - aAng[0]) % twoPi) + twoPi) % twoPi + aAng[0]);
19+ const order = Array.from(bAngU.keys()).sort((i, j) => bAngU[i] - bAngU[j]);
20+ const bi = order.map((i) => bIdx[i]), ba = order.map((i) => bAngU[i]);
21+ const ta = Array.from(aAng); ta.push(aAng[0] + twoPi);
22+ const tb = ba.slice(); tb.push(ba[0] + twoPi);
23+ let i = 0, j = 0;
24+ while (i < na || j < nb) {
25+ const nextA = i < na ? ta[i + 1] : Infinity, nextB = j < nb ? tb[j + 1] : Infinity;
26+ if (nextA <= nextB) { out.push(aIdx[i % na], bi[j % nb], aIdx[(i + 1) % na]); i++; }
27+ else { out.push(aIdx[i % na], bi[j % nb], bi[(j + 1) % nb]); j++; }
28+ }
29+}
30+
31+function smooth(xy: Float64Array, tri: Uint32Array, fixed: Uint32Array, nIter: number): Float64Array {
32+ const N = xy.length / 2, E = tri.length / 3;
33+ const src: number[] = [], dst: number[] = [];
34+ for (let e = 0; e < E; e++) {
35+ for (let k = 0; k < 3; k++) {
36+ const a = tri[3 * e + k], b = tri[3 * e + ((k + 1) % 3)];
37+ src.push(a, b); dst.push(b, a);
38+ }
39+ }
40+ const deg = new Float64Array(N);
41+ for (const s of src) deg[s]++;
42+ const free = new Uint8Array(N).fill(1);
43+ for (const f of fixed) free[f] = 0;
44+ let cur = Float64Array.from(xy);
45+ for (let it = 0; it < nIter; it++) {
46+ const acc = new Float64Array(2 * N);
47+ for (let k = 0; k < src.length; k++) { acc[2 * src[k]] += cur[2 * dst[k]]; acc[2 * src[k] + 1] += cur[2 * dst[k] + 1]; }
48+ const next = Float64Array.from(cur);
49+ for (let n = 0; n < N; n++) if (free[n]) { next[2 * n] = acc[2 * n] / deg[n]; next[2 * n + 1] = acc[2 * n + 1] / deg[n]; }
50+ cur = next;
51+ }
52+ return cur;
53+}
54+
55+export function diskMesh(radius: number, elementSize: number, smoothing = 1): Mesh {
56+ const nRings = Math.max(2, Math.round(radius / elementSize));
57+ const dr = radius / nRings;
58+ const nodes: number[] = [0, 0];
59+ let prevIdx = new Uint32Array([0]), prevAng = new Float64Array([0]);
60+ const tris: number[] = [];
61+ for (let k = 1; k <= nRings; k++) {
62+ const r = k * dr;
63+ const nk = Math.max(6, Math.round((2 * Math.PI * r) / dr));
64+ const ang = new Float64Array(nk), idx = new Uint32Array(nk);
65+ const base = nodes.length / 2;
66+ for (let j = 0; j < nk; j++) {
67+ ang[j] = (2 * Math.PI * (j + 0.5 * (k % 2))) / nk;
68+ idx[j] = base + j;
69+ nodes.push(r * Math.cos(ang[j]), r * Math.sin(ang[j]));
70+ }
71+ if (k === 1) for (let j = 0; j < nk; j++) tris.push(0, idx[j], idx[(j + 1) % nk]);
72+ else stitch(prevIdx, prevAng, idx, ang, tris);
73+ prevIdx = idx; prevAng = ang;
74+ }
75+ let xy: Float64Array = Float64Array.from(nodes);
76+ const tri = Uint32Array.from(tris);
77+ if (smoothing > 0) xy = smooth(xy, tri, prevIdx, smoothing);
78+ const E = tri.length / 3;
79+ for (let e = 0; e < E; e++) {
80+ const a = tri[3 * e], b = tri[3 * e + 1], c = tri[3 * e + 2];
81+ const s = (xy[2 * b] - xy[2 * a]) * (xy[2 * c + 1] - xy[2 * a + 1]) - (xy[2 * c] - xy[2 * a]) * (xy[2 * b + 1] - xy[2 * a + 1]);
82+ if (s < 0) { tri[3 * e + 1] = c; tri[3 * e + 2] = b; }
83+ }
84+ return makeMesh(xy, tri, prevIdx);
85+}
86+
87+export function makeMesh(xy: Float64Array, tri: Uint32Array, boundary: Uint32Array): Mesh {
88+ const N = xy.length / 2, E = tri.length / 3;
89+ const area = new Float64Array(E), Gx = new Float64Array(3 * E), Gy = new Float64Array(3 * E), centroid = new Float64Array(2 * E);
90+ for (let e = 0; e < E; e++) {
91+ const a = tri[3 * e], b = tri[3 * e + 1], c = tri[3 * e + 2];
92+ const x0 = xy[2 * a], y0 = xy[2 * a + 1], x1 = xy[2 * b], y1 = xy[2 * b + 1], x2 = xy[2 * c], y2 = xy[2 * c + 1];
93+ const A = 0.5 * ((x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0));
94+ if (!(A > 0)) throw new Error(`element ${e} has non-positive area`);
95+ area[e] = A;
96+ Gx[3 * e] = (y1 - y2) / (2 * A); Gx[3 * e + 1] = (y2 - y0) / (2 * A); Gx[3 * e + 2] = (y0 - y1) / (2 * A);
97+ Gy[3 * e] = (x2 - x1) / (2 * A); Gy[3 * e + 1] = (x0 - x2) / (2 * A); Gy[3 * e + 2] = (x1 - x0) / (2 * A);
98+ centroid[2 * e] = (x0 + x1 + x2) / 3; centroid[2 * e + 1] = (y0 + y1 + y2) / 3;
99+ }
100+ return { n: N, e: E, xy, tri, boundary, area, Gx, Gy, centroid };
101+}
102+
103+/** Containing element and barycentric weights of a point; throws when outside the mesh. */
104+export function locate(m: Mesh, px: number, py: number): { element: number; nodes: [number, number, number]; weights: [number, number, number] } {
105+ const { xy, tri, area } = m;
106+ for (let e = 0; e < m.e; e++) {
107+ const a = tri[3 * e], b = tri[3 * e + 1], c = tri[3 * e + 2];
108+ const x0 = xy[2 * a], y0 = xy[2 * a + 1], x1 = xy[2 * b], y1 = xy[2 * b + 1], x2 = xy[2 * c], y2 = xy[2 * c + 1];
109+ const a2 = 2 * area[e];
110+ const l1 = ((y1 - y2) * (px - x2) + (x2 - x1) * (py - y2)) / a2;
111+ const l2 = ((y2 - y0) * (px - x2) + (x0 - x2) * (py - y2)) / a2;
112+ const l3 = 1 - l1 - l2;
113+ if (l1 >= -1e-9 && l2 >= -1e-9 && l3 >= -1e-9) return { element: e, nodes: [a, b, c], weights: [l1, l2, l3] };
114+ }
115+ throw new Error(`point (${px.toFixed(4)}, ${py.toFixed(4)}) lies outside the mesh`);
116+}
117+
118+export function edgeLengths(m: Mesh): Float64Array {
119+ const L = new Float64Array(3 * m.e);
120+ for (let e = 0; e < m.e; e++) {
121+ for (let k = 0; k < 3; k++) {
122+ const a = m.tri[3 * e + k], b = m.tri[3 * e + ((k + 1) % 3)];
123+ L[3 * e + k] = Math.hypot(m.xy[2 * a] - m.xy[2 * b], m.xy[2 * a + 1] - m.xy[2 * b + 1]);
124+ }
125+ }
126+ return L;
127+}
src/model/radiation.tsadded+74−0View file
@@ -0,0 +1,74 @@
1+// Rayleigh-integral listener with the rim-diffraction back term, as in cymsim/radiation.py, but
2+// with the retarded-time sum evaluated at the audio rate: the per-element acceleration is
3+// decimated to the audio rate by a windowed-sinc low-pass spanning DECIMATION_SPAN audio samples
4+// (applied in the time loop through per-element accumulator slots) and delayed by an integer
5+// plus a fractional number of audio samples with linear interpolation. The per-element
6+// coefficients, the delays and the decimation taps are computed here.
7+import type { Listener } from "../config/schema.ts";
8+import type { CymbalProfile } from "./geometry.ts";
9+import type { Mesh } from "./mesh.ts";
10+
11+/** Audio samples spanned by the decimation kernel (accumulator slots per element). */
12+export const DECIMATION_SPAN = 8;
13+
14+/**
15+ * Decimation taps: taps[w * m + kk] is the weight with which the sub-step of phase kk (0..m-1)
16+ * within audio sample j0 contributes to audio sample j0 + w - (W/2 - 1), for slot w = 0..W-1.
17+ * The kernel is a Blackman-windowed sinc with cutoff 0.45 fs, normalised to unit DC gain.
18+ */
19+export function decimationTaps(m: number, W = DECIMATION_SPAN): Float64Array {
20+ const taps = new Float64Array(W * m), half = (W * m) / 2, fc = 0.45 / m;
21+ let sum = 0;
22+ for (let w = 0; w < W; w++) for (let kk = 0; kk < m; kk++) {
23+ const d = kk + (W / 2 - 1 - w) * m; // sub-step offset from the sample centre
24+ const x = Math.PI * fc * d, sinc = d === 0 ? 1 : Math.sin(2 * x) / (2 * x);
25+ const u = (d + half) / (2 * half), win = 0.42 - 0.5 * Math.cos(2 * Math.PI * u) + 0.08 * Math.cos(4 * Math.PI * u);
26+ taps[w * m + kk] = sinc * Math.max(win, 0);
27+ sum += taps[w * m + kk];
28+ }
29+ for (let i = 0; i < taps.length; i++) taps[i] /= sum;
30+ return taps;
31+}
32+
33+export interface Radiation {
34+ coefF: Float64Array; coefB: Float64Array; // rho A / (2 pi r), front and back
35+ i0F: Uint32Array; frF: Float64Array; // delay in audio samples: integer and fraction
36+ i0B: Uint32Array; frB: Float64Array;
37+ ringLength: number; // history ring length in audio samples
38+ taps: Float64Array; // (DECIMATION_SPAN, substeps) decimation weights
39+ rFront: Float64Array; rBack: Float64Array;
40+}
41+
42+export function buildRadiation(mesh: Mesh, profile: CymbalProfile, z0Node: Float64Array, listener: Listener, fs: number, substeps: number, nRim = 720): Radiation {
43+ const E = mesh.e, [Lx, Ly, Lz] = listener.position;
44+ const cx = new Float64Array(E), cy = new Float64Array(E), cz = new Float64Array(E);
45+ for (let e = 0; e < E; e++) {
46+ cx[e] = mesh.centroid[2 * e]; cy[e] = mesh.centroid[2 * e + 1];
47+ cz[e] = (z0Node[mesh.tri[3 * e]] + z0Node[mesh.tri[3 * e + 1]] + z0Node[mesh.tri[3 * e + 2]]) / 3;
48+ }
49+ let R = 0;
50+ for (let n = 0; n < mesh.n; n++) R = Math.max(R, Math.hypot(mesh.xy[2 * n], mesh.xy[2 * n + 1]));
51+ const rimX = new Float64Array(nRim), rimY = new Float64Array(nRim), rimZ = new Float64Array(nRim), d2 = new Float64Array(nRim);
52+ for (let j = 0; j < nRim; j++) {
53+ const phi = (2 * Math.PI * j) / nRim;
54+ rimX[j] = R * Math.cos(phi); rimY[j] = R * Math.sin(phi); rimZ[j] = profile.z0(rimX[j], rimY[j]);
55+ d2[j] = Math.hypot(rimX[j] - Lx, rimY[j] - Ly, rimZ[j] - Lz);
56+ }
57+ const rFront = new Float64Array(E), rBack = new Float64Array(E);
58+ const coefF = new Float64Array(E), coefB = new Float64Array(E), i0F = new Uint32Array(E), frF = new Float64Array(E);
59+ const i0B = new Uint32Array(E), frB = new Float64Array(E);
60+ const { c_air: c, rho_air: rho, back_diffraction_gain: g } = listener;
61+ let maxI0 = 0;
62+ for (let e = 0; e < E; e++) {
63+ rFront[e] = Math.hypot(Lx - cx[e], Ly - cy[e], Lz - cz[e]);
64+ let best = Infinity;
65+ for (let j = 0; j < nRim; j++) best = Math.min(best, Math.hypot(cx[e] - rimX[j], cy[e] - rimY[j], cz[e] - rimZ[j]) + d2[j]);
66+ rBack[e] = best;
67+ coefF[e] = (rho * mesh.area[e]) / (2 * Math.PI * rFront[e]);
68+ coefB[e] = (g * rho * mesh.area[e]) / (2 * Math.PI * rBack[e]);
69+ const dF = (rFront[e] / c) * fs, dB = (rBack[e] / c) * fs;
70+ i0F[e] = Math.floor(dF); frF[e] = dF - i0F[e]; i0B[e] = Math.floor(dB); frB[e] = dB - i0B[e];
71+ maxI0 = Math.max(maxI0, i0F[e], i0B[e]);
72+ }
73+ return { coefF, coefB, i0F, frF, i0B, frB, ringLength: maxI0 + 4, taps: decimationTaps(substeps), rFront, rBack };
74+}
src/model/rng.tsadded+39−0View file
@@ -0,0 +1,39 @@
1+// Seedable pseudo-random generator for the imperfections: xoshiro128** seeded through splitmix32.
2+// It is not numpy's PCG64, so a seed gives a different dent pattern than the Python package
3+// (same statistics, different sample); within the web app a seed is reproducible.
4+
5+export class Rng {
6+ private s = new Uint32Array(4);
7+
8+ constructor(seed: number) {
9+ let x = (seed >>> 0) ^ 0x9e3779b9;
10+ for (let i = 0; i < 4; i++) {
11+ x = (x + 0x9e3779b9) >>> 0;
12+ let z = x;
13+ z = Math.imul(z ^ (z >>> 16), 0x85ebca6b) >>> 0;
14+ z = Math.imul(z ^ (z >>> 13), 0xc2b2ae35) >>> 0;
15+ this.s[i] = (z ^ (z >>> 16)) >>> 0;
16+ }
17+ }
18+
19+ next32(): number {
20+ const s = this.s;
21+ const r = Math.imul(rotl(Math.imul(s[1], 5) >>> 0, 7), 9) >>> 0;
22+ const t = (s[1] << 9) >>> 0;
23+ s[2] ^= s[0]; s[3] ^= s[1]; s[1] ^= s[2]; s[0] ^= s[3];
24+ s[2] ^= t;
25+ s[3] = rotl(s[3], 11);
26+ return r;
27+ }
28+
29+ /** Uniform double in [0, 1) with 53 random bits. */
30+ random(): number {
31+ const hi = this.next32() >>> 5, lo = this.next32() >>> 6;
32+ return (hi * 67108864 + lo) / 9007199254740992;
33+ }
34+
35+ uniform(lo: number, hi: number): number { return lo + (hi - lo) * this.random(); }
36+ sign(): number { return this.random() < 0.5 ? -1 : 1; }
37+}
38+
39+function rotl(x: number, k: number): number { return ((x << k) | (x >>> (32 - k))) >>> 0; }
src/model/setup.tsadded+61−0View file
@@ -0,0 +1,61 @@
1+// Everything the time loop needs, built once from a configuration: profile, mesh, shell, time
2+// step, hit schedule and radiation, plus a human-readable summary of the set-up.
3+import type { SimulationConfig } from "../config/schema.ts";
4+import { buildPatch, buildSchedule, freeContactTime, patchEnergy, type ContactPatch, type HitSchedule } from "./exciter.ts";
5+import { CymbalProfile } from "./geometry.ts";
6+import { diskMesh, type Mesh } from "./mesh.ts";
7+import { buildRadiation, type Radiation } from "./radiation.ts";
8+import { buildShell, type Shell } from "./shell.ts";
9+
10+export interface Setup {
11+ config: SimulationConfig; profile: CymbalProfile; mesh: Mesh; shell: Shell;
12+ fs: number; dt: number; substeps: number; nSamples: number; nSteps: number;
13+ patches: ContactPatch[]; schedule: HitSchedule; radiation: Radiation;
14+ summary: SetupSummary;
15+}
16+
17+export interface SetupSummary {
18+ nodes: number; elements: number; dofs: number; mass: number; height: number; rimWarp: number; dents: number;
19+ hMin: number; hMax: number; maxSlope: number; slopeWarning: boolean; omegaMax: number; limitingR: number; limitingH: number;
20+ dt: number; substeps: number; nSteps: number; setupSeconds: number;
21+ hits: { time: number; kind: string; points: number; r: number; kTotal: number; alpha: number; mass: number; velocity: number;
22+ energy: number; freeContact: number; directBelow: number; late: boolean }[];
23+}
24+
25+/** Build the set-up; ``progress`` is awaited between phases so a UI can repaint. */
26+export async function buildSetup(config: SimulationConfig, progress?: (phase: string) => Promise<void> | void): Promise<Setup> {
27+ const t0 = performance.now();
28+ const step = async (s: string) => { if (progress) await progress(s); };
29+ await step("profile and mesh");
30+ const profile = new CymbalProfile(config.profile, config.imperfections);
31+ const mesh = diskMesh(config.profile.radius, config.mesh.element_size, config.mesh.smoothing);
32+ await step(`shell assembly (${mesh.n} nodes, ${mesh.e} elements)`);
33+ const shell = buildShell(mesh, profile, config.material, config.numerics);
34+ const num = config.numerics, fs = num.fs;
35+ const substeps = Math.ceil(1 / (fs * num.cfl_safety * shell.dtStable));
36+ const dt = 1 / (fs * substeps);
37+ const nSamples = Math.ceil(num.duration * fs);
38+ const nSteps = nSamples * substeps;
39+ await step("hits and radiation");
40+ const patches = config.hits.map((h) => buildPatch(h, mesh, profile));
41+ const schedule = buildSchedule(patches, dt);
42+ const radiation = buildRadiation(mesh, profile, shell.z0Node, config.listener, fs, substeps);
43+ let zMax = -Infinity, rimMin = Infinity, rimMax = -Infinity, hMin = Infinity, hMax = -Infinity;
44+ for (const z of shell.z0Node) zMax = Math.max(zMax, z);
45+ for (const b of mesh.boundary) { rimMin = Math.min(rimMin, shell.z0Node[b]); rimMax = Math.max(rimMax, shell.z0Node[b]); }
46+ for (const h of shell.hE) { hMin = Math.min(hMin, h); hMax = Math.max(hMax, h); }
47+ const le = shell.limitingElement;
48+ const summary: SetupSummary = {
49+ nodes: mesh.n, elements: mesh.e, dofs: 5 * mesh.n, mass: shell.mass, height: zMax, rimWarp: rimMax - rimMin,
50+ dents: profile.dentX.length, hMin, hMax, maxSlope: shell.maxSlope, slopeWarning: shell.maxSlope > config.profile.max_slope,
51+ omegaMax: shell.omegaMax, limitingR: Math.hypot(mesh.centroid[2 * le], mesh.centroid[2 * le + 1]), limitingH: shell.hE[le],
52+ dt, substeps, nSteps, setupSeconds: 0,
53+ hits: patches.map((p) => {
54+ const tau = freeContactTime(p);
55+ return { time: p.time, kind: p.hit.exciter.kind, points: p.x.length, r: p.hit.r, kTotal: p.kTotal, alpha: p.alpha, mass: p.mass,
56+ velocity: p.velocity, energy: patchEnergy(p), freeContact: tau, directBelow: 0.5 / tau, late: p.time >= num.duration };
57+ }),
58+ };
59+ summary.setupSeconds = (performance.now() - t0) / 1000;
60+ return { config, profile, mesh, shell, fs, dt, substeps, nSamples, nSteps, patches, schedule, radiation, summary };
61+}
src/model/shell.tsadded+135−0View file
@@ -0,0 +1,135 @@
1+// The discrete shell, as in cymsim/shell.py: thickness and height sampled on the mesh, the CST
2+// slope of the nodal heights, element stiffnesses, lumped mass with the inflated rotational
3+// inertia, the six M-orthonormal rigid-body modes, the node-to-element adjacency used by the
4+// gather of element forces, and the Irons element bound on the largest eigenfrequency that sets
5+// the stable time step. Everything here is float64 and computed once per configuration.
6+import type { Material, Numerics } from "../config/schema.ts";
7+import { dktStiffness } from "./dkt.ts";
8+import type { CymbalProfile } from "./geometry.ts";
9+import { symmetricEigenvalues } from "./linalg.ts";
10+import type { Mesh } from "./mesh.ts";
11+
12+export const N_DOF = 5; // (u, v, w, theta_x, theta_y) per node
13+
14+export interface Shell {
15+ mesh: Mesh; material: Material; numerics: Numerics; nl: number;
16+ hE: Float64Array; hNode: Float64Array; z0Node: Float64Array; z0x: Float64Array; z0y: Float64Array;
17+ De: Float64Array; Cm: Float64Array; // (E,) and (E, 3) = (C11, C12, C33)
18+ Ke: Float64Array; // (E, 81)
19+ rhoHA: Float64Array; // (E,) rho h A
20+ M: Float64Array; Minv: Float64Array; // (N, 5)
21+ Rm: Float64Array; // (6, N, 5), M-orthonormal
22+ adjOffsets: Uint32Array; adjEntries: Uint32Array; // CSR: node -> list of (3 e + local index)
23+ mass: number; maxSlope: number; omegaMax: number; dtStable: number; limitingElement: number;
24+ zeroModes: [number, number]; // min and max count of zero-energy element modes (expect 6)
25+}
26+
27+export function buildShell(mesh: Mesh, profile: CymbalProfile, material: Material, numerics: Numerics): Shell {
28+ const N = mesh.n, E = mesh.e, { xy, tri, area, Gx, Gy } = mesh;
29+ const nl = numerics.nonlinear ? 1 : 0;
30+ const hE = new Float64Array(E), hNode = new Float64Array(N), z0Node = new Float64Array(N);
31+ for (let e = 0; e < E; e++) hE[e] = profile.thickness(mesh.centroid[2 * e], mesh.centroid[2 * e + 1]);
32+ for (let n = 0; n < N; n++) { hNode[n] = profile.thickness(xy[2 * n], xy[2 * n + 1]); z0Node[n] = profile.z0(xy[2 * n], xy[2 * n + 1]); }
33+ const z0x = new Float64Array(E), z0y = new Float64Array(E);
34+ let maxSlope = 0;
35+ for (let e = 0; e < E; e++) {
36+ let sx = 0, sy = 0;
37+ for (let i = 0; i < 3; i++) { const z = z0Node[tri[3 * e + i]]; sx += Gx[3 * e + i] * z; sy += Gy[3 * e + i] * z; }
38+ z0x[e] = sx; z0y[e] = sy; maxSlope = Math.max(maxSlope, Math.hypot(sx, sy));
39+ }
40+ const { E: Ey, nu, rho } = material;
41+ const De = new Float64Array(E), Cm = new Float64Array(3 * E), Ke = new Float64Array(81 * E), rhoHA = new Float64Array(E);
42+ for (let e = 0; e < E; e++) {
43+ De[e] = (Ey * hE[e] ** 3) / (12 * (1 - nu * nu));
44+ const c = (Ey * hE[e]) / (1 - nu * nu);
45+ Cm[3 * e] = c; Cm[3 * e + 1] = c * nu; Cm[3 * e + 2] = c * 0.5 * (1 - nu);
46+ rhoHA[e] = rho * hE[e] * area[e];
47+ const xe = [xy[2 * tri[3 * e]], xy[2 * tri[3 * e + 1]], xy[2 * tri[3 * e + 2]]];
48+ const ye = [xy[2 * tri[3 * e] + 1], xy[2 * tri[3 * e + 1] + 1], xy[2 * tri[3 * e + 2] + 1]];
49+ Ke.set(dktStiffness(xe, ye, De[e], nu), 81 * e);
50+ }
51+ // lumped mass
52+ const M = new Float64Array(N_DOF * N);
53+ const mE = new Float64Array(E), IE = new Float64Array(E);
54+ for (let e = 0; e < E; e++) {
55+ mE[e] = (rho * hE[e] * area[e]) / 3;
56+ IE[e] = mE[e] * ((hE[e] * hE[e]) / 12 + numerics.rot_inertia_factor * area[e]);
57+ for (let k = 0; k < 3; k++) {
58+ const n = tri[3 * e + k];
59+ M[5 * n] += mE[e] * numerics.inplane_mass_scale; M[5 * n + 1] += mE[e] * numerics.inplane_mass_scale;
60+ M[5 * n + 2] += mE[e]; M[5 * n + 3] += IE[e]; M[5 * n + 4] += IE[e];
61+ }
62+ }
63+ const Minv = M.map((m) => 1 / m);
64+ let mass = 0;
65+ for (let n = 0; n < N; n++) mass += M[5 * n + 2];
66+ // rigid-body modes of the curved shell, M-orthonormalised by Gram-Schmidt
67+ let cx = 0, cy = 0;
68+ for (let n = 0; n < N; n++) { cx += xy[2 * n]; cy += xy[2 * n + 1]; }
69+ cx /= N; cy /= N;
70+ const Rm = new Float64Array(6 * N_DOF * N);
71+ const set = (k: number, n: number, d: number, val: number) => { Rm[(k * N + n) * N_DOF + d] = val; };
72+ for (let n = 0; n < N; n++) {
73+ const X = xy[2 * n] - cx, Y = xy[2 * n + 1] - cy, z = z0Node[n];
74+ set(0, n, 0, 1); set(1, n, 1, 1);
75+ set(2, n, 0, -Y); set(2, n, 1, X);
76+ set(3, n, 2, 1);
77+ set(4, n, 2, Y); set(4, n, 3, 1); set(4, n, 1, -z);
78+ set(5, n, 2, -X); set(5, n, 4, 1); set(5, n, 0, z);
79+ }
80+ const L = N_DOF * N;
81+ const mdot = (a: number, b: number) => { let s = 0; for (let i = 0; i < L; i++) s += Rm[a * L + i] * M[i] * Rm[b * L + i]; return s; };
82+ for (let k = 0; k < 6; k++) {
83+ for (let l = 0; l < k; l++) { const c = mdot(l, k); for (let i = 0; i < L; i++) Rm[k * L + i] -= c * Rm[l * L + i]; }
84+ const nrm = Math.sqrt(mdot(k, k));
85+ for (let i = 0; i < L; i++) Rm[k * L + i] /= nrm;
86+ }
87+ // adjacency (CSR)
88+ const deg = new Uint32Array(N);
89+ for (let e = 0; e < E; e++) for (let k = 0; k < 3; k++) deg[tri[3 * e + k]]++;
90+ const adjOffsets = new Uint32Array(N + 1);
91+ for (let n = 0; n < N; n++) adjOffsets[n + 1] = adjOffsets[n] + deg[n];
92+ const adjEntries = new Uint32Array(adjOffsets[N]), fill = new Uint32Array(N);
93+ for (let e = 0; e < E; e++) for (let k = 0; k < 3; k++) { const n = tri[3 * e + k]; adjEntries[adjOffsets[n] + fill[n]++] = 3 * e + k; }
94+ // Irons bound: lambda_max(M^-1 K) <= max_e lambda_max(M_e^-1 K_e), K_e the element tangent at rest
95+ let lamMax = 0, limitingElement = 0, zmin = 15, zmax = 0;
96+ const S = new Float64Array(225), me = new Float64Array(15);
97+ for (let e = 0; e < E; e++) {
98+ elementTangent(e, Gx, Gy, area[e], z0x[e], z0y[e], Cm, Ke, S);
99+ for (let i = 0; i < 3; i++) {
100+ me[5 * i] = mE[e] * numerics.inplane_mass_scale; me[5 * i + 1] = mE[e] * numerics.inplane_mass_scale;
101+ me[5 * i + 2] = mE[e]; me[5 * i + 3] = IE[e]; me[5 * i + 4] = IE[e];
102+ }
103+ for (let i = 0; i < 15; i++) for (let j = 0; j < 15; j++) S[15 * i + j] /= Math.sqrt(me[i] * me[j]);
104+ const ev = symmetricEigenvalues(S, 15);
105+ const top = ev[14];
106+ let nz = 0; for (const v of ev) if (v < 1e-9 * top) nz++;
107+ zmin = Math.min(zmin, nz); zmax = Math.max(zmax, nz);
108+ if (top > lamMax) { lamMax = top; limitingElement = e; }
109+ }
110+ const omegaMax = Math.sqrt(lamMax);
111+ return { mesh, material, numerics, nl, hE, hNode, z0Node, z0x, z0y, De, Cm, Ke, rhoHA, M, Minv, Rm, adjOffsets, adjEntries,
112+ mass, maxSlope, omegaMax, dtStable: 2 / omegaMax, limitingElement, zeroModes: [zmin, zmax] };
113+}
114+
115+/** 15 x 15 tangent stiffness of one element at rest: A B^T C B (membrane, linear part) plus DKT on the bending slots. */
116+export function elementTangent(e: number, Gx: Float64Array, Gy: Float64Array, A: number, z0x: number, z0y: number,
117+ Cm: Float64Array, Ke: Float64Array, out: Float64Array) {
118+ out.fill(0);
119+ // B rows: exx, eyy, gxy over the 15 DOFs (node-major u, v, w, thx, thy)
120+ const B = [new Float64Array(15), new Float64Array(15), new Float64Array(15)];
121+ for (let i = 0; i < 3; i++) {
122+ const gx = Gx[3 * e + i], gy = Gy[3 * e + i];
123+ B[0][5 * i] = gx; B[0][5 * i + 2] = z0x * gx;
124+ B[1][5 * i + 1] = gy; B[1][5 * i + 2] = z0y * gy;
125+ B[2][5 * i] = gy; B[2][5 * i + 1] = gx; B[2][5 * i + 2] = z0x * gy + z0y * gx;
126+ }
127+ const C = [[Cm[3 * e], Cm[3 * e + 1], 0], [Cm[3 * e + 1], Cm[3 * e], 0], [0, 0, Cm[3 * e + 2]]];
128+ for (let i = 0; i < 15; i++) for (let j = 0; j < 15; j++) {
129+ let s = 0;
130+ for (let k = 0; k < 3; k++) for (let l = 0; l < 3; l++) s += B[k][i] * C[k][l] * B[l][j];
131+ out[15 * i + j] = A * s;
132+ }
133+ const slot = [2, 3, 4, 7, 8, 9, 12, 13, 14]; // (w, thx, thy) x 3 within the 15
134+ for (let i = 0; i < 9; i++) for (let j = 0; j < 9; j++) out[15 * slot[i] + slot[j]] += Ke[81 * e + 9 * i + j];
135+}
src/model/simulator.tsadded+23−0View file
@@ -0,0 +1,23 @@
1+// The interface shared by the CPU and the GPU time loops.
2+export interface ChunkResult {
3+ firstSample: number; // audio index of pressureFront[0]
4+ pressureFront: Float32Array; // baffled Rayleigh pressure [Pa]
5+ pressureBack: Float32Array; // diffracted back term [Pa]; unbaffled = front - back
6+}
7+
8+export interface HitState { peakForce: number; contactTime: number; velocity: number; height: number }
9+
10+export interface Diagnostics {
11+ time: number; kinetic: number; membrane: number; bending: number; exciters: number; maxWoverH: number; hits: HitState[];
12+}
13+
14+export interface SimulatorBackend {
15+ readonly kind: "cpu" | "gpu";
16+ readonly samplesDone: number;
17+ /** Advance by nSamples audio samples and return the pressure samples completed meanwhile. */
18+ runChunk(nSamples: number): Promise<ChunkResult>;
19+ diagnostics(): Promise<Diagnostics>;
20+ /** Transverse displacement w at every node, for the plate view. */
21+ displacement(): Promise<Float32Array>;
22+ dispose(): void;
23+}
src/run.tsadded+47−0View file
@@ -0,0 +1,47 @@
1+// Drives a simulator backend chunk by chunk with an adaptive chunk size, pausing and stopping on
2+// request, and reports pressure chunks, diagnostics and progress to the UI.
3+import type { Setup } from "./model/setup.ts";
4+import type { ChunkResult, Diagnostics, SimulatorBackend } from "./model/simulator.ts";
5+
6+export interface RunCallbacks {
7+ onChunk(res: ChunkResult): void;
8+ onDiagnostics(d: Diagnostics): void;
9+ onProgress(samplesDone: number, wallSeconds: number): void;
10+ onDone(stopped: boolean): void;
11+}
12+
13+export class Runner {
14+ private paused = false; private stopped = false; private resume: (() => void) | null = null;
15+ private chunk = 512;
16+ readonly backend: SimulatorBackend; readonly setup: Setup; private readonly cb: RunCallbacks;
17+
18+ constructor(backend: SimulatorBackend, setup: Setup, cb: RunCallbacks) { this.backend = backend; this.setup = setup; this.cb = cb; }
19+
20+ async run() {
21+ const { setup, backend } = this, t0 = performance.now();
22+ let lastDiag = t0;
23+ while (!this.stopped && backend.samplesDone < setup.nSamples) {
24+ if (this.paused) await new Promise<void>((r) => { this.resume = r; });
25+ if (this.stopped) break;
26+ const n = Math.min(this.chunk, setup.nSamples - backend.samplesDone);
27+ const tc = performance.now();
28+ const res = await backend.runChunk(n);
29+ const dtc = (performance.now() - tc) / 1000;
30+ // aim at about 0.4 s of wall time per chunk, in multiples of 128 samples, at most 8192
31+ this.chunk = Math.max(128, Math.min(8192, Math.round((n * 0.4) / Math.max(dtc, 1e-3) / 128) * 128));
32+ this.cb.onChunk(res);
33+ this.cb.onProgress(backend.samplesDone, (performance.now() - t0) / 1000);
34+ if (performance.now() - lastDiag > 1000 || backend.samplesDone >= setup.nSamples) {
35+ this.cb.onDiagnostics(await backend.diagnostics());
36+ lastDiag = performance.now();
37+ }
38+ await new Promise((r) => setTimeout(r, 0));
39+ }
40+ this.cb.onDone(this.stopped);
41+ }
42+
43+ pause() { this.paused = true; }
44+ unpause() { this.paused = false; this.resume?.(); this.resume = null; }
45+ stop() { this.stopped = true; this.unpause(); }
46+ get isPaused() { return this.paused; }
47+}
src/style.cssadded+56−0View file
@@ -0,0 +1,56 @@
1+:root { --bg: #14161a; --panel: #1d2026; --border: #2c3038; --text: #e6e6e6; --muted: #9aa0a8; --accent: #e0a23a; --danger: #d9534f; }
2+* { box-sizing: border-box; }
3+html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text); font: 13px/1.4 system-ui, sans-serif; }
4+#app { display: grid; grid-template-columns: 420px 1fr; height: 100vh; }
5+#sidebar { background: var(--panel); border-right: 1px solid var(--border); display: flex; flex-direction: column; min-height: 0; }
6+#sidebar header { padding: 10px 12px 4px; }
7+#sidebar h1 { margin: 0; font-size: 18px; display: inline; }
8+.subtitle { color: var(--muted); margin-left: 8px; }
9+.run { padding: 6px 12px; border-bottom: 1px solid var(--border); }
10+.row { display: flex; gap: 6px; align-items: center; margin: 4px 0; }
11+.row label { display: flex; gap: 6px; align-items: center; flex: 1; }
12+.row select { flex: 1; }
13+.buttons button { flex: 1; }
14+button { background: #2a2e36; color: var(--text); border: 1px solid var(--border); border-radius: 4px; padding: 5px 10px; cursor: pointer; }
15+button:hover:not(:disabled) { background: #343943; }
16+button:disabled { opacity: 0.45; cursor: default; }
17+button.primary { background: var(--accent); color: #111; border-color: var(--accent); font-weight: 600; }
18+select, input[type="text"], input[type="number"] { background: #111318; color: var(--text); border: 1px solid var(--border); border-radius: 3px; padding: 3px 5px; }
19+progress { flex: 1; height: 10px; }
20+.muted { color: var(--muted); }
21+.tabs { display: flex; border-bottom: 1px solid var(--border); }
22+.tabs button { flex: 1; border: none; border-radius: 0; background: transparent; padding: 6px; color: var(--muted); }
23+.tabs button.active { color: var(--text); border-bottom: 2px solid var(--accent); }
24+.tab { flex: 1; min-height: 0; overflow: auto; }
25+.tab.hidden { display: none; }
26+#editor { height: 100%; }
27+#editor .cm-editor { height: 100%; font-size: 12px; }
28+#editor .cm-scroller { font-family: ui-monospace, Menlo, Consolas, monospace; }
29+#issues { list-style: none; margin: 0; padding: 4px 12px; max-height: 120px; overflow: auto; border-top: 1px solid var(--border); font-size: 12px; }
30+#issues li { color: var(--danger); }
31+#issues li.ok { color: #6fbf73; }
32+#issues code { color: var(--text); }
33+#form { padding: 6px 10px; }
34+#form details { margin-bottom: 4px; }
35+#form summary { cursor: pointer; font-weight: 600; padding: 3px 0; }
36+#form .grid { display: grid; grid-template-columns: 1fr 120px; gap: 3px 8px; align-items: center; padding: 2px 0 6px 10px; }
37+#form .grid label { color: var(--muted); }
38+#form .grid label span.unit { color: #6d737b; margin-left: 4px; }
39+#form .hit { border: 1px solid var(--border); border-radius: 4px; padding: 4px 8px; margin: 4px 0; }
40+#form .hit .head { display: flex; justify-content: space-between; align-items: center; }
41+#form input[type="number"] { width: 100%; }
42+#summary { padding: 6px 12px; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px; white-space: pre-wrap; }
43+main { display: grid; grid-template-rows: 1fr 1fr auto auto; min-height: 0; }
44+main section { min-height: 0; display: flex; flex-direction: column; border-bottom: 1px solid var(--border); }
45+.toolbar { display: flex; gap: 14px; align-items: center; padding: 4px 10px; font-weight: 600; }
46+.toolbar label { font-weight: normal; color: var(--muted); display: flex; gap: 6px; align-items: center; }
47+canvas { flex: 1; width: 100%; height: 100%; display: block; min-height: 0; }
48+#playback { flex-direction: row; align-items: center; gap: 10px; padding: 6px 10px; }
49+#playback #position { flex: 1; }
50+#playback label { color: var(--muted); display: flex; gap: 6px; align-items: center; }
51+#readouts { padding: 6px 10px; max-height: 190px; overflow: auto; font-size: 12px; }
52+#readouts table { border-collapse: collapse; margin-right: 24px; display: inline-table; vertical-align: top; }
53+#readouts th, #readouts td { padding: 1px 8px; text-align: right; border-bottom: 1px solid var(--border); }
54+#readouts th { color: var(--muted); font-weight: normal; }
55+#readouts .log { font-family: ui-monospace, Menlo, Consolas, monospace; color: var(--muted); white-space: pre-wrap; margin-top: 4px; }
56+@media (max-width: 900px) { #app { grid-template-columns: 1fr; grid-template-rows: auto 1fr; } #sidebar { max-height: 45vh; } }
src/ui/editor.tsadded+55−0View file
@@ -0,0 +1,55 @@
1+// CodeMirror YAML editor.
2+import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
3+import { HighlightStyle, StreamLanguage, syntaxHighlighting } from "@codemirror/language";
4+import { yaml } from "@codemirror/legacy-modes/mode/yaml";
5+import { EditorState } from "@codemirror/state";
6+import { EditorView, drawSelection, highlightActiveLine, keymap, lineNumbers } from "@codemirror/view";
7+import { tags } from "@lezer/highlight";
8+
9+const highlight = HighlightStyle.define([
10+ { tag: tags.atom, color: "#e0a23a" }, { tag: tags.number, color: "#9cdcfe" }, { tag: tags.string, color: "#ce9178" },
11+ { tag: tags.comment, color: "#6a9955", fontStyle: "italic" }, { tag: tags.keyword, color: "#c586c0" }, { tag: tags.meta, color: "#9aa0a8" },
12+]);
13+
14+const theme = EditorView.theme({
15+ "&": { backgroundColor: "#111318", color: "#e6e6e6" },
16+ ".cm-gutters": { backgroundColor: "#15181d", color: "#5e646c", border: "none" },
17+ ".cm-activeLine": { backgroundColor: "#1a1e25" },
18+ ".cm-activeLineGutter": { backgroundColor: "#1a1e25" },
19+ "&.cm-focused .cm-cursor": { borderLeftColor: "#e6e6e6" },
20+ "&.cm-focused .cm-selectionBackground, ::selection": { backgroundColor: "#2c3a55" },
21+}, { dark: true });
22+
23+export class YamlEditor {
24+ readonly view: EditorView;
25+ private suppress = false;
26+
27+ constructor(parent: HTMLElement, initial: string, onChange: (text: string) => void) {
28+ let timer: number | undefined;
29+ this.view = new EditorView({
30+ parent,
31+ state: EditorState.create({
32+ doc: initial,
33+ extensions: [
34+ lineNumbers(), history(), drawSelection(), highlightActiveLine(),
35+ keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
36+ StreamLanguage.define(yaml), syntaxHighlighting(highlight), theme,
37+ EditorView.updateListener.of((u) => {
38+ if (!u.docChanged || this.suppress) return;
39+ window.clearTimeout(timer);
40+ timer = window.setTimeout(() => onChange(this.text), 250);
41+ }),
42+ ],
43+ }),
44+ });
45+ }
46+
47+ get text(): string { return this.view.state.doc.toString(); }
48+
49+ setText(text: string) {
50+ if (text === this.text) return;
51+ this.suppress = true;
52+ this.view.dispatch({ changes: { from: 0, to: this.view.state.doc.length, insert: text } });
53+ this.suppress = false;
54+ }
55+}
src/ui/form.tsadded+124−0View file
@@ -0,0 +1,124 @@
1+// Form editor of a SimulationConfig, kept in sync with the YAML editor by main.ts.
2+import { defaultMallet, defaultStick, type Exciter, type SimulationConfig } from "../config/schema.ts";
3+
4+interface Field { key: string; label: string; unit?: string; step?: number }
5+interface Group { section: Exclude<keyof SimulationConfig, "hits">; title: string; fields: Field[] }
6+
7+const GROUPS: Group[] = [
8+ { section: "material", title: "Material", fields: [
9+ { key: "E", label: "Young's modulus", unit: "Pa" }, { key: "nu", label: "Poisson ratio" }, { key: "rho", label: "density", unit: "kg/m³" },
10+ { key: "sigma0", label: "σ0 (frequency-independent loss)", unit: "1/s" }, { key: "sigma1", label: "σ1 (loss ∝ ω)", unit: "m²/s" } ] },
11+ { section: "profile", title: "Profile", fields: [
12+ { key: "radius", label: "radius", unit: "m" }, { key: "bell_height", label: "bell height", unit: "m" }, { key: "bell_top_radius", label: "bell plateau radius", unit: "m" },
13+ { key: "bell_shoulder_radius", label: "shoulder radius", unit: "m" }, { key: "bow_height", label: "bow height", unit: "m" }, { key: "bow_power", label: "bow exponent" },
14+ { key: "h_rim", label: "thickness at rim", unit: "m" }, { key: "h_shoulder", label: "thickness at shoulder", unit: "m" }, { key: "h_bell", label: "thickness in bell", unit: "m" },
15+ { key: "max_slope", label: "slope warning above" } ] },
16+ { section: "imperfections", title: "Imperfections", fields: [
17+ { key: "seed", label: "seed", step: 1 }, { key: "n_dents", label: "hammer dents", step: 1 }, { key: "dent_depth", label: "dent depth", unit: "m" },
18+ { key: "dent_sigma", label: "dent width σ", unit: "m" }, { key: "dent_margin", label: "dent margin", unit: "m" }, { key: "rim_warp", label: "rim warp (m = 2)", unit: "m" },
19+ { key: "rim_warp_modes", label: "rim warp modes" }, { key: "shoulder_ovality", label: "shoulder ovality" }, { key: "bell_offset", label: "bell offset", unit: "m" } ] },
20+ { section: "mesh", title: "Mesh", fields: [ { key: "element_size", label: "element size", unit: "m" }, { key: "smoothing", label: "smoothing passes", step: 1 } ] },
21+ { section: "listener", title: "Listener", fields: [
22+ { key: "position", label: "position x, y, z", unit: "m" }, { key: "c_air", label: "speed of sound", unit: "m/s" }, { key: "rho_air", label: "air density", unit: "kg/m³" },
23+ { key: "back_diffraction_gain", label: "back-diffraction gain" } ] },
24+ { section: "numerics", title: "Numerics", fields: [
25+ { key: "fs", label: "sample rate", unit: "Hz", step: 1 }, { key: "duration", label: "duration", unit: "s" }, { key: "cfl_safety", label: "CFL safety" },
26+ { key: "rot_inertia_factor", label: "rotational inertia factor" }, { key: "inplane_mass_scale", label: "in-plane mass scale" }, { key: "nonlinear", label: "nonlinear" },
27+ { key: "n_progress", label: "progress reports", step: 1 } ] },
28+];
29+
30+const HIT_FIELDS: Field[] = [ { key: "time", label: "time", unit: "s" }, { key: "r", label: "radius r", unit: "m" }, { key: "phi", label: "azimuth φ", unit: "rad" }, { key: "velocity", label: "velocity", unit: "m/s" } ];
31+const EXCITER_FIELDS: Record<Exciter["kind"], Field[]> = {
32+ stick: [ { key: "mass", label: "mass", unit: "kg" }, { key: "k", label: "contact stiffness k", unit: "N/m^α" }, { key: "alpha", label: "exponent α" },
33+ { key: "patch_length", label: "patch length", unit: "m" }, { key: "patch_points", label: "patch points", step: 1 }, { key: "patch_angle", label: "patch angle", unit: "rad" }, { key: "gap", label: "release gap", unit: "m" } ],
34+ mallet: [ { key: "mass", label: "mass", unit: "kg" }, { key: "alpha", label: "exponent α" }, { key: "compression", label: "compression at stop", unit: "m" },
35+ { key: "radius", label: "head radius", unit: "m" }, { key: "gap", label: "release gap", unit: "m" } ],
36+};
37+
38+export class ConfigForm {
39+ private cfg: SimulationConfig;
40+ private readonly root: HTMLElement;
41+ private readonly onChange: (cfg: SimulationConfig) => void;
42+
43+ constructor(root: HTMLElement, cfg: SimulationConfig, onChange: (cfg: SimulationConfig) => void) {
44+ this.root = root; this.cfg = structuredClone(cfg); this.onChange = onChange;
45+ this.render();
46+ }
47+
48+ /** Replace the model (from the YAML side) and rebuild the form. */
49+ setConfig(cfg: SimulationConfig) { this.cfg = structuredClone(cfg); this.render(); }
50+ get config(): SimulationConfig { return structuredClone(this.cfg); }
51+
52+ private emit() { this.onChange(structuredClone(this.cfg)); }
53+
54+ private input(obj: Record<string, unknown>, field: Field): HTMLElement {
55+ const val = obj[field.key];
56+ let el: HTMLInputElement;
57+ if (typeof val === "boolean") {
58+ el = document.createElement("input"); el.type = "checkbox"; el.checked = val;
59+ el.onchange = () => { obj[field.key] = el.checked; this.emit(); };
60+ } else if (Array.isArray(val)) {
61+ el = document.createElement("input"); el.type = "text"; el.value = val.join(", ");
62+ el.onchange = () => {
63+ const nums = el.value.split(/[,\s]+/).filter((s) => s.length).map(Number);
64+ if (nums.every((x) => Number.isFinite(x))) { obj[field.key] = nums; this.emit(); }
65+ };
66+ } else if (typeof val === "string") {
67+ el = document.createElement("input"); el.type = "text"; el.value = val;
68+ el.onchange = () => { obj[field.key] = el.value; this.emit(); };
69+ } else {
70+ el = document.createElement("input"); el.type = "number"; el.step = field.step ? String(field.step) : "any"; el.value = String(val);
71+ el.onchange = () => { const x = Number(el.value); if (Number.isFinite(x)) { obj[field.key] = x; this.emit(); } };
72+ }
73+ return el;
74+ }
75+
76+ private grid(obj: Record<string, unknown>, fields: Field[]): HTMLElement {
77+ const grid = document.createElement("div"); grid.className = "grid";
78+ for (const f of fields) {
79+ const label = document.createElement("label"); label.textContent = f.label;
80+ if (f.unit) { const u = document.createElement("span"); u.className = "unit"; u.textContent = f.unit; label.appendChild(u); }
81+ grid.appendChild(label); grid.appendChild(this.input(obj, f));
82+ }
83+ return grid;
84+ }
85+
86+ private render() {
87+ this.root.replaceChildren();
88+ for (const g of GROUPS) {
89+ const det = document.createElement("details"); det.open = g.section === "numerics";
90+ const sum = document.createElement("summary"); sum.textContent = g.title; det.appendChild(sum);
91+ det.appendChild(this.grid(this.cfg[g.section] as unknown as Record<string, unknown>, g.fields));
92+ this.root.appendChild(det);
93+ }
94+ const det = document.createElement("details"); det.open = true;
95+ const sum = document.createElement("summary"); sum.textContent = `Hits (${this.cfg.hits.length})`; det.appendChild(sum);
96+ this.cfg.hits.forEach((hit, i) => {
97+ const box = document.createElement("div"); box.className = "hit";
98+ const head = document.createElement("div"); head.className = "head";
99+ const title = document.createElement("span"); title.textContent = `hit ${i + 1}`;
100+ const kind = document.createElement("select");
101+ for (const k of ["mallet", "stick"]) { const o = document.createElement("option"); o.value = k; o.textContent = k; o.selected = hit.exciter.kind === k; kind.appendChild(o); }
102+ kind.onchange = () => { hit.exciter = kind.value === "stick" ? defaultStick() : defaultMallet(); this.emit(); this.render(); };
103+ const rm = document.createElement("button"); rm.textContent = "remove";
104+ rm.onclick = () => { this.cfg.hits.splice(i, 1); this.emit(); this.render(); };
105+ head.append(title, kind, rm); box.appendChild(head);
106+ box.appendChild(this.grid(hit as unknown as Record<string, unknown>, HIT_FIELDS));
107+ box.appendChild(this.grid(hit.exciter as unknown as Record<string, unknown>, EXCITER_FIELDS[hit.exciter.kind]));
108+ det.appendChild(box);
109+ });
110+ const add = document.createElement("div"); add.className = "row buttons";
111+ for (const k of ["mallet", "stick"] as const) {
112+ const b = document.createElement("button"); b.textContent = `+ ${k} hit`;
113+ b.onclick = () => {
114+ const last = this.cfg.hits[this.cfg.hits.length - 1];
115+ this.cfg.hits.push({ time: last ? last.time + 0.5 : 0, r: k === "stick" ? 0.194 : 0.137, phi: 0.3, velocity: k === "stick" ? 7 : 8,
116+ exciter: k === "stick" ? defaultStick() : defaultMallet() });
117+ this.emit(); this.render();
118+ };
119+ add.appendChild(b);
120+ }
121+ det.appendChild(add);
122+ this.root.appendChild(det);
123+ }
124+}
src/ui/plateView.tsadded+166−0View file
@@ -0,0 +1,166 @@
1+// Live view of the cymbal: a lit 3D height field rendered with WebGPU from the mesh and the
2+// simulation's state buffer (w read straight from it, no readback), or a 2D colour map on a
3+// canvas when WebGPU is unavailable. Drag to orbit, wheel to zoom.
4+import type { Mesh } from "../model/mesh.ts";
5+
6+type Mat4 = Float32Array;
7+function perspective(fovy: number, aspect: number, near: number, far: number): Mat4 {
8+ const f = 1 / Math.tan(fovy / 2), m = new Float32Array(16);
9+ m[0] = f / aspect; m[5] = f; m[10] = far / (near - far); m[11] = -1; m[14] = (near * far) / (near - far);
10+ return m;
11+}
12+function lookAt(eye: number[], target: number[], up: number[]): Mat4 {
13+ const sub = (a: number[], b: number[]) => a.map((x, i) => x - b[i]);
14+ const norm = (a: number[]) => { const l = Math.hypot(...a); return a.map((x) => x / l); };
15+ const cross = (a: number[], b: number[]) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
16+ const z = norm(sub(eye, target)), x = norm(cross(up, z)), y = cross(z, x);
17+ const dot = (a: number[], b: number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
18+ return new Float32Array([x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0, -dot(x, eye), -dot(y, eye), -dot(z, eye), 1]);
19+}
20+function mul(a: Mat4, b: Mat4): Mat4 {
21+ const m = new Float32Array(16);
22+ for (let i = 0; i < 4; i++) for (let j = 0; j < 4; j++) { let s = 0; for (let k = 0; k < 4; k++) s += a[k * 4 + j] * b[i * 4 + k]; m[i * 4 + j] = s; }
23+ return m;
24+}
25+
26+const SHADER = /* wgsl */ `
27+struct U { mvp: mat4x4<f32>, exag: f32, wScale: f32, zMid: f32, pad: f32 }
28+@group(0) @binding(0) var<uniform> u: U;
29+@group(0) @binding(1) var<storage, read> pos: array<f32>;
30+@group(0) @binding(2) var<storage, read> state: array<f32>;
31+struct VSOut { @builtin(position) clip: vec4<f32>, @location(0) world: vec3<f32>, @location(1) color: vec3<f32> }
32+@vertex fn vs(@builtin(vertex_index) vi: u32) -> VSOut {
33+ let w = state[5u * vi + 2u];
34+ let p = vec3<f32>(pos[3u * vi], pos[3u * vi + 1u], pos[3u * vi + 2u] + u.exag * w - u.zMid);
35+ let t = clamp(w / u.wScale, -1.0, 1.0);
36+ let bronze = vec3<f32>(0.80, 0.62, 0.32);
37+ let c = mix(bronze, select(vec3<f32>(0.15, 0.35, 1.0), vec3<f32>(1.0, 0.2, 0.15), t > 0.0), abs(t));
38+ var o: VSOut; o.clip = u.mvp * vec4<f32>(p, 1.0); o.world = p; o.color = c; return o;
39+}
40+@fragment fn fs(in: VSOut) -> @location(0) vec4<f32> {
41+ let n = normalize(cross(dpdx(in.world), dpdy(in.world)));
42+ let l = normalize(vec3<f32>(0.4, 0.3, 1.0));
43+ let shade = 0.3 + 0.7 * abs(dot(n, l));
44+ return vec4<f32>(in.color * shade, 1.0);
45+}`;
46+
47+export class PlateView {
48+ private readonly canvas: HTMLCanvasElement;
49+ private mesh: Mesh | null = null; private z0: Float64Array | null = null; private hRef = 1e-3;
50+ private w: Float32Array | null = null;
51+ // camera
52+ private az = 0.6; private el = 0.9; private dist = 0.7;
53+ exaggeration = 20;
54+ // WebGPU
55+ private device: GPUDevice | null = null; private ctx: GPUCanvasContext | null = null; private format: GPUTextureFormat = "bgra8unorm";
56+ private pipeline: GPURenderPipeline | null = null; private uniform: GPUBuffer | null = null; private posBuf: GPUBuffer | null = null;
57+ private stateBuf: GPUBuffer | null = null; private ownState = false; private indexBuf: GPUBuffer | null = null;
58+ private depth: GPUTexture | null = null; private bindGroup: GPUBindGroup | null = null;
59+
60+ constructor(canvas: HTMLCanvasElement, device: GPUDevice | null) {
61+ this.canvas = canvas; this.device = device;
62+ if (device) {
63+ this.ctx = canvas.getContext("webgpu");
64+ if (this.ctx) {
65+ this.format = navigator.gpu.getPreferredCanvasFormat();
66+ this.ctx.configure({ device, format: this.format, alphaMode: "opaque" });
67+ const module = device.createShaderModule({ code: SHADER });
68+ this.pipeline = device.createRenderPipeline({
69+ layout: "auto", vertex: { module, entryPoint: "vs" }, fragment: { module, entryPoint: "fs", targets: [{ format: this.format }] },
70+ primitive: { topology: "triangle-list", cullMode: "none" }, depthStencil: { format: "depth24plus", depthWriteEnabled: true, depthCompare: "less" },
71+ });
72+ this.uniform = device.createBuffer({ size: 80, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
73+ } else this.device = null;
74+ }
75+ let dragging = false, lx = 0, ly = 0;
76+ canvas.addEventListener("pointerdown", (e) => { dragging = true; lx = e.clientX; ly = e.clientY; canvas.setPointerCapture(e.pointerId); });
77+ canvas.addEventListener("pointermove", (e) => {
78+ if (!dragging) return;
79+ this.az -= (e.clientX - lx) * 0.01; this.el = Math.min(1.5, Math.max(0.05, this.el + (e.clientY - ly) * 0.01)); lx = e.clientX; ly = e.clientY; this.render();
80+ });
81+ canvas.addEventListener("pointerup", () => { dragging = false; });
82+ canvas.addEventListener("wheel", (e) => { e.preventDefault(); this.dist *= Math.exp(e.deltaY * 0.001); this.render(); }, { passive: false });
83+ }
84+
85+ get usesWebGPU() { return this.device !== null; }
86+
87+ resetView() { this.az = 0.6; this.el = 0.9; this.dist = 0.7; this.render(); }
88+
89+ /** Install a mesh; ``stateBuffer`` is the simulator's state buffer (5 floats per node) or null to upload w from the CPU. */
90+ setMesh(mesh: Mesh, z0: Float64Array, hRef: number, stateBuffer: GPUBuffer | null) {
91+ this.mesh = mesh; this.z0 = z0; this.hRef = hRef; this.w = new Float32Array(mesh.n);
92+ const d = this.device;
93+ if (d) {
94+ this.posBuf?.destroy(); this.indexBuf?.destroy(); if (this.ownState) this.stateBuf?.destroy();
95+ const pos = new Float32Array(3 * mesh.n);
96+ for (let n = 0; n < mesh.n; n++) { pos[3 * n] = mesh.xy[2 * n]; pos[3 * n + 1] = mesh.xy[2 * n + 1]; pos[3 * n + 2] = z0[n]; }
97+ this.posBuf = d.createBuffer({ size: pos.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
98+ d.queue.writeBuffer(this.posBuf, 0, pos);
99+ this.indexBuf = d.createBuffer({ size: mesh.tri.byteLength, usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST });
100+ d.queue.writeBuffer(this.indexBuf, 0, new Uint32Array(mesh.tri));
101+ if (stateBuffer) { this.stateBuf = stateBuffer; this.ownState = false; }
102+ else { this.stateBuf = d.createBuffer({ size: 5 * mesh.n * 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST }); this.ownState = true; }
103+ this.bindGroup = d.createBindGroup({ layout: this.pipeline!.getBindGroupLayout(0), entries: [
104+ { binding: 0, resource: { buffer: this.uniform! } }, { binding: 1, resource: { buffer: this.posBuf } }, { binding: 2, resource: { buffer: this.stateBuf } } ] });
105+ }
106+ this.render();
107+ }
108+
109+ /** Update w from the CPU (used with the CPU backend or before a run). */
110+ setDisplacement(w: Float32Array) {
111+ this.w = w;
112+ if (this.device && this.ownState && this.stateBuf) {
113+ const st = new Float32Array(5 * w.length);
114+ for (let n = 0; n < w.length; n++) st[5 * n + 2] = w[n];
115+ this.device.queue.writeBuffer(this.stateBuf, 0, st);
116+ }
117+ this.render();
118+ }
119+
120+ render() {
121+ const cv = this.canvas, dpr = window.devicePixelRatio || 1;
122+ const W = Math.max(1, Math.round(cv.clientWidth * dpr)), H = Math.max(1, Math.round(cv.clientHeight * dpr));
123+ if (cv.width !== W || cv.height !== H) { cv.width = W; cv.height = H; this.depth?.destroy(); this.depth = null; }
124+ if (!this.mesh) return;
125+ if (this.device && this.ctx && this.bindGroup) this.renderGpu(W, H); else this.render2d();
126+ }
127+
128+ private renderGpu(W: number, H: number) {
129+ const d = this.device!, mesh = this.mesh!;
130+ if (!this.depth) this.depth = d.createTexture({ size: [W, H], format: "depth24plus", usage: GPUTextureUsage.RENDER_ATTACHMENT });
131+ let zMin = Infinity, zMax = -Infinity;
132+ for (const z of this.z0!) { zMin = Math.min(zMin, z); zMax = Math.max(zMax, z); }
133+ const zMid = 0.5 * (zMin + zMax), R = this.dist;
134+ const eye = [R * Math.cos(this.el) * Math.cos(this.az), R * Math.cos(this.el) * Math.sin(this.az), R * Math.sin(this.el)];
135+ const mvp = mul(perspective(0.6, W / H, 0.02, 20), lookAt(eye, [0, 0, 0], [0, 0, 1]));
136+ const u = new Float32Array(20); u.set(mvp); u[16] = this.exaggeration; u[17] = 3 * this.hRef; u[18] = zMid;
137+ d.queue.writeBuffer(this.uniform!, 0, u);
138+ const enc = d.createCommandEncoder();
139+ const pass = enc.beginRenderPass({
140+ colorAttachments: [{ view: this.ctx!.getCurrentTexture().createView(), clearValue: { r: 0.08, g: 0.086, b: 0.1, a: 1 }, loadOp: "clear", storeOp: "store" }],
141+ depthStencilAttachment: { view: this.depth.createView(), depthClearValue: 1, depthLoadOp: "clear", depthStoreOp: "store" },
142+ });
143+ pass.setPipeline(this.pipeline!); pass.setBindGroup(0, this.bindGroup!); pass.setIndexBuffer(this.indexBuf!, "uint32");
144+ pass.drawIndexed(3 * mesh.e); pass.end();
145+ d.queue.submit([enc.finish()]);
146+ }
147+
148+ private render2d() {
149+ const cv = this.canvas, ctx = cv.getContext("2d")!, mesh = this.mesh!, w = this.w!;
150+ const dpr = window.devicePixelRatio || 1;
151+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
152+ const cw = cv.clientWidth, ch = cv.clientHeight;
153+ ctx.fillStyle = "#14161a"; ctx.fillRect(0, 0, cw, ch);
154+ let R = 0; for (let n = 0; n < mesh.n; n++) R = Math.max(R, Math.hypot(mesh.xy[2 * n], mesh.xy[2 * n + 1]));
155+ const s = (0.46 * Math.min(cw, ch)) / R, cx = cw / 2, cy = ch / 2, scale = 3 * this.hRef;
156+ for (let e = 0; e < mesh.e; e++) {
157+ const a = mesh.tri[3 * e], b = mesh.tri[3 * e + 1], c = mesh.tri[3 * e + 2];
158+ const t = Math.max(-1, Math.min(1, (w[a] + w[b] + w[c]) / (3 * scale)));
159+ const base = [204, 158, 82], tint = t > 0 ? [255, 51, 38] : [38, 89, 255], k = Math.abs(t);
160+ ctx.fillStyle = `rgb(${base.map((v, i) => Math.round(v + k * (tint[i] - v))).join(",")})`;
161+ ctx.beginPath(); ctx.moveTo(cx + s * mesh.xy[2 * a], cy - s * mesh.xy[2 * a + 1]);
162+ ctx.lineTo(cx + s * mesh.xy[2 * b], cy - s * mesh.xy[2 * b + 1]); ctx.lineTo(cx + s * mesh.xy[2 * c], cy - s * mesh.xy[2 * c + 1]);
163+ ctx.closePath(); ctx.fill();
164+ }
165+ }
166+}
src/ui/readouts.tsadded+50−0View file
@@ -0,0 +1,50 @@
1+// Set-up summary, energy and hit tables, and a short log.
2+import type { SetupSummary } from "../model/setup.ts";
3+import type { Diagnostics } from "../model/simulator.ts";
4+
5+const f = (x: number, d = 3) => (Number.isFinite(x) ? x.toFixed(d) : "-");
6+const e = (x: number) => (Number.isFinite(x) ? x.toExponential(2) : "-");
7+
8+export function formatSummary(s: SetupSummary): string {
9+ const lines = [
10+ `mesh: ${s.nodes} nodes, ${s.elements} triangles, ${s.dofs} DOFs`,
11+ `profile: height ${f(s.height * 1e3, 1)} mm, rim warp p-p ${f(s.rimWarp * 1e3, 2)} mm, ${s.dents} dents, h in [${f(s.hMin * 1e3, 2)}, ${f(s.hMax * 1e3, 2)}] mm, max |grad z0| = ${f(s.maxSlope)}${s.slopeWarning ? " (above the shallow-shell bound)" : ""}, mass ${f(s.mass)} kg`,
12+ `omega_max bound ${e(s.omegaMax)} rad/s (limiting element at r = ${f(s.limitingR)} m, h = ${f(s.limitingH * 1e3, 2)} mm)`,
13+ `dt = ${e(s.dt)} s, ${s.substeps} sub-steps per sample, ${s.nSteps} steps`,
14+ ];
15+ s.hits.forEach((h, k) => lines.push(
16+ `hit ${k} at t = ${f(h.time)} s: ${h.kind}, ${h.points}-point patch at r = ${f(h.r)} m, k = ${e(h.kTotal)} N/m^${f(h.alpha, 2)}, m = ${f(h.mass)} kg, v = ${f(h.velocity, 1)} m/s, energy ${f(h.energy, 2)} J, free contact ~ ${f(h.freeContact * 1e3, 2)} ms (direct excitation below ~${f(h.directBelow, 0)} Hz)${h.late ? " (after the end of the run)" : ""}`));
17+ lines.push(`set-up took ${f(s.setupSeconds, 1)} s`);
18+ return lines.join("\n");
19+}
20+
21+export class Readouts {
22+ private readonly root: HTMLElement;
23+ private readonly energies: HTMLTableElement; private readonly hits: HTMLTableElement; private readonly logEl: HTMLDivElement;
24+ private lines: string[] = [];
25+
26+ constructor(root: HTMLElement) {
27+ this.root = root;
28+ this.energies = document.createElement("table"); this.hits = document.createElement("table");
29+ this.logEl = document.createElement("div"); this.logEl.className = "log";
30+ root.append(this.energies, this.hits, this.logEl);
31+ this.energies.innerHTML = "<tr><th>t [s]</th><th>kinetic [J]</th><th>membrane [J]</th><th>bending [J]</th><th>exciters [J]</th><th>total [J]</th><th>max|w|/h</th></tr><tr><td colspan=7 class='muted'>no run yet</td></tr>";
32+ }
33+
34+ setDiagnostics(d: Diagnostics, kinds: string[]) {
35+ const tot = d.kinetic + d.membrane + d.bending + d.exciters;
36+ this.energies.innerHTML = `<tr><th>t [s]</th><th>kinetic [J]</th><th>membrane [J]</th><th>bending [J]</th><th>exciters [J]</th><th>total [J]</th><th>max|w|/h</th></tr>` +
37+ `<tr><td>${f(d.time)}</td><td>${e(d.kinetic)}</td><td>${e(d.membrane)}</td><td>${e(d.bending)}</td><td>${e(d.exciters)}</td><td>${e(tot)}</td><td>${f(d.maxWoverH, 2)}</td></tr>`;
38+ this.hits.innerHTML = `<tr><th>hit</th><th>peak force [N]</th><th>contact [ms]</th><th>exciter velocity [m/s]</th></tr>` +
39+ d.hits.map((h, k) => `<tr><td>${k} (${kinds[k]})</td><td>${f(h.peakForce, 1)}</td><td>${f(h.contactTime * 1e3, 2)}</td><td>${f(h.velocity, 2)}</td></tr>`).join("");
40+ }
41+
42+ log(line: string) {
43+ this.lines.push(line);
44+ if (this.lines.length > 8) this.lines.shift();
45+ this.logEl.textContent = this.lines.join("\n");
46+ this.root.scrollTop = this.root.scrollHeight;
47+ }
48+
49+ clearLog() { this.lines = []; this.logEl.textContent = ""; }
50+}
src/ui/spectrogramView.tsadded+95−0View file
@@ -0,0 +1,95 @@
1+// Spectrogram canvas: log-frequency rows, dB colour scale relative to the loudest column seen,
2+// a time axis spanning the whole run, the frontier of the generated signal and a playback cursor.
3+import type { Stft } from "../analysis/spectrogram.ts";
4+
5+const ROWS = 256, DB_RANGE = 80, F_MIN = 40;
6+const STOPS = [[0.0, 13, 8, 60], [0.25, 40, 40, 130], [0.5, 30, 120, 140], [0.75, 90, 200, 90], [1.0, 250, 240, 80]];
7+
8+function color(t: number): [number, number, number] {
9+ const x = Math.min(Math.max(t, 0), 1);
10+ for (let i = 1; i < STOPS.length; i++) {
11+ if (x <= STOPS[i][0]) {
12+ const [t0, r0, g0, b0] = STOPS[i - 1], [t1, r1, g1, b1] = STOPS[i], u = (x - t0) / (t1 - t0);
13+ return [r0 + u * (r1 - r0), g0 + u * (g1 - g0), b0 + u * (b1 - b0)];
14+ }
15+ }
16+ return [250, 240, 80];
17+}
18+
19+export class SpectrogramView {
20+ private readonly canvas: HTMLCanvasElement;
21+ private readonly off = document.createElement("canvas");
22+ private stft: Stft | null = null;
23+ private duration = 1; private drawn = 0; private maxPower = 0;
24+ private rowLo: Int32Array = new Int32Array(0); private rowHi: Int32Array = new Int32Array(0);
25+ cursorTime: number | null = null; frontierTime = 0;
26+
27+ constructor(canvas: HTMLCanvasElement) { this.canvas = canvas; }
28+
29+ reset(stft: Stft, duration: number) {
30+ this.stft = stft; this.duration = duration; this.drawn = 0; this.maxPower = 0; this.frontierTime = 0; this.cursorTime = null;
31+ this.off.width = Math.max(1, Math.ceil((duration * stft.fs) / stft.hop)); this.off.height = ROWS;
32+ this.off.getContext("2d")!.fillStyle = "#0d0838"; this.off.getContext("2d")!.fillRect(0, 0, this.off.width, ROWS);
33+ const fMax = stft.fs / 2;
34+ this.rowLo = new Int32Array(ROWS); this.rowHi = new Int32Array(ROWS);
35+ for (let r = 0; r < ROWS; r++) {
36+ const fHi = F_MIN * Math.pow(fMax / F_MIN, 1 - r / ROWS), fLo = F_MIN * Math.pow(fMax / F_MIN, 1 - (r + 1) / ROWS);
37+ this.rowLo[r] = Math.max(0, Math.floor(fLo / stft.binFrequency(1)));
38+ this.rowHi[r] = Math.max(this.rowLo[r] + 1, Math.ceil(fHi / stft.binFrequency(1)));
39+ }
40+ this.draw();
41+ }
42+
43+ /** Draw the columns computed since the last call; rescales everything when the maximum grows. */
44+ update() {
45+ const st = this.stft;
46+ if (!st) return;
47+ let mx = this.maxPower;
48+ for (let c = this.drawn; c < st.columns.length; c++) for (const p of st.columns[c]) mx = Math.max(mx, p);
49+ if (mx > this.maxPower * 2) { this.maxPower = mx; this.drawn = 0; } else this.maxPower = Math.max(this.maxPower, mx);
50+ const ctx = this.off.getContext("2d")!;
51+ const ref = this.maxPower || 1;
52+ for (let c = this.drawn; c < st.columns.length && c < this.off.width; c++) {
53+ const img = ctx.createImageData(1, ROWS), col = st.columns[c];
54+ for (let r = 0; r < ROWS; r++) {
55+ let p = 0;
56+ for (let b = this.rowLo[r]; b < Math.min(this.rowHi[r], col.length); b++) p = Math.max(p, col[b]);
57+ const db = 10 * Math.log10(p / ref + 1e-30);
58+ const [R, G, B] = color(1 + db / DB_RANGE);
59+ img.data[4 * r] = R; img.data[4 * r + 1] = G; img.data[4 * r + 2] = B; img.data[4 * r + 3] = 255;
60+ }
61+ ctx.putImageData(img, c, 0);
62+ }
63+ this.drawn = Math.min(st.columns.length, this.off.width);
64+ this.draw();
65+ }
66+
67+ draw() {
68+ const cv = this.canvas, dpr = window.devicePixelRatio || 1;
69+ const w = cv.clientWidth, h = cv.clientHeight;
70+ if (cv.width !== Math.round(w * dpr) || cv.height !== Math.round(h * dpr)) { cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr); }
71+ const ctx = cv.getContext("2d")!;
72+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
73+ ctx.imageSmoothingEnabled = false;
74+ ctx.fillStyle = "#0d0838"; ctx.fillRect(0, 0, w, h);
75+ const left = 44, bottom = 18, pw = w - left - 6, ph = h - bottom - 4;
76+ if (this.stft) ctx.drawImage(this.off, 0, 0, this.off.width, ROWS, left, 4, pw, ph);
77+ ctx.fillStyle = "#c8c8c8"; ctx.font = "10px system-ui, sans-serif"; ctx.textAlign = "right";
78+ const fMax = this.stft ? this.stft.fs / 2 : 22050;
79+ for (const f of [50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000]) {
80+ if (f < F_MIN || f > fMax) continue;
81+ const y = 4 + ph * (1 - Math.log(f / F_MIN) / Math.log(fMax / F_MIN));
82+ ctx.fillText(f >= 1000 ? `${f / 1000}k` : String(f), left - 4, y + 3);
83+ ctx.fillStyle = "rgba(255,255,255,0.12)"; ctx.fillRect(left, y, pw, 1); ctx.fillStyle = "#c8c8c8";
84+ }
85+ ctx.textAlign = "center";
86+ const tick = this.duration > 4 ? 1 : this.duration > 1.5 ? 0.5 : 0.1;
87+ for (let t = 0; t <= this.duration + 1e-9; t += tick) {
88+ const x = left + (pw * t) / this.duration;
89+ ctx.fillText(`${t.toFixed(tick < 0.5 ? 1 : 1)}`, x, h - 5);
90+ }
91+ const xf = left + (pw * this.frontierTime) / this.duration;
92+ ctx.fillStyle = "rgba(255,255,255,0.5)"; ctx.fillRect(xf, 4, 1, ph);
93+ if (this.cursorTime !== null) { ctx.fillStyle = "#ff5a5a"; ctx.fillRect(left + (pw * this.cursorTime) / this.duration, 4, 2, ph); }
94+ }
95+}
tests/analysis.test.tsadded+34−0View file
@@ -0,0 +1,34 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import { Stft, fft } from "../src/analysis/spectrogram.ts";
4+import { encodeWav, scaleForPlayback } from "../src/audio/player.ts";
5+
6+test("FFT of a sine peaks at the right bin", () => {
7+ const n = 1024, re = new Float32Array(n), im = new Float32Array(n);
8+ for (let i = 0; i < n; i++) re[i] = Math.sin((2 * Math.PI * 64 * i) / n);
9+ fft(re, im);
10+ let best = 0;
11+ for (let b = 1; b < n / 2; b++) if (Math.hypot(re[b], im[b]) > Math.hypot(re[best], im[best])) best = b;
12+ assert.equal(best, 64);
13+ assert.ok(Math.abs(Math.hypot(re[64], im[64]) - n / 2) < 1e-2);
14+});
15+
16+test("incremental STFT tracks a chirp's centroid", () => {
17+ const fs = 44100, st = new Stft(fs, 1024, 256);
18+ const sig = new Float32Array(fs);
19+ for (let i = 0; i < fs; i++) { const t = i / fs; sig[i] = Math.sin(2 * Math.PI * (500 * t + 2000 * t * t)); }
20+ let cols = 0;
21+ for (let s = 0; s < fs; s += 1000) cols += st.push(sig.subarray(s, Math.min(fs, s + 1000)));
22+ assert.equal(cols, st.columns.length);
23+ assert.ok(cols > 150);
24+ const early = st.centroid[5], late = st.centroid[cols - 5];
25+ assert.ok(early > 400 && early < 1200 && late > 3500 && late < 5500, `${early} ${late}`);
26+});
27+
28+test("WAV encoding and playback scaling", () => {
29+ const p = new Float32Array([0, 2, -4, 1]);
30+ const s = scaleForPlayback(p, true);
31+ assert.ok(Math.abs(Math.max(...Array.from(s).map(Math.abs)) - 0.9) < 1e-6);
32+ const blob = encodeWav(s, 44100);
33+ assert.equal(blob.size, 44 + 8);
34+});
tests/config.test.tsadded+53−0View file
@@ -0,0 +1,53 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import { configToYaml, normalizeConfig, parseConfigYaml, validateConfig } from "../src/config/yaml.ts";
4+import { defaultConfig } from "../src/config/schema.ts";
5+import { PRESETS } from "../src/config/presets.ts";
6+import { fixture } from "./helpers.ts";
7+
8+test("presets parse without issues", () => {
9+ for (const p of PRESETS) {
10+ const { config, issues } = parseConfigYaml(p.yaml);
11+ assert.ok(config, p.id);
12+ assert.deepEqual(issues, [], `${p.id}: ${JSON.stringify(issues)}`);
13+ }
14+});
15+
16+test("round trip through YAML preserves the configuration", () => {
17+ const { config } = parseConfigYaml(PRESETS[2].yaml);
18+ const back = parseConfigYaml(configToYaml(config!));
19+ assert.deepEqual(back.config, config);
20+ assert.deepEqual(back.issues, []);
21+});
22+
23+test("Python fixture config normalizes to itself", () => {
24+ const { config, issues } = normalizeConfig(fixture.config);
25+ assert.deepEqual(issues, []);
26+ assert.deepEqual(config, fixture.config);
27+});
28+
29+test("partial documents take defaults", () => {
30+ const { config, issues } = parseConfigYaml("hits:\n - time: 0.1\n exciter: {kind: stick}\nnumerics:\n duration: 0.5\n");
31+ assert.deepEqual(issues, []);
32+ assert.equal(config!.numerics.duration, 0.5);
33+ assert.equal(config!.material.E, 1.1e11);
34+ assert.equal(config!.hits[0].exciter.kind, "stick");
35+ assert.equal(config!.hits[0].time, 0.1);
36+});
37+
38+test("unknown keys, wrong types and bad values are reported", () => {
39+ let r = parseConfigYaml("material:\n youngs: 1\n E: abc\n");
40+ assert.ok(r.issues.some((i) => i.path === "material.youngs" && i.message === "unknown field"));
41+ assert.ok(r.issues.some((i) => i.path === "material.E"));
42+ r = parseConfigYaml("hits:\n - exciter: {kind: hammer}\n");
43+ assert.ok(r.issues.some((i) => i.path === "hits[0].exciter.kind"));
44+ r = parseConfigYaml("bogus: 1\n");
45+ assert.ok(r.issues.some((i) => i.path === "bogus"));
46+ r = parseConfigYaml("material: [1, 2\n");
47+ assert.equal(r.config, null);
48+ const cfg = defaultConfig();
49+ cfg.hits[0].r = 0.23;
50+ assert.ok(validateConfig(cfg).some((i) => i.path === "hits[0].r"));
51+ cfg.hits = [];
52+ assert.ok(validateConfig(cfg).some((i) => i.path === "hits"));
53+});
tests/cpu.test.tsadded+69−0View file
@@ -0,0 +1,69 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import { buildSetup } from "../src/model/setup.ts";
4+import { CpuSimulator } from "../src/cpu/simulator.ts";
5+import { fixture } from "./helpers.ts";
6+
7+test("CPU time loop reproduces the Python run (dent-free, float64)", async () => {
8+ const setup = await buildSetup(fixture.config);
9+ assert.equal(setup.substeps, fixture.timestep.substeps);
10+ assert.ok(Math.abs(setup.dt / fixture.timestep.dt - 1) < 1e-12);
11+ const sim = new CpuSimulator(setup);
12+ const front: number[] = [], back: number[] = [];
13+ const py = fixture.run, hit = py.hits[0], last = py.energy_log[py.energy_log.length - 1];
14+ const nSamples = Math.round(last[0] * setup.fs); // Python ran whole blocks, slightly past the duration
15+ const chunk = 147;
16+ for (let done = 0; done < nSamples; done += chunk) {
17+ const r = await sim.runChunk(Math.min(chunk, nSamples - done));
18+ front.push(...r.pressureFront); back.push(...r.pressureBack);
19+ }
20+ const d = await sim.diagnostics();
21+ assert.ok(Math.abs(d.time - last[0]) < 1e-9);
22+ const rel = (a: number, b: number) => Math.abs(a - b) / Math.abs(b);
23+ assert.ok(rel(d.hits[0].peakForce, hit.peak_force) < 1e-3, `peak ${d.hits[0].peakForce} vs ${hit.peak_force}`);
24+ assert.ok(Math.abs(d.hits[0].contactTime - hit.contact_time) <= 2 * setup.dt + 1e-12);
25+ assert.ok(rel(d.hits[0].velocity, hit.rebound_velocity) < 1e-3, `rebound ${d.hits[0].velocity} vs ${hit.rebound_velocity}`);
26+ // the run is strongly nonlinear (max|w|/h ~ 10), so small scheme differences are amplified: 1 percent
27+ assert.ok(rel(d.kinetic, last[1]) < 1e-2 && rel(d.membrane, last[2]) < 1e-2 && rel(d.bending, last[3]) < 1e-2,
28+ `energies ${[d.kinetic, d.membrane, d.bending]} vs ${last.slice(1, 4)}`);
29+ assert.ok(rel(d.exciters, last[4]) < 1e-2 && rel(d.maxWoverH, last[5]) < 1e-2);
30+ // the lagged projection leaves a rigid velocity of one step's increment: tiny against the vibration
31+ assert.ok(sim.rigidContent() < 1e-3 * Math.sqrt(2 * d.kinetic), `rigid content ${sim.rigidContent()}`);
32+ // radiation: a different decimation and delay scheme, so compare loosely (rms and correlation)
33+ const pu = front.map((f, i) => f - back[i]), ref: number[] = py.pressure_unbaffled;
34+ const n = Math.min(pu.length, ref.length) - 4;
35+ const rms = (a: number[]) => Math.sqrt(a.slice(0, n).reduce((s, x) => s + x * x, 0) / n);
36+ let best = 0;
37+ for (let lag = -3; lag <= 3; lag++) {
38+ let dot = 0;
39+ for (let i = 4; i < n; i++) dot += pu[i] * ref[i + lag];
40+ best = Math.max(best, dot / (rms(pu) * rms(ref) * (n - 4)));
41+ }
42+ assert.ok(Math.abs(rms(pu) / rms(ref) - 1) < 0.1, `rms ${rms(pu)} vs ${rms(ref)}`);
43+ assert.ok(best > 0.95, `correlation ${best}`);
44+});
45+
46+test("linear run matches Python tightly (no chaotic amplification)", async () => {
47+ const cfg = structuredClone(fixture.config);
48+ cfg.numerics.nonlinear = false;
49+ const setup = await buildSetup(cfg);
50+ const sim = new CpuSimulator(setup);
51+ const py = fixture.run_linear, hit = py.hits[0], last = py.energy_log[py.energy_log.length - 1];
52+ const nSamples = Math.round(last[0] * setup.fs);
53+ const front: number[] = [], back: number[] = [];
54+ for (let done = 0; done < nSamples; done += 200) {
55+ const r = await sim.runChunk(Math.min(200, nSamples - done));
56+ front.push(...r.pressureFront); back.push(...r.pressureBack);
57+ }
58+ const d = await sim.diagnostics();
59+ const rel = (a: number, b: number) => Math.abs(a - b) / Math.abs(b);
60+ assert.ok(rel(d.hits[0].peakForce, hit.peak_force) < 1e-5, `peak ${d.hits[0].peakForce} vs ${hit.peak_force}`);
61+ assert.ok(rel(d.hits[0].velocity, hit.rebound_velocity) < 1e-5);
62+ assert.ok(rel(d.kinetic, last[1]) < 1e-4 && rel(d.membrane, last[2]) < 1e-4 && rel(d.bending, last[3]) < 1e-4,
63+ `energies ${[d.kinetic, d.membrane, d.bending]} vs ${last.slice(1, 4)}`);
64+ assert.ok(rel(d.maxWoverH, last[5]) < 1e-4);
65+ const pu = front.map((f, i) => f - back[i]), ref: number[] = py.pressure_unbaffled;
66+ const n = Math.min(pu.length, ref.length) - 4;
67+ const rms = (a: number[]) => Math.sqrt(a.slice(0, n).reduce((s, x) => s + x * x, 0) / n);
68+ assert.ok(Math.abs(rms(pu) / rms(ref) - 1) < 0.1, `rms ${rms(pu)} vs ${rms(ref)}`);
69+});
tests/dkt.test.tsadded+35−0View file
@@ -0,0 +1,35 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import { dktStiffness } from "../src/model/dkt.ts";
4+import { symmetricEigenvalues, planeResiduals } from "../src/model/linalg.ts";
5+import { fixture, maxAbs, maxAbsDiff } from "./helpers.ts";
6+
7+test("DKT stiffness matches the Python (autodiff) element", () => {
8+ const f = fixture.dkt;
9+ const Ke = dktStiffness(f.x, f.y, f.D, f.nu);
10+ assert.ok(maxAbsDiff(Ke, f.Ke) < 1e-9 * maxAbs(f.Ke));
11+});
12+
13+test("DKT rigid modes, constant curvature and twist", () => {
14+ const x = [0, 0.03, 0.01], y = [0, 0.005, 0.025], D = 10, nu = 0.3;
15+ const Ke = dktStiffness(x, y, D, nu);
16+ const apply = (U: number[]) => Array.from({ length: 9 }, (_, i) => U.reduce((s, u, j) => s + Ke[9 * i + j] * u, 0));
17+ const energy = (U: number[]) => 0.5 * U.reduce((s, u, i) => s + u * apply(U)[i], 0);
18+ const A = 0.5 * ((x[1] - x[0]) * (y[2] - y[0]) - (x[2] - x[0]) * (y[1] - y[0]));
19+ const scale = maxAbs(Ke);
20+ for (const U of [[1, 0, 0, 1, 0, 0, 1, 0, 0], [y[0], 1, 0, y[1], 1, 0, y[2], 1, 0], [-x[0], 0, 1, -x[1], 0, 1, -x[2], 0, 1]])
21+ assert.ok(maxAbs(apply(U)) < 1e-12 * scale);
22+ const Ucur = x.flatMap((xi) => [0.5 * xi * xi, 0, -xi]);
23+ assert.ok(Math.abs(energy(Ucur) / (0.5 * D * A) - 1) < 1e-9);
24+ const Utw = x.map((xi, i) => [xi * y[i], xi, -y[i]]).flat();
25+ assert.ok(Math.abs(energy(Utw) / (D * (1 - nu) * A) - 1) < 1e-9);
26+ const ev = symmetricEigenvalues(Ke, 9);
27+ assert.ok(ev[2] < 1e-10 * ev[8] && ev[3] > 1e-6 * ev[8]);
28+});
29+
30+test("Jacobi eigenvalues and plane fit", () => {
31+ const ev = symmetricEigenvalues([2, 1, 0, 1, 2, 0, 0, 0, 5], 3);
32+ assert.ok(maxAbsDiff(ev, [1, 3, 5]) < 1e-12);
33+ const res = planeResiduals([0, 1, 0, 1], [0, 0, 1, 1], [1, 3, 4, 6]);
34+ assert.ok(maxAbs(res) < 1e-12);
35+});
tests/fixtures/python_reference.jsonadded+1−0View file
@@ -0,0 +1 @@
1+{"dkt": {"x": [0.0, 0.03, 0.01], "y": [0.0, 0.005, 0.025], "D": 10.0, "nu": 0.3, "Ke": [99406.02555828959, 692.5488717820883, -713.0398000918938, -36670.86471373518, 384.75731089896993, -538.6463817032316, -62735.16084455441, 674.4271620014778, -475.7913680624739, 692.5488717820884, 11.761025240023859, -3.7895479298892223, 168.37642628649147, 4.170287632193504, 1.6536336796122435, -860.9252980685798, 4.749937448064677, -1.4220459418140756, -713.0398000918939, -3.7895479298892223, 10.840958517132316, 620.9273695876681, -1.4471351927083524, 4.599250378723818, 92.1124305042259, -0.17076448794641216, 4.108736496816164, -36670.86471373518, 168.37642628649147, 620.927369587668, 88425.80122352082, 332.56197498173856, 871.2877784879474, -51754.93650978566, 350.8060053588072, 643.0095235321528, 384.7573108989698, 4.170287632193504, -1.4471351927083522, 332.5619749817385, 8.232701300480016, 2.705837322602525, -717.3192858807083, 3.867183339435495, 1.5449642607508982, -538.6463817032317, 1.6536336796122437, 4.599250378723818, 871.2877784879475, 2.7058373226025245, 13.314535668631951, -332.64139678471577, -0.39987497503661085, 4.898433339435494, -62735.1608445544, -860.9252980685798, 92.11243050422591, -51754.93650978565, -717.3192858807083, -332.64139678471577, 114490.09735434003, -1025.233167360285, -167.21815546967892, 674.4271620014778, 4.749937448064677, -0.17076448794641186, 350.80600535880717, 3.867183339435494, -0.39987497503661074, -1025.233167360285, 15.259678369712919, 0.8424879501443864, -475.79136806247385, -1.422045941814075, 4.108736496816164, 643.0095235321528, 1.5449642607508978, 4.898433339435494, -167.218155469679, 0.842487950144387, 8.610934315016134]}, "config": {"material": {"E": 110000000000.0, "nu": 0.34, "rho": 8700.0, "sigma0": 0.9, "sigma1": 0.0005}, "profile": {"radius": 0.2286, "bell_height": 0.013, "bell_top_radius": 0.025, "bell_shoulder_radius": 0.08, "bow_height": 0.03, "bow_power": 1.4, "h_rim": 0.001, "h_shoulder": 0.0017, "h_bell": 0.0026, "max_slope": 0.4}, "imperfections": {"seed": 1, "n_dents": 0, "dent_depth": 0.00025, "dent_sigma": 0.007, "dent_margin": 0.01, "rim_warp": 0.0, "rim_warp_modes": [2, 3, 4, 5], "shoulder_ovality": 0.0, "bell_offset": 0.0}, "mesh": {"element_size": 0.03, "smoothing": 1}, "hits": [{"time": 0.0, "r": 0.13716, "phi": 0.3, "velocity": 8.0, "exciter": {"kind": "mallet", "mass": 0.3, "alpha": 2.5, "compression": 0.012, "radius": 0.012, "gap": 0.0001}}], "listener": {"position": [0.4, 0.3, 0.9], "c_air": 343.0, "rho_air": 1.2, "back_diffraction_gain": 1.0}, "numerics": {"fs": 44100, "duration": 0.02, "cfl_safety": 0.6, "rot_inertia_factor": 0.015625, "inplane_mass_scale": 1.0, "steps_per_block": 500, "dtype": "float64", "nonlinear": true, "n_progress": 4}, "output": {"prefix": "crash", "wav": true, "npz": true, "cascade_plot": true}}, "mesh": {"n_nodes": 227, "n_elements": 402, "xy_first5": [[-2.6020852139652106e-18, -1.734723475976807e-18], [0.019117343221916425, 0.024102211562137932], [-0.009359064545977937, 0.02944032505710612], [-0.029750452483819415, 0.008697493319743578], [-0.02387984322191643, -0.01988300473366113]], "xy_last3": [[0.2125469046740543, -0.08415327274371735], [0.22141811063400504, -0.056850508205885934], [0.2267974207204896, -0.028651177192800043]], "tri_first5": [[0, 1, 2], [0, 2, 3], [0, 3, 4], [0, 4, 5], [0, 5, 6]], "area_sum": 0.1637414776568517, "n_boundary": 50, "min_edge": 0.02033391325139436, "max_edge": 0.04000169788235948}, "shell": {"mass": 1.9867603122224469, "omega_max": 523630.5046043768, "limiting_element": 391, "max_slope": 0.3199066511168112, "h_e_min": 0.0010336844623136114, "h_e_max": 0.0026, "z0_max": 0.043, "z0_node_first5": [0.043, 0.042650943402618983, 0.042635328560266056, 0.04262251986580999, 0.04261272663125421], "Ke0": [1475858.697091178, 16136.614127767534, -3010.9870566465834, -744974.3738707487, 11362.375060718527, -4072.940123810292, -730884.3232204291, 11974.012833670424, -317.6100596991204, 16136.614127767536, 265.4942758428761, -26.24265371303801, -6441.08010017829, 86.62541964662539, 15.420370323525153, -9695.534027589243, 88.56425314813289, -21.572926833518178, -3010.987056646583, -26.242653713038017, 131.55137337915315, 10758.763195909394, -27.574130665448678, 74.29910824383609, -7747.776139262813, 22.60384569332314, 72.3404240116815, -744974.3738707488, -6441.080100178291, 10758.763195909392, 1700430.0901629245, -5372.6916803654785, 16709.037866806622, -955455.7162921759, -1041.4271338764995, 13982.076315404956, 11362.375060718525, 86.6254196466254, -27.574130665448678, -5372.691680365478, 149.38273125836008, -37.40584143249695, -5989.683380353046, 69.8238263400615, 18.326214585559335, -4072.9401238102923, 15.420370323525159, 74.29910824383607, 16709.037866806622, -37.40584143249694, 270.8565206314472, -12636.09774299633, -8.728469549398342, 92.53883731844594, -730884.3232204291, -9695.534027589243, -7747.776139262813, -955455.716292176, -5989.683380353046, -12636.097742996331, 1686340.0395126056, -10932.585699793926, -13664.46625570584, 11974.012833670424, 88.56425314813289, 22.603845693323144, -1041.4271338764997, 69.8238263400615, -8.72846954939834, -10932.585699793926, 188.57149433565115, 68.53407911594813, -317.61005969912173, -21.572926833518192, 72.3404240116815, 13982.076315404956, 18.326214585559327, 92.53883731844597, -13664.46625570584, 68.53407911594815, 230.30751221998605], "D0": 182.17247097844108, "Cm0": [323383084.57711446, 109950248.75621893, 106716417.91044776], "M_first3": [[0.018925757245507557, 0.018925757245507557, 0.018925757245507557, 1.349581375051811e-07, 1.349581375051811e-07], [0.018301177103390947, 0.018301177103390947, 0.018301177103390947, 1.3318962480538492e-07, 1.3318962480538492e-07], [0.0171465332628693, 0.0171465332628693, 0.0171465332628693, 1.1629463074387797e-07, 1.1629463074387797e-07]], "Rm4_first3": [[1.6642438532559925e-10, -0.15126505910989055, 1.1499294116890349e-05, 6.565421629146718, 0.0], [2.290323631435171e-06, -0.1489751718778917, 0.15825268039444734, 6.565421629146718, 0.0], [2.7975434382580364e-06, -0.14886994806718185, 0.19329964619315146, 6.565421629146718, 0.0]], "checks": {"rigid_tilt": 1.1178412285407855e-17, "curvature": 1.0574874309554616e-11, "rigid_ratio": 8.345767362347624e-17}}, "patch": {"nodes": [[40, 64, 65], [64, 96, 65], [65, 98, 66], [40, 65, 41], [40, 65, 41], [40, 64, 65], [40, 64, 65]], "weights": [[0.2252173731126971, 0.07714084870076315, 0.6976417781865398], [0.1061831804671339, 0.17972030988676246, 0.7140965096461036], [0.885284540249201, 0.040491544716748484, 0.07422391503405051], [0.035068991510846984, 0.6020618382743711, 0.36286917021478193], [0.45395423010845526, 0.3747167884065698, 0.17132898148497494], [0.4586678823965067, 0.25090048155400707, 0.29043163604948624], [0.06406325887112681, 0.48346594958593464, 0.4524707915429385]], "offsets": [8.509094170816925e-05, -1.2100312040630445e-05, -1.4522796111370723e-05, -1.533464731140119e-05, -1.3275742822043551e-05, -1.3347365329926392e-05, -1.6510078092817765e-05], "k_total": 177502680.67297047, "free_contact_time": 0.004052173591888192, "max_compression": 0.012000000000000004}, "timestep": {"dt": 2.2675736961451247e-06, "substeps": 10}, "run": {"hits": [{"time": 0.0, "kind": "mallet", "peak_force": 1408.6936215717985, "contact_time": 0.006321995464852607, "rebound_velocity": 4.626189736796063, "energy_in": 9.6, "energy_out": 3.2102447221255845}], "energy_log": [[0.0011337868480725624, 0.16329717688529438, 0.15764703961621906, 0.18473595175549615, 7.054970683789774, 0.8234470224894345], [0.005668934240362812, 2.4008851343329916, 2.0136837079334446, 1.9261672298383783, 3.167625222077137, 22.656185700120297], [0.01020408163265306, 4.635407698689287, 0.804622411252682, 0.8346449017381632, 3.2102447221255845, 9.208001220602256], [0.01473922902494331, 1.519353425145847, 2.382188983592165, 2.2757874155567825, 3.2102447221255845, 15.882210252263013], [0.01927437641723356, 4.325549291650169, 0.4865859400407113, 1.2862981947244616, 3.2102447221255845, 6.534477357990252], [0.02040816326530612, 4.642862914194414, 0.4144282958465078, 1.0110477059039795, 3.2102447221255845, 4.634266324642433]], "pressure_rayleigh": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -6.575835953553364e-11, -2.5772988627244747e-08, -2.74223285737792e-07, 5.302211041460007e-08, 9.924913995852767e-07, 3.1284829583108954e-06, 6.111596477627036e-06, 1.0208360211060865e-05, 1.5038878283519835e-05, 2.1174901861935532e-05, 2.7109828302606114e-05, 0.00013174071081781575, 0.0004981054658041094, 0.00021745211481896185, -0.0021583172953782244, -0.007376571426797957, -0.015634723902552917, -0.02686576582144065, -0.040851330992709434, -0.0572411281688479, -0.07565258032854284, -0.09569558597318269, -0.11697823668209054, -0.13906163125687407, -0.16148408911757453, -0.18384748647923946, -0.20578198042363602, -0.2269275913114396, -0.24691263232175387, -0.2654295717873421, -0.2822544173338371, -0.29723598594492207, -0.31026863300101054, -0.32127827176644297, -0.3302815932152237, -0.3373413474011706, -0.3425335697146255, -0.3459226612093522, -0.3476184683933219, -0.3477322385004293, -0.3463882756058122, -0.3437323767050424, -0.3398920999965954, -0.33496115854756203, -0.32907153794020305, -0.32237480335810453, -0.31499113384221655, -0.3069985575998157, -0.298467358360421, -0.2894922164914228, -0.2801525748206787, -0.2705021368003711, -0.26061997123461206, -0.2505810583037687, -0.24045437798048228, -0.23032111625363375, -0.22023997674511164, -0.2102456934383444, -0.20036230432887192, -0.1905953915629843, -0.1809441977983048, -0.17144622970324477, -0.16215024404607253, -0.15311945051207135, -0.14443715922787526, -0.1361495929335207, -0.12831002515000886, -0.12093975816771649, -0.11407779536869257, -0.10779054286234728, -0.10210625496992316, -0.09699871877664099, -0.09246375665568649, -0.08852836044930151, -0.08519051297152383, -0.08242055330890999, -0.08018001508959208, -0.07843664218822997, -0.07713642258873196, -0.07620343996841626, -0.07557484235526651, -0.0752247403923693, -0.0751350873868218, -0.0752945240309624, -0.07571898216637028, -0.0764120307638332, -0.07732779656327819, -0.07840843622313912, -0.07951863337433056, -0.08049471685323671, -0.08119168496572196, -0.08148977086797186, -0.08132291535594917, -0.08062562529779599, -0.0794353072201408, -0.0779105506371264, -0.07628973255178377, -0.07485452600690223, -0.07392454358876288, -0.07383223771974308, -0.07488131737607862, -0.07733765899258718, -0.08142234250399373, -0.0872823658073975, -0.09503697004145706, -0.10471991753308743, -0.11627599470266563, -0.12956758241808813, -0.14442950720325667, -0.16066767051016198, -0.1780423953167816, -0.1963379619104433, -0.21541511992391196, -0.23523440782992186, -0.2558565047744397, -0.2773857414980982, -0.30001004601931036, -0.3239574032266829, -0.34952643781210835, -0.37698511941680846, -0.40655087365201564, -0.438369323573563, -0.4725169378028543, -0.5089856586464783, -0.5476752705400776, -0.5884089607424359, -0.631023765045366, -0.6753078707329411, -0.720969740043964, -0.7676824241972646, -0.8151482526162422, -0.8631286575412364, -0.9113891591842115, -0.9597516078064543, -1.0080895767111637, -1.0563133210694986, -1.1043902933521423, -1.1523084021463257, -1.200028985589835, -1.2474692936327307, -1.29451580584158, -1.34102018485932, -1.3867084118277804, -1.431162650918758, -1.4738339233479543, -1.5140728432410622, -1.5511442854698645, -1.5842636291679255, -1.6126141065927897, -1.6354179027136289, -1.6519681974950244, -1.6616225530471178, -1.663806415017586, -1.6580683970445103, -1.6440856879357468, -1.6216355557015212, -1.5905955775618124, -1.5509404011512578, -1.5027804195882637, -1.4463638084723451, -1.3820009583373272, -1.3100906212760073, -1.2310628613057861, -1.1453069224231653, -1.0531924727690571, -0.9550808232988309, -0.8513973011175298, -0.7426003426973645, -0.6293187538913085, -0.5123883979527536, -0.39286176619347973, -0.2719388510405943, -0.15094271049905036, -0.0313150250438542, 0.08545824646029992, 0.19794767927160933, 0.30471523359717995, 0.4044350232817758, 0.4959064834577797, 0.578034086403436, 0.6498595350247756, 0.7105382525822124, 0.7593445506289264, 0.7957158833266326, 0.8192658579758298, 0.8297489554874915, 0.827093781844046, 0.8113468476887545, 0.7826793646844573, 0.7413453576814673, 0.6876470263367849, 0.6219455392505079, 0.5446519421302731, 0.45618169267378506, 0.35705878813229786, 0.247878900893231, 0.12928928699875022, 0.001999340470972113, -0.13319013375199557, -0.27525813589629156, -0.42292483062289504, -0.5745143404659813, -0.7278242611810914, -0.879952866173318, -1.027134137616146, -1.1646823039231362, -1.286849497025765, -1.3869215936961432, -1.4574815088388693, -1.4908666333287266, -1.479931867246597, -1.4189554879059174, -1.3045871968306202, -1.136672885059627, -0.9187122723575031, -0.6576929645829406, -0.36322935697821546, -0.04627777446545522, 0.2823826311089972, 0.6137912728471113, 0.942147669853103, 1.265171549352772, 1.5833720575929966, 1.898212965179251, 2.2095849192089507, 2.5137258277832473, 2.802633487941564, 3.065959183323637, 3.295114032782332, 3.4877894071200646, 3.64969109637662, 3.7907293749777957, 3.9161558776246532, 4.017712016606288, 4.071407785723286, 4.0440838646432935, 3.9051952350627905, 3.637770939033519, 3.2447578841692666, 2.7501095928314627, 2.1941301844010774, 1.6232759580434535, 1.0766770093654618, 0.5751081923628106, 0.11838786150149716, -0.3073222856880296, -0.7172632605496626, -1.1186385179717198, -1.5097224308794133, -1.8876020844458778, -2.259085025171371, -2.6467726750467437, -3.0851174399630925, -3.605767059633079, -4.21715495629661, -4.888399034046428, -5.5502909848035324, -6.12000420478871, -6.5397597422154545, -6.804068988759096, -6.954981987272402, -7.050117074015239, -7.12909327965323, -7.199147385973155, -7.239191939199615, -7.2096638984249815, -7.061529380749322, -6.747656528821038, -6.238960683242768, -5.539691629324162, -4.691116473996621, -3.757483984984589, -2.7987383331892355, -1.8455733857882617, -0.894632254414114, 0.0722340512807346, 1.063415668566183, 2.0743304052052767, 3.109989261869268, 4.205652176553765, 5.412022427466317, 6.741435928859663, 8.116322360765574, 9.37887588204362, 10.37552331400786, 11.048744715830745, 11.44857438886207, 11.662254798815043, 11.746332439507247, 11.719779690069819, 11.593049856969145, 11.384312620387757, 11.109508587428108, 10.759574073375608, 10.286383455776157, 9.618368851854, 8.705911138064824, 7.561653435385159, 6.259534994274189, 4.8996979379107435, 3.5757336184892905, 2.362897637044849, 1.3144015235884445, 0.44613943972780007, -0.2883361504953875, -1.0021531692294416, -1.8278981347470777, -2.8336504106069724, -3.9696378034862216, -5.102622037398461, -6.110282682120246, -6.938923509581223, -7.570964338007157, -7.96247794367891, -8.047150510892312, -7.80652046505642, -7.314755615180696, -6.706447633214157, -6.108878670529262, -5.592199591686493, -5.146764635085224, -4.686810997228615, -4.088362210826955, -3.2502260181173863, -2.1426662868925446, -0.8180270976397394, 0.6114889612216298, 2.0067698235922093, 3.231712260057755, 4.184093565878481, 4.8341824288823645, 5.249627385033511, 5.565965761977121, 5.906640420992526, 6.3211327303971645, 6.794950510635972, 7.297901886133668, 7.794422855269832, 8.20433611226536, 8.385723410614148, 8.196342469782877, 7.5940091223104496, 6.6832132368854795, 5.670835714795675, 4.7804442663518705, 4.180241466394389, 3.9290442701267176, 3.937802656409591, 3.9791750040967955, 3.769883340394672, 3.0939025587372213, 1.8997458877192217, 0.32406731202167166, -1.3594117790719091, -2.8323976266146733, -3.8344767110027105, -4.251998769938937, -4.1602488591521825, -3.78937988230719, -3.4312147520679073, -3.3466253476239283, -3.705409890442023, -4.537276275548084, -5.683643049119656, -6.800804828477289, -7.473177108312055, -7.409612308894138, -6.599387398363618, -5.315170306695009, -3.969073706476545, -2.9302292321881787, -2.404318553782874, -2.3966560033309645, -2.7330440606856174, -3.1238739164327236, -3.2630387568887222, -2.9289486605096338, -2.0469378488092778, -0.7011564567349509, 0.8911342060328691, 2.43498444048759, 3.640280357501135, 4.313694378271732, 4.425973625958861, 4.124473738852533, 3.6868362094359513, 3.435595406296207, 3.6332513901486685, 4.374059574245549, 5.516700647690707, 6.722313749342146, 7.607554919984433, 7.9278359684993855, 7.677355178594045, 7.057632413066707, 6.361692999821499, 5.86295345963707, 5.751332858855956, 6.092224689152746, 6.789323123453414, 7.586464605223731, 8.144812460148536, 8.164979030171278, 7.487421685490664, 6.140504920300259, 4.338150153597925, 2.423944006098237, 0.76364431871474, -0.3776359509046506, -0.9188914650885391, -0.9785862752969786, -0.830269916142485, -0.8136838482727986, -1.2131741782961887, -2.135064020371247, -3.444080429244688, -4.812167047700893, -5.869112966174045, -6.366547526464649, -6.256882440398833, -5.66807042536157, -4.832759214891268, -4.022760006649624, -3.4858968373999373, -3.367961624205458, -3.6419190930659835, -4.10084677184437, -4.438979158183788, -4.375492725998584, -3.7549207769563164, -2.5985700845808317, -1.109904184012315, 0.3771219178140476, 1.511356488072331, 2.0572721656315944, 1.9848121509177716, 1.4821711552465238, 0.8908827239409784, 0.5809395785928986, 0.8019487886527592, 1.569241872508546, 2.65150709165274, 3.6855186324507008, 4.358185478580408, 4.538022591660641, 4.280330291976275, 3.742144266088529, 3.1046307021850224, 2.548672263711293, 2.242767740148638, 2.2933722297494668, 2.6789765125951375, 3.232651847323981, 3.699297933551687, 3.833661985613389, 3.4909542457259963, 2.685472319947457, 1.6016798698720285, 0.5362420914131173, -0.22708514506317687, -0.5517760935297075, -0.5089100629640276, -0.33190462043011765, -0.30527278272463615, -0.646176921195972, -1.4220788427731026, -2.5320532756839826, -3.7571348160733873, -4.854186210046289, -5.645192898792441, -6.056607797717215, -6.10139013519297, -5.847087718907465, -5.413338970075858, -4.979286218520961, -4.742350652427041, -4.822081749573657, -5.18089722136621, -5.632866805314371, -5.936593781305481, -5.905860727382557, -5.475318015428669, -4.705443203202449, -3.7489585585004135, -2.80137221316622, -2.0420403682277253, -1.5753938436758916, -1.3977769658461812, -1.407395570454602, -1.4482569213151684, -1.3663197926870483, -1.0637607577843344, -0.5373455430059602, 0.12246099498769251, 0.777121074634991, 1.2984418415758452, 1.601570337831135, 1.6481505823574873, 1.4454195583072824, 1.0568577646873643, 0.6044624343271707, 0.2369986046201442, 0.07453883597082155, 0.16859726504532888, 0.4977323821708868, 0.979469320174441, 1.4791240809298218, 1.8303216508841686, 1.8874331143420524, 1.5922409805405546, 1.00892118482568, 0.2995631686146358, -0.34346122049359673, -0.765385977385528, -0.8873474056101719, -0.7166004023047694, -0.33716190313849026, 0.11924196459793197, 0.5104465213211006, 0.7075677049424481, 0.6080156593132787, 0.1661250019781795, -0.5572033935437009, -1.3774214955486264, -2.051273955056781, -2.401830641201638, -2.417296215509066, -2.2500106447281643, -2.123468881811278, -2.2232648288208767, -2.6330000307221457, -3.318272755058941, -4.138201660477664, -4.881173031688546, -5.332170992268334, -5.354279147461268, -4.94007705881226, -4.208021344948336, -3.363488758394568, -2.6565195038789056, -2.336836473459211, -2.583447209439559, -3.415989281435441, -4.648914047944632, -5.948470300793656, -6.973073340918722, -7.510484944093726, -7.541484225106615, -7.215858055209445, -6.761035734808136, -6.364804587420073, -6.092920175528965, -5.885448628161439, -5.615586835099604, -5.155532545384839, -4.411513868596842, -3.3344117445435435, -1.937377615456993, -0.3311600820558539, 1.2600218721027947, 2.5501333125285006, 3.3071105930700417, 3.4677896182582724, 3.1771000917287417, 2.7281442253890837, 2.440468396578446, 2.5358397548939497, 3.060473206330656, 3.8806247400273386, 4.748041420104169, 5.4016859482398125, 5.65961254707818, 5.468474767743822, 4.9066671101889705, 4.153780319607003, 3.436128975726114, 2.9547395453957965, 2.8153152104232593, 2.9892424638539996, 3.321461000536852, 3.580029282664804, 3.532671961297612, 3.0285807380671645, 2.054974594848379, 0.7418754959487806, -0.6915005128435168, -2.027069493066883, -3.125072339734306, -3.9432310421667633, -4.504678033682421, -4.863603110255107, -5.099741549937547, -5.322171290112439, -5.642148183036872, -6.108752762669707, -6.651227273033478, -7.081389988863759, -7.169669590557637, -6.757435052603788, -5.843305133017469, -4.594859542252123, -3.2784972159941734, -2.1490332948679973, -1.366334170131582, -0.9778054572313006, -0.9412545825101128, -1.1370617791678332, -1.3632226934698963, -1.3575356741215012, -0.8778997393705373, 0.18592529657232013, 1.7335251759117207, 3.46200147594746, 4.967289162669015, 5.893257114338846, 6.0652020107595686, 5.55814623512686, 4.667452376898423, 3.7811711377384927, 3.215709809215618, 3.109312797826668, 3.4176099421648245, 3.973532386183609, 4.548397993656476, 4.89396694575281, 4.789698018289969, 4.11266539163385, 2.9091340855493946, 1.4202293795766872, 0.025136107150153148, -0.89216365615612, -1.10022551256518, -0.6289422512172058, 0.22964566492971852, 1.046191388246298, 1.4537679598725886, 1.3119920182775442, 0.7392845189966141, 0.01272687823347688, -0.5745631761291461, -0.8102379371147226, -0.6014830217784538, 0.0034805912941800873, 0.7975881179063049, 1.442035076378583, 1.5714329288725102, 0.9416259018903929, -0.4571865562033891, -2.3756117382557838, -4.375151551115509, -5.963958525719385, -6.7511092814307725, -6.571156877446006, -5.537813295643637, -4.003232284174019, -2.435092844298376, -1.260313635474437, -0.7312334333816136, -0.8491116510188189, -1.363540293671675, -1.8589322479880694, -1.9106059450723674, -1.24909018127887, 0.1360780038430961, 1.9828992144313262, 3.8284431836499366, 5.151737134272497, 5.5585377755041, 4.944948420658237, 3.5623377949980917, 1.942141213112833, 0.7073810354978516, 0.35381378809635916, 1.0893555179181678, 2.7755976763788857, 4.968994187161711, 7.045950710062558, 8.388328850663317, 8.582227763615533, 7.565724038110266, 5.664959537834743, 3.487173443324473, 1.695588398471702, 0.7534526703281554, 0.7490717935643805, 1.380916898190176, 2.109313136009625, 2.401430994084328, 1.9496799376491918, 0.7697083687684695, -0.8317040477305948, -2.3678473586073734, -3.320254789540292, -3.3043324247186017, -2.215036840334529, -0.29222197940086103, 1.9346389127511978, 3.8100023378543564, 4.770717225140723, 4.550522101570911, 3.298758637984624, 1.5498972244072244, 0.023469081284128848, -0.6796435961042923, -0.33918473326360227, 0.8015752828621365, 2.16284294688721, 3.0788590192692227, 3.0485511306330553, 1.9006095516355235, -0.15935546486811858, -2.618529628375375, -4.816547719948776, -6.142141237711191, -6.211896599368568, -4.995118041265115, -2.8557579512805273, -0.4743320300954571, 1.3694614600133406, 2.097456953761631, 1.5774499041769732, 0.1712855935016003, -1.4436512032694693, -2.5754195181042214, -2.784836098239497, -2.0143959749511984, -0.5683720463503187, 1.0076282030632056, 2.1048663963958294, 2.258859829444146, 1.329453970796415, -0.4288021140362268, -2.4463504171980537, -4.032156750488315, -4.609324998109569, -3.92075974394511, -2.1319865435081367, 0.22221366019191846, 2.439325310605854, 3.91950530712456, 4.390097902027011, 3.9914292311557027, 3.198311567445152, 2.6186508932775587, 2.746078704256476, 3.7547230101146685, 5.420174820361624, 7.2102927972262565, 8.502533267602953, 8.814253661392598, 7.9514384466599575, 6.0576875122104665, 3.583613619380389, 1.1758504405107648, -0.520070628786454, -1.0962757596794235, -0.5520257466728419, 0.7039947726920777, 2.0288106159898653, 2.8043866015049668, 2.654482840568598, 1.5398179597572696, -0.27631969647911264, -2.3507391515994955, -4.21323308223219, -5.50265096139334, -6.064154153251193, -5.993552985068652, -5.6067211221781745, -5.323368847373334], "pressure_unbaffled": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -6.575835953553364e-11, -2.574585591669021e-08, -2.6679847187550807e-07, 1.4535802275241862e-07, 1.302091014154428e-06, 3.941987896191903e-06, 7.74163992677223e-06, 1.3267274123195524e-05, 2.003959506125995e-05, 2.918177656288291e-05, 3.857304191128755e-05, 0.00014773582374848527, 0.0004923108128435231, 4.09479130317451e-05, -0.002884580203808125, -0.009321239794464625, -0.019805865697164773, -0.034680182403907175, -0.05417611870814311, -0.07837643408297586, -0.10714329938163997, -0.13996175315726017, -0.1761943866114521, -0.2149385539936949, -0.25513894354710426, -0.2954370690154299, -0.33440909581786626, -0.3708236298531813, -0.40355920605957096, -0.431690226558733, -0.45444786842084844, -0.4713278162440203, -0.48198111323613746, -0.48623277675353993, -0.48408732651106434, -0.47567376586585763, -0.4612339627922486, -0.44107592503786547, -0.41561544156550706, -0.3853137127245371, -0.3506863851721431, -0.312302079261696, -0.2707088842842842, -0.22648355836409428, -0.18027494409178432, -0.13275720070700267, -0.08459005444812709, -0.03639628222131486, 0.011223154960741721, 0.05764735413290961, 0.10228916357609641, 0.1445917647326519, 0.18402408957158473, 0.2201129498859924, 0.2524456052329417, 0.2806106755597263, 0.3042796937475254, 0.32321668641995593, 0.33728040471115983, 0.34639481265619054, 0.35055651331965676, 0.3497943957320574, 0.34422478597579337, 0.33402126114764397, 0.3194120004772143, 0.3007457074636843, 0.27840813456822955, 0.25288094396923055, 0.22470653358411352, 0.1944539295973376, 0.1627884031935738, 0.13047398334065696, 0.09826742178510134, 0.06688760468866722, 0.03705322318422563, 0.009473218495666458, -0.015239025341912224, -0.03660700981273859, -0.05430029904917626, -0.06814378235445911, -0.07819713372001215, -0.08475140193997156, -0.08828697090391721, -0.08941275721466284, -0.08880506183686897, -0.08711919374474159, -0.08481465324334794, -0.08213018097977273, -0.07892202996586417, -0.07475440084162718, -0.06886795856128035, -0.060259863202605235, -0.047809938112202154, -0.03029765524625029, -0.006682057741398198, 0.023791143437972048, 0.061526510761277256, 0.10653183172908315, 0.15841929843725538, 0.2164036720283508, 0.27940828698809955, 0.3461290623554383, 0.41516788196996557, 0.48515336539054504, 0.5548052081575418, 0.6231026460674357, 0.6893927354341033, 0.7534830395434087, 0.8155802421373773, 0.8763075329318977, 0.9366977188675214, 0.9979908638885215, 1.0614581825119793, 1.12823647337649, 1.1991928029272731, 1.2748451453827627, 1.3552360443015177, 1.439859817116415, 1.5276286070836183, 1.6170196770594107, 1.706115641908587, 1.7927280410072963, 1.8744827473050192, 1.9489576509570798, 2.0138151050350217, 2.0669166677733735, 2.106319592486578, 2.1304406125495445, 2.1381257377120293, 2.1286419214538674, 2.1016073756884115, 2.0569590861505036, 1.9949875737974168, 1.9162544679594227, 1.8215420363686805, 1.711848444915822, 1.5882813210293165, 1.4520050574575896, 1.3042270269417207, 1.1460855350358625, 0.9785644703797163, 0.8024120620449253, 0.618250163052529, 0.4265449220935662, 0.2276897250825114, 0.02206073325916824, -0.18983218596693385, -0.40718108044968004, -0.6288023353756322, -0.8529907295359268, -1.0774535103947627, -1.2992905876734333, -1.515056693594659, -1.7209976256225754, -1.913241806528262, -2.0880292438250834, -2.241936829858799, -2.3720760400101857, -2.4762238173300224, -2.5528310871594697, -2.600933668449454, -2.620051915026333, -2.610093975678777, -2.571148995678329, -2.5034744700982206, -2.4074924320278615, -2.283890212013045, -2.1336001199737487, -1.9579521450456285, -1.7587312518931415, -1.5381477792813791, -1.2987968011920974, -1.0435476249798576, -0.7755256195206579, -0.49804205250219585, -0.21440439425661223, 0.07197443027741537, 0.35773260631331666, 0.6395957391973539, 0.9143884297409014, 1.1790750795480418, 1.4307546633431825, 1.6666870739664075, 1.8842949882833433, 2.081154960951134, 2.2548673960444305, 2.403055894189084, 2.523251690471608, 2.61287611489354, 2.6691714367614545, 2.68922463085362, 2.6700442431842863, 2.608615637153929, 2.5019208932079136, 2.347127221989443, 2.1415969646243083, 1.8828631108763312, 1.5686587569181265, 1.1970164302661401, 0.7664106735482894, 0.2758574281310798, -0.27475091802890417, -0.8843421987779224, -1.5501690415805185, -2.2673811955215153, -3.0286059221366393, -3.823431133512564, -4.638166275092879, -5.455897312354745, -6.256816352291025, -7.019025444960352, -7.719653664913817, -8.336315412857056, -8.848609704828215, -9.23947719108403, -9.496043358118989, -9.60985153373531, -9.576656626740908, -9.395901654051375, -9.06989623599345, -8.602901182808118, -8.000859815281212, -7.2716149223223665, -6.425556584303687, -5.476382382479825, -4.441088084054989, -3.3383785065380622, -2.1849717955210077, -0.9902381348084783, 0.24891784209227907, 1.5494609103682184, 2.9367152999091863, 4.431558842040968, 6.03476325555158, 7.715790137330995, 9.411073633482019, 11.032994038061059, 12.487466046312965, 13.695920382943854, 14.614778815282046, 15.242977784110916, 15.611986154306718, 15.762154593637016, 15.719117581912117, 15.485243774627147, 15.051453524711848, 14.420499269325791, 13.624899000964106, 12.726012202152265, 11.792532718568284, 10.86962170355377, 9.956425684905613, 9.003499595377424, 7.929420017652687, 6.6458105119605735, 5.082169292139753, 3.211244936398013, 1.073296540528668, -1.2223988897915445, -3.5404830970254957, -5.784543214618795, -7.928096315820029, -9.988719502216473, -11.97786713867337, -13.867872471939842, -15.589750745073875, -17.054314665867963, -18.18562012888822, -18.956319414503604, -19.410259453881885, -19.65339099568976, -19.80650099300627, -19.941910207454693, -20.045125220866126, -20.02887507312069, -19.78988064141498, -19.265819118335664, -18.44973626505936, -17.356287293608077, -15.97691636016583, -14.271304457649146, -12.21112452100736, -9.836283474843421, -7.254954260451957, -4.56932617851866, -1.8001589573135348, 1.1040500244079041, 4.186833125282371, 7.421266548361095, 10.711551429795497, 13.92988322688484, 16.951140399513413, 19.672379079294117, 22.011401462843246, 23.89161873446043, 25.235794662924, 25.980496368077457, 26.09598044805761, 25.596488831111458, 24.54665145171421, 23.061911469806017, 21.28377062368372, 19.330910478167358, 17.25688445761683, 15.039957103019088, 12.611041796275694, 9.912091235955778, 6.956568533853606, 3.84755215052729, 0.7310107664440224, -2.2779528321666205, -5.121294647745466, -7.751820006598502, -10.083991960146019, -12.023406196888207, -13.549846142869177, -14.752898821020114, -15.784162107509607, -16.781982766305767, -17.81358766361233, -18.83481328476644, -19.68368122621503, -20.13464374773803, -19.993060810870894, -19.173713829617437, -17.729425959726342, -15.830900493544332, -13.709936628039348, -11.581405941621364, -9.567780339095195, -7.652553872583379, -5.691054793911563, -3.4971161542124625, -0.9707723027637014, 1.8251009225363246, 4.698857450934047, 7.425414755385236, 9.801742518770544, 11.638790618937978, 12.762585170948945, 13.077696064441763, 12.647624511825786, 11.70938099706413, 10.605951605019435, 9.686978696852377, 9.212601705269853, 9.262618428888729, 9.679132056001446, 10.102634359814303, 10.11594340451327, 9.427694600445411, 7.995433933882307, 6.027555959221774, 3.880969390620164, 1.9287558121192463, 0.4536145631422146, -0.42494101201654066, -0.7823253039335047, -0.8552754413529282, -0.9517788514371217, -1.3556215968320013, -2.2512346999964805, -3.652429004809631, -5.344804291543535, -6.908627822478962, -7.874329771125442, -7.958617190355286, -7.23230759191518, -6.097247990885442, -5.090800070182944, -4.652251473184276, -4.971737856523722, -5.950886568529382, -7.251336072177448, -8.412926138929238, -9.015374109171876, -8.821834689272322, -7.8406761731896735, -6.291718681071352, -4.513027605641742, -2.8537150101939655, -1.582993178325209, -0.8264119765541018, -0.5319130429110039, -0.4789848755198056, -0.3450267118059842, 0.18436421986485563, 1.2957685466468423, 2.952608895790124, 4.867799308016498, 6.593139297903363, 7.713796911887796, 8.045275657874322, 7.713513497493045, 7.078449043657773, 6.562998129521695, 6.490939837608676, 6.9943741316710515, 7.985791725132195, 9.180238305630136, 10.177876742446516, 10.600435099549848, 10.224964763411958, 9.05531823381253, 7.320829416979231, 5.415866445253551, 3.788025631538569, 2.7939091686107007, 2.569827732352263, 2.9725410904508878, 3.6165821554614106, 3.9938183202317195, 3.6388278983275413, 2.293924610905825, 0.016253678259429805, -2.830313803450335, -5.715600782118457, -8.15351817694562, -9.866796210227308, -10.828351316383209, -11.200963797144254, -11.250655778809815, -11.270762195584705, -11.500544245246285, -12.030685927572394, -12.738903845128556, -13.320158988470663, -13.41840820376335, -12.781507154021849, -11.354004017693132, -9.29429672470422, -6.941004844663606, -4.725407549848657, -3.0321383822376315, -2.0641389336770533, -1.7742164308593347, -1.880393423946598, -1.9559307428909867, -1.577184890106272, -0.4878305407043202, 1.2886942734153335, 3.45345572764232, 5.570617571335526, 7.275520299990028, 8.424985178819579, 9.102890716349759, 9.518570039682501, 9.906290091824683, 10.475271834716002, 11.369796474610125, 12.601920051634798, 13.999267141919567, 15.234947710182064, 15.940700015271602, 15.842742342662623, 14.865161080968612, 13.177978195504288, 11.172923510879286, 9.34799531630865, 8.117730531428931, 7.631186345849666, 7.702785361590464, 7.895338999569489, 7.704012478582831, 6.7541574856479425, 4.937649084239981, 2.443182126653964, -0.3211501879801002, -2.8769317661024667, -4.838906903408689, -6.0283269608142405, -6.495858309652149, -6.474146344887157, -6.31027494759386, -6.387127890829162, -7.0090000641690136, -8.272269865317416, -10.00026849828193, -11.802189166245256, -13.234838154636588, -13.979622393278163, -13.947461928127733, -13.281406531420942, -12.28340168136789, -11.302905781982671, -10.613164948290878, -10.31006750205115, -10.280417087325223, -10.256397880860508, -9.925471003098075, -9.047874604687806, -7.546199474249154, -5.542818699708786, -3.330234896001602, -1.2827253113446029, 0.25970415371454947, 1.0987075580288344, 1.2443229127644742, 0.9202536354140006, 0.505206228148382, 0.4201743604911041, 0.9878821872069219, 2.318923902794333, 4.284865226829484, 6.584618523117825, 8.848520212088207, 10.731345261952969, 11.99229905622694, 12.562082273713912, 12.566022688952671, 12.273815922830345, 11.989044690570202, 11.930156571973914, 12.157956474680207, 12.572343828717473, 12.960378572160844, 13.069951979618628, 12.69571944426429, 11.753048847874968, 10.305632331616952, 8.541932430637667, 6.73114325233928, 5.176295415647112, 4.14563740383382, 3.7729920428573336, 3.9716511541579527, 4.434633791156964, 4.751529104092555, 4.586501307946044, 3.8141330413671475, 2.5519476674331205, 1.1012731394272919, -0.1634851336075065, -0.9257549054243461, -1.0483060966968853, -0.6390685334621269, -0.01884671461707388, 0.3998167703121238, 0.25591831232212536, -0.6651270015944477, -2.3989945037443126, -4.798477763887101, -7.54428656077855, -10.207338537613547, -12.387440646798673, -13.86960004004541, -14.69905039501533, -15.13605930188438, -15.526938288716392, -16.1482131556798, -17.07766012400372, -18.14853430095782, -19.015704638136516, -19.299046418879342, -18.730569064417466, -17.24267029854012, -14.978524790872687, -12.251701331060117, -9.484219434824482, -7.112783148109965, -5.45032784031993, -4.547700150756488, -4.14080305709207, -3.7355105781809015, -2.8012011807079853, -0.9821465855473264, 1.7664542501528717, 5.167897419922738, 8.734244817171923, 11.950555482734694, 14.46314348367851, 16.19582607613981, 17.34248762457328, 18.24567772932372, 19.228777632632678, 20.457458398370665, 21.871730368947194, 23.19861455824997, 24.035875247526597, 23.97818941163982, 22.748346835747885, 20.30069449706098, 16.863618739599502, 12.891802851092486, 8.932421500421459, 5.451143562210569, 2.6811374543711843, 0.5600841534324279, -1.2073598807837254, -2.993998651759104, -5.091167142028789, -7.620570221891081, -10.524949597540182, -13.601221903768002, -16.54939307699744, -19.039839725600952, -20.802472800404892, -21.715755295636804, -21.855749625183993, -21.475308352008664, -20.9107997619189, -20.44345571390805, -20.175670011199742, -19.994321667851512, -19.642617931394735, -18.842876076568913, -17.39064538232123, -15.19285376710661, -12.274554844551474, -8.779129283380952, -4.961824781061908, -1.1611328217361685, 2.266918326912445, 5.0434032725226965, 7.049935376031055, 8.361114920053652, 9.214732871999644, 9.920675400054979, 10.725092282727706, 11.697289689105395, 12.72209753811948, 13.602288295573803, 14.183671725877183, 14.414811986938389, 14.330146902429421, 14.00441164678259, 13.52269171162676, 12.975397995944023, 12.457307400328714, 12.04887573443271, 11.78166503021385, 11.60817225912897, 11.396317330681933, 10.96599968815745, 10.174479806618947, 9.01715276449831, 7.668821045203113, 6.4134345592187625, 5.495555160182889, 4.9925924785160145, 4.78711416598698, 4.632886608226907, 4.242072361106925, 3.343759015199359, 1.7328403390324176, -0.6616317252216235, -3.7185016838506275, -7.131752123136132, -10.491066133084153, -13.396626711008327, -15.561584283889296, -16.87387709575991, -17.409512709443174, -17.398418884347105, -17.147100772228466, -16.935155774783265, -16.923515282084853, -17.113497554813236, -17.365807075009066, -17.4613279842267, -17.18343052179591, -16.399822207653663, -15.109847914938143, -13.435540436427232, -11.570261098612756, -9.712409473733365, -7.997016637895531, -6.435027692910917, -4.882543409391321, -3.0657776384872077, -0.6707361636929376, 2.5255923423852713, 6.538642001933392, 11.130931430330003, 15.851651172444939, 20.136406349330738, 23.439333540544425, 25.36706075663594, 25.775344191924958, 24.801450836403014, 22.822300909714635, 20.339222363304067, 17.8164664703552, 15.53520981641338, 13.52513884028874, 11.598108974207829, 9.468138010050069, 6.908424595641558, 3.8697047600692756, 0.5028429596657145, -2.9026970967603574, -6.010162559430823, -8.5271478534633, -10.290810366536382, -11.321365861687491, -11.812602544543788, -12.059670618969513, -12.353942791571605, -12.875241648793624, -13.60709636210658, -14.307357572593519, -14.56838711566466, -13.976329706549691, -12.316223926877628, -9.709963324342924, -6.595095168312724, -3.5578689761824096, -1.122081318440983, 0.40818519907302964, 1.0074771274997931, 0.8932094492821854, 0.424656878868813, -0.02147526174268677, -0.15481148668689393, 0.16593236614035334, 0.9018819332794976, 1.8263068557217754, 2.5648261457763293, 2.7213473980627594, 2.0680478010069896, 0.6920285627738086, -1.0098410905948332, -2.4916362161491885, -3.274764941749116, -3.109341707283343, -2.0303970112485556, -0.32502491803791334, 1.5523540036724701, 3.1034120530868123, 3.940160934487785, 3.9172536850622057, 3.1664060017256137, 2.0200245201375964, 0.8844439264289372, 0.11559515096560435, -0.08787934484194482, 0.2507013262754807, 0.9056790697143249, 1.561005507827249, 1.9638229583077904, 2.038975282938555, 1.9083302165980633, 1.8228323377828308, 2.0568560138015597, 2.802469100928767, 4.081389973605192, 5.710246014635688, 7.363532757874328, 8.714749429982541, 9.56420348983364, 9.883795758161476, 9.793152210077531, 9.5164194862913, 9.330890341474952, 9.483737805934012, 10.076577998509693, 10.97762002973027, 11.841971062540662, 12.2481504710546, 11.8606570166334, 10.518826236295999, 8.232991364628235, 5.138043292375536, 1.4538926715421305, -2.5362098134644486, -6.51083223256929, -10.163185124172191, -13.270314358889145, -15.733272288499052, -17.5607892940292], "fs": 44100}, "run_linear": {"hits": [{"time": 0.0, "kind": "mallet", "peak_force": 1977.553084535146, "contact_time": 0.004492063492063492, "rebound_velocity": 4.849030136533902, "energy_in": 9.6, "energy_out": 3.5269639897520992}], "energy_log": [[0.0011337868480725624, 0.11518240667885472, 0.19065997418634467, 0.11451987457233755, 7.022382852819921, 0.6860066068981031], [0.005668934240362812, 0.5570099533337556, 2.8770921846038275, 2.6003136787430403, 3.5269639897520992, 23.43797422196923], [0.01020408163265306, 4.998916708394579, 0.1307667480176439, 0.8429981585032716, 3.5269639897520992, 7.36942830859002], [0.01473922902494331, 1.4294124659295904, 2.1731314274331015, 2.3215972995204033, 3.5269639897520992, 15.067711563105279], [0.01927437641723356, 3.334860836020404, 1.2724056899637306, 1.2420828517676603, 3.5269639897520992, 13.382669747904702], [0.02040816326530612, 0.9979042635872203, 2.389129283330328, 2.450891765985065, 3.5269639897520992, 17.103206826435265]], "pressure_unbaffled": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -6.575835953553448e-11, -2.5745855916083203e-08, -2.667984702553921e-07, 1.4535817882720368e-07, 1.3020939219515675e-06, 3.94200956174839e-06, 7.74173673202151e-06, 1.3267591433321903e-05, 2.0040431107102075e-05, 2.9183643117710726e-05, 3.8576680284117305e-05, 0.00014774211913339387, 0.0004923205147802023, 4.096054478851008e-05, -0.0028845728998242327, -0.00932128030702288, -0.019806099200608038, -0.03468098424022304, -0.054178274480636814, -0.07838132545190035, -0.10715299441964092, -0.13997884185058584, -0.17622134260587013, -0.2149763557355056, -0.25518465009053304, -0.29548006118489434, -0.33442570593574095, -0.3707699601584844, -0.403362014622779, -0.4312359296844236, -0.4535698344251444, -0.46979233598320275, -0.47947266968753843, -0.48233934939972545, -0.47828672137668826, -0.4673218842156348, -0.44955627643774976, -0.4251628383868588, -0.39442259104279737, -0.35766770603424325, -0.3152960529508156, -0.26777532964377004, -0.21557520265492947, -0.1592208985370125, -0.0993402804517789, -0.036619576623667494, 0.028235016390353085, 0.09451969861139581, 0.16151892490055525, 0.22846611056564847, 0.2946007192248025, 0.35916844966230055, 0.4214214662061847, 0.4806556222459323, 0.5362177831234832, 0.5874518328469904, 0.6337837069988869, 0.6747353360823061, 0.7099299578234962, 0.7390647340327439, 0.7619196491088664, 0.7783157393483066, 0.7881715959825628, 0.7914700194732956, 0.7882506330841365, 0.778671933673678, 0.7629222270024162, 0.7412758427152082, 0.7140501699263486, 0.68156494493065, 0.6442128151735648, 0.6024632752390858, 0.5567579330674153, 0.5074975733415592, 0.455105069241882, 0.40003796930920044, 0.3427369996691762, 0.2836275728539337, 0.22315079263311713, 0.1617843382687413, 0.09998157961451182, 0.038174476430275134, -0.023209872099540618, -0.08374290989872087, -0.1430195534841236, -0.2006690061908739, -0.25628568565069954, -0.30950196687286613, -0.35989205677331043, -0.40709823601215933, -0.45077630800884155, -0.4906152674711578, -0.526362314554282, -0.5577068859901175, -0.5844307515265869, -0.6063936151757886, -0.6235036540928064, -0.6357109519367689, -0.6430031146882986, -0.6454415433680832, -0.6431058468815186, -0.6361385714713705, -0.6247105501231083, -0.609009872997285, -0.5893231567646925, -0.5659678036453314, -0.5392688762378166, -0.5095344586411966, -0.4771441828796046, -0.4424926987874236, -0.4059090072023054, -0.36773729470793254, -0.3283553099303184, -0.28815609828906597, -0.24753682876876576, -0.20684377141380733, -0.16640554212837028, -0.12656048129258013, -0.08769844227670626, -0.05014927464940949, -0.014236073471829536, 0.019753561834203903, 0.05154994172402194, 0.08090910112995764, 0.10761819603426176, 0.13150680978554752, 0.15234774264769102, 0.1699478002261925, 0.18417229423818782, 0.19493339735248205, 0.20214479162087612, 0.20571190099255154, 0.2056117221734508, 0.2018568230467967, 0.19448363833204435, 0.1835997742555468, 0.16933274174914187, 0.15182952255804044, 0.13133090656074375, 0.10812970317321463, 0.08253558728180732, 0.05486763541571393, 0.025555248345380643, -0.00494603608515054, -0.03612842676457251, -0.06748281285988189, -0.09849197087704686, -0.12862263187224504, -0.15738398900282685, -0.18432611279485903, -0.20904513484488768, -0.23115948049999627, -0.25029059035054513, -0.2661532546394521, -0.278540025970051, -0.28729732234658173, -0.29229215657138385, -0.29344614512087763, -0.29076291355215456, -0.2842887389224225, -0.27409971186925486, -0.2603227853860632, -0.24316396899913797, -0.22281527945164925, -0.1994777368240899, -0.173372951843111, -0.14477474633465853, -0.11391747974034602, -0.08108645012521576, -0.04662497892784223, -0.010898417570278346, 0.025706899928953225, 0.06282030208303684, 0.10005046576328235, 0.1369570398332438, 0.1732013986213488, 0.2083768244689646, 0.2420947943753819, 0.273999530595877, 0.3037382556937499, 0.3309954903930602, 0.355464351149086, 0.3768659378438025, 0.39495137036521827, 0.4095496920197965, 0.42051876735804505, 0.42779381784582177, 0.4313543309072328, 0.4312472383468971, 0.42755473873583905, 0.42039009748726225, 0.4099127714664322, 0.39633445794819966, 0.3798485554553681, 0.360829500067939, 0.33975364450091056, 0.31701258978395414, 0.2928572846802016, 0.2674465270769317, 0.24097058099213145, 0.21357436230323879, 0.18542589531372816, 0.15672676701555394, 0.12778847619906394, 0.09875992731634096, 0.06961274438361818, 0.04027198061580789, 0.010690811248782267, -0.019098225682785464, -0.04897279108827819, -0.07873568348043453, -0.10825045045491397, -0.13752115557415442, -0.16654889012518803, -0.1953046459890166, -0.2237200319335242, -0.25154745177240867, -0.2783985142516599, -0.30383143174502936, -0.32747880010371, -0.3488782927179727, -0.3675638207967634, -0.3831408907553073, -0.3952135551566219, -0.4033987360147211, -0.40738791678856995, -0.40690928373261576, -0.401750858711294, -0.3917910315617405, -0.37696749024448617, -0.35729446767171735, -0.33287767174932215, -0.30391409993518237, -0.27071086564361463, -0.233596025749998, -0.1929274420265012, -0.14909498566130133, -0.10250741197166416, -0.05359555715209188, -0.0027890514649915182, 0.0494874400586939, 0.10278126536573196, 0.15657553247131994, 0.21038300100999074, 0.263719801884969, 0.3160761165828311, 0.3669461848496597, 0.41581675550907227, 0.46218933216595876, 0.5056577016669264, 0.5457829262052001, 0.5821887885324321, 0.6145781808581229, 0.6426779673737347, 0.6662304088619969, 0.6850455395190848, 0.698964058600469, 0.7078889476555669, 0.7117688947348704, 0.7106039891056708, 0.704468627438445, 0.6934343297376934, 0.6775776521005316, 0.6570002310320956, 0.6318339273174459, 0.6022293148923848, 0.5684055087099762, 0.5305503580506712, 0.48892285749869413, 0.4438331414788714, 0.39563832166192553, 0.3446942091499976, 0.29140258931893737, 0.2362396092717739, 0.17970764807395034, 0.12232133351843846, 0.06458919060012847, 0.007027249296623942, -0.04982558763642966, -0.1054594107869141, -0.15932101962483605, -0.21087282159707216, -0.25967809475027026, -0.3052491317463476, -0.3472188569933091, -0.3852500822894598, -0.41905958850564196, -0.4484083709712725, -0.4730522996364495, -0.49285319127079197, -0.5077469855167062, -0.5177000015758806, -0.5227265929061716, -0.5228362726550874, -0.5181465663434579, -0.5087849406278282, -0.4949700038243606, -0.47692634649513105, -0.454929481700631, -0.4292681610610947, -0.4002748519238013, -0.36831893714584274, -0.333788840865493, -0.29707009500331666, -0.2586236001564925, -0.2188933654630073, -0.17835629495344285, -0.1375338699340005, -0.09692922256263473, -0.056996934888865904, -0.018239557010696496, 0.01884556496874374, 0.05381874044908204, 0.08626727057960186, 0.11583677504224635, 0.14221428472260672, 0.16516088490846606, 0.18448751402377828, 0.2001218864045442, 0.21201209950029193, 0.22017687536463632, 0.22469049221921852, 0.22574239304392552, 0.2235415350951347, 0.2183110516365888, 0.21031135404725054, 0.19984861068619741, 0.18719717082522086, 0.17264483621892693, 0.15644838382896503, 0.13886071131014788, 0.12017751794459584, 0.10065483009777437, 0.080498057661179, 0.059910801225220016, 0.03909685682297399, 0.018259348107379954, -0.0023556131127335095, -0.022530127681013472, -0.04206737788339736, -0.060768628174055206, -0.07842089188210077, -0.09486067711773735, -0.10994019023630905, -0.12344520886505654, -0.1351672586249507, -0.1449809317401365, -0.15273614281394648, -0.15828333748257212, -0.1614775934800401, -0.16219863281892494, -0.1603522564155378, -0.1558507246240839, -0.14858710429842625, -0.13846578763798809, -0.12545325889724712, -0.10959703559537931, -0.09089777407076177, -0.06937996224173881, -0.04511119588731477, -0.018208026350481625, 0.011142501823598525, 0.042746573886373346, 0.07633014388883805, 0.11155589955113077, 0.14810868565742602, 0.18565476091208188, 0.2238389846194933, 0.2622582423620546, 0.3004892522014648, 0.338153200173249, 0.3748157466387668, 0.41004143535212734, 0.4433862169901308, 0.47440692414136076, 0.5026511694801781, 0.5276878293532569, 0.5490993728371832, 0.566420563655846, 0.5792360087053633, 0.5871537615540516, 0.5898271580388859, 0.5869640719816556, 0.5783043687992026, 0.5636660486683005, 0.5429318888866281, 0.5160669981431497, 0.4831555912990559, 0.44441307568907257, 0.40017162781072474, 0.35085002992943165, 0.2969215843185642, 0.23890962837823748, 0.17743551511758482, 0.11319780017482224, 0.046876944310040955, -0.020810130426023254, -0.08915819110692191, -0.15744726599391234, -0.22501938121770249, -0.2912353137522511, -0.3554583180271623, -0.41711207544107803, -0.47567317970324613, -0.5306773237345335, -0.5816957737198738, -0.6283321066830223, -0.6702315486918671, -0.7070577596323633, -0.738523510094296, -0.764329436750254, -0.7842085203179325, -0.7979984349096101, -0.8055531101101141, -0.806774328257889, -0.8016044372549951, -0.7900427734744129, -0.7721750816676748, -0.7481456050827914, -0.718119773759958, -0.6823866959067701, -0.6413108983218878, -0.5952770588399652, -0.5447603770485355, -0.4902752802626848, -0.43243240295838975, -0.3718299468643143, -0.3090810604495603, -0.24486757678628496, -0.1798100811331429, -0.11452996602847353, -0.04962307469029654, 0.014330147450122607, 0.07682227447427792, 0.13734506600139315, 0.19537380610034172, 0.2505050327071677, 0.3023301874835454, 0.3504653659727936, 0.3946083476634585, 0.43444713246360994, 0.4697220631330356, 0.5002026003378481, 0.5256474671924732, 0.545871546800912, 0.5607259422029138, 0.5701592788773642, 0.5741439910686548, 0.572704700718939, 0.5658945670805633, 0.5538790133575172, 0.5368967542267628, 0.5152593047431365, 0.48930604852195003, 0.4594439149892613, 0.42612768086570296, 0.38985208338483385, 0.35106962966081895, 0.3103072608218581, 0.2681383930147075, 0.2250731458219927, 0.18163789736563668, 0.13834704874402573, 0.09565950928804082, 0.05399181772396379, 0.013735445059038787, -0.02476541460855967, -0.06114766994427698, -0.09507789260470736, -0.12631843476948426, -0.15470507558499413, -0.18006951614936967, -0.20228943205367345, -0.22125913903753353, -0.23697978394760166, -0.24943796485783698, -0.25868822808792785, -0.2648409710314299, -0.26802221789272057, -0.26839159844730964, -0.26612947160751754, -0.2613779219195226, -0.25430492639221386, -0.24510912415663116, -0.2340268807754845, -0.22122434507696193, -0.206880644677344, -0.19112285154910513, -0.17412510189630043, -0.15601618463409633, -0.13683850362322109, -0.11670495262309384, -0.09575323038266705, -0.07411824717060053, -0.05191818012319564, -0.029270060239526288, -0.006329513633778282, 0.016695481859843195, 0.039615218521455275, 0.06219927971869425, 0.0841948227400825, 0.10532452211782088, 0.12526231078249336, 0.1436640370065658, 0.16021995981821135, 0.17456273718605234, 0.18634529644783085, 0.19521958211047633, 0.2008869313880153, 0.20302656126297033, 0.20140960985223416, 0.19590364910231942, 0.18636411759012647, 0.1727039897371955, 0.1548430723125125, 0.13281758288860562, 0.10671830190848222, 0.076663097664702, 0.042883075038593094, 0.005655919594920317, -0.03470205056362338, -0.07779300444445489, -0.12320923617131645, -0.17050115280197164, -0.21914614931756618, -0.2686064951053488, -0.3183239462909291, -0.3676955433468359, -0.416118504455403, -0.4629517230840695, -0.50753463962425, -0.5492343123128072, -0.58742912409312, -0.6215133959404885, -0.6509220645661407, -0.6751414361444827, -0.693754660060718, -0.7063616118824618, -0.7126318469386578, -0.7122926647974539, -0.7051379984501792, -0.6910470379588429, -0.6699935640105668, -0.6420175866059271, -0.6072666178358135, -0.5659776739801612, -0.5184290736407986, -0.4649573873658694, -0.4060373697672965, -0.34218838567304677, -0.27395145814113997, -0.20193698357033912, -0.12678884451938777, -0.04922650041343154, 0.02998566817218591, 0.1100330586220047, 0.19008398445815194, 0.2692780794312762, 0.34671247659765175, 0.4215279618290825, 0.49294664004216493, 0.5601888017526108, 0.6224721316651733, 0.6790673337042815, 0.7293557718076243, 0.772749457312452, 0.8087145431967455, 0.836839560161274, 0.8567874425724172, 0.868382985682693, 0.8714770762716023, 0.8660083079973772, 0.8520799166682743, 0.8298566209543075, 0.7996221727725406, 0.7617549595580518, 0.716674979292849, 0.6648883493546326, 0.6069784678396312, 0.543619136272169, 0.4755580396084116, 0.40356624920000617, 0.3285133892188586, 0.25130985774833375, 0.17285322602572056, 0.09402410729954637, 0.015693670257519526, -0.0612908552449028, -0.13608636648000103, -0.20790058750329843, -0.27601292924750986, -0.33977834480358593, -0.3985930920830195, -0.451962898776589, -0.4995052202687672, -0.5409057630247528, -0.5759551836709802, -0.6045488985992522, -0.6266772570552501, -0.6424008580134111, -0.6518253743739777, -0.6551877448230092, -0.6527686083135413, -0.6448517498727795, -0.6317531718977698, -0.6138345852523893, -0.5915006860978701, -0.5652023598466627, -0.535398260685349, -0.5025431239683694, -0.4671168483867119, -0.42956484560099634, -0.3903228619120159, -0.34987634963405617, -0.30869890618992496, -0.2672853045830281, -0.22611766579752515, -0.18561752552272004, -0.14624052634214382, -0.10838536566009359, -0.07245524829294255, -0.038804486210010106, -0.007720653448678987, 0.020561223587501797, 0.045879385179941334, 0.06809828384422802, 0.08721211333408549, 0.10320613507921553, 0.11617224679273087, 0.12620637321201053, 0.1334001228912685, 0.13794474580931188, 0.1400502363033028, 0.1398927565504433, 0.1377079997017662, 0.13368337847353828, 0.12798812246795382, 0.12072817741453383, 0.112075414859546, 0.10220403282571233, 0.09126365487032059, 0.07936329251617336, 0.06661621743842976, 0.053161234945031804, 0.03913753573937859, 0.024737099302204094, 0.01012067805718335, -0.004561821376713349, -0.019120287136494593, -0.0333475173445639, -0.047112834224522254, -0.060256636546910394, -0.07265695011089307, -0.084168678008951, -0.09465189199040971, -0.10401753152092522, -0.1121838829765392, -0.11900742596374086, -0.12439850211571163, -0.12827661719258057, -0.1305401989156667, -0.13105208750639158, -0.12970738372629664, -0.1264439296415125, -0.12119158465584223, -0.11387637470423563, -0.10446398029620144, -0.09293308802094716, -0.07930024683123715, -0.06361996289901253, -0.0459714781099114, -0.026474061273041756, -0.005325096227417118, 0.017251073447021807, 0.041053725741670266, 0.0657915345625997, 0.09118096686653657, 0.1169729292774722, 0.1429057621938702, 0.1687087386432651, 0.1941729783497823, 0.21903755935392683, 0.24313125082933396, 0.26627576694687427, 0.288326058949792, 0.3091319552018772, 0.32853649304925464, 0.3463959502707512, 0.3626196740481276, 0.37709739174616064, 0.3897273445338495, 0.40039187917664043, 0.40893332628470774, 0.4151955859304769, 0.41897256151356604, 0.4200341521879748, 0.41818557120224464, 0.4132536373582154, 0.4050533465464329, 0.3934175442785697, 0.37818521166966856, 0.3592439875389295, 0.33650960639104954, 0.30996595156152323, 0.27959316214701496, 0.24548980700198925, 0.20777798312590967, 0.16654949808497993, 0.12199784660051971, 0.07440434313924173, 0.024162928797389772, -0.02834111728355146, -0.08270477819888208, -0.1384415209428089, -0.1950541092448348, -0.2520392642689835, -0.30886882423264594, -0.3649503304145496, -0.4196846311942969, -0.4724495012681571, -0.5225749606743066, -0.5694300369148165, -0.6123855612922828, -0.6508434144341441, -0.6842477855457305, -0.7120677295380842, -0.7338686144860648, -0.7492569059956615, -0.7579009344282894, -0.7595971843718035, -0.7542072381729817, -0.7416851562953478, -0.7220527778241165, -0.6954133562833598, -0.661992655401915, -0.6221384963244513, -0.576269435031473, -0.5249018673315797, -0.4686177218238276, -0.4080382803671126, -0.34384258408228174, -0.27674716489654605, -0.20749517260158487, -0.13688119240799, -0.06572301523178684, 0.005145184354395482, 0.07485979744754345, 0.14258406477298619, 0.20752444483656007, 0.2689679553034332, 0.32622384894718054, 0.3786739985856401, 0.42580141239889596, 0.4672159634655321, 0.5026131093704431, 0.5317844641436494, 0.5546279165679344, 0.5711364255529481, 0.58138746952773, 0.5855521309211857, 0.5838717294550781, 0.576646939558256, 0.5643468587111969, 0.5474169249867294, 0.5262970552332147, 0.5014468947130502, 0.47333118377318706, 0.4424440870227143, 0.4092645553242591, 0.37426212890965593]}}
tests/gpu.check.tsadded+70−0View file
@@ -0,0 +1,70 @@
1+// Runs the WGSL kernels under Dawn (the `webgpu` package) and compares them with the float64 CPU
2+// loop. This is a plain script rather than a node:test file: Dawn's threads abort the process
3+// under the node:test runner. Run with `node tests/gpu.check.ts`; a failed check exits non-zero.
4+import assert from "node:assert/strict";
5+import { create, globals } from "webgpu";
6+import { buildSetup } from "../src/model/setup.ts";
7+import { CpuSimulator } from "../src/cpu/simulator.ts";
8+import { GpuSimulator } from "../src/gpu/simulator.ts";
9+import { fixture } from "./helpers.ts";
10+
11+Object.assign(globalThis, globals);
12+
13+// one Dawn instance and device for the whole file (a second instance crashes the process)
14+const gpuInstance = create([]);
15+const adapterP = gpuInstance.requestAdapter().then((a) => { if (!a) throw new Error("no WebGPU adapter"); return a.requestDevice(); });
16+async function getDevice(): Promise<GPUDevice> { return adapterP; }
17+
18+let failures = 0;
19+async function test(name: string, fn: () => Promise<void>) {
20+ const t0 = performance.now();
21+ try { await fn(); console.log(`ok - ${name} (${(performance.now() - t0).toFixed(0)} ms)`); }
22+ catch (err) { failures++; console.log(`not ok - ${name}\n ${(err as Error).message}`); }
23+}
24+
25+async function runBoth(cfg: any, nSamples: number, chunk: number) {
26+ const setup = await buildSetup(cfg);
27+ const device = await getDevice();
28+ const cpu = new CpuSimulator(setup), gpu = new GpuSimulator(device, setup, 4096);
29+ const pc: number[] = [], pg: number[] = [];
30+ for (let done = 0; done < nSamples; done += chunk) {
31+ const n = Math.min(chunk, nSamples - done);
32+ const rc = await cpu.runChunk(n), rg = await gpu.runChunk(n);
33+ assert.equal(rg.firstSample, rc.firstSample); assert.equal(rg.pressureFront.length, rc.pressureFront.length);
34+ for (let i = 0; i < rc.pressureFront.length; i++) { pc.push(rc.pressureFront[i] - rc.pressureBack[i]); pg.push(rg.pressureFront[i] - rg.pressureBack[i]); }
35+ }
36+ const dc = await cpu.diagnostics(), dg = await gpu.diagnostics();
37+ gpu.dispose();
38+ return { setup, pc, pg, dc, dg };
39+}
40+
41+const rel = (a: number, b: number) => Math.abs(a - b) / Math.max(Math.abs(b), 1e-30);
42+
43+test("GPU kernels match the CPU loop on a linear run", async () => {
44+ const cfg = structuredClone(fixture.config);
45+ cfg.numerics.nonlinear = false;
46+ const { pc, pg, dc, dg } = await runBoth(cfg, 420, 150);
47+ assert.ok(rel(dg.hits[0].peakForce, dc.hits[0].peakForce) < 1e-4, `peak ${dg.hits[0].peakForce} vs ${dc.hits[0].peakForce}`);
48+ assert.equal(Math.round(dg.hits[0].contactTime / 1e-6), Math.round(dc.hits[0].contactTime / 1e-6));
49+ assert.ok(rel(dg.hits[0].velocity, dc.hits[0].velocity) < 1e-4);
50+ assert.ok(rel(dg.kinetic, dc.kinetic) < 1e-3 && rel(dg.membrane, dc.membrane) < 1e-3 && rel(dg.bending, dc.bending) < 1e-3,
51+ `energies ${[dg.kinetic, dg.membrane, dg.bending]} vs ${[dc.kinetic, dc.membrane, dc.bending]}`);
52+ assert.ok(rel(dg.maxWoverH, dc.maxWoverH) < 1e-3);
53+ let num = 0, den = 0;
54+ for (let i = 0; i < pc.length; i++) { num += (pc[i] - pg[i]) ** 2; den += pc[i] ** 2; }
55+ assert.ok(pc.length > 200 && Math.sqrt(num / den) < 1e-3, `pressure rel error ${Math.sqrt(num / den)}`);
56+});
57+
58+test("GPU kernels match the CPU loop on the nonlinear run (float32, short)", async () => {
59+ const { pc, pg, dc, dg } = await runBoth(fixture.config, 300, 100);
60+ assert.ok(rel(dg.hits[0].peakForce, dc.hits[0].peakForce) < 1e-3, `peak ${dg.hits[0].peakForce} vs ${dc.hits[0].peakForce}`);
61+ assert.ok(rel(dg.kinetic + dg.membrane + dg.bending, dc.kinetic + dc.membrane + dc.bending) < 1e-2);
62+ let num = 0, den = 0;
63+ for (let i = 0; i < pc.length; i++) { num += (pc[i] - pg[i]) ** 2; den += pc[i] ** 2; }
64+ assert.ok(Math.sqrt(num / den) < 1e-2, `pressure rel error ${Math.sqrt(num / den)}`);
65+});
66+
67+console.log(failures ? `${failures} check(s) failed` : "all GPU checks passed");
68+// tear the device down and leave explicitly: Dawn aborts when destroyed by the natural exit
69+(await getDevice()).destroy();
70+process.exit(failures ? 1 : 0);
tests/helpers.tsadded+17−0View file
@@ -0,0 +1,17 @@
1+import fs from "node:fs";
2+import path from "node:path";
3+import { fileURLToPath } from "node:url";
4+
5+const here = path.dirname(fileURLToPath(import.meta.url));
6+export const fixture = JSON.parse(fs.readFileSync(path.join(here, "fixtures", "python_reference.json"), "utf8"));
7+
8+export function maxAbsDiff(a: ArrayLike<number>, b: ArrayLike<number>): number {
9+ let m = 0;
10+ for (let i = 0; i < a.length; i++) m = Math.max(m, Math.abs(a[i] - b[i]));
11+ return m;
12+}
13+export function maxAbs(a: ArrayLike<number>): number {
14+ let m = 0;
15+ for (let i = 0; i < a.length; i++) m = Math.max(m, Math.abs(a[i]));
16+ return m;
17+}
tests/mesh.test.tsadded+53−0View file
@@ -0,0 +1,53 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import { diskMesh, edgeLengths, locate } from "../src/model/mesh.ts";
4+import { fixture, maxAbsDiff } from "./helpers.ts";
5+
6+test("disk mesh is a valid manifold with good quality", () => {
7+ for (const h of [0.03, 0.012]) {
8+ const R = 0.2286, m = diskMesh(R, h);
9+ let areaSum = 0;
10+ for (const a of m.area) { assert.ok(a > 0); areaSum += a; }
11+ assert.ok(areaSum > 0.98 * Math.PI * R * R && areaSum <= Math.PI * R * R + 1e-12);
12+ const count = new Map<string, number>();
13+ for (let e = 0; e < m.e; e++) for (let k = 0; k < 3; k++) {
14+ const a = m.tri[3 * e + k], b = m.tri[3 * e + ((k + 1) % 3)];
15+ const key = a < b ? `${a}-${b}` : `${b}-${a}`;
16+ count.set(key, (count.get(key) ?? 0) + 1);
17+ }
18+ let n1 = 0;
19+ for (const c of count.values()) { assert.ok(c === 1 || c === 2); if (c === 1) n1++; }
20+ assert.equal(n1, m.boundary.length);
21+ for (const b of m.boundary) assert.ok(Math.abs(Math.hypot(m.xy[2 * b], m.xy[2 * b + 1]) - R) < 1e-12);
22+ const L = edgeLengths(m);
23+ let qmin = 1, lsum = 0;
24+ for (let e = 0; e < m.e; e++) {
25+ const a = L[3 * e], b = L[3 * e + 1], c = L[3 * e + 2], s = (a + b + c) / 2;
26+ qmin = Math.min(qmin, (8 * (s - a) * (s - b) * (s - c)) / (a * b * c));
27+ lsum += a + b + c;
28+ }
29+ assert.ok(qmin > 0.5, `quality ${qmin}`);
30+ const lmean = lsum / L.length;
31+ assert.ok(lmean > 0.5 * h && lmean < 1.5 * h);
32+ }
33+});
34+
35+test("mesh matches the Python mesher exactly", () => {
36+ const f = fixture.mesh, cfg = fixture.config;
37+ const m = diskMesh(cfg.profile.radius, cfg.mesh.element_size, cfg.mesh.smoothing);
38+ assert.equal(m.n, f.n_nodes); assert.equal(m.e, f.n_elements); assert.equal(m.boundary.length, f.n_boundary);
39+ assert.ok(maxAbsDiff(m.xy.subarray(0, 10), f.xy_first5.flat()) < 1e-12);
40+ assert.ok(maxAbsDiff(m.xy.subarray(2 * m.n - 6), f.xy_last3.flat()) < 1e-12);
41+ assert.deepEqual(Array.from(m.tri.subarray(0, 15)), f.tri_first5.flat());
42+ let areaSum = 0; for (const a of m.area) areaSum += a;
43+ assert.ok(Math.abs(areaSum - f.area_sum) < 1e-12);
44+ const L = edgeLengths(m);
45+ assert.ok(Math.abs(Math.min(...L) - f.min_edge) < 1e-12 && Math.abs(Math.max(...L) - f.max_edge) < 1e-12);
46+});
47+
48+test("locate returns barycentric weights that sum to one", () => {
49+ const m = diskMesh(0.2, 0.03);
50+ const { weights } = locate(m, 0.05, -0.07);
51+ assert.ok(Math.abs(weights[0] + weights[1] + weights[2] - 1) < 1e-12);
52+ assert.throws(() => locate(m, 0.3, 0));
53+});
tests/shell.test.tsadded+49−0View file
@@ -0,0 +1,49 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import { CymbalProfile } from "../src/model/geometry.ts";
4+import { diskMesh } from "../src/model/mesh.ts";
5+import { buildShell } from "../src/model/shell.ts";
6+import { shellChecks } from "../src/model/forces.ts";
7+import { buildPatch, freeContactTime, maxCompression } from "../src/model/exciter.ts";
8+import { fixture, maxAbs, maxAbsDiff } from "./helpers.ts";
9+
10+const cfg = fixture.config;
11+const profile = new CymbalProfile(cfg.profile, cfg.imperfections);
12+const mesh = diskMesh(cfg.profile.radius, cfg.mesh.element_size, cfg.mesh.smoothing);
13+const shell = buildShell(mesh, profile, cfg.material, cfg.numerics);
14+
15+test("dent-free shell matches the Python assembly", () => {
16+ const f = fixture.shell;
17+ const rel = (a: number, b: number) => Math.abs(a - b) / Math.abs(b);
18+ assert.ok(rel(shell.mass, f.mass) < 1e-10);
19+ assert.ok(rel(shell.omegaMax, f.omega_max) < 1e-8, `omega ${shell.omegaMax} vs ${f.omega_max}`);
20+ assert.equal(shell.limitingElement, f.limiting_element);
21+ assert.ok(rel(shell.maxSlope, f.max_slope) < 1e-10);
22+ assert.ok(rel(Math.min(...shell.hE), f.h_e_min) < 1e-10 && rel(Math.max(...shell.hE), f.h_e_max) < 1e-10);
23+ assert.ok(rel(Math.max(...shell.z0Node), f.z0_max) < 1e-10);
24+ assert.ok(maxAbsDiff(shell.z0Node.subarray(0, 5), f.z0_node_first5) < 1e-14);
25+ assert.ok(maxAbsDiff(shell.Ke.subarray(0, 81), f.Ke0) < 1e-8 * maxAbs(f.Ke0));
26+ assert.ok(rel(shell.De[0], f.D0) < 1e-12 && maxAbsDiff(shell.Cm.subarray(0, 3), f.Cm0) < 1e-6);
27+ assert.ok(maxAbsDiff(shell.M.subarray(0, 15), f.M_first3.flat()) < 1e-12);
28+ const N = mesh.n;
29+ assert.ok(maxAbsDiff(shell.Rm.subarray(4 * 5 * N, 4 * 5 * N + 15), f.Rm4_first3.flat()) < 1e-8);
30+ assert.deepEqual(shell.zeroModes, [6, 6]);
31+});
32+
33+test("element and rigid-mode checks", () => {
34+ const c = shellChecks(shell);
35+ assert.ok(c.rigidTiltResidual < 1e-12);
36+ assert.ok(c.constantCurvatureError < 1e-8);
37+ assert.ok(c.rigidModeRatio < 1e-8, `rigid ratio ${c.rigidModeRatio}`);
38+});
39+
40+test("contact patch matches Python", () => {
41+ const p = buildPatch(cfg.hits[0], mesh, profile);
42+ const f = fixture.patch;
43+ assert.deepEqual(Array.from(p.nodes), f.nodes.flat());
44+ assert.ok(maxAbsDiff(p.weights, f.weights.flat()) < 1e-10);
45+ assert.ok(maxAbsDiff(p.offsets, f.offsets) < 1e-12);
46+ assert.ok(Math.abs(p.kTotal / f.k_total - 1) < 1e-12);
47+ assert.ok(Math.abs(maxCompression(p) / f.max_compression - 1) < 1e-10);
48+ assert.ok(Math.abs(freeContactTime(p) / f.free_contact_time - 1) < 1e-6);
49+});
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", "WebWorker"],
16+ "types": ["@webgpu/types", "vite/client", "node"]
17+ },
18+ "include": ["src", "tests"]
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" }, worker: { format: "es" } });