Cymbal designer: scripted design, thickness field, mount, single strike
A rebuild of steelsim31 around a design script. The script returns the
material, the bounding box, the outline (any function, including a center
hole), the thickness field, the stress-free profile, an optional mount, and
the losses; presets cover flat and bowed steel sheets, a disc on a stand,
a 16 inch crash, a 20 inch ride, a twisted ellipse, and an 18 inch china.
The kernel carries a per-node thickness through the bending stiffness, the
membrane stiffnesses, and the mass, and a spring-and-dashpot mount over a
patch; the push excitation, the hit series, the feature-list shape editor,
and the URL links of steelsim31 are gone. The CPU tests cover the tapered,
holed, and mounted discretizations, and the GPU self-test runs on the
presets.
35 changed files+6516−0
.github/workflows/deploy.ymladded+38−0View file
@@ -0,0 +1,38 @@
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+ environment:
20+ name: github-pages
21+ url: ${{ steps.deployment.outputs.page_url }}
22+ steps:
23+ - uses: actions/checkout@v4
24+ - uses: actions/setup-node@v4
25+ with:
26+ node-version: 24
27+ cache: npm
28+ - run: npm ci
29+ # Discretization tests on the float64 reference (no GPU needed).
30+ - run: npm run test:cpu
31+ # Type check and Vite build.
32+ - run: npm run build
33+ - uses: actions/configure-pages@v5
34+ - uses: actions/upload-pages-artifact@v3
35+ with:
36+ path: dist
37+ - id: deployment
38+ uses: actions/deploy-pages@v4
.gitignoreadded+3−0View file
@@ -0,0 +1,3 @@
1+node_modules/
2+dist/
3+dataset/
README.mdadded+70−0View file
@@ -0,0 +1,70 @@
1+# steelsim41
2+
3+A browser application for designing a virtual cymbal and simulating a drumstick strike on it. The cymbal is described by a short JavaScript script (material, bounding box, outline, thickness field, stress-free profile, mount, and losses) that the user edits in the page, or that an AI writes for them. The sound is computed by solving the geometrically nonlinear (von Karman) plate equations with in-plane inertia and a spatially varying thickness, stepped explicitly on the GPU through WebGPU. The model and the numerical method are described in [THEORY.md](THEORY.md).
4+
5+The physics and most of the interface come from steelsim31 (the hanging steel sheet); the changes here are the scripted design, the thickness field, the mount, an arbitrary outline (including a center hole), and the removal of the push excitation, the hit series, and the feature-list shape editor.
6+
7+The app is deployed by the `deploy` workflow on every push to `main`: https://steelsim41--proof-of-concept.vault1-sites.magland.org/
8+
9+## Running
10+
11+Requires Node 20 or later and a browser with WebGPU (Chrome or Edge 113+; Firefox and Safari with WebGPU enabled).
12+
13+ npm install
14+ npm run dev # development server
15+ npm run build # static site in dist/
16+ npm test # all tests (see below)
17+
18+## Using the app
19+
20+The sidebar has three parts: the design script, the strike and simulation parameters, and the controls. The main area shows the plate (2D color map or a lit 3D height field; drag to orbit, wheel to zoom), the movie and playback toolbar, and the spectrogram of the listener signal.
21+
22+**Design script.** Pick a preset from the dropdown (a flat steel rectangle by default; a bowed rectangle, a flat disc on a stand, a 16 inch crash, a 20 inch ride, an ellipse with a twist, an 18 inch china) and edit it freely; the script is compiled as you type (or with Ctrl+Enter) and the view shows the resulting stress-free shape, colored by height or by thickness. Errors are reported under the script, and a summary lists the bounding box, the material, the thickness range, the mass, the grid, the profile's peak-to-peak height and largest slope (with a warning above the shallow-shell range), the mount, and the losses. Revert restores the selected preset.
23+
24+The script is a plain JavaScript function body that returns the design object. All lengths are meters; `inch` and `mm` are conversion constants. Every field function receives `(x, y, r, theta)`: the point relative to the center of the bounding box, its distance from the center, and its angle from the x axis.
25+
26+```js
27+const D = 16 * inch, R = D / 2;
28+return {
29+ name: "16 inch crash",
30+ material: { E: 110e9, nu: 0.34, rho: 8800 }, // Pa, -, kg/m^3
31+ bounds: { width: D, height: D }, // bounding box [m]
32+ inside: circle(D, 12 * mm), // outline, or (x, y, r, theta) => boolean; omit for the whole box
33+ thickness: (x, y, r) => lerp(1.4 * mm, 0.8 * mm, clamp((r - 60 * mm) / (R - 60 * mm), 0, 1)),
34+ profile: (x, y, r) => 16 * mm * (1 - (r / R) ** 2) + dome(r, 60 * mm, 12 * mm), // stress-free height [m]
35+ mount: { radius: 20 * mm, stiffness: 2e4, damping: 5 }, // or null for a free plate
36+ loss: { sigma0: 0.6, sigma1: 2.5e-4, sigmaIn: 20 },
37+};
38+```
39+
40+`thickness` and `profile` may be numbers or functions; `inside`, `mount`, and `loss` are optional (`mount` may also give `x` and `y`). Helpers in scope: `circle(diameter, hole)`, `ellipse(width, height, hole)`, `rectangle(width, height, hole)` (outlines), `clamp`, `lerp`, `smoothstep(e0, e1, x)`, `bump(d, radius, height)` (Gaussian), `dome(d, radius, height)` (cosine dome with zero slope at the rim), `bow(s, span, sag, offset)` (parabolic bow), and `noise2d(seed, length)` (a smooth random field of unit RMS). The profile's mean plane is removed before use, since only its slopes enter the physics and a tilt would add a spurious membrane coupling.
41+
42+**Strike and simulation.** The strike point is given as fractions of the bounding box (the yellow ring in the view), with the stick speed, its effective mass, the Hertz contact stiffness and exponent, and the tip radius. The listener distance and direction can be changed while a simulation runs. The grid spacing (8 mm by default), the in-plane mass factor, the sample rate, and the frame rate take effect at the next Start; the panel reports the grid, the frequencies at which grid dispersion lowers the partials by 3 and 10 percent, the time step, and the 60 dB decay times of the design's losses. The rigid-body projection applies to free plates only; a mounted plate takes the reaction of the strike through the mount.
43+
44+**Running.** Press Start. The stick strikes the plate at rest in its stress-free shape, and the recording of audio and movie frames starts at the impact. The view shows the total shape by default (profile plus vibration); untick Show profile to see the vibration alone, which makes a small vibration on a curved cymbal visible. Pause halts the run temporarily; Stop ends it, keeps the audio and movie, and unlocks the script and the fields. To extend a finished run, raise the duration and press Continue. After the run, drag the time slider to explore the movie; Play starts the audio from the slider position and moves the movie and the spectrogram cursor with it; Live returns to the running view and Design to the stress-free shape. The audio can be played or downloaded as a 16-bit WAV at any time; Absolute level maps 1 Pa to full scale shifted by the gain slider (the peak level in dBFS is shown with a clipping warning), and Loudness and Peak normalize each run instead.
45+
46+**URL parameters.** `?preset=crash-16` selects a preset; any strike, listener, or numerics parameter can be preset in SI units, for example `?preset=ride-20&spacing=0.006&duration=3&autostart=1`.
47+
48+The default grid spacing of 8 mm keeps the simulation interactive but lowers the bending partials by a few percent near 1 kHz and by 10 percent a few kilohertz higher, depending on the thickness; the panel reports the two frequencies. The stable time step is set by the in-plane wave speed (about 1.3 microseconds at 8 mm for steel and 1.7 for bronze) and does not depend on the thickness, so a thick ride costs the same per step as a thin crash; a 16 inch cymbal at 8 mm has about 2000 nodes and runs at 60 to 70 thousand steps per second on an integrated GPU, or roughly 50 s for 4 s of sound.
49+
50+## Tests
51+
52+`npm test` runs three groups of tests with Node's built-in runner.
53+
54+- `tests/cpuRef.test.ts` checks the float64 reference of the spatial discretization without a GPU: symmetry of the stiffness with exactly three rigid modes per block on uniform, tapered, elliptical, and holed plates, forces as the exact gradient of the discrete energy (flat and bowed, uniform and tapered, free and mounted), dissipativity of the loss terms and the mount's dashpot, the scaling of the bending frequencies with the thickness, the mount's stiffness against rigid translation and tilt, and convergence of the free square and circular plate frequencies to the reference values of Gorman and Leissa.
55+- `tests/design.test.ts` checks the design script: compilation, the checks and their messages, the helpers, the mask of a disc with a hole, the removal of the mean plane, the mount patch, and that every preset evaluates on the default grid.
56+- `tests/gpu.test.ts` serves the app with Vite and drives a headless Chromium through Playwright. It compares one step of the WGSL kernel with the float64 reference on flat, bowed, tapered, mounted, and elliptical presets (`?selftest=1` in the browser shows the same numbers), runs a short strike on a free plate and on a mounted cymbal to completion, and checks that a script with an error is reported and cannot start. It needs Playwright's Chromium (`npx playwright install chromium`) and a machine whose Chromium has WebGPU; `npm run test:cpu` runs only the first two groups.
57+
58+## Layout
59+
60+- `src/params.ts`: simulation parameters, grid and mask, the plate as evaluated on the grid, material functions, patches of the strike and the mount, grid dispersion estimate.
61+- `src/design.ts`: the script API, the helpers, compilation and checks, evaluation on the grid.
62+- `src/presets.ts`: the preset scripts.
63+- `src/sim/cpuRef.ts`: float64 reference of the spatial discretization; used for the time-step estimate and the tests.
64+- `src/sim/shaders/`: WGSL kernels (time step, rigid-body sums and projection, listener sum, display extraction).
65+- `src/sim/simulation.ts`: WebGPU driver (buffers, batch encoding, readback).
66+- `src/selftest.ts`: the in-browser GPU-versus-CPU comparison.
67+- `src/render/`: 2D and 3D WebGPU rendering of the plate.
68+- `src/audio/`: FFT, spectrogram, WAV encoder, playback, level mapping.
69+- `src/ui/editor.ts`, `src/ui/form.ts`, and `src/main.ts`: the script editor, the parameter form, and the rest of the interface.
70+- `tests/`: the three test files above.
THEORY.mdadded+107−0View file
@@ -0,0 +1,107 @@
1+# Model and numerical method
2+
3+This note describes the model behind the simulator: a thin metal plate of arbitrary outline and spatially varying thickness, with a stress-free curved shape (the bow and the bell of a cymbal), either hanging freely or sitting on a stand, struck once with a drumstick. The plate rings like a cymbal or a gong. Its vibration amplitude, several thicknesses, is beyond the range where a linear plate model is accurate, so we solve the geometrically nonlinear plate equations of Foppl and von Karman with a thickness field, keep the in-plane inertia, discretize on a regular grid, and step explicitly on the GPU. Section 1 gives the continuous model, Section 2 the support and the rigid-body motion, Section 3 the strike, Sections 4 and 5 the discretization in space and time, Section 6 the radiation model, and Section 7 the limitations. The method is that of steelsim31, the hanging steel sheet, extended to a thickness field, a general outline, and a mount; the parts that carry over are repeated here so that this note stands alone.
4+
5+## 1. Plate model
6+
7+The plate occupies a region $\Omega$ of the plane, given by the design script as a bounding box $[-L_x/2, L_x/2] \times [-L_y/2, L_y/2]$ and an outline function (a rectangle, a circle or an ellipse with or without a center hole, or any other test). It has a thickness $h(x,y)$, Young's modulus $E$, Poisson ratio $\nu$, and density $\rho$; the material constants are uniform, the thickness is not. Its motion is described by the in-plane displacements $u(x,y,t)$ and $v(x,y,t)$ and the transverse displacement $w(x,y,t)$, all measured from a stress-free initial shape $w_0(x,y)$, the profile of the design (below). The membrane strains in the Marguerre form are
8+
9+$$
10+\varepsilon_{xx} = u_x + \tfrac12\left[(w_0 + w)_x^2 - w_{0,x}^2\right], \qquad
11+\varepsilon_{yy} = v_y + \tfrac12\left[(w_0 + w)_y^2 - w_{0,y}^2\right],
12+$$
13+
14+$$
15+\gamma_{xy} = u_y + v_x + (w_0 + w)_x (w_0 + w)_y - w_{0,x} w_{0,y},
16+$$
17+
18+so that $u = v = w = 0$ carries no stress. The membrane forces per unit length are $N_{xx} = C(\varepsilon_{xx} + \nu\varepsilon_{yy})$, $N_{yy} = C(\varepsilon_{yy} + \nu\varepsilon_{xx})$, and $N_{xy} = G\gamma_{xy}$, with $C = Eh/(1-\nu^2)$ and $G = Eh/(2(1+\nu))$, both now functions of position through $h$. Writing $W = w_0 + w$ for the total shape and $D = Eh^3/(12(1-\nu^2))$ for the local bending stiffness, the equations of motion are
19+
20+$$
21+\rho h\, u_{tt} = \partial_x N_{xx} + \partial_y N_{xy}, \qquad
22+\rho h\, v_{tt} = \partial_x N_{xy} + \partial_y N_{yy},
23+$$
24+
25+$$
26+\rho h\, w_{tt} = -\mathcal{B}[w] + \partial_x(N_{xx} W_x + N_{xy} W_y) + \partial_y(N_{xy} W_x + N_{yy} W_y) - 2\sigma_0\rho h\, w_t + 2\sigma_1 \rho h\, \nabla^2 w_t ,
27+$$
28+
29+where $\mathcal{B}[w]$ is the variable-stiffness bending operator, the Euler-Lagrange derivative of the bending energy
30+
31+$$
32+U_b = \frac12 \int_\Omega D(x,y)\left[w_{xx}^2 + w_{yy}^2 + 2\nu\, w_{xx}w_{yy} + 2(1-\nu)\, w_{xy}^2\right] dA ,
33+$$
34+
35+which reduces to $D\nabla^4 w$ where $D$ is constant. Bending acts on the deviation $w$ from the initial shape; the membrane forces act through the total slope. For $w_0 = 0$ and constant $h$ these are the von Karman equations. The quadratic terms in the strains couple bending to stretching and produce the cymbal-like behavior: the pitch glide of a strongly excited plate and the transfer of energy from the low modes toward high frequencies, which is what turns the initial tone of a struck cymbal into a wash. The in-plane inertia terms $\rho h u_{tt}$ and $\rho h v_{tt}$ are usually dropped in favor of an Airy stress function, since in-plane waves are much faster than bending waves. We keep them because they make the whole system explicit and local, which suits the GPU, at the cost of a time step set by the in-plane wave speed (Section 5).
36+
37+**Thickness field.** A real cymbal is lathed to a taper, thick at the bell and thin at the edge, and the taper is a large part of what distinguishes a crash from a ride. The thickness enters the model in three places: the mass per unit area $\rho h$, the membrane stiffnesses $C$ and $G$, which are proportional to $h$, and the bending stiffness $D$, which is proportional to $h^3$. Note that a plate of twice the thickness has twice the bending frequencies but the same in-plane frequencies; the tests check both. The thickness must be positive over the plate, and it should vary slowly on the scale of the grid.
38+
39+**Profile.** The stress-free shape $w_0$ is given by the design script as a function of position: a paraboloidal bow plus a cosine bell for the cymbal presets, a cylindrical bow or a saddle for the sheet-metal presets, or anything else. A curved plate is a shallow shell, and its behavior differs qualitatively from a flat plate: the flat plate has a symmetric, purely hardening nonlinearity, whereas a shell has an asymmetric one, may snap between two curved states, and oscillates more slowly at large amplitude as it passes through the soft flat configuration. Only the slopes of $w_0$ enter the strains, so its mean is immaterial, but its tilt is not: the von Karman strains are not invariant under rotation (Section 2), and a tilted stress-free shape would add a spurious membrane coupling linear in the tilt. The mass-weighted least-squares plane is therefore subtracted from every profile. Bending acts on the deviation $w$ only, so the profile carries no residual stress; this is the idealization of a permanent set, whereas a real hammered and lathed cymbal does carry some. The profile should be smooth on the scale of the grid, and its slopes should be small compared with one for the shallow-shell treatment to hold. This is the main point of tension between the model and a real cymbal: the bell of a crash rises 25 mm over a radius of 50 mm, a slope of 0.5 or more, and the bow itself has a slope of 0.1 to 0.2 near the edge. The presets use somewhat flatter shapes, the interface reports the largest slope and warns above 0.1, and the china preset, with its upturned edge, is the least trustworthy of them (Section 7).
40+
41+**Losses.** The two-parameter loss model is the standard one in plate sound synthesis. A bending mode of angular frequency $\omega$ and wavenumber $k$ decays at the rate $\sigma_0 + \sigma_1 k^2$, and for the flat plate $k^2 = \omega/\kappa$ with $\kappa = \sqrt{D/\rho h}$, so $\sigma_0$ sets the low-frequency decay and $\sigma_1$ makes high frequencies decay faster in proportion to frequency. The $\sigma_1$ term is implemented as a Laplacian of the velocity and does not depend on the thickness; where the plate is thicker, $\kappa$ is larger and a given frequency corresponds to a smaller $k^2$, so the frequency-dependent loss is weaker there. The in-plane motion carries an additional frequency-independent loss $\sigma_{\mathrm{in}}$. The stretching modes of the plate lie at several kilohertz, couple linearly to the transverse motion once the plate is curved, and would otherwise ring for the whole low-frequency decay time; a material loss factor of about $10^{-3}$ at 5 kHz gives a rate of this order. The three values are set by the design script (steel: $\sigma_0 = 1.15\ \mathrm{s^{-1}}$, $\sigma_1 = 5\times10^{-4}\ \mathrm{m^2/s}$, $\sigma_{\mathrm{in}} = 20\ \mathrm{s^{-1}}$ as in steelsim31; the bronze presets ring longer). None of these values are measured; they are chosen to sound plausible.
42+
43+## 2. Support and rigid-body motion
44+
45+The whole edge is free, including the edge of a center hole: the bending moment and the Kirchhoff shear vanish, and so does the in-plane traction. Rather than imposing these conditions explicitly, the discretization of Section 4 is derived from a discrete energy, and free edges are then its natural boundary conditions.
46+
47+The plate is supported in one of two ways, chosen by the design script.
48+
49+**Free plate.** A sheet hanging from a thread is treated as free, and every external load is made self-equilibrated: the reaction to the stick is distributed over the plate as the inertial load of a rigid body, which is the inertia-relief equilibrium of a free body. The plate then gains no net momentum or angular momentum, which stands in for the thread taking them up. The stick force is known at every step, so its reaction is applied directly as the rigid acceleration field that balances the force and its moments about the center (Section 3). Once the plate rings freely, no load remains that could move it as a whole, apart from rounding and the small non-invariance of the von Karman energy under finite rotation, and an optional mass-weighted projection of the six rigid-body modes (three translations, three rotations) every 32 steps removes that drift; it is off by default.
50+
51+**Mounted plate.** A cymbal sits on a stand: a rod through the center hole, with a felt washer below and usually another above, held by a wing nut. At audio frequencies the felt is a soft spring compared with the local stiffness of the plate, so it does not clamp the cymbal; it fixes the average position and tilt of the cymbal, and it lets the cymbal rock on the stand after a hit. We model the mount as a spring of total stiffness $K_m$ and a dashpot of total rate $C_m$ to ground, spread over the plate nodes within a radius $r_m$ of the mount point in proportion to their quadrature weight, and acting on all three displacement components. A uniform translation of the plate then feels exactly $K_m$, and a tilt feels a restoring moment $K_m \langle r^2 \rangle$ set by the second moment of the mount patch, so the finite radius of the felt is what holds the cymbal level; the tests check both. With the default values ($r_m = 20$ mm, $K_m = 2\times10^4$ N/m, $C_m = 5$ N s/m) a 16 inch bronze cymbal of about 1 kg bounces on the mount at roughly 20 Hz and rocks at a few hertz, and a hit near the edge starts a visible rock of several millimeters at the edge that decays over a second or so, as on a real stand. The mount takes the reaction of the stick directly, so no inertia relief is applied to a mounted plate, and the rigid-body projection is not applied either.
52+
53+A rigid rotation is not exactly a null mode of the von Karman energy: the strains contain $\tfrac12 W_x^2$, so a rotation $\theta$ about an in-plane axis produces a strain $\theta^2/2$ plus, on a curved plate, a term $\theta\, w_{0,y}$ linear in the tilt. Both are relieved by an in-plane displacement, $v = -\theta w_0$ for the linear term, which the free edges allow and which the in-plane waves establish within a fraction of a millisecond, far faster than the rock of the cymbal. The mount's in-plane spring resists this relief only weakly ($K_m$ against a membrane stiffness $Eh$ of order $10^8$ N/m), so the rocking of a mounted cymbal does not stress it spuriously. This is the reason the loads on a free plate have to be self-equilibrated, since a slow drift in rotation there would have no such relief through the mount and would build up. Gravity is dropped: the in-plane stress it induces is a strain of order $10^{-7}$.
54+
55+## 3. Excitation
56+
57+**Drumstick strike.** The stick tip is a free mass $M$ (default 30 g, the effective mass of a stick in a wrist stroke rather than its full mass) arriving with speed $v_0$ (default 3 m/s) at a point of the plate, given as fractions of the bounding box. The contact patch consists of the plate nodes within the tip radius (5 mm) plus the nearest one. Its contact coordinate is the mass-weighted mean transverse displacement $w_c$ of these nodes, the penetration is $\delta = w_c - x$ with $x$ the tip position, and the force is the Hertz law $F = K\,\delta^{\alpha}$ for $\delta > 0$ and zero otherwise, with $\alpha = 1.5$ for a rounded tip and $K = 5\times10^{8}\ \mathrm{N/m^{1.5}}$ for wood on metal. The force is spread over the patch in proportion to the node masses, which carry the local thickness. Because that distribution is exactly conjugate to the mean displacement, the contact has a potential energy and the coupled system is Hamiltonian apart from the losses. On a free plate the force is balanced over the whole plate by the inertia-relief field $a_k = F\,(1/M_s + y_k y_c/I_x + x_k x_c/I_y)$, where $M_s$ is the mass of the plate, $I_x$ and $I_y$ its moments of inertia about the in-plane axes, and $(x_c, y_c)$ the mass-weighted center of the patch, so that the total force and the two moments of the load vanish (Section 2). The field uses the force of the previous step, a lag of one microsecond whose net impulse over the contact is zero. On a mounted plate no such field is applied. The stick obeys $M\ddot x = F$ and is stepped together with the plate; it may bounce and strike again if the plate comes back to meet it. The recording starts at the impact, with the plate at rest in its stress-free shape. Note that a thin plate is a soft target: its driving-point impedance is $8\sqrt{D\rho h}$, about 60 N s/m for 0.8 mm steel and about the same for 1 mm bronze, so the contact lasts well under a millisecond and the peak force is a few hundred newtons. The excitation is therefore broadband, and the sound is a short bright attack followed by the ring.
58+
59+## 4. Spatial discretization
60+
61+The bounding box is covered by a regular grid of $n_x \times n_y$ nodes with spacings $h_x$ and $h_y$ (8 mm by default), and the outline selects the part of the grid that is the plate. All three displacements live at the nodes, and so does the thickness. The forces are the negative gradient of a discrete potential energy $U = U_b + U_m + U_s$ (bending, membrane, mount), so the semi-discrete system is Hamiltonian, its linear part has a symmetric stiffness matrix, and the free edge needs no special formulas.
62+
63+**Outline.** A cell belongs to the plate when its center lies inside the outline, and a node when it is a corner of such a cell; the edge is therefore a staircase of whole cells, and a hole is simply a region whose cells are absent. Every rule below refers to this membership: a second difference exists where the node and both of its neighbors belong to the plate, a cell contributes membrane energy and the mixed bending term when its four corners do, and the weight of a node is the fraction of its four cells that belong to the plate (1 inside, 1/2 on an edge, 1/4 at a corner of a full rectangle). Because the forces are the gradient of the discrete energy, the staircase edge is free, the stiffness stays symmetric, and exactly three rigid modes remain in each block, hole or no hole. Nodes outside the plate are frozen and carry no mass; a node that would be left with no cell of its own is removed. Deciding membership by cell centers rather than by nodes keeps the area unbiased; a node-based mask loses about half a cell all around the edge and raises every frequency by several percent.
64+
65+**Bending with a thickness field.** We evaluate $w_{xx}$ and $w_{yy}$ by three-point second differences at the nodes and $w_{xy}$ by the four-point difference at cell centers, and we integrate the bending energy with the node weights, the bending stiffness $D_k = E h_k^3/(12(1-\nu^2))$ at the nodes and $D_c$ at the cells, where the cell thickness is the mean of its four corners:
66+
67+$$
68+U_b = \frac{h_x h_y}{2}\left[\sum_k \omega_k D_k \left(a_k^2 + b_k^2 + 2\nu a_k b_k\right) + 2(1-\nu)\sum_c D_c\, c_c^2\right],
69+$$
70+
71+with $a_k$, $b_k$ the second differences at node $k$ (absent where undefined) and $c_c$ the mixed difference at cell $c$. The force at a node is the gradient of this sum, which the kernel assembles from the weighted moments $M^a_k = \omega_k D_k (a_k + \nu b_k)$, $M^b_k = \omega_k D_k (b_k + \nu a_k)$, and $M^c_c = D_c c_c$ by applying the second-difference stencils once more. Where $D$ is constant the interior stencil is the standard 13-point biharmonic; with a varying $D$ the same gather produces the discrete form of the variable-stiffness operator, and the edges are handled exactly as the energy dictates. The three rigid modes $w = 1, x, y$ lie in the null space for any thickness field.
72+
73+**Membrane.** The cells are bilinear (Q4) finite elements with 2 by 2 Gauss integration, using the strains of Section 1 with $w_0$ and $w$ interpolated bilinearly and the stiffnesses $C$ and $G$ of the cell's mean thickness. One-point integration would be cheaper but admits hourglass modes, which the nonlinear coupling would excite. On the GPU the force of each cell on its four nodes is computed once per cell within a workgroup and gathered by the nodes.
74+
75+**Mass.** The mass matrix is lumped: the mass of a node is $\rho h_k h_x h_y \omega_k$, so the total mass is the exact integral of $\rho h$ over the staircase plate.
76+
77+**Mount.** The spring of Section 2 is a diagonal term $\tfrac12 \sum_k K_k (u_k^2 + v_k^2 + w_k^2)$ with $K_k = K_m \omega_k / \sum_{m} \omega_m$ over the mount patch, and the dashpot is the same distribution of $C_m$ applied to the velocities.
78+
79+**Accuracy.** Two errors limit the accuracy of the spatial scheme. The first is the treatment of the edge. For the rectangle the scheme converges at second order: the first seven frequencies of a completely free square plate are low by about 3.5 percent at 10 cells per side, 0.8 percent at 20, and 0.3 percent at 30, compared with the reference values of Gorman. The staircase edge of a curved outline is only first-order accurate; the frequencies of a free circular plate are within about 2 percent of Leissa's values at 24 cells across the diameter and within 1 percent at 60, and the degenerate pairs, which the staircase splits, differ by 1 to 2 percent. A 16 inch cymbal at 8 mm has about 50 cells across, so its low modes are placed to within a percent or two, and the splitting of the pairs is of the same order as the splitting that the hammering of a real cymbal produces. The second error is grid dispersion, which affects every partial regardless of the outline. A plane bending wave of wavenumber $k$ along a grid axis has the discrete frequency ratio $(2 - 2\cos kh)/(kh)^2$, so a partial comes out low by 1 percent at $kh \approx 0.35$, by 3 percent at $kh \approx 0.60$, and by 10 percent at $kh \approx 1.12$. Since the bending frequency at a given $k$ is proportional to the thickness, these points move up with the thickness: for 0.8 mm steel on the 8 mm grid the 3 percent point is near 1.1 kHz and the 10 percent point near 3.6 kHz; for 1.5 mm bronze they are near 1.6 and 5.4 kHz. The interface reports the two frequencies at the mean thickness of the design. The high partials of the cymbal sound are therefore audibly flattened at 8 mm, and a finer grid is a matter of patience rather than of the method: the cost scales with the inverse cube of the spacing (Section 5).
80+
81+**Validation.** The tests check that both stiffness blocks are symmetric with exactly three rigid modes on uniform and tapered rectangles, on an ellipse, and on a disc with a hole; that the forces are the exact gradient of the discrete energy at a strongly nonlinear state, flat and bowed, uniform and tapered, free and mounted; that the loss terms and the mount's dashpot only remove energy; that doubling the thickness doubles the bending frequencies and leaves the membrane frequencies unchanged; that the mount resists a rigid translation with exactly its stiffness and a tilt with a restoring moment; and that the free-plate frequencies converge as stated. A further test runs one time step of the GPU kernel on a smooth random state of about 2.5 thicknesses amplitude on the flat and bowed rectangles, the tapered and mounted crash and china, and the twisted ellipse, and compares it with the float64 reference: the accelerations agree to about $10^{-6}$ relative, which is single-precision rounding.
82+
83+## 5. Time integration
84+
85+The system is advanced with the symplectic Euler scheme, $v^{n+1} = v^n + \Delta t\, a(x^n, v^n)$ and $x^{n+1} = x^n + \Delta t\, v^{n+1}$, in single precision on the GPU. With the velocities interpreted at half steps this is the leapfrog (Stormer-Verlet) scheme, second order and symplectic for the conservative part; the losses and the mount's dashpot are evaluated explicitly at the old velocity. The stability limit is $\Delta t < 2/\omega_{\max}$, where $\omega_{\max}$ is the largest frequency of the linearized discrete system. We estimate $\omega_{\max}$ at start-up by power iteration on a small uniform grid with the same spacing and the largest thickness of the design, which bounds the bending frequencies, and use 90 percent of the limit. The in-plane waves, with speed $c_p = \sqrt{E/\rho(1-\nu^2)}$, about 5200 m/s for steel and 3800 m/s for bronze, set this limit, and $c_p$ does not depend on the thickness: at 8 mm spacing $\Delta t \approx 1.3\ \mu$s for steel and $1.7\ \mu$s for bronze, several times smaller than bending alone would require even at the bell of a ride, and 4 s of sound take 2 to 3 million steps; at 4 mm the step halves and the node count quadruples. The step is rounded down so that an integer number of steps makes one audio sample.
86+
87+**In-plane mass scaling.** Since the in-plane waves set the step and the in-plane inertia is a small effect (the usual Airy stress function formulation drops it altogether), the interface offers a factor $s \geq 1$ by which the in-plane inertia is scaled; the default is 1. The in-plane wave speed becomes $c_p/\sqrt{s}$ and the stable step grows by up to $\sqrt{s}$, until bending sets the limit. The trade-off is that the in-plane modes of the plate, which start near 5 to 8 kHz for the presets, move down by the same factor and, once the plate is curved, are coupled to the transverse motion and to the sound. A factor of 4 halves the run time and keeps the in-plane modes above 3 kHz; it is meant for quick previews at a finer spacing rather than for the final sound.
88+
89+Single precision needs one precaution. With $w$ of a few millimeters and $\Delta t \approx 1\ \mu$s, a velocity below about $10^{-3}$ m/s produces an increment smaller than the resolution of $w$, and the rounding would act as a broadband noise source. The update of $w$ is therefore compensated: the rounding error of each addition is carried in a separate array and added back at the next step (Kahan summation). The self-test of Section 4 also checks this update.
90+
91+The state is stored twice and each step reads one copy and writes the other. A step is one compute dispatch in which each workgroup of 256 threads advances a 12 by 12 tile of nodes: the threads load the tile with a two-node halo (displacements, profile, thickness, and node weights) into shared memory, compute the membrane forces of the cells and the bending moments at the nodes once each, and then evaluate the update of the tile's nodes from shared memory; the transverse velocity, which only the loss Laplacian needs, is read from global memory to keep the workgroup within the 16 KB of shared memory that every WebGPU adapter guarantees. On grids of a few thousand nodes the cost of a step is the latency of one such workgroup, about 15 microseconds on an integrated GPU, rather than the arithmetic, so the step time barely grows until the grid exceeds several thousand nodes. A per-step uniform, addressed by a dynamic offset, carries the damping and the audio bookkeeping, so that a whole movie frame of steps is encoded in one pass, and the following frame is encoded while the GPU works on the current one. The mass-weighted sums over the plate (rigid-body components, kinetic energy, peak displacement) run in one workgroup once per frame, or every 32 steps when the optional projection of Section 2 is on.
92+
93+## 6. Radiation to the listener
94+
95+The listener model is the Rayleigh integral for a baffled plate,
96+
97+$$
98+p(\mathbf r, t) = \frac{\rho_{\mathrm{air}}}{2\pi} \sum_k \frac{\Delta A_k}{r_k}\, \ddot w_k(t - r_k/c),
99+$$
100+
101+where the sum runs over the nodes, $\Delta A_k$ is the node's share of the area, $r_k$ is the distance from node $k$ to the listener, and $c = 343$ m/s. The transverse velocity of every node is recorded in a ring buffer at the audio rate, and each output sample is a delayed, weighted sum over the plate, evaluated on the GPU once per movie frame for all the frame's samples, with linear interpolation between samples; the acceleration is formed as the difference between successive delayed velocities, which is the average acceleration over one sample interval. This matters in single precision: the force evaluation carries a broadband rounding error of the order of $1\ \mathrm{m/s^2}$ per node, and sampling the instantaneous acceleration would put that noise straight into the audio, whereas the averaged form suppresses it by the square root of the number of steps per sample. The common delay is discarded; only the spread of delays across the plate is kept.
102+
103+The plate is unbaffled in reality, so the sound from the two faces partly cancels at low frequencies, and the Rayleigh integral overestimates the low-frequency level; in particular the slow rock of a mounted cymbal, at a few hertz, radiates nothing audible in reality and next to nothing here either, since the acceleration involved is tiny. An accurate treatment would require solving the exterior acoustic problem, which is outside the scope of this simulator. The pressure is reported in pascals and the peak sound pressure level is displayed. Playback and the WAV file use, by default, a fixed mapping from pressure to full scale (1 Pa at 0 dBFS, shifted by a user gain in decibels), so that runs with different designs can be compared by ear; a soft clipper bounds the output and the resulting peak level is shown. Two normalized modes are also offered, to the peak or to the loudness of the sustained sound.
104+
105+## 7. Limitations
106+
107+The von Karman model assumes moderate rotations, so it is not valid for displacements that are large compared with the plate size; a strike that moves a 400 mm cymbal by a few millimeters is well within its range. The Marguerre treatment of the profile is a shallow-shell approximation, adequate for a bow of a few millimeters on a sheet and for a gentle cymbal bow, but strained by the bell of a real cymbal and by the upturned edge of a china, whose slopes are several times the range in which the approximation holds; the presets use flatter shapes than real cymbals, and the interface reports the largest slope of a design. A deep bell also behaves as a stiff cup that shifts the bell modes up and couples them to the bow in ways the shallow shell does not capture. The thickness field is treated as a slowly varying coefficient in a thin-plate model, which is appropriate for a lathed taper but not for a step or a groove on the scale of the grid. The hammering of a real cymbal, which leaves residual stresses and a slightly non-axisymmetric shape, is absent unless the script adds it (a random field through `noise2d` is one way). The loss model is empirical and its parameters are not calibrated against a real cymbal. Grid dispersion lowers the high partials by the amounts given in Section 4, and the staircase edge places the low frequencies within a few percent and splits modes that would be degenerate on a true disc. The mount is a linear spring, whereas a felt is nonlinear and the cymbal can lift off it; and the rod in the hole, which limits the in-plane motion and the rock, is only represented by the mount's in-plane spring. Air loading, which lowers the frequencies of a thin plate slightly and adds radiation damping, is not included, and the radiation model is approximate, as discussed above. Finally, single precision limits the dynamic range of the tail of the sound to roughly 100 dB below the peak.
index.htmladded+33−0View file
@@ -0,0 +1,33 @@
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>Cymbal designer</title>
7+ <link rel="stylesheet" href="./src/style.css" />
8+ </head>
9+ <body>
10+ <div id="app">
11+ <aside id="sidebar">
12+ <h1>Cymbal designer</h1>
13+ <p class="sub">A scripted cymbal, struck once: geometrically nonlinear plate (von Karman with in-plane inertia) with a thickness field, stepped explicitly on the GPU.</p>
14+ <details open>
15+ <summary>Design script</summary>
16+ <div id="design"></div>
17+ </details>
18+ <div id="params"></div>
19+ <div id="controls"></div>
20+ <div id="info"></div>
21+ <p class="sub">The model and the numerical method are described in THEORY.md in the repository.</p>
22+ </aside>
23+ <main id="main">
24+ <div class="toolbar" id="viewToolbar"></div>
25+ <div id="viewWrap"><canvas id="view"></canvas><div id="nogpu" hidden></div></div>
26+ <div class="toolbar" id="movieToolbar"></div>
27+ <div class="toolbar" id="specToolbar"></div>
28+ <canvas id="spec"></canvas>
29+ </main>
30+ </div>
31+ <script type="module" src="./src/main.ts"></script>
32+ </body>
33+</html>
package-lock.jsonadded+1235−0View file
@@ -0,0 +1,1235 @@
1+{
2+ "name": "steelsim41",
3+ "version": "0.1.0",
4+ "lockfileVersion": 3,
5+ "requires": true,
6+ "packages": {
7+ "": {
8+ "name": "steelsim41",
9+ "version": "0.1.0",
10+ "devDependencies": {
11+ "@types/node": "^24.0.0",
12+ "@webgpu/types": "^0.1.60",
13+ "playwright": "^1.50.0",
14+ "typescript": "^5.6.0",
15+ "vite": "^6.0.0"
16+ }
17+ },
18+ "node_modules/@esbuild/aix-ppc64": {
19+ "version": "0.25.12",
20+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
21+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
22+ "cpu": [
23+ "ppc64"
24+ ],
25+ "dev": true,
26+ "license": "MIT",
27+ "optional": true,
28+ "os": [
29+ "aix"
30+ ],
31+ "engines": {
32+ "node": ">=18"
33+ }
34+ },
35+ "node_modules/@esbuild/android-arm": {
36+ "version": "0.25.12",
37+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
38+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
39+ "cpu": [
40+ "arm"
41+ ],
42+ "dev": true,
43+ "license": "MIT",
44+ "optional": true,
45+ "os": [
46+ "android"
47+ ],
48+ "engines": {
49+ "node": ">=18"
50+ }
51+ },
52+ "node_modules/@esbuild/android-arm64": {
53+ "version": "0.25.12",
54+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
55+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
56+ "cpu": [
57+ "arm64"
58+ ],
59+ "dev": true,
60+ "license": "MIT",
61+ "optional": true,
62+ "os": [
63+ "android"
64+ ],
65+ "engines": {
66+ "node": ">=18"
67+ }
68+ },
69+ "node_modules/@esbuild/android-x64": {
70+ "version": "0.25.12",
71+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
72+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
73+ "cpu": [
74+ "x64"
75+ ],
76+ "dev": true,
77+ "license": "MIT",
78+ "optional": true,
79+ "os": [
80+ "android"
81+ ],
82+ "engines": {
83+ "node": ">=18"
84+ }
85+ },
86+ "node_modules/@esbuild/darwin-arm64": {
87+ "version": "0.25.12",
88+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
89+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
90+ "cpu": [
91+ "arm64"
92+ ],
93+ "dev": true,
94+ "license": "MIT",
95+ "optional": true,
96+ "os": [
97+ "darwin"
98+ ],
99+ "engines": {
100+ "node": ">=18"
101+ }
102+ },
103+ "node_modules/@esbuild/darwin-x64": {
104+ "version": "0.25.12",
105+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
106+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
107+ "cpu": [
108+ "x64"
109+ ],
110+ "dev": true,
111+ "license": "MIT",
112+ "optional": true,
113+ "os": [
114+ "darwin"
115+ ],
116+ "engines": {
117+ "node": ">=18"
118+ }
119+ },
120+ "node_modules/@esbuild/freebsd-arm64": {
121+ "version": "0.25.12",
122+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
123+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
124+ "cpu": [
125+ "arm64"
126+ ],
127+ "dev": true,
128+ "license": "MIT",
129+ "optional": true,
130+ "os": [
131+ "freebsd"
132+ ],
133+ "engines": {
134+ "node": ">=18"
135+ }
136+ },
137+ "node_modules/@esbuild/freebsd-x64": {
138+ "version": "0.25.12",
139+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
140+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
141+ "cpu": [
142+ "x64"
143+ ],
144+ "dev": true,
145+ "license": "MIT",
146+ "optional": true,
147+ "os": [
148+ "freebsd"
149+ ],
150+ "engines": {
151+ "node": ">=18"
152+ }
153+ },
154+ "node_modules/@esbuild/linux-arm": {
155+ "version": "0.25.12",
156+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
157+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
158+ "cpu": [
159+ "arm"
160+ ],
161+ "dev": true,
162+ "license": "MIT",
163+ "optional": true,
164+ "os": [
165+ "linux"
166+ ],
167+ "engines": {
168+ "node": ">=18"
169+ }
170+ },
171+ "node_modules/@esbuild/linux-arm64": {
172+ "version": "0.25.12",
173+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
174+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
175+ "cpu": [
176+ "arm64"
177+ ],
178+ "dev": true,
179+ "license": "MIT",
180+ "optional": true,
181+ "os": [
182+ "linux"
183+ ],
184+ "engines": {
185+ "node": ">=18"
186+ }
187+ },
188+ "node_modules/@esbuild/linux-ia32": {
189+ "version": "0.25.12",
190+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
191+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
192+ "cpu": [
193+ "ia32"
194+ ],
195+ "dev": true,
196+ "license": "MIT",
197+ "optional": true,
198+ "os": [
199+ "linux"
200+ ],
201+ "engines": {
202+ "node": ">=18"
203+ }
204+ },
205+ "node_modules/@esbuild/linux-loong64": {
206+ "version": "0.25.12",
207+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
208+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
209+ "cpu": [
210+ "loong64"
211+ ],
212+ "dev": true,
213+ "license": "MIT",
214+ "optional": true,
215+ "os": [
216+ "linux"
217+ ],
218+ "engines": {
219+ "node": ">=18"
220+ }
221+ },
222+ "node_modules/@esbuild/linux-mips64el": {
223+ "version": "0.25.12",
224+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
225+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
226+ "cpu": [
227+ "mips64el"
228+ ],
229+ "dev": true,
230+ "license": "MIT",
231+ "optional": true,
232+ "os": [
233+ "linux"
234+ ],
235+ "engines": {
236+ "node": ">=18"
237+ }
238+ },
239+ "node_modules/@esbuild/linux-ppc64": {
240+ "version": "0.25.12",
241+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
242+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
243+ "cpu": [
244+ "ppc64"
245+ ],
246+ "dev": true,
247+ "license": "MIT",
248+ "optional": true,
249+ "os": [
250+ "linux"
251+ ],
252+ "engines": {
253+ "node": ">=18"
254+ }
255+ },
256+ "node_modules/@esbuild/linux-riscv64": {
257+ "version": "0.25.12",
258+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
259+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
260+ "cpu": [
261+ "riscv64"
262+ ],
263+ "dev": true,
264+ "license": "MIT",
265+ "optional": true,
266+ "os": [
267+ "linux"
268+ ],
269+ "engines": {
270+ "node": ">=18"
271+ }
272+ },
273+ "node_modules/@esbuild/linux-s390x": {
274+ "version": "0.25.12",
275+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
276+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
277+ "cpu": [
278+ "s390x"
279+ ],
280+ "dev": true,
281+ "license": "MIT",
282+ "optional": true,
283+ "os": [
284+ "linux"
285+ ],
286+ "engines": {
287+ "node": ">=18"
288+ }
289+ },
290+ "node_modules/@esbuild/linux-x64": {
291+ "version": "0.25.12",
292+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
293+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
294+ "cpu": [
295+ "x64"
296+ ],
297+ "dev": true,
298+ "license": "MIT",
299+ "optional": true,
300+ "os": [
301+ "linux"
302+ ],
303+ "engines": {
304+ "node": ">=18"
305+ }
306+ },
307+ "node_modules/@esbuild/netbsd-arm64": {
308+ "version": "0.25.12",
309+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
310+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
311+ "cpu": [
312+ "arm64"
313+ ],
314+ "dev": true,
315+ "license": "MIT",
316+ "optional": true,
317+ "os": [
318+ "netbsd"
319+ ],
320+ "engines": {
321+ "node": ">=18"
322+ }
323+ },
324+ "node_modules/@esbuild/netbsd-x64": {
325+ "version": "0.25.12",
326+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
327+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
328+ "cpu": [
329+ "x64"
330+ ],
331+ "dev": true,
332+ "license": "MIT",
333+ "optional": true,
334+ "os": [
335+ "netbsd"
336+ ],
337+ "engines": {
338+ "node": ">=18"
339+ }
340+ },
341+ "node_modules/@esbuild/openbsd-arm64": {
342+ "version": "0.25.12",
343+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
344+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
345+ "cpu": [
346+ "arm64"
347+ ],
348+ "dev": true,
349+ "license": "MIT",
350+ "optional": true,
351+ "os": [
352+ "openbsd"
353+ ],
354+ "engines": {
355+ "node": ">=18"
356+ }
357+ },
358+ "node_modules/@esbuild/openbsd-x64": {
359+ "version": "0.25.12",
360+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
361+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
362+ "cpu": [
363+ "x64"
364+ ],
365+ "dev": true,
366+ "license": "MIT",
367+ "optional": true,
368+ "os": [
369+ "openbsd"
370+ ],
371+ "engines": {
372+ "node": ">=18"
373+ }
374+ },
375+ "node_modules/@esbuild/openharmony-arm64": {
376+ "version": "0.25.12",
377+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
378+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
379+ "cpu": [
380+ "arm64"
381+ ],
382+ "dev": true,
383+ "license": "MIT",
384+ "optional": true,
385+ "os": [
386+ "openharmony"
387+ ],
388+ "engines": {
389+ "node": ">=18"
390+ }
391+ },
392+ "node_modules/@esbuild/sunos-x64": {
393+ "version": "0.25.12",
394+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
395+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
396+ "cpu": [
397+ "x64"
398+ ],
399+ "dev": true,
400+ "license": "MIT",
401+ "optional": true,
402+ "os": [
403+ "sunos"
404+ ],
405+ "engines": {
406+ "node": ">=18"
407+ }
408+ },
409+ "node_modules/@esbuild/win32-arm64": {
410+ "version": "0.25.12",
411+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
412+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
413+ "cpu": [
414+ "arm64"
415+ ],
416+ "dev": true,
417+ "license": "MIT",
418+ "optional": true,
419+ "os": [
420+ "win32"
421+ ],
422+ "engines": {
423+ "node": ">=18"
424+ }
425+ },
426+ "node_modules/@esbuild/win32-ia32": {
427+ "version": "0.25.12",
428+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
429+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
430+ "cpu": [
431+ "ia32"
432+ ],
433+ "dev": true,
434+ "license": "MIT",
435+ "optional": true,
436+ "os": [
437+ "win32"
438+ ],
439+ "engines": {
440+ "node": ">=18"
441+ }
442+ },
443+ "node_modules/@esbuild/win32-x64": {
444+ "version": "0.25.12",
445+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
446+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
447+ "cpu": [
448+ "x64"
449+ ],
450+ "dev": true,
451+ "license": "MIT",
452+ "optional": true,
453+ "os": [
454+ "win32"
455+ ],
456+ "engines": {
457+ "node": ">=18"
458+ }
459+ },
460+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
461+ "version": "1.5.1",
462+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
463+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
464+ "cpu": [
465+ "x64"
466+ ],
467+ "dev": true,
468+ "libc": [
469+ "glibc"
470+ ],
471+ "license": "MIT",
472+ "optional": true,
473+ "os": [
474+ "linux"
475+ ],
476+ "engines": {
477+ "node": "^22.20 || ^24.12 || >=25"
478+ }
479+ },
480+ "node_modules/@rollup/rollup-android-arm-eabi": {
481+ "version": "4.63.2",
482+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.2.tgz",
483+ "integrity": "sha512-Xa6RDoWa+hNiX6PgsljlH6W75RaONx3y6PVlbLhkEWW+GaPQ3dP5gwbL/erAzQHWwkvW5UxdD5l87Qx2FAQ/4A==",
484+ "cpu": [
485+ "arm"
486+ ],
487+ "dev": true,
488+ "license": "MIT",
489+ "optional": true,
490+ "os": [
491+ "android"
492+ ]
493+ },
494+ "node_modules/@rollup/rollup-android-arm64": {
495+ "version": "4.63.2",
496+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.2.tgz",
497+ "integrity": "sha512-vNASxsghMfQ5s+v3PrpnJd+ryL/26lxCCaGI+sDJ7VzmHiYXIrrVltsDhaawxLM1WcoMU2oYlbPHLaYQtBzhcg==",
498+ "cpu": [
499+ "arm64"
500+ ],
501+ "dev": true,
502+ "license": "MIT",
503+ "optional": true,
504+ "os": [
505+ "android"
506+ ]
507+ },
508+ "node_modules/@rollup/rollup-darwin-arm64": {
509+ "version": "4.63.2",
510+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.2.tgz",
511+ "integrity": "sha512-0dWDjmlrpZAgjPD/aPzUDhBW8APLRjAni5bOrM76wiiZm+E+KTMVKNhAzaTBohz8UyO2fKNAl0+fygbe2HZXOA==",
512+ "cpu": [
513+ "arm64"
514+ ],
515+ "dev": true,
516+ "license": "MIT",
517+ "optional": true,
518+ "os": [
519+ "darwin"
520+ ]
521+ },
522+ "node_modules/@rollup/rollup-darwin-x64": {
523+ "version": "4.63.2",
524+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.2.tgz",
525+ "integrity": "sha512-N58uktcwzk3+qT4KHEuNdIxX1N01RWrkfVoml69EAbSaNDL+sbNVLx2RMl4Qd23lpA0fgPvyh5hHb4weD5WKmg==",
526+ "cpu": [
527+ "x64"
528+ ],
529+ "dev": true,
530+ "license": "MIT",
531+ "optional": true,
532+ "os": [
533+ "darwin"
534+ ]
535+ },
536+ "node_modules/@rollup/rollup-freebsd-arm64": {
537+ "version": "4.63.2",
538+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.2.tgz",
539+ "integrity": "sha512-HWF2zH8EAp2scWRpt2PGe6iUGz7zi04waXsdRr3zb4DWCk2ImIo5FZu0jjmD53nP/DGSvnW0e7/1ToCNZs2lZw==",
540+ "cpu": [
541+ "arm64"
542+ ],
543+ "dev": true,
544+ "license": "MIT",
545+ "optional": true,
546+ "os": [
547+ "freebsd"
548+ ]
549+ },
550+ "node_modules/@rollup/rollup-freebsd-x64": {
551+ "version": "4.63.2",
552+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.2.tgz",
553+ "integrity": "sha512-MkvcwHMnzPSMOQEwB6wHnLzmc+hT8BGc5bW/Mhmjjgx3wbj6VBnlc47XsK74kD0K9MikFfXpQqyz4NUXaUW62A==",
554+ "cpu": [
555+ "x64"
556+ ],
557+ "dev": true,
558+ "license": "MIT",
559+ "optional": true,
560+ "os": [
561+ "freebsd"
562+ ]
563+ },
564+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
565+ "version": "4.63.2",
566+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.2.tgz",
567+ "integrity": "sha512-xe1bCKPJaKsD0tfd7Rb6bGfUogJTpKbTEEthsfdb7hTfTRNJVQTdirabQx0o6ERVba/smkM720soMY+0QnrlSQ==",
568+ "cpu": [
569+ "arm"
570+ ],
571+ "dev": true,
572+ "libc": [
573+ "glibc"
574+ ],
575+ "license": "MIT",
576+ "optional": true,
577+ "os": [
578+ "linux"
579+ ]
580+ },
581+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
582+ "version": "4.63.2",
583+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.2.tgz",
584+ "integrity": "sha512-yOM7LdK0p6gk6+Q773OEwtlsikT1TL3yMmYsTtRlDRPha5vV2DC5x7LqRWDr6f3cSYNMKVqxzffXv8ivxNBIFQ==",
585+ "cpu": [
586+ "arm"
587+ ],
588+ "dev": true,
589+ "libc": [
590+ "musl"
591+ ],
592+ "license": "MIT",
593+ "optional": true,
594+ "os": [
595+ "linux"
596+ ]
597+ },
598+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
599+ "version": "4.63.2",
600+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.2.tgz",
601+ "integrity": "sha512-qiWuJJV3DybA2IfzvRimeKXGrGuVPv1zobSY/26KnP3HbV0VcNb3ECzgvtbvF3xjSMkcooou6HASXZuLdjnhpQ==",
602+ "cpu": [
603+ "arm64"
604+ ],
605+ "dev": true,
606+ "libc": [
607+ "glibc"
608+ ],
609+ "license": "MIT",
610+ "optional": true,
611+ "os": [
612+ "linux"
613+ ]
614+ },
615+ "node_modules/@rollup/rollup-linux-arm64-musl": {
616+ "version": "4.63.2",
617+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.2.tgz",
618+ "integrity": "sha512-akcZquRzCY/KpUoZAMBhGf7oi4LmXq1BzRA5CPAC3rkUf28Y/sAYV3jSL+JKd7cwEyFvR5G0XVZ0gaMedP+60A==",
619+ "cpu": [
620+ "arm64"
621+ ],
622+ "dev": true,
623+ "libc": [
624+ "musl"
625+ ],
626+ "license": "MIT",
627+ "optional": true,
628+ "os": [
629+ "linux"
630+ ]
631+ },
632+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
633+ "version": "4.63.2",
634+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.2.tgz",
635+ "integrity": "sha512-fNwYHrPyYyxauPzX/cpYw8Z7LQpp+DGA0KCoswA0aVFBpmdMil9XgjB8V3Ny64Ihu797+GKcuJqnsOKEmor7fA==",
636+ "cpu": [
637+ "loong64"
638+ ],
639+ "dev": true,
640+ "libc": [
641+ "glibc"
642+ ],
643+ "license": "MIT",
644+ "optional": true,
645+ "os": [
646+ "linux"
647+ ]
648+ },
649+ "node_modules/@rollup/rollup-linux-loong64-musl": {
650+ "version": "4.63.2",
651+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.2.tgz",
652+ "integrity": "sha512-XfvsgzR7DZqREdst7K1Mj3ilSUM5xLAHJcIMDFPKdxTs9q5VHOT8aMA+a683fqBu7DQl8+Sd9HCsQYL8EMY9qA==",
653+ "cpu": [
654+ "loong64"
655+ ],
656+ "dev": true,
657+ "libc": [
658+ "musl"
659+ ],
660+ "license": "MIT",
661+ "optional": true,
662+ "os": [
663+ "linux"
664+ ]
665+ },
666+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
667+ "version": "4.63.2",
668+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.2.tgz",
669+ "integrity": "sha512-Pp7gVZggEFlbcuztay+/U0gVG9S1XAh8i7I1Re/htbAzo43P5wHZHw6pTyzotISqlKohoh9RpIfnOz3RbemK1w==",
670+ "cpu": [
671+ "ppc64"
672+ ],
673+ "dev": true,
674+ "libc": [
675+ "glibc"
676+ ],
677+ "license": "MIT",
678+ "optional": true,
679+ "os": [
680+ "linux"
681+ ]
682+ },
683+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
684+ "version": "4.63.2",
685+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.2.tgz",
686+ "integrity": "sha512-zkgL2xff6i7u5hau/m6FGeS8gRkLEdgLw522WGmdWWlLd9btmNl3S80mcEjtGq+kvgUekQ3+BOYLLLcPlS2LIA==",
687+ "cpu": [
688+ "ppc64"
689+ ],
690+ "dev": true,
691+ "libc": [
692+ "musl"
693+ ],
694+ "license": "MIT",
695+ "optional": true,
696+ "os": [
697+ "linux"
698+ ]
699+ },
700+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
701+ "version": "4.63.2",
702+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.2.tgz",
703+ "integrity": "sha512-qOheJomrkVCbbHFJ7L3J97cnhfogKqguAQphv26+3ZsAQIF1L19b+dArl//s8rjJHJLz9byykyM8NBP4nmSa1g==",
704+ "cpu": [
705+ "riscv64"
706+ ],
707+ "dev": true,
708+ "libc": [
709+ "glibc"
710+ ],
711+ "license": "MIT",
712+ "optional": true,
713+ "os": [
714+ "linux"
715+ ]
716+ },
717+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
718+ "version": "4.63.2",
719+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.2.tgz",
720+ "integrity": "sha512-XlxLD54wQhH3FciCgMofxBw27NzUe818gJH410qWvc41UT0ZFcgxVjyX5/EK8MPTupjeVWqN5oy+9pCA9mqfCA==",
721+ "cpu": [
722+ "riscv64"
723+ ],
724+ "dev": true,
725+ "libc": [
726+ "musl"
727+ ],
728+ "license": "MIT",
729+ "optional": true,
730+ "os": [
731+ "linux"
732+ ]
733+ },
734+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
735+ "version": "4.63.2",
736+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.2.tgz",
737+ "integrity": "sha512-vdryWeRb2bLJZf0Fv/W8se6nvsHe2PkTCxV0meheK3nQE+G90VCJcke51Miy1yQRsfm2uqIyjXOu4wmUzbTtkQ==",
738+ "cpu": [
739+ "s390x"
740+ ],
741+ "dev": true,
742+ "libc": [
743+ "glibc"
744+ ],
745+ "license": "MIT",
746+ "optional": true,
747+ "os": [
748+ "linux"
749+ ]
750+ },
751+ "node_modules/@rollup/rollup-linux-x64-gnu": {
752+ "version": "4.63.2",
753+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.2.tgz",
754+ "integrity": "sha512-bcq2h2pkKmH2po4cZV8VWzO4lL40STyu/nLoFpYMQp9C2tCVNTdcVv86MwSsn3D5s1FBe2Ty1atqvVAUTMimNg==",
755+ "cpu": [
756+ "x64"
757+ ],
758+ "dev": true,
759+ "libc": [
760+ "glibc"
761+ ],
762+ "license": "MIT",
763+ "optional": true,
764+ "os": [
765+ "linux"
766+ ]
767+ },
768+ "node_modules/@rollup/rollup-linux-x64-musl": {
769+ "version": "4.63.2",
770+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.2.tgz",
771+ "integrity": "sha512-EGoo5DMVMRkTId8fuTDaoxVlR5ZTsKULUezRjd9gCw5eeY+DjCvDpZAOlNUvKPGX+7rS1RWx6j+yOpNPx0cUgQ==",
772+ "cpu": [
773+ "x64"
774+ ],
775+ "dev": true,
776+ "libc": [
777+ "musl"
778+ ],
779+ "license": "MIT",
780+ "optional": true,
781+ "os": [
782+ "linux"
783+ ]
784+ },
785+ "node_modules/@rollup/rollup-openbsd-x64": {
786+ "version": "4.63.2",
787+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.2.tgz",
788+ "integrity": "sha512-MErl12k7BFHZG1TI9QF/3lSSZARzq9KgNy/FjnqFMCkv+N4RSSzoUCA5h2mqHX4Mox3WaTVKblyzhQ1zRb2ZuQ==",
789+ "cpu": [
790+ "x64"
791+ ],
792+ "dev": true,
793+ "license": "MIT",
794+ "optional": true,
795+ "os": [
796+ "openbsd"
797+ ]
798+ },
799+ "node_modules/@rollup/rollup-openharmony-arm64": {
800+ "version": "4.63.2",
801+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.2.tgz",
802+ "integrity": "sha512-ILs8k07Wh4p0PsNY4wYLEaXZKMOpVhrG5QDB0yHhGhuzOfDlnyHN6sflL4El/MpUP1y8uY2lUZrv4oBS6pTT3g==",
803+ "cpu": [
804+ "arm64"
805+ ],
806+ "dev": true,
807+ "license": "MIT",
808+ "optional": true,
809+ "os": [
810+ "openharmony"
811+ ]
812+ },
813+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
814+ "version": "4.63.2",
815+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.2.tgz",
816+ "integrity": "sha512-hKgB3nz/TKD3Wv78XEsyXzQsNjvhOHmwKQTvXADGOyU/cIClZDO7DsoggbdmJDPGp5V80tA3Vfv61PaKTLH3LA==",
817+ "cpu": [
818+ "arm64"
819+ ],
820+ "dev": true,
821+ "license": "MIT",
822+ "optional": true,
823+ "os": [
824+ "win32"
825+ ]
826+ },
827+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
828+ "version": "4.63.2",
829+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.2.tgz",
830+ "integrity": "sha512-T4wf1mudIDxN8Q/CWIBJC1u5gQUc+r5mPvlwoSbIvNkyVTP2TAFeobEmst5AQ4gMyAz4sSByVdoTDfvTmGK/8g==",
831+ "cpu": [
832+ "ia32"
833+ ],
834+ "dev": true,
835+ "license": "MIT",
836+ "optional": true,
837+ "os": [
838+ "win32"
839+ ]
840+ },
841+ "node_modules/@rollup/rollup-win32-x64-gnu": {
842+ "version": "4.63.2",
843+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.2.tgz",
844+ "integrity": "sha512-tC3IY7qoaD9Ll3/8WJQn49j5V2f/NuI9S41NOE2iM5MPs3sPIvOkVToLcz/7Bz4pyF7PSvrtwu8I/pUrGOSecQ==",
845+ "cpu": [
846+ "x64"
847+ ],
848+ "dev": true,
849+ "license": "MIT",
850+ "optional": true,
851+ "os": [
852+ "win32"
853+ ]
854+ },
855+ "node_modules/@rollup/rollup-win32-x64-msvc": {
856+ "version": "4.63.2",
857+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.2.tgz",
858+ "integrity": "sha512-6NHnk/K3eq2ZFYcU1X8g67s9qIJRCOTT92gwLMVBp08dB2uuuwI1/Q/empzL2Bfr2f2WRLJVwpp90RmacQyFkw==",
859+ "cpu": [
860+ "x64"
861+ ],
862+ "dev": true,
863+ "license": "MIT",
864+ "optional": true,
865+ "os": [
866+ "win32"
867+ ]
868+ },
869+ "node_modules/@types/estree": {
870+ "version": "1.0.9",
871+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
872+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
873+ "dev": true,
874+ "license": "MIT"
875+ },
876+ "node_modules/@types/node": {
877+ "version": "24.13.4",
878+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz",
879+ "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==",
880+ "dev": true,
881+ "license": "MIT",
882+ "dependencies": {
883+ "undici-types": "~7.18.0"
884+ }
885+ },
886+ "node_modules/@webgpu/types": {
887+ "version": "0.1.72",
888+ "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.72.tgz",
889+ "integrity": "sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw==",
890+ "dev": true,
891+ "license": "BSD-3-Clause"
892+ },
893+ "node_modules/esbuild": {
894+ "version": "0.25.12",
895+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
896+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
897+ "dev": true,
898+ "hasInstallScript": true,
899+ "license": "MIT",
900+ "bin": {
901+ "esbuild": "bin/esbuild"
902+ },
903+ "engines": {
904+ "node": ">=18"
905+ },
906+ "optionalDependencies": {
907+ "@esbuild/aix-ppc64": "0.25.12",
908+ "@esbuild/android-arm": "0.25.12",
909+ "@esbuild/android-arm64": "0.25.12",
910+ "@esbuild/android-x64": "0.25.12",
911+ "@esbuild/darwin-arm64": "0.25.12",
912+ "@esbuild/darwin-x64": "0.25.12",
913+ "@esbuild/freebsd-arm64": "0.25.12",
914+ "@esbuild/freebsd-x64": "0.25.12",
915+ "@esbuild/linux-arm": "0.25.12",
916+ "@esbuild/linux-arm64": "0.25.12",
917+ "@esbuild/linux-ia32": "0.25.12",
918+ "@esbuild/linux-loong64": "0.25.12",
919+ "@esbuild/linux-mips64el": "0.25.12",
920+ "@esbuild/linux-ppc64": "0.25.12",
921+ "@esbuild/linux-riscv64": "0.25.12",
922+ "@esbuild/linux-s390x": "0.25.12",
923+ "@esbuild/linux-x64": "0.25.12",
924+ "@esbuild/netbsd-arm64": "0.25.12",
925+ "@esbuild/netbsd-x64": "0.25.12",
926+ "@esbuild/openbsd-arm64": "0.25.12",
927+ "@esbuild/openbsd-x64": "0.25.12",
928+ "@esbuild/openharmony-arm64": "0.25.12",
929+ "@esbuild/sunos-x64": "0.25.12",
930+ "@esbuild/win32-arm64": "0.25.12",
931+ "@esbuild/win32-ia32": "0.25.12",
932+ "@esbuild/win32-x64": "0.25.12"
933+ }
934+ },
935+ "node_modules/fdir": {
936+ "version": "6.5.0",
937+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
938+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
939+ "dev": true,
940+ "license": "MIT",
941+ "engines": {
942+ "node": ">=12.0.0"
943+ },
944+ "peerDependencies": {
945+ "picomatch": "^3 || ^4"
946+ },
947+ "peerDependenciesMeta": {
948+ "picomatch": {
949+ "optional": true
950+ }
951+ }
952+ },
953+ "node_modules/fsevents": {
954+ "version": "2.3.3",
955+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
956+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
957+ "dev": true,
958+ "hasInstallScript": true,
959+ "license": "MIT",
960+ "optional": true,
961+ "os": [
962+ "darwin"
963+ ],
964+ "engines": {
965+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
966+ }
967+ },
968+ "node_modules/nanoid": {
969+ "version": "3.3.19",
970+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
971+ "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
972+ "dev": true,
973+ "funding": [
974+ {
975+ "type": "github",
976+ "url": "https://github.com/sponsors/ai"
977+ }
978+ ],
979+ "license": "MIT",
980+ "bin": {
981+ "nanoid": "bin/nanoid.cjs"
982+ },
983+ "engines": {
984+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
985+ }
986+ },
987+ "node_modules/picocolors": {
988+ "version": "1.1.1",
989+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
990+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
991+ "dev": true,
992+ "license": "ISC"
993+ },
994+ "node_modules/picomatch": {
995+ "version": "4.0.7",
996+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
997+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
998+ "dev": true,
999+ "license": "MIT",
1000+ "engines": {
1001+ "node": ">=12"
1002+ },
1003+ "funding": {
1004+ "url": "https://github.com/sponsors/jonschlinkert"
1005+ }
1006+ },
1007+ "node_modules/playwright": {
1008+ "version": "1.63.0",
1009+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
1010+ "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
1011+ "dev": true,
1012+ "license": "Apache-2.0",
1013+ "dependencies": {
1014+ "playwright-core": "1.63.0"
1015+ },
1016+ "bin": {
1017+ "playwright": "cli.js"
1018+ },
1019+ "engines": {
1020+ "node": ">=20"
1021+ }
1022+ },
1023+ "node_modules/playwright-core": {
1024+ "version": "1.63.0",
1025+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
1026+ "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
1027+ "dev": true,
1028+ "license": "Apache-2.0",
1029+ "bin": {
1030+ "playwright-core": "cli.js"
1031+ },
1032+ "engines": {
1033+ "node": ">=20"
1034+ }
1035+ },
1036+ "node_modules/postcss": {
1037+ "version": "8.5.28",
1038+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
1039+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
1040+ "dev": true,
1041+ "funding": [
1042+ {
1043+ "type": "opencollective",
1044+ "url": "https://opencollective.com/postcss/"
1045+ },
1046+ {
1047+ "type": "tidelift",
1048+ "url": "https://tidelift.com/funding/github/npm/postcss"
1049+ },
1050+ {
1051+ "type": "github",
1052+ "url": "https://github.com/sponsors/ai"
1053+ }
1054+ ],
1055+ "license": "MIT",
1056+ "dependencies": {
1057+ "nanoid": "^3.3.18",
1058+ "picocolors": "^1.1.1",
1059+ "source-map-js": "^1.2.1"
1060+ },
1061+ "engines": {
1062+ "node": "^10 || ^12 || >=14"
1063+ }
1064+ },
1065+ "node_modules/rollup": {
1066+ "version": "4.63.2",
1067+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.2.tgz",
1068+ "integrity": "sha512-l5eyksV4tPBj6lJyEa37YzIOCSOV7lkZzEHUdpjWZbtD7wTcFYmEYXSgm5bT4vV+dZLb9rBG1W9GROOG4NS4Ew==",
1069+ "dev": true,
1070+ "license": "MIT",
1071+ "dependencies": {
1072+ "@types/estree": "1.0.9"
1073+ },
1074+ "bin": {
1075+ "rollup": "dist/bin/rollup"
1076+ },
1077+ "engines": {
1078+ "node": ">=18.0.0",
1079+ "npm": ">=8.0.0"
1080+ },
1081+ "optionalDependencies": {
1082+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
1083+ "@rollup/rollup-android-arm-eabi": "4.63.2",
1084+ "@rollup/rollup-android-arm64": "4.63.2",
1085+ "@rollup/rollup-darwin-arm64": "4.63.2",
1086+ "@rollup/rollup-darwin-x64": "4.63.2",
1087+ "@rollup/rollup-freebsd-arm64": "4.63.2",
1088+ "@rollup/rollup-freebsd-x64": "4.63.2",
1089+ "@rollup/rollup-linux-arm-gnueabihf": "4.63.2",
1090+ "@rollup/rollup-linux-arm-musleabihf": "4.63.2",
1091+ "@rollup/rollup-linux-arm64-gnu": "4.63.2",
1092+ "@rollup/rollup-linux-arm64-musl": "4.63.2",
1093+ "@rollup/rollup-linux-loong64-gnu": "4.63.2",
1094+ "@rollup/rollup-linux-loong64-musl": "4.63.2",
1095+ "@rollup/rollup-linux-ppc64-gnu": "4.63.2",
1096+ "@rollup/rollup-linux-ppc64-musl": "4.63.2",
1097+ "@rollup/rollup-linux-riscv64-gnu": "4.63.2",
1098+ "@rollup/rollup-linux-riscv64-musl": "4.63.2",
1099+ "@rollup/rollup-linux-s390x-gnu": "4.63.2",
1100+ "@rollup/rollup-linux-x64-gnu": "4.63.2",
1101+ "@rollup/rollup-linux-x64-musl": "4.63.2",
1102+ "@rollup/rollup-openbsd-x64": "4.63.2",
1103+ "@rollup/rollup-openharmony-arm64": "4.63.2",
1104+ "@rollup/rollup-win32-arm64-msvc": "4.63.2",
1105+ "@rollup/rollup-win32-ia32-msvc": "4.63.2",
1106+ "@rollup/rollup-win32-x64-gnu": "4.63.2",
1107+ "@rollup/rollup-win32-x64-msvc": "4.63.2",
1108+ "fsevents": "~2.3.2"
1109+ }
1110+ },
1111+ "node_modules/source-map-js": {
1112+ "version": "1.2.1",
1113+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1114+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1115+ "dev": true,
1116+ "license": "BSD-3-Clause",
1117+ "engines": {
1118+ "node": ">=0.10.0"
1119+ }
1120+ },
1121+ "node_modules/tinyglobby": {
1122+ "version": "0.2.17",
1123+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
1124+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
1125+ "dev": true,
1126+ "license": "MIT",
1127+ "dependencies": {
1128+ "fdir": "^6.5.0",
1129+ "picomatch": "^4.0.4"
1130+ },
1131+ "engines": {
1132+ "node": ">=12.0.0"
1133+ },
1134+ "funding": {
1135+ "url": "https://github.com/sponsors/SuperchupuDev"
1136+ }
1137+ },
1138+ "node_modules/typescript": {
1139+ "version": "5.9.3",
1140+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
1141+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1142+ "dev": true,
1143+ "license": "Apache-2.0",
1144+ "bin": {
1145+ "tsc": "bin/tsc",
1146+ "tsserver": "bin/tsserver"
1147+ },
1148+ "engines": {
1149+ "node": ">=14.17"
1150+ }
1151+ },
1152+ "node_modules/undici-types": {
1153+ "version": "7.18.2",
1154+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
1155+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
1156+ "dev": true,
1157+ "license": "MIT"
1158+ },
1159+ "node_modules/vite": {
1160+ "version": "6.4.3",
1161+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
1162+ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
1163+ "dev": true,
1164+ "license": "MIT",
1165+ "dependencies": {
1166+ "esbuild": "^0.25.0",
1167+ "fdir": "^6.4.4",
1168+ "picomatch": "^4.0.2",
1169+ "postcss": "^8.5.3",
1170+ "rollup": "^4.34.9",
1171+ "tinyglobby": "^0.2.13"
1172+ },
1173+ "bin": {
1174+ "vite": "bin/vite.js"
1175+ },
1176+ "engines": {
1177+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
1178+ },
1179+ "funding": {
1180+ "url": "https://github.com/vitejs/vite?sponsor=1"
1181+ },
1182+ "optionalDependencies": {
1183+ "fsevents": "~2.3.3"
1184+ },
1185+ "peerDependencies": {
1186+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
1187+ "jiti": ">=1.21.0",
1188+ "less": "*",
1189+ "lightningcss": "^1.21.0",
1190+ "sass": "*",
1191+ "sass-embedded": "*",
1192+ "stylus": "*",
1193+ "sugarss": "*",
1194+ "terser": "^5.16.0",
1195+ "tsx": "^4.8.1",
1196+ "yaml": "^2.4.2"
1197+ },
1198+ "peerDependenciesMeta": {
1199+ "@types/node": {
1200+ "optional": true
1201+ },
1202+ "jiti": {
1203+ "optional": true
1204+ },
1205+ "less": {
1206+ "optional": true
1207+ },
1208+ "lightningcss": {
1209+ "optional": true
1210+ },
1211+ "sass": {
1212+ "optional": true
1213+ },
1214+ "sass-embedded": {
1215+ "optional": true
1216+ },
1217+ "stylus": {
1218+ "optional": true
1219+ },
1220+ "sugarss": {
1221+ "optional": true
1222+ },
1223+ "terser": {
1224+ "optional": true
1225+ },
1226+ "tsx": {
1227+ "optional": true
1228+ },
1229+ "yaml": {
1230+ "optional": true
1231+ }
1232+ }
1233+ }
1234+ }
1235+}
package.jsonadded+22−0View file
@@ -0,0 +1,22 @@
1+{
2+ "name": "steelsim41",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "description": "Browser simulation of a struck cymbal designed by a script: geometrically nonlinear plate (Foppl-von Karman) with variable thickness, stepped explicitly with WebGPU.",
7+ "scripts": {
8+ "dev": "vite",
9+ "build": "tsc --noEmit && vite build",
10+ "preview": "vite preview",
11+ "test": "node --test tests/*.test.ts",
12+ "test:cpu": "node --test tests/cpuRef.test.ts tests/design.test.ts",
13+ "test:gpu": "node --test tests/gpu.test.ts"
14+ },
15+ "devDependencies": {
16+ "@types/node": "^24.0.0",
17+ "@webgpu/types": "^0.1.60",
18+ "playwright": "^1.50.0",
19+ "typescript": "^5.6.0",
20+ "vite": "^6.0.0"
21+ }
22+}
src/audio/fft.tsadded+46−0View file
@@ -0,0 +1,46 @@
1+// In-place radix-2 complex FFT.
2+
3+export class FFT {
4+ private cos: Float32Array;
5+ private sin: Float32Array;
6+ private rev: Uint32Array;
7+ constructor(readonly n: number) {
8+ if (n & (n - 1)) throw new Error("FFT size must be a power of two");
9+ this.cos = new Float32Array(n / 2);
10+ this.sin = new Float32Array(n / 2);
11+ for (let k = 0; k < n / 2; k++) {
12+ this.cos[k] = Math.cos((2 * Math.PI * k) / n);
13+ this.sin[k] = -Math.sin((2 * Math.PI * k) / n);
14+ }
15+ this.rev = new Uint32Array(n);
16+ const bits = Math.log2(n);
17+ for (let i = 0; i < n; i++) {
18+ let r = 0;
19+ for (let b = 0; b < bits; b++) r |= ((i >> b) & 1) << (bits - 1 - b);
20+ this.rev[i] = r;
21+ }
22+ }
23+ transform(re: Float32Array, im: Float32Array): void {
24+ const n = this.n;
25+ for (let i = 0; i < n; i++) {
26+ const r = this.rev[i];
27+ if (r > i) {
28+ let t = re[i]; re[i] = re[r]; re[r] = t;
29+ t = im[i]; im[i] = im[r]; im[r] = t;
30+ }
31+ }
32+ for (let size = 2; size <= n; size <<= 1) {
33+ const half = size >> 1, step = n / size;
34+ for (let start = 0; start < n; start += size) {
35+ for (let k = 0; k < half; k++) {
36+ const c = this.cos[k * step], s = this.sin[k * step];
37+ const a = start + k, b = a + half;
38+ const tr = re[b] * c - im[b] * s;
39+ const ti = re[b] * s + im[b] * c;
40+ re[b] = re[a] - tr; im[b] = im[a] - ti;
41+ re[a] += tr; im[a] += ti;
42+ }
43+ }
44+ }
45+ }
46+}
src/audio/level.tsadded+43−0View file
@@ -0,0 +1,43 @@
1+// Playback level: absolute (fixed pascal-to-full-scale mapping with a user gain),
2+// loudness normalization, or peak normalization. A soft clipper bounds the output.
3+
4+export type LevelMode = "absolute" | "loudness" | "peak";
5+
6+/** Linear gain applied to the pressure signal [Pa] for the given mode. */
7+export function levelGain(samples: Float32Array, fs: number, mode: LevelMode, gainDb: number, peak = 0.95): number {
8+ if (samples.length === 0) return 0;
9+ if (mode === "absolute") {
10+ // 0 dBFS corresponds to 1 Pa at 0 dB gain.
11+ return 10 ** (gainDb / 20);
12+ }
13+ if (mode === "peak") {
14+ let max = 0;
15+ for (let k = 0; k < samples.length; k++) max = Math.max(max, Math.abs(samples[k]));
16+ return max > 0 ? peak / max : 0;
17+ }
18+ // Loudness: scale the RMS of the sustained part (after the first 50 ms) to -16 dBFS.
19+ const start = Math.min(samples.length - 1, Math.round(0.05 * fs));
20+ let sum = 0;
21+ for (let k = start; k < samples.length; k++) sum += samples[k] * samples[k];
22+ const rms = Math.sqrt(sum / Math.max(1, samples.length - start));
23+ return rms > 0 ? 10 ** (-16 / 20) / rms : 0;
24+}
25+
26+/** Peak level [dBFS] the signal would reach before clipping. */
27+export function peakDbfs(samples: Float32Array, gain: number): number {
28+ let max = 0;
29+ for (let k = 0; k < samples.length; k++) max = Math.max(max, Math.abs(samples[k]));
30+ return max > 0 ? 20 * Math.log10(max * gain) : -Infinity;
31+}
32+
33+/** Returns a copy of `samples` scaled for playback or export, soft-clipped to [-peak, peak]. */
34+export function normalize(samples: Float32Array, fs: number, mode: LevelMode, gainDb: number, peak = 0.95): Float32Array {
35+ const out = new Float32Array(samples.length);
36+ const g = levelGain(samples, fs, mode, gainDb, peak);
37+ if (mode === "peak") {
38+ for (let k = 0; k < samples.length; k++) out[k] = samples[k] * g;
39+ return out;
40+ }
41+ for (let k = 0; k < samples.length; k++) out[k] = peak * Math.tanh((samples[k] * g) / peak);
42+ return out;
43+}
src/audio/player.tsadded+54−0View file
@@ -0,0 +1,54 @@
1+// Web Audio playback of a Float32Array signal with position reporting.
2+
3+import { normalize, type LevelMode } from "./level.ts";
4+
5+export class Player {
6+ private ctx: AudioContext | null = null;
7+ private source: AudioBufferSourceNode | null = null;
8+ private startedAt = 0;
9+ private offset = 0;
10+ private length = 0;
11+ onEnded: (() => void) | null = null;
12+
13+ get playing(): boolean {
14+ return this.source !== null;
15+ }
16+
17+ /** Current playback position [s], or null when stopped. */
18+ position(): number | null {
19+ if (!this.source || !this.ctx) return null;
20+ return Math.min(this.length, this.offset + this.ctx.currentTime - this.startedAt);
21+ }
22+
23+ play(samples: Float32Array, fs: number, offset = 0, mode: LevelMode = "peak", gainDb = 0): void {
24+ this.stop();
25+ if (samples.length === 0) return;
26+ if (!this.ctx) this.ctx = new AudioContext({ sampleRate: fs });
27+ const ctx = this.ctx;
28+ const buffer = ctx.createBuffer(1, samples.length, fs);
29+ buffer.getChannelData(0).set(normalize(samples, fs, mode, gainDb, 0.9));
30+ const src = ctx.createBufferSource();
31+ src.buffer = buffer;
32+ src.connect(ctx.destination);
33+ src.onended = () => {
34+ if (this.source === src) {
35+ this.source = null;
36+ this.onEnded?.();
37+ }
38+ };
39+ this.length = samples.length / fs;
40+ this.offset = Math.max(0, Math.min(offset, this.length));
41+ this.startedAt = ctx.currentTime;
42+ this.source = src;
43+ src.start(0, this.offset);
44+ void ctx.resume();
45+ }
46+
47+ stop(): void {
48+ if (this.source) {
49+ const s = this.source;
50+ this.source = null;
51+ try { s.stop(); } catch { /* already stopped */ }
52+ }
53+ }
54+}
src/audio/spectrogram.tsadded+233−0View file
@@ -0,0 +1,233 @@
1+// Incremental STFT spectrogram drawn on a 2D canvas.
2+
3+import { FFT } from "./fft.ts";
4+
5+export interface SpectrogramOptions {
6+ windowSize: number;
7+ hop: number;
8+ fmax: number; // Hz
9+ dbFloor: number; // dB below the peak
10+ logFrequency: boolean;
11+}
12+
13+export class Spectrogram {
14+ private fft: FFT;
15+ private window: Float32Array;
16+ private re: Float32Array;
17+ private im: Float32Array;
18+ /** Magnitude (dB) columns, each of length windowSize / 2. */
19+ private columns: Float32Array[] = [];
20+ private consumed = 0;
21+ private peakDb = -Infinity;
22+ private opts: SpectrogramOptions;
23+
24+ constructor(opts: SpectrogramOptions) {
25+ this.opts = opts;
26+ this.fft = new FFT(opts.windowSize);
27+ this.window = new Float32Array(opts.windowSize);
28+ for (let k = 0; k < opts.windowSize; k++) this.window[k] = 0.5 - 0.5 * Math.cos((2 * Math.PI * k) / opts.windowSize);
29+ this.re = new Float32Array(opts.windowSize);
30+ this.im = new Float32Array(opts.windowSize);
31+ }
32+
33+ get options(): SpectrogramOptions {
34+ return this.opts;
35+ }
36+
37+ /** Changes options; recomputes from scratch when the STFT parameters change. */
38+ configure(opts: Partial<SpectrogramOptions>, audio: Float32Array, nSamples: number): void {
39+ const next = { ...this.opts, ...opts };
40+ const recompute = next.windowSize !== this.opts.windowSize || next.hop !== this.opts.hop;
41+ this.opts = next;
42+ if (recompute) {
43+ this.fft = new FFT(next.windowSize);
44+ this.window = new Float32Array(next.windowSize);
45+ for (let k = 0; k < next.windowSize; k++) this.window[k] = 0.5 - 0.5 * Math.cos((2 * Math.PI * k) / next.windowSize);
46+ this.re = new Float32Array(next.windowSize);
47+ this.im = new Float32Array(next.windowSize);
48+ this.reset();
49+ this.update(audio, nSamples);
50+ }
51+ }
52+
53+ reset(): void {
54+ this.columns = [];
55+ this.consumed = 0;
56+ this.peakDb = -Infinity;
57+ }
58+
59+ /** Adds columns for all complete windows in audio[0..nSamples). */
60+ update(audio: Float32Array, nSamples: number): void {
61+ const { windowSize, hop } = this.opts;
62+ while (this.consumed + windowSize <= nSamples) {
63+ for (let k = 0; k < windowSize; k++) {
64+ this.re[k] = audio[this.consumed + k] * this.window[k];
65+ this.im[k] = 0;
66+ }
67+ this.fft.transform(this.re, this.im);
68+ const col = new Float32Array(windowSize / 2);
69+ for (let k = 0; k < windowSize / 2; k++) {
70+ const mag2 = this.re[k] * this.re[k] + this.im[k] * this.im[k];
71+ const db = 10 * Math.log10(mag2 + 1e-30);
72+ col[k] = db;
73+ if (db > this.peakDb) this.peakDb = db;
74+ }
75+ this.columns.push(col);
76+ this.consumed += hop;
77+ }
78+ }
79+
80+ get columnCount(): number {
81+ return this.columns.length;
82+ }
83+
84+ private imageKey = "";
85+ private image: ImageData | null = null;
86+ private drawKey = "";
87+
88+ /** Forces the next draw to recompute everything. */
89+ invalidate(): void {
90+ this.imageKey = "";
91+ this.drawKey = "";
92+ }
93+
94+ /**
95+ * Draws the spectrogram; `duration` [s] fixes the time axis, `playhead` [s] draws a cursor.
96+ * The plot image is cached and only recomputed when the data, options, or size change,
97+ * and nothing is drawn at all when the previous frame is still current.
98+ */
99+ draw(canvas: HTMLCanvasElement, fs: number, duration: number, playhead: number | null): void {
100+ const dpr = window.devicePixelRatio || 1;
101+ const W = Math.max(1, Math.floor(canvas.clientWidth * dpr));
102+ const H = Math.max(1, Math.floor(canvas.clientHeight * dpr));
103+ const o = this.opts;
104+ const imageKey = `${W}|${H}|${this.columns.length}|${duration}|${fs}|${o.windowSize}|${o.hop}|${o.fmax}|${o.dbFloor}|${o.logFrequency}`;
105+ const drawKey = `${imageKey}|${playhead === null ? "-" : playhead.toFixed(4)}`;
106+ if (drawKey === this.drawKey) return;
107+ this.drawKey = drawKey;
108+ if (canvas.width !== W || canvas.height !== H) {
109+ canvas.width = W;
110+ canvas.height = H;
111+ }
112+ const ctx = canvas.getContext("2d")!;
113+ // Whole pixels throughout: a fractional device pixel ratio would otherwise
114+ // make the ImageData and the pixel buffer disagree in size.
115+ const left = Math.round(52 * dpr), bottom = Math.round(22 * dpr), top = Math.round(6 * dpr), right = Math.round(8 * dpr);
116+ const pw = Math.max(1, Math.floor(W - left - right)), ph = Math.max(1, Math.floor(H - top - bottom));
117+ ctx.fillStyle = "#1c1e22";
118+ ctx.fillRect(0, 0, W, H);
119+ const { fmax, logFrequency } = this.opts;
120+ const fmin = logFrequency ? 20 : 0;
121+ const logMin = Math.log(fmin || 1), logMax = Math.log(fmax);
122+ if (imageKey !== this.imageKey || !this.image) {
123+ this.image = this.renderImage(ctx, pw, ph, fs, duration);
124+ this.imageKey = imageKey;
125+ }
126+ ctx.putImageData(this.image, left, top);
127+ this.drawAxes(ctx, dpr, left, top, pw, ph, fmin, fmax, logMin, logMax, logFrequency, duration);
128+ if (playhead !== null && playhead >= 0 && playhead <= duration) {
129+ const x = left + (playhead / duration) * pw;
130+ ctx.fillStyle = "rgba(255,255,255,0.8)";
131+ ctx.fillRect(x, top, Math.max(1, dpr), ph);
132+ }
133+ }
134+
135+ private renderImage(ctx: CanvasRenderingContext2D, pw: number, ph: number, fs: number, duration: number): ImageData {
136+ const img = ctx.createImageData(pw, ph);
137+ img.data.set(spectrogramPixels(this.columns, this.peakDb, this.opts, fs, duration, pw, ph));
138+ return img;
139+ }
140+
141+ /** STFT columns (dB) and their peak, for renderers outside the browser. */
142+ get data(): { columns: Float32Array[]; peakDb: number } {
143+ return { columns: this.columns, peakDb: this.peakDb };
144+ }
145+
146+ private drawAxes(ctx: CanvasRenderingContext2D, dpr: number, left: number, top: number, pw: number, ph: number, fmin: number, fmax: number, logMin: number, logMax: number, logFrequency: boolean, duration: number): void {
147+ ctx.fillStyle = "#c8cbd0";
148+ ctx.font = `${11 * dpr}px system-ui, sans-serif`;
149+ ctx.textAlign = "right";
150+ ctx.textBaseline = "middle";
151+ const fticks = logFrequency ? [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000].filter((f) => f >= fmin && f <= fmax) : niceTicks(0, fmax, 6);
152+ for (const f of fticks) {
153+ const frac = logFrequency ? (Math.log(f) - logMin) / (logMax - logMin) : (f - fmin) / (fmax - fmin);
154+ const y = top + (1 - frac) * ph;
155+ ctx.fillText(f >= 1000 ? `${f / 1000}k` : `${f}`, left - 4 * dpr, y);
156+ ctx.fillRect(left - 3 * dpr, y, 3 * dpr, 1);
157+ }
158+ ctx.textAlign = "center";
159+ ctx.textBaseline = "top";
160+ for (const t of niceTicks(0, duration, 8)) {
161+ const x = left + (t / duration) * pw;
162+ ctx.fillText(`${+t.toFixed(2)}`, x, top + ph + 4 * dpr);
163+ ctx.fillRect(x, top + ph, 1, 3 * dpr);
164+ }
165+ ctx.save();
166+ ctx.translate(12 * dpr, top + ph / 2);
167+ ctx.rotate(-Math.PI / 2);
168+ ctx.textBaseline = "middle";
169+ ctx.fillText("Hz", 0, 0);
170+ ctx.restore();
171+ }
172+}
173+
174+/** RGBA pixels (row-major, top row first) of the spectrogram plot area. */
175+export function spectrogramPixels(columns: Float32Array[], peakDb: number, opts: SpectrogramOptions, fs: number, duration: number, pw: number, ph: number, peakRef?: number): Uint8ClampedArray {
176+ const { windowSize, hop, fmax, dbFloor, logFrequency } = opts;
177+ const binHz = fs / windowSize;
178+ const fmin = logFrequency ? 20 : 0;
179+ const data = new Uint8ClampedArray(pw * ph * 4);
180+ const cols = columns.length;
181+ const colPerPx = (duration * fs) / hop / pw;
182+ const logMin = Math.log(fmin || 1), logMax = Math.log(fmax);
183+ const ref = peakRef ?? peakDb;
184+ for (let py = 0; py < ph; py++) {
185+ const frac = 1 - py / ph;
186+ const f = logFrequency ? Math.exp(logMin + frac * (logMax - logMin)) : fmin + frac * (fmax - fmin);
187+ const bin = Math.min(windowSize / 2 - 1, Math.max(0, Math.round(f / binHz)));
188+ for (let px = 0; px < pw; px++) {
189+ const c0 = Math.floor(px * colPerPx);
190+ const c1 = Math.max(c0 + 1, Math.floor((px + 1) * colPerPx));
191+ let v = -Infinity;
192+ for (let c = c0; c < c1 && c < cols; c++) v = Math.max(v, columns[c][bin]);
193+ const o = (py * pw + px) * 4;
194+ if (c0 >= cols) {
195+ data[o] = 28; data[o + 1] = 30; data[o + 2] = 34; data[o + 3] = 255;
196+ continue;
197+ }
198+ const t = ref > -200 ? Math.max(0, Math.min(1, (v - ref + dbFloor) / dbFloor)) : 0;
199+ const [r, g, b] = inferno(t);
200+ data[o] = r; data[o + 1] = g; data[o + 2] = b; data[o + 3] = 255;
201+ }
202+ }
203+ return data;
204+}
205+
206+function niceTicks(lo: number, hi: number, count: number): number[] {
207+ const span = hi - lo;
208+ if (span <= 0) return [lo];
209+ const raw = span / count;
210+ const mag = 10 ** Math.floor(Math.log10(raw));
211+ const norm = raw / mag;
212+ const step = (norm < 1.5 ? 1 : norm < 3.5 ? 2 : norm < 7.5 ? 5 : 10) * mag;
213+ const ticks: number[] = [];
214+ for (let t = Math.ceil(lo / step) * step; t <= hi + 1e-9; t += step) ticks.push(t);
215+ return ticks;
216+}
217+
218+// Compact polynomial approximation of the inferno colormap.
219+function inferno(t: number): [number, number, number] {
220+ const c0 = [0.0002189403691192265, 0.001651004631001012, -0.01948089843709184];
221+ const c1 = [0.1065134194856116, 0.5639564367884091, 3.932712388889277];
222+ const c2 = [11.60249308247187, -3.972853965665698, -15.9423940062914];
223+ const c3 = [-41.70399613139459, 17.43639888205313, 44.35414519872813];
224+ const c4 = [77.162935699427, -33.40235894210092, -81.80730925738993];
225+ const c5 = [-71.31942824499214, 32.62606426397723, 73.20951985803202];
226+ const c6 = [25.13112622477341, -12.24266895238567, -23.07032500287172];
227+ const out: number[] = [];
228+ for (let k = 0; k < 3; k++) {
229+ const v = c0[k] + t * (c1[k] + t * (c2[k] + t * (c3[k] + t * (c4[k] + t * (c5[k] + t * c6[k])))));
230+ out.push(Math.max(0, Math.min(255, Math.round(v * 255))));
231+ }
232+ return [out[0], out[1], out[2]];
233+}
src/audio/wav.tsadded+33−0View file
@@ -0,0 +1,33 @@
1+// 16-bit PCM mono WAV encoder.
2+
3+import { normalize, type LevelMode } from "./level.ts";
4+
5+/** Encodes the scaled signal as WAV bytes. */
6+export function encodeWavBytes(samples: Float32Array, fs: number, mode: LevelMode = "peak", gainDb = 0): Uint8Array<ArrayBuffer> {
7+ const scaled = normalize(samples, fs, mode, gainDb, 0.99);
8+ const buf = new ArrayBuffer(44 + samples.length * 2);
9+ const v = new DataView(buf);
10+ const str = (o: number, s: string) => { for (let k = 0; k < s.length; k++) v.setUint8(o + k, s.charCodeAt(k)); };
11+ str(0, "RIFF");
12+ v.setUint32(4, 36 + samples.length * 2, true);
13+ str(8, "WAVE");
14+ str(12, "fmt ");
15+ v.setUint32(16, 16, true);
16+ v.setUint16(20, 1, true);
17+ v.setUint16(22, 1, true);
18+ v.setUint32(24, fs, true);
19+ v.setUint32(28, fs * 2, true);
20+ v.setUint16(32, 2, true);
21+ v.setUint16(34, 16, true);
22+ str(36, "data");
23+ v.setUint32(40, samples.length * 2, true);
24+ for (let k = 0; k < samples.length; k++) {
25+ const s = Math.max(-1, Math.min(1, scaled[k]));
26+ v.setInt16(44 + 2 * k, Math.round(s * 32767), true);
27+ }
28+ return new Uint8Array(buf);
29+}
30+
31+export function encodeWav(samples: Float32Array, fs: number, mode: LevelMode = "peak", gainDb = 0): Blob {
32+ return new Blob([encodeWavBytes(samples, fs, mode, gainDb)], { type: "audio/wav" });
33+}
src/design.tsadded+284−0View file
@@ -0,0 +1,284 @@
1+// The cymbal design: a JavaScript script that returns an object describing
2+// the material, the bounding box, the outline, the thickness field, the
3+// stress-free profile, the mount, and the losses. The script runs in the page
4+// with a set of helper functions in scope (see HELPERS) and returns a plain
5+// object; compileDesign checks it and evaluatePlate samples the fields on a
6+// grid of the requested spacing.
7+//
8+// Lengths are meters throughout; `inch` and `mm` are available as constants.
9+// Every field function receives (x, y, r, theta): the point relative to the
10+// center of the bounding box, its distance from the center, and its angle
11+// from the x axis in radians.
12+
13+import {
14+ type Grid,
15+ type InsideFn,
16+ type Loss,
17+ type Material,
18+ type Mount,
19+ type Plate,
20+ insideNode,
21+ makeGrid,
22+ nodeWeight,
23+ nodeX,
24+ nodeY,
25+} from "./params.ts";
26+
27+export type PointFn = (x: number, y: number, r: number, theta: number) => number;
28+export type OutlineFn = (x: number, y: number, r: number, theta: number) => boolean;
29+
30+/** A compiled design: the script's object, checked and with defaults filled in. */
31+export interface Design {
32+ name: string;
33+ material: Material;
34+ Lx: number;
35+ Ly: number;
36+ inside: OutlineFn | null;
37+ thickness: PointFn;
38+ profile: PointFn;
39+ mount: Mount | null;
40+ loss: Loss;
41+}
42+
43+export const DEFAULT_LOSS: Loss = { sigma0: 1.15, sigma1: 5e-4, sigmaIn: 20 };
44+
45+// ---------- Helpers available to the script ----------
46+
47+/** Deterministic uniform generator on [0, 1) (mulberry32). */
48+function mulberry32(seed: number): () => number {
49+ let a = Math.floor(seed) >>> 0;
50+ return () => {
51+ a = (a + 0x6d2b79f5) >>> 0;
52+ let t = a;
53+ t = Math.imul(t ^ (t >>> 15), t | 1);
54+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
55+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
56+ };
57+}
58+
59+const NOISE_TERMS = 64;
60+
61+/**
62+ * A smooth random field of unit RMS with the covariance exp(-d^2 / length^2):
63+ * a sum of plane waves whose wavevectors are Gaussian with standard deviation
64+ * sqrt(2) / length per component. The same seed gives the same field on any grid.
65+ */
66+export function noise2d(seed: number, length: number): (x: number, y: number) => number {
67+ const rnd = mulberry32(seed);
68+ const gauss = () => Math.sqrt(-2 * Math.log(1 - rnd())) * Math.cos(2 * Math.PI * rnd());
69+ const L = Math.max(length, 1e-4);
70+ const terms = Array.from({ length: NOISE_TERMS }, () => ({ kx: (gauss() * Math.SQRT2) / L, ky: (gauss() * Math.SQRT2) / L, ph: 2 * Math.PI * rnd() }));
71+ const norm = 1 / Math.sqrt(NOISE_TERMS / 2);
72+ return (x, y) => {
73+ let v = 0;
74+ for (const t of terms) v += Math.cos(t.kx * x + t.ky * y + t.ph);
75+ return v * norm;
76+ };
77+}
78+
79+export const HELPERS = {
80+ inch: 0.0254,
81+ mm: 1e-3,
82+ /** Outline of a circle of the given diameter, with an optional center hole of diameter `hole`. */
83+ circle: (diameter: number, hole = 0): OutlineFn => (_x, _y, r) => r <= diameter / 2 && r >= hole / 2,
84+ /** Outline of the ellipse with the given width and height, with an optional center hole. */
85+ ellipse: (width: number, height: number, hole = 0): OutlineFn => (x, y, r) => (x / (width / 2)) ** 2 + (y / (height / 2)) ** 2 <= 1 && r >= hole / 2,
86+ /** Outline of the rectangle of the given width and height (centered), with an optional center hole. */
87+ rectangle: (width: number, height: number, hole = 0): OutlineFn => (x, y, r) => Math.abs(x) <= width / 2 && Math.abs(y) <= height / 2 && r >= hole / 2,
88+ clamp: (x: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, x)),
89+ lerp: (a: number, b: number, t: number): number => a + (b - a) * t,
90+ /** 0 below e0, 1 above e1, a smooth cubic between. */
91+ smoothstep: (e0: number, e1: number, x: number): number => {
92+ const t = Math.min(1, Math.max(0, (x - e0) / (e1 - e0)));
93+ return t * t * (3 - 2 * t);
94+ },
95+ /** Gaussian bump of the given height, falling to 1/e at `radius` from the center; `d` is the distance from the center. */
96+ bump: (d: number, radius: number, height: number): number => height * Math.exp(-(d * d) / (radius * radius)),
97+ /** Cosine dome of the given height and base radius (zero slope at the rim and the apex); `d` is the distance from the center. */
98+ dome: (d: number, radius: number, height: number): number => (d >= radius ? 0 : 0.5 * height * (1 + Math.cos((Math.PI * d) / radius))),
99+ /** Parabolic bow of the given sag across a span L centered on 0; `s` is the coordinate along the span; the offset in -1..1 moves the crest toward one end. */
100+ bow: (s: number, L: number, sag: number, offset = 0): number => {
101+ const t = (2 * s) / L;
102+ return sag * (1 - t * t) * (1 + offset * t);
103+ },
104+ noise2d,
105+};
106+
107+export type Helpers = typeof HELPERS;
108+
109+// ---------- Compilation ----------
110+
111+/** Message of an error thrown by the script or by the checks. */
112+export class DesignError extends Error {}
113+
114+function num(v: unknown, what: string, min = -Infinity): number {
115+ if (typeof v !== "number" || !isFinite(v)) throw new DesignError(`${what} must be a finite number`);
116+ if (v < min) throw new DesignError(`${what} must be at least ${min}`);
117+ return v;
118+}
119+
120+function fieldFn(v: unknown, what: string): PointFn {
121+ if (typeof v === "number") {
122+ if (!isFinite(v)) throw new DesignError(`${what} must be a finite number or a function`);
123+ return () => v;
124+ }
125+ if (typeof v === "function") return v as PointFn;
126+ throw new DesignError(`${what} must be a number or a function (x, y, r, theta) => number`);
127+}
128+
129+/**
130+ * Runs the script and checks the object it returns. The script body has the
131+ * helpers in scope as free variables and must `return` the design object.
132+ */
133+export function compileDesign(source: string): Design {
134+ let raw: unknown;
135+ try {
136+ const fn = new Function(...Object.keys(HELPERS), source) as (...args: unknown[]) => unknown;
137+ raw = fn(...Object.values(HELPERS));
138+ } catch (e) {
139+ throw new DesignError(`script error: ${e instanceof Error ? e.message : String(e)}`);
140+ }
141+ if (!raw || typeof raw !== "object") throw new DesignError("the script must return an object (end it with `return { ... };`)");
142+ const o = raw as Record<string, unknown>;
143+ const name = typeof o.name === "string" && o.name.trim() ? o.name.trim() : "Untitled design";
144+
145+ const m = o.material as Record<string, unknown> | undefined;
146+ if (!m || typeof m !== "object") throw new DesignError("material must be an object { E, nu, rho }");
147+ const material: Material = { E: num(m.E, "material.E", 1e6), nu: num(m.nu, "material.nu", 0), rho: num(m.rho, "material.rho", 1) };
148+ if (material.nu >= 0.5) throw new DesignError("material.nu must be below 0.5");
149+
150+ const b = o.bounds as Record<string, unknown> | undefined;
151+ if (!b || typeof b !== "object") throw new DesignError("bounds must be an object { width, height }");
152+ const Lx = num(b.width, "bounds.width", 1e-3), Ly = num(b.height, "bounds.height", 1e-3);
153+
154+ let inside: OutlineFn | null = null;
155+ if (o.inside !== undefined && o.inside !== null) {
156+ if (typeof o.inside !== "function") throw new DesignError("inside must be a function (x, y, r, theta) => boolean");
157+ inside = o.inside as OutlineFn;
158+ }
159+
160+ if (o.thickness === undefined) throw new DesignError("thickness is required (a number in meters, or a function)");
161+ const thickness = fieldFn(o.thickness, "thickness");
162+ const profile = o.profile === undefined || o.profile === null ? () => 0 : fieldFn(o.profile, "profile");
163+
164+ let mount: Mount | null = null;
165+ if (o.mount !== undefined && o.mount !== null && o.mount !== false) {
166+ const mo = o.mount as Record<string, unknown>;
167+ if (typeof mo !== "object") throw new DesignError("mount must be an object { x, y, radius, stiffness, damping } or null");
168+ mount = {
169+ x: mo.x === undefined ? 0 : num(mo.x, "mount.x"),
170+ y: mo.y === undefined ? 0 : num(mo.y, "mount.y"),
171+ radius: num(mo.radius, "mount.radius", 0),
172+ stiffness: num(mo.stiffness, "mount.stiffness", 0),
173+ damping: mo.damping === undefined ? 0 : num(mo.damping, "mount.damping", 0),
174+ };
175+ }
176+
177+ const lo = (o.loss ?? {}) as Record<string, unknown>;
178+ if (typeof lo !== "object") throw new DesignError("loss must be an object { sigma0, sigma1, sigmaIn }");
179+ const loss: Loss = {
180+ sigma0: lo.sigma0 === undefined ? DEFAULT_LOSS.sigma0 : num(lo.sigma0, "loss.sigma0", 0),
181+ sigma1: lo.sigma1 === undefined ? DEFAULT_LOSS.sigma1 : num(lo.sigma1, "loss.sigma1", 0),
182+ sigmaIn: lo.sigmaIn === undefined ? DEFAULT_LOSS.sigmaIn : num(lo.sigmaIn, "loss.sigmaIn", 0),
183+ };
184+
185+ return { name, material, Lx, Ly, inside, thickness, profile, mount, loss };
186+}
187+
188+// ---------- Evaluation on a grid ----------
189+
190+/** Subtracts the weighted least-squares plane a + b x + c y from f over the plate nodes. */
191+export function removePlane(f: Float64Array, g: Grid): void {
192+ let sw = 0, sx = 0, sy = 0, sxx = 0, sxy = 0, syy = 0, sf = 0, sxf = 0, syf = 0;
193+ for (let j = 0; j < g.ny; j++) {
194+ for (let i = 0; i < g.nx; i++) {
195+ const wgt = nodeWeight(g, i, j);
196+ if (wgt === 0) continue;
197+ const x = nodeX(g, i), y = nodeY(g, j), v = f[j * g.nx + i];
198+ sw += wgt; sx += wgt * x; sy += wgt * y;
199+ sxx += wgt * x * x; sxy += wgt * x * y; syy += wgt * y * y;
200+ sf += wgt * v; sxf += wgt * x * v; syf += wgt * y * v;
201+ }
202+ }
203+ if (sw === 0) return;
204+ // Normal equations of the 3-parameter fit, solved by Cramer's rule.
205+ const det = sw * (sxx * syy - sxy * sxy) - sx * (sx * syy - sxy * sy) + sy * (sx * sxy - sxx * sy);
206+ if (Math.abs(det) < 1e-300) return;
207+ const a = (sf * (sxx * syy - sxy * sxy) - sx * (sxf * syy - sxy * syf) + sy * (sxf * sxy - sxx * syf)) / det;
208+ const b = (sw * (sxf * syy - sxy * syf) - sf * (sx * syy - sxy * sy) + sy * (sx * syf - sxf * sy)) / det;
209+ const c = (sw * (sxx * syf - sxf * sxy) - sx * (sx * syf - sxf * sy) + sf * (sx * sxy - sxx * sy)) / det;
210+ for (let j = 0; j < g.ny; j++) for (let i = 0; i < g.nx; i++) f[j * g.nx + i] -= a + b * nodeX(g, i) + c * nodeY(g, j);
211+}
212+
213+function callField(fn: PointFn, x: number, y: number, what: string): number {
214+ let v: unknown;
215+ try {
216+ v = fn(x, y, Math.hypot(x, y), Math.atan2(y, x));
217+ } catch (e) {
218+ throw new DesignError(`${what} threw at (${x.toFixed(4)}, ${y.toFixed(4)}): ${e instanceof Error ? e.message : String(e)}`);
219+ }
220+ if (typeof v !== "number" || !isFinite(v)) throw new DesignError(`${what} returned ${String(v)} at (${x.toFixed(4)}, ${y.toFixed(4)})`);
221+ return v;
222+}
223+
224+/**
225+ * Samples the design on a grid of about the requested spacing: the mask from
226+ * the outline, the thickness at the nodes (which must be positive on the
227+ * plate), and the profile at the nodes with its mean plane removed. Only the
228+ * slopes of the profile enter the physics, so its mean is immaterial, but its
229+ * tilt is not: the von Karman strains are not invariant under rotation, and a
230+ * tilted stress-free shape would add a spurious membrane coupling.
231+ */
232+export function evaluatePlate(d: Design, spacing: number): Plate {
233+ let inside: InsideFn | undefined;
234+ if (d.inside) {
235+ const fn = d.inside;
236+ inside = (x, y) => {
237+ try {
238+ return !!fn(x, y, Math.hypot(x, y), Math.atan2(y, x));
239+ } catch (e) {
240+ throw new DesignError(`inside threw at (${x.toFixed(4)}, ${y.toFixed(4)}): ${e instanceof Error ? e.message : String(e)}`);
241+ }
242+ };
243+ }
244+ const g = makeGrid(d.Lx, d.Ly, spacing, inside);
245+ const h = new Float64Array(g.n), w0 = new Float64Array(g.n);
246+ let any = false;
247+ for (let j = 0; j < g.ny; j++) {
248+ for (let i = 0; i < g.nx; i++) {
249+ if (!insideNode(g, i, j)) continue;
250+ any = true;
251+ const x = nodeX(g, i), y = nodeY(g, j), k = j * g.nx + i;
252+ h[k] = callField(d.thickness, x, y, "thickness");
253+ if (h[k] <= 0) throw new DesignError(`thickness must be positive; it is ${h[k]} at (${x.toFixed(4)}, ${y.toFixed(4)})`);
254+ w0[k] = callField(d.profile, x, y, "profile");
255+ }
256+ }
257+ if (!any) throw new DesignError("the outline contains no grid cells; enlarge it or reduce the grid spacing");
258+ removePlane(w0, g);
259+ for (let j = 0; j < g.ny; j++) for (let i = 0; i < g.nx; i++) if (!insideNode(g, i, j)) w0[j * g.nx + i] = 0;
260+ return { name: d.name, Lx: d.Lx, Ly: d.Ly, grid: g, mat: d.material, h, w0, mount: d.mount, loss: d.loss };
261+}
262+
263+/** Peak-to-peak height and the largest slope of the profile over the plate nodes. */
264+export function profileStats(pl: Plate): { peakToPeak: number; maxSlope: number } {
265+ const g = pl.grid, w0 = pl.w0;
266+ let lo = Infinity, hi = -Infinity, maxSlope = 0;
267+ const at = (i: number, j: number) => w0[j * g.nx + i];
268+ const slope = (i: number, j: number, di: number, dj: number, hg: number) => {
269+ const hasP = insideNode(g, i + di, j + dj), hasM = insideNode(g, i - di, j - dj);
270+ if (hasP && hasM) return (at(i + di, j + dj) - at(i - di, j - dj)) / (2 * hg);
271+ if (hasP) return (at(i + di, j + dj) - at(i, j)) / hg;
272+ if (hasM) return (at(i, j) - at(i - di, j - dj)) / hg;
273+ return 0;
274+ };
275+ for (let j = 0; j < g.ny; j++) {
276+ for (let i = 0; i < g.nx; i++) {
277+ if (!insideNode(g, i, j)) continue;
278+ const v = at(i, j);
279+ lo = Math.min(lo, v); hi = Math.max(hi, v);
280+ maxSlope = Math.max(maxSlope, Math.hypot(slope(i, j, 1, 0, g.hx), slope(i, j, 0, 1, g.hy)));
281+ }
282+ }
283+ return { peakToPeak: isFinite(hi - lo) ? hi - lo : 0, maxSlope };
284+}
src/main.tsadded+673−0View file
@@ -0,0 +1,673 @@
1+// Application: design editor, parameter form, run loop, live and movie
2+// views, spectrogram, playback, WAV download, and the test hooks on
3+// window.steelsim.
4+
5+import { type Plate, type SimParams, countInside, defaultParams, massProperties, mountPatch, nodeWeights, strikePoint, thicknessRange } from "./params.ts";
6+import { DesignError, evaluatePlate, profileStats } from "./design.ts";
7+import { PRESETS } from "./presets.ts";
8+import { Simulation, type SimStatus } from "./sim/simulation.ts";
9+import { Renderer, type ColorMode, type ViewMode } from "./render/renderer.ts";
10+import { Spectrogram } from "./audio/spectrogram.ts";
11+import { Player } from "./audio/player.ts";
12+import { encodeWav } from "./audio/wav.ts";
13+import { levelGain, peakDbfs, type LevelMode } from "./audio/level.ts";
14+import { ParamForm } from "./ui/form.ts";
15+import { DesignEditor } from "./ui/editor.ts";
16+import { runSelfTest, selfTestPassed, type SelfTestCase } from "./selftest.ts";
17+
18+const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
19+
20+/** Slope above which the shallow-shell treatment of the profile is doubtful. */
21+const SLOPE_LIMIT = 0.1;
22+
23+// ---------- State ----------
24+
25+const params: SimParams = { ...defaultParams };
26+let form: ParamForm;
27+let editor: DesignEditor;
28+let device: GPUDevice | null = null;
29+let sim: Simulation | null = null;
30+let renderer: Renderer | null = null;
31+let running = false;
32+let paused = false;
33+let live = true;
34+let scrubFrame = 0;
35+let movieBuf: GPUBuffer | null = null;
36+let movieBufFrame = -1;
37+/** The design evaluated at the current spacing, or null while the script has an error. */
38+let plate: Plate | null = null;
39+let plateError: string | null = null;
40+/** Show the design (its stress-free shape) instead of the simulation. */
41+let previewMode = true;
42+let preview: { plate: Plate; w0: GPUBuffer; zero: GPUBuffer; nw: GPUBuffer; hn: GPUBuffer; wmax: number; stale: boolean } | null = null;
43+const spec = new Spectrogram({ windowSize: 2048, hop: 512, fmax: 5000, dbFloor: 80, logFrequency: false });
44+const player = new Player();
45+let lastStatsTime = 0, lastStatsSteps = 0, stepsPerSec = 0;
46+let duration = params.duration;
47+
48+// ---------- Controls ----------
49+
50+const btnStart = mkButton("Start", "primary");
51+const btnPause = mkButton("Pause");
52+const btnStop = mkButton("Stop");
53+const btnContinue = mkButton("Continue");
54+const durInput = document.createElement("input");
55+
56+function mkButton(text: string, cls = ""): HTMLButtonElement {
57+ const b = document.createElement("button");
58+ b.textContent = text;
59+ b.className = cls;
60+ return b;
61+}
62+
63+function buildControls(): void {
64+ const c = $("controls");
65+ const durRow = document.createElement("div");
66+ durRow.className = "field";
67+ durRow.style.width = "100%";
68+ const l = document.createElement("label");
69+ l.textContent = "Recorded duration";
70+ durInput.type = "number";
71+ durInput.step = "0.5";
72+ durInput.min = "0.1";
73+ durInput.value = String(duration);
74+ durInput.addEventListener("change", () => {
75+ const v = parseFloat(durInput.value);
76+ if (isFinite(v) && v > 0) {
77+ duration = v;
78+ params.duration = v;
79+ sim?.setDuration(v);
80+ form.updateNotes();
81+ updateButtons();
82+ }
83+ });
84+ const u = document.createElement("span");
85+ u.className = "unit";
86+ u.textContent = "s";
87+ durRow.append(l, durInput, u);
88+ c.append(durRow, btnStart, btnPause, btnStop, btnContinue);
89+ btnStart.addEventListener("click", () => void start());
90+ btnPause.addEventListener("click", () => {
91+ paused = !paused;
92+ btnPause.textContent = paused ? "Resume" : "Pause";
93+ if (!paused) void runLoop();
94+ });
95+ btnStop.addEventListener("click", () => {
96+ if (!sim) return;
97+ sim.stop();
98+ paused = false;
99+ btnPause.textContent = "Pause";
100+ if (!running) {
101+ setEditable(true);
102+ updateButtons();
103+ }
104+ });
105+ btnContinue.addEventListener("click", () => {
106+ if (!sim) return;
107+ sim.setDuration(duration);
108+ if (sim.phase !== "done") void runLoop();
109+ updateButtons();
110+ });
111+ updateButtons();
112+}
113+
114+function updateButtons(): void {
115+ const s = sim?.status();
116+ btnStart.disabled = !device || !plate;
117+ btnPause.disabled = !sim || s?.phase === "done" || (!running && !paused);
118+ btnStop.disabled = !sim || s?.phase === "done";
119+ btnContinue.disabled = !sim || running || !sim.canContinue();
120+}
121+
122+function setEditable(enabled: boolean): void {
123+ form.setEnabled(enabled);
124+ editor.setEnabled(enabled);
125+}
126+
127+// ---------- View toolbars ----------
128+
129+const viewMode = { value: "2d" as ViewMode };
130+const colorSel = document.createElement("select");
131+const scrub = document.createElement("input");
132+const timeLabel = document.createElement("span");
133+const btnLive = mkButton("Live");
134+const btnDesign = mkButton("Design");
135+const btnPlay = mkButton("Play");
136+const btnWav = mkButton("Download .wav");
137+const levelSel = document.createElement("select");
138+const gainSlider = document.createElement("input");
139+const gainLabel = document.createElement("span");
140+const peakLabel = document.createElement("span");
141+let exagInput: HTMLInputElement;
142+let rangeInput: HTMLInputElement;
143+let shapeCheck: HTMLInputElement;
144+
145+function gainDb(): number {
146+ return parseFloat(gainSlider.value) || 0;
147+}
148+function levelMode(): LevelMode {
149+ return levelSel.value as LevelMode;
150+}
151+
152+function updateLevelReadout(): void {
153+ gainLabel.textContent = `${gainDb() >= 0 ? "+" : ""}${gainDb()} dB`;
154+ gainSlider.disabled = levelMode() !== "absolute";
155+ if (!sim || sim.samples === 0) { peakLabel.textContent = ""; return; }
156+ const audio = sim.recordedAudio();
157+ const db = peakDbfs(audio, levelGain(audio, sim.fs, levelMode(), gainDb()));
158+ peakLabel.textContent = isFinite(db) ? `peak ${db.toFixed(1)} dBFS${db > 0 ? " (clipped)" : ""}` : "";
159+ peakLabel.className = db > 0 ? "warn" : "sub";
160+ peakLabel.style.margin = "0";
161+}
162+
163+function buildViewToolbar(): void {
164+ const tb = $("viewToolbar");
165+ const seg = document.createElement("span");
166+ seg.className = "seg";
167+ const b2 = mkButton("2D", "on"), b3 = mkButton("3D");
168+ b2.addEventListener("click", () => { viewMode.value = "2d"; b2.classList.add("on"); b3.classList.remove("on"); });
169+ b3.addEventListener("click", () => { viewMode.value = "3d"; b3.classList.add("on"); b2.classList.remove("on"); });
170+ seg.append(b2, b3);
171+ for (const [v, t] of [["field", "Height / displacement"], ["thickness", "Thickness"]]) {
172+ const o = document.createElement("option");
173+ o.value = v; o.textContent = t;
174+ colorSel.appendChild(o);
175+ }
176+ exagInput = numInput(4, 0.5);
177+ rangeInput = numInput(0, 0.5);
178+ shapeCheck = document.createElement("input");
179+ shapeCheck.type = "checkbox";
180+ shapeCheck.checked = true;
181+ shapeCheck.title = "Show the total shape (design profile plus vibration); unticked, only the vibration";
182+ const hint = document.createElement("span");
183+ hint.className = "sub";
184+ hint.style.margin = "0";
185+ hint.textContent = "3D: drag to orbit, wheel to zoom";
186+ tb.append(seg, labeled("Color", colorSel), labeled("Exaggeration ×", exagInput), labeled("Color range (mm, 0 = auto)", rangeInput), labeled("Show profile", shapeCheck), hint);
187+}
188+
189+function buildMovieToolbar(): void {
190+ const tb = $("movieToolbar");
191+ scrub.type = "range";
192+ scrub.min = "0"; scrub.max = "0"; scrub.value = "0";
193+ timeLabel.className = "time";
194+ // Playback drives the movie; afterwards the view returns to where it was (live or scrubbed).
195+ let liveBeforePlay = true;
196+ const endPlayback = () => {
197+ btnPlay.textContent = "Play";
198+ if (liveBeforePlay) {
199+ live = true;
200+ updateLiveButton();
201+ }
202+ };
203+ scrub.addEventListener("input", () => {
204+ live = false;
205+ liveBeforePlay = false;
206+ previewMode = false;
207+ scrubFrame = parseInt(scrub.value);
208+ if (player.playing) player.stop();
209+ updateLiveButton();
210+ });
211+ btnLive.addEventListener("click", () => { live = true; liveBeforePlay = true; previewMode = false; updateLiveButton(); });
212+ btnDesign.title = "Show the design's stress-free shape instead of the simulation";
213+ btnDesign.addEventListener("click", () => { showPreview(); updateLiveButton(); });
214+ btnPlay.addEventListener("click", () => {
215+ if (player.playing) { player.stop(); endPlayback(); return; }
216+ if (!sim || sim.samples === 0) return;
217+ liveBeforePlay = live;
218+ const offset = live ? 0 : scrubFrame / sim.fps;
219+ live = false;
220+ previewMode = false;
221+ player.play(sim.recordedAudio(), sim.fs, offset, levelMode(), gainDb());
222+ btnPlay.textContent = "Stop";
223+ updateLiveButton();
224+ });
225+ player.onEnded = endPlayback;
226+ btnWav.addEventListener("click", () => {
227+ if (!sim || sim.samples === 0) return;
228+ const blob = encodeWav(sim.recordedAudio(), sim.fs, levelMode(), gainDb());
229+ const a = document.createElement("a");
230+ a.href = URL.createObjectURL(blob);
231+ const name = sim.plate.name.replace(/[^a-z0-9]+/gi, "_").replace(/^_+|_+$/g, "").toLowerCase() || "cymbal";
232+ a.download = `${name}_${(sim.samples / sim.fs).toFixed(1)}s_${sim.fs}Hz.wav`;
233+ a.click();
234+ setTimeout(() => URL.revokeObjectURL(a.href), 5000);
235+ });
236+ for (const [v, t] of [["absolute", "Absolute (0 dBFS = 1 Pa at 0 dB)"], ["loudness", "Loudness (RMS, soft clip)"], ["peak", "Peak"]]) {
237+ const o = document.createElement("option");
238+ o.value = v; o.textContent = t;
239+ levelSel.appendChild(o);
240+ }
241+ gainSlider.type = "range";
242+ gainSlider.min = "-40"; gainSlider.max = "60"; gainSlider.step = "1"; gainSlider.value = "20";
243+ gainSlider.style.width = "120px";
244+ gainSlider.style.flex = "none";
245+ const restart = () => {
246+ updateLevelReadout();
247+ const pos = player.position();
248+ if (pos !== null && sim) player.play(sim.recordedAudio(), sim.fs, pos, levelMode(), gainDb());
249+ };
250+ gainSlider.addEventListener("input", restart);
251+ levelSel.addEventListener("change", restart);
252+ tb.append(btnPlay, btnLive, btnDesign, scrub, timeLabel, labeled("Level", levelSel), labeled("Gain", gainSlider), gainLabel, peakLabel, btnWav);
253+ updateLevelReadout();
254+ updateLiveButton();
255+}
256+
257+function updateLiveButton(): void {
258+ btnLive.classList.toggle("on", live && !previewMode);
259+ btnLive.disabled = !sim || (live && !previewMode);
260+ btnDesign.classList.toggle("on", previewMode);
261+ btnDesign.disabled = previewMode;
262+}
263+
264+function buildSpecToolbar(): void {
265+ const tb = $("specToolbar");
266+ const win = document.createElement("select");
267+ for (const n of [512, 1024, 2048, 4096, 8192]) {
268+ const o = document.createElement("option");
269+ o.value = String(n); o.textContent = String(n);
270+ win.appendChild(o);
271+ }
272+ win.value = "2048";
273+ const hop = numInput(512, 64);
274+ const fmax = numInput(5000, 500);
275+ const floor = numInput(80, 5);
276+ const logf = document.createElement("input");
277+ logf.type = "checkbox";
278+ const apply = () => {
279+ if (!sim) return;
280+ spec.configure(
281+ { windowSize: parseInt(win.value), hop: Math.max(16, parseInt(hop.value)), fmax: Math.min(sim.fs / 2, parseFloat(fmax.value)), dbFloor: parseFloat(floor.value), logFrequency: logf.checked },
282+ sim.recordedAudio(), sim.samples,
283+ );
284+ };
285+ for (const el of [win, hop, fmax, floor, logf]) el.addEventListener("change", apply);
286+ tb.append(document.createTextNode("Spectrogram"), labeled("Window", win), labeled("Hop", hop), labeled("Max Hz", fmax), labeled("dB range", floor), labeled("Log freq.", logf));
287+}
288+
289+function numInput(v: number, step: number): HTMLInputElement {
290+ const i = document.createElement("input");
291+ i.type = "number"; i.value = String(v); i.step = String(step);
292+ return i;
293+}
294+function labeled(text: string, el: HTMLElement): HTMLLabelElement {
295+ const l = document.createElement("label");
296+ l.append(text, el);
297+ return l;
298+}
299+
300+// ---------- Design ----------
301+
302+/** Re-evaluates the current design at the current spacing and refreshes everything that depends on it. */
303+function rebuildPlate(): void {
304+ const design = editor.design;
305+ plate = null;
306+ plateError = editor.error;
307+ if (design) {
308+ try {
309+ plate = evaluatePlate(design, params.spacing);
310+ } catch (e) {
311+ plateError = e instanceof DesignError ? e.message : String(e);
312+ }
313+ }
314+ form.setPlate(plate);
315+ editor.setStatus(plateError, plate ? designSummary(plate) : "");
316+ showPreview();
317+ updateButtons();
318+}
319+
320+function fmtMm(v: number): string {
321+ return `${(v * 1e3).toFixed(2)} mm`;
322+}
323+
324+function designSummary(pl: Plate): string {
325+ const g = pl.grid;
326+ const hr = thicknessRange(pl);
327+ const { mass } = massProperties(pl);
328+ const { peakToPeak, maxSlope } = profileStats(pl);
329+ const rows: [string, string][] = [
330+ ["Design", pl.name],
331+ ["Bounding box", `${fmtMm(pl.Lx)} × ${fmtMm(pl.Ly)} (${(pl.Lx / 0.0254).toFixed(1)} × ${(pl.Ly / 0.0254).toFixed(1)} in)`],
332+ ["Material", `E ${(pl.mat.E / 1e9).toFixed(0)} GPa, ν ${pl.mat.nu}, ρ ${pl.mat.rho} kg/m³`],
333+ ["Thickness", hr.min === hr.max ? fmtMm(hr.min) : `${fmtMm(hr.min)} to ${fmtMm(hr.max)} (mean ${fmtMm(hr.mean)})`],
334+ ["Mass", `${(mass * 1e3).toFixed(0)} g`],
335+ ["Grid", `${g.nx} × ${g.ny}, ${countInside(g)} nodes in the plate`],
336+ ["Profile", peakToPeak > 0 ? `${fmtMm(peakToPeak)} peak to peak, largest slope ${maxSlope.toFixed(3)}` : "flat"],
337+ ];
338+ if (pl.mount) {
339+ const mp = mountPatch(pl);
340+ rows.push(["Mount", `${mp?.nodes.length ?? 0} nodes within ${fmtMm(pl.mount.radius)} of (${fmtMm(pl.mount.x)}, ${fmtMm(pl.mount.y)}); ${pl.mount.stiffness.toExponential(1)} N/m, ${pl.mount.damping} N s/m`]);
341+ } else {
342+ rows.push(["Mount", "none (free plate, inertia relief)"]);
343+ }
344+ rows.push(["Losses", `σ₀ ${pl.loss.sigma0} 1/s, σ₁ ${pl.loss.sigma1} m²/s, in-plane ${pl.loss.sigmaIn} 1/s`]);
345+ let html = rows.map(([a, b]) => `<div class="row"><span>${a}</span><span>${b}</span></div>`).join("");
346+ if (maxSlope > SLOPE_LIMIT) html += `<div class="warn">The largest slope of the profile, ${maxSlope.toFixed(2)}, is beyond the shallow-shell range of about ${SLOPE_LIMIT}; the bell or the bow is steeper than the model treats accurately.</div>`;
347+ return html;
348+}
349+
350+// ---------- Simulation control ----------
351+
352+async function start(): Promise<void> {
353+ if (!device || !plate) return;
354+ player.stop();
355+ running = false;
356+ await new Promise((r) => setTimeout(r, 0));
357+ sim?.destroy();
358+ spec.reset();
359+ live = true;
360+ paused = false;
361+ btnPause.textContent = "Pause";
362+ params.duration = duration;
363+ try {
364+ sim = new Simulation(device, plate, params);
365+ } catch (e) {
366+ $("info").innerHTML = `<div class="err">Could not start: ${String(e)}</div>`;
367+ return;
368+ }
369+ const hr = thicknessRange(plate);
370+ renderer?.setField(sim.grid, sim.w0, sim.nw, sim.hn, sim.w0Max, [hr.min, hr.max]);
371+ previewMode = false;
372+ movieBuf?.destroy();
373+ movieBuf = device.createBuffer({ size: Math.max(16, sim.grid.n * 4), usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
374+ movieBufFrame = -1;
375+ scrub.max = "0"; scrub.value = "0";
376+ lastStatsTime = performance.now(); lastStatsSteps = 0; stepsPerSec = 0;
377+ setEditable(false);
378+ updateLiveButton();
379+ void runLoop();
380+}
381+
382+async function runLoop(): Promise<void> {
383+ if (!sim || running) return;
384+ running = true;
385+ updateButtons();
386+ try {
387+ while (sim.phase !== "done" && !paused) {
388+ await sim.runBatch();
389+ spec.update(sim.recordedAudio(), sim.samples);
390+ scrub.max = String(Math.max(0, sim.frames.length - 1));
391+ if (live) scrub.value = scrub.max;
392+ updateLevelReadout();
393+ const now = performance.now();
394+ if (now - lastStatsTime > 500) {
395+ stepsPerSec = ((sim.totalSteps - lastStatsSteps) * 1000) / (now - lastStatsTime);
396+ lastStatsTime = now; lastStatsSteps = sim.totalSteps;
397+ }
398+ }
399+ } catch (e) {
400+ console.error(e);
401+ $("info").innerHTML = `<div class="err">Simulation error: ${String(e)}</div>`;
402+ }
403+ running = false;
404+ if (sim.phase === "done") setEditable(true);
405+ updateButtons();
406+}
407+
408+// ---------- Render loop ----------
409+
410+let frameErrorShown = false;
411+let lastRenderKey = "";
412+let lastInfoHtml = "";
413+
414+function frame(): void {
415+ requestAnimationFrame(frame);
416+ try {
417+ frameBody();
418+ } catch (e) {
419+ if (!frameErrorShown) { frameErrorShown = true; console.error("frame exception:", e); }
420+ }
421+}
422+
423+function markers(pl: Plate): void {
424+ if (!renderer) return;
425+ const [sx, sy] = strikePoint(params, pl);
426+ renderer.strike = { x: sx, y: sy, r: params.contactRadius };
427+ renderer.mount = pl.mount ? { x: pl.mount.x, y: pl.mount.y, r: pl.mount.radius } : null;
428+}
429+
430+function frameBody(): void {
431+ if (!device || !renderer) return;
432+ renderer.mode = viewMode.value;
433+ renderer.colorMode = colorSel.value as ColorMode;
434+ renderer.exaggeration = parseFloat(exagInput.value) || 1;
435+ renderer.colorRange = (parseFloat(rangeInput.value) || 0) * 1e-3;
436+ renderer.showShape = shapeCheck.checked;
437+ const canvas = $<HTMLCanvasElement>("view");
438+ const cam = renderer.camera;
439+ const viewKey = [
440+ renderer.mode, renderer.colorMode, renderer.exaggeration, renderer.colorRange, renderer.showShape,
441+ cam.yaw.toFixed(4), cam.pitch.toFixed(4), cam.dist.toFixed(4),
442+ canvas.clientWidth, canvas.clientHeight, window.devicePixelRatio,
443+ params.strikeFracX, params.strikeFracY, params.contactRadius,
444+ ];
445+ if (previewMode || !sim) {
446+ // The stress-free shape of the design that the next Start will use.
447+ ensurePreview(device);
448+ const renderKey = ["preview", ...viewKey, preview?.plate.name, preview?.plate.grid.n].join("|");
449+ if (renderKey !== lastRenderKey) {
450+ lastRenderKey = renderKey;
451+ renderPreview(device);
452+ }
453+ if (sim) {
454+ spec.draw($("spec"), sim.fs, Math.max(duration, sim.samples / sim.fs), null);
455+ updateInfo(sim.status());
456+ }
457+ return;
458+ }
459+ const pos = player.position();
460+ if (pos !== null) {
461+ scrubFrame = Math.min(sim.frames.length - 1, Math.floor(pos * sim.fps));
462+ scrub.value = String(scrubFrame);
463+ }
464+ const st = sim.status();
465+ // Render only when something visible changed, so an idle or paused page does no work.
466+ const renderKey = [...viewKey, live, scrubFrame, sim.frames.length, st.totalSteps, st.phase].join("|");
467+ if (renderKey !== lastRenderKey) {
468+ lastRenderKey = renderKey;
469+ renderView(device, st);
470+ }
471+ spec.draw($("spec"), sim.fs, Math.max(duration, sim.samples / sim.fs), pos ?? (live ? null : scrubFrame / sim.fps));
472+ updateInfo(st);
473+}
474+
475+/** Builds the preview buffers from the current plate when they are missing or stale. */
476+function ensurePreview(dev: GPUDevice): void {
477+ if (preview && !preview.stale) return;
478+ if (!plate) return;
479+ if (preview) for (const b of [preview.w0, preview.zero, preview.nw, preview.hn]) b.destroy();
480+ const grid = plate.grid;
481+ const shape = Float32Array.from(plate.w0);
482+ let wmax = 0;
483+ for (let k = 0; k < shape.length; k++) wmax = Math.max(wmax, Math.abs(shape[k]));
484+ const mk = (data: Float32Array<ArrayBuffer>) => {
485+ const b = dev.createBuffer({ size: Math.max(16, data.byteLength), usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
486+ dev.queue.writeBuffer(b, 0, data);
487+ return b;
488+ };
489+ preview = { plate, w0: mk(shape), zero: mk(new Float32Array(grid.n)), nw: mk(nodeWeights(grid)), hn: mk(Float32Array.from(plate.h)), wmax, stale: false };
490+ const hr = thicknessRange(plate);
491+ renderer?.setField(grid, preview.zero, preview.nw, preview.hn, 0, [hr.min, hr.max]);
492+ lastRenderKey = "";
493+}
494+
495+/** Marks the preview stale (the design or the spacing changed) and shows it; the next frame rebuilds it. */
496+function showPreview(): void {
497+ if (preview) preview.stale = true;
498+ previewMode = true;
499+ updateLiveButton();
500+ if (!sim) updateIdleInfo();
501+}
502+
503+function renderPreview(dev: GPUDevice): void {
504+ if (!renderer || !preview) return;
505+ // The renderer's "initial shape" buffer is zero here, and the shape itself is the displayed field.
506+ if (renderer.fieldGrid !== preview.plate.grid) {
507+ const hr = thicknessRange(preview.plate);
508+ renderer.setField(preview.plate.grid, preview.zero, preview.nw, preview.hn, 0, [hr.min, hr.max]);
509+ }
510+ markers(preview.plate);
511+ const enc = dev.createCommandEncoder();
512+ renderer.render(enc, preview.w0, preview.wmax);
513+ timeLabel.textContent = "design";
514+ dev.pushErrorScope("validation");
515+ dev.queue.submit([enc.finish()]);
516+ void dev.popErrorScope().then((e) => { if (e && !frameErrorShown) { frameErrorShown = true; console.error("frame validation error:", e.message); } });
517+}
518+
519+function updateIdleInfo(): void {
520+ const html = plate
521+ ? `<p class="sub">The view shows the design's stress-free shape. Press Start to strike it.</p>`
522+ : `<p class="err">${plateError ?? "No design."}</p>`;
523+ if (html !== lastInfoHtml) {
524+ lastInfoHtml = html;
525+ $("info").innerHTML = html;
526+ }
527+}
528+
529+function renderView(dev: GPUDevice, st: SimStatus): void {
530+ if (!sim || !renderer) return;
531+ if (renderer.fieldGrid !== sim.grid) {
532+ const hr = thicknessRange(sim.plate);
533+ renderer.setField(sim.grid, sim.w0, sim.nw, sim.hn, sim.w0Max, [hr.min, hr.max]);
534+ }
535+ markers(sim.plate);
536+ const enc = dev.createCommandEncoder();
537+ if (live || sim.frames.length === 0) {
538+ sim.encodeDisplay(enc);
539+ renderer.render(enc, sim.displayW, st.wmax);
540+ timeLabel.textContent = `${st.t.toFixed(3)} s`;
541+ } else {
542+ const f = Math.max(0, Math.min(sim.frames.length - 1, scrubFrame));
543+ if (movieBufFrame !== f) {
544+ dev.queue.writeBuffer(movieBuf!, 0, sim.frames[f]);
545+ movieBufFrame = f;
546+ }
547+ let wmax = 0;
548+ const fr = sim.frames[f];
549+ for (let k = 0; k < fr.length; k += 7) wmax = Math.max(wmax, Math.abs(fr[k]));
550+ renderer.render(enc, movieBuf!, wmax);
551+ timeLabel.textContent = `${(f / sim.fps).toFixed(3)} s`;
552+ }
553+ dev.pushErrorScope("validation");
554+ dev.queue.submit([enc.finish()]);
555+ void dev.popErrorScope().then((e) => { if (e && !frameErrorShown) { frameErrorShown = true; console.error("frame validation error:", e.message); } });
556+}
557+
558+function updateInfo(st: SimStatus): void {
559+ if (!sim) return;
560+ const g = sim.grid;
561+ const spl = st.peakPressure > 0 ? 20 * Math.log10(st.peakPressure / 2e-5) : -Infinity;
562+ const rows: [string, string][] = [
563+ ["Design", sim.plate.name],
564+ ["Phase", st.phase === "done" ? "done" : "ringing (recording)"],
565+ ["Time", `${st.t.toFixed(3)} s`],
566+ ["Grid", `${g.nx} × ${g.ny} nodes (${countInside(g)} in the plate), ${(g.hx * 1e3).toFixed(2)} × ${(g.hy * 1e3).toFixed(2)} mm`],
567+ ["Time step", `${(sim.dt * 1e6).toFixed(3)} µs (${sim.stepsPerSample} per sample)`],
568+ ["Speed", `${(stepsPerSec / 1e3).toFixed(0)} k steps/s (${(stepsPerSec * sim.dt).toFixed(3)}× real time)`],
569+ ["Kinetic energy", `${st.ke.toExponential(2)} J`],
570+ ["Max |w|", `${(st.wmax * 1e3).toFixed(3)} mm`],
571+ ["Audio", `${(st.samples / sim.fs).toFixed(2)} s, ${st.frames} frames`],
572+ ["Peak pressure", st.peakPressure > 0 ? `${st.peakPressure.toFixed(3)} Pa (${spl.toFixed(1)} dB SPL)` : "–"],
573+ ["Stick", `${(st.stick.x * 1e3).toFixed(2)} mm, ${st.stick.v.toFixed(2)} m/s`],
574+ ["Contact force", `${st.stick.force.toFixed(1)} N (max ${st.stick.maxForce.toFixed(1)} N)`],
575+ ];
576+ let html = rows.map(([a, b]) => `<div class="row"><span>${a}</span><span>${b}</span></div>`).join("");
577+ if (st.delayClamped) html += `<div class="warn">The listener is so close that some propagation delays were clamped.</div>`;
578+ if (!isFinite(st.wmax) || st.wmax > 1) html += `<div class="err">The solution has diverged. Increase the grid spacing (which lengthens the time step) or reduce the strike.</div>`;
579+ if (html !== lastInfoHtml) {
580+ lastInfoHtml = html;
581+ $("info").innerHTML = html;
582+ }
583+}
584+
585+// ---------- Self-test ----------
586+
587+let selfTestResult: { done: boolean; passed?: boolean; cases?: SelfTestCase[]; error?: string } = { done: false };
588+
589+async function selfTest(): Promise<void> {
590+ if (!device) { selfTestResult = { done: true, passed: false, error: "no GPU device" }; return; }
591+ $("info").innerHTML = `<p class="sub">Running the GPU-versus-CPU self-test...</p>`;
592+ try {
593+ const cases = await runSelfTest(device);
594+ const passed = cases.every(selfTestPassed);
595+ selfTestResult = { done: true, passed, cases };
596+ const rows = cases.map((c) => `<div class="row"><span>${c.name}</span><span class="${selfTestPassed(c) ? "" : "err"}">acc ${c.accError.u.toExponential(1)} / ${c.accError.v.toExponential(1)} / ${c.accError.w.toExponential(1)}, pos ${c.positionError.toExponential(1)}</span></div>`);
597+ $("info").innerHTML = `<div class="${passed ? "row" : "err"}">Self-test ${passed ? "passed" : "FAILED"}</div>${rows.join("")}<p class="sub">Maximum relative errors of the u, v, w accelerations and of the compensated w update, GPU step versus float64 reference.</p>`;
598+ } catch (e) {
599+ selfTestResult = { done: true, passed: false, error: String(e) };
600+ $("info").innerHTML = `<div class="err">Self-test error: ${String(e)}</div>`;
601+ }
602+}
603+
604+// ---------- Init ----------
605+
606+async function init(): Promise<void> {
607+ editor = new DesignEditor($("design"));
608+ form = new ParamForm($("params"), params);
609+ form.onChange = (key, isLive) => {
610+ if (key === "spacing") { rebuildPlate(); return; }
611+ if (!isLive) { showPreview(); return; }
612+ if (!sim) return;
613+ if (key.startsWith("listener")) sim.setListener(params.listenerDist, params.listenerAzimuth, params.listenerElevation);
614+ };
615+ editor.onDesign = () => rebuildPlate();
616+ buildControls();
617+ buildViewToolbar();
618+ buildMovieToolbar();
619+ buildSpecToolbar();
620+ const q = new URLSearchParams(location.search);
621+ const set = form.applyUrl(q);
622+ if (set.includes("duration")) {
623+ duration = params.duration;
624+ durInput.value = String(duration);
625+ }
626+ const presetId = q.get("preset");
627+ const preset = presetId ? PRESETS.find((p) => p.id === presetId) : null;
628+ if (preset) editor.setScript(preset.source, preset.id);
629+ else editor.compileNow();
630+ exposeHooks();
631+ if (!("gpu" in navigator)) {
632+ showNoGpu("WebGPU is not available in this browser. Use a recent Chrome or Edge, or Firefox or Safari with WebGPU enabled.");
633+ return;
634+ }
635+ const adapter = (await navigator.gpu.requestAdapter({ powerPreference: "high-performance" })) ?? (await navigator.gpu.requestAdapter());
636+ if (!adapter) {
637+ showNoGpu("No WebGPU adapter was found.");
638+ return;
639+ }
640+ device = await adapter.requestDevice();
641+ device.lost.then((info) => showNoGpu(`WebGPU device lost: ${info.message}`));
642+ device.addEventListener("uncapturederror", (ev) => console.error("WebGPU error:", (ev as GPUUncapturedErrorEvent).error.message));
643+ renderer = new Renderer(device, $("view"));
644+ updateIdleInfo();
645+ updateButtons();
646+ requestAnimationFrame(frame);
647+ if (q.get("selftest") === "1") await selfTest();
648+ else if (q.get("autostart") === "1") void start();
649+}
650+
651+/** Hooks for automated tests, on window.steelsim. */
652+function exposeHooks(): void {
653+ (window as unknown as { steelsim: unknown }).steelsim = {
654+ status: () => (sim ? { ...sim.status(), dt: sim.dt, stepsPerSample: sim.stepsPerSample, nx: sim.grid.nx, ny: sim.grid.ny, stepsPerSec } : null),
655+ audio: () => (sim ? Array.from(sim.recordedAudio()) : null),
656+ frames: () => sim?.frames.length ?? 0,
657+ design: () => (plate ? { name: plate.name, nodes: countInside(plate.grid), mounted: !!plate.mount, error: null } : { name: null, nodes: 0, mounted: false, error: plateError }),
658+ setScript: (source: string) => editor.setScript(source),
659+ start: () => void start(),
660+ selftest: () => selfTestResult,
661+ noGpu: () => { const e = $("nogpu"); return e.hidden ? null : e.textContent; },
662+ };
663+}
664+
665+function showNoGpu(msg: string): void {
666+ const el = $("nogpu");
667+ el.hidden = false;
668+ el.textContent = msg;
669+ btnStart.disabled = true;
670+ selfTestResult = { done: true, passed: false, error: msg };
671+}
672+
673+void init();
src/params.tsadded+383−0View file
@@ -0,0 +1,383 @@
1+// Simulation parameters (strike, listener, numerics), the grid with its mask,
2+// the plate as evaluated on the grid (material, thickness field, stress-free
3+// shape, mount), and the derived quantities the kernel needs. All values are
4+// SI. The cymbal design itself is produced by the script in design.ts.
5+
6+export interface SimParams {
7+ // Strike
8+ strikeFracX: number; // strike point as a fraction of the bounding-box width from the left edge
9+ strikeFracY: number; // strike point as a fraction of the bounding-box height from the bottom edge
10+ strikeVelocity: number; // stick tip speed at impact [m/s]
11+ stickMass: number; // effective mass of the stick [kg]
12+ contactStiffness: number; // Hertz contact stiffness K [N/m^alpha]
13+ contactExponent: number; // Hertz exponent alpha (1.5 for a rounded tip)
14+ contactRadius: number; // radius of the contact patch [m]
15+ // Listener
16+ listenerDist: number; // [m]
17+ listenerAzimuth: number; // [deg], rotation about the plate's vertical axis (0 = on the normal)
18+ listenerElevation: number; // [deg]
19+ // Numerics and output
20+ spacing: number; // requested grid spacing [m]
21+ inPlaneMass: number; // factor by which the in-plane inertia is scaled; 1 is physical, larger lengthens the time step
22+ rigidProjection: boolean; // remove the six rigid-body modes every RB_INTERVAL steps (free plates only)
23+ fs: number; // audio sample rate [Hz]
24+ fps: number; // movie frame rate, also the batch rate [Hz]
25+ duration: number; // recorded duration after the impact [s]
26+}
27+
28+export const defaultParams: SimParams = {
29+ strikeFracX: 0.7,
30+ strikeFracY: 0.5,
31+ strikeVelocity: 3,
32+ stickMass: 0.03,
33+ contactStiffness: 5e8,
34+ contactExponent: 1.5,
35+ contactRadius: 5e-3,
36+ listenerDist: 1.0,
37+ listenerAzimuth: 0,
38+ listenerElevation: 0,
39+ spacing: 0.008,
40+ inPlaneMass: 1,
41+ rigidProjection: false,
42+ fs: 48000,
43+ fps: 120,
44+ duration: 4,
45+};
46+
47+export const SPEED_OF_SOUND = 343; // [m/s]
48+export const RHO_AIR = 1.2; // [kg/m^3]
49+export const CFL = 0.9; // fraction of the stability limit used for the time step
50+// The ring buffer of the retarded-time listener sum holds one batch of samples
51+// plus this margin, which bounds the spread of the delays across the plate.
52+export const HIST_MARGIN = 128; // [samples]
53+export const RB_INTERVAL = 32; // steps between rigid-body projections
54+
55+// ---------- Grid ----------
56+
57+export interface Grid {
58+ nx: number;
59+ ny: number;
60+ hx: number;
61+ hy: number;
62+ n: number;
63+ /** 1 for nodes that belong to the plate, 0 otherwise; absent for a full rectangle. */
64+ mask?: Uint8Array;
65+}
66+
67+/** True when the point (x, y), relative to the center of the bounding box, lies in the plate. */
68+export type InsideFn = (x: number, y: number) => boolean;
69+
70+/**
71+ * The grid covering the bounding box Lx by Ly with about the requested
72+ * spacing, restricted to the outline `inside` when one is given.
73+ */
74+export function makeGrid(Lx: number, Ly: number, spacing: number, inside?: InsideFn): Grid {
75+ const nx = Math.max(6, Math.round(Lx / spacing) + 1);
76+ const ny = Math.max(6, Math.round(Ly / spacing) + 1);
77+ const g: Grid = { nx, ny, hx: Lx / (nx - 1), hy: Ly / (ny - 1), n: nx * ny };
78+ return inside ? withMask(g, inside) : g;
79+}
80+
81+/**
82+ * Restricts a grid to an outline. The edge is a staircase of whole cells: a
83+ * cell belongs to the plate when its center lies inside the outline, and a
84+ * node when it is a corner of such a cell. Deciding by cell centers rather
85+ * than by nodes keeps the covered area unbiased; a node-based mask loses
86+ * about half a cell all around the edge, which raises every frequency by a
87+ * few percent. A node with no cell of its own would carry no mass, and the
88+ * trailing loop removes any such node. A hole in the outline (a cymbal's
89+ * center hole) is handled by the same rule.
90+ */
91+export function withMask(g: Grid, inside: InsideFn): Grid {
92+ const mask = new Uint8Array(g.n);
93+ for (let cj = 0; cj < g.ny - 1; cj++) {
94+ for (let ci = 0; ci < g.nx - 1; ci++) {
95+ if (inside(nodeX(g, ci) + g.hx / 2, nodeY(g, cj) + g.hy / 2)) {
96+ mask[cj * g.nx + ci] = 1; mask[cj * g.nx + ci + 1] = 1;
97+ mask[(cj + 1) * g.nx + ci] = 1; mask[(cj + 1) * g.nx + ci + 1] = 1;
98+ }
99+ }
100+ }
101+ const gm: Grid = { ...g, mask };
102+ for (let changed = true; changed; ) {
103+ changed = false;
104+ for (let j = 0; j < g.ny; j++) {
105+ for (let i = 0; i < g.nx; i++) {
106+ if (mask[j * g.nx + i] && nodeWeight(gm, i, j) === 0) {
107+ mask[j * g.nx + i] = 0;
108+ changed = true;
109+ }
110+ }
111+ }
112+ }
113+ return gm;
114+}
115+
116+/** The ellipse inscribed in the bounding box (kept for the tests of the discretization). */
117+export function withEllipseMask(g: Grid): Grid {
118+ const a = ((g.nx - 1) * g.hx) / 2, b = ((g.ny - 1) * g.hy) / 2;
119+ return withMask(g, (x, y) => (x / a) ** 2 + (y / b) ** 2 <= 1);
120+}
121+
122+/** Node coordinates relative to the center of the bounding box. */
123+export function nodeX(g: Grid, i: number): number {
124+ return (i - (g.nx - 1) / 2) * g.hx;
125+}
126+export function nodeY(g: Grid, j: number): number {
127+ return (j - (g.ny - 1) / 2) * g.hy;
128+}
129+
130+/** True when node (i, j) exists and belongs to the plate. */
131+export function insideNode(g: Grid, i: number, j: number): boolean {
132+ if (i < 0 || i >= g.nx || j < 0 || j >= g.ny) return false;
133+ return g.mask ? g.mask[j * g.nx + i] !== 0 : true;
134+}
135+
136+/** True when cell (ci, cj), with nodes (ci..ci+1, cj..cj+1), lies within the plate. */
137+export function insideCell(g: Grid, ci: number, cj: number): boolean {
138+ return insideNode(g, ci, cj) && insideNode(g, ci + 1, cj) && insideNode(g, ci + 1, cj + 1) && insideNode(g, ci, cj + 1);
139+}
140+
141+/**
142+ * Quadrature and lumped-mass weight of a node: the fraction of its four
143+ * surrounding cells that belong to the plate. On a full rectangle this is the
144+ * trapezoidal rule (1 inside, 1/2 on an edge, 1/4 at a corner). Zero outside.
145+ */
146+export function nodeWeight(g: Grid, i: number, j: number): number {
147+ if (!insideNode(g, i, j)) return 0;
148+ let c = 0;
149+ if (insideCell(g, i, j)) c++;
150+ if (insideCell(g, i - 1, j)) c++;
151+ if (insideCell(g, i, j - 1)) c++;
152+ if (insideCell(g, i - 1, j - 1)) c++;
153+ return c / 4;
154+}
155+
156+/** Per-node weights as a flat array. */
157+export function nodeWeights(g: Grid): Float32Array<ArrayBuffer> {
158+ const w = new Float32Array(g.n);
159+ for (let j = 0; j < g.ny; j++) for (let i = 0; i < g.nx; i++) w[j * g.nx + i] = nodeWeight(g, i, j);
160+ return w;
161+}
162+
163+/** Per-cell membership (1 or 0) as a flat array of (nx - 1) * (ny - 1). */
164+export function cellMask(g: Grid): Float32Array<ArrayBuffer> {
165+ const c = new Float32Array((g.nx - 1) * (g.ny - 1));
166+ for (let cj = 0; cj < g.ny - 1; cj++) for (let ci = 0; ci < g.nx - 1; ci++) c[cj * (g.nx - 1) + ci] = insideCell(g, ci, cj) ? 1 : 0;
167+ return c;
168+}
169+
170+/** Number of nodes in the plate. */
171+export function countInside(g: Grid): number {
172+ if (!g.mask) return g.n;
173+ let c = 0;
174+ for (let k = 0; k < g.n; k++) c += g.mask[k];
175+ return c;
176+}
177+
178+// ---------- Material and plate ----------
179+
180+export interface Material {
181+ E: number; // Young's modulus [Pa]
182+ nu: number; // Poisson ratio
183+ rho: number; // density [kg/m^3]
184+}
185+
186+/** Losses of the plate (see THEORY.md). */
187+export interface Loss {
188+ sigma0: number; // frequency-independent loss of all motion [1/s]
189+ sigma1: number; // loss proportional to the squared wavenumber, transverse motion [m^2/s]
190+ sigmaIn: number; // additional frequency-independent loss of the in-plane motion [1/s]
191+}
192+
193+/**
194+ * The stand: the plate nodes within `radius` of (x, y) are tied to the ground
195+ * by a spring of total stiffness `stiffness` and a dashpot of total rate
196+ * `damping`, both spread over the nodes in proportion to their weight and
197+ * acting on all three displacement components.
198+ */
199+export interface Mount {
200+ x: number; // [m] from the center of the bounding box
201+ y: number;
202+ radius: number; // [m]
203+ stiffness: number; // [N/m]
204+ damping: number; // [N s/m]
205+}
206+
207+/** A design evaluated on a grid: everything the simulation needs to know about the plate. */
208+export interface Plate {
209+ name: string;
210+ Lx: number; // bounding box [m]
211+ Ly: number;
212+ grid: Grid;
213+ mat: Material;
214+ /** Thickness at every node [m]; zero outside the plate. */
215+ h: Float64Array;
216+ /** Stress-free shape at every node [m], mean plane removed; zero outside the plate. */
217+ w0: Float64Array;
218+ mount: Mount | null;
219+ loss: Loss;
220+}
221+
222+/** Bending stiffness [N m] of a plate of thickness h. */
223+export function bendingStiffness(m: Material, h: number): number {
224+ return (m.E * h ** 3) / (12 * (1 - m.nu * m.nu));
225+}
226+/** Membrane stiffness E h / (1 - nu^2) [N/m]. */
227+export function membraneStiffness(m: Material, h: number): number {
228+ return (m.E * h) / (1 - m.nu * m.nu);
229+}
230+/** Membrane shear stiffness E h / (2 (1 + nu)) [N/m]. */
231+export function shearStiffness(m: Material, h: number): number {
232+ return (m.E * h) / (2 * (1 + m.nu));
233+}
234+
235+/** Mass of node (i, j) [kg]: density, local thickness, cell area, and the node's weight. */
236+export function nodeMass(pl: Plate, i: number, j: number): number {
237+ const g = pl.grid;
238+ return pl.mat.rho * pl.h[j * g.nx + i] * g.hx * g.hy * nodeWeight(g, i, j);
239+}
240+
241+/** Thickness of a cell: the mean over its four corners (zero for a cell outside the plate). */
242+export function cellThickness(pl: Plate, ci: number, cj: number): number {
243+ const g = pl.grid;
244+ if (!insideCell(g, ci, cj)) return 0;
245+ const k = cj * g.nx + ci;
246+ return 0.25 * (pl.h[k] + pl.h[k + 1] + pl.h[k + g.nx] + pl.h[k + g.nx + 1]);
247+}
248+
249+/** Smallest, mean, and largest thickness over the plate nodes. */
250+export function thicknessRange(pl: Plate): { min: number; mean: number; max: number } {
251+ const g = pl.grid;
252+ let lo = Infinity, hi = 0, sum = 0, sw = 0;
253+ for (let j = 0; j < g.ny; j++) {
254+ for (let i = 0; i < g.nx; i++) {
255+ const wgt = nodeWeight(g, i, j);
256+ if (wgt === 0) continue;
257+ const h = pl.h[j * g.nx + i];
258+ lo = Math.min(lo, h); hi = Math.max(hi, h); sum += wgt * h; sw += wgt;
259+ }
260+ }
261+ return { min: isFinite(lo) ? lo : 0, mean: sw > 0 ? sum / sw : 0, max: hi };
262+}
263+
264+/** Total mass [kg] and the moments of inertia about the in-plane axes through the center of the bounding box. */
265+export function massProperties(pl: Plate): { mass: number; Ix: number; Iy: number } {
266+ const g = pl.grid;
267+ let mass = 0, Ix = 0, Iy = 0;
268+ for (let j = 0; j < g.ny; j++) {
269+ for (let i = 0; i < g.nx; i++) {
270+ const mk = nodeMass(pl, i, j);
271+ mass += mk;
272+ Ix += mk * nodeY(g, j) ** 2;
273+ Iy += mk * nodeX(g, i) ** 2;
274+ }
275+ }
276+ return { mass, Ix, Iy };
277+}
278+
279+/**
280+ * 60 dB decay time [s] of a bending mode of angular frequency omega under the
281+ * loss model sigma0 + sigma1 k^2, using the flat-plate dispersion k^2 = omega / kappa
282+ * for a plate of thickness h.
283+ */
284+export function t60(m: Material, h: number, loss: Loss, omega: number): number {
285+ const kappa = Math.sqrt(bendingStiffness(m, h) / (m.rho * h));
286+ const sigma = loss.sigma0 + loss.sigma1 * (omega / kappa);
287+ return (3 * Math.LN10) / sigma;
288+}
289+
290+/**
291+ * Frequency [Hz] at which the three-point second difference lowers a bending
292+ * frequency by the relative amount `err` on a grid of spacing hmax, for a
293+ * plate of thickness h. A plane bending wave along a grid axis with
294+ * wavenumber k has the discrete frequency ratio (2 - 2 cos kh) / (kh)^2.
295+ */
296+export function dispersionFrequency(m: Material, h: number, g: Grid, err: number): number {
297+ const hg = Math.max(g.hx, g.hy);
298+ let lo = 0, hi = Math.PI;
299+ for (let it = 0; it < 60; it++) {
300+ const x = (lo + hi) / 2;
301+ if ((2 - 2 * Math.cos(x)) / (x * x) > 1 - err) lo = x; else hi = x;
302+ }
303+ const k = lo / hg;
304+ const kappa = Math.sqrt(bendingStiffness(m, h) / (m.rho * h));
305+ return (kappa * k * k) / (2 * Math.PI);
306+}
307+
308+// ---------- Geometry of the excitation and the mount ----------
309+
310+/** Strike point in plate coordinates. */
311+export function strikePoint(p: SimParams, pl: Plate): [number, number] {
312+ return [(p.strikeFracX - 0.5) * pl.Lx, (p.strikeFracY - 0.5) * pl.Ly];
313+}
314+
315+/** The plate node nearest to a point, by index; -1 when the plate has no nodes. */
316+export function nearestNode(g: Grid, x: number, y: number): number {
317+ let nearest = -1, best = Infinity;
318+ for (let j = 0; j < g.ny; j++) {
319+ for (let i = 0; i < g.nx; i++) {
320+ if (nodeWeight(g, i, j) === 0) continue;
321+ const d2 = (nodeX(g, i) - x) ** 2 + (nodeY(g, j) - y) ** 2;
322+ if (d2 < best) { best = d2; nearest = j * g.nx + i; }
323+ }
324+ }
325+ return nearest;
326+}
327+
328+export interface Patch {
329+ nodes: number[];
330+ /** The plate node nearest the center; always a member. */
331+ nearest: number;
332+ /** Total mass of the member nodes [kg]. */
333+ mass: number;
334+ /** Total node weight of the members. */
335+ weight: number;
336+ /** Mass-weighted center [m]. */
337+ center: [number, number];
338+ /** Index box enclosing the members (i0, i1, j0, j1, inclusive). */
339+ box: [number, number, number, number];
340+}
341+
342+/**
343+ * The plate nodes within `radius` of (x, y), plus the nearest one. Used for
344+ * the drumstick contact patch and for the mount. The contact force is spread
345+ * over the patch in proportion to the node masses, and the contact coordinate
346+ * is their mass-weighted mean displacement, so the two are conjugate and the
347+ * contact has a potential energy. The mass-weighted center of the patch is
348+ * where the inertia-relief reaction to the force is taken.
349+ */
350+export function patchAround(pl: Plate, x: number, y: number, radius: number): Patch {
351+ const g = pl.grid;
352+ const nearest = nearestNode(g, x, y);
353+ const nodes: number[] = [];
354+ let mass = 0, weight = 0, cx = 0, cy = 0;
355+ const box: [number, number, number, number] = [g.nx, -1, g.ny, -1];
356+ for (let j = 0; j < g.ny; j++) {
357+ for (let i = 0; i < g.nx; i++) {
358+ const k = j * g.nx + i;
359+ const wgt = nodeWeight(g, i, j);
360+ if (wgt === 0) continue;
361+ const d2 = (nodeX(g, i) - x) ** 2 + (nodeY(g, j) - y) ** 2;
362+ if (d2 <= radius * radius || k === nearest) {
363+ nodes.push(k);
364+ const mk = nodeMass(pl, i, j);
365+ mass += mk; weight += wgt; cx += mk * nodeX(g, i); cy += mk * nodeY(g, j);
366+ box[0] = Math.min(box[0], i); box[1] = Math.max(box[1], i);
367+ box[2] = Math.min(box[2], j); box[3] = Math.max(box[3], j);
368+ }
369+ }
370+ }
371+ return { nodes, nearest, mass, weight, center: mass > 0 ? [cx / mass, cy / mass] : [x, y], box };
372+}
373+
374+export function contactPatch(p: SimParams, pl: Plate): Patch {
375+ const [sx, sy] = strikePoint(p, pl);
376+ return patchAround(pl, sx, sy, p.contactRadius);
377+}
378+
379+/** The mount patch, or null when the plate is free. */
380+export function mountPatch(pl: Plate): Patch | null {
381+ if (!pl.mount) return null;
382+ return patchAround(pl, pl.mount.x, pl.mount.y, pl.mount.radius);
383+}
src/presets.tsadded+148−0View file
@@ -0,0 +1,148 @@
1+// Predefined design scripts, selectable from the interface. Each is a plain
2+// JavaScript script body that returns the design object; see design.ts for
3+// the fields and the helpers in scope.
4+
5+export interface Preset {
6+ id: string;
7+ label: string;
8+ source: string;
9+}
10+
11+const flatRectangle = `// A flat rectangular sheet of stainless steel, hanging freely (no mount).
12+// Lengths are meters; inch and mm are conversion constants.
13+// Every field can be a number or a function (x, y, r, theta) of the point,
14+// measured from the center of the bounding box.
15+return {
16+ name: "Flat steel rectangle",
17+ material: { E: 170e9, nu: 0.29, rho: 7900 },
18+ bounds: { width: 12 * inch, height: 18 * inch },
19+ thickness: 0.79 * mm,
20+ profile: 0,
21+ mount: null,
22+ loss: { sigma0: 1.15, sigma1: 5e-4, sigmaIn: 20 },
23+};
24+`;
25+
26+const bowedRectangle = `// The same steel sheet with a 3 mm parabolic bow across its width, the
27+// kind of bow that rolling and handling leave in real sheet metal. The bow
28+// makes the sheet a shallow shell, which oil-cans and wobbles when struck.
29+const W = 12 * inch, H = 18 * inch;
30+return {
31+ name: "Bowed steel rectangle",
32+ material: { E: 170e9, nu: 0.29, rho: 7900 },
33+ bounds: { width: W, height: H },
34+ thickness: 0.79 * mm,
35+ // bow(s, span, sag, offset): parabola of the given sag across the span; the
36+ // offset in -1..1 moves the crest toward one edge.
37+ profile: (x, y) => bow(x, W, 3 * mm, 0.3),
38+ mount: null,
39+ loss: { sigma0: 1.15, sigma1: 5e-4, sigmaIn: 20 },
40+};
41+`;
42+
43+const flatDisc = `// A flat 16 inch steel disc with a center hole, sitting on a stand: the
44+// nodes within the mount radius are tied to the ground by a spring (the felt)
45+// and a dashpot, spread over the mount area.
46+const D = 16 * inch;
47+return {
48+ name: "Flat steel disc",
49+ material: { E: 170e9, nu: 0.29, rho: 7900 },
50+ bounds: { width: D, height: D },
51+ inside: circle(D, 12 * mm), // diameter, hole diameter
52+ thickness: 1.0 * mm,
53+ profile: 0,
54+ mount: { radius: 20 * mm, stiffness: 2e4, damping: 5 },
55+ loss: { sigma0: 1.15, sigma1: 5e-4, sigmaIn: 20 },
56+};
57+`;
58+
59+const crash16 = `// A 16 inch crash: B20 bronze, tapered from the bell to the edge, with a
60+// shallow bow and a cosine bell. The shallow-shell model behind the
61+// simulation is strained where the slope of the profile exceeds about 0.1
62+// to 0.2, so the bow and the bell are a little flatter than on a real crash.
63+const D = 16 * inch, R = D / 2;
64+const bellR = 60 * mm, bellH = 12 * mm; // base radius and height of the bell
65+const bowH = 16 * mm; // rise from the edge to the foot of the bell
66+return {
67+ name: "16 inch crash",
68+ material: { E: 110e9, nu: 0.34, rho: 8800 },
69+ bounds: { width: D, height: D },
70+ inside: circle(D, 12 * mm),
71+ // Taper: 1.4 mm at the bell, 0.8 mm at the edge.
72+ thickness: (x, y, r) => lerp(1.4 * mm, 0.8 * mm, clamp((r - bellR) / (R - bellR), 0, 1)),
73+ // A paraboloidal bow plus the bell; dome(d, radius, height) has zero slope at its rim.
74+ profile: (x, y, r) => bowH * (1 - (r / R) ** 2) + dome(r, bellR, bellH),
75+ mount: { radius: 20 * mm, stiffness: 2e4, damping: 5 },
76+ loss: { sigma0: 0.6, sigma1: 2.5e-4, sigmaIn: 20 },
77+};
78+`;
79+
80+const ride20 = `// A 20 inch ride: heavier bronze, a larger bell, a flatter bow. The taper
81+// is stronger than on the crash, and the mount is a little stiffer.
82+const D = 20 * inch, R = D / 2;
83+const bellR = 70 * mm, bellH = 14 * mm;
84+const bowH = 15 * mm;
85+return {
86+ name: "20 inch ride",
87+ material: { E: 110e9, nu: 0.34, rho: 8800 },
88+ bounds: { width: D, height: D },
89+ inside: circle(D, 12 * mm),
90+ thickness: (x, y, r) => lerp(2.0 * mm, 1.1 * mm, smoothstep(bellR, R, r)),
91+ profile: (x, y, r) => bowH * (1 - (r / R) ** 2) + dome(r, bellR, bellH),
92+ mount: { radius: 22 * mm, stiffness: 3e4, damping: 8 },
93+ loss: { sigma0: 0.5, sigma1: 2e-4, sigmaIn: 20 },
94+};
95+`;
96+
97+const ellipseTwist = `// A steel ellipse with a saddle (twist) and a bow, hanging freely. The
98+// twist raises two opposite quadrants and lowers the other two, which
99+// breaks the symmetry of the disc modes.
100+const W = 14 * inch, H = 20 * inch;
101+return {
102+ name: "Steel ellipse with a twist",
103+ material: { E: 170e9, nu: 0.29, rho: 7900 },
104+ bounds: { width: W, height: H },
105+ inside: ellipse(W, H),
106+ thickness: 0.79 * mm,
107+ profile: (x, y) => {
108+ const X = (2 * x) / W, Y = (2 * y) / H; // -1..1 over the bounding box
109+ return 2 * mm * X * Y + bow(y, H, 2 * mm);
110+ },
111+ mount: null,
112+ loss: { sigma0: 1.15, sigma1: 5e-4, sigmaIn: 20 },
113+};
114+`;
115+
116+const china18 = `// An 18 inch china: a conical bow that falls from the bell toward a flange,
117+// and an upturned edge that rises again over the outer 45 mm. The bell is
118+// square-topped (a plateau with rounded shoulders). The edge slope is well
119+// beyond the shallow-shell range, so this preset is the least trustworthy.
120+const D = 18 * inch, R = D / 2;
121+const bellR = 65 * mm, bellH = 14 * mm;
122+const flangeR = R - 45 * mm, bowH = 14 * mm, edgeH = 12 * mm;
123+return {
124+ name: "18 inch china",
125+ material: { E: 110e9, nu: 0.34, rho: 8800 },
126+ bounds: { width: D, height: D },
127+ inside: circle(D, 12 * mm),
128+ thickness: (x, y, r) => lerp(1.6 * mm, 0.9 * mm, smoothstep(bellR, flangeR, r)),
129+ profile: (x, y, r) =>
130+ bowH * Math.max(0, 1 - r / flangeR) // cone down to the flange
131+ + edgeH * smoothstep(flangeR, R, r) // upturned edge
132+ + bellH * (1 - smoothstep(bellR - 20 * mm, bellR, r)), // flat-topped bell
133+ mount: { radius: 20 * mm, stiffness: 2e4, damping: 5 },
134+ loss: { sigma0: 0.7, sigma1: 3e-4, sigmaIn: 20 },
135+};
136+`;
137+
138+export const PRESETS: Preset[] = [
139+ { id: "flat-rectangle", label: "Flat steel rectangle", source: flatRectangle },
140+ { id: "bowed-rectangle", label: "Bowed steel rectangle", source: bowedRectangle },
141+ { id: "flat-disc", label: "Flat steel disc on a stand", source: flatDisc },
142+ { id: "crash-16", label: "16 inch crash", source: crash16 },
143+ { id: "ride-20", label: "20 inch ride", source: ride20 },
144+ { id: "ellipse-twist", label: "Steel ellipse with a twist", source: ellipseTwist },
145+ { id: "china-18", label: "18 inch china", source: china18 },
146+];
147+
148+export const DEFAULT_PRESET = PRESETS[0];
src/render/mat4.tsadded+50−0View file
@@ -0,0 +1,50 @@
1+// Minimal column-major 4x4 matrix helpers (WebGPU layout).
2+
3+export type Mat4 = Float32Array;
4+
5+export function perspective(fovY: number, aspect: number, near: number, far: number): Mat4 {
6+ const f = 1 / Math.tan(fovY / 2);
7+ const m = new Float32Array(16);
8+ m[0] = f / aspect;
9+ m[5] = f;
10+ m[10] = far / (near - far);
11+ m[11] = -1;
12+ m[14] = (near * far) / (near - far);
13+ return m;
14+}
15+
16+export function lookAt(eye: number[], target: number[], up: number[]): Mat4 {
17+ const z = normalize([eye[0] - target[0], eye[1] - target[1], eye[2] - target[2]]);
18+ const x = normalize(cross(up, z));
19+ const y = cross(z, x);
20+ const m = new Float32Array(16);
21+ m[0] = x[0]; m[1] = y[0]; m[2] = z[0];
22+ m[4] = x[1]; m[5] = y[1]; m[6] = z[1];
23+ m[8] = x[2]; m[9] = y[2]; m[10] = z[2];
24+ m[12] = -dot(x, eye); m[13] = -dot(y, eye); m[14] = -dot(z, eye);
25+ m[15] = 1;
26+ return m;
27+}
28+
29+export function multiply(a: Mat4, b: Mat4): Mat4 {
30+ const out = new Float32Array(16);
31+ for (let c = 0; c < 4; c++) {
32+ for (let r = 0; r < 4; r++) {
33+ let s = 0;
34+ for (let k = 0; k < 4; k++) s += a[k * 4 + r] * b[c * 4 + k];
35+ out[c * 4 + r] = s;
36+ }
37+ }
38+ return out;
39+}
40+
41+function cross(a: number[], b: number[]): number[] {
42+ return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
43+}
44+function dot(a: number[], b: number[]): number {
45+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
46+}
47+function normalize(a: number[]): number[] {
48+ const l = Math.hypot(a[0], a[1], a[2]) || 1;
49+ return [a[0] / l, a[1] / l, a[2] / l];
50+}
src/render/renderer.tsadded+230−0View file
@@ -0,0 +1,230 @@
1+// WebGPU rendering of the plate: a 2D color map or a lit 3D height field of
2+// the displacement (plus the stress-free shape), colored by the displacement
3+// or by the thickness.
4+
5+import { type Grid, cellMask, insideCell } from "../params.ts";
6+import { lookAt, multiply, perspective } from "./mat4.ts";
7+import shaderCode from "./shaders.wgsl?raw";
8+
9+export type ViewMode = "2d" | "3d";
10+export type ColorMode = "field" | "thickness";
11+
12+export interface Marker {
13+ x: number;
14+ y: number;
15+ r: number;
16+}
17+
18+export class Renderer {
19+ mode: ViewMode = "2d";
20+ /** Vertical exaggeration of the 3D view. */
21+ exaggeration = 4;
22+ /** Color range |w| [m]; 0 means automatic (from the current maximum). */
23+ colorRange = 0;
24+ /** Show the total shape (initial shape plus deviation) rather than the deviation alone, in both views. */
25+ showShape = true;
26+ colorMode: ColorMode = "field";
27+ camera = { yaw: 0.6, pitch: 0.5, dist: 1.0 };
28+ /** Markers drawn in the 2D view; read at every render. */
29+ strike: Marker = { x: 0, y: 0, r: 0 };
30+ mount: Marker | null = null;
31+
32+ private device: GPUDevice;
33+ private canvas: HTMLCanvasElement;
34+ private ctx: GPUCanvasContext;
35+ private format: GPUTextureFormat;
36+ private uniform: GPUBuffer;
37+ private pipe2d: GPURenderPipeline;
38+ private pipe3d: GPURenderPipeline;
39+ private bgLayout: GPUBindGroupLayout;
40+ private index: GPUBuffer | null = null;
41+ private indexCount = 0;
42+ private depth: GPUTexture | null = null;
43+ private grid: Grid | null = null;
44+ private bindGroups = new Map<GPUBuffer, GPUBindGroup>();
45+
46+ private w0: GPUBuffer | null = null;
47+ private w0Max = 0;
48+ private nw: GPUBuffer | null = null;
49+ private hn: GPUBuffer | null = null;
50+ private cm: GPUBuffer | null = null;
51+ private hRange: [number, number] = [0, 1];
52+
53+ /** The grid of the field currently set, or null before setField. */
54+ get fieldGrid(): Grid | null {
55+ return this.grid;
56+ }
57+
58+ /** The renderer draws nothing until setField gives it a grid. */
59+ constructor(device: GPUDevice, canvas: HTMLCanvasElement) {
60+ this.device = device;
61+ this.canvas = canvas;
62+ this.ctx = canvas.getContext("webgpu") as GPUCanvasContext;
63+ this.format = navigator.gpu.getPreferredCanvasFormat();
64+ this.ctx.configure({ device, format: this.format, alphaMode: "opaque" });
65+ this.uniform = device.createBuffer({ size: 160, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
66+ const module = device.createShaderModule({ code: shaderCode });
67+ const vis = GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT;
68+ this.bgLayout = device.createBindGroupLayout({
69+ entries: [
70+ { binding: 0, visibility: vis, buffer: { type: "uniform" } },
71+ { binding: 1, visibility: vis, buffer: { type: "read-only-storage" } },
72+ { binding: 2, visibility: vis, buffer: { type: "read-only-storage" } },
73+ { binding: 3, visibility: vis, buffer: { type: "read-only-storage" } },
74+ { binding: 4, visibility: vis, buffer: { type: "read-only-storage" } },
75+ { binding: 5, visibility: vis, buffer: { type: "read-only-storage" } },
76+ ],
77+ });
78+ const layout = device.createPipelineLayout({ bindGroupLayouts: [this.bgLayout] });
79+ this.pipe2d = device.createRenderPipeline({
80+ layout,
81+ vertex: { module, entryPoint: "vs2d" },
82+ fragment: { module, entryPoint: "fs2d", targets: [{ format: this.format }] },
83+ primitive: { topology: "triangle-list" },
84+ });
85+ this.pipe3d = device.createRenderPipeline({
86+ layout,
87+ vertex: { module, entryPoint: "vs3d" },
88+ fragment: { module, entryPoint: "fs3d", targets: [{ format: this.format }] },
89+ primitive: { topology: "triangle-list", cullMode: "none" },
90+ depthStencil: { format: "depth24plus", depthWriteEnabled: true, depthCompare: "less" },
91+ });
92+ this.installControls();
93+ }
94+
95+ /**
96+ * Sets the grid and the per-node buffers the views read: the stress-free
97+ * shape (added to the displayed field when showShape is on; w0Max is its
98+ * largest magnitude, for the automatic color range), the node weights (zero
99+ * outside the plate), and the thickness with its range for the thickness
100+ * coloring. Called at every Start and for the preview of the design; the
101+ * camera and the canvas controls persist.
102+ */
103+ setField(grid: Grid, w0: GPUBuffer, nw: GPUBuffer, hn: GPUBuffer, w0Max: number, hRange: [number, number]): void {
104+ this.grid = grid;
105+ this.w0 = w0;
106+ this.w0Max = w0Max;
107+ this.nw = nw;
108+ this.hn = hn;
109+ this.hRange = hRange;
110+ this.bindGroups.clear();
111+ const cmData = cellMask(grid);
112+ this.cm?.destroy();
113+ this.cm = this.device.createBuffer({ size: Math.max(16, cmData.byteLength), usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
114+ this.device.queue.writeBuffer(this.cm, 0, cmData);
115+ // Triangle indices of the height field (cells of the plate only).
116+ const { nx, ny } = grid;
117+ const idx = new Uint32Array((nx - 1) * (ny - 1) * 6);
118+ let k = 0;
119+ for (let j = 0; j < ny - 1; j++) {
120+ for (let i = 0; i < nx - 1; i++) {
121+ if (!insideCell(grid, i, j)) continue;
122+ const a = j * nx + i, b = a + 1, c = a + nx, d = c + 1;
123+ idx[k++] = a; idx[k++] = b; idx[k++] = d;
124+ idx[k++] = a; idx[k++] = d; idx[k++] = c;
125+ }
126+ }
127+ this.indexCount = k;
128+ this.index?.destroy();
129+ this.index = this.device.createBuffer({ size: Math.max(16, idx.byteLength), usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST });
130+ this.device.queue.writeBuffer(this.index, 0, idx);
131+ }
132+
133+ private installControls(): void {
134+ let dragging = false, lastX = 0, lastY = 0;
135+ this.canvas.addEventListener("pointerdown", (e) => {
136+ dragging = true; lastX = e.clientX; lastY = e.clientY;
137+ this.canvas.setPointerCapture(e.pointerId);
138+ });
139+ this.canvas.addEventListener("pointerup", () => (dragging = false));
140+ this.canvas.addEventListener("pointermove", (e) => {
141+ if (!dragging || this.mode !== "3d") return;
142+ this.camera.yaw += (e.clientX - lastX) * 0.01;
143+ this.camera.pitch = Math.max(-1.5, Math.min(1.5, this.camera.pitch + (e.clientY - lastY) * 0.01));
144+ lastX = e.clientX; lastY = e.clientY;
145+ });
146+ this.canvas.addEventListener("wheel", (e) => {
147+ if (this.mode !== "3d") return;
148+ e.preventDefault();
149+ this.camera.dist *= Math.exp(e.deltaY * 0.001);
150+ this.camera.dist = Math.max(0.2, Math.min(5, this.camera.dist));
151+ }, { passive: false });
152+ }
153+
154+ private resize(): void {
155+ const dpr = window.devicePixelRatio || 1;
156+ const w = Math.max(1, Math.floor(this.canvas.clientWidth * dpr));
157+ const h = Math.max(1, Math.floor(this.canvas.clientHeight * dpr));
158+ if (this.canvas.width !== w || this.canvas.height !== h || !this.depth) {
159+ this.canvas.width = w;
160+ this.canvas.height = h;
161+ this.depth?.destroy();
162+ this.depth = this.device.createTexture({ size: [w, h], format: "depth24plus", usage: GPUTextureUsage.RENDER_ATTACHMENT });
163+ }
164+ }
165+
166+ /** Renders the w field held in `wBuffer` (f32 per node). `wmax`, the largest |w|, drives the automatic color range. */
167+ render(encoder: GPUCommandEncoder, wBuffer: GPUBuffer, wmax: number): void {
168+ if (!this.grid || !this.w0 || !this.nw || !this.hn || !this.cm || !this.index) return;
169+ this.resize();
170+ const aspect = this.canvas.width / this.canvas.height;
171+ const auto = wmax + (this.showShape ? this.w0Max : 0);
172+ const range = this.colorRange > 0 ? this.colorRange : Math.max(auto, 1e-6);
173+ const u = new Float32Array(40);
174+ const ui = new Uint32Array(u.buffer);
175+ const eye = [
176+ this.camera.dist * Math.cos(this.camera.pitch) * Math.sin(this.camera.yaw),
177+ this.camera.dist * Math.sin(this.camera.pitch),
178+ this.camera.dist * Math.cos(this.camera.pitch) * Math.cos(this.camera.yaw),
179+ ];
180+ const mvp = multiply(perspective(0.7, aspect, 0.02, 20), lookAt(eye, [0, 0, 0], [0, 1, 0]));
181+ u.set(mvp, 0);
182+ ui[16] = this.grid.nx; ui[17] = this.grid.ny;
183+ u[18] = this.grid.hx; u[19] = this.grid.hy;
184+ u[20] = this.exaggeration; u[21] = range; u[22] = aspect;
185+ ui[23] = this.mode === "3d" ? 1 : 0;
186+ u[24] = this.strike.x; u[25] = this.strike.y; u[26] = this.strike.r;
187+ u[27] = this.mount?.x ?? 0; u[28] = this.mount?.y ?? 0; u[29] = this.mount?.r ?? 0;
188+ u[30] = 0.4; u[31] = 0.5; u[32] = 1.0;
189+ ui[33] = this.showShape ? 1 : 0;
190+ ui[34] = this.colorMode === "thickness" ? 1 : 0;
191+ ui[35] = this.mount ? 1 : 0;
192+ u[36] = this.hRange[0]; u[37] = this.hRange[1];
193+ this.device.queue.writeBuffer(this.uniform, 0, u);
194+
195+ let bg = this.bindGroups.get(wBuffer);
196+ if (!bg) {
197+ bg = this.device.createBindGroup({
198+ layout: this.bgLayout,
199+ entries: [
200+ { binding: 0, resource: { buffer: this.uniform } },
201+ { binding: 1, resource: { buffer: wBuffer } },
202+ { binding: 2, resource: { buffer: this.w0 } },
203+ { binding: 3, resource: { buffer: this.nw } },
204+ { binding: 4, resource: { buffer: this.hn } },
205+ { binding: 5, resource: { buffer: this.cm } },
206+ ],
207+ });
208+ this.bindGroups.set(wBuffer, bg);
209+ }
210+ const view = this.ctx.getCurrentTexture().createView();
211+ const clear = { r: 0.11, g: 0.12, b: 0.14, a: 1 };
212+ if (this.mode === "2d") {
213+ const pass = encoder.beginRenderPass({ colorAttachments: [{ view, clearValue: clear, loadOp: "clear", storeOp: "store" }] });
214+ pass.setPipeline(this.pipe2d);
215+ pass.setBindGroup(0, bg);
216+ pass.draw(3);
217+ pass.end();
218+ } else {
219+ const pass = encoder.beginRenderPass({
220+ colorAttachments: [{ view, clearValue: clear, loadOp: "clear", storeOp: "store" }],
221+ depthStencilAttachment: { view: this.depth!.createView(), depthClearValue: 1, depthLoadOp: "clear", depthStoreOp: "store" },
222+ });
223+ pass.setPipeline(this.pipe3d);
224+ pass.setBindGroup(0, bg);
225+ pass.setIndexBuffer(this.index, "uint32");
226+ pass.drawIndexed(this.indexCount);
227+ pass.end();
228+ }
229+ }
230+}
src/render/shaders.wgsladded+168−0View file
@@ -0,0 +1,168 @@
1+struct RU {
2+ mvp: mat4x4f,
3+ nx: u32,
4+ ny: u32,
5+ hx: f32,
6+ hy: f32,
7+ wScale: f32, // vertical exaggeration in the 3D view
8+ colorRange: f32, // |w| mapped to full color [m]
9+ aspect: f32, // canvas width / height
10+ mode: u32, // 0: 2D, 1: 3D
11+ strikeX: f32,
12+ strikeY: f32,
13+ strikeR: f32,
14+ mountX: f32,
15+ mountY: f32,
16+ mountR: f32,
17+ lightX: f32,
18+ lightY: f32,
19+ lightZ: f32,
20+ showShape: u32, // add the stress-free initial shape to the displayed field (both views)
21+ colorMode: u32, // 0: color by the displayed field, 1: color by the thickness
22+ mountOn: u32, // draw the mount marker
23+ hMin: f32, // thickness range for colorMode 1 [m]
24+ hMax: f32,
25+ pad1: u32,
26+ pad2: u32,
27+};
28+
29+@group(0) @binding(0) var<uniform> R: RU;
30+@group(0) @binding(1) var<storage, read> w: array<f32>;
31+@group(0) @binding(2) var<storage, read> w0: array<f32>;
32+@group(0) @binding(3) var<storage, read> nw: array<f32>; // node weights, 0 outside the plate
33+@group(0) @binding(4) var<storage, read> hn: array<f32>; // thickness per node
34+@group(0) @binding(5) var<storage, read> cm: array<f32>; // cell membership, (nx - 1) * (ny - 1)
35+
36+const BG = vec3f(0.11, 0.12, 0.14);
37+
38+// Diverging map for the displacement: blue, light gray, red.
39+fn colormap(t: f32) -> vec3f {
40+ let s = clamp(t, -1.0, 1.0);
41+ let mid = vec3f(0.93, 0.93, 0.93);
42+ let blue = vec3f(0.10, 0.35, 0.85);
43+ let red = vec3f(0.85, 0.20, 0.12);
44+ if (s < 0.0) { return mix(mid, blue, -s); }
45+ return mix(mid, red, s);
46+}
47+// Sequential map for the thickness: dark teal through green to pale yellow.
48+fn thickmap(t: f32) -> vec3f {
49+ let s = clamp(t, 0.0, 1.0);
50+ let a = vec3f(0.13, 0.22, 0.40);
51+ let b = vec3f(0.20, 0.60, 0.55);
52+ let c = vec3f(0.98, 0.93, 0.55);
53+ if (s < 0.5) { return mix(a, b, 2.0 * s); }
54+ return mix(b, c, 2.0 * s - 1.0);
55+}
56+
57+fn idx(i: i32, j: i32) -> u32 {
58+ let ii = clamp(i, 0, i32(R.nx) - 1);
59+ let jj = clamp(j, 0, i32(R.ny) - 1);
60+ return u32(jj) * R.nx + u32(ii);
61+}
62+// Displayed field: the deviation w, plus the initial shape when requested (the total shape).
63+fn wAt(i: i32, j: i32) -> f32 {
64+ let k = idx(i, j);
65+ var z = w[k];
66+ if (R.showShape != 0u) { z += w0[k]; }
67+ return z;
68+}
69+fn hAt(i: i32, j: i32) -> f32 { return hn[idx(i, j)]; }
70+fn nwAt(i: i32, j: i32) -> f32 {
71+ if (i < 0 || i >= i32(R.nx) || j < 0 || j >= i32(R.ny)) { return 0.0; }
72+ return nw[u32(j) * R.nx + u32(i)];
73+}
74+fn cellAt(ci: i32, cj: i32) -> f32 {
75+ if (ci < 0 || ci >= i32(R.nx) - 1 || cj < 0 || cj >= i32(R.ny) - 1) { return 0.0; }
76+ return cm[u32(cj) * (R.nx - 1u) + u32(ci)];
77+}
78+// Color of a node from the displayed field value and the thickness.
79+fn colorOf(val: f32, h: f32) -> vec3f {
80+ if (R.colorMode == 1u) { return thickmap((h - R.hMin) / max(R.hMax - R.hMin, 1e-9)); }
81+ return colormap(val / R.colorRange);
82+}
83+// Height used by the 3D view: the same field as the color.
84+fn zAt(i: i32, j: i32) -> f32 { return wAt(i, j); }
85+// Slope along one axis, one-sided where a neighbor lies outside the plate.
86+fn slope(i: i32, j: i32, di: i32, dj: i32, h: f32) -> f32 {
87+ let hasP = nwAt(i + di, j + dj) > 0.0;
88+ let hasM = nwAt(i - di, j - dj) > 0.0;
89+ if (hasP && hasM) { return (zAt(i + di, j + dj) - zAt(i - di, j - dj)) / (2.0 * h); }
90+ if (hasP) { return (zAt(i + di, j + dj) - zAt(i, j)) / h; }
91+ if (hasM) { return (zAt(i, j) - zAt(i - di, j - dj)) / h; }
92+ return 0.0;
93+}
94+
95+// ---------- 2D view: full-screen triangle, bilinear lookup in the fragment shader ----------
96+
97+struct V2 { @builtin(position) pos: vec4f, @location(0) uv: vec2f };
98+
99+@vertex
100+fn vs2d(@builtin(vertex_index) vi: u32) -> V2 {
101+ var p = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0));
102+ var o: V2;
103+ o.pos = vec4f(p[vi], 0.0, 1.0);
104+ o.uv = p[vi];
105+ return o;
106+}
107+
108+@fragment
109+fn fs2d(in: V2) -> @location(0) vec4f {
110+ let Lx = f32(R.nx - 1u) * R.hx;
111+ let Ly = f32(R.ny - 1u) * R.hy;
112+ // Fit the plate into the canvas with a margin, preserving its aspect ratio.
113+ let margin = 0.92;
114+ var sx = margin; var sy = margin;
115+ let plateAspect = Lx / Ly;
116+ if (plateAspect > R.aspect) { sy = margin * R.aspect / plateAspect; } else { sx = margin * plateAspect / R.aspect; }
117+ let px = in.uv.x / sx * 0.5 * Lx;
118+ let py = in.uv.y / sy * 0.5 * Ly;
119+ let bg = vec4f(BG, 1.0);
120+ if (abs(px) > 0.5 * Lx || abs(py) > 0.5 * Ly) { return bg; }
121+ let fi = (px + 0.5 * Lx) / R.hx;
122+ let fj = (py + 0.5 * Ly) / R.hy;
123+ let i0 = min(i32(floor(fi)), i32(R.nx) - 2); let j0 = min(i32(floor(fj)), i32(R.ny) - 2);
124+ // Only the cells of the plate are drawn (staircase edge, hole included).
125+ if (cellAt(i0, j0) == 0.0) { return bg; }
126+ let a = fi - f32(i0); let b = fj - f32(j0);
127+ let c = vec4f((1.0 - a) * (1.0 - b), a * (1.0 - b), (1.0 - a) * b, a * b);
128+ let val = dot(c, vec4f(wAt(i0, j0), wAt(i0 + 1, j0), wAt(i0, j0 + 1), wAt(i0 + 1, j0 + 1)));
129+ let h = dot(c, vec4f(hAt(i0, j0), hAt(i0 + 1, j0), hAt(i0, j0 + 1), hAt(i0 + 1, j0 + 1)));
130+ var col = colorOf(val, h);
131+ // Markers: the mount (dark ring) and the strike point (yellow ring).
132+ if (R.mountOn != 0u) {
133+ let dm = distance(vec2f(px, py), vec2f(R.mountX, R.mountY));
134+ if (abs(dm - R.mountR) < 0.12 * R.mountR + 0.0015) { col = vec3f(0.1, 0.1, 0.1); }
135+ }
136+ let ds = distance(vec2f(px, py), vec2f(R.strikeX, R.strikeY));
137+ if (abs(ds - R.strikeR * 1.6) < 0.25 * R.strikeR) { col = vec3f(0.95, 0.75, 0.1); }
138+ return vec4f(col, 1.0);
139+}
140+
141+// ---------- 3D view: height field ----------
142+
143+struct V3 { @builtin(position) pos: vec4f, @location(0) color: vec3f, @location(1) normal: vec3f };
144+
145+@vertex
146+fn vs3d(@builtin(vertex_index) vi: u32) -> V3 {
147+ let i = i32(vi % R.nx); let j = i32(vi / R.nx);
148+ let x = (f32(i) - 0.5 * f32(R.nx - 1u)) * R.hx;
149+ let y = (f32(j) - 0.5 * f32(R.ny - 1u)) * R.hy;
150+ let val = wAt(i, j);
151+ let z = zAt(i, j) * R.wScale;
152+ let dzdx = slope(i, j, 1, 0, R.hx) * R.wScale;
153+ let dzdy = slope(i, j, 0, 1, R.hy) * R.wScale;
154+ var o: V3;
155+ o.pos = R.mvp * vec4f(x, y, z, 1.0);
156+ o.color = colorOf(val, hAt(i, j));
157+ o.normal = normalize(vec3f(-dzdx, -dzdy, 1.0));
158+ return o;
159+}
160+
161+@fragment
162+fn fs3d(in: V3) -> @location(0) vec4f {
163+ let l = normalize(vec3f(R.lightX, R.lightY, R.lightZ));
164+ let n = normalize(in.normal);
165+ let diff = abs(dot(n, l));
166+ let col = in.color * (0.35 + 0.65 * diff) + vec3f(0.15) * pow(diff, 24.0);
167+ return vec4f(col, 1.0);
168+}
src/selftest.tsadded+119−0View file
@@ -0,0 +1,119 @@
1+// GPU-versus-CPU self-test: one time step of the WGSL kernel on a smooth,
2+// strongly nonlinear random state is compared with the float64 reference,
3+// for several of the preset designs (flat and bowed rectangles, tapered
4+// discs with a hole and a mount, an ellipse). Runs in the browser
5+// (?selftest=1) and is driven by tests/gpu.test.ts.
6+
7+import { defaultParams, nodeWeight, type SimParams } from "./params.ts";
8+import { compileDesign, evaluatePlate } from "./design.ts";
9+import { PRESETS } from "./presets.ts";
10+import { accelerations, addDamping, fields } from "./sim/cpuRef.ts";
11+import { Simulation } from "./sim/simulation.ts";
12+
13+export interface SelfTestCase {
14+ name: string;
15+ /** Maximum relative error of the acceleration of each component, over the plate. */
16+ accError: { u: number; v: number; w: number };
17+ /** Maximum error of the compensated w update relative to the largest increment dt * w_t. */
18+ positionError: number;
19+ nodes: number;
20+ dt: number;
21+}
22+
23+export const ACC_TOLERANCE = 1e-3;
24+export const POSITION_TOLERANCE = 1e-2;
25+
26+const cases: { name: string; preset: string; overrides?: Partial<SimParams> }[] = [
27+ { name: "flat rectangle", preset: "flat-rectangle" },
28+ { name: "bowed rectangle", preset: "bowed-rectangle" },
29+ { name: "16 inch crash (taper, bell, hole, mount)", preset: "crash-16" },
30+ { name: "18 inch china", preset: "china-18" },
31+ { name: "ellipse with a twist", preset: "ellipse-twist" },
32+ { name: "bowed rectangle, in-plane mass x4", preset: "bowed-rectangle", overrides: { inPlaneMass: 4 } },
33+];
34+
35+/** A smooth random field: a few sinusoids across the plate plus a little white noise. */
36+function smoothField(nx: number, ny: number, amplitude: number, noise: number, rnd: () => number): Float64Array {
37+ const f = new Float64Array(nx * ny);
38+ const terms = Array.from({ length: 6 }, () => ({ kx: 1 + Math.floor(4 * rnd()), ky: 1 + Math.floor(4 * rnd()), px: 2 * Math.PI * rnd(), py: 2 * Math.PI * rnd(), a: rnd() - 0.5 }));
39+ for (let j = 0; j < ny; j++) {
40+ for (let i = 0; i < nx; i++) {
41+ let s = 0;
42+ for (const t of terms) s += t.a * Math.cos((t.kx * Math.PI * i) / (nx - 1) + t.px) * Math.cos((t.ky * Math.PI * j) / (ny - 1) + t.py);
43+ f[j * nx + i] = amplitude * s + noise * (rnd() - 0.5);
44+ }
45+ }
46+ return f;
47+}
48+
49+export async function runSelfTest(device: GPUDevice): Promise<SelfTestCase[]> {
50+ const results: SelfTestCase[] = [];
51+ let seed = 2024;
52+ const rnd = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296);
53+ for (const c of cases) {
54+ const preset = PRESETS.find((p) => p.id === c.preset)!;
55+ const params: SimParams = { ...defaultParams, ...c.overrides, spacing: 0.012 };
56+ const plate = evaluatePlate(compileDesign(preset.source), params.spacing);
57+ const sim = new Simulation(device, plate, params);
58+ const g = sim.grid;
59+ const n = g.n;
60+
61+ // State in float32 (what the GPU sees), then the same values in float64 for the reference.
62+ const pos = new Float32Array(4 * n), vel = new Float32Array(3 * n);
63+ const fw = smoothField(g.nx, g.ny, 2e-3, 5e-5, rnd); // about 2.5 thicknesses: strongly nonlinear
64+ const fu = smoothField(g.nx, g.ny, 2e-5, 1e-6, rnd);
65+ const fv = smoothField(g.nx, g.ny, 2e-5, 1e-6, rnd);
66+ const fdw = smoothField(g.nx, g.ny, 0.5, 1e-2, rnd);
67+ const fdu = smoothField(g.nx, g.ny, 1e-2, 1e-3, rnd);
68+ const fdv = smoothField(g.nx, g.ny, 1e-2, 1e-3, rnd);
69+ for (let k = 0; k < n; k++) {
70+ if (nodeWeight(g, k % g.nx, Math.floor(k / g.nx)) === 0) continue;
71+ pos[k] = fu[k]; pos[n + k] = fv[k]; pos[2 * n + k] = fw[k]; pos[3 * n + k] = 0;
72+ vel[k] = fdu[k]; vel[n + k] = fdv[k]; vel[2 * n + k] = fdw[k];
73+ }
74+ const x = fields(n), v = fields(n);
75+ for (let k = 0; k < n; k++) {
76+ x.u[k] = pos[k]; x.v[k] = pos[n + k]; x.w[k] = pos[2 * n + k];
77+ v.u[k] = vel[k]; v.v[k] = vel[n + k]; v.w[k] = vel[2 * n + k];
78+ }
79+
80+ sim.writeState(pos, vel);
81+ await sim.stepOnce();
82+ const out = await sim.readState();
83+ sim.destroy();
84+
85+ // Reference: a = elastic and mount (in-plane scaled) + losses, v' = v + dt a, w' = w + dt v'.
86+ const acc = fields(n);
87+ accelerations(plate, x, acc);
88+ for (let k = 0; k < n; k++) { acc.u[k] /= params.inPlaneMass; acc.v[k] /= params.inPlaneMass; }
89+ addDamping(plate, v, acc);
90+ const dt = sim.dt;
91+ const err = { u: 0, v: 0, w: 0 };
92+ let scale = { u: 0, v: 0, w: 0 };
93+ let posErr = 0, posScale = 0;
94+ for (let k = 0; k < n; k++) {
95+ if (nodeWeight(g, k % g.nx, Math.floor(k / g.nx)) === 0) continue;
96+ const gu = (out.vel[k] - vel[k]) / dt, gv = (out.vel[n + k] - vel[n + k]) / dt, gw = (out.vel[2 * n + k] - vel[2 * n + k]) / dt;
97+ err.u = Math.max(err.u, Math.abs(gu - acc.u[k]));
98+ err.v = Math.max(err.v, Math.abs(gv - acc.v[k]));
99+ err.w = Math.max(err.w, Math.abs(gw - acc.w[k]));
100+ scale = { u: Math.max(scale.u, Math.abs(acc.u[k])), v: Math.max(scale.v, Math.abs(acc.v[k])), w: Math.max(scale.w, Math.abs(acc.w[k])) };
101+ const vNew = v.w[k] + dt * acc.w[k];
102+ const wNew = x.w[k] + dt * vNew;
103+ posErr = Math.max(posErr, Math.abs(out.pos[2 * n + k] + out.pos[3 * n + k] - wNew));
104+ posScale = Math.max(posScale, Math.abs(dt * vNew));
105+ }
106+ results.push({
107+ name: c.name,
108+ accError: { u: err.u / scale.u, v: err.v / scale.v, w: err.w / scale.w },
109+ positionError: posErr / posScale,
110+ nodes: n,
111+ dt,
112+ });
113+ }
114+ return results;
115+}
116+
117+export function selfTestPassed(r: SelfTestCase): boolean {
118+ return r.accError.u < ACC_TOLERANCE && r.accError.v < ACC_TOLERANCE && r.accError.w < ACC_TOLERANCE && r.positionError < POSITION_TOLERANCE;
119+}
src/sim/cpuRef.tsadded+304−0View file
@@ -0,0 +1,304 @@
1+// Float64 reference implementation of the spatial discretization. It mirrors
2+// step.wgsl and is used for the time-step estimate, the tests, and the
3+// GPU-versus-CPU self-test. It is not used in the time loop.
4+
5+import {
6+ type Grid,
7+ type Material,
8+ type Patch,
9+ type Plate,
10+ bendingStiffness,
11+ cellThickness,
12+ insideCell,
13+ insideNode,
14+ membraneStiffness,
15+ mountPatch,
16+ nodeMass,
17+ nodeWeight,
18+ shearStiffness,
19+} from "../params.ts";
20+
21+export interface Fields {
22+ u: Float64Array;
23+ v: Float64Array;
24+ w: Float64Array;
25+}
26+
27+export function fields(n: number): Fields {
28+ return { u: new Float64Array(n), v: new Float64Array(n), w: new Float64Array(n) };
29+}
30+
31+/** A plate of uniform thickness on the given grid (for the tests and the time-step estimate). */
32+export function uniformPlate(g: Grid, mat: Material, h: number, w0?: Float64Array): Plate {
33+ const hs = new Float64Array(g.n);
34+ for (let j = 0; j < g.ny; j++) for (let i = 0; i < g.nx; i++) if (insideNode(g, i, j)) hs[j * g.nx + i] = h;
35+ return { name: "uniform", Lx: (g.nx - 1) * g.hx, Ly: (g.ny - 1) * g.hy, grid: g, mat, h: hs, w0: w0 ?? new Float64Array(g.n), mount: null, loss: { sigma0: 0, sigma1: 0, sigmaIn: 0 } };
36+}
37+
38+const G3 = 1 / Math.sqrt(3);
39+
40+/** Shape function derivatives of the bilinear cell at Gauss point gp (0..3), scaled by 2/h. */
41+function shapeDerivatives(gp: number, hx: number, hy: number, dNdx: Float64Array, dNdy: Float64Array): void {
42+ const xi = gp & 1 ? G3 : -G3;
43+ const eta = gp & 2 ? G3 : -G3;
44+ dNdx[0] = (-(1 - eta) / 4) * (2 / hx);
45+ dNdx[1] = ((1 - eta) / 4) * (2 / hx);
46+ dNdx[2] = ((1 + eta) / 4) * (2 / hx);
47+ dNdx[3] = (-(1 + eta) / 4) * (2 / hx);
48+ dNdy[0] = (-(1 - xi) / 4) * (2 / hy);
49+ dNdy[1] = (-(1 + xi) / 4) * (2 / hy);
50+ dNdy[2] = ((1 + xi) / 4) * (2 / hy);
51+ dNdy[3] = ((1 - xi) / 4) * (2 / hy);
52+}
53+
54+/** Per-node spring stiffness of the mount [N/m] (zero away from the mount). */
55+export function mountStiffness(pl: Plate, patch: Patch | null): Float64Array {
56+ const g = pl.grid;
57+ const K = new Float64Array(g.n);
58+ if (!pl.mount || !patch || patch.weight === 0) return K;
59+ for (const k of patch.nodes) K[k] = (pl.mount.stiffness * nodeWeight(g, k % g.nx, Math.floor(k / g.nx))) / patch.weight;
60+ return K;
61+}
62+
63+/** Per-node dashpot rate of the mount [N s/m] (zero away from the mount). */
64+export function mountDamping(pl: Plate, patch: Patch | null): Float64Array {
65+ const g = pl.grid;
66+ const C = new Float64Array(g.n);
67+ if (!pl.mount || !patch || patch.weight === 0) return C;
68+ for (const k of patch.nodes) C[k] = (pl.mount.damping * nodeWeight(g, k % g.nx, Math.floor(k / g.nx))) / patch.weight;
69+ return C;
70+}
71+
72+/**
73+ * Undamped accelerations (out.u, out.v, out.w) of every node for the
74+ * displacement fields f, measured from the stress-free shape pl.w0. Bending
75+ * forces are the gradient of the discrete Kirchhoff energy with the local
76+ * bending stiffness D(h) at the nodes and at the cells (natural free-edge
77+ * conditions); membrane forces come from bilinear cells with 2x2 Gauss
78+ * integration, the cell's mean thickness, and the Marguerre form of the von
79+ * Karman strains; the mount adds a spring to ground over its patch. Nodes
80+ * outside the plate get zero.
81+ */
82+export function accelerations(pl: Plate, f: Fields, out: Fields): void {
83+ const g = pl.grid, m = pl.mat, w0 = pl.w0;
84+ const { nx, ny, hx, hy } = g;
85+ const { u, v, w } = f;
86+ const hx2 = hx * hx, hy2 = hy * hy;
87+ const nu = m.nu;
88+ const id = (i: number, j: number) => j * nx + i;
89+ const Dn = (i: number, j: number) => bendingStiffness(m, pl.h[id(i, j)]);
90+ const Dc = (ci: number, cj: number) => bendingStiffness(m, cellThickness(pl, ci, cj));
91+
92+ // A second difference exists where the node and both of its neighbors belong to the plate.
93+ const hasA = (i: number, j: number) => insideNode(g, i - 1, j) && insideNode(g, i, j) && insideNode(g, i + 1, j);
94+ const hasB = (i: number, j: number) => insideNode(g, i, j - 1) && insideNode(g, i, j) && insideNode(g, i, j + 1);
95+ const dxx = (i: number, j: number) => (w[id(i + 1, j)] - 2 * w[id(i, j)] + w[id(i - 1, j)]) / hx2;
96+ const dyy = (i: number, j: number) => (w[id(i, j + 1)] - 2 * w[id(i, j)] + w[id(i, j - 1)]) / hy2;
97+ // Weighted bending moments at nodes; zero where the second difference is undefined.
98+ const Ma = (i: number, j: number): number => {
99+ if (!hasA(i, j)) return 0;
100+ return nodeWeight(g, i, j) * Dn(i, j) * (dxx(i, j) + nu * (hasB(i, j) ? dyy(i, j) : 0));
101+ };
102+ const Mb = (i: number, j: number): number => {
103+ if (!hasB(i, j)) return 0;
104+ return nodeWeight(g, i, j) * Dn(i, j) * (dyy(i, j) + nu * (hasA(i, j) ? dxx(i, j) : 0));
105+ };
106+ // Weighted mixed second difference at a cell center; zero for cells outside the plate.
107+ const Cc = (ci: number, cj: number): number => {
108+ if (!insideCell(g, ci, cj)) return 0;
109+ return (Dc(ci, cj) * (w[id(ci + 1, cj + 1)] - w[id(ci + 1, cj)] - w[id(ci, cj + 1)] + w[id(ci, cj)])) / (hx * hy);
110+ };
111+
112+ const q = (hx * hy) / 4; // area per Gauss point
113+ const U = new Float64Array(4), V = new Float64Array(4), Wn = new Float64Array(4), W0 = new Float64Array(4);
114+ const dNdx = new Float64Array(4), dNdy = new Float64Array(4);
115+
116+ // Adds the membrane force of cell (ci, cj) on its local node a to F = [Fu, Fv, Fw].
117+ const cellForce = (ci: number, cj: number, a: number, F: Float64Array): void => {
118+ if (!insideCell(g, ci, cj)) return;
119+ const hc = cellThickness(pl, ci, cj);
120+ const C = membraneStiffness(m, hc), Gs = shearStiffness(m, hc);
121+ const nn = [id(ci, cj), id(ci + 1, cj), id(ci + 1, cj + 1), id(ci, cj + 1)];
122+ for (let b = 0; b < 4; b++) {
123+ U[b] = u[nn[b]]; V[b] = v[nn[b]]; Wn[b] = w[nn[b]]; W0[b] = w0[nn[b]];
124+ }
125+ for (let gp = 0; gp < 4; gp++) {
126+ shapeDerivatives(gp, hx, hy, dNdx, dNdy);
127+ let ux = 0, uy = 0, vx = 0, vy = 0, wx = 0, wy = 0, w0x = 0, w0y = 0;
128+ for (let b = 0; b < 4; b++) {
129+ ux += dNdx[b] * U[b]; uy += dNdy[b] * U[b];
130+ vx += dNdx[b] * V[b]; vy += dNdy[b] * V[b];
131+ wx += dNdx[b] * (Wn[b] + W0[b]); wy += dNdy[b] * (Wn[b] + W0[b]);
132+ w0x += dNdx[b] * W0[b]; w0y += dNdy[b] * W0[b];
133+ }
134+ // Marguerre strains: total slope (w0 + w) minus the stress-free slope of w0.
135+ const exx = ux + 0.5 * (wx * wx - w0x * w0x);
136+ const eyy = vy + 0.5 * (wy * wy - w0y * w0y);
137+ const gxy = uy + vx + wx * wy - w0x * w0y;
138+ const Nxx = C * (exx + nu * eyy);
139+ const Nyy = C * (eyy + nu * exx);
140+ const Nxy = Gs * gxy;
141+ F[0] -= q * (Nxx * dNdx[a] + Nxy * dNdy[a]);
142+ F[1] -= q * (Nyy * dNdy[a] + Nxy * dNdx[a]);
143+ F[2] -= q * ((Nxx * wx + Nxy * wy) * dNdx[a] + (Nxy * wx + Nyy * wy) * dNdy[a]);
144+ }
145+ };
146+
147+ const Km = mountStiffness(pl, mountPatch(pl));
148+ const F = new Float64Array(3);
149+ for (let j = 0; j < ny; j++) {
150+ for (let i = 0; i < nx; i++) {
151+ const k = id(i, j);
152+ const mass = nodeMass(pl, i, j);
153+ if (mass === 0) {
154+ out.u[k] = 0; out.v[k] = 0; out.w[k] = 0;
155+ continue;
156+ }
157+ const bend =
158+ (Ma(i - 1, j) - 2 * Ma(i, j) + Ma(i + 1, j)) / hx2 +
159+ (Mb(i, j - 1) - 2 * Mb(i, j) + Mb(i, j + 1)) / hy2 +
160+ (2 * (1 - nu) * (Cc(i, j) - Cc(i - 1, j) - Cc(i, j - 1) + Cc(i - 1, j - 1))) / (hx * hy);
161+ F[0] = -Km[k] * u[k]; F[1] = -Km[k] * v[k]; F[2] = -hx * hy * bend - Km[k] * w[k];
162+ cellForce(i, j, 0, F);
163+ cellForce(i - 1, j, 1, F);
164+ cellForce(i - 1, j - 1, 2, F);
165+ cellForce(i, j - 1, 3, F);
166+ out.u[k] = F[0] / mass;
167+ out.v[k] = F[1] / mass;
168+ out.w[k] = F[2] / mass;
169+ }
170+ }
171+}
172+
173+/**
174+ * Adds the loss terms to the accelerations `out` for the velocities `vel`:
175+ * -2 sigma0 v on every component, +2 sigma1 (graph Laplacian of w_t) on the
176+ * transverse component, -2 sigmaIn v on the in-plane components, and the
177+ * dashpot of the mount over its patch.
178+ */
179+export function addDamping(pl: Plate, vel: Fields, out: Fields): void {
180+ const g = pl.grid;
181+ const { sigma0, sigma1, sigmaIn } = pl.loss;
182+ const { nx, ny, hx, hy } = g;
183+ const id = (i: number, j: number) => j * nx + i;
184+ const Cm = mountDamping(pl, mountPatch(pl));
185+ for (let j = 0; j < ny; j++) {
186+ for (let i = 0; i < nx; i++) {
187+ const k = id(i, j);
188+ const mass = nodeMass(pl, i, j);
189+ if (mass === 0) continue;
190+ const c = vel.w[k];
191+ let lap = 0;
192+ if (insideNode(g, i - 1, j)) lap += (vel.w[id(i - 1, j)] - c) / (hx * hx);
193+ if (insideNode(g, i + 1, j)) lap += (vel.w[id(i + 1, j)] - c) / (hx * hx);
194+ if (insideNode(g, i, j - 1)) lap += (vel.w[id(i, j - 1)] - c) / (hy * hy);
195+ if (insideNode(g, i, j + 1)) lap += (vel.w[id(i, j + 1)] - c) / (hy * hy);
196+ const cm = Cm[k] / mass;
197+ out.u[k] -= (2 * (sigma0 + sigmaIn) + cm) * vel.u[k];
198+ out.v[k] -= (2 * (sigma0 + sigmaIn) + cm) * vel.v[k];
199+ out.w[k] += -(2 * sigma0 + cm) * c + 2 * sigma1 * lap;
200+ }
201+ }
202+}
203+
204+/** Discrete potential energy (bending + membrane + mount) whose negative gradient is the force in `accelerations`. */
205+export function potentialEnergy(pl: Plate, f: Fields): number {
206+ const g = pl.grid, m = pl.mat, w0 = pl.w0;
207+ const { nx, ny, hx, hy } = g;
208+ const { u, v, w } = f;
209+ const nu = m.nu;
210+ const id = (i: number, j: number) => j * nx + i;
211+ let Ub = 0;
212+ for (let j = 0; j < ny; j++) {
213+ for (let i = 0; i < nx; i++) {
214+ let a = 0, b = 0;
215+ if (insideNode(g, i - 1, j) && insideNode(g, i, j) && insideNode(g, i + 1, j)) a = (w[id(i + 1, j)] - 2 * w[id(i, j)] + w[id(i - 1, j)]) / (hx * hx);
216+ if (insideNode(g, i, j - 1) && insideNode(g, i, j) && insideNode(g, i, j + 1)) b = (w[id(i, j + 1)] - 2 * w[id(i, j)] + w[id(i, j - 1)]) / (hy * hy);
217+ Ub += nodeWeight(g, i, j) * bendingStiffness(m, pl.h[id(i, j)]) * (a * a + b * b + 2 * nu * a * b);
218+ }
219+ }
220+ for (let cj = 0; cj < ny - 1; cj++) {
221+ for (let ci = 0; ci < nx - 1; ci++) {
222+ if (!insideCell(g, ci, cj)) continue;
223+ const c = (w[id(ci + 1, cj + 1)] - w[id(ci + 1, cj)] - w[id(ci, cj + 1)] + w[id(ci, cj)]) / (hx * hy);
224+ Ub += 2 * (1 - nu) * bendingStiffness(m, cellThickness(pl, ci, cj)) * c * c;
225+ }
226+ }
227+ Ub *= 0.5 * hx * hy;
228+
229+ let Um = 0;
230+ const q = (hx * hy) / 4;
231+ const dNdx = new Float64Array(4), dNdy = new Float64Array(4);
232+ for (let cj = 0; cj < ny - 1; cj++) {
233+ for (let ci = 0; ci < nx - 1; ci++) {
234+ if (!insideCell(g, ci, cj)) continue;
235+ const hc = cellThickness(pl, ci, cj);
236+ const C = membraneStiffness(m, hc), Gs = shearStiffness(m, hc);
237+ const nn = [id(ci, cj), id(ci + 1, cj), id(ci + 1, cj + 1), id(ci, cj + 1)];
238+ for (let gp = 0; gp < 4; gp++) {
239+ shapeDerivatives(gp, hx, hy, dNdx, dNdy);
240+ let ux = 0, uy = 0, vx = 0, vy = 0, wx = 0, wy = 0, w0x = 0, w0y = 0;
241+ for (let b = 0; b < 4; b++) {
242+ const z0 = w0[nn[b]];
243+ ux += dNdx[b] * u[nn[b]]; uy += dNdy[b] * u[nn[b]];
244+ vx += dNdx[b] * v[nn[b]]; vy += dNdy[b] * v[nn[b]];
245+ wx += dNdx[b] * (w[nn[b]] + z0); wy += dNdy[b] * (w[nn[b]] + z0);
246+ w0x += dNdx[b] * z0; w0y += dNdy[b] * z0;
247+ }
248+ const exx = ux + 0.5 * (wx * wx - w0x * w0x);
249+ const eyy = vy + 0.5 * (wy * wy - w0y * w0y);
250+ const gxy = uy + vx + wx * wy - w0x * w0y;
251+ Um += q * 0.5 * (C * (exx * exx + eyy * eyy + 2 * nu * exx * eyy) + Gs * gxy * gxy);
252+ }
253+ }
254+ }
255+
256+ let Us = 0;
257+ const Km = mountStiffness(pl, mountPatch(pl));
258+ for (let k = 0; k < g.n; k++) if (Km[k] > 0) Us += 0.5 * Km[k] * (u[k] * u[k] + v[k] * v[k] + w[k] * w[k]);
259+ return Ub + Um + Us;
260+}
261+
262+/**
263+ * Largest angular frequency of the linearized discrete system, by power
264+ * iteration on a small full grid with the same spacing and a uniform
265+ * thickness h (the largest of the plate, which bounds the bending frequency;
266+ * the in-plane wave speed does not depend on the thickness). The extreme mode
267+ * is a grid-scale mode, so neither the plate size nor its outline matters.
268+ * `inPlaneMass` is the factor by which the in-plane inertia is scaled.
269+ */
270+export function maxAngularFrequency(g: Grid, m: Material, h: number, inPlaneMass = 1, nSmall = 12, iters = 300): number {
271+ const gs: Grid = { nx: nSmall, ny: nSmall, hx: g.hx, hy: g.hy, n: nSmall * nSmall };
272+ const pl = uniformPlate(gs, m, h);
273+ const n = gs.n;
274+ const x = fields(n), y = fields(n);
275+ let seed = 12345;
276+ const rnd = () => {
277+ seed = (seed * 1664525 + 1013904223) >>> 0;
278+ return seed / 4294967296 - 0.5;
279+ };
280+ for (let k = 0; k < n; k++) {
281+ x.u[k] = rnd(); x.v[k] = rnd(); x.w[k] = rnd();
282+ }
283+ const eps = 1e-9; // keeps the nonlinear terms negligible
284+ let lambda = 0;
285+ for (let it = 0; it < iters; it++) {
286+ let norm = 0;
287+ for (let k = 0; k < n; k++) norm += x.u[k] ** 2 + x.v[k] ** 2 + x.w[k] ** 2;
288+ norm = Math.sqrt(norm);
289+ for (let k = 0; k < n; k++) {
290+ x.u[k] *= eps / norm; x.v[k] *= eps / norm; x.w[k] *= eps / norm;
291+ }
292+ accelerations(pl, x, y);
293+ if (inPlaneMass !== 1) {
294+ for (let k = 0; k < n; k++) { y.u[k] /= inPlaneMass; y.v[k] /= inPlaneMass; }
295+ }
296+ let dot = 0;
297+ for (let k = 0; k < n; k++) dot += x.u[k] * y.u[k] + x.v[k] * y.v[k] + x.w[k] * y.w[k];
298+ lambda = -dot / (eps * eps); // Rayleigh quotient of -M^{-1} K
299+ for (let k = 0; k < n; k++) {
300+ x.u[k] = -y.u[k]; x.v[k] = -y.v[k]; x.w[k] = -y.w[k];
301+ }
302+ }
303+ return Math.sqrt(Math.max(lambda, 0));
304+}
src/sim/shaders/audio.wgsladded+46−0View file
@@ -0,0 +1,46 @@
1+// Retarded-time sum over the plate (Rayleigh integral), one workgroup per
2+// output sample of the batch, dispatched once at the end of the batch.
3+// lw[k] = (weight, delay in samples) for node k; delay is relative to the
4+// closest node so that the ring buffer only has to cover the spread.
5+// The ring buffer holds the transverse velocity at the sample instants; the
6+// acceleration is the first difference of the delayed velocity, which is the
7+// average acceleration over one sample interval (band-limited, and immune to
8+// the per-step rounding noise of the force evaluation). The bound StepParams
9+// are those of the batch's first audio step; workgroup q handles the sample
10+// q slots later.
11+
12+@group(0) @binding(0) var<uniform> P: Params;
13+@group(0) @binding(1) var<uniform> SP: StepParams;
14+@group(0) @binding(2) var<storage, read> hist: array<f32>;
15+@group(0) @binding(3) var<storage, read> lw: array<vec2f>;
16+@group(0) @binding(4) var<storage, read_write> audioOut: array<f32>;
17+
18+var<workgroup> sh: array<f32, 256>;
19+
20+@compute @workgroup_size(256)
21+fn main(@builtin(local_invocation_id) lid: vec3u, @builtin(workgroup_id) wid: vec3u) {
22+ let t = lid.x;
23+ let H = i32(P.histLen);
24+ let slot = (SP.audioSlot + i32(wid.x)) % H;
25+ var acc = 0.0;
26+ for (var k = t; k < P.n; k += 256u) {
27+ let d = lw[k].y;
28+ let k0 = i32(floor(d));
29+ let f = d - f32(k0);
30+ let s0 = ((slot - k0) % H + H) % H;
31+ let s1 = ((slot - k0 - 1) % H + H) % H;
32+ let s2 = ((slot - k0 - 2) % H + H) % H;
33+ let v0 = hist[u32(s0) * P.n + k];
34+ let v1 = hist[u32(s1) * P.n + k];
35+ let v2 = hist[u32(s2) * P.n + k];
36+ let a = (mix(v0, v1, f) - mix(v1, v2, f)) * P.fs;
37+ acc += lw[k].x * a;
38+ }
39+ sh[t] = acc;
40+ workgroupBarrier();
41+ for (var stride = 128u; stride > 0u; stride >>= 1u) {
42+ if (t < stride) { sh[t] += sh[t + stride]; }
43+ workgroupBarrier();
44+ }
45+ if (t == 0u) { audioOut[u32(SP.audioOut) + wid.x] = sh[0]; }
46+}
src/sim/shaders/common.wgsladded+79−0View file
@@ -0,0 +1,79 @@
1+// Shared declarations, prepended to every kernel.
2+
3+struct Params {
4+ nx: u32,
5+ ny: u32,
6+ n: u32,
7+ histLen: u32,
8+ hx: f32,
9+ hy: f32,
10+ dt: f32,
11+ nu: f32,
12+ Db: f32, // E / (12 (1 - nu^2)): bending stiffness per h^3 [Pa]
13+ Cm: f32, // E / (1 - nu^2): membrane stiffness per h [Pa]
14+ Gm: f32, // E / (2 (1 + nu)): membrane shear stiffness per h [Pa]
15+ rho: f32, // density [kg/m^3]
16+ mass: f32, // total mass [kg]
17+ Ix: f32, // sum m y^2
18+ Iy: f32, // sum m x^2
19+ Iz: f32, // sum m (x^2 + y^2)
20+ fs: f32, // audio sample rate [Hz]
21+ strikeX: f32, // drumstick contact point [m]
22+ strikeY: f32,
23+ strikeR2: f32, // squared contact radius [m^2]
24+ patchMass: f32, // mass of the contact patch [kg]
25+ strikeNode: u32, // plate node nearest the contact point
26+ K: f32, // Hertz contact stiffness [N/m^alpha]
27+ alpha: f32, // Hertz exponent
28+ stickMass: f32, // [kg]
29+ patchI0: u32, // index box enclosing the contact patch (inclusive)
30+ patchI1: u32,
31+ patchJ0: u32,
32+ patchJ1: u32,
33+ patchCX: f32, // mass-weighted center of the contact patch [m]
34+ patchCY: f32,
35+ inPlaneMass: f32, // factor by which the in-plane inertia is scaled (1: physical)
36+ invInPlaneMass: f32, // its reciprocal
37+ mountX: f32, // center of the mount patch [m]
38+ mountY: f32,
39+ mountR2: f32, // squared mount radius [m^2]
40+ mountKw: f32, // mount stiffness per unit node weight [N/m]
41+ mountCw: f32, // mount damping per unit node weight [N s/m]
42+ mountNode: u32, // plate node nearest the mount center (always a member)
43+ mountOn: u32, // 1 when the plate sits on a mount, 0 when it is free
44+};
45+
46+// Per-step parameters, addressed with a dynamic uniform offset.
47+struct StepParams {
48+ step: u32,
49+ flags: u32, // bit 0: drumstick active
50+ audioSlot: i32, // ring-buffer slot to record the velocity into (-1: none)
51+ audioOut: i32, // index of this sample within the batch's output
52+ sigma0: f32,
53+ sigma1: f32,
54+ sigmaIn: f32, // additional frequency-independent loss of the in-plane motion
55+ parity: u32, // which of the two stick-state slots holds the current state
56+ pad0: u32,
57+ pad1: u32,
58+ pad2: u32,
59+ pad3: u32,
60+};
61+
62+fn nodeX(i: i32) -> f32 { return (f32(i) - 0.5 * f32(P.nx - 1u)) * P.hx; }
63+fn nodeY(j: i32) -> f32 { return (f32(j) - 0.5 * f32(P.ny - 1u)) * P.hy; }
64+
65+// Static per-node data, written once by the host: x = stress-free shape w0 [m],
66+// y = quadrature and lumped-mass weight (the fraction of the node's four cells
67+// that belong to the plate; trapezoidal weights on a full rectangle, zero
68+// outside), z = thickness [m].
69+@group(0) @binding(8) var<storage, read> nd: array<vec4f>;
70+fn nodeWeight(i: i32, j: i32) -> f32 {
71+ if (i < 0 || i >= i32(P.nx) || j < 0 || j >= i32(P.ny)) { return 0.0; }
72+ return nd[u32(j) * P.nx + u32(i)].y;
73+}
74+// Lumped mass of a node [kg].
75+fn nodeMass(i: i32, j: i32) -> f32 {
76+ if (i < 0 || i >= i32(P.nx) || j < 0 || j >= i32(P.ny)) { return 0.0; }
77+ let k = u32(j) * P.nx + u32(i);
78+ return P.rho * nd[k].z * P.hx * P.hy * nd[k].y;
79+}
src/sim/shaders/extract.wgsladded+12−0View file
@@ -0,0 +1,12 @@
1+// Copies the transverse displacement into a plain f32 array (display / movie frame).
2+
3+@group(0) @binding(0) var<uniform> P: Params;
4+@group(0) @binding(1) var<storage, read> pos: array<f32>;
5+@group(0) @binding(2) var<storage, read_write> out: array<f32>;
6+
7+@compute @workgroup_size(256)
8+fn main(@builtin(global_invocation_id) gid: vec3u) {
9+ let k = gid.x;
10+ if (k >= P.n) { return; }
11+ out[k] = pos[2u * P.n + k];
12+}
src/sim/shaders/rigid.wgsladded+73−0View file
@@ -0,0 +1,73 @@
1+// Rigid-body projection: (1) mass-weighted sums of the six rigid modes for
2+// displacement and velocity, kinetic energy and max |w| in one workgroup;
3+// (2) subtraction of the rigid components from every node. The in-plane
4+// inertia may be scaled by P.inPlaneMass; the rigid coefficients are ratios
5+// and do not see the factor, the kinetic energy does. The node mass carries
6+// the local thickness.
7+
8+@group(0) @binding(0) var<uniform> P: Params;
9+@group(0) @binding(1) var<storage, read_write> pos: array<f32>; // [u | v | w | wLo]
10+@group(0) @binding(2) var<storage, read_write> vel: array<f32>; // [du | dv | dw]
11+@group(0) @binding(3) var<storage, read_write> stats: array<f32>; // 0..11 coefficients, 12 KE, 13 max|w|
12+
13+const NS = 14u;
14+var<workgroup> sh: array<array<f32, NS>, 256>;
15+
16+@compute @workgroup_size(256)
17+fn sums(@builtin(local_invocation_id) lid: vec3u) {
18+ let t = lid.x;
19+ var s: array<f32, NS>;
20+ for (var q = 0u; q < NS; q++) { s[q] = 0.0; }
21+ for (var k = t; k < P.n; k += 256u) {
22+ let i = i32(k % P.nx); let j = i32(k / P.nx);
23+ let x = nodeX(i); let y = nodeY(j);
24+ let m = nodeMass(i, j);
25+ let n = P.n;
26+ let p = vec3f(pos[k], pos[n + k], pos[2u * n + k]);
27+ let v = vec3f(vel[k], vel[n + k], vel[2u * n + k]);
28+ s[0] += m * p.x; s[1] += m * p.y; s[2] += m * p.z;
29+ s[3] += m * (x * p.y - y * p.x); s[4] += m * y * p.z; s[5] += m * (-x * p.z);
30+ s[6] += m * v.x; s[7] += m * v.y; s[8] += m * v.z;
31+ s[9] += m * (x * v.y - y * v.x); s[10] += m * y * v.z; s[11] += m * (-x * v.z);
32+ s[12] += 0.5 * m * (P.inPlaneMass * (v.x * v.x + v.y * v.y) + v.z * v.z);
33+ s[13] = max(s[13], abs(p.z));
34+ }
35+ for (var q = 0u; q < NS; q++) { sh[t][q] = s[q]; }
36+ workgroupBarrier();
37+ for (var stride = 128u; stride > 0u; stride >>= 1u) {
38+ if (t < stride) {
39+ for (var q = 0u; q < 13u; q++) { sh[t][q] += sh[t + stride][q]; }
40+ sh[t][13] = max(sh[t][13], sh[t + stride][13]);
41+ }
42+ workgroupBarrier();
43+ }
44+ if (t == 0u) {
45+ let inv = array<f32, 6>(1.0 / P.mass, 1.0 / P.mass, 1.0 / P.mass, 1.0 / P.Iz, 1.0 / P.Ix, 1.0 / P.Iy);
46+ for (var q = 0u; q < 6u; q++) {
47+ stats[q] = sh[0][q] * inv[q];
48+ stats[q + 6u] = sh[0][q + 6u] * inv[q];
49+ }
50+ // Kinetic energy of the non-rigid motion (the six rigid modes are mass-orthogonal).
51+ let ip = array<f32, 6>(P.inPlaneMass, P.inPlaneMass, 1.0, P.inPlaneMass, 1.0, 1.0);
52+ var keRigid = 0.0;
53+ for (var q = 6u; q < 12u; q++) { keRigid += 0.5 * ip[q - 6u] * sh[0][q] * sh[0][q] * inv[q - 6u]; }
54+ stats[12] = max(sh[0][12] - keRigid, 0.0);
55+ stats[13] = sh[0][13];
56+ }
57+}
58+
59+@compute @workgroup_size(256)
60+fn apply(@builtin(global_invocation_id) gid: vec3u) {
61+ let k = gid.x;
62+ if (k >= P.n) { return; }
63+ let i = i32(k % P.nx); let j = i32(k / P.nx);
64+ if (nodeWeight(i, j) == 0.0) { return; } // outside the plate
65+ let x = nodeX(i); let y = nodeY(j);
66+ let n = P.n;
67+ pos[k] -= stats[0] - stats[3] * y;
68+ pos[n + k] -= stats[1] + stats[3] * x;
69+ pos[2u * n + k] -= stats[2] + stats[4] * y - stats[5] * x;
70+ vel[k] -= stats[6] - stats[9] * y;
71+ vel[n + k] -= stats[7] + stats[9] * x;
72+ vel[2u * n + k] -= stats[8] + stats[10] * y - stats[11] * x;
73+}
src/sim/shaders/step.wgsladded+318−0View file
@@ -0,0 +1,318 @@
1+// One explicit time step (symplectic Euler) of the von Karman plate with a
2+// thickness field.
3+//
4+// State layout (structure of arrays, n = nx * ny nodes):
5+// pos: [u | v | w | wLo] wLo carries the rounding error of the w update
6+// vel: [du | dv | dw]
7+// A workgroup of 16x16 threads advances a 12x12 tile of nodes in three
8+// phases separated by barriers. (1) Every thread loads one entry of the tile
9+// plus its 2-node halo (16x16 values of w, u, v, w0, the thickness, and the
10+// node weights) into shared memory. (2) The threads compute, once each
11+// and again into shared memory, the membrane forces of the 13x13 cells
12+// around the tile and the weighted bending moments and mixed differences
13+// that the bending stencil gathers. (3) Each of the 144 tile nodes evaluates
14+// its update from shared memory alone. On grids of a few thousand nodes the
15+// kernel is bound by the latency of one workgroup rather than by throughput,
16+// so the shared phases, which replace a tenfold per-node recomputation of
17+// the stencil ingredients, are what make a step cheap.
18+//
19+// The thickness enters as the local bending stiffness D(h) at the nodes and
20+// the cells, the local membrane stiffnesses at the cells (mean thickness of
21+// the four corners), and the node mass. Nodes outside the plate (weight 0)
22+// are skipped: a second difference exists only where the node and both
23+// neighbors belong to the plate, and a cell contributes only when its four
24+// corners do. That reproduces the free edge of the rectangle and gives a
25+// staircase free edge for any other outline, including a center hole.
26+
27+@group(0) @binding(0) var<uniform> P: Params;
28+@group(0) @binding(1) var<uniform> SP: StepParams;
29+@group(0) @binding(2) var<storage, read> posIn: array<f32>;
30+@group(0) @binding(3) var<storage, read> velIn: array<f32>;
31+@group(0) @binding(4) var<storage, read_write> posOut: array<f32>;
32+@group(0) @binding(5) var<storage, read_write> velOut: array<f32>;
33+@group(0) @binding(6) var<storage, read_write> hist: array<f32>; // [slot * n + node]: transverse velocity at the audio sample instants
34+// Two 4-float slots of drumstick state (position, velocity, force, max force).
35+@group(0) @binding(7) var<storage, read_write> stick: array<f32>;
36+
37+const TILE = 12; // nodes per workgroup along each axis (must match simulation.ts)
38+const HALO = 2;
39+const TW = 16; // TILE + 2 * HALO: the loaded tile, one thread per entry
40+const CT = 13; // TILE + 1: cells adjacent to the tile's nodes
41+const MT = 14; // TILE + 2: nodes at which the bending moments are needed
42+// Tile with halo, local index (j - oy) * TW + (i - ox).
43+var<workgroup> tw: array<f32, 256>; // w
44+var<workgroup> tu: array<f32, 256>; // u
45+var<workgroup> tv: array<f32, 256>; // v
46+var<workgroup> tw0: array<f32, 256>; // stress-free shape
47+var<workgroup> th: array<f32, 256>; // thickness
48+var<workgroup> tn: array<u32, 64>; // node weights in quarters (0..4), four to a word
49+// Membrane force contributions of the CT x CT cells, 4 nodes x 3 components each.
50+var<workgroup> cellF: array<f32, 2028>;
51+// Weighted bending moments at the MT x MT inner nodes and mixed differences at the CT x CT cells.
52+var<workgroup> ma: array<f32, 196>;
53+var<workgroup> mb: array<f32, 196>;
54+var<workgroup> cc: array<f32, 169>;
55+
56+// Tile origin in global node coordinates (set once per invocation).
57+var<private> ox: i32;
58+var<private> oy: i32;
59+var<private> ihx2: f32;
60+var<private> ihy2: f32;
61+var<private> ihxy: f32;
62+
63+fn id(i: i32, j: i32) -> u32 { return u32(j) * P.nx + u32(i); }
64+fn lidx(i: i32, j: i32) -> u32 { return u32(j - oy) * u32(TW) + u32(i - ox); }
65+fn midx(i: i32, j: i32) -> u32 { return u32(j - oy - 1) * u32(MT) + u32(i - ox - 1); }
66+fn cidx(ci: i32, cj: i32) -> u32 { return u32(cj - oy - 1) * u32(CT) + u32(ci - ox - 1); }
67+// Tile lookups with global indices; the tile covers [ox, ox + TW) x [oy, oy + TW).
68+fn W(i: i32, j: i32) -> f32 { return tw[lidx(i, j)]; }
69+fn U(i: i32, j: i32) -> f32 { return tu[lidx(i, j)]; }
70+fn V(i: i32, j: i32) -> f32 { return tv[lidx(i, j)]; }
71+fn W0(i: i32, j: i32) -> f32 { return tw0[lidx(i, j)]; }
72+// Transverse velocity, read from global memory (only the loss Laplacian needs it,
73+// and keeping it out of shared memory keeps the workgroup within 16 KB).
74+fn DW(i: i32, j: i32) -> f32 { return velIn[2u * P.n + id(i, j)]; }
75+fn TH(i: i32, j: i32) -> f32 { return th[lidx(i, j)]; }
76+fn NWq(i: i32, j: i32) -> u32 { let l = lidx(i, j); return (tn[l >> 2u] >> ((l & 3u) * 8u)) & 0xFFu; }
77+fn NW(i: i32, j: i32) -> f32 { return 0.25 * f32(NWq(i, j)); }
78+fn INS(i: i32, j: i32) -> bool { return NWq(i, j) != 0u; }
79+fn insCell(ci: i32, cj: i32) -> bool { return INS(ci, cj) && INS(ci + 1, cj) && INS(ci + 1, cj + 1) && INS(ci, cj + 1); }
80+// Bending stiffness at a node and at a cell (mean thickness of the four corners).
81+fn Dn(i: i32, j: i32) -> f32 { let h = TH(i, j); return P.Db * h * h * h; }
82+fn cellH(ci: i32, cj: i32) -> f32 { return 0.25 * (TH(ci, cj) + TH(ci + 1, cj) + TH(ci + 1, cj + 1) + TH(ci, cj + 1)); }
83+
84+// Graph Laplacian of the transverse velocity at node (i, j), over neighbors in the plate.
85+fn lapDW(i: i32, j: i32) -> f32 {
86+ let c = DW(i, j);
87+ var lap = 0.0;
88+ if (INS(i - 1, j)) { lap += (DW(i - 1, j) - c) * ihx2; }
89+ if (INS(i + 1, j)) { lap += (DW(i + 1, j) - c) * ihx2; }
90+ if (INS(i, j - 1)) { lap += (DW(i, j - 1) - c) * ihy2; }
91+ if (INS(i, j + 1)) { lap += (DW(i, j + 1) - c) * ihy2; }
92+ return lap;
93+}
94+
95+// Weighted bending moments at nodes; zero where the second difference is undefined.
96+fn Ma(i: i32, j: i32) -> f32 {
97+ if (!(INS(i - 1, j) && INS(i, j) && INS(i + 1, j))) { return 0.0; }
98+ let a = (W(i + 1, j) - 2.0 * W(i, j) + W(i - 1, j)) * ihx2;
99+ var b = 0.0;
100+ if (INS(i, j - 1) && INS(i, j + 1)) { b = (W(i, j + 1) - 2.0 * W(i, j) + W(i, j - 1)) * ihy2; }
101+ return NW(i, j) * Dn(i, j) * (a + P.nu * b);
102+}
103+fn Mb(i: i32, j: i32) -> f32 {
104+ if (!(INS(i, j - 1) && INS(i, j) && INS(i, j + 1))) { return 0.0; }
105+ let b = (W(i, j + 1) - 2.0 * W(i, j) + W(i, j - 1)) * ihy2;
106+ var a = 0.0;
107+ if (INS(i - 1, j) && INS(i + 1, j)) { a = (W(i + 1, j) - 2.0 * W(i, j) + W(i - 1, j)) * ihx2; }
108+ return NW(i, j) * Dn(i, j) * (b + P.nu * a);
109+}
110+// Weighted mixed second difference at a cell center; zero outside the plate.
111+fn Cc(ci: i32, cj: i32) -> f32 {
112+ if (!insCell(ci, cj)) { return 0.0; }
113+ let h = cellH(ci, cj);
114+ return P.Db * h * h * h * (W(ci + 1, cj + 1) - W(ci + 1, cj) - W(ci, cj + 1) + W(ci, cj)) * ihxy;
115+}
116+
117+// Membrane forces of the bilinear cell (ci, cj) on its four nodes (2x2 Gauss),
118+// written to cellF at local cell index lc.
119+fn cellForces(ci: i32, cj: i32, lc: u32) {
120+ let base = lc * 12u;
121+ if (!insCell(ci, cj)) {
122+ for (var q = 0u; q < 12u; q++) { cellF[base + q] = 0.0; }
123+ return;
124+ }
125+ let hc = cellH(ci, cj);
126+ let C = P.Cm * hc;
127+ let G = P.Gm * hc;
128+ let Un = vec4f(U(ci, cj), U(ci + 1, cj), U(ci + 1, cj + 1), U(ci, cj + 1));
129+ let Vn = vec4f(V(ci, cj), V(ci + 1, cj), V(ci + 1, cj + 1), V(ci, cj + 1));
130+ let W0n = vec4f(W0(ci, cj), W0(ci + 1, cj), W0(ci + 1, cj + 1), W0(ci, cj + 1));
131+ let Wn = vec4f(W(ci, cj), W(ci + 1, cj), W(ci + 1, cj + 1), W(ci, cj + 1)) + W0n; // total shape
132+ let q = 0.25 * P.hx * P.hy;
133+ let ihx = 1.0 / P.hx; let ihy = 1.0 / P.hy;
134+ let g3 = 0.57735026918962576;
135+ var Fu = vec4f(0.0); var Fv = vec4f(0.0); var Fw = vec4f(0.0);
136+ for (var gp = 0; gp < 4; gp++) {
137+ var xi = -g3; if ((gp & 1) != 0) { xi = g3; }
138+ var eta = -g3; if ((gp & 2) != 0) { eta = g3; }
139+ let dNdx = vec4f(-(1.0 - eta), 1.0 - eta, 1.0 + eta, -(1.0 + eta)) * (0.5 * ihx);
140+ let dNdy = vec4f(-(1.0 - xi), -(1.0 + xi), 1.0 + xi, 1.0 - xi) * (0.5 * ihy);
141+ let ux = dot(dNdx, Un); let uy = dot(dNdy, Un);
142+ let vx = dot(dNdx, Vn); let vy = dot(dNdy, Vn);
143+ let wx = dot(dNdx, Wn); let wy = dot(dNdy, Wn);
144+ let w0x = dot(dNdx, W0n); let w0y = dot(dNdy, W0n);
145+ // Marguerre strains: total slope minus the stress-free slope.
146+ let exx = ux + 0.5 * (wx * wx - w0x * w0x);
147+ let eyy = vy + 0.5 * (wy * wy - w0y * w0y);
148+ let gxy = uy + vx + wx * wy - w0x * w0y;
149+ let Nxx = C * (exx + P.nu * eyy);
150+ let Nyy = C * (eyy + P.nu * exx);
151+ let Nxy = G * gxy;
152+ Fu -= q * (Nxx * dNdx + Nxy * dNdy);
153+ Fv -= q * (Nyy * dNdy + Nxy * dNdx);
154+ Fw -= q * ((Nxx * wx + Nxy * wy) * dNdx + (Nxy * wx + Nyy * wy) * dNdy);
155+ }
156+ for (var a = 0u; a < 4u; a++) {
157+ cellF[base + a * 3u] = Fu[a];
158+ cellF[base + a * 3u + 1u] = Fv[a];
159+ cellF[base + a * 3u + 2u] = Fw[a];
160+ }
161+}
162+
163+// True for the plate nodes of the drumstick contact patch (same rule as patchAround in params.ts).
164+fn inContactPatch(i: i32, j: i32) -> bool {
165+ if (nodeWeight(i, j) == 0.0) { return false; }
166+ let dxs = nodeX(i) - P.strikeX; let dys = nodeY(j) - P.strikeY;
167+ return dxs * dxs + dys * dys <= P.strikeR2 || id(i, j) == P.strikeNode;
168+}
169+// True for the plate nodes of the mount patch.
170+fn inMount(i: i32, j: i32) -> bool {
171+ if (P.mountOn == 0u || nodeWeight(i, j) == 0.0) { return false; }
172+ let dxm = nodeX(i) - P.mountX; let dym = nodeY(j) - P.mountY;
173+ return dxm * dxm + dym * dym <= P.mountR2 || id(i, j) == P.mountNode;
174+}
175+
176+// Stored membrane force on local node `a` of cell (ci, cj).
177+fn cellForce(ci: i32, cj: i32, a: u32) -> vec3f {
178+ let base = cidx(ci, cj) * 12u + a * 3u;
179+ return vec3f(cellF[base], cellF[base + 1u], cellF[base + 2u]);
180+}
181+
182+@compute @workgroup_size(16, 16)
183+fn main(@builtin(local_invocation_id) lid: vec3u, @builtin(workgroup_id) wid: vec3u) {
184+ let n = P.n;
185+ ox = i32(wid.x) * TILE - HALO;
186+ oy = i32(wid.y) * TILE - HALO;
187+ ihx2 = 1.0 / (P.hx * P.hx); ihy2 = 1.0 / (P.hy * P.hy); ihxy = 1.0 / (P.hx * P.hy);
188+ let lt = lid.y * 16u + lid.x;
189+
190+ // Phase 1: one entry of the tile with halo per thread.
191+ {
192+ let gi = ox + i32(lid.x); let gj = oy + i32(lid.y);
193+ var ww = 0.0; var uu = 0.0; var vv = 0.0; var z0 = 0.0; var hh = 0.0;
194+ if (gi >= 0 && gi < i32(P.nx) && gj >= 0 && gj < i32(P.ny)) {
195+ let k = id(gi, gj);
196+ let st = nd[k];
197+ ww = posIn[2u * n + k]; uu = posIn[k]; vv = posIn[n + k]; z0 = st.x; hh = st.z;
198+ }
199+ tw[lt] = ww; tu[lt] = uu; tv[lt] = vv; tw0[lt] = z0; th[lt] = hh;
200+ if (lt < 64u) {
201+ var word = 0u;
202+ for (var q = 0u; q < 4u; q++) {
203+ let e = lt * 4u + q;
204+ let ei = ox + i32(e % u32(TW)); let ej = oy + i32(e / u32(TW));
205+ var wq = 0u;
206+ if (ei >= 0 && ei < i32(P.nx) && ej >= 0 && ej < i32(P.ny)) { wq = u32(round(4.0 * nd[id(ei, ej)].y)); }
207+ word |= wq << (q * 8u);
208+ }
209+ tn[lt] = word;
210+ }
211+ }
212+ workgroupBarrier();
213+
214+ // Phase 2: membrane forces of the CT x CT cells, bending moments at the MT x MT
215+ // inner nodes, mixed differences at the cells; one item per thread each.
216+ if (lt < 169u) {
217+ let ci = ox + 1 + i32(lt % u32(CT)); let cj = oy + 1 + i32(lt / u32(CT));
218+ cellForces(ci, cj, lt);
219+ cc[lt] = Cc(ci, cj);
220+ }
221+ if (lt < 196u) {
222+ let mi = ox + 1 + i32(lt % u32(MT)); let mj = oy + 1 + i32(lt / u32(MT));
223+ ma[lt] = Ma(mi, mj);
224+ mb[lt] = Mb(mi, mj);
225+ }
226+ workgroupBarrier();
227+
228+ // Phase 3: the update of the TILE x TILE nodes (no barriers past this point).
229+ if (lt >= 144u) { return; }
230+ let i = ox + HALO + i32(lt % u32(TILE)); let j = oy + HALO + i32(lt / u32(TILE));
231+ if (i >= i32(P.nx) || j >= i32(P.ny)) { return; }
232+ let k = id(i, j);
233+ let weight = NW(i, j);
234+ if (weight == 0.0) {
235+ // Outside the plate: the node stays at rest.
236+ posOut[k] = 0.0; posOut[n + k] = 0.0; posOut[2u * n + k] = 0.0; posOut[3u * n + k] = 0.0;
237+ velOut[k] = 0.0; velOut[n + k] = 0.0; velOut[2u * n + k] = 0.0;
238+ return;
239+ }
240+
241+ // Elastic forces: bending from the discrete Kirchhoff energy, membrane from the cells.
242+ let bend = (ma[midx(i - 1, j)] - 2.0 * ma[midx(i, j)] + ma[midx(i + 1, j)]) * ihx2
243+ + (mb[midx(i, j - 1)] - 2.0 * mb[midx(i, j)] + mb[midx(i, j + 1)]) * ihy2
244+ + 2.0 * (1.0 - P.nu) * (cc[cidx(i, j)] - cc[cidx(i - 1, j)] - cc[cidx(i, j - 1)] + cc[cidx(i - 1, j - 1)]) * ihxy;
245+ var F = vec3f(0.0, 0.0, -P.hx * P.hy * bend);
246+ F += cellForce(i, j, 0u);
247+ F += cellForce(i - 1, j, 1u);
248+ F += cellForce(i - 1, j - 1, 2u);
249+ F += cellForce(i, j - 1, 3u);
250+ let pOld = vec3f(posIn[k], posIn[n + k], posIn[2u * n + k]);
251+ let vel = vec3f(velIn[k], velIn[n + k], velIn[2u * n + k]);
252+ // The mount: a spring and a dashpot to ground over the mount patch, on all three components.
253+ let mounted = inMount(i, j);
254+ if (mounted) { F -= P.mountKw * weight * pOld + P.mountCw * weight * vel; }
255+ let mass = P.rho * TH(i, j) * P.hx * P.hy * weight;
256+ var acc = F * (1.0 / mass);
257+ // Optional scaling of the in-plane inertia (lengthens the stable time step).
258+ acc.x *= P.invInPlaneMass; acc.y *= P.invInPlaneMass;
259+
260+ // Losses: frequency-independent on all components, the graph Laplacian of the
261+ // transverse velocity (~k^2), and an extra frequency-independent in-plane loss.
262+ acc -= 2.0 * SP.sigma0 * vel;
263+ acc.z += 2.0 * SP.sigma1 * lapDW(i, j);
264+ acc.x -= 2.0 * SP.sigmaIn * vel.x;
265+ acc.y -= 2.0 * SP.sigmaIn * vel.y;
266+
267+ // Drumstick: Hertz contact between a free mass and the contact patch (the
268+ // plate nodes within the tip radius, plus the nearest one). The contact
269+ // coordinate is the mass-weighted mean displacement of the patch, and the
270+ // force is spread over the patch in proportion to the node masses, so the
271+ // pair is conjugate and the contact has a potential energy.
272+ if ((SP.flags & 1u) != 0u) {
273+ let sIn = 4u * SP.parity;
274+ if (P.mountOn == 0u) {
275+ // Free plate: inertia relief. The force of the previous step is applied
276+ // to the whole plate as the rigid acceleration field that balances the
277+ // patch load and its moments about the center, so the plate gains no net
278+ // momentum (the thread would take it up in reality). The one-step lag
279+ // integrates to zero. A mounted plate takes the reaction through the mount.
280+ let Fprev = stick[sIn + 2u];
281+ acc.z += Fprev * (1.0 / P.mass + nodeY(j) * P.patchCY / P.Ix + nodeX(i) * P.patchCX / P.Iy);
282+ }
283+ if (inContactPatch(i, j)) {
284+ let sx = stick[sIn]; let sv = stick[sIn + 1u]; let sMax = stick[sIn + 3u];
285+ var wc = 0.0;
286+ for (var pj = i32(P.patchJ0); pj <= i32(P.patchJ1); pj++) {
287+ for (var pi = i32(P.patchI0); pi <= i32(P.patchI1); pi++) {
288+ if (inContactPatch(pi, pj)) { wc += nodeMass(pi, pj) * posIn[2u * n + id(pi, pj)]; }
289+ }
290+ }
291+ wc /= P.patchMass;
292+ let pen = wc - sx; // the stick sits on the +z side and moves toward -z
293+ var Fc = 0.0;
294+ if (pen > 0.0) { Fc = P.K * pow(pen, P.alpha); }
295+ acc.z -= Fc / P.patchMass;
296+ if (k == P.strikeNode) {
297+ let sOut = 4u * (1u - SP.parity);
298+ let svn = sv + P.dt * Fc / P.stickMass;
299+ stick[sOut] = sx + P.dt * svn;
300+ stick[sOut + 1u] = svn;
301+ stick[sOut + 2u] = Fc;
302+ stick[sOut + 3u] = max(sMax, Fc);
303+ }
304+ }
305+ }
306+
307+ let vNew = vel + P.dt * acc;
308+ if (SP.audioSlot >= 0) { hist[u32(SP.audioSlot) * n + k] = vNew.z; }
309+ var pNew = pOld + P.dt * vNew;
310+ // Compensated update of w: the increment dt * v can be far below the
311+ // float32 resolution of w, so the rounding error is carried separately.
312+ let y = P.dt * vNew.z + posIn[3u * n + k];
313+ let t = pOld.z + y;
314+ let lo = (pOld.z - t) + y;
315+ pNew.z = t;
316+ posOut[k] = pNew.x; posOut[n + k] = pNew.y; posOut[2u * n + k] = pNew.z; posOut[3u * n + k] = lo;
317+ velOut[k] = vNew.x; velOut[n + k] = vNew.y; velOut[2u * n + k] = vNew.z;
318+}
src/sim/simulation.tsadded+633−0View file
@@ -0,0 +1,633 @@
1+// WebGPU time-stepping driver for the von Karman plate.
2+//
3+// The simulation advances in batches of one movie frame (1/fps seconds). A
4+// batch encodes one compute dispatch per time step (with a per-step uniform
5+// addressed by dynamic offset), an optional rigid-body projection every
6+// RB_INTERVAL steps, one retarded-time listener sum over the batch's audio
7+// samples, and one movie-frame extraction at the end. Audio, frame, and
8+// statistics are read back after every batch. One batch is kept encoded
9+// ahead of the one being read back, so the GPU does not wait for the
10+// readback or for the encoding. The recording starts at the impact, with the
11+// plate at rest in its stress-free shape.
12+
13+import {
14+ type SimParams,
15+ type Plate,
16+ type Grid,
17+ strikePoint,
18+ contactPatch,
19+ mountPatch,
20+ massProperties,
21+ thicknessRange,
22+ nodeX,
23+ nodeY,
24+ nodeWeight,
25+ nodeWeights,
26+ CFL,
27+ HIST_MARGIN,
28+ RB_INTERVAL,
29+ RHO_AIR,
30+ SPEED_OF_SOUND,
31+} from "../params.ts";
32+import { maxAngularFrequency } from "./cpuRef.ts";
33+import commonWgsl from "./shaders/common.wgsl?raw";
34+import stepWgsl from "./shaders/step.wgsl?raw";
35+import rigidWgsl from "./shaders/rigid.wgsl?raw";
36+import audioWgsl from "./shaders/audio.wgsl?raw";
37+import extractWgsl from "./shaders/extract.wgsl?raw";
38+
39+export type Phase = "recording" | "done";
40+
41+export interface StickState {
42+ x: number;
43+ v: number;
44+ force: number;
45+ maxForce: number;
46+}
47+
48+export interface SimStatus {
49+ phase: Phase;
50+ /** Simulation time since the impact [s]. */
51+ t: number;
52+ totalSteps: number;
53+ ke: number;
54+ wmax: number;
55+ samples: number;
56+ frames: number;
57+ peakPressure: number;
58+ delayClamped: boolean;
59+ stick: StickState;
60+}
61+
62+const PARAMS_LEN = 40; // floats in the Params uniform
63+const STEP_LEN = 12; // floats in the StepParams uniform
64+const STATS_LEN = 16;
65+const STICK_LEN = 8;
66+/** Nodes per workgroup along each axis in step.wgsl. */
67+const TILE = 12;
68+
69+/** A batch that has been encoded and submitted but not read back. */
70+interface Pending {
71+ staging: GPUBuffer;
72+ steps: number;
73+ nAudio: number;
74+ /** Which of the two stick-state slots the batch's last step wrote. */
75+ stickParity: number;
76+}
77+
78+export class Simulation {
79+ readonly plate: Plate;
80+ readonly grid: Grid;
81+ readonly dt: number;
82+ /** Time steps per audio sample. */
83+ readonly stepsPerSample: number;
84+ readonly samplesPerBatch: number;
85+ readonly stepsPerBatch: number;
86+ readonly omegaMax: number;
87+ /** Ring buffer length of the listener sum [samples]. */
88+ readonly histLen: number;
89+ /** Total mass of the plate [kg]. */
90+ readonly mass: number;
91+
92+ /** Listener pressure [Pa] at fs, from the impact. */
93+ audio: Float32Array = new Float32Array(0);
94+ samples = 0;
95+ /** Movie frames of w [m]; frame k is at t = k / fps. */
96+ frames: Float32Array<ArrayBuffer>[] = [];
97+
98+ phase: Phase = "recording";
99+ totalSteps = 0;
100+ ke = 0;
101+ wmax = 0;
102+ peakPressure = 0;
103+ delayClamped = false;
104+
105+ private stick: StickState;
106+ private inFlight: Pending | null = null;
107+
108+ private device: GPUDevice;
109+ private params: SimParams;
110+ private paramsBuf: GPUBuffer;
111+ private stepBuf: GPUBuffer;
112+ private stepStride: number;
113+ private stepData: ArrayBuffer;
114+ private pos: GPUBuffer[];
115+ private vel: GPUBuffer[];
116+ private hist: GPUBuffer;
117+ private stickBuf: GPUBuffer;
118+ /** Static per-node data for the kernels, packed as (w0, weight, thickness, 0) per node. */
119+ private nd: GPUBuffer;
120+ /** Stress-free initial shape (f32 per node), for the renderer. */
121+ readonly w0: GPUBuffer;
122+ /** Largest magnitude of the initial shape [m]. */
123+ readonly w0Max: number;
124+ /** Node weights (f32 per node, 0 outside the plate), for the renderer. */
125+ readonly nw: GPUBuffer;
126+ /** Thickness (f32 per node, 0 outside the plate), for the renderer. */
127+ readonly hn: GPUBuffer;
128+ private lw: GPUBuffer;
129+ private audioOut: GPUBuffer;
130+ private frameBuf: GPUBuffer;
131+ private statsBuf: GPUBuffer;
132+ private staging: GPUBuffer[];
133+ private stagingIdx = 0;
134+ private stagingSize: number;
135+ /** Index of the buffer pair holding the current state. */
136+ private cur = 0;
137+ /** Plain f32 array of w for the live view. */
138+ readonly displayW: GPUBuffer;
139+
140+ private stepPipe: GPUComputePipeline;
141+ private sumsPipe: GPUComputePipeline;
142+ private applyPipe: GPUComputePipeline;
143+ private audioPipe: GPUComputePipeline;
144+ private extractPipe: GPUComputePipeline;
145+ private stepBG: GPUBindGroup[];
146+ private rbBG: GPUBindGroup[];
147+ private audioBG: GPUBindGroup;
148+ private extractBG: GPUBindGroup[];
149+ private displayBG: GPUBindGroup[];
150+
151+ constructor(device: GPUDevice, plate: Plate, params: SimParams) {
152+ this.device = device;
153+ this.plate = plate;
154+ this.params = { ...params };
155+ const g = plate.grid;
156+ const m = plate.mat;
157+ this.grid = g;
158+
159+ // Stable time step, rounded so that an integer number of steps make one audio sample.
160+ const hr = thicknessRange(plate);
161+ this.omegaMax = maxAngularFrequency(g, m, hr.max, params.inPlaneMass);
162+ const dtMax = (CFL * 2) / this.omegaMax;
163+ this.stepsPerSample = Math.ceil(1 / (params.fs * dtMax));
164+ this.dt = 1 / (params.fs * this.stepsPerSample);
165+ this.samplesPerBatch = Math.max(1, Math.round(params.fs / params.fps));
166+ this.stepsPerBatch = this.samplesPerBatch * this.stepsPerSample;
167+ this.histLen = this.samplesPerBatch + HIST_MARGIN;
168+
169+ const n = g.n;
170+ const f32 = (size: number, usage = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST) =>
171+ device.createBuffer({ size: Math.max(16, size * 4), usage });
172+
173+ this.pos = [f32(4 * n), f32(4 * n)]; // [u | v | w | wLo]
174+ this.vel = [f32(3 * n), f32(3 * n)]; // [du | dv | dw]
175+ this.hist = f32(this.histLen * n);
176+ this.stickBuf = f32(STICK_LEN);
177+ this.w0 = f32(n);
178+ const shape = Float32Array.from(plate.w0);
179+ device.queue.writeBuffer(this.w0, 0, shape);
180+ this.w0Max = shape.reduce((mx, v) => Math.max(mx, Math.abs(v)), 0);
181+ const weights = nodeWeights(g);
182+ this.nw = f32(n);
183+ device.queue.writeBuffer(this.nw, 0, weights);
184+ const thick = Float32Array.from(plate.h);
185+ this.hn = f32(n);
186+ device.queue.writeBuffer(this.hn, 0, thick);
187+ const packed = new Float32Array(4 * n);
188+ for (let k = 0; k < n; k++) { packed[4 * k] = shape[k]; packed[4 * k + 1] = weights[k]; packed[4 * k + 2] = thick[k]; }
189+ this.nd = f32(4 * n);
190+ device.queue.writeBuffer(this.nd, 0, packed);
191+ this.lw = f32(2 * n);
192+ this.audioOut = f32(this.samplesPerBatch);
193+ this.frameBuf = f32(n);
194+ this.displayW = f32(n);
195+ this.statsBuf = f32(STATS_LEN);
196+ this.stagingSize = (this.samplesPerBatch + n + STATS_LEN + STICK_LEN) * 4;
197+ this.staging = [0, 1].map(() =>
198+ device.createBuffer({ size: this.stagingSize, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST }),
199+ );
200+
201+ // Mass and moments of inertia of the plate, the contact patch, the mount patch,
202+ // and the initial stick state (position 0, moving toward the plate).
203+ const { mass, Ix, Iy } = massProperties(plate);
204+ this.mass = mass;
205+ const cp = contactPatch(params, plate);
206+ const mp = mountPatch(plate);
207+ const stick = new Float32Array(STICK_LEN);
208+ stick[1] = -params.strikeVelocity; stick[5] = -params.strikeVelocity;
209+ device.queue.writeBuffer(this.stickBuf, 0, stick);
210+ this.stick = { x: 0, v: -params.strikeVelocity, force: 0, maxForce: 0 };
211+
212+ const [sx, sy] = strikePoint(params, plate);
213+ const pf = new Float32Array(PARAMS_LEN);
214+ const pu = new Uint32Array(pf.buffer);
215+ pu[0] = g.nx; pu[1] = g.ny; pu[2] = n; pu[3] = this.histLen;
216+ pf[4] = g.hx; pf[5] = g.hy; pf[6] = this.dt; pf[7] = m.nu;
217+ pf[8] = m.E / (12 * (1 - m.nu * m.nu)); pf[9] = m.E / (1 - m.nu * m.nu); pf[10] = m.E / (2 * (1 + m.nu)); pf[11] = m.rho;
218+ pf[12] = mass; pf[13] = Ix; pf[14] = Iy; pf[15] = Ix + Iy;
219+ pf[16] = params.fs; pf[17] = sx; pf[18] = sy; pf[19] = params.contactRadius ** 2;
220+ pf[20] = cp.mass; pu[21] = cp.nearest; pf[22] = params.contactStiffness; pf[23] = params.contactExponent;
221+ pf[24] = params.stickMass; pu[25] = cp.box[0]; pu[26] = cp.box[1]; pu[27] = cp.box[2];
222+ pu[28] = cp.box[3]; pf[29] = cp.center[0]; pf[30] = cp.center[1]; pf[31] = params.inPlaneMass;
223+ pf[32] = 1 / params.inPlaneMass;
224+ if (mp && plate.mount) {
225+ pf[33] = plate.mount.x; pf[34] = plate.mount.y; pf[35] = plate.mount.radius ** 2;
226+ pf[36] = plate.mount.stiffness / mp.weight; pf[37] = plate.mount.damping / mp.weight;
227+ pu[38] = mp.nearest; pu[39] = 1;
228+ }
229+ this.paramsBuf = device.createBuffer({ size: pf.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
230+ device.queue.writeBuffer(this.paramsBuf, 0, pf);
231+
232+ // Per-step parameters, one struct per step at a dynamic offset.
233+ this.stepStride = Math.max(STEP_LEN * 4, device.limits.minUniformBufferOffsetAlignment);
234+ this.stepData = new ArrayBuffer(this.stepStride * this.stepsPerBatch);
235+ this.stepBuf = device.createBuffer({ size: this.stepData.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
236+
237+ // Pipelines.
238+ const shader = (code: string) => device.createShaderModule({ code: commonWgsl + code });
239+ const entry = (binding: number, type: GPUBufferBindingType, hasDynamicOffset = false): GPUBindGroupLayoutEntry => ({
240+ binding,
241+ visibility: GPUShaderStage.COMPUTE,
242+ buffer: { type, hasDynamicOffset },
243+ });
244+ const pipeline = (module: GPUShaderModule, entryPoint: string, layout: GPUBindGroupLayout) =>
245+ device.createComputePipeline({ layout: device.createPipelineLayout({ bindGroupLayouts: [layout] }), compute: { module, entryPoint } });
246+
247+ const stepLayout = device.createBindGroupLayout({
248+ entries: [
249+ entry(0, "uniform"),
250+ entry(1, "uniform", true),
251+ entry(2, "read-only-storage"),
252+ entry(3, "read-only-storage"),
253+ entry(4, "storage"),
254+ entry(5, "storage"),
255+ entry(6, "storage"),
256+ entry(7, "storage"),
257+ entry(8, "read-only-storage"),
258+ ],
259+ });
260+ this.stepPipe = pipeline(shader(stepWgsl), "main", stepLayout);
261+
262+ const rigidModule = shader(rigidWgsl);
263+ const rbLayout = device.createBindGroupLayout({
264+ entries: [entry(0, "uniform"), entry(1, "storage"), entry(2, "storage"), entry(3, "storage"), entry(8, "read-only-storage")],
265+ });
266+ this.sumsPipe = pipeline(rigidModule, "sums", rbLayout);
267+ this.applyPipe = pipeline(rigidModule, "apply", rbLayout);
268+
269+ const audioLayout = device.createBindGroupLayout({
270+ entries: [entry(0, "uniform"), entry(1, "uniform", true), entry(2, "read-only-storage"), entry(3, "read-only-storage"), entry(4, "storage")],
271+ });
272+ this.audioPipe = pipeline(shader(audioWgsl), "main", audioLayout);
273+
274+ const extractLayout = device.createBindGroupLayout({
275+ entries: [entry(0, "uniform"), entry(1, "read-only-storage"), entry(2, "storage")],
276+ });
277+ this.extractPipe = pipeline(shader(extractWgsl), "main", extractLayout);
278+
279+ // Bind groups. The state is double-buffered: bind group p reads pair p and writes pair 1 - p.
280+ const sp = { buffer: this.stepBuf, size: STEP_LEN * 4 };
281+ this.stepBG = [0, 1].map((p) =>
282+ device.createBindGroup({
283+ layout: stepLayout,
284+ entries: [
285+ { binding: 0, resource: { buffer: this.paramsBuf } },
286+ { binding: 1, resource: sp },
287+ { binding: 2, resource: { buffer: this.pos[p] } },
288+ { binding: 3, resource: { buffer: this.vel[p] } },
289+ { binding: 4, resource: { buffer: this.pos[1 - p] } },
290+ { binding: 5, resource: { buffer: this.vel[1 - p] } },
291+ { binding: 6, resource: { buffer: this.hist } },
292+ { binding: 7, resource: { buffer: this.stickBuf } },
293+ { binding: 8, resource: { buffer: this.nd } },
294+ ],
295+ }),
296+ );
297+ this.rbBG = [0, 1].map((p) =>
298+ device.createBindGroup({
299+ layout: rbLayout,
300+ entries: [
301+ { binding: 0, resource: { buffer: this.paramsBuf } },
302+ { binding: 1, resource: { buffer: this.pos[p] } },
303+ { binding: 2, resource: { buffer: this.vel[p] } },
304+ { binding: 3, resource: { buffer: this.statsBuf } },
305+ { binding: 8, resource: { buffer: this.nd } },
306+ ],
307+ }),
308+ );
309+ this.audioBG = device.createBindGroup({
310+ layout: audioLayout,
311+ entries: [
312+ { binding: 0, resource: { buffer: this.paramsBuf } },
313+ { binding: 1, resource: sp },
314+ { binding: 2, resource: { buffer: this.hist } },
315+ { binding: 3, resource: { buffer: this.lw } },
316+ { binding: 4, resource: { buffer: this.audioOut } },
317+ ],
318+ });
319+ const extractBG = (p: number, out: GPUBuffer) =>
320+ device.createBindGroup({
321+ layout: extractLayout,
322+ entries: [
323+ { binding: 0, resource: { buffer: this.paramsBuf } },
324+ { binding: 1, resource: { buffer: this.pos[p] } },
325+ { binding: 2, resource: { buffer: out } },
326+ ],
327+ });
328+ this.extractBG = [0, 1].map((p) => extractBG(p, this.frameBuf));
329+ this.displayBG = [0, 1].map((p) => extractBG(p, this.displayW));
330+
331+ this.setListener(params.listenerDist, params.listenerAzimuth, params.listenerElevation);
332+ // Frame 0: the plate at rest when the stick arrives.
333+ this.frames.push(new Float32Array(n));
334+ }
335+
336+ /** Recomputes the per-node weights and delays of the listener sum. Takes effect on later samples. */
337+ setListener(dist: number, azimuthDeg: number, elevationDeg: number): void {
338+ const g = this.grid;
339+ const az = (azimuthDeg * Math.PI) / 180;
340+ const el = (elevationDeg * Math.PI) / 180;
341+ const L = [dist * Math.sin(az) * Math.cos(el), dist * Math.sin(el), dist * Math.cos(az) * Math.cos(el)];
342+ const data = new Float32Array(2 * g.n);
343+ let dmin = Infinity;
344+ for (let j = 0; j < g.ny; j++) {
345+ for (let i = 0; i < g.nx; i++) {
346+ const k = j * g.nx + i;
347+ const rx = L[0] - nodeX(g, i), ry = L[1] - nodeY(g, j), rz = L[2];
348+ const r = Math.sqrt(rx * rx + ry * ry + rz * rz);
349+ const dA = g.hx * g.hy * nodeWeight(g, i, j);
350+ data[2 * k] = (RHO_AIR * dA) / (2 * Math.PI * r);
351+ data[2 * k + 1] = (r / SPEED_OF_SOUND) * this.params.fs;
352+ dmin = Math.min(dmin, data[2 * k + 1]);
353+ }
354+ }
355+ // Only the spread of delays across the plate is kept; the common delay is dropped.
356+ this.delayClamped = false;
357+ for (let k = 0; k < g.n; k++) {
358+ let d = data[2 * k + 1] - dmin;
359+ if (d > HIST_MARGIN - 3) {
360+ d = HIST_MARGIN - 3;
361+ this.delayClamped = true;
362+ }
363+ data[2 * k + 1] = d;
364+ }
365+ this.device.queue.writeBuffer(this.lw, 0, data);
366+ this.params.listenerDist = dist;
367+ this.params.listenerAzimuth = azimuthDeg;
368+ this.params.listenerElevation = elevationDeg;
369+ }
370+
371+ setDuration(duration: number): void {
372+ this.params.duration = duration;
373+ if (this.canContinue()) this.phase = "recording";
374+ }
375+
376+ /** True when a finished or stopped run can be extended. */
377+ canContinue(): boolean {
378+ return this.phase === "done" && this.samples < this.targetSamples();
379+ }
380+
381+ /** Ends the run; the state, audio, and movie are kept. */
382+ stop(): void {
383+ this.phase = "done";
384+ }
385+
386+ targetSamples(): number {
387+ return Math.round(this.params.duration * this.params.fs);
388+ }
389+
390+ status(): SimStatus {
391+ // Report the time of the state that has been read back, not the one enqueued.
392+ const ahead = this.inFlight ? this.inFlight.steps * this.dt : 0;
393+ return {
394+ phase: this.phase,
395+ t: this.totalSteps * this.dt - ahead,
396+ totalSteps: this.totalSteps,
397+ ke: this.ke,
398+ wmax: this.wmax,
399+ samples: this.samples,
400+ frames: this.frames.length,
401+ peakPressure: this.peakPressure,
402+ delayClamped: this.delayClamped,
403+ stick: this.stick,
404+ };
405+ }
406+
407+ /** Enqueues an extraction of the current w field into `displayW` (for the live view). */
408+ encodeDisplay(encoder: GPUCommandEncoder): void {
409+ const pass = encoder.beginComputePass();
410+ pass.setPipeline(this.extractPipe);
411+ pass.setBindGroup(0, this.displayBG[this.cur]);
412+ pass.dispatchWorkgroups(Math.ceil(this.grid.n / 256));
413+ pass.end();
414+ }
415+
416+ private stepWorkgroups(): [number, number] {
417+ return [Math.ceil(this.grid.nx / TILE), Math.ceil(this.grid.ny / TILE)];
418+ }
419+
420+ /** The recorded listener pressure so far [Pa]. */
421+ recordedAudio(): Float32Array {
422+ return this.audio.subarray(0, this.samples);
423+ }
424+
425+ private appendAudio(raw: Float32Array): void {
426+ const need = this.samples + raw.length;
427+ if (this.audio.length < need) {
428+ const a = new Float32Array(Math.max(need, 2 * this.audio.length, this.params.fs));
429+ a.set(this.audio.subarray(0, this.samples));
430+ this.audio = a;
431+ }
432+ this.audio.set(raw, this.samples);
433+ for (let k = 0; k < raw.length; k++) this.peakPressure = Math.max(this.peakPressure, Math.abs(raw[k]));
434+ this.samples = need;
435+ }
436+
437+ /** Writes the per-step uniform for step s of a batch. */
438+ private writeStepParams(s: number, fields: { flags: number; slot: number; outIdx: number }): void {
439+ const o = (s * this.stepStride) / 4;
440+ const u32 = new Uint32Array(this.stepData);
441+ const i32 = new Int32Array(this.stepData);
442+ const f32 = new Float32Array(this.stepData);
443+ const { sigma0, sigma1, sigmaIn } = this.plate.loss;
444+ u32[o] = this.totalSteps + s;
445+ u32[o + 1] = fields.flags;
446+ i32[o + 2] = fields.slot;
447+ i32[o + 3] = fields.outIdx;
448+ f32[o + 4] = sigma0;
449+ f32[o + 5] = sigma1;
450+ f32[o + 6] = sigmaIn;
451+ u32[o + 7] = (this.totalSteps + s) & 1;
452+ }
453+
454+ /**
455+ * Runs one batch (one movie frame of time). Resolves when its results have
456+ * been read back. The following batch is encoded before this one is awaited.
457+ */
458+ async runBatch(): Promise<void> {
459+ if (this.phase === "done") return;
460+ const cur = this.inFlight ?? this.encodeBatch();
461+ this.inFlight = null;
462+ if (!cur) {
463+ this.phase = "done";
464+ return;
465+ }
466+ if (this.stepsLeft() > 0) this.inFlight = this.encodeBatch();
467+ await this.collect(cur);
468+ }
469+
470+ private stepsLeft(): number {
471+ return this.targetSamples() * this.stepsPerSample - this.totalSteps;
472+ }
473+
474+ /** Encodes and submits one batch; null when no steps remain. */
475+ private encodeBatch(): Pending | null {
476+ const p = this.params;
477+ const steps = Math.min(this.stepsPerBatch, Math.max(0, this.stepsLeft()));
478+ if (steps === 0) return null;
479+
480+ // Per-step parameters: the drumstick and the audio bookkeeping.
481+ const audioSteps: number[] = [];
482+ for (let s = 0; s < steps; s++) {
483+ const local = this.totalSteps + s;
484+ let slot = -1, outIdx = -1;
485+ if (local % this.stepsPerSample === 0) {
486+ slot = (local / this.stepsPerSample) % this.histLen;
487+ outIdx = audioSteps.length;
488+ audioSteps.push(s);
489+ }
490+ this.writeStepParams(s, { flags: 1, slot, outIdx });
491+ }
492+ this.device.queue.writeBuffer(this.stepBuf, 0, this.stepData, 0, steps * this.stepStride);
493+
494+ // Encode the batch in chunks so that the GPU starts while JS is still encoding.
495+ const CHUNK = 1024;
496+ let enc = this.device.createCommandEncoder();
497+ let pass = enc.beginComputePass();
498+ const [wgX, wgY] = this.stepWorkgroups();
499+ const wgN = Math.ceil(this.grid.n / 256);
500+ let cur = this.cur;
501+ // The projection is optional and only meaningful for a free plate; the
502+ // sums still run once per batch for the statistics.
503+ const project = p.rigidProjection && !this.plate.mount;
504+ let stepBound = false;
505+ for (let s = 0; s < steps; s++) {
506+ if (s > 0 && s % CHUNK === 0) {
507+ pass.end();
508+ this.device.queue.submit([enc.finish()]);
509+ enc = this.device.createCommandEncoder();
510+ pass = enc.beginComputePass();
511+ stepBound = false;
512+ }
513+ if (!stepBound) {
514+ pass.setPipeline(this.stepPipe);
515+ stepBound = true;
516+ }
517+ pass.setBindGroup(0, this.stepBG[cur], [s * this.stepStride]);
518+ pass.dispatchWorkgroups(wgX, wgY);
519+ cur = 1 - cur;
520+ const global = this.totalSteps + s + 1;
521+ if ((project && global % RB_INTERVAL === 0) || s === steps - 1) {
522+ pass.setPipeline(this.sumsPipe);
523+ pass.setBindGroup(0, this.rbBG[cur]);
524+ pass.dispatchWorkgroups(1);
525+ if (project) {
526+ pass.setPipeline(this.applyPipe);
527+ pass.setBindGroup(0, this.rbBG[cur]);
528+ pass.dispatchWorkgroups(wgN);
529+ }
530+ stepBound = false;
531+ }
532+ }
533+ // The listener sum for every audio sample of the batch, one workgroup each;
534+ // the ring buffer is long enough that no slot of the batch has been reused.
535+ const nAudio = audioSteps.length;
536+ if (nAudio > 0) {
537+ pass.setPipeline(this.audioPipe);
538+ pass.setBindGroup(0, this.audioBG, [audioSteps[0] * this.stepStride]);
539+ pass.dispatchWorkgroups(nAudio);
540+ }
541+ pass.setPipeline(this.extractPipe);
542+ pass.setBindGroup(0, this.extractBG[cur]);
543+ pass.dispatchWorkgroups(wgN);
544+ pass.end();
545+ const staging = this.staging[this.stagingIdx];
546+ this.stagingIdx = 1 - this.stagingIdx;
547+ const n = this.grid.n;
548+ enc.copyBufferToBuffer(this.audioOut, 0, staging, 0, Math.max(4, nAudio * 4));
549+ enc.copyBufferToBuffer(this.frameBuf, 0, staging, this.samplesPerBatch * 4, n * 4);
550+ enc.copyBufferToBuffer(this.statsBuf, 0, staging, (this.samplesPerBatch + n) * 4, STATS_LEN * 4);
551+ enc.copyBufferToBuffer(this.stickBuf, 0, staging, (this.samplesPerBatch + n + STATS_LEN) * 4, STICK_LEN * 4);
552+ this.device.queue.submit([enc.finish()]);
553+ this.cur = cur;
554+ this.totalSteps += steps;
555+ return { staging, steps, nAudio, stickParity: this.totalSteps & 1 };
556+ }
557+
558+ /** Reads back a batch and applies its results. */
559+ private async collect(b: Pending): Promise<void> {
560+ const n = this.grid.n;
561+ await b.staging.mapAsync(GPUMapMode.READ);
562+ const view = new Float32Array(b.staging.getMappedRange());
563+ const stats = view.subarray(this.samplesPerBatch + n, this.samplesPerBatch + n + STATS_LEN);
564+ this.ke = stats[12];
565+ this.wmax = stats[13];
566+ // The slot written by the last step is the one the next step reads.
567+ const base = this.samplesPerBatch + n + STATS_LEN + 4 * b.stickParity;
568+ this.stick = { x: view[base], v: view[base + 1], force: view[base + 2], maxForce: view[base + 3] };
569+ this.appendAudio(view.slice(0, b.nAudio));
570+ this.frames.push(view.slice(this.samplesPerBatch, this.samplesPerBatch + n));
571+ b.staging.unmap();
572+ if (this.samples >= this.targetSamples()) this.phase = "done";
573+ }
574+
575+ // ---------- Testing support ----------
576+
577+ /**
578+ * Advances the state by exactly one free step (no drumstick, no rigid-body
579+ * projection, no audio) with the plate's loss parameters and its mount.
580+ */
581+ async stepOnce(): Promise<void> {
582+ this.writeStepParams(0, { flags: 0, slot: -1, outIdx: -1 });
583+ this.device.queue.writeBuffer(this.stepBuf, 0, this.stepData, 0, this.stepStride);
584+ const enc = this.device.createCommandEncoder();
585+ const pass = enc.beginComputePass();
586+ pass.setPipeline(this.stepPipe);
587+ pass.setBindGroup(0, this.stepBG[this.cur], [0]);
588+ pass.dispatchWorkgroups(...this.stepWorkgroups());
589+ pass.end();
590+ this.device.queue.submit([enc.finish()]);
591+ this.cur = 1 - this.cur;
592+ this.totalSteps++;
593+ await this.device.queue.onSubmittedWorkDone();
594+ }
595+
596+ /** Overwrites the state: pos as [u | v | w | wLo], vel as [du | dv | dw]. */
597+ writeState(pos: Float32Array<ArrayBuffer>, vel: Float32Array<ArrayBuffer>): void {
598+ const n = this.grid.n;
599+ if (pos.length !== 4 * n || vel.length !== 3 * n) throw new Error("state arrays have the wrong length");
600+ this.device.queue.writeBuffer(this.pos[this.cur], 0, pos);
601+ this.device.queue.writeBuffer(this.vel[this.cur], 0, vel);
602+ }
603+
604+ /** Reads back the full state as [u | v | w | wLo] and [du | dv | dw]. */
605+ async readState(): Promise<{ pos: Float32Array; vel: Float32Array }> {
606+ const n = this.grid.n;
607+ const stg = this.device.createBuffer({ size: 7 * n * 4, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST });
608+ const enc = this.device.createCommandEncoder();
609+ enc.copyBufferToBuffer(this.pos[this.cur], 0, stg, 0, 4 * n * 4);
610+ enc.copyBufferToBuffer(this.vel[this.cur], 0, stg, 4 * n * 4, 3 * n * 4);
611+ this.device.queue.submit([enc.finish()]);
612+ await stg.mapAsync(GPUMapMode.READ);
613+ const all = new Float32Array(stg.getMappedRange()).slice();
614+ stg.unmap();
615+ stg.destroy();
616+ return { pos: all.subarray(0, 4 * n), vel: all.subarray(4 * n) };
617+ }
618+
619+ destroy(): void {
620+ this.inFlight = null;
621+ for (const b of [...this.pos, ...this.vel, this.hist, this.stickBuf, this.nd, this.w0, this.nw, this.hn, this.lw, this.audioOut, this.frameBuf, this.displayW, this.statsBuf, this.paramsBuf, this.stepBuf, ...this.staging]) b.destroy();
622+ }
623+
624+ get fs(): number {
625+ return this.params.fs;
626+ }
627+ get fps(): number {
628+ return this.params.fps;
629+ }
630+ get simParams(): SimParams {
631+ return this.params;
632+ }
633+}
src/style.cssadded+69−0View file
@@ -0,0 +1,69 @@
1+:root {
2+ color-scheme: dark;
3+ --bg: #15171a;
4+ --panel: #1c1e22;
5+ --border: #2c3036;
6+ --text: #dfe2e6;
7+ --muted: #9aa0a8;
8+ --accent: #f0b429;
9+ --accent2: #3b8ce6;
10+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
11+ font-size: 13px;
12+}
13+* { box-sizing: border-box; }
14+html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text); }
15+#app { display: flex; height: 100vh; }
16+#sidebar {
17+ width: 420px; flex: none; overflow-y: auto; padding: 12px 14px; background: var(--panel);
18+ border-right: 1px solid var(--border);
19+}
20+#main { flex: 1; display: flex; flex-direction: column; min-width: 0; padding: 8px; gap: 6px; }
21+h1 { font-size: 17px; margin: 0 0 4px; }
22+.sub { color: var(--muted); margin: 0 0 8px; line-height: 1.35; }
23+a { color: var(--accent2); }
24+details { border-top: 1px solid var(--border); padding: 4px 0; }
25+details summary { cursor: pointer; font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); padding: 4px 0; }
26+.field { display: grid; grid-template-columns: 1fr 92px 46px; align-items: center; gap: 6px; margin: 3px 0; }
27+.field label { color: var(--text); }
28+.field .unit { color: var(--muted); font-size: 11px; }
29+input[type=number], select, textarea {
30+ width: 100%; background: #0f1113; color: var(--text); border: 1px solid var(--border); border-radius: 4px; padding: 3px 5px; font: inherit;
31+}
32+input[type=number]:disabled, textarea:disabled { color: var(--muted); }
33+button {
34+ background: #2a2e34; color: var(--text); border: 1px solid #3a3f47; border-radius: 5px; padding: 5px 10px; font: inherit; cursor: pointer;
35+}
36+button:hover { background: #343941; }
37+button:disabled { opacity: 0.45; cursor: default; }
38+button.primary { background: var(--accent); color: #1a1500; border-color: var(--accent); font-weight: 600; }
39+button.primary:hover { background: #f8c453; }
40+#controls { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0; border-top: 1px solid var(--border); padding-top: 8px; }
41+#info { margin-top: 10px; border-top: 1px solid var(--border); padding-top: 8px; }
42+.row { display: flex; justify-content: space-between; gap: 8px; padding: 1px 0; }
43+.row span:last-child { font-variant-numeric: tabular-nums; color: var(--text); text-align: right; }
44+.row span:first-child { color: var(--muted); flex: none; }
45+.warn { color: var(--accent); }
46+.err { color: #ff6b6b; }
47+.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 4px 6px; background: var(--panel); border: 1px solid var(--border); border-radius: 6px; }
48+.toolbar label { display: inline-flex; align-items: center; gap: 4px; color: var(--muted); }
49+.toolbar input[type=number], .toolbar select { width: 82px; }
50+.toolbar select { width: auto; }
51+.toolbar input[type=range] { flex: 1; min-width: 120px; }
52+.toolbar .time { font-variant-numeric: tabular-nums; min-width: 72px; }
53+#viewWrap { position: relative; flex: 3; min-height: 200px; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
54+#view { width: 100%; height: 100%; display: block; touch-action: none; }
55+#spec { flex: 2; min-height: 140px; width: 100%; border: 1px solid var(--border); border-radius: 6px; }
56+#nogpu[hidden] { display: none; }
57+#nogpu { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 24px; text-align: center; color: #ff8a8a; background: var(--bg); }
58+.seg button.on { background: var(--accent2); border-color: var(--accent2); color: white; }
59+button.on { background: var(--accent2); border-color: var(--accent2); color: white; }
60+.editor-head { display: flex; align-items: center; gap: 6px; margin: 4px 0 6px; }
61+.editor-head label { color: var(--muted); flex: none; }
62+.editor-head select { flex: 1; }
63+textarea.script {
64+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.4;
65+ min-height: 280px; height: 340px; resize: vertical; white-space: pre; overflow: auto; tab-size: 2;
66+}
67+.editor-status { margin: 6px 0 4px; min-height: 1.2em; white-space: pre-wrap; }
68+.editor-summary { margin-bottom: 6px; }
69+.editor-summary .warn { margin-top: 4px; line-height: 1.35; }
src/ui/editor.tsadded+125−0View file
@@ -0,0 +1,125 @@
1+// The design editor: a preset selector, the script text, and a status line.
2+// The script is compiled on every edit (debounced); the owner is told about
3+// each new design or error and supplies the summary shown under the script.
4+
5+import { type Design, DesignError, compileDesign } from "../design.ts";
6+import { DEFAULT_PRESET, PRESETS } from "../presets.ts";
7+
8+const CUSTOM = "custom";
9+const DEBOUNCE_MS = 400;
10+
11+export class DesignEditor {
12+ /** Called with the compiled design, or null and the message when the script fails. */
13+ onDesign: (design: Design | null, error: string | null) => void = () => {};
14+
15+ readonly select = document.createElement("select");
16+ readonly textarea = document.createElement("textarea");
17+ private status = document.createElement("div");
18+ private summary = document.createElement("div");
19+ private timer: number | null = null;
20+ private lastSource = "";
21+ design: Design | null = null;
22+ error: string | null = null;
23+
24+ constructor(root: HTMLElement) {
25+ const head = document.createElement("div");
26+ head.className = "editor-head";
27+ const label = document.createElement("label");
28+ label.textContent = "Preset";
29+ for (const p of PRESETS) {
30+ const o = document.createElement("option");
31+ o.value = p.id; o.textContent = p.label;
32+ this.select.appendChild(o);
33+ }
34+ const custom = document.createElement("option");
35+ custom.value = CUSTOM; custom.textContent = "Custom (edited)";
36+ this.select.appendChild(custom);
37+ this.select.value = DEFAULT_PRESET.id;
38+ this.select.addEventListener("change", () => {
39+ const p = PRESETS.find((q) => q.id === this.select.value);
40+ if (!p) return;
41+ this.textarea.value = p.source;
42+ this.compileNow();
43+ });
44+ const btnReset = document.createElement("button");
45+ btnReset.textContent = "Revert";
46+ btnReset.title = "Restore the selected preset's script";
47+ btnReset.addEventListener("click", () => {
48+ const p = PRESETS.find((q) => q.id === this.select.value) ?? DEFAULT_PRESET;
49+ this.textarea.value = p.source;
50+ this.select.value = p.id;
51+ this.compileNow();
52+ });
53+ head.append(label, this.select, btnReset);
54+
55+ this.textarea.className = "script";
56+ this.textarea.spellcheck = false;
57+ this.textarea.wrap = "off";
58+ this.textarea.value = DEFAULT_PRESET.source;
59+ this.textarea.addEventListener("input", () => {
60+ if (this.select.value !== CUSTOM && this.textarea.value !== PRESETS.find((q) => q.id === this.select.value)?.source) this.select.value = CUSTOM;
61+ if (this.timer !== null) window.clearTimeout(this.timer);
62+ this.timer = window.setTimeout(() => this.compileNow(), DEBOUNCE_MS);
63+ });
64+ this.textarea.addEventListener("keydown", (e) => {
65+ if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
66+ e.preventDefault();
67+ this.compileNow();
68+ } else if (e.key === "Tab") {
69+ // Insert two spaces rather than leaving the field.
70+ e.preventDefault();
71+ const t = this.textarea;
72+ const s = t.selectionStart, end = t.selectionEnd;
73+ t.value = t.value.slice(0, s) + " " + t.value.slice(end);
74+ t.selectionStart = t.selectionEnd = s + 2;
75+ t.dispatchEvent(new Event("input"));
76+ }
77+ });
78+ this.status.className = "editor-status sub";
79+ this.summary.className = "editor-summary";
80+ const hint = document.createElement("p");
81+ hint.className = "sub";
82+ hint.textContent = "The script returns the design: material, bounds, outline, thickness, profile, mount, and losses, in SI units. It is applied as you type (or with Ctrl+Enter). Helpers: inch, mm, circle, ellipse, rectangle, clamp, lerp, smoothstep, bump, dome, bow, noise2d; every field function receives (x, y, r, theta).";
83+ root.append(head, this.textarea, this.status, this.summary, hint);
84+ }
85+
86+ /** Compiles the current text at once (no debounce) and notifies the owner if anything changed. */
87+ compileNow(): void {
88+ if (this.timer !== null) { window.clearTimeout(this.timer); this.timer = null; }
89+ const src = this.textarea.value;
90+ if (src === this.lastSource) return;
91+ this.lastSource = src;
92+ try {
93+ this.design = compileDesign(src);
94+ this.error = null;
95+ } catch (e) {
96+ this.design = null;
97+ this.error = e instanceof DesignError ? e.message : String(e);
98+ }
99+ this.setStatus(this.error, "");
100+ this.onDesign(this.design, this.error);
101+ }
102+
103+ /** Replaces the script (from a preset id or a source string) and compiles it. */
104+ setScript(source: string, presetId: string | null = null): void {
105+ this.textarea.value = source;
106+ this.select.value = presetId && PRESETS.some((p) => p.id === presetId) ? presetId : CUSTOM;
107+ this.compileNow();
108+ }
109+
110+ get source(): string {
111+ return this.textarea.value;
112+ }
113+
114+ /** Shows an error (red) or a message, and the summary HTML below it. */
115+ setStatus(error: string | null, summaryHtml: string): void {
116+ this.status.textContent = error ?? "";
117+ this.status.className = error ? "editor-status err" : "editor-status sub";
118+ this.summary.innerHTML = summaryHtml;
119+ }
120+
121+ setEnabled(enabled: boolean): void {
122+ this.textarea.disabled = !enabled;
123+ this.select.disabled = !enabled;
124+ }
125+}
src/ui/form.tsadded+250−0View file
@@ -0,0 +1,250 @@
1+// Parameter form for the strike, the listener, and the numerics: numeric
2+// fields in convenient units and a few selects, with derived-quantity notes
3+// that depend on the current plate. Everything is stored in SI in `params`.
4+
5+import {
6+ type Plate,
7+ type SimParams,
8+ CFL,
9+ countInside,
10+ defaultParams,
11+ dispersionFrequency,
12+ membraneStiffness,
13+ t60,
14+ thicknessRange,
15+} from "../params.ts";
16+import { maxAngularFrequency } from "../sim/cpuRef.ts";
17+
18+interface FieldSpec {
19+ key: keyof SimParams;
20+ label: string;
21+ unit: string;
22+ scale: number; // form value = SI value / scale
23+ step?: number;
24+ live?: boolean; // may be changed while a simulation runs
25+}
26+
27+interface SelectSpec {
28+ key: keyof SimParams;
29+ label: string;
30+ options: [string, string][];
31+}
32+
33+interface Group {
34+ title: string;
35+ open: boolean;
36+ selects?: SelectSpec[];
37+ fields: FieldSpec[];
38+ note?: "strike" | "grid" | "numerics";
39+}
40+
41+const groups: Group[] = [
42+ {
43+ title: "Strike",
44+ open: true,
45+ fields: [
46+ { key: "strikeFracX", label: "Strike x (fraction of width)", unit: "", scale: 1, step: 0.05 },
47+ { key: "strikeFracY", label: "Strike y (fraction of height)", unit: "", scale: 1, step: 0.05 },
48+ { key: "strikeVelocity", label: "Stick speed", unit: "m/s", scale: 1, step: 0.5 },
49+ { key: "stickMass", label: "Stick mass", unit: "g", scale: 1e-3, step: 5 },
50+ { key: "contactStiffness", label: "Contact stiffness K", unit: "N/mᵅ", scale: 1, step: 1e8 },
51+ { key: "contactExponent", label: "Contact exponent α", unit: "", scale: 1, step: 0.1 },
52+ { key: "contactRadius", label: "Tip radius", unit: "mm", scale: 1e-3, step: 1 },
53+ ],
54+ note: "strike",
55+ },
56+ {
57+ title: "Listener",
58+ open: true,
59+ fields: [
60+ { key: "listenerDist", label: "Distance", unit: "m", scale: 1, step: 0.1, live: true },
61+ { key: "listenerAzimuth", label: "Azimuth", unit: "deg", scale: 1, step: 5, live: true },
62+ { key: "listenerElevation", label: "Elevation", unit: "deg", scale: 1, step: 5, live: true },
63+ ],
64+ },
65+ {
66+ title: "Grid and numerics",
67+ open: true,
68+ selects: [{ key: "rigidProjection", label: "Rigid-body projection (free plate)", options: [["false", "Off"], ["true", "On (every 32 steps)"]] }],
69+ fields: [
70+ { key: "spacing", label: "Grid spacing", unit: "mm", scale: 1e-3, step: 0.5 },
71+ { key: "inPlaneMass", label: "In-plane mass factor", unit: "", scale: 1, step: 1 },
72+ { key: "fs", label: "Audio sample rate", unit: "Hz", scale: 1, step: 100 },
73+ { key: "fps", label: "Movie frame rate", unit: "fps", scale: 1, step: 10 },
74+ ],
75+ note: "grid",
76+ },
77+];
78+
79+const selectKeys: (keyof SimParams)[] = ["rigidProjection"];
80+
81+export function formatNumber(v: number): string {
82+ if (v === 0) return "0";
83+ const a = Math.abs(v);
84+ if (a >= 1e5 || a < 1e-3) return v.toExponential(3);
85+ return String(+v.toPrecision(6));
86+}
87+
88+export class ParamForm {
89+ readonly params: SimParams;
90+ /** Called after any change; `live` tells whether the field may change during a run. */
91+ onChange: (key: keyof SimParams, live: boolean) => void = () => {};
92+
93+ private inputs = new Map<keyof SimParams, HTMLInputElement | HTMLSelectElement>();
94+ private strikeNote: HTMLElement | null = null;
95+ private gridNote: HTMLElement | null = null;
96+ private omegaCache: { key: string; omega: number } | null = null;
97+ private liveKeys = new Set<keyof SimParams>();
98+ private plate: Plate | null = null;
99+
100+ constructor(root: HTMLElement, params: SimParams) {
101+ this.params = params;
102+ for (const g of groups) {
103+ const det = document.createElement("details");
104+ det.open = g.open;
105+ const sum = document.createElement("summary");
106+ sum.textContent = g.title;
107+ det.appendChild(sum);
108+ for (const s of g.selects ?? []) det.appendChild(this.selectRow(s));
109+ for (const f of g.fields) {
110+ det.appendChild(this.fieldRow(f));
111+ if (f.live) this.liveKeys.add(f.key);
112+ }
113+ if (g.note) {
114+ const note = document.createElement("p");
115+ note.className = "sub";
116+ if (g.note === "strike") this.strikeNote = note;
117+ else if (g.note === "grid") this.gridNote = note;
118+ det.appendChild(note);
119+ }
120+ root.appendChild(det);
121+ }
122+ this.updateNotes();
123+ }
124+
125+ private selectRow(s: SelectSpec): HTMLElement {
126+ const row = document.createElement("div");
127+ row.className = "field";
128+ const l = document.createElement("label");
129+ l.textContent = s.label;
130+ const sel = document.createElement("select");
131+ for (const [v, t] of s.options) {
132+ const o = document.createElement("option");
133+ o.value = v; o.textContent = t;
134+ sel.appendChild(o);
135+ }
136+ sel.value = String(this.params[s.key]);
137+ sel.addEventListener("change", () => {
138+ (this.params as unknown as Record<string, boolean>)[s.key] = sel.value === "true";
139+ this.updateNotes();
140+ this.onChange(s.key, false);
141+ });
142+ row.append(l, sel, document.createElement("span"));
143+ this.inputs.set(s.key, sel);
144+ return row;
145+ }
146+
147+ private fieldRow(f: FieldSpec): HTMLElement {
148+ const row = document.createElement("div");
149+ row.className = "field";
150+ const label = document.createElement("label");
151+ label.textContent = f.label;
152+ const inp = document.createElement("input");
153+ inp.type = "number";
154+ inp.step = String(f.step ?? 1);
155+ inp.value = formatNumber((this.params[f.key] as number) / f.scale);
156+ inp.addEventListener("change", () => {
157+ const v = parseFloat(inp.value);
158+ if (!isFinite(v)) return;
159+ (this.params as unknown as Record<string, number>)[f.key] = v * f.scale;
160+ this.updateNotes();
161+ this.onChange(f.key, !!f.live);
162+ });
163+ this.inputs.set(f.key, inp);
164+ const unit = document.createElement("span");
165+ unit.className = "unit";
166+ unit.textContent = f.unit;
167+ row.append(label, inp, unit);
168+ return row;
169+ }
170+
171+ /** Rewrites every input from the current parameter values. */
172+ refresh(): void {
173+ for (const g of groups) {
174+ for (const f of g.fields) {
175+ const inp = this.inputs.get(f.key) as HTMLInputElement;
176+ inp.value = formatNumber((this.params[f.key] as number) / f.scale);
177+ }
178+ }
179+ for (const k of selectKeys) (this.inputs.get(k) as HTMLSelectElement).value = String(this.params[k]);
180+ this.updateNotes();
181+ }
182+
183+ /** Enables or disables the fields that cannot change while a simulation runs. */
184+ setEnabled(enabled: boolean): void {
185+ for (const [key, el] of this.inputs) if (!this.liveKeys.has(key)) el.disabled = !enabled;
186+ }
187+
188+ /** Applies URL parameters: any SimParams key in SI units (booleans as 0/1 or false/true). Returns the keys that were set. */
189+ applyUrl(q: URLSearchParams): (keyof SimParams)[] {
190+ const set: (keyof SimParams)[] = [];
191+ for (const [k, v] of q) {
192+ if (!(k in this.params)) continue;
193+ const key = k as keyof SimParams;
194+ if (selectKeys.includes(key)) {
195+ (this.params as unknown as Record<string, boolean>)[key] = v === "1" || v === "true";
196+ set.push(key);
197+ } else {
198+ const num = parseFloat(v);
199+ if (isFinite(num)) {
200+ (this.params as unknown as Record<string, number>)[key] = num;
201+ set.push(key);
202+ }
203+ }
204+ }
205+ this.refresh();
206+ return set;
207+ }
208+
209+ /** The plate the notes describe (the design evaluated at the current spacing), or null while the script has an error. */
210+ setPlate(plate: Plate | null): void {
211+ this.plate = plate;
212+ this.updateNotes();
213+ }
214+
215+ /** Rewrites the derived-quantity notes; call after a change made outside the form (the recorded duration). */
216+ updateNotes(): void {
217+ const p = this.params;
218+ const pl = this.plate;
219+ if (this.strikeNote) {
220+ this.strikeNote.textContent = `One hit at ${p.strikeVelocity.toFixed(1)} m/s; the recorded duration of ${p.duration} s starts at the impact. The strike point is a fraction of the bounding box; the yellow ring in the view marks it.`;
221+ }
222+ if (this.gridNote) {
223+ if (!pl) {
224+ this.gridNote.textContent = "The grid follows the design's bounding box at the chosen spacing.";
225+ return;
226+ }
227+ const g = pl.grid;
228+ const hr = thicknessRange(pl);
229+ const f3 = dispersionFrequency(pl.mat, hr.mean, g, 0.03), f10 = dispersionFrequency(pl.mat, hr.mean, g, 0.1);
230+ const s = Math.max(1, p.inPlaneMass);
231+ // The power iteration takes a moment, so its result is kept until the spacing, the material, the thickness, or the factor changes.
232+ const key = [g.hx, g.hy, pl.mat.E, pl.mat.nu, pl.mat.rho, hr.max, s].join("|");
233+ if (!this.omegaCache || this.omegaCache.key !== key) this.omegaCache = { key, omega: maxAngularFrequency(g, pl.mat, hr.max, s) };
234+ const stepsPerSample = Math.ceil(1 / (p.fs * ((CFL * 2) / this.omegaCache.omega)));
235+ const dt = 1 / (p.fs * stepsPerSample);
236+ const cp = Math.sqrt(membraneStiffness(pl.mat, hr.mean) / (pl.mat.rho * hr.mean) / s);
237+ const steps = Math.round(p.duration / dt);
238+ const t = (hz: number) => t60(pl.mat, hr.mean, pl.loss, 2 * Math.PI * hz).toFixed(2);
239+ this.gridNote.textContent =
240+ `${g.nx} × ${g.ny} grid, ${countInside(g)} nodes in the plate, ${(g.hx * 1e3).toFixed(2)} × ${(g.hy * 1e3).toFixed(2)} mm. ` +
241+ `Bending partials come out low by 3% near ${(f3 / 1e3).toFixed(1)} kHz and by 10% near ${(f10 / 1e3).toFixed(1)} kHz (grid dispersion at the mean thickness). ` +
242+ `Time step ${(dt * 1e6).toFixed(2)} µs (${stepsPerSample} per sample, ${(steps / 1e6).toFixed(2)} million steps for ${p.duration} s), set by the in-plane wave speed of ${cp.toFixed(0)} m/s. ` +
243+ `An in-plane mass factor above 1 lengthens the step by up to its square root at the cost of lowering the in-plane modes. ` +
244+ `With the design's losses, T60 is ${t(100)} s at 100 Hz, ${t(1000)} s at 1 kHz, and ${t(5000)} s at 5 kHz.` +
245+ (pl.mount ? " The plate sits on a mount, which takes the reaction of the strike; the rigid-body projection is not applied." : " The plate is free: the strike is balanced by inertia relief, and the projection only removes rounding drift.");
246+ }
247+ }
248+}
249+
250+export { defaultParams };
tests/cpuRef.test.tsadded+341−0View file
@@ -0,0 +1,341 @@
1+// Tests of the spatial discretization on the float64 reference (no GPU needed).
2+
3+import { test } from "node:test";
4+import assert from "node:assert/strict";
5+import { accelerations, addDamping, fields, maxAngularFrequency, potentialEnergy, uniformPlate, type Fields } from "../src/sim/cpuRef.ts";
6+import { type Grid, type Material, type Plate, countInside, insideNode, membraneStiffness, mountPatch, nodeMass, nodeX, nodeY, withEllipseMask, withMask } from "../src/params.ts";
7+
8+// Steel with nu = 0.3, the value the reference tables use; 0.79 mm thick.
9+const mat: Material = { E: 170e9, nu: 0.3, rho: 7900 };
10+const H = 0.79e-3;
11+
12+function rectangle(nx: number, ny: number, Lx = 0.3, Ly = 0.2): Grid {
13+ return { nx, ny, hx: Lx / (nx - 1), hy: Ly / (ny - 1), n: nx * ny };
14+}
15+
16+function lcg(seed: number): () => number {
17+ return () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296 - 0.5);
18+}
19+
20+function massOf(pl: Plate, k: number): number {
21+ return nodeMass(pl, k % pl.grid.nx, Math.floor(k / pl.grid.nx));
22+}
23+
24+/** A plate whose thickness rises linearly from 0.5 to 1.5 mm across the width. */
25+function taperedPlate(g: Grid, w0?: Float64Array, mount: Plate["mount"] = null): Plate {
26+ const pl = uniformPlate(g, mat, H, w0);
27+ for (let j = 0; j < g.ny; j++) {
28+ for (let i = 0; i < g.nx; i++) {
29+ if (insideNode(g, i, j)) pl.h[j * g.nx + i] = 1e-3 * (1 + nodeX(g, i) / ((g.nx - 1) * g.hx));
30+ }
31+ }
32+ return { ...pl, mount };
33+}
34+
35+/** Eigenvalues of a symmetric matrix (row-major, n x n) by cyclic Jacobi. */
36+function symEig(A: Float64Array, n: number): number[] {
37+ const a = Float64Array.from(A);
38+ for (let sweep = 0; sweep < 60; sweep++) {
39+ let off = 0;
40+ for (let p = 0; p < n; p++) for (let q = p + 1; q < n; q++) off += a[p * n + q] ** 2;
41+ if (off < 1e-22) break;
42+ for (let p = 0; p < n; p++) {
43+ for (let q = p + 1; q < n; q++) {
44+ const apq = a[p * n + q];
45+ if (Math.abs(apq) < 1e-300) continue;
46+ const theta = (a[q * n + q] - a[p * n + p]) / (2 * apq);
47+ const t = Math.sign(theta || 1) / (Math.abs(theta) + Math.sqrt(theta * theta + 1));
48+ const c = 1 / Math.sqrt(t * t + 1), s = t * c;
49+ for (let k = 0; k < n; k++) {
50+ const akp = a[k * n + p], akq = a[k * n + q];
51+ a[k * n + p] = c * akp - s * akq;
52+ a[k * n + q] = s * akp + c * akq;
53+ }
54+ for (let k = 0; k < n; k++) {
55+ const apk = a[p * n + k], aqk = a[q * n + k];
56+ a[p * n + k] = c * apk - s * aqk;
57+ a[q * n + k] = s * apk + c * aqk;
58+ }
59+ }
60+ }
61+ }
62+ const ev: number[] = [];
63+ for (let k = 0; k < n; k++) ev.push(a[k * n + k]);
64+ return ev.sort((x, y) => x - y);
65+}
66+
67+/** Mass-symmetrized linear stiffness M^{-1/2} K M^{-1/2} of one block ("w" or "uv") over the nodes of the plate. */
68+function stiffness(pl: Plate, block: "w" | "uv"): { K: Float64Array; n: number } {
69+ const g = pl.grid;
70+ const nodes: number[] = [];
71+ for (let k = 0; k < g.n; k++) if (massOf(pl, k) > 0) nodes.push(k);
72+ const N = nodes.length;
73+ const n = block === "w" ? N : 2 * N;
74+ const K = new Float64Array(n * n);
75+ const x = fields(g.n), y = fields(g.n);
76+ const eps = 1e-9;
77+ for (let col = 0; col < n; col++) {
78+ x.u.fill(0); x.v.fill(0); x.w.fill(0);
79+ if (block === "w") x.w[nodes[col]] = eps;
80+ else if (col < N) x.u[nodes[col]] = eps;
81+ else x.v[nodes[col - N]] = eps;
82+ accelerations(pl, x, y);
83+ const mc = massOf(pl, nodes[col % N]);
84+ for (let row = 0; row < n; row++) {
85+ const acc = block === "w" ? y.w[nodes[row]] : row < N ? y.u[nodes[row]] : y.v[nodes[row - N]];
86+ K[row * n + col] = (-acc / eps) * Math.sqrt(massOf(pl, nodes[row % N]) / mc);
87+ }
88+ }
89+ return { K, n };
90+}
91+
92+function checkSymmetric(K: Float64Array, n: number): void {
93+ let asym = 0, scale = 0;
94+ for (let r = 0; r < n; r++) {
95+ for (let c = 0; c < n; c++) {
96+ asym = Math.max(asym, Math.abs(K[r * n + c] - K[c * n + r]));
97+ scale = Math.max(scale, Math.abs(K[r * n + c]));
98+ }
99+ }
100+ assert.ok(asym < 1e-6 * scale, `asymmetry ${asym} vs scale ${scale}`);
101+}
102+
103+/** `gap` is the smallest ratio of the fourth eigenvalue to the largest that counts as non-rigid (a tapered plate on a fine grid has softer low modes). */
104+function checkSymmetricWithThreeRigidModes(K: Float64Array, n: number, gap = 1e-4): number[] {
105+ checkSymmetric(K, n);
106+ const ev = symEig(K, n);
107+ assert.ok(Math.abs(ev[2]) < 1e-6 * ev[n - 1], "third rigid mode");
108+ assert.ok(ev[3] > gap * ev[n - 1], `fourth mode is not rigid (${ev[3]} vs ${ev[n - 1]})`);
109+ return ev;
110+}
111+
112+/** Largest relative mismatch between the forces and the centered-difference gradient of the energy. */
113+function gradientMismatch(pl: Plate, f: Fields): number {
114+ const g = pl.grid;
115+ const acc = fields(g.n);
116+ accelerations(pl, f, acc);
117+ const delta = 1e-7;
118+ let worst = 0;
119+ for (const [arr, accArr] of [[f.w, acc.w], [f.u, acc.u], [f.v, acc.v]] as const) {
120+ for (let k = 0; k < g.n; k += 5) {
121+ const mass = massOf(pl, k);
122+ if (mass === 0) continue;
123+ const orig = arr[k];
124+ arr[k] = orig + delta;
125+ const Ep = potentialEnergy(pl, f);
126+ arr[k] = orig - delta;
127+ const Em = potentialEnergy(pl, f);
128+ arr[k] = orig;
129+ const Ffd = -(Ep - Em) / (2 * delta);
130+ const Fan = accArr[k] * mass;
131+ worst = Math.max(worst, Math.abs(Ffd - Fan) / (Math.abs(Fan) + 1e-3));
132+ }
133+ }
134+ return worst;
135+}
136+
137+/** A random state with w of about 25 thicknesses (strongly nonlinear) and a cylindrical 3 mm bow. */
138+function randomState(g: Grid, seed: number): { f: Fields; w0: Float64Array } {
139+ const rnd = lcg(seed);
140+ const f = fields(g.n);
141+ const w0 = new Float64Array(g.n);
142+ for (let k = 0; k < g.n; k++) {
143+ const x = (k % g.nx) / (g.nx - 1) - 0.5;
144+ w0[k] = 0.003 * (1 - 4 * x * x);
145+ f.w[k] = 0.02 * rnd();
146+ f.u[k] = 1e-4 * rnd();
147+ f.v[k] = 1e-4 * rnd();
148+ }
149+ return { f, w0 };
150+}
151+
152+test("bending stiffness is symmetric and has exactly three rigid modes", () => {
153+ const { K, n } = stiffness(uniformPlate(rectangle(9, 7), mat, H), "w");
154+ checkSymmetricWithThreeRigidModes(K, n);
155+});
156+
157+test("membrane stiffness is symmetric and has exactly three rigid modes", () => {
158+ const { K, n } = stiffness(uniformPlate(rectangle(7, 6), mat, H), "uv");
159+ checkSymmetricWithThreeRigidModes(K, n);
160+});
161+
162+test("free square plate frequencies converge to the reference values", () => {
163+ // Completely free square plate, nu = 0.3, lambda^2 = omega a^2 sqrt(rho h / D)
164+ // (Gorman 1978; Leissa's Ritz values are slightly higher). The scheme is
165+ // second order: the errors are about 0.8 percent at 20 cells per side and
166+ // 0.3 percent at 30 (the pair at 34.80 is degenerate).
167+ const ref = [13.468, 19.596, 24.271, 34.801, 34.801, 61.093, 61.093];
168+ const a = 0.3;
169+ const D = (mat.E * H ** 3) / (12 * (1 - mat.nu ** 2));
170+ const errors: number[][] = [];
171+ for (const N of [16, 21]) {
172+ const { K, n } = stiffness(uniformPlate(rectangle(N, N, a, a), mat, H), "w");
173+ const ev = symEig(K, n);
174+ const lam2 = ev.slice(3, 3 + ref.length).map((e) => Math.sqrt(e) * a * a * Math.sqrt((mat.rho * H) / D));
175+ errors.push(lam2.map((v, k) => Math.abs(v - ref[k]) / ref[k]));
176+ }
177+ for (let k = 0; k < ref.length; k++) {
178+ assert.ok(errors[1][k] < 0.03, `mode ${k}: error ${(errors[1][k] * 100).toFixed(2)}% at 20 cells`);
179+ assert.ok(errors[1][k] < 0.7 * errors[0][k], `mode ${k}: error does not shrink from 15 to 20 cells`);
180+ }
181+});
182+
183+test("forces are the negative gradient of the discrete energy, flat and bowed", () => {
184+ const g = rectangle(8, 7);
185+ const { f, w0 } = randomState(g, 7);
186+ assert.ok(gradientMismatch(uniformPlate(g, mat, H), f) < 1e-4, "flat");
187+ const bowed = uniformPlate(g, mat, H, w0);
188+ assert.ok(gradientMismatch(bowed, f) < 1e-4, "bowed");
189+ // The stress-free shape itself carries no energy.
190+ assert.ok(potentialEnergy(bowed, fields(g.n)) < 1e-12, "w0 is stress free");
191+});
192+
193+test("loss terms only remove energy", () => {
194+ const g = rectangle(9, 8);
195+ const pl = { ...uniformPlate(g, mat, H), loss: { sigma0: 1.0, sigma1: 1e-3, sigmaIn: 5.0 } };
196+ const rnd = lcg(3);
197+ const v = fields(g.n), acc = fields(g.n);
198+ for (let k = 0; k < g.n; k++) { v.u[k] = rnd(); v.v[k] = rnd(); v.w[k] = rnd(); }
199+ addDamping(pl, v, acc);
200+ let power = 0;
201+ for (let k = 0; k < g.n; k++) power += massOf(pl, k) * (v.u[k] * acc.u[k] + v.v[k] * acc.v[k] + v.w[k] * acc.w[k]);
202+ assert.ok(power < 0, `power ${power} should be negative`);
203+});
204+
205+test("maximum frequency estimate is close to the in-plane CFL estimate", () => {
206+ const g: Grid = { nx: 50, ny: 50, hx: 0.004, hy: 0.004, n: 2500 };
207+ const wmax = maxAngularFrequency(g, mat, H);
208+ const cp = Math.sqrt(membraneStiffness(mat, H) / (mat.rho * H));
209+ const cfl = (2 * cp * Math.SQRT2) / 0.004; // 2 c sqrt(1/hx^2 + 1/hy^2)
210+ assert.ok(wmax > 0.5 * cfl && wmax < 1.5 * cfl, `wmax ${wmax} vs cfl ${cfl}`);
211+ // The in-plane wave speed does not depend on the thickness, so neither does the estimate.
212+ const wmax2 = maxAngularFrequency(g, mat, 2 * H);
213+ assert.ok(Math.abs(wmax2 - wmax) < 0.05 * wmax, `wmax ${wmax} vs ${wmax2} at twice the thickness`);
214+});
215+
216+// ---------- Variable thickness ----------
217+
218+test("tapered plate: both stiffness blocks are symmetric with exactly three rigid modes", () => {
219+ const pl = taperedPlate(rectangle(9, 7));
220+ const b = stiffness(pl, "w");
221+ checkSymmetricWithThreeRigidModes(b.K, b.n);
222+ const m = stiffness(taperedPlate(rectangle(7, 6)), "uv");
223+ checkSymmetricWithThreeRigidModes(m.K, m.n);
224+});
225+
226+test("tapered plate: forces are the energy gradient, flat and bowed", () => {
227+ const g = rectangle(8, 7);
228+ const { f, w0 } = randomState(g, 11);
229+ assert.ok(gradientMismatch(taperedPlate(g), f) < 1e-4, "flat");
230+ assert.ok(gradientMismatch(taperedPlate(g, w0), f) < 1e-4, "bowed");
231+});
232+
233+test("a uniform plate of twice the thickness has twice the bending frequencies", () => {
234+ // Bending frequencies scale with h, membrane frequencies do not.
235+ const thin = stiffness(uniformPlate(rectangle(9, 8), mat, H), "w");
236+ const thick = stiffness(uniformPlate(rectangle(9, 8), mat, 2 * H), "w");
237+ const e1 = symEig(thin.K, thin.n), e2 = symEig(thick.K, thick.n);
238+ for (let k = 3; k < 10; k++) assert.ok(Math.abs(e2[k] / e1[k] - 4) < 1e-6, `mode ${k}: ratio ${e2[k] / e1[k]}`);
239+ const thinM = stiffness(uniformPlate(rectangle(6, 5), mat, H), "uv");
240+ const thickM = stiffness(uniformPlate(rectangle(6, 5), mat, 2 * H), "uv");
241+ const m1 = symEig(thinM.K, thinM.n), m2 = symEig(thickM.K, thickM.n);
242+ for (let k = 3; k < 10; k++) assert.ok(Math.abs(m2[k] / m1[k] - 1) < 1e-6, `membrane mode ${k}: ratio ${m2[k] / m1[k]}`);
243+});
244+
245+// ---------- Mount ----------
246+
247+test("mounted plate: the stiffness is symmetric and the rigid modes feel the mount", () => {
248+ const g = rectangle(9, 7);
249+ const pl = taperedPlate(g, undefined, { x: 0, y: 0, radius: 0.04, stiffness: 1e4, damping: 0 });
250+ assert.ok(mountPatch(pl)!.nodes.length > 1, "the mount covers several nodes");
251+ for (const block of ["w", "uv"] as const) {
252+ const { K, n } = stiffness(pl, block);
253+ checkSymmetric(K, n);
254+ }
255+ // A uniform translation of the plate in any direction feels the whole mount stiffness.
256+ const x = fields(g.n), y = fields(g.n);
257+ for (const comp of ["u", "v", "w"] as const) {
258+ x.u.fill(0); x.v.fill(0); x.w.fill(0);
259+ x[comp].fill(1e-6);
260+ accelerations(pl, x, y);
261+ let F = 0;
262+ for (let k = 0; k < g.n; k++) F += massOf(pl, k) * y[comp][k];
263+ assert.ok(Math.abs(-F / 1e-6 - 1e4) < 1e-6 * 1e4, `${comp}: mount force ${-F / 1e-6} vs 1e4`);
264+ }
265+ // A tilt about the x axis feels a restoring moment from the finite mount patch.
266+ x.u.fill(0); x.v.fill(0);
267+ for (let k = 0; k < g.n; k++) x.w[k] = 1e-6 * nodeY(g, Math.floor(k / g.nx));
268+ accelerations(pl, x, y);
269+ let M = 0;
270+ for (let k = 0; k < g.n; k++) M += massOf(pl, k) * y.w[k] * nodeY(g, Math.floor(k / g.nx));
271+ assert.ok(M < 0, `tilt moment ${M} should restore`);
272+});
273+
274+test("mounted plate: forces are the energy gradient and the dashpot removes energy", () => {
275+ const g = rectangle(8, 7);
276+ const { f, w0 } = randomState(g, 13);
277+ const pl = { ...taperedPlate(g, w0, { x: 0.02, y: -0.01, radius: 0.03, stiffness: 5e3, damping: 2 }), loss: { sigma0: 0, sigma1: 0, sigmaIn: 0 } };
278+ assert.ok(gradientMismatch(pl, f) < 1e-4, "gradient");
279+ const rnd = lcg(5);
280+ const v = fields(g.n), acc = fields(g.n);
281+ for (let k = 0; k < g.n; k++) { v.u[k] = rnd(); v.v[k] = rnd(); v.w[k] = rnd(); }
282+ addDamping(pl, v, acc);
283+ let power = 0, touched = 0;
284+ for (let k = 0; k < g.n; k++) {
285+ power += massOf(pl, k) * (v.u[k] * acc.u[k] + v.v[k] * acc.v[k] + v.w[k] * acc.w[k]);
286+ if (acc.w[k] !== 0) touched++;
287+ }
288+ assert.ok(power < 0, `power ${power} should be negative`);
289+ assert.ok(touched > 0 && touched < countInside(g), "the dashpot acts on the mount nodes only");
290+});
291+
292+// ---------- Masked grids ----------
293+
294+test("elliptical plate: both stiffness blocks are symmetric with exactly three rigid modes", () => {
295+ const g = withEllipseMask(rectangle(13, 10));
296+ const pl = uniformPlate(g, mat, H);
297+ assert.ok(countInside(g) < g.n && countInside(g) > g.n / 2, "mask removes some nodes");
298+ for (let k = 0; k < g.n; k++) {
299+ // Every node of the plate carries mass (no whiskers left after the mask is trimmed).
300+ if (g.mask![k]) assert.ok(massOf(pl, k) > 0, `node ${k} has no mass`);
301+ }
302+ const b = stiffness(pl, "w");
303+ checkSymmetricWithThreeRigidModes(b.K, b.n);
304+ const m = stiffness(pl, "uv");
305+ checkSymmetricWithThreeRigidModes(m.K, m.n);
306+});
307+
308+test("disc with a center hole: symmetric stiffness with three rigid modes, forces are the energy gradient", () => {
309+ const nx = 15, a = 0.1;
310+ const g0: Grid = { nx, ny: nx, hx: (2 * a) / (nx - 1), hy: (2 * a) / (nx - 1), n: nx * nx };
311+ const g = withMask(g0, (x, y) => Math.hypot(x, y) <= a && Math.hypot(x, y) >= 0.02);
312+ assert.ok(!insideNode(g, 7, 7), "the center node lies in the hole");
313+ const pl = taperedPlate(g);
314+ const b = stiffness(pl, "w");
315+ checkSymmetricWithThreeRigidModes(b.K, b.n, 1e-5);
316+ const { f } = randomState(g, 9);
317+ assert.ok(gradientMismatch(pl, f) < 1e-4, "gradient");
318+});
319+
320+test("free circular plate frequencies approach the reference values", () => {
321+ const nx = 25, a = 0.15; // radius; 24 cells across the diameter
322+ const g = withEllipseMask({ nx, ny: nx, hx: (2 * a) / (nx - 1), hy: (2 * a) / (nx - 1), n: nx * nx });
323+ const { K, n } = stiffness(uniformPlate(g, mat, H), "w");
324+ const ev = symEig(K, n);
325+ const D = (mat.E * H ** 3) / (12 * (1 - mat.nu ** 2));
326+ const lam2 = ev.slice(3, 12).map((e) => Math.sqrt(e) * a * a * Math.sqrt((mat.rho * H) / D));
327+ // Completely free circular plate, nu = 0.3 (Leissa, Table 2.4): modes (2,0) x2, (0,1),
328+ // (3,0) x2, (1,1) x2, (4,0) x2 by nodal diameters and circles. The staircase edge is
329+ // first-order accurate: about 2 percent here, under 1 percent at 60 cells across.
330+ const ref = [5.358, 5.358, 9.003, 12.439, 12.439, 20.475, 20.475, 21.835, 21.835];
331+ for (let k = 0; k < ref.length; k++) {
332+ const relErr = Math.abs(lam2[k] - ref[k]) / ref[k];
333+ assert.ok(relErr < 0.04, `mode ${k}: got ${lam2[k].toFixed(3)}, expected ${ref[k]} (err ${(relErr * 100).toFixed(2)}%)`);
334+ }
335+});
336+
337+test("node coordinates are centered on the bounding box", () => {
338+ const g = rectangle(9, 7, 0.4, 0.3);
339+ assert.ok(Math.abs(nodeX(g, 0) + 0.2) < 1e-12 && Math.abs(nodeX(g, 8) - 0.2) < 1e-12);
340+ assert.ok(Math.abs(nodeY(g, 0) + 0.15) < 1e-12 && Math.abs(nodeY(g, 6) - 0.15) < 1e-12);
341+});
tests/design.test.tsadded+132−0View file
@@ -0,0 +1,132 @@
1+// Tests of the design script: compilation, the checks, the helpers, the
2+// evaluation on the grid, and the presets.
3+
4+import { test } from "node:test";
5+import assert from "node:assert/strict";
6+import { DesignError, HELPERS, compileDesign, evaluatePlate, noise2d, profileStats, removePlane } from "../src/design.ts";
7+import { PRESETS } from "../src/presets.ts";
8+import { countInside, insideNode, mountPatch, nodeWeight, nodeX, nodeY, thicknessRange } from "../src/params.ts";
9+
10+const minimal = `return { material: { E: 170e9, nu: 0.29, rho: 7900 }, bounds: { width: 0.3, height: 0.2 }, thickness: 1e-3 };`;
11+
12+test("a minimal script compiles with defaults filled in", () => {
13+ const d = compileDesign(minimal);
14+ assert.equal(d.name, "Untitled design");
15+ assert.equal(d.inside, null);
16+ assert.equal(d.mount, null);
17+ assert.equal(d.thickness(0, 0, 0, 0), 1e-3);
18+ assert.equal(d.profile(0.1, 0.05, 0, 0), 0);
19+ assert.deepEqual(d.loss, { sigma0: 1.15, sigma1: 5e-4, sigmaIn: 20 });
20+});
21+
22+test("the helpers are in scope and lengths are SI", () => {
23+ const d = compileDesign(`return { name: "x", material: { E: 1e11, nu: 0.3, rho: 8000 }, bounds: { width: 10 * inch, height: 100 * mm }, thickness: (x, y, r) => lerp(2 * mm, 1 * mm, clamp(r / 0.1, 0, 1)), profile: (x, y, r) => dome(r, 0.05, 0.01) + bump(r, 0.02, 0.001) + bow(x, 0.254, 0.002) };`);
24+ assert.ok(Math.abs(d.Lx - 0.254) < 1e-12 && Math.abs(d.Ly - 0.1) < 1e-12);
25+ assert.ok(Math.abs(d.thickness(0, 0, 0, 0) - 2e-3) < 1e-12);
26+ assert.ok(Math.abs(d.thickness(0, 0, 0.1, 0) - 1e-3) < 1e-12);
27+ assert.ok(Math.abs(d.profile(0, 0, 0, 0) - (0.01 + 0.001 + 0.002)) < 1e-12);
28+ assert.ok(Math.abs(d.profile(0, 0, 0.06, 0) - (0.002 + 0.001 * Math.exp(-9))) < 1e-15);
29+ assert.equal(HELPERS.smoothstep(0, 1, -1), 0);
30+ assert.equal(HELPERS.smoothstep(0, 1, 2), 1);
31+ assert.equal(HELPERS.smoothstep(0, 1, 0.5), 0.5);
32+});
33+
34+test("scripts that do not describe a plate are rejected with a message", () => {
35+ const bad: [string, RegExp][] = [
36+ [`return 5;`, /must return an object/],
37+ [`this is not javascript`, /script error/],
38+ [`throw new Error("boom");`, /boom/],
39+ [`return { bounds: { width: 0.3, height: 0.2 }, thickness: 1e-3 };`, /material/],
40+ [`return { material: { E: 170e9, nu: 0.6, rho: 7900 }, bounds: { width: 0.3, height: 0.2 }, thickness: 1e-3 };`, /nu/],
41+ [`return { material: { E: 170e9, nu: 0.3, rho: 7900 }, bounds: { width: 0.3 }, thickness: 1e-3 };`, /bounds.height/],
42+ [`return { material: { E: 170e9, nu: 0.3, rho: 7900 }, bounds: { width: 0.3, height: 0.2 } };`, /thickness/],
43+ [`return { material: { E: 170e9, nu: 0.3, rho: 7900 }, bounds: { width: 0.3, height: 0.2 }, thickness: "thin" };`, /thickness/],
44+ [`return { material: { E: 170e9, nu: 0.3, rho: 7900 }, bounds: { width: 0.3, height: 0.2 }, thickness: 1e-3, inside: 5 };`, /inside/],
45+ [`return { material: { E: 170e9, nu: 0.3, rho: 7900 }, bounds: { width: 0.3, height: 0.2 }, thickness: 1e-3, mount: { stiffness: 1e4 } };`, /mount.radius/],
46+ ];
47+ for (const [src, re] of bad) assert.throws(() => compileDesign(src), (e: unknown) => e instanceof DesignError && re.test(e.message), src);
48+});
49+
50+test("evaluation rejects a thickness that is not positive and a profile that is not finite", () => {
51+ const zero = compileDesign(`return { material: { E: 170e9, nu: 0.3, rho: 7900 }, bounds: { width: 0.3, height: 0.2 }, thickness: (x) => x };`);
52+ assert.throws(() => evaluatePlate(zero, 0.02), /thickness must be positive/);
53+ const nan = compileDesign(`return { material: { E: 170e9, nu: 0.3, rho: 7900 }, bounds: { width: 0.3, height: 0.2 }, thickness: 1e-3, profile: (x, y) => Math.sqrt(x) };`);
54+ assert.throws(() => evaluatePlate(nan, 0.02), /profile returned NaN/);
55+ const thrower = compileDesign(`return { material: { E: 170e9, nu: 0.3, rho: 7900 }, bounds: { width: 0.3, height: 0.2 }, thickness: 1e-3, inside: () => { throw new Error("no"); } };`);
56+ assert.throws(() => evaluatePlate(thrower, 0.02), /inside threw/);
57+ const empty = compileDesign(`return { material: { E: 170e9, nu: 0.3, rho: 7900 }, bounds: { width: 0.3, height: 0.2 }, thickness: 1e-3, inside: () => false };`);
58+ assert.throws(() => evaluatePlate(empty, 0.02), /no grid cells/);
59+});
60+
61+test("a circle with a hole excludes the center and the corners", () => {
62+ const d = compileDesign(`return { material: { E: 170e9, nu: 0.3, rho: 7900 }, bounds: { width: 0.2, height: 0.2 }, thickness: 1e-3, inside: circle(0.2, 0.03) };`);
63+ const pl = evaluatePlate(d, 0.01);
64+ const g = pl.grid;
65+ assert.equal(g.nx, 21);
66+ const c = (g.nx - 1) / 2;
67+ assert.ok(!insideNode(g, c, c), "center node lies in the hole");
68+ assert.ok(!insideNode(g, 0, 0), "corner node lies outside the disc");
69+ assert.ok(insideNode(g, c, 3), "a node on the radius belongs to the disc");
70+ let weight = 0;
71+ for (let j = 0; j < g.ny; j++) for (let i = 0; i < g.nx; i++) weight += nodeWeight(g, i, j);
72+ const area = weight * g.hx * g.hy;
73+ const exact = Math.PI * (0.1 ** 2 - 0.015 ** 2);
74+ assert.ok(Math.abs(area / exact - 1) < 0.05, `the ${countInside(g)} nodes cover ${area} vs ${exact}`);
75+ assert.equal(thicknessRange(pl).max, 1e-3);
76+});
77+
78+test("the profile is made plane-free and the mount patch is found", () => {
79+ const d = compileDesign(`return { material: { E: 170e9, nu: 0.3, rho: 7900 }, bounds: { width: 0.3, height: 0.2 }, thickness: 1e-3, profile: (x, y) => 0.01 + 0.05 * x - 0.02 * y + 0.001 * x * x, mount: { radius: 0.02, stiffness: 1e4, damping: 1 } };`);
80+ const pl = evaluatePlate(d, 0.01);
81+ const g = pl.grid;
82+ let s = 0, sx = 0, sy = 0;
83+ for (let j = 0; j < g.ny; j++) {
84+ for (let i = 0; i < g.nx; i++) {
85+ const wgt = nodeWeight(g, i, j), v = pl.w0[j * g.nx + i];
86+ s += wgt * v; sx += wgt * nodeX(g, i) * v; sy += wgt * nodeY(g, j) * v;
87+ }
88+ }
89+ assert.ok(Math.abs(s) < 1e-12 && Math.abs(sx) < 1e-12 && Math.abs(sy) < 1e-12, "mean plane removed");
90+ const stats = profileStats(pl);
91+ assert.ok(stats.peakToPeak > 0 && stats.peakToPeak < 1e-4, `peak to peak ${stats.peakToPeak}`);
92+ const mp = mountPatch(pl)!;
93+ assert.ok(mp.nodes.length >= 9 && mp.nodes.length <= 21, `mount patch has ${mp.nodes.length} nodes`);
94+ assert.ok(mp.weight > 0);
95+});
96+
97+test("removePlane leaves a plane-free field unchanged", () => {
98+ const g = { nx: 7, ny: 5, hx: 0.05, hy: 0.05, n: 35 };
99+ const f = new Float64Array(g.n);
100+ for (let j = 0; j < g.ny; j++) for (let i = 0; i < g.nx; i++) f[j * g.nx + i] = nodeX(g, i) ** 2 - 0.0125;
101+ const before = Float64Array.from(f);
102+ removePlane(f, g);
103+ // The parabola x^2 has a nonzero mean but no tilt; only the mean should move.
104+ let shift = f[0] - before[0];
105+ for (let k = 0; k < g.n; k++) assert.ok(Math.abs(f[k] - before[k] - shift) < 1e-12);
106+});
107+
108+test("noise2d is deterministic with unit RMS", () => {
109+ const a = noise2d(7, 0.05), b = noise2d(7, 0.05), c = noise2d(8, 0.05);
110+ assert.equal(a(0.01, 0.02), b(0.01, 0.02));
111+ assert.notEqual(a(0.01, 0.02), c(0.01, 0.02));
112+ let s2 = 0, n = 0;
113+ for (let x = -0.5; x < 0.5; x += 0.01) for (let y = -0.5; y < 0.5; y += 0.01) { s2 += a(x, y) ** 2; n++; }
114+ const rms = Math.sqrt(s2 / n);
115+ assert.ok(Math.abs(rms - 1) < 0.2, `rms ${rms}`);
116+});
117+
118+test("every preset compiles and evaluates on the default grid", () => {
119+ for (const p of PRESETS) {
120+ const d = compileDesign(p.source);
121+ const pl = evaluatePlate(d, 0.008);
122+ const hr = thicknessRange(pl);
123+ assert.ok(hr.min > 0 && hr.max < 5e-3, `${p.id}: thickness ${hr.min}..${hr.max}`);
124+ assert.ok(countInside(pl.grid) > 100, `${p.id}: ${countInside(pl.grid)} nodes`);
125+ const stats = profileStats(pl);
126+ assert.ok(stats.peakToPeak < 0.05, `${p.id}: peak to peak ${stats.peakToPeak}`);
127+ if (pl.mount) {
128+ const mp = mountPatch(pl)!;
129+ assert.ok(mp.nodes.length > 1, `${p.id}: mount patch has ${mp.nodes.length} nodes`);
130+ }
131+ }
132+});
tests/gpu.test.tsadded+137−0View file
@@ -0,0 +1,137 @@
1+// GPU-versus-CPU test: serves the app with Vite's dev server, opens it in a
2+// headless Chromium with WebGPU through Playwright, and reads the result of the
3+// in-page self-test (src/selftest.ts), which compares one step of the WGSL
4+// kernel with the float64 reference on several presets. Then runs short
5+// strikes on a free plate and on a mounted cymbal to completion.
6+
7+import { test } from "node:test";
8+import assert from "node:assert/strict";
9+import { fileURLToPath } from "node:url";
10+import { dirname, join } from "node:path";
11+import { createServer } from "vite";
12+import { chromium, type Page } from "playwright";
13+
14+const root = join(dirname(fileURLToPath(import.meta.url)), "..");
15+
16+interface SelfTestCase {
17+ name: string;
18+ accError: { u: number; v: number; w: number };
19+ positionError: number;
20+ nodes: number;
21+ dt: number;
22+}
23+interface SelfTestResult { done: boolean; passed?: boolean; cases?: SelfTestCase[]; error?: string }
24+
25+interface Status {
26+ phase: string;
27+ t: number;
28+ totalSteps: number;
29+ wmax: number;
30+ peakPressure: number;
31+ samples: number;
32+ frames: number;
33+ stepsPerSec: number;
34+ dt: number;
35+ nx: number;
36+ ny: number;
37+ stick: { maxForce: number };
38+ design: { name: string | null; nodes: number; mounted: boolean; error: string | null };
39+}
40+
41+const ARGS = ["--enable-unsafe-webgpu", "--enable-features=Vulkan", "--use-angle=vulkan", "--ignore-gpu-blocklist", "--no-sandbox"];
42+
43+/** Serves the app and opens `query` in a headless Chromium; `body` gets the page and the list of page errors. */
44+async function withPage(query: string, body: (page: Page, errors: string[]) => Promise<void>): Promise<void> {
45+ const server = await createServer({ root, logLevel: "silent", server: { port: 0, strictPort: false } });
46+ await server.listen();
47+ const url = server.resolvedUrls?.local[0];
48+ assert.ok(url, "dev server URL");
49+ const browser = await chromium.launch({ headless: true, args: ARGS });
50+ try {
51+ const page = await browser.newPage();
52+ const errors: string[] = [];
53+ page.on("pageerror", (e) => errors.push(e.message));
54+ page.on("console", (msg) => { if (msg.type() === "error" || msg.type() === "warning") errors.push(`console.${msg.type()}: ${msg.text()}`); });
55+ await page.goto(`${url}?${query}`);
56+ await body(page, errors);
57+ } finally {
58+ await browser.close();
59+ await server.close();
60+ }
61+}
62+
63+test("one GPU step matches the float64 reference on flat, bowed, tapered, mounted, and elliptical plates", { timeout: 180_000 }, async () => {
64+ await withPage("selftest=1", async (page, errors) => {
65+ let result: SelfTestResult = { done: false };
66+ for (let i = 0; i < 240 && !result.done; i++) {
67+ await page.waitForTimeout(500);
68+ result = await page.evaluate<SelfTestResult>("window.steelsim ? window.steelsim.selftest() : { done: false }");
69+ }
70+ assert.deepEqual(errors, [], "page errors");
71+ assert.ok(result.done, "self-test did not finish");
72+ assert.equal(result.error, undefined, `self-test error: ${result.error}`);
73+ for (const c of result.cases ?? []) {
74+ console.log(` ${c.name}: ${c.nodes} nodes, dt ${(c.dt * 1e6).toFixed(2)} us, acc errors ${c.accError.u.toExponential(1)} / ${c.accError.v.toExponential(1)} / ${c.accError.w.toExponential(1)}, position ${c.positionError.toExponential(1)}`);
75+ }
76+ assert.ok(result.passed, `self-test failed: ${JSON.stringify(result.cases)}`);
77+ });
78+});
79+
80+/** Runs a short simulation to completion and returns its final status. */
81+async function runToCompletion(query: string): Promise<Status> {
82+ let st: Status | null = null;
83+ await withPage(`autostart=1&${query}`, async (page, errors) => {
84+ for (let i = 0; i < 600; i++) {
85+ await page.waitForTimeout(250);
86+ st = await page.evaluate<Status | null>("window.steelsim && window.steelsim.status() ? { ...window.steelsim.status(), design: window.steelsim.design() } : null");
87+ const noGpu = await page.evaluate<string | null>("window.steelsim.noGpu()");
88+ assert.equal(noGpu, null, `no WebGPU: ${noGpu}`);
89+ if (st && st.phase === "done") break;
90+ }
91+ assert.deepEqual(errors, [], "page errors");
92+ });
93+ assert.ok(st, "no status");
94+ return st!;
95+}
96+
97+test("a short strike on the free bowed rectangle completes with finite output", { timeout: 180_000 }, async () => {
98+ const st = await runToCompletion("preset=bowed-rectangle&spacing=0.012&duration=0.2&rigidProjection=1");
99+ console.log(` ${st.design.name}: ${st.nx}x${st.ny}, dt ${(st.dt * 1e6).toFixed(2)} us, ${(st.stepsPerSec / 1e3).toFixed(0)} k steps/s, peak ${st.peakPressure.toFixed(3)} Pa, max |w| ${(st.wmax * 1e3).toFixed(2)} mm, max contact force ${st.stick.maxForce.toFixed(1)} N`);
100+ assert.equal(st.design.name, "Bowed steel rectangle", "the preset was read from the URL");
101+ assert.equal(st.design.mounted, false);
102+ assert.equal(st.phase, "done");
103+ assert.ok(st.samples >= 0.2 * 48000 - 1, "recorded the requested duration");
104+ assert.ok(isFinite(st.wmax) && st.wmax < 0.05, "displacement stays bounded");
105+ assert.ok(st.peakPressure > 1e-3 && isFinite(st.peakPressure), "the plate radiates");
106+ assert.ok(st.stick.maxForce > 1, "the stick hit the plate");
107+});
108+
109+test("a short strike on the mounted, tapered 16 inch crash completes with finite output", { timeout: 180_000 }, async () => {
110+ const st = await runToCompletion("preset=crash-16&spacing=0.012&duration=0.2");
111+ console.log(` ${st.design.name}: ${st.nx}x${st.ny}, ${st.design.nodes} nodes, ${(st.stepsPerSec / 1e3).toFixed(0)} k steps/s, peak ${st.peakPressure.toFixed(3)} Pa, max |w| ${(st.wmax * 1e3).toFixed(2)} mm, max contact force ${st.stick.maxForce.toFixed(1)} N`);
112+ assert.equal(st.design.name, "16 inch crash");
113+ assert.equal(st.design.mounted, true);
114+ assert.equal(st.phase, "done");
115+ assert.ok(isFinite(st.wmax) && st.wmax < 0.05, "displacement stays bounded");
116+ assert.ok(st.peakPressure > 1e-3, "the cymbal radiates");
117+ assert.ok(st.stick.maxForce > 1, "the stick hit the cymbal");
118+});
119+
120+test("a script with an error is reported and does not start", { timeout: 120_000 }, async () => {
121+ await withPage("", async (page, errors) => {
122+ for (let i = 0; i < 40; i++) {
123+ await page.waitForTimeout(250);
124+ const ready = await page.evaluate<boolean>("!!window.steelsim");
125+ if (ready) break;
126+ }
127+ await page.evaluate("window.steelsim.setScript('return { material: { E: 1e11, nu: 0.3, rho: 8000 }, bounds: { width: 0.3, height: 0.2 }, thickness: -1 };')");
128+ await page.waitForTimeout(200);
129+ const d = await page.evaluate<Status["design"]>("window.steelsim.design()");
130+ assert.equal(d.name, null);
131+ assert.match(d.error ?? "", /thickness must be positive/);
132+ await page.evaluate("window.steelsim.start()");
133+ await page.waitForTimeout(500);
134+ assert.equal(await page.evaluate("window.steelsim.status()"), null, "nothing started");
135+ assert.deepEqual(errors, [], "page errors");
136+ });
137+});
tsconfig.jsonadded+19−0View file
@@ -0,0 +1,19 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "bundler",
6+ "strict": true,
7+ "noUnusedLocals": true,
8+ "noUnusedParameters": true,
9+ "noFallthroughCasesInSwitch": true,
10+ "isolatedModules": true,
11+ "verbatimModuleSyntax": true,
12+ "skipLibCheck": true,
13+ "allowImportingTsExtensions": true,
14+ "noEmit": true,
15+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
16+ "types": ["@webgpu/types", "vite/client", "node"]
17+ },
18+ "include": ["src", "tests"]
19+}
vite.config.tsadded+6−0View file
@@ -0,0 +1,6 @@
1+import { defineConfig } from "vite";
2+
3+export default defineConfig({
4+ base: "./",
5+ build: { target: "es2022" },
6+});