// Simulation parameters (strike, listener, numerics), the grid with its mask, // the plate as evaluated on the grid (material, thickness field, stress-free // shape, stand, losses), and the derived quantities both solvers need. All // values are SI. The design itself is produced by the script in design.ts. export interface SimParams { // Strike strikeFracX: number; // strike point as a fraction of the bounding-box width from the left edge strikeFracY: number; // strike point as a fraction of the bounding-box height from the bottom edge strikeVelocity: number; // stick tip speed at impact [m/s] stickMass: number; // effective mass of the stick [kg] contactStiffness: number; // Hertz contact stiffness K [N/m^alpha] contactExponent: number; // Hertz exponent alpha (1.5 for a rounded tip) contactRadius: number; // radius of the contact patch [m] // Listener listenerDist: number; // [m] listenerAngle: number; // [deg] angle from the plate's normal (0 = straight above the plate) listenerAzimuth: number; // [deg] rotation about the normal (0 = toward +x) // Numerics and output spacing: number; // requested grid spacing [m] modeMaxFreq: number; // modes up to this frequency are kept in the linear model [Hz] padLiftoff: boolean; // full simulation: the pads push only while compressed fs: number; // audio sample rate [Hz] fps: number; // movie frame rate, also the batch rate of the full simulation [Hz] duration: number; // recorded duration after the impact [s] } export const defaultParams: SimParams = { strikeFracX: 0.9, strikeFracY: 0.5, strikeVelocity: 6, stickMass: 0.03, contactStiffness: 5e8, contactExponent: 1.5, contactRadius: 5e-3, listenerDist: 0.5, listenerAngle: 30, listenerAzimuth: 0, spacing: 0.008, modeMaxFreq: 16000, padLiftoff: true, fs: 48000, fps: 120, duration: 4, }; export const SPEED_OF_SOUND = 343; // [m/s] export const RHO_AIR = 1.2; // [kg/m^3] export const GRAVITY = 9.81; // [m/s^2] export const CFL = 0.9; // fraction of the stability limit used for the time step of the full simulation export const HIST_MARGIN = 128; // ring buffer margin of the retarded-time listener sum [samples] export const STICK_RELEASE = 5e-3; // the stick is withdrawn once the contact has been open this long [s] export const PAD_POISSON = 0.3; // Poisson ratio of the felt, which sets its shear stiffness from E // ---------- Grid ---------- export interface Grid { nx: number; ny: number; hx: number; hy: number; n: number; /** 1 for nodes that belong to the plate, 0 otherwise; absent for a full rectangle. */ mask?: Uint8Array; } /** True when the point (x, y), relative to the center of the bounding box, lies in the plate. */ export type InsideFn = (x: number, y: number) => boolean; /** The grid covering the bounding box Lx by Ly with about the requested spacing, restricted to the outline when one is given. */ export function makeGrid(Lx: number, Ly: number, spacing: number, inside?: InsideFn): Grid { const nx = Math.max(6, Math.round(Lx / spacing) + 1); const ny = Math.max(6, Math.round(Ly / spacing) + 1); const g: Grid = { nx, ny, hx: Lx / (nx - 1), hy: Ly / (ny - 1), n: nx * ny }; return inside ? withMask(g, inside) : g; } /** * Restricts a grid to an outline. The edge is a staircase of whole cells: 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. Deciding by cell centers keeps * the covered area unbiased. A node left with no cell of its own would carry * no mass, and the trailing loop removes any such node. A hole is handled by * the same rule. */ export function withMask(g: Grid, inside: InsideFn): Grid { return withCellMask(g, (ci, cj) => inside(nodeX(g, ci) + g.hx / 2, nodeY(g, cj) + g.hy / 2)); } /** The same restriction with the membership of each cell (ci, cj) given directly. */ export function withCellMask(g: Grid, cellInside: (ci: number, cj: number) => boolean): Grid { const mask = new Uint8Array(g.n); for (let cj = 0; cj < g.ny - 1; cj++) { for (let ci = 0; ci < g.nx - 1; ci++) { if (cellInside(ci, cj)) { mask[cj * g.nx + ci] = 1; mask[cj * g.nx + ci + 1] = 1; mask[(cj + 1) * g.nx + ci] = 1; mask[(cj + 1) * g.nx + ci + 1] = 1; } } } const gm: Grid = { ...g, mask }; for (let changed = true; changed; ) { changed = false; for (let j = 0; j < g.ny; j++) { for (let i = 0; i < g.nx; i++) { if (mask[j * g.nx + i] && nodeWeight(gm, i, j) === 0) { mask[j * g.nx + i] = 0; changed = true; } } } } return gm; } /** Node coordinates relative to the center of the bounding box. */ export function nodeX(g: Grid, i: number): number { return (i - (g.nx - 1) / 2) * g.hx; } export function nodeY(g: Grid, j: number): number { return (j - (g.ny - 1) / 2) * g.hy; } /** True when node (i, j) exists and belongs to the plate. */ export function insideNode(g: Grid, i: number, j: number): boolean { if (i < 0 || i >= g.nx || j < 0 || j >= g.ny) return false; return g.mask ? g.mask[j * g.nx + i] !== 0 : true; } /** True when cell (ci, cj), with nodes (ci..ci+1, cj..cj+1), lies within the plate. */ export function insideCell(g: Grid, ci: number, cj: number): boolean { return insideNode(g, ci, cj) && insideNode(g, ci + 1, cj) && insideNode(g, ci + 1, cj + 1) && insideNode(g, ci, cj + 1); } /** * Quadrature and lumped-mass weight of a node: the fraction of its four * surrounding cells that belong to the plate (1 inside, 1/2 on an edge, 1/4 * at a corner of a full rectangle). Zero outside. */ export function nodeWeight(g: Grid, i: number, j: number): number { if (!insideNode(g, i, j)) return 0; let c = 0; if (insideCell(g, i, j)) c++; if (insideCell(g, i - 1, j)) c++; if (insideCell(g, i, j - 1)) c++; if (insideCell(g, i - 1, j - 1)) c++; return c / 4; } /** Per-node weights as a flat array. */ export function nodeWeights(g: Grid): Float32Array { const w = new Float32Array(g.n); 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); return w; } /** Per-cell membership (1 or 0) as a flat array of (nx - 1) * (ny - 1). */ export function cellMask(g: Grid): Float32Array { const c = new Float32Array((g.nx - 1) * (g.ny - 1)); 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; return c; } /** Number of nodes in the plate. */ export function countInside(g: Grid): number { if (!g.mask) return g.n; let c = 0; for (let k = 0; k < g.n; k++) c += g.mask[k]; return c; } // ---------- Material, stand, losses, plate ---------- export interface Material { E: number; // Young's modulus [Pa] nu: number; // Poisson ratio rho: number; // density [kg/m^3] } /** A felt pad of the stand: an annulus of felt between the plate and the stand (or the wing nut). */ export interface Pad { innerRadius: number; // [m] outerRadius: number; // [m] thickness: number; // [m] E: number; // Young's modulus of the felt in compression [Pa] lossFactor: number; // loss factor eta of the felt } /** * The stand: a rod through the center hole at (x, y) with a felt pad below * the plate and, when topPad is set, the same pad above it under the wing * nut, which compresses the top pad by `preload` at rest. */ export interface Stand { x: number; y: number; pad: Pad; topPad: boolean; preload: number; // compression of the top pad at rest [m] } /** * Decay times of the plate as a function of frequency, sampled by the design * script on a fixed log-spaced set of frequencies (see loss.ts). */ export interface Loss { freqs: Float64Array; t60: Float64Array; } /** The plate as evaluated on the grid. */ export interface Plate { name: string; Lx: number; Ly: number; grid: Grid; mat: Material; /** Thickness at the nodes [m]; zero outside the plate. */ h: Float64Array; /** Stress-free height at the nodes [m], mean plane removed; zero outside the plate. */ w0: Float64Array; stand: Stand | null; loss: Loss; } export function bendingStiffness(m: Material, h: number): number { return (m.E * h * h * h) / (12 * (1 - m.nu * m.nu)); } export function membraneStiffness(m: Material, h: number): number { return (m.E * h) / (1 - m.nu * m.nu); } export function shearStiffness(m: Material, h: number): number { return (m.E * h) / (2 * (1 + m.nu)); } /** In-plane wave speed, which sets the time step of the full simulation. */ export function inPlaneSpeed(m: Material): number { return Math.sqrt(m.E / (m.rho * (1 - m.nu * m.nu))); } /** Bending-wave constant kappa = sqrt(D / rho h), so that omega = kappa k^2. */ export function bendingConstant(m: Material, h: number): number { return Math.sqrt(bendingStiffness(m, h) / (m.rho * h)); } /** Thickness of a cell: the mean of its four corners. */ export function cellThickness(pl: Plate, ci: number, cj: number): number { const nx = pl.grid.nx; 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]); } /** Lumped mass of a node: rho h dA times its weight. */ export function nodeMass(pl: Plate, i: number, j: number): number { const g = pl.grid; return pl.mat.rho * pl.h[j * g.nx + i] * g.hx * g.hy * nodeWeight(g, i, j); } /** Per-node masses as a flat array. */ export function nodeMasses(pl: Plate): Float64Array { const g = pl.grid; const m = new Float64Array(g.n); 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); return m; } /** Total mass, the mass-weighted center, and the moments of inertia about the in-plane axes through it. */ export function massProperties(pl: Plate): { mass: number; cx: number; cy: number; Ix: number; Iy: number; area: number } { const g = pl.grid; let mass = 0, sx = 0, sy = 0, area = 0; for (let j = 0; j < g.ny; j++) { for (let i = 0; i < g.nx; i++) { const m = nodeMass(pl, i, j); mass += m; sx += m * nodeX(g, i); sy += m * nodeY(g, j); area += g.hx * g.hy * nodeWeight(g, i, j); } } const cx = mass > 0 ? sx / mass : 0, cy = mass > 0 ? sy / mass : 0; let Ix = 0, Iy = 0; for (let j = 0; j < g.ny; j++) { for (let i = 0; i < g.nx; i++) { const m = nodeMass(pl, i, j); Ix += m * (nodeY(g, j) - cy) ** 2; Iy += m * (nodeX(g, i) - cx) ** 2; } } return { mass, cx, cy, Ix, Iy, area }; } /** Mean thickness over the plate (area-weighted). */ export function meanThickness(pl: Plate): number { const g = pl.grid; let s = 0, a = 0; for (let j = 0; j < g.ny; j++) { for (let i = 0; i < g.nx; i++) { const w = nodeWeight(g, i, j); s += w * pl.h[j * g.nx + i]; a += w; } } return a > 0 ? s / a : 0; } /** Smallest and largest thickness over the plate. */ export function thicknessRange(pl: Plate): { min: number; max: number } { const g = pl.grid; let lo = Infinity, hi = 0; for (let j = 0; j < g.ny; j++) { for (let i = 0; i < g.nx; i++) { if (!insideNode(g, i, j)) continue; const h = pl.h[j * g.nx + i]; lo = Math.min(lo, h); hi = Math.max(hi, h); } } return { min: isFinite(lo) ? lo : 0, max: hi }; } // ---------- Stand as seen by the solvers ---------- /** * The pads as per-node spring rates. `kw[k]` is the transverse stiffness of * the pads at node k (both pads summed), `ku[k]` the in-plane (shear) * stiffness, both in N/m; `kwBottom` is the bottom pad alone, which the full * simulation needs for the lift-off rule. `eta` is the loss factor, and * `preloadTop`, `preloadBottom` are the pad compressions at rest. */ export interface StandRates { kw: Float64Array; ku: Float64Array; kwBottom: Float64Array; nodes: number[]; eta: number; preloadTop: number; preloadBottom: number; /** Total transverse stiffness of the pads [N/m]. */ totalKw: number; /** Bounce frequency of the plate on the pads [Hz]. */ bounceHz: number; } /** * Per-node pad stiffness of the stand, or null for a free plate. The pads * act on the plate nodes within the annulus; because a coarse grid covers * the annulus with a handful of nodes, the per-node rates are scaled so * that their sum equals the stiffness of the exact annulus area. */ export function standRates(pl: Plate): StandRates | null { const st = pl.stand; if (!st) return null; const g = pl.grid; const kw = new Float64Array(g.n), ku = new Float64Array(g.n), kwBottom = new Float64Array(g.n); const nodes: number[] = []; const pad = st.pad; const kArea = pad.E / pad.thickness; // N/m^3 const gArea = pad.E / (2 * (1 + PAD_POISSON)) / pad.thickness; const nPads = st.topPad ? 2 : 1; const annulus = Math.PI * (pad.outerRadius ** 2 - pad.innerRadius ** 2); let nodeArea = 0; for (let j = 0; j < g.ny; j++) { for (let i = 0; i < g.nx; i++) { const wgt = nodeWeight(g, i, j); if (wgt === 0) continue; const r = Math.hypot(nodeX(g, i) - st.x, nodeY(g, j) - st.y); if (r < pad.innerRadius || r > pad.outerRadius) continue; nodes.push(j * g.nx + i); nodeArea += g.hx * g.hy * wgt; } } const empty = { kw, ku, kwBottom, nodes, eta: pad.lossFactor, preloadTop: 0, preloadBottom: 0, totalKw: 0, bounceHz: 0 }; if (nodes.length === 0 || nodeArea === 0) return empty; const scale = annulus / nodeArea; let totalKw = 0; for (const k of nodes) { const dA = scale * g.hx * g.hy * nodeWeight(g, k % g.nx, Math.floor(k / g.nx)); kwBottom[k] = kArea * dA; kw[k] = nPads * kArea * dA; ku[k] = nPads * gArea * dA; totalKw += kw[k]; } const { mass } = massProperties(pl); const preloadTop = st.topPad ? st.preload : 0; // The bottom pad carries the top pad's preload plus the weight of the plate. const preloadBottom = preloadTop + (mass * GRAVITY) / (kArea * annulus); return { kw, ku, kwBottom, nodes, eta: pad.lossFactor, preloadTop, preloadBottom, totalKw, bounceHz: Math.sqrt(totalKw / mass) / (2 * Math.PI) }; } // ---------- Patches ---------- /** A set of plate nodes with the quantities the strike needs. */ export interface Patch { nodes: number[]; nearest: number; mass: number; cx: number; cy: number; } /** The plate nodes within `radius` of (x, y), always including the nearest one. */ export function patchAround(pl: Plate, x: number, y: number, radius: number): Patch { const g = pl.grid; const nodes: number[] = []; let nearest = -1, best = Infinity; for (let j = 0; j < g.ny; j++) { for (let i = 0; i < g.nx; i++) { if (nodeMass(pl, i, j) === 0) continue; const d = Math.hypot(nodeX(g, i) - x, nodeY(g, j) - y); if (d < best) { best = d; nearest = j * g.nx + i; } if (d <= radius) nodes.push(j * g.nx + i); } } if (nearest >= 0 && !nodes.includes(nearest)) nodes.push(nearest); let mass = 0, cx = 0, cy = 0; for (const k of nodes) { const m = nodeMass(pl, k % g.nx, Math.floor(k / g.nx)); mass += m; cx += m * nodeX(g, k % g.nx); cy += m * nodeY(g, Math.floor(k / g.nx)); } return { nodes, nearest, mass, cx: mass > 0 ? cx / mass : 0, cy: mass > 0 ? cy / mass : 0 }; } /** The strike point in plate coordinates from the fractions of the bounding box. */ export function strikePoint(pl: Plate, p: SimParams): { x: number; y: number } { return { x: (p.strikeFracX - 0.5) * pl.Lx, y: (p.strikeFracY - 0.5) * pl.Ly }; } export function strikePatch(pl: Plate, p: SimParams): Patch { const { x, y } = strikePoint(pl, p); return patchAround(pl, x, y, p.contactRadius); } // ---------- Listener ---------- /** Listener position relative to the center of the plate, z along the plate's normal. */ export function listenerPosition(p: SimParams): { x: number; y: number; z: number } { const th = (p.listenerAngle * Math.PI) / 180, ph = (p.listenerAzimuth * Math.PI) / 180; 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) }; } /** * Per-node terms of the Rayleigh integral for the listener: the weight * rho_air dA / (2 pi r) and the delay r / c in seconds, for every node (zero * outside the plate). The profile is included in the node positions. */ export function listenerTerms(pl: Plate, p: SimParams): { weight: Float64Array; delay: Float64Array } { const g = pl.grid; const L = listenerPosition(p); const weight = new Float64Array(g.n), delay = new Float64Array(g.n); for (let j = 0; j < g.ny; j++) { for (let i = 0; i < g.nx; i++) { const wgt = nodeWeight(g, i, j); if (wgt === 0) continue; const k = j * g.nx + i; const r = Math.hypot(L.x - nodeX(g, i), L.y - nodeY(g, j), L.z - pl.w0[k]); weight[k] = (RHO_AIR * g.hx * g.hy * wgt) / (2 * Math.PI * Math.max(r, 1e-3)); delay[k] = r / SPEED_OF_SOUND; } } return { weight, delay }; } // ---------- Grid dispersion ---------- /** * Frequencies at which grid dispersion lowers a bending partial by the given * fraction, for a plate of thickness h: a plane wave along a grid axis has the * discrete frequency ratio (2 - 2 cos kh) / (kh)^2. */ export function dispersionFrequency(m: Material, h: number, spacing: number, fraction: number): number { let lo = 0, hi = Math.PI; for (let it = 0; it < 60; it++) { const mid = (lo + hi) / 2; const ratio = (2 - 2 * Math.cos(mid)) / (mid * mid); if (1 - ratio < fraction) lo = mid; else hi = mid; } const k = lo / spacing; return (bendingConstant(m, h) * k * k) / (2 * Math.PI); }