1// Parameter form: numeric fields in convenient units, a few selects, and the
2// derived-quantity notes. Everything is stored in SI in `params`.
4import {
5 type BowShape,
6 type Excitation,
7 type Grid,
8 type PhysParams,
9 type PlateShape,
10 countInside,
11 dispersionFrequency,
12 makeGrid,
13 makeMaterial,
14 t60,
15} from "../params.ts";
17export type SizeUnit = "in" | "mm";
19interface FieldSpec {
20 key: keyof PhysParams;
21 label: string;
22 unit: string;
23 scale: number; // form value = SI value / scale
24 step?: number;
25 live?: boolean; // may be changed while a simulation runs
26}
28interface SelectSpec {
29 key: keyof PhysParams;
30 label: string;
31 options: [string, string][];
32 before?: keyof PhysParams; // insert before this field (default: at the start of the group)
33}
35interface Group {
36 title: string;
37 open: boolean;
38 fields: FieldSpec[];
39 selects?: SelectSpec[];
40 note?: string | "grid" | "damping";
41}
43const groups: Group[] = [
44 {
45 title: "Sheet",
46 open: true,
47 selects: [{ key: "shape", label: "Outline", options: [["rectangle", "Rectangle"], ["ellipse", "Ellipse (inscribed)"]] }],
48 fields: [
49 { key: "Lx", label: "Width", unit: "size", scale: 1, step: 0.5 },
50 { key: "Ly", label: "Height", unit: "size", scale: 1, step: 0.5 },
51 { key: "h", label: "Thickness", unit: "mm", scale: 1e-3, step: 0.01 },
52 { key: "E", label: "Young's modulus", unit: "GPa", scale: 1e9, step: 1 },
53 { key: "nu", label: "Poisson ratio", unit: "", scale: 1, step: 0.01 },
54 { key: "rho", label: "Density", unit: "kg/m³", scale: 1, step: 10 },
55 { key: "holeInset", label: "Hole inset", unit: "mm", scale: 1e-3, step: 1 },
56 ],
57 note: "grid",
58 },
59 {
60 title: "Initial shape",
61 open: true,
62 selects: [{ key: "bowShape", label: "Bow shape", options: [["width", "Curved across the width"], ["height", "Curved across the height"], ["dish", "Dish (curved both ways)"], ["none", "Flat"]] }],
63 fields: [{ key: "bowAmplitude", label: "Bow (center sag)", unit: "mm", scale: 1e-3, step: 0.5 }],
64 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.",
65 },
66 {
67 title: "Excitation: push and release",
68 open: true,
69 fields: [
70 { key: "pushAmount", label: "Push amount", unit: "mm", scale: 1e-3, step: 0.5 },
71 { key: "fingerRadius", label: "Finger radius", unit: "mm", scale: 1e-3, step: 1 },
72 { key: "rampTime", label: "Push ramp", unit: "s", scale: 1, step: 0.01 },
73 { key: "holdMax", label: "Max settle time", unit: "s", scale: 1, step: 0.1 },
74 { key: "releaseTime", label: "Release time", unit: "ms", scale: 1e-3, step: 1 },
75 { key: "settleSigma0", label: "Settle damping σ₀", unit: "1/s", scale: 1, step: 10 },
76 ],
77 },
78 {
79 title: "Excitation: drumstick strike",
80 open: false,
81 fields: [
82 { key: "strikeFracX", label: "Strike x (fraction of width)", unit: "", scale: 1, step: 0.05 },
83 { key: "strikeFracY", label: "Strike y (fraction of height)", unit: "", scale: 1, step: 0.05 },
84 { key: "strikeVelocity", label: "Stick speed", unit: "m/s", scale: 1, step: 0.5 },
85 { key: "stickMass", label: "Stick mass", unit: "g", scale: 1e-3, step: 5 },
86 { key: "contactStiffness", label: "Contact stiffness K", unit: "N/mᵅ", scale: 1, step: 1e8 },
87 { key: "contactExponent", label: "Contact exponent α", unit: "", scale: 1, step: 0.1 },
88 { key: "contactRadius", label: "Tip radius", unit: "mm", scale: 1e-3, step: 1 },
89 ],
90 },
91 {
92 title: "Losses",
93 open: true,
94 fields: [
95 { key: "sigma0", label: "σ₀ (frequency independent)", unit: "1/s", scale: 1, step: 0.1, live: true },
96 { key: "sigma1", label: "σ₁ (∝ frequency)", unit: "m²/s", scale: 1, step: 1e-5, live: true },
97 { key: "sigmaIn", label: "In-plane loss", unit: "1/s", scale: 1, step: 5, live: true },
98 ],
99 note: "damping",
100 },
101 {
102 title: "Listener",
103 open: true,
104 fields: [
105 { key: "listenerDist", label: "Distance", unit: "m", scale: 1, step: 0.1, live: true },
106 { key: "listenerAzimuth", label: "Azimuth", unit: "deg", scale: 1, step: 5, live: true },
107 { key: "listenerElevation", label: "Elevation", unit: "deg", scale: 1, step: 5, live: true },
108 ],
109 },
110 {
111 title: "Numerics",
112 open: false,
113 fields: [
114 { key: "spacing", label: "Grid spacing", unit: "mm", scale: 1e-3, step: 0.5 },
115 { key: "fs", label: "Audio sample rate", unit: "Hz", scale: 1, step: 100 },
116 { key: "fps", label: "Movie frame rate", unit: "fps", scale: 1, step: 10 },
117 { key: "settleKE", label: "Settled below KE", unit: "J", scale: 1, step: 1e-9 },
118 ],
119 },
120];
122const SIZE_SCALE: Record<SizeUnit, number> = { in: 0.0254, mm: 1e-3 };
123const enumKeys: (keyof PhysParams)[] = ["shape", "bowShape", "excitation"];
124const enumValues: Record<string, string[]> = {
125 shape: ["rectangle", "ellipse"],
126 bowShape: ["none", "width", "height", "dish"],
127 excitation: ["push", "strike"],
128};
130export function formatNumber(v: number): string {
131 if (v === 0) return "0";
132 const a = Math.abs(v);
133 if (a >= 1e5 || a < 1e-3) return v.toExponential(3);
134 return String(+v.toPrecision(6));
135}
137export class ParamForm {
138 readonly params: PhysParams;
139 sizeUnit: SizeUnit = "in";
140 /** Called after any change; `live` tells whether the field may change during a run. */
141 onChange: (key: keyof PhysParams, live: boolean) => void = () => {};
143 private inputs = new Map<keyof PhysParams, HTMLInputElement | HTMLSelectElement>();
144 private sizeUnitSelect: HTMLSelectElement;
145 private gridNote: HTMLElement | null = null;
146 private dampingNote: HTMLElement | null = null;
147 private liveKeys = new Set<keyof PhysParams>();
149 constructor(root: HTMLElement, params: PhysParams) {
150 this.params = params;
151 // Excitation selector at the top, outside the groups.
152 this.sizeUnitSelect = document.createElement("select");
153 root.appendChild(this.selectRow("Excitation", "excitation", [["push", "Push a corner and release"], ["strike", "Drumstick strike"]]));
154 for (const g of groups) {
155 const det = document.createElement("details");
156 det.open = g.open;
157 const sum = document.createElement("summary");
158 sum.textContent = g.title;
159 det.appendChild(sum);
160 for (const s of g.selects ?? []) if (!s.before) det.appendChild(this.selectRow(s.label, s.key, s.options));
161 for (const f of g.fields) {
162 for (const s of g.selects ?? []) if (s.before === f.key) det.appendChild(this.selectRow(s.label, s.key, s.options));
163 det.appendChild(this.fieldRow(f));
164 if (f.live) this.liveKeys.add(f.key);
165 }
166 if (g.note) {
167 const note = document.createElement("p");
168 note.className = "sub";
169 if (g.note === "grid") this.gridNote = note;
170 else if (g.note === "damping") this.dampingNote = note;
171 else note.textContent = g.note;
172 det.appendChild(note);
173 }
174 root.appendChild(det);
175 }
176 this.updateNotes();
177 }
179 private selectRow(label: string, key: keyof PhysParams, options: [string, string][]): HTMLElement {
180 const row = document.createElement("div");
181 row.className = "field";
182 const l = document.createElement("label");
183 l.textContent = label;
184 const sel = document.createElement("select");
185 for (const [v, t] of options) {
186 const o = document.createElement("option");
187 o.value = v; o.textContent = t;
188 sel.appendChild(o);
189 }
190 sel.value = String(this.params[key]);
191 sel.addEventListener("change", () => {
192 (this.params as unknown as Record<string, string>)[key] = sel.value;
193 this.updateNotes();
194 this.onChange(key, false);
195 });
196 row.append(l, sel, document.createElement("span"));
197 this.inputs.set(key, sel);
198 return row;
199 }
201 private fieldRow(f: FieldSpec): HTMLElement {
202 const row = document.createElement("div");
203 row.className = "field";
204 const label = document.createElement("label");
205 label.textContent = f.label;
206 const inp = document.createElement("input");
207 inp.type = "number";
208 inp.step = String(f.step ?? 1);
209 inp.value = formatNumber((this.params[f.key] as number) / this.scale(f));
210 inp.addEventListener("change", () => {
211 const v = parseFloat(inp.value);
212 if (!isFinite(v)) return;
213 (this.params as unknown as Record<string, number>)[f.key] = v * this.scale(f);
214 this.updateNotes();
215 this.onChange(f.key, !!f.live);
216 });
217 this.inputs.set(f.key, inp);
218 const unit = document.createElement("span");
219 unit.className = "unit";
220 if (f.unit === "size") {
221 // Width and height share one unit selector (inches by default), shown on the first of them.
222 const sel = this.sizeUnitSelect;
223 if (sel.options.length === 0) {
224 for (const u of ["in", "mm"] as SizeUnit[]) {
225 const o = document.createElement("option");
226 o.value = u; o.textContent = u;
227 sel.appendChild(o);
228 }
229 sel.value = this.sizeUnit;
230 sel.addEventListener("change", () => {
231 this.sizeUnit = sel.value as SizeUnit;
232 for (const el of this.sizeUnitLabels) el.textContent = this.sizeUnit;
233 this.refresh();
234 });
235 row.append(label, inp, sel);
236 return row;
237 }
238 unit.textContent = this.sizeUnit;
239 this.sizeUnitLabels.push(unit);
240 } else {
241 unit.textContent = f.unit;
242 }
243 row.append(label, inp, unit);
244 return row;
245 }
246 private sizeUnitLabels: HTMLElement[] = [];
248 private scale(f: FieldSpec): number {
249 return f.unit === "size" ? SIZE_SCALE[this.sizeUnit] : f.scale;
250 }
252 /** Rewrites every input from the current parameter values. */
253 refresh(): void {
254 for (const g of groups) {
255 for (const f of g.fields) {
256 const inp = this.inputs.get(f.key) as HTMLInputElement;
257 inp.value = formatNumber((this.params[f.key] as number) / this.scale(f));
258 }
259 }
260 for (const k of enumKeys) (this.inputs.get(k) as HTMLSelectElement).value = String(this.params[k]);
261 this.updateNotes();
262 }
264 /** Enables or disables the fields that cannot change while a simulation runs. */
265 setEnabled(enabled: boolean): void {
266 for (const [key, el] of this.inputs) if (!this.liveKeys.has(key)) el.disabled = !enabled;
267 }
269 /** Applies URL parameters: any PhysParams key in SI units. Returns the keys that were set. */
270 applyUrl(q: URLSearchParams): (keyof PhysParams)[] {
271 const set: (keyof PhysParams)[] = [];
272 for (const [k, v] of q) {
273 if (!(k in this.params)) continue;
274 const key = k as keyof PhysParams;
275 if (enumKeys.includes(key)) {
276 if (enumValues[key].includes(v)) {
277 (this.params as unknown as Record<string, string>)[key] = v;
278 set.push(key);
279 }
280 } else {
281 const num = parseFloat(v);
282 if (isFinite(num)) {
283 (this.params as unknown as Record<string, number>)[key] = num;
284 set.push(key);
285 }
286 }
287 }
288 this.refresh();
289 return set;
290 }
292 gridInfo(): { grid: Grid; inside: number } {
293 const grid = makeGrid(this.params);
294 return { grid, inside: countInside(grid) };
295 }
297 private updateNotes(): void {
298 const p = this.params;
299 if (this.gridNote) {
300 const { grid, inside } = this.gridInfo();
301 const m = makeMaterial(p);
302 const f3 = dispersionFrequency(m, grid, 0.03), f10 = dispersionFrequency(m, grid, 0.1);
303 const outline = p.shape === "ellipse" ? "ellipse with a stepped edge" : "rectangle";
304 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).`;
305 }
306 if (this.dampingNote) {
307 const m = makeMaterial(p);
308 const f = (hz: number) => t60(m, p, 2 * Math.PI * hz).toFixed(2);
309 this.dampingNote.textContent = `T60: ${f(100)} s at 100 Hz, ${f(1000)} s at 1 kHz, ${f(5000)} s at 5 kHz.`;
310 }
311 }
313 get excitation(): Excitation {
314 return this.params.excitation;
315 }
316 get shape(): PlateShape {
317 return this.params.shape;
318 }
319 get bowShape(): BowShape {
320 return this.params.bowShape;
321 }
322}