// Physical and numerical parameters of the simulation, plus derived quantities. export interface PhysParams { shape: PlateShape; // outline of the sheet within its bounding box Lx: number; // plate width along x [m] (bounding box for an ellipse) Ly: number; // plate height along y [m] h: number; // thickness [m] E: number; // Young's modulus [Pa] nu: number; // Poisson ratio rho: number; // density [kg/m^3] sigma0: number; // frequency-independent loss [1/s] sigma1: number; // frequency-dependent loss [m^2/s] sigmaIn: number; // additional loss of the in-plane motion [1/s] holeInset: number; // distance of the thread hole from the two edges of the top-left corner [m] pushAmount: number; // corner displacement [m] fingerRadius: number; // radius of the pushed patch [m] rampTime: number; // duration of the push ramp [s] holdMax: number; // maximum settle time before release [s] releaseTime: number; // time over which the finger force falls to zero [s] settleSigma0: number; // artificial damping during push and settle [1/s] settleKE: number; // kinetic energy threshold that counts as settled [J] spacing: number; // requested grid spacing [m] fs: number; // audio sample rate [Hz] fps: number; // movie frame rate (also the batch rate) [Hz] duration: number; // recorded duration after release [s] listenerDist: number; // [m] listenerAzimuth: number; // [deg], rotation about the plate's vertical axis (0 = on the normal) listenerElevation: number; // [deg] radiation: "rayleigh" | "dipole"; bowShape: BowShape; // stress-free initial shape of the sheet bowAmplitude: number; // sag of the initial shape, center relative to the edges [m] excitation: Excitation; strikeFracX: number; // strike point as a fraction of the width from the left edge strikeFracY: number; // strike point as a fraction of the 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 sphere) contactRadius: number; // radius of the contact patch [m] } export type Excitation = "push" | "strike"; /** A rectangle Lx by Ly, or the ellipse inscribed in that rectangle. */ export type PlateShape = "rectangle" | "ellipse"; export type BowShape = "none" | "width" | "height" | "dish"; export const defaultParams: PhysParams = { shape: "rectangle", Lx: 12 * 0.0254, Ly: 18 * 0.0254, h: 0.79e-3, E: 193e9, nu: 0.29, rho: 7900, sigma0: 1.15, sigma1: 5e-4, sigmaIn: 20, holeInset: 0.012, pushAmount: 0.01, fingerRadius: 0.01, rampTime: 0.1, holdMax: 0.5, releaseTime: 0.02, settleSigma0: 300, settleKE: 1e-8, spacing: 0.008, fs: 48000, fps: 120, duration: 4, listenerDist: 1.0, listenerAzimuth: 0, listenerElevation: 0, radiation: "rayleigh", bowShape: "width", bowAmplitude: 3e-3, excitation: "push", strikeFracX: 0.7, strikeFracY: 0.35, strikeVelocity: 3, stickMass: 0.03, contactStiffness: 5e8, contactExponent: 1.5, contactRadius: 5e-3, }; /** Strike point in plate coordinates (relative to the center). */ export function strikePoint(p: PhysParams): [number, number] { return [(p.strikeFracX - 0.5) * p.Lx, (p.strikeFracY - 0.5) * p.Ly]; } /** * Center of the thread hole. Rectangle: inset from the two edges of the * top-left corner. Ellipse: inset along the radius from the boundary point * in the top-left direction (parametric angle 135 degrees). */ export function holePoint(p: PhysParams): [number, number] { if (p.shape === "ellipse") { const c = Math.SQRT1_2; return [-(p.Lx / 2 - p.holeInset) * c, (p.Ly / 2 - p.holeInset) * c]; } return [-p.Lx / 2 + p.holeInset, p.Ly / 2 - p.holeInset]; } /** Center of the pushed patch: the bottom-right corner, or the boundary point opposite the hole. */ export function pushPoint(p: PhysParams): [number, number] { if (p.shape === "ellipse") { const c = Math.SQRT1_2; return [(p.Lx / 2) * c, -(p.Ly / 2) * c]; } return [p.Lx / 2, -p.Ly / 2]; } /** Stress-free initial shape w0 at every node. */ export function initialShape(p: PhysParams, g: Grid): Float32Array { const w0 = new Float32Array(g.n); if (p.bowShape === "none" || p.bowAmplitude === 0) return w0; for (let j = 0; j < g.ny; j++) { for (let i = 0; i < g.nx; i++) { const X = (2 * nodeX(g, i)) / p.Lx; // -1..1 const Y = (2 * nodeY(g, j)) / p.Ly; let f = 0; if (p.bowShape === "width") f = 1 - X * X; else if (p.bowShape === "height") f = 1 - Y * Y; else f = 1 - 0.5 * (X * X + Y * Y); w0[j * g.nx + i] = p.bowAmplitude * f; } } return w0; } export const SPEED_OF_SOUND = 343; // [m/s] export const RHO_AIR = 1.2; // [kg/m^3] export const HIST_LEN = 256; // ring buffer length for the retarded-time audio sum [samples] export const RB_INTERVAL = 32; // steps between rigid-body projections while ringing export const RB_INTERVAL_HELD = 4; // while the finger constraint is active export interface Grid { nx: number; ny: number; hx: number; hy: number; n: number; /** 1 for nodes that belong to the sheet, 0 otherwise; absent for a full rectangle. */ mask?: Uint8Array; } export interface Material { D: number; // bending stiffness [N m] C: number; // membrane stiffness E h / (1 - nu^2) [N/m] G: number; // membrane shear stiffness E h / (2 (1 + nu)) [N/m] nu: number; rhoh: number; // mass per unit area [kg/m^2] } export function makeGrid(p: PhysParams): Grid { const nx = Math.max(6, Math.round(p.Lx / p.spacing) + 1); const ny = Math.max(6, Math.round(p.Ly / p.spacing) + 1); const g: Grid = { nx, ny, hx: p.Lx / (nx - 1), hy: p.Ly / (ny - 1), n: nx * ny }; return p.shape === "ellipse" ? withEllipseMask(g) : g; } /** * Restricts a grid to the ellipse inscribed in its bounding box. The edge is a * staircase of whole cells: a cell belongs to the sheet when its center lies * inside the ellipse, and a node when it is a corner of such a cell. Deciding * by cell centers rather than by nodes keeps the covered area unbiased (a * node-based mask loses about half a cell all around the edge, which raises * every frequency by a few percent). Nodes without a cell of their own would * carry no mass; the trailing loop removes any such node. */ export function withEllipseMask(g: Grid): Grid { const mask = new Uint8Array(g.n); const a = ((g.nx - 1) * g.hx) / 2, b = ((g.ny - 1) * g.hy) / 2; for (let cj = 0; cj < g.ny - 1; cj++) { for (let ci = 0; ci < g.nx - 1; ci++) { const X = (nodeX(g, ci) + g.hx / 2) / a, Y = (nodeY(g, cj) + g.hy / 2) / b; if (X * X + Y * Y <= 1) { 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; } /** True when node (i, j) exists and belongs to the sheet. */ 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 sheet. */ 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); } /** Number of inside nodes (the degrees of freedom of the sheet). */ 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; } export function makeMaterial(p: PhysParams): Material { return { D: (p.E * p.h ** 3) / (12 * (1 - p.nu * p.nu)), C: (p.E * p.h) / (1 - p.nu * p.nu), G: (p.E * p.h) / (2 * (1 + p.nu)), nu: p.nu, rhoh: p.rho * p.h, }; } /** Node coordinates relative to the plate center. */ 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; } /** * Quadrature (and lumped-mass) weight of a node: the fraction of its four * surrounding cells that belong to the sheet. On a full rectangle this is the * trapezoidal rule: 1 in the interior, 1/2 on an edge, 1/4 at a corner. Zero * for nodes outside the sheet. */ 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 (0 outside the sheet). */ 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; } /** Decay time T60 [s] of a mode with angular frequency omega under the two-parameter loss model. */ export function t60(m: Material, p: PhysParams, omega: number): number { const kappa = Math.sqrt(m.D / m.rhoh); const k2 = omega / kappa; const sigma = p.sigma0 + p.sigma1 * k2; return (3 * Math.LN10) / sigma; // amplitude factor 1000 = 60 dB }