/ proof-of-concept / steelsim31
Sign in
proof-of-concept / steelsim31
steelsim31 / src / ui / form.ts
395 lines · 16.7 KBBlameHistoryRaw
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 hitSchedule,
9 type PhysParams,
10 type PlateShape,
11 countInside,
12 dispersionFrequency,
13 makeGrid,
14 makeMaterial,
15 t60,
16 CFL,
17} from "../params.ts";
18import { maxAngularFrequency } from "../sim/cpuRef.ts";
20export type SizeUnit = "in" | "mm";
22interface FieldSpec {
23 key: keyof PhysParams;
24 label: string;
25 unit: string; // "size" means the shared width/height unit
26 scale: number; // form value = SI value / scale
27 step?: number;
28 live?: boolean; // may be changed while a simulation runs
29 show?: (p: PhysParams) => boolean; // shown only when this holds
32interface SelectSpec {
33 key: keyof PhysParams;
34 label: string;
35 options: [string, string][];
36 show?: (p: PhysParams) => boolean;
39interface Group {
40 title: string;
41 open: boolean;
42 selects?: SelectSpec[]; // shown at the top of the group
43 fields: FieldSpec[];
44 note?: string | "grid" | "damping" | "numerics" | "hits";
47const push = (p: PhysParams) => p.excitation === "push";
48const strike = (p: PhysParams) => p.excitation === "strike";
49const series = (p: PhysParams) => strike(p) && p.hitSequence !== "single";
50const graded = (p: PhysParams) => strike(p) && (p.hitSequence === "crescendo" || p.hitSequence === "decrescendo");
52const groups: Group[] = [
53 {
54 title: "Sheet",
55 open: true,
56 selects: [{ key: "shape", label: "Outline", options: [["rectangle", "Rectangle"], ["ellipse", "Ellipse (inscribed)"]] }],
57 fields: [
58 { key: "Lx", label: "Width", unit: "size", scale: 1, step: 0.5 },
59 { key: "Ly", label: "Height", unit: "size", scale: 1, step: 0.5 },
60 { key: "h", label: "Thickness", unit: "mm", scale: 1e-3, step: 0.01 },
61 { key: "E", label: "Young's modulus", unit: "GPa", scale: 1e9, step: 1 },
62 { key: "nu", label: "Poisson ratio", unit: "", scale: 1, step: 0.01 },
63 { key: "rho", label: "Density", unit: "kg/m³", scale: 1, step: 10 },
64 { key: "holeInset", label: "Hole inset", unit: "mm", scale: 1e-3, step: 1 },
65 ],
66 note: "grid",
67 },
68 {
69 title: "Initial shape",
70 open: true,
71 selects: [{ key: "bowShape", label: "Bow shape", options: [["width", "Curved across the width"], ["height", "Curved across the height"], ["dish", "Dish (curved both ways)"], ["none", "Flat"]] }],
72 fields: [{ key: "bowAmplitude", label: "Bow (center sag)", unit: "mm", scale: 1e-3, step: 0.5 }],
73 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.",
74 },
75 {
76 title: "Excitation",
77 open: true,
78 selects: [
79 { key: "excitation", label: "Type", options: [["strike", "Drumstick strike"], ["push", "Push a corner and release"]] },
80 { key: "hitSequence", label: "Hits", options: [["single", "One hit"], ["steady", "Series, same speed"], ["crescendo", "Series, soft to loud"], ["decrescendo", "Series, loud to soft"]], show: strike },
81 ],
82 fields: [
83 { key: "pushAmount", label: "Push amount", unit: "mm", scale: 1e-3, step: 0.5, show: push },
84 { key: "fingerRadius", label: "Finger radius", unit: "mm", scale: 1e-3, step: 1, show: push },
85 { key: "rampTime", label: "Push ramp", unit: "s", scale: 1, step: 0.01, show: push },
86 { key: "holdMax", label: "Max settle time", unit: "s", scale: 1, step: 0.1, show: push },
87 { key: "releaseTime", label: "Release time", unit: "ms", scale: 1e-3, step: 1, show: push },
88 { key: "settleSigma0", label: "Settle damping σ₀", unit: "1/s", scale: 1, step: 10, show: push },
89 { key: "strikeFracX", label: "Strike x (fraction of width)", unit: "", scale: 1, step: 0.05, show: strike },
90 { key: "strikeFracY", label: "Strike y (fraction of height)", unit: "", scale: 1, step: 0.05, show: strike },
91 { key: "strikeVelocity", label: "Stick speed", unit: "m/s", scale: 1, step: 0.5, show: strike },
92 { key: "strikeVelocityMin", label: "Softest stick speed", unit: "m/s", scale: 1, step: 0.5, show: graded },
93 { key: "hitCount", label: "Number of hits", unit: "", scale: 1, step: 1, show: series },
94 { key: "hitInterval", label: "Time between hits", unit: "s", scale: 1, step: 0.1, show: series },
95 { key: "stickMass", label: "Stick mass", unit: "g", scale: 1e-3, step: 5, show: strike },
96 { key: "contactStiffness", label: "Contact stiffness K", unit: "N/mᵅ", scale: 1, step: 1e8, show: strike },
97 { key: "contactExponent", label: "Contact exponent α", unit: "", scale: 1, step: 0.1, show: strike },
98 { key: "contactRadius", label: "Tip radius", unit: "mm", scale: 1e-3, step: 1, show: strike },
99 ],
100 note: "hits",
101 },
102 {
103 title: "Losses",
104 open: true,
105 fields: [
106 { key: "sigma0", label: "σ₀ (frequency independent)", unit: "1/s", scale: 1, step: 0.1, live: true },
107 { key: "sigma1", label: "σ₁ (∝ frequency)", unit: "m²/s", scale: 1, step: 1e-5, live: true },
108 { key: "sigmaIn", label: "In-plane loss", unit: "1/s", scale: 1, step: 5, live: true },
109 ],
110 note: "damping",
111 },
112 {
113 title: "Listener",
114 open: true,
115 fields: [
116 { key: "listenerDist", label: "Distance", unit: "m", scale: 1, step: 0.1, live: true },
117 { key: "listenerAzimuth", label: "Azimuth", unit: "deg", scale: 1, step: 5, live: true },
118 { key: "listenerElevation", label: "Elevation", unit: "deg", scale: 1, step: 5, live: true },
119 ],
120 },
121 {
122 title: "Numerics",
123 open: false,
124 selects: [{ key: "rigidProjection", label: "Rigid-body projection while ringing", options: [["false", "Off"], ["true", "On (every 32 steps)"]] }],
125 fields: [
126 { key: "spacing", label: "Grid spacing", unit: "mm", scale: 1e-3, step: 0.5 },
127 { key: "inPlaneMass", label: "In-plane mass factor", unit: "", scale: 1, step: 1 },
128 { key: "fs", label: "Audio sample rate", unit: "Hz", scale: 1, step: 100 },
129 { key: "fps", label: "Movie frame rate", unit: "fps", scale: 1, step: 10 },
130 { key: "settleKE", label: "Settled below KE", unit: "J", scale: 1, step: 1e-9 },
131 ],
132 note: "numerics",
133 },
134];
136const SIZE_SCALE: Record<SizeUnit, number> = { in: 0.0254, mm: 1e-3 };
137const selectKeys: (keyof PhysParams)[] = ["shape", "bowShape", "excitation", "hitSequence", "rigidProjection"];
138const selectValues: Record<string, string[]> = {
139 shape: ["rectangle", "ellipse"],
140 bowShape: ["none", "width", "height", "dish"],
141 excitation: ["push", "strike"],
142 hitSequence: ["single", "steady", "crescendo", "decrescendo"],
143 rigidProjection: ["false", "true"],
144};
146/** Parses a select value (URL or form) into the parameter's type. */
147function parseSelect(key: keyof PhysParams, v: string): string | boolean {
148 return key === "rigidProjection" ? v === "true" || v === "1" : v;
151export function formatNumber(v: number): string {
152 if (v === 0) return "0";
153 const a = Math.abs(v);
154 if (a >= 1e5 || a < 1e-3) return v.toExponential(3);
155 return String(+v.toPrecision(6));
158export class ParamForm {
159 readonly params: PhysParams;
160 sizeUnit: SizeUnit = "in";
161 /** Called after any change; `live` tells whether the field may change during a run. */
162 onChange: (key: keyof PhysParams, live: boolean) => void = () => {};
164 private inputs = new Map<keyof PhysParams, HTMLInputElement | HTMLSelectElement>();
165 private sizeUnitSelect = document.createElement("select");
166 private sizeUnitLabels: HTMLElement[] = [];
167 private conditional: { el: HTMLElement; show: (p: PhysParams) => boolean }[] = [];
168 private gridNote: HTMLElement | null = null;
169 private hitsNote: HTMLElement | null = null;
170 private dampingNote: HTMLElement | null = null;
171 private numericsNote: HTMLElement | null = null;
172 private omegaCache: { key: string; omega: number } | null = null;
173 private liveKeys = new Set<keyof PhysParams>();
175 constructor(root: HTMLElement, params: PhysParams) {
176 this.params = params;
177 for (const g of groups) {
178 const det = document.createElement("details");
179 det.open = g.open;
180 const sum = document.createElement("summary");
181 sum.textContent = g.title;
182 det.appendChild(sum);
183 for (const s of g.selects ?? []) {
184 const row = this.selectRow(s);
185 if (s.show) this.conditional.push({ el: row, show: s.show });
186 det.appendChild(row);
187 }
188 for (const f of g.fields) {
189 const row = this.fieldRow(f);
190 if (f.show) this.conditional.push({ el: row, show: f.show });
191 if (f.live) this.liveKeys.add(f.key);
192 det.appendChild(row);
193 }
194 if (g.note) {
195 const note = document.createElement("p");
196 note.className = "sub";
197 if (g.note === "grid") this.gridNote = note;
198 else if (g.note === "hits") this.hitsNote = note;
199 else if (g.note === "damping") this.dampingNote = note;
200 else if (g.note === "numerics") this.numericsNote = note;
201 else note.textContent = g.note;
202 det.appendChild(note);
203 }
204 root.appendChild(det);
205 }
206 this.updateVisibility();
207 this.updateNotes();
208 }
210 private selectRow(s: SelectSpec): HTMLElement {
211 const row = document.createElement("div");
212 row.className = "field";
213 const l = document.createElement("label");
214 l.textContent = s.label;
215 const sel = document.createElement("select");
216 for (const [v, t] of s.options) {
217 const o = document.createElement("option");
218 o.value = v; o.textContent = t;
219 sel.appendChild(o);
220 }
221 sel.value = String(this.params[s.key]);
222 sel.addEventListener("change", () => {
223 (this.params as unknown as Record<string, string | boolean>)[s.key] = parseSelect(s.key, sel.value);
224 this.updateVisibility();
225 this.updateNotes();
226 this.onChange(s.key, false);
227 });
228 row.append(l, sel, document.createElement("span"));
229 this.inputs.set(s.key, sel);
230 return row;
231 }
233 private fieldRow(f: FieldSpec): HTMLElement {
234 const row = document.createElement("div");
235 row.className = "field";
236 const label = document.createElement("label");
237 label.textContent = f.label;
238 const inp = document.createElement("input");
239 inp.type = "number";
240 inp.step = String(f.step ?? 1);
241 inp.value = formatNumber((this.params[f.key] as number) / this.scale(f));
242 inp.addEventListener("change", () => {
243 const v = parseFloat(inp.value);
244 if (!isFinite(v)) return;
245 (this.params as unknown as Record<string, number>)[f.key] = v * this.scale(f);
246 this.updateVisibility();
247 this.updateNotes();
248 this.onChange(f.key, !!f.live);
249 });
250 this.inputs.set(f.key, inp);
251 const unit = document.createElement("span");
252 unit.className = "unit";
253 if (f.unit === "size") {
254 // Width and height share one unit selector (inches by default), shown on the first of them.
255 const sel = this.sizeUnitSelect;
256 if (sel.options.length === 0) {
257 for (const u of ["in", "mm"] as SizeUnit[]) {
258 const o = document.createElement("option");
259 o.value = u; o.textContent = u;
260 sel.appendChild(o);
261 }
262 sel.value = this.sizeUnit;
263 sel.className = "unit-select";
264 sel.addEventListener("change", () => {
265 this.sizeUnit = sel.value as SizeUnit;
266 for (const el of this.sizeUnitLabels) el.textContent = this.sizeUnit;
267 this.refresh();
268 });
269 row.append(label, inp, sel);
270 return row;
271 }
272 unit.textContent = this.sizeUnit;
273 this.sizeUnitLabels.push(unit);
274 } else {
275 unit.textContent = f.unit;
276 }
277 row.append(label, inp, unit);
278 return row;
279 }
281 private scale(f: FieldSpec): number {
282 return f.unit === "size" ? SIZE_SCALE[this.sizeUnit] : f.scale;
283 }
285 /** Shows the fields that apply to the selected excitation and hit sequence only. */
286 private updateVisibility(): void {
287 for (const c of this.conditional) c.el.hidden = !c.show(this.params);
288 }
290 /** Rewrites every input from the current parameter values. */
291 refresh(): void {
292 for (const g of groups) {
293 for (const f of g.fields) {
294 const inp = this.inputs.get(f.key) as HTMLInputElement;
295 inp.value = formatNumber((this.params[f.key] as number) / this.scale(f));
296 }
297 }
298 for (const k of selectKeys) (this.inputs.get(k) as HTMLSelectElement).value = String(this.params[k]);
299 this.updateVisibility();
300 this.updateNotes();
301 }
303 /** Enables or disables the fields that cannot change while a simulation runs. */
304 setEnabled(enabled: boolean): void {
305 for (const [key, el] of this.inputs) if (!this.liveKeys.has(key)) el.disabled = !enabled;
306 }
308 /** Applies URL parameters: any PhysParams key in SI units (booleans as 0/1 or false/true). Returns the keys that were set. */
309 applyUrl(q: URLSearchParams): (keyof PhysParams)[] {
310 const set: (keyof PhysParams)[] = [];
311 for (const [k, v] of q) {
312 if (!(k in this.params)) continue;
313 const key = k as keyof PhysParams;
314 if (selectKeys.includes(key)) {
315 const norm = v === "1" ? "true" : v === "0" ? "false" : v;
316 if (selectValues[key].includes(norm)) {
317 (this.params as unknown as Record<string, string | boolean>)[key] = parseSelect(key, norm);
318 set.push(key);
319 }
320 } else {
321 const num = parseFloat(v);
322 if (isFinite(num)) {
323 (this.params as unknown as Record<string, number>)[key] = num;
324 set.push(key);
325 }
326 }
327 }
328 this.refresh();
329 return set;
330 }
332 gridInfo(): { grid: Grid; inside: number } {
333 const grid = makeGrid(this.params);
334 return { grid, inside: countInside(grid) };
335 }
337 /** Rewrites the derived-quantity notes; call after a change made outside the form (the recorded duration). */
338 updateNotes(): void {
339 const p = this.params;
340 if (this.hitsNote) {
341 if (p.excitation !== "strike") {
342 this.hitsNote.textContent = "";
343 } else {
344 const hits = hitSchedule(p);
345 const last = hits[hits.length - 1];
346 const decay = p.duration - last.t;
347 const speeds = hits.length <= 8 ? hits.map((h) => h.v.toFixed(1)).join(", ") : `${hits[0].v.toFixed(1)} ... ${last.v.toFixed(1)}`;
348 this.hitsNote.textContent =
349 hits.length === 1
350 ? `One hit at ${speeds} m/s; the recorded duration of ${p.duration} s starts at the impact.`
351 : `${hits.length} hits at ${speeds} m/s, the last at ${last.t.toFixed(2)} s. ` +
352 (decay > 0
353 ? `The recorded duration of ${p.duration} s leaves ${decay.toFixed(2)} s of ring after the last hit.`
354 : `The recorded duration of ${p.duration} s ends before the last hit; raise it to hear the whole series.`);
355 }
356 }
357 if (this.gridNote) {
358 const { grid, inside } = this.gridInfo();
359 const m = makeMaterial(p);
360 const f3 = dispersionFrequency(m, grid, 0.03), f10 = dispersionFrequency(m, grid, 0.1);
361 const outline = p.shape === "ellipse" ? "ellipse with a stepped edge" : "rectangle";
362 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).`;
363 }
364 if (this.dampingNote) {
365 const m = makeMaterial(p);
366 const f = (hz: number) => t60(m, p, 2 * Math.PI * hz).toFixed(2);
367 this.dampingNote.textContent = `T60: ${f(100)} s at 100 Hz, ${f(1000)} s at 1 kHz, ${f(5000)} s at 5 kHz.`;
368 }
369 if (this.numericsNote) {
370 const { grid } = this.gridInfo();
371 const m = makeMaterial(p);
372 const s = Math.max(1, p.inPlaneMass);
373 // The power iteration takes a moment, so its result is kept until the spacing, the material, or the factor changes.
374 const key = [grid.hx, grid.hy, m.D, m.C, m.G, m.nu, m.rhoh, s].join("|");
375 if (!this.omegaCache || this.omegaCache.key !== key) this.omegaCache = { key, omega: maxAngularFrequency(grid, m, s) };
376 const stepsPerSample = Math.ceil(1 / (p.fs * ((CFL * 2) / this.omegaCache.omega)));
377 const dt = 1 / (p.fs * stepsPerSample);
378 const cp = Math.sqrt(m.C / m.rhoh / s);
379 this.numericsNote.textContent =
380 `Time step ${(dt * 1e6).toFixed(2)} µs (${stepsPerSample} per sample), set by the in-plane wave speed of ${cp.toFixed(0)} m/s. ` +
381 `An in-plane mass factor above 1 slows the in-plane waves by its square root and lengthens the step by up to the same factor, at the cost of lowering the in-plane modes, which the bow couples to the sound. ` +
382 `The excitation forces are self-equilibrated, so the projection while ringing only removes rounding drift of the six rigid modes; it is always applied while the sheet is held.`;
383 }
384 }
386 get excitation(): Excitation {
387 return this.params.excitation;
388 }
389 get shape(): PlateShape {
390 return this.params.shape;
391 }
392 get bowShape(): BowShape {
393 return this.params.bowShape;
394 }
↑↓ move↵openescclose