/ proof-of-concept / steelsim31
Sign in
proof-of-concept / steelsim31
steelsim31 / src / ui / form.ts
334 lines · 13.1 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 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; // "size" means the shared width/height unit
23 scale: number; // form value = SI value / scale
24 step?: number;
25 live?: boolean; // may be changed while a simulation runs
26 only?: Excitation; // shown only for this excitation
29interface SelectSpec {
30 key: keyof PhysParams;
31 label: string;
32 options: [string, string][];
35interface Group {
36 title: string;
37 open: boolean;
38 selects?: SelectSpec[]; // shown at the top of the group
39 fields: FieldSpec[];
40 note?: string | "grid" | "damping";
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",
68 open: true,
69 selects: [{ key: "excitation", label: "Type", options: [["push", "Push a corner and release"], ["strike", "Drumstick strike"]] }],
70 fields: [
71 { key: "pushAmount", label: "Push amount", unit: "mm", scale: 1e-3, step: 0.5, only: "push" },
72 { key: "fingerRadius", label: "Finger radius", unit: "mm", scale: 1e-3, step: 1, only: "push" },
73 { key: "rampTime", label: "Push ramp", unit: "s", scale: 1, step: 0.01, only: "push" },
74 { key: "holdMax", label: "Max settle time", unit: "s", scale: 1, step: 0.1, only: "push" },
75 { key: "releaseTime", label: "Release time", unit: "ms", scale: 1e-3, step: 1, only: "push" },
76 { key: "settleSigma0", label: "Settle damping σ₀", unit: "1/s", scale: 1, step: 10, only: "push" },
77 { key: "strikeFracX", label: "Strike x (fraction of width)", unit: "", scale: 1, step: 0.05, only: "strike" },
78 { key: "strikeFracY", label: "Strike y (fraction of height)", unit: "", scale: 1, step: 0.05, only: "strike" },
79 { key: "strikeVelocity", label: "Stick speed", unit: "m/s", scale: 1, step: 0.5, only: "strike" },
80 { key: "stickMass", label: "Stick mass", unit: "g", scale: 1e-3, step: 5, only: "strike" },
81 { key: "contactStiffness", label: "Contact stiffness K", unit: "N/mᵅ", scale: 1, step: 1e8, only: "strike" },
82 { key: "contactExponent", label: "Contact exponent α", unit: "", scale: 1, step: 0.1, only: "strike" },
83 { key: "contactRadius", label: "Tip radius", unit: "mm", scale: 1e-3, step: 1, only: "strike" },
84 ],
85 },
86 {
87 title: "Losses",
88 open: true,
89 fields: [
90 { key: "sigma0", label: "σ₀ (frequency independent)", unit: "1/s", scale: 1, step: 0.1, live: true },
91 { key: "sigma1", label: "σ₁ (∝ frequency)", unit: "m²/s", scale: 1, step: 1e-5, live: true },
92 { key: "sigmaIn", label: "In-plane loss", unit: "1/s", scale: 1, step: 5, live: true },
93 ],
94 note: "damping",
95 },
96 {
97 title: "Listener",
98 open: true,
99 fields: [
100 { key: "listenerDist", label: "Distance", unit: "m", scale: 1, step: 0.1, live: true },
101 { key: "listenerAzimuth", label: "Azimuth", unit: "deg", scale: 1, step: 5, live: true },
102 { key: "listenerElevation", label: "Elevation", unit: "deg", scale: 1, step: 5, live: true },
103 ],
104 },
105 {
106 title: "Numerics",
107 open: false,
108 selects: [{ key: "rigidProjection", label: "Rigid-body projection while ringing", options: [["false", "Off"], ["true", "On (every 32 steps)"]] }],
109 fields: [
110 { key: "spacing", label: "Grid spacing", unit: "mm", scale: 1e-3, step: 0.5 },
111 { key: "fs", label: "Audio sample rate", unit: "Hz", scale: 1, step: 100 },
112 { key: "fps", label: "Movie frame rate", unit: "fps", scale: 1, step: 10 },
113 { key: "settleKE", label: "Settled below KE", unit: "J", scale: 1, step: 1e-9 },
114 ],
115 note: "The excitation forces are self-equilibrated, so the projection only removes rounding drift of the six rigid modes. It is always applied while the sheet is held.",
116 },
117];
119const SIZE_SCALE: Record<SizeUnit, number> = { in: 0.0254, mm: 1e-3 };
120const selectKeys: (keyof PhysParams)[] = ["shape", "bowShape", "excitation", "rigidProjection"];
121const selectValues: Record<string, string[]> = {
122 shape: ["rectangle", "ellipse"],
123 bowShape: ["none", "width", "height", "dish"],
124 excitation: ["push", "strike"],
125 rigidProjection: ["false", "true"],
126};
128/** Parses a select value (URL or form) into the parameter's type. */
129function parseSelect(key: keyof PhysParams, v: string): string | boolean {
130 return key === "rigidProjection" ? v === "true" || v === "1" : v;
133export function formatNumber(v: number): string {
134 if (v === 0) return "0";
135 const a = Math.abs(v);
136 if (a >= 1e5 || a < 1e-3) return v.toExponential(3);
137 return String(+v.toPrecision(6));
140export class ParamForm {
141 readonly params: PhysParams;
142 sizeUnit: SizeUnit = "in";
143 /** Called after any change; `live` tells whether the field may change during a run. */
144 onChange: (key: keyof PhysParams, live: boolean) => void = () => {};
146 private inputs = new Map<keyof PhysParams, HTMLInputElement | HTMLSelectElement>();
147 private sizeUnitSelect = document.createElement("select");
148 private sizeUnitLabels: HTMLElement[] = [];
149 private conditional: { el: HTMLElement; only: Excitation }[] = [];
150 private gridNote: HTMLElement | null = null;
151 private dampingNote: HTMLElement | null = null;
152 private liveKeys = new Set<keyof PhysParams>();
154 constructor(root: HTMLElement, params: PhysParams) {
155 this.params = params;
156 for (const g of groups) {
157 const det = document.createElement("details");
158 det.open = g.open;
159 const sum = document.createElement("summary");
160 sum.textContent = g.title;
161 det.appendChild(sum);
162 for (const s of g.selects ?? []) det.appendChild(this.selectRow(s));
163 for (const f of g.fields) {
164 const row = this.fieldRow(f);
165 if (f.only) this.conditional.push({ el: row, only: f.only });
166 if (f.live) this.liveKeys.add(f.key);
167 det.appendChild(row);
168 }
169 if (g.note) {
170 const note = document.createElement("p");
171 note.className = "sub";
172 if (g.note === "grid") this.gridNote = note;
173 else if (g.note === "damping") this.dampingNote = note;
174 else note.textContent = g.note;
175 det.appendChild(note);
176 }
177 root.appendChild(det);
178 }
179 this.updateVisibility();
180 this.updateNotes();
181 }
183 private selectRow(s: SelectSpec): HTMLElement {
184 const row = document.createElement("div");
185 row.className = "field";
186 const l = document.createElement("label");
187 l.textContent = s.label;
188 const sel = document.createElement("select");
189 for (const [v, t] of s.options) {
190 const o = document.createElement("option");
191 o.value = v; o.textContent = t;
192 sel.appendChild(o);
193 }
194 sel.value = String(this.params[s.key]);
195 sel.addEventListener("change", () => {
196 (this.params as unknown as Record<string, string | boolean>)[s.key] = parseSelect(s.key, sel.value);
197 this.updateVisibility();
198 this.updateNotes();
199 this.onChange(s.key, false);
200 });
201 row.append(l, sel, document.createElement("span"));
202 this.inputs.set(s.key, sel);
203 return row;
204 }
206 private fieldRow(f: FieldSpec): HTMLElement {
207 const row = document.createElement("div");
208 row.className = "field";
209 const label = document.createElement("label");
210 label.textContent = f.label;
211 const inp = document.createElement("input");
212 inp.type = "number";
213 inp.step = String(f.step ?? 1);
214 inp.value = formatNumber((this.params[f.key] as number) / this.scale(f));
215 inp.addEventListener("change", () => {
216 const v = parseFloat(inp.value);
217 if (!isFinite(v)) return;
218 (this.params as unknown as Record<string, number>)[f.key] = v * this.scale(f);
219 this.updateNotes();
220 this.onChange(f.key, !!f.live);
221 });
222 this.inputs.set(f.key, inp);
223 const unit = document.createElement("span");
224 unit.className = "unit";
225 if (f.unit === "size") {
226 // Width and height share one unit selector (inches by default), shown on the first of them.
227 const sel = this.sizeUnitSelect;
228 if (sel.options.length === 0) {
229 for (const u of ["in", "mm"] as SizeUnit[]) {
230 const o = document.createElement("option");
231 o.value = u; o.textContent = u;
232 sel.appendChild(o);
233 }
234 sel.value = this.sizeUnit;
235 sel.className = "unit-select";
236 sel.addEventListener("change", () => {
237 this.sizeUnit = sel.value as SizeUnit;
238 for (const el of this.sizeUnitLabels) el.textContent = this.sizeUnit;
239 this.refresh();
240 });
241 row.append(label, inp, sel);
242 return row;
243 }
244 unit.textContent = this.sizeUnit;
245 this.sizeUnitLabels.push(unit);
246 } else {
247 unit.textContent = f.unit;
248 }
249 row.append(label, inp, unit);
250 return row;
251 }
253 private scale(f: FieldSpec): number {
254 return f.unit === "size" ? SIZE_SCALE[this.sizeUnit] : f.scale;
255 }
257 /** Shows the fields of the selected excitation only. */
258 private updateVisibility(): void {
259 for (const c of this.conditional) c.el.hidden = c.only !== this.params.excitation;
260 }
262 /** Rewrites every input from the current parameter values. */
263 refresh(): void {
264 for (const g of groups) {
265 for (const f of g.fields) {
266 const inp = this.inputs.get(f.key) as HTMLInputElement;
267 inp.value = formatNumber((this.params[f.key] as number) / this.scale(f));
268 }
269 }
270 for (const k of selectKeys) (this.inputs.get(k) as HTMLSelectElement).value = String(this.params[k]);
271 this.updateVisibility();
272 this.updateNotes();
273 }
275 /** Enables or disables the fields that cannot change while a simulation runs. */
276 setEnabled(enabled: boolean): void {
277 for (const [key, el] of this.inputs) if (!this.liveKeys.has(key)) el.disabled = !enabled;
278 }
280 /** Applies URL parameters: any PhysParams key in SI units (booleans as 0/1 or false/true). Returns the keys that were set. */
281 applyUrl(q: URLSearchParams): (keyof PhysParams)[] {
282 const set: (keyof PhysParams)[] = [];
283 for (const [k, v] of q) {
284 if (!(k in this.params)) continue;
285 const key = k as keyof PhysParams;
286 if (selectKeys.includes(key)) {
287 const norm = v === "1" ? "true" : v === "0" ? "false" : v;
288 if (selectValues[key].includes(norm)) {
289 (this.params as unknown as Record<string, string | boolean>)[key] = parseSelect(key, norm);
290 set.push(key);
291 }
292 } else {
293 const num = parseFloat(v);
294 if (isFinite(num)) {
295 (this.params as unknown as Record<string, number>)[key] = num;
296 set.push(key);
297 }
298 }
299 }
300 this.refresh();
301 return set;
302 }
304 gridInfo(): { grid: Grid; inside: number } {
305 const grid = makeGrid(this.params);
306 return { grid, inside: countInside(grid) };
307 }
309 private updateNotes(): void {
310 const p = this.params;
311 if (this.gridNote) {
312 const { grid, inside } = this.gridInfo();
313 const m = makeMaterial(p);
314 const f3 = dispersionFrequency(m, grid, 0.03), f10 = dispersionFrequency(m, grid, 0.1);
315 const outline = p.shape === "ellipse" ? "ellipse with a stepped edge" : "rectangle";
316 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).`;
317 }
318 if (this.dampingNote) {
319 const m = makeMaterial(p);
320 const f = (hz: number) => t60(m, p, 2 * Math.PI * hz).toFixed(2);
321 this.dampingNote.textContent = `T60: ${f(100)} s at 100 Hz, ${f(1000)} s at 1 kHz, ${f(5000)} s at 5 kHz.`;
322 }
323 }
325 get excitation(): Excitation {
326 return this.params.excitation;
327 }
328 get shape(): PlateShape {
329 return this.params.shape;
330 }
331 get bowShape(): BowShape {
332 return this.params.bowShape;
333 }
↑↓ move↵openescclose