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