1// Parameter form for the strike, the listener, and the numerics: numeric
2// fields in convenient units and a few selects, with derived-quantity notes
3// that depend on the current plate. Everything is stored in SI in `params`.
5import {
6 type Plate,
7 type SimParams,
8 CFL,
9 countInside,
10 defaultParams,
11 dispersionFrequency,
12 membraneStiffness,
13 t60,
14 thicknessRange,
15} from "../params.ts";
16import { maxAngularFrequency } from "../sim/cpuRef.ts";
18interface FieldSpec {
19 key: keyof SimParams;
20 label: string;
21 unit: string;
22 scale: number; // form value = SI value / scale
23 step?: number;
24 live?: boolean; // may be changed while a simulation runs
25}
27interface SelectSpec {
28 key: keyof SimParams;
29 label: string;
30 options: [string, string][];
31}
33interface Group {
34 title: string;
35 open: boolean;
36 selects?: SelectSpec[];
37 fields: FieldSpec[];
38 note?: "strike" | "grid" | "numerics";
39}
41const groups: Group[] = [
42 {
43 title: "Strike",
44 open: true,
45 fields: [
46 { key: "strikeFracX", label: "Strike x (fraction of width)", unit: "", scale: 1, step: 0.05 },
47 { key: "strikeFracY", label: "Strike y (fraction of height)", unit: "", scale: 1, step: 0.05 },
48 { key: "strikeVelocity", label: "Stick speed", unit: "m/s", scale: 1, step: 0.5 },
49 { key: "stickMass", label: "Stick mass", unit: "g", scale: 1e-3, step: 5 },
50 { key: "contactStiffness", label: "Contact stiffness K", unit: "N/mᵅ", scale: 1, step: 1e8 },
51 { key: "contactExponent", label: "Contact exponent α", unit: "", scale: 1, step: 0.1 },
52 { key: "contactRadius", label: "Tip radius", unit: "mm", scale: 1e-3, step: 1 },
53 ],
54 note: "strike",
55 },
56 {
57 title: "Listener",
58 open: true,
59 fields: [
60 { key: "listenerDist", label: "Distance", unit: "m", scale: 1, step: 0.1, live: true },
61 { key: "listenerAzimuth", label: "Azimuth", unit: "deg", scale: 1, step: 5, live: true },
62 { key: "listenerElevation", label: "Elevation", unit: "deg", scale: 1, step: 5, live: true },
63 ],
64 },
65 {
66 title: "Grid and numerics",
67 open: true,
68 selects: [{ key: "rigidProjection", label: "Rigid-body projection (free plate)", options: [["false", "Off"], ["true", "On (every 32 steps)"]] }],
69 fields: [
70 { key: "spacing", label: "Grid spacing", unit: "mm", scale: 1e-3, step: 0.5 },
71 { key: "inPlaneMass", label: "In-plane mass factor", unit: "", scale: 1, step: 1 },
72 { key: "fs", label: "Audio sample rate", unit: "Hz", scale: 1, step: 100 },
73 { key: "fps", label: "Movie frame rate", unit: "fps", scale: 1, step: 10 },
74 ],
75 note: "grid",
76 },
77];
79const selectKeys: (keyof SimParams)[] = ["rigidProjection"];
81export function formatNumber(v: number): string {
82 if (v === 0) return "0";
83 const a = Math.abs(v);
84 if (a >= 1e5 || a < 1e-3) return v.toExponential(3);
85 return String(+v.toPrecision(6));
86}
88export class ParamForm {
89 readonly params: SimParams;
90 /** Called after any change; `live` tells whether the field may change during a run. */
91 onChange: (key: keyof SimParams, live: boolean) => void = () => {};
93 private inputs = new Map<keyof SimParams, HTMLInputElement | HTMLSelectElement>();
94 private strikeNote: HTMLElement | null = null;
95 private gridNote: HTMLElement | null = null;
96 private omegaCache: { key: string; omega: number } | null = null;
97 private liveKeys = new Set<keyof SimParams>();
98 private plate: Plate | null = null;
100 constructor(root: HTMLElement, params: SimParams) {
101 this.params = params;
102 for (const g of groups) {
103 const det = document.createElement("details");
104 det.open = g.open;
105 const sum = document.createElement("summary");
106 sum.textContent = g.title;
107 det.appendChild(sum);
108 for (const s of g.selects ?? []) det.appendChild(this.selectRow(s));
109 for (const f of g.fields) {
110 det.appendChild(this.fieldRow(f));
111 if (f.live) this.liveKeys.add(f.key);
112 }
113 if (g.note) {
114 const note = document.createElement("p");
115 note.className = "sub";
116 if (g.note === "strike") this.strikeNote = note;
117 else if (g.note === "grid") this.gridNote = note;
118 det.appendChild(note);
119 }
120 root.appendChild(det);
121 }
122 this.updateNotes();
123 }
125 private selectRow(s: SelectSpec): HTMLElement {
126 const row = document.createElement("div");
127 row.className = "field";
128 const l = document.createElement("label");
129 l.textContent = s.label;
130 const sel = document.createElement("select");
131 for (const [v, t] of s.options) {
132 const o = document.createElement("option");
133 o.value = v; o.textContent = t;
134 sel.appendChild(o);
135 }
136 sel.value = String(this.params[s.key]);
137 sel.addEventListener("change", () => {
138 (this.params as unknown as Record<string, boolean>)[s.key] = sel.value === "true";
139 this.updateNotes();
140 this.onChange(s.key, false);
141 });
142 row.append(l, sel, document.createElement("span"));
143 this.inputs.set(s.key, sel);
144 return row;
145 }
147 private fieldRow(f: FieldSpec): HTMLElement {
148 const row = document.createElement("div");
149 row.className = "field";
150 const label = document.createElement("label");
151 label.textContent = f.label;
152 const inp = document.createElement("input");
153 inp.type = "number";
154 inp.step = String(f.step ?? 1);
155 inp.value = formatNumber((this.params[f.key] as number) / f.scale);
156 inp.addEventListener("change", () => {
157 const v = parseFloat(inp.value);
158 if (!isFinite(v)) return;
159 (this.params as unknown as Record<string, number>)[f.key] = v * f.scale;
160 this.updateNotes();
161 this.onChange(f.key, !!f.live);
162 });
163 this.inputs.set(f.key, inp);
164 const unit = document.createElement("span");
165 unit.className = "unit";
166 unit.textContent = f.unit;
167 row.append(label, inp, unit);
168 return row;
169 }
171 /** Rewrites every input from the current parameter values. */
172 refresh(): void {
173 for (const g of groups) {
174 for (const f of g.fields) {
175 const inp = this.inputs.get(f.key) as HTMLInputElement;
176 inp.value = formatNumber((this.params[f.key] as number) / f.scale);
177 }
178 }
179 for (const k of selectKeys) (this.inputs.get(k) as HTMLSelectElement).value = String(this.params[k]);
180 this.updateNotes();
181 }
183 /** Enables or disables the fields that cannot change while a simulation runs. */
184 setEnabled(enabled: boolean): void {
185 for (const [key, el] of this.inputs) if (!this.liveKeys.has(key)) el.disabled = !enabled;
186 }
188 /** Applies URL parameters: any SimParams key in SI units (booleans as 0/1 or false/true). Returns the keys that were set. */
189 applyUrl(q: URLSearchParams): (keyof SimParams)[] {
190 const set: (keyof SimParams)[] = [];
191 for (const [k, v] of q) {
192 if (!(k in this.params)) continue;
193 const key = k as keyof SimParams;
194 if (selectKeys.includes(key)) {
195 (this.params as unknown as Record<string, boolean>)[key] = v === "1" || v === "true";
196 set.push(key);
197 } else {
198 const num = parseFloat(v);
199 if (isFinite(num)) {
200 (this.params as unknown as Record<string, number>)[key] = num;
201 set.push(key);
202 }
203 }
204 }
205 this.refresh();
206 return set;
207 }
209 /** The plate the notes describe (the design evaluated at the current spacing), or null while the script has an error. */
210 setPlate(plate: Plate | null): void {
211 this.plate = plate;
212 this.updateNotes();
213 }
215 /** Rewrites the derived-quantity notes; call after a change made outside the form (the recorded duration). */
216 updateNotes(): void {
217 const p = this.params;
218 const pl = this.plate;
219 if (this.strikeNote) {
220 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.`;
221 }
222 if (this.gridNote) {
223 if (!pl) {
224 this.gridNote.textContent = "The grid follows the design's bounding box at the chosen spacing.";
225 return;
226 }
227 const g = pl.grid;
228 const hr = thicknessRange(pl);
229 const f3 = dispersionFrequency(pl.mat, hr.mean, g, 0.03), f10 = dispersionFrequency(pl.mat, hr.mean, g, 0.1);
230 const s = Math.max(1, p.inPlaneMass);
231 // The power iteration takes a moment, so its result is kept until the spacing, the material, the thickness, or the factor changes.
232 const key = [g.hx, g.hy, pl.mat.E, pl.mat.nu, pl.mat.rho, hr.max, s].join("|");
233 if (!this.omegaCache || this.omegaCache.key !== key) this.omegaCache = { key, omega: maxAngularFrequency(g, pl.mat, hr.max, s) };
234 const stepsPerSample = Math.ceil(1 / (p.fs * ((CFL * 2) / this.omegaCache.omega)));
235 const dt = 1 / (p.fs * stepsPerSample);
236 const cp = Math.sqrt(membraneStiffness(pl.mat, hr.mean) / (pl.mat.rho * hr.mean) / s);
237 const steps = Math.round(p.duration / dt);
238 const t = (hz: number) => t60(pl.mat, hr.mean, pl.loss, 2 * Math.PI * hz).toFixed(2);
239 this.gridNote.textContent =
240 `${g.nx} × ${g.ny} grid, ${countInside(g)} nodes in the plate, ${(g.hx * 1e3).toFixed(2)} × ${(g.hy * 1e3).toFixed(2)} mm. ` +
241 `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). ` +
242 `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. ` +
243 `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. ` +
244 `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.` +
245 (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.");
246 }
247 }
248}
250export { defaultParams };