// Parameter form for the strike, the listener, and the numerics: numeric // fields in convenient units and a few selects, with derived-quantity notes // that depend on the current plate. Everything is stored in SI in `params`. import { type Plate, type SimParams, CFL, countInside, defaultParams, dispersionFrequency, membraneStiffness, t60, thicknessRange, } from "../params.ts"; import { maxAngularFrequency } from "../sim/cpuRef.ts"; interface FieldSpec { key: keyof SimParams; label: string; unit: string; scale: number; // form value = SI value / scale step?: number; live?: boolean; // may be changed while a simulation runs } interface SelectSpec { key: keyof SimParams; label: string; options: [string, string][]; } interface Group { title: string; open: boolean; selects?: SelectSpec[]; fields: FieldSpec[]; note?: "strike" | "grid" | "numerics"; } const groups: Group[] = [ { title: "Strike", open: true, fields: [ { key: "strikeFracX", label: "Strike x (fraction of width)", unit: "", scale: 1, step: 0.05 }, { key: "strikeFracY", label: "Strike y (fraction of height)", unit: "", scale: 1, step: 0.05 }, { key: "strikeVelocity", label: "Stick speed", unit: "m/s", scale: 1, step: 0.5 }, { key: "stickMass", label: "Stick mass", unit: "g", scale: 1e-3, step: 5 }, { key: "contactStiffness", label: "Contact stiffness K", unit: "N/mᵅ", scale: 1, step: 1e8 }, { key: "contactExponent", label: "Contact exponent α", unit: "", scale: 1, step: 0.1 }, { key: "contactRadius", label: "Tip radius", unit: "mm", scale: 1e-3, step: 1 }, ], note: "strike", }, { title: "Listener", open: true, fields: [ { key: "listenerDist", label: "Distance", unit: "m", scale: 1, step: 0.1, live: true }, { key: "listenerAzimuth", label: "Azimuth", unit: "deg", scale: 1, step: 5, live: true }, { key: "listenerElevation", label: "Elevation", unit: "deg", scale: 1, step: 5, live: true }, ], }, { title: "Grid and numerics", open: true, selects: [{ key: "rigidProjection", label: "Rigid-body projection (free plate)", options: [["false", "Off"], ["true", "On (every 32 steps)"]] }], fields: [ { key: "spacing", label: "Grid spacing", unit: "mm", scale: 1e-3, step: 0.5 }, { key: "inPlaneMass", label: "In-plane mass factor", unit: "", scale: 1, step: 1 }, { key: "fs", label: "Audio sample rate", unit: "Hz", scale: 1, step: 100 }, { key: "fps", label: "Movie frame rate", unit: "fps", scale: 1, step: 10 }, ], note: "grid", }, ]; const selectKeys: (keyof SimParams)[] = ["rigidProjection"]; export function formatNumber(v: number): string { if (v === 0) return "0"; const a = Math.abs(v); if (a >= 1e5 || a < 1e-3) return v.toExponential(3); return String(+v.toPrecision(6)); } export class ParamForm { readonly params: SimParams; /** Called after any change; `live` tells whether the field may change during a run. */ onChange: (key: keyof SimParams, live: boolean) => void = () => {}; private inputs = new Map(); private strikeNote: HTMLElement | null = null; private gridNote: HTMLElement | null = null; private omegaCache: { key: string; omega: number } | null = null; private liveKeys = new Set(); private plate: Plate | null = null; constructor(root: HTMLElement, params: SimParams) { this.params = params; for (const g of groups) { const det = document.createElement("details"); det.open = g.open; const sum = document.createElement("summary"); sum.textContent = g.title; det.appendChild(sum); for (const s of g.selects ?? []) det.appendChild(this.selectRow(s)); for (const f of g.fields) { det.appendChild(this.fieldRow(f)); if (f.live) this.liveKeys.add(f.key); } if (g.note) { const note = document.createElement("p"); note.className = "sub"; if (g.note === "strike") this.strikeNote = note; else if (g.note === "grid") this.gridNote = note; det.appendChild(note); } root.appendChild(det); } this.updateNotes(); } private selectRow(s: SelectSpec): HTMLElement { const row = document.createElement("div"); row.className = "field"; const l = document.createElement("label"); l.textContent = s.label; const sel = document.createElement("select"); for (const [v, t] of s.options) { const o = document.createElement("option"); o.value = v; o.textContent = t; sel.appendChild(o); } sel.value = String(this.params[s.key]); sel.addEventListener("change", () => { (this.params as unknown as Record)[s.key] = sel.value === "true"; this.updateNotes(); this.onChange(s.key, false); }); row.append(l, sel, document.createElement("span")); this.inputs.set(s.key, sel); return row; } private fieldRow(f: FieldSpec): HTMLElement { const row = document.createElement("div"); row.className = "field"; const label = document.createElement("label"); label.textContent = f.label; const inp = document.createElement("input"); inp.type = "number"; inp.step = String(f.step ?? 1); inp.value = formatNumber((this.params[f.key] as number) / f.scale); inp.addEventListener("change", () => { const v = parseFloat(inp.value); if (!isFinite(v)) return; (this.params as unknown as Record)[f.key] = v * f.scale; this.updateNotes(); this.onChange(f.key, !!f.live); }); this.inputs.set(f.key, inp); const unit = document.createElement("span"); unit.className = "unit"; unit.textContent = f.unit; row.append(label, inp, unit); return row; } /** Rewrites every input from the current parameter values. */ refresh(): void { for (const g of groups) { for (const f of g.fields) { const inp = this.inputs.get(f.key) as HTMLInputElement; inp.value = formatNumber((this.params[f.key] as number) / f.scale); } } for (const k of selectKeys) (this.inputs.get(k) as HTMLSelectElement).value = String(this.params[k]); this.updateNotes(); } /** Enables or disables the fields that cannot change while a simulation runs. */ setEnabled(enabled: boolean): void { for (const [key, el] of this.inputs) if (!this.liveKeys.has(key)) el.disabled = !enabled; } /** Applies URL parameters: any SimParams key in SI units (booleans as 0/1 or false/true). Returns the keys that were set. */ applyUrl(q: URLSearchParams): (keyof SimParams)[] { const set: (keyof SimParams)[] = []; for (const [k, v] of q) { if (!(k in this.params)) continue; const key = k as keyof SimParams; if (selectKeys.includes(key)) { (this.params as unknown as Record)[key] = v === "1" || v === "true"; set.push(key); } else { const num = parseFloat(v); if (isFinite(num)) { (this.params as unknown as Record)[key] = num; set.push(key); } } } this.refresh(); return set; } /** The plate the notes describe (the design evaluated at the current spacing), or null while the script has an error. */ setPlate(plate: Plate | null): void { this.plate = plate; this.updateNotes(); } /** Rewrites the derived-quantity notes; call after a change made outside the form (the recorded duration). */ updateNotes(): void { const p = this.params; const pl = this.plate; if (this.strikeNote) { 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.`; } if (this.gridNote) { if (!pl) { this.gridNote.textContent = "The grid follows the design's bounding box at the chosen spacing."; return; } const g = pl.grid; const hr = thicknessRange(pl); const f3 = dispersionFrequency(pl.mat, hr.mean, g, 0.03), f10 = dispersionFrequency(pl.mat, hr.mean, g, 0.1); const s = Math.max(1, p.inPlaneMass); // The power iteration takes a moment, so its result is kept until the spacing, the material, the thickness, or the factor changes. const key = [g.hx, g.hy, pl.mat.E, pl.mat.nu, pl.mat.rho, hr.max, s].join("|"); if (!this.omegaCache || this.omegaCache.key !== key) this.omegaCache = { key, omega: maxAngularFrequency(g, pl.mat, hr.max, s) }; const stepsPerSample = Math.ceil(1 / (p.fs * ((CFL * 2) / this.omegaCache.omega))); const dt = 1 / (p.fs * stepsPerSample); const cp = Math.sqrt(membraneStiffness(pl.mat, hr.mean) / (pl.mat.rho * hr.mean) / s); const steps = Math.round(p.duration / dt); const t = (hz: number) => t60(pl.mat, hr.mean, pl.loss, 2 * Math.PI * hz).toFixed(2); this.gridNote.textContent = `${g.nx} × ${g.ny} grid, ${countInside(g)} nodes in the plate, ${(g.hx * 1e3).toFixed(2)} × ${(g.hy * 1e3).toFixed(2)} mm. ` + `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). ` + `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. ` + `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. ` + `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.` + (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."); } } } export { defaultParams };