// Parameter form: numeric fields in convenient units, a few selects, and the // derived-quantity notes. Everything is stored in SI in `params`. import { type BowShape, type Excitation, type Grid, type PhysParams, type PlateShape, countInside, dispersionFrequency, makeGrid, makeMaterial, t60, } from "../params.ts"; export type SizeUnit = "in" | "mm"; interface FieldSpec { key: keyof PhysParams; 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 PhysParams; label: string; options: [string, string][]; before?: keyof PhysParams; // insert before this field (default: at the start of the group) } interface Group { title: string; open: boolean; fields: FieldSpec[]; selects?: SelectSpec[]; note?: string | "grid" | "damping"; } const groups: Group[] = [ { title: "Sheet", open: true, selects: [{ key: "shape", label: "Outline", options: [["rectangle", "Rectangle"], ["ellipse", "Ellipse (inscribed)"]] }], fields: [ { key: "Lx", label: "Width", unit: "size", scale: 1, step: 0.5 }, { key: "Ly", label: "Height", unit: "size", scale: 1, step: 0.5 }, { key: "h", label: "Thickness", unit: "mm", scale: 1e-3, step: 0.01 }, { key: "E", label: "Young's modulus", unit: "GPa", scale: 1e9, step: 1 }, { key: "nu", label: "Poisson ratio", unit: "", scale: 1, step: 0.01 }, { key: "rho", label: "Density", unit: "kg/m³", scale: 1, step: 10 }, { key: "holeInset", label: "Hole inset", unit: "mm", scale: 1e-3, step: 1 }, ], note: "grid", }, { title: "Initial shape", open: true, selects: [{ key: "bowShape", label: "Bow shape", options: [["width", "Curved across the width"], ["height", "Curved across the height"], ["dish", "Dish (curved both ways)"], ["none", "Flat"]] }], fields: [{ key: "bowAmplitude", label: "Bow (center sag)", unit: "mm", scale: 1e-3, step: 0.5 }], note: "Real sheet metal is never flat. A bow of a few millimeters makes the sheet a shallow shell with an asymmetric stiffness; a negative push amount pushes against the bow.", }, { title: "Excitation: push and release", open: true, fields: [ { key: "pushAmount", label: "Push amount", unit: "mm", scale: 1e-3, step: 0.5 }, { key: "fingerRadius", label: "Finger radius", unit: "mm", scale: 1e-3, step: 1 }, { key: "rampTime", label: "Push ramp", unit: "s", scale: 1, step: 0.01 }, { key: "holdMax", label: "Max settle time", unit: "s", scale: 1, step: 0.1 }, { key: "releaseTime", label: "Release time", unit: "ms", scale: 1e-3, step: 1 }, { key: "settleSigma0", label: "Settle damping σ₀", unit: "1/s", scale: 1, step: 10 }, ], }, { title: "Excitation: drumstick strike", open: false, 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 }, ], }, { title: "Losses", open: true, fields: [ { key: "sigma0", label: "σ₀ (frequency independent)", unit: "1/s", scale: 1, step: 0.1, live: true }, { key: "sigma1", label: "σ₁ (∝ frequency)", unit: "m²/s", scale: 1, step: 1e-5, live: true }, { key: "sigmaIn", label: "In-plane loss", unit: "1/s", scale: 1, step: 5, live: true }, ], note: "damping", }, { 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: "Numerics", open: false, fields: [ { key: "spacing", label: "Grid spacing", unit: "mm", scale: 1e-3, step: 0.5 }, { key: "fs", label: "Audio sample rate", unit: "Hz", scale: 1, step: 100 }, { key: "fps", label: "Movie frame rate", unit: "fps", scale: 1, step: 10 }, { key: "settleKE", label: "Settled below KE", unit: "J", scale: 1, step: 1e-9 }, ], }, ]; const SIZE_SCALE: Record = { in: 0.0254, mm: 1e-3 }; const enumKeys: (keyof PhysParams)[] = ["shape", "bowShape", "excitation"]; const enumValues: Record = { shape: ["rectangle", "ellipse"], bowShape: ["none", "width", "height", "dish"], excitation: ["push", "strike"], }; 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: PhysParams; sizeUnit: SizeUnit = "in"; /** Called after any change; `live` tells whether the field may change during a run. */ onChange: (key: keyof PhysParams, live: boolean) => void = () => {}; private inputs = new Map(); private sizeUnitSelect: HTMLSelectElement; private gridNote: HTMLElement | null = null; private dampingNote: HTMLElement | null = null; private liveKeys = new Set(); constructor(root: HTMLElement, params: PhysParams) { this.params = params; // Excitation selector at the top, outside the groups. this.sizeUnitSelect = document.createElement("select"); root.appendChild(this.selectRow("Excitation", "excitation", [["push", "Push a corner and release"], ["strike", "Drumstick strike"]])); 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 ?? []) if (!s.before) det.appendChild(this.selectRow(s.label, s.key, s.options)); for (const f of g.fields) { for (const s of g.selects ?? []) if (s.before === f.key) det.appendChild(this.selectRow(s.label, s.key, s.options)); 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 === "grid") this.gridNote = note; else if (g.note === "damping") this.dampingNote = note; else note.textContent = g.note; det.appendChild(note); } root.appendChild(det); } this.updateNotes(); } private selectRow(label: string, key: keyof PhysParams, options: [string, string][]): HTMLElement { const row = document.createElement("div"); row.className = "field"; const l = document.createElement("label"); l.textContent = label; const sel = document.createElement("select"); for (const [v, t] of options) { const o = document.createElement("option"); o.value = v; o.textContent = t; sel.appendChild(o); } sel.value = String(this.params[key]); sel.addEventListener("change", () => { (this.params as unknown as Record)[key] = sel.value; this.updateNotes(); this.onChange(key, false); }); row.append(l, sel, document.createElement("span")); this.inputs.set(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) / this.scale(f)); inp.addEventListener("change", () => { const v = parseFloat(inp.value); if (!isFinite(v)) return; (this.params as unknown as Record)[f.key] = v * this.scale(f); this.updateNotes(); this.onChange(f.key, !!f.live); }); this.inputs.set(f.key, inp); const unit = document.createElement("span"); unit.className = "unit"; if (f.unit === "size") { // Width and height share one unit selector (inches by default), shown on the first of them. const sel = this.sizeUnitSelect; if (sel.options.length === 0) { for (const u of ["in", "mm"] as SizeUnit[]) { const o = document.createElement("option"); o.value = u; o.textContent = u; sel.appendChild(o); } sel.value = this.sizeUnit; sel.addEventListener("change", () => { this.sizeUnit = sel.value as SizeUnit; for (const el of this.sizeUnitLabels) el.textContent = this.sizeUnit; this.refresh(); }); row.append(label, inp, sel); return row; } unit.textContent = this.sizeUnit; this.sizeUnitLabels.push(unit); } else { unit.textContent = f.unit; } row.append(label, inp, unit); return row; } private sizeUnitLabels: HTMLElement[] = []; private scale(f: FieldSpec): number { return f.unit === "size" ? SIZE_SCALE[this.sizeUnit] : f.scale; } /** 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) / this.scale(f)); } } for (const k of enumKeys) (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 PhysParams key in SI units. Returns the keys that were set. */ applyUrl(q: URLSearchParams): (keyof PhysParams)[] { const set: (keyof PhysParams)[] = []; for (const [k, v] of q) { if (!(k in this.params)) continue; const key = k as keyof PhysParams; if (enumKeys.includes(key)) { if (enumValues[key].includes(v)) { (this.params as unknown as Record)[key] = v; 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; } gridInfo(): { grid: Grid; inside: number } { const grid = makeGrid(this.params); return { grid, inside: countInside(grid) }; } private updateNotes(): void { const p = this.params; if (this.gridNote) { const { grid, inside } = this.gridInfo(); const m = makeMaterial(p); const f3 = dispersionFrequency(m, grid, 0.03), f10 = dispersionFrequency(m, grid, 0.1); const outline = p.shape === "ellipse" ? "ellipse with a stepped edge" : "rectangle"; this.gridNote.textContent = `${outline}; ${grid.nx} × ${grid.ny} grid, ${inside} nodes in the sheet. Bending partials come out low by 3% near ${(f3 / 1e3).toFixed(1)} kHz and by 10% near ${(f10 / 1e3).toFixed(1)} kHz (grid dispersion).`; } if (this.dampingNote) { const m = makeMaterial(p); const f = (hz: number) => t60(m, p, 2 * Math.PI * hz).toFixed(2); this.dampingNote.textContent = `T60: ${f(100)} s at 100 Hz, ${f(1000)} s at 1 kHz, ${f(5000)} s at 5 kHz.`; } } get excitation(): Excitation { return this.params.excitation; } get shape(): PlateShape { return this.params.shape; } get bowShape(): BowShape { return this.params.bowShape; } }