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, stand, losses), and the derived quantities both solvers need. All
4// values are SI. The design itself is produced by the script in design.ts.
6export 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 listenerAngle: number; // [deg] angle from the plate's normal (0 = straight above the plate)
18 listenerAzimuth: number; // [deg] rotation about the normal (0 = toward +x)
19 // Numerics and output
20 spacing: number; // requested grid spacing [m]
21 modeMaxFreq: number; // modes up to this frequency are kept in the linear model [Hz]
22 padLiftoff: boolean; // full simulation: the pads push only while compressed
23 fs: number; // audio sample rate [Hz]
24 fps: number; // movie frame rate, also the batch rate of the full simulation [Hz]
25 duration: number; // recorded duration after the impact [s]
26}
28export const defaultParams: SimParams = {
29 strikeFracX: 0.9,
30 strikeFracY: 0.5,
31 strikeVelocity: 6,
32 stickMass: 0.03,
33 contactStiffness: 5e8,
34 contactExponent: 1.5,
35 contactRadius: 5e-3,
36 listenerDist: 0.5,
37 listenerAngle: 30,
38 listenerAzimuth: 0,
39 spacing: 0.008,
40 modeMaxFreq: 16000,
41 padLiftoff: true,
42 fs: 48000,
43 fps: 120,
44 duration: 4,
45};
47export const SPEED_OF_SOUND = 343; // [m/s]
48export const RHO_AIR = 1.2; // [kg/m^3]
49export const GRAVITY = 9.81; // [m/s^2]
50export const CFL = 0.9; // fraction of the stability limit used for the time step of the full simulation
51export const HIST_MARGIN = 128; // ring buffer margin of the retarded-time listener sum [samples]
52export const STICK_RELEASE = 5e-3; // the stick is withdrawn once the contact has been open this long [s]
53export const PAD_POISSON = 0.3; // Poisson ratio of the felt, which sets its shear stiffness from E
55// ---------- Grid ----------
57export 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}
67/** True when the point (x, y), relative to the center of the bounding box, lies in the plate. */
68export type InsideFn = (x: number, y: number) => boolean;
70/** The grid covering the bounding box Lx by Ly with about the requested spacing, restricted to the outline when one is given. */
71export function makeGrid(Lx: number, Ly: number, spacing: number, inside?: InsideFn): Grid {
72 const nx = Math.max(6, Math.round(Lx / spacing) + 1);
73 const ny = Math.max(6, Math.round(Ly / spacing) + 1);
74 const g: Grid = { nx, ny, hx: Lx / (nx - 1), hy: Ly / (ny - 1), n: nx * ny };
75 return inside ? withMask(g, inside) : g;
76}
78/**
79 * Restricts a grid to an outline. The edge is a staircase of whole cells: a
80 * cell belongs to the plate when its center lies inside the outline, and a
81 * node when it is a corner of such a cell. Deciding by cell centers keeps
82 * the covered area unbiased. A node left with no cell of its own would carry
83 * no mass, and the trailing loop removes any such node. A hole is handled by
84 * the same rule.
85 */
86export function withMask(g: Grid, inside: InsideFn): Grid {
87 return withCellMask(g, (ci, cj) => inside(nodeX(g, ci) + g.hx / 2, nodeY(g, cj) + g.hy / 2));
88}
90/** The same restriction with the membership of each cell (ci, cj) given directly. */
91export function withCellMask(g: Grid, cellInside: (ci: number, cj: number) => boolean): 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 (cellInside(ci, cj)) {
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}
116/** Node coordinates relative to the center of the bounding box. */
117export function nodeX(g: Grid, i: number): number {
118 return (i - (g.nx - 1) / 2) * g.hx;
119}
120export function nodeY(g: Grid, j: number): number {
121 return (j - (g.ny - 1) / 2) * g.hy;
122}
124/** True when node (i, j) exists and belongs to the plate. */
125export function insideNode(g: Grid, i: number, j: number): boolean {
126 if (i < 0 || i >= g.nx || j < 0 || j >= g.ny) return false;
127 return g.mask ? g.mask[j * g.nx + i] !== 0 : true;
128}
130/** True when cell (ci, cj), with nodes (ci..ci+1, cj..cj+1), lies within the plate. */
131export function insideCell(g: Grid, ci: number, cj: number): boolean {
132 return insideNode(g, ci, cj) && insideNode(g, ci + 1, cj) && insideNode(g, ci + 1, cj + 1) && insideNode(g, ci, cj + 1);
133}
135/**
136 * Quadrature and lumped-mass weight of a node: the fraction of its four
137 * surrounding cells that belong to the plate (1 inside, 1/2 on an edge, 1/4
138 * at a corner of a full rectangle). Zero outside.
139 */
140export function nodeWeight(g: Grid, i: number, j: number): number {
141 if (!insideNode(g, i, j)) return 0;
142 let c = 0;
143 if (insideCell(g, i, j)) c++;
144 if (insideCell(g, i - 1, j)) c++;
145 if (insideCell(g, i, j - 1)) c++;
146 if (insideCell(g, i - 1, j - 1)) c++;
147 return c / 4;
148}
150/** Per-node weights as a flat array. */
151export function nodeWeights(g: Grid): Float32Array<ArrayBuffer> {
152 const w = new Float32Array(g.n);
153 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);
154 return w;
155}
157/** Per-cell membership (1 or 0) as a flat array of (nx - 1) * (ny - 1). */
158export function cellMask(g: Grid): Float32Array<ArrayBuffer> {
159 const c = new Float32Array((g.nx - 1) * (g.ny - 1));
160 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;
161 return c;
162}
164/** Number of nodes in the plate. */
165export function countInside(g: Grid): number {
166 if (!g.mask) return g.n;
167 let c = 0;
168 for (let k = 0; k < g.n; k++) c += g.mask[k];
169 return c;
170}
172// ---------- Material, stand, losses, plate ----------
174export interface Material {
175 E: number; // Young's modulus [Pa]
176 nu: number; // Poisson ratio
177 rho: number; // density [kg/m^3]
178}
180/** A felt pad of the stand: an annulus of felt between the plate and the stand (or the wing nut). */
181export interface Pad {
182 innerRadius: number; // [m]
183 outerRadius: number; // [m]
184 thickness: number; // [m]
185 E: number; // Young's modulus of the felt in compression [Pa]
186 lossFactor: number; // loss factor eta of the felt
187}
189/**
190 * The stand: a rod through the center hole at (x, y) with a felt pad below
191 * the plate and, when topPad is set, the same pad above it under the wing
192 * nut, which compresses the top pad by `preload` at rest.
193 */
194export interface Stand {
195 x: number;
196 y: number;
197 pad: Pad;
198 topPad: boolean;
199 preload: number; // compression of the top pad at rest [m]
200}
202/**
203 * Decay times of the plate as a function of frequency, sampled by the design
204 * script on a fixed log-spaced set of frequencies (see loss.ts).
205 */
206export interface Loss {
207 freqs: Float64Array;
208 t60: Float64Array;
209}
211/** The plate as evaluated on the grid. */
212export interface Plate {
213 name: string;
214 Lx: number;
215 Ly: number;
216 grid: Grid;
217 mat: Material;
218 /** Thickness at the nodes [m]; zero outside the plate. */
219 h: Float64Array;
220 /** Stress-free height at the nodes [m], mean plane removed; zero outside the plate. */
221 w0: Float64Array;
222 stand: Stand | null;
223 loss: Loss;
224}
226export function bendingStiffness(m: Material, h: number): number {
227 return (m.E * h * h * h) / (12 * (1 - m.nu * m.nu));
228}
229export function membraneStiffness(m: Material, h: number): number {
230 return (m.E * h) / (1 - m.nu * m.nu);
231}
232export function shearStiffness(m: Material, h: number): number {
233 return (m.E * h) / (2 * (1 + m.nu));
234}
235/** In-plane wave speed, which sets the time step of the full simulation. */
236export function inPlaneSpeed(m: Material): number {
237 return Math.sqrt(m.E / (m.rho * (1 - m.nu * m.nu)));
238}
239/** Bending-wave constant kappa = sqrt(D / rho h), so that omega = kappa k^2. */
240export function bendingConstant(m: Material, h: number): number {
241 return Math.sqrt(bendingStiffness(m, h) / (m.rho * h));
242}
244/** Thickness of a cell: the mean of its four corners. */
245export function cellThickness(pl: Plate, ci: number, cj: number): number {
246 const nx = pl.grid.nx;
247 return 0.25 * (pl.h[cj * nx + ci] + pl.h[cj * nx + ci + 1] + pl.h[(cj + 1) * nx + ci] + pl.h[(cj + 1) * nx + ci + 1]);
248}
250/** Lumped mass of a node: rho h dA times its weight. */
251export function nodeMass(pl: Plate, i: number, j: number): number {
252 const g = pl.grid;
253 return pl.mat.rho * pl.h[j * g.nx + i] * g.hx * g.hy * nodeWeight(g, i, j);
254}
256/** Per-node masses as a flat array. */
257export function nodeMasses(pl: Plate): Float64Array {
258 const g = pl.grid;
259 const m = new Float64Array(g.n);
260 for (let j = 0; j < g.ny; j++) for (let i = 0; i < g.nx; i++) m[j * g.nx + i] = nodeMass(pl, i, j);
261 return m;
262}
264/** Total mass, the mass-weighted center, and the moments of inertia about the in-plane axes through it. */
265export function massProperties(pl: Plate): { mass: number; cx: number; cy: number; Ix: number; Iy: number; area: number } {
266 const g = pl.grid;
267 let mass = 0, sx = 0, sy = 0, area = 0;
268 for (let j = 0; j < g.ny; j++) {
269 for (let i = 0; i < g.nx; i++) {
270 const m = nodeMass(pl, i, j);
271 mass += m; sx += m * nodeX(g, i); sy += m * nodeY(g, j);
272 area += g.hx * g.hy * nodeWeight(g, i, j);
273 }
274 }
275 const cx = mass > 0 ? sx / mass : 0, cy = mass > 0 ? sy / mass : 0;
276 let Ix = 0, Iy = 0;
277 for (let j = 0; j < g.ny; j++) {
278 for (let i = 0; i < g.nx; i++) {
279 const m = nodeMass(pl, i, j);
280 Ix += m * (nodeY(g, j) - cy) ** 2;
281 Iy += m * (nodeX(g, i) - cx) ** 2;
282 }
283 }
284 return { mass, cx, cy, Ix, Iy, area };
285}
287/** Mean thickness over the plate (area-weighted). */
288export function meanThickness(pl: Plate): number {
289 const g = pl.grid;
290 let s = 0, a = 0;
291 for (let j = 0; j < g.ny; j++) {
292 for (let i = 0; i < g.nx; i++) {
293 const w = nodeWeight(g, i, j);
294 s += w * pl.h[j * g.nx + i]; a += w;
295 }
296 }
297 return a > 0 ? s / a : 0;
298}
300/** Smallest and largest thickness over the plate. */
301export function thicknessRange(pl: Plate): { min: number; max: number } {
302 const g = pl.grid;
303 let lo = Infinity, hi = 0;
304 for (let j = 0; j < g.ny; j++) {
305 for (let i = 0; i < g.nx; i++) {
306 if (!insideNode(g, i, j)) continue;
307 const h = pl.h[j * g.nx + i];
308 lo = Math.min(lo, h); hi = Math.max(hi, h);
309 }
310 }
311 return { min: isFinite(lo) ? lo : 0, max: hi };
312}
314// ---------- Stand as seen by the solvers ----------
316/**
317 * The pads as per-node spring rates. `kw[k]` is the transverse stiffness of
318 * the pads at node k (both pads summed), `ku[k]` the in-plane (shear)
319 * stiffness, both in N/m; `kwBottom` is the bottom pad alone, which the full
320 * simulation needs for the lift-off rule. `eta` is the loss factor, and
321 * `preloadTop`, `preloadBottom` are the pad compressions at rest.
322 */
323export interface StandRates {
324 kw: Float64Array;
325 ku: Float64Array;
326 kwBottom: Float64Array;
327 nodes: number[];
328 eta: number;
329 preloadTop: number;
330 preloadBottom: number;
331 /** Total transverse stiffness of the pads [N/m]. */
332 totalKw: number;
333 /** Bounce frequency of the plate on the pads [Hz]. */
334 bounceHz: number;
335}
337/**
338 * Per-node pad stiffness of the stand, or null for a free plate. The pads
339 * act on the plate nodes within the annulus; because a coarse grid covers
340 * the annulus with a handful of nodes, the per-node rates are scaled so
341 * that their sum equals the stiffness of the exact annulus area.
342 */
343export function standRates(pl: Plate): StandRates | null {
344 const st = pl.stand;
345 if (!st) return null;
346 const g = pl.grid;
347 const kw = new Float64Array(g.n), ku = new Float64Array(g.n), kwBottom = new Float64Array(g.n);
348 const nodes: number[] = [];
349 const pad = st.pad;
350 const kArea = pad.E / pad.thickness; // N/m^3
351 const gArea = pad.E / (2 * (1 + PAD_POISSON)) / pad.thickness;
352 const nPads = st.topPad ? 2 : 1;
353 const annulus = Math.PI * (pad.outerRadius ** 2 - pad.innerRadius ** 2);
354 let nodeArea = 0;
355 for (let j = 0; j < g.ny; j++) {
356 for (let i = 0; i < g.nx; i++) {
357 const wgt = nodeWeight(g, i, j);
358 if (wgt === 0) continue;
359 const r = Math.hypot(nodeX(g, i) - st.x, nodeY(g, j) - st.y);
360 if (r < pad.innerRadius || r > pad.outerRadius) continue;
361 nodes.push(j * g.nx + i);
362 nodeArea += g.hx * g.hy * wgt;
363 }
364 }
365 const empty = { kw, ku, kwBottom, nodes, eta: pad.lossFactor, preloadTop: 0, preloadBottom: 0, totalKw: 0, bounceHz: 0 };
366 if (nodes.length === 0 || nodeArea === 0) return empty;
367 const scale = annulus / nodeArea;
368 let totalKw = 0;
369 for (const k of nodes) {
370 const dA = scale * g.hx * g.hy * nodeWeight(g, k % g.nx, Math.floor(k / g.nx));
371 kwBottom[k] = kArea * dA;
372 kw[k] = nPads * kArea * dA;
373 ku[k] = nPads * gArea * dA;
374 totalKw += kw[k];
375 }
376 const { mass } = massProperties(pl);
377 const preloadTop = st.topPad ? st.preload : 0;
378 // The bottom pad carries the top pad's preload plus the weight of the plate.
379 const preloadBottom = preloadTop + (mass * GRAVITY) / (kArea * annulus);
380 return { kw, ku, kwBottom, nodes, eta: pad.lossFactor, preloadTop, preloadBottom, totalKw, bounceHz: Math.sqrt(totalKw / mass) / (2 * Math.PI) };
381}
383// ---------- Patches ----------
385/** A set of plate nodes with the quantities the strike needs. */
386export interface Patch {
387 nodes: number[];
388 nearest: number;
389 mass: number;
390 cx: number;
391 cy: number;
392}
394/** The plate nodes within `radius` of (x, y), always including the nearest one. */
395export function patchAround(pl: Plate, x: number, y: number, radius: number): Patch {
396 const g = pl.grid;
397 const nodes: number[] = [];
398 let nearest = -1, best = Infinity;
399 for (let j = 0; j < g.ny; j++) {
400 for (let i = 0; i < g.nx; i++) {
401 if (nodeMass(pl, i, j) === 0) continue;
402 const d = Math.hypot(nodeX(g, i) - x, nodeY(g, j) - y);
403 if (d < best) { best = d; nearest = j * g.nx + i; }
404 if (d <= radius) nodes.push(j * g.nx + i);
405 }
406 }
407 if (nearest >= 0 && !nodes.includes(nearest)) nodes.push(nearest);
408 let mass = 0, cx = 0, cy = 0;
409 for (const k of nodes) {
410 const m = nodeMass(pl, k % g.nx, Math.floor(k / g.nx));
411 mass += m; cx += m * nodeX(g, k % g.nx); cy += m * nodeY(g, Math.floor(k / g.nx));
412 }
413 return { nodes, nearest, mass, cx: mass > 0 ? cx / mass : 0, cy: mass > 0 ? cy / mass : 0 };
414}
416/** The strike point in plate coordinates from the fractions of the bounding box. */
417export function strikePoint(pl: Plate, p: SimParams): { x: number; y: number } {
418 return { x: (p.strikeFracX - 0.5) * pl.Lx, y: (p.strikeFracY - 0.5) * pl.Ly };
419}
421export function strikePatch(pl: Plate, p: SimParams): Patch {
422 const { x, y } = strikePoint(pl, p);
423 return patchAround(pl, x, y, p.contactRadius);
424}
426// ---------- Listener ----------
428/** Listener position relative to the center of the plate, z along the plate's normal. */
429export function listenerPosition(p: SimParams): { x: number; y: number; z: number } {
430 const th = (p.listenerAngle * Math.PI) / 180, ph = (p.listenerAzimuth * Math.PI) / 180;
431 return { x: p.listenerDist * Math.sin(th) * Math.cos(ph), y: p.listenerDist * Math.sin(th) * Math.sin(ph), z: p.listenerDist * Math.cos(th) };
432}
434/**
435 * Per-node terms of the Rayleigh integral for the listener: the weight
436 * rho_air dA / (2 pi r) and the delay r / c in seconds, for every node (zero
437 * outside the plate). The profile is included in the node positions.
438 */
439export function listenerTerms(pl: Plate, p: SimParams): { weight: Float64Array; delay: Float64Array } {
440 const g = pl.grid;
441 const L = listenerPosition(p);
442 const weight = new Float64Array(g.n), delay = new Float64Array(g.n);
443 for (let j = 0; j < g.ny; j++) {
444 for (let i = 0; i < g.nx; i++) {
445 const wgt = nodeWeight(g, i, j);
446 if (wgt === 0) continue;
447 const k = j * g.nx + i;
448 const r = Math.hypot(L.x - nodeX(g, i), L.y - nodeY(g, j), L.z - pl.w0[k]);
449 weight[k] = (RHO_AIR * g.hx * g.hy * wgt) / (2 * Math.PI * Math.max(r, 1e-3));
450 delay[k] = r / SPEED_OF_SOUND;
451 }
452 }
453 return { weight, delay };
454}
456// ---------- Grid dispersion ----------
458/**
459 * Frequencies at which grid dispersion lowers a bending partial by the given
460 * fraction, for a plate of thickness h: a plane wave along a grid axis has the
461 * discrete frequency ratio (2 - 2 cos kh) / (kh)^2.
462 */
463export function dispersionFrequency(m: Material, h: number, spacing: number, fraction: number): number {
464 let lo = 0, hi = Math.PI;
465 for (let it = 0; it < 60; it++) {
466 const mid = (lo + hi) / 2;
467 const ratio = (2 - 2 * Math.cos(mid)) / (mid * mid);
468 if (1 - ratio < fraction) lo = mid; else hi = mid;
469 }
470 const k = lo / spacing;
471 return (bendingConstant(m, h) * k * k) / (2 * Math.PI);
472}