/ proof-of-concept / steelsim31
Sign in
proof-of-concept / steelsim31
steelsim31 / src / ui / form.ts
612 lines · 25.0 KBBlameHistoryRaw
1// Parameter form: numeric fields in convenient units, a few selects, the
2// editable list of initial-shape features, and the derived-quantity notes.
3// Everything is stored in SI in `params`.
5import {
6 type Excitation,
7 type Grid,
8 hitSchedule,
9 type PhysParams,
10 type PlateShape,
11 countInside,
12 defaultParams,
13 dispersionFrequency,
14 makeGrid,
15 makeMaterial,
16 t60,
17 CFL,
18} from "../params.ts";
19import { maxAngularFrequency } from "../sim/cpuRef.ts";
20import {
21 FEATURE_SPECS,
22 FEATURE_TYPES,
23 type FeatureType,
24 type ShapeFeature,
25 encodeFeatures,
26 initialShape,
27 legacyFeatures,
28 newFeature,
29 parseFeatures,
30 randomFeatures,
31 shapeStats,
32} from "../shape.ts";
34export type SizeUnit = "in" | "mm";
36interface FieldSpec {
37 key: keyof PhysParams;
38 label: string;
39 unit: string; // "size" means the shared width/height unit
40 scale: number; // form value = SI value / scale
41 step?: number;
42 live?: boolean; // may be changed while a simulation runs
43 show?: (p: PhysParams) => boolean; // shown only when this holds
46interface SelectSpec {
47 key: keyof PhysParams;
48 label: string;
49 options: [string, string][];
50 show?: (p: PhysParams) => boolean;
53interface Group {
54 title: string;
55 open: boolean;
56 selects?: SelectSpec[]; // shown at the top of the group
57 fields: FieldSpec[];
58 features?: boolean; // the group holds the initial-shape feature list
59 note?: string | "grid" | "damping" | "numerics" | "hits" | "shape";
62const push = (p: PhysParams) => p.excitation === "push";
63const strike = (p: PhysParams) => p.excitation === "strike";
64const series = (p: PhysParams) => strike(p) && p.hitSequence !== "single";
65const graded = (p: PhysParams) => strike(p) && (p.hitSequence === "crescendo" || p.hitSequence === "decrescendo");
67/** Slope above which the shallow-shell treatment of the initial shape is doubtful. */
68const SLOPE_LIMIT = 0.1;
70const groups: Group[] = [
71 {
72 title: "Sheet",
73 open: true,
74 selects: [{ key: "shape", label: "Outline", options: [["rectangle", "Rectangle"], ["ellipse", "Ellipse (inscribed)"]] }],
75 fields: [
76 { key: "Lx", label: "Width", unit: "size", scale: 1, step: 0.5 },
77 { key: "Ly", label: "Height", unit: "size", scale: 1, step: 0.5 },
78 { key: "h", label: "Thickness", unit: "mm", scale: 1e-3, step: 0.01 },
79 { key: "E", label: "Young's modulus", unit: "GPa", scale: 1e9, step: 1 },
80 { key: "nu", label: "Poisson ratio", unit: "", scale: 1, step: 0.01 },
81 { key: "rho", label: "Density", unit: "kg/m³", scale: 1, step: 10 },
82 { key: "holeInset", label: "Hole inset", unit: "mm", scale: 1e-3, step: 1 },
83 ],
84 note: "grid",
85 },
86 {
87 title: "Initial shape",
88 open: true,
89 fields: [],
90 features: true,
91 note: "shape",
92 },
93 {
94 title: "Excitation",
95 open: true,
96 selects: [
97 { key: "excitation", label: "Type", options: [["strike", "Drumstick strike"], ["push", "Push a corner and release"]] },
98 { key: "hitSequence", label: "Hits", options: [["single", "One hit"], ["steady", "Series, same speed"], ["crescendo", "Series, soft to loud"], ["decrescendo", "Series, loud to soft"]], show: strike },
99 ],
100 fields: [
101 { key: "pushAmount", label: "Push amount", unit: "mm", scale: 1e-3, step: 0.5, show: push },
102 { key: "fingerRadius", label: "Finger radius", unit: "mm", scale: 1e-3, step: 1, show: push },
103 { key: "rampTime", label: "Push ramp", unit: "s", scale: 1, step: 0.01, show: push },
104 { key: "holdMax", label: "Max settle time", unit: "s", scale: 1, step: 0.1, show: push },
105 { key: "releaseTime", label: "Release time", unit: "ms", scale: 1e-3, step: 1, show: push },
106 { key: "settleSigma0", label: "Settle damping σ₀", unit: "1/s", scale: 1, step: 10, show: push },
107 { key: "strikeFracX", label: "Strike x (fraction of width)", unit: "", scale: 1, step: 0.05, show: strike },
108 { key: "strikeFracY", label: "Strike y (fraction of height)", unit: "", scale: 1, step: 0.05, show: strike },
109 { key: "strikeVelocity", label: "Stick speed", unit: "m/s", scale: 1, step: 0.5, show: strike },
110 { key: "strikeVelocityMin", label: "Softest stick speed", unit: "m/s", scale: 1, step: 0.5, show: graded },
111 { key: "hitCount", label: "Number of hits", unit: "", scale: 1, step: 1, show: series },
112 { key: "hitInterval", label: "Time between hits", unit: "s", scale: 1, step: 0.1, show: series },
113 { key: "stickMass", label: "Stick mass", unit: "g", scale: 1e-3, step: 5, show: strike },
114 { key: "contactStiffness", label: "Contact stiffness K", unit: "N/mᵅ", scale: 1, step: 1e8, show: strike },
115 { key: "contactExponent", label: "Contact exponent α", unit: "", scale: 1, step: 0.1, show: strike },
116 { key: "contactRadius", label: "Tip radius", unit: "mm", scale: 1e-3, step: 1, show: strike },
117 ],
118 note: "hits",
119 },
120 {
121 title: "Losses",
122 open: true,
123 fields: [
124 { key: "sigma0", label: "σ₀ (frequency independent)", unit: "1/s", scale: 1, step: 0.1, live: true },
125 { key: "sigma1", label: "σ₁ (∝ frequency)", unit: "m²/s", scale: 1, step: 1e-5, live: true },
126 { key: "sigmaIn", label: "In-plane loss", unit: "1/s", scale: 1, step: 5, live: true },
127 ],
128 note: "damping",
129 },
130 {
131 title: "Listener",
132 open: true,
133 fields: [
134 { key: "listenerDist", label: "Distance", unit: "m", scale: 1, step: 0.1, live: true },
135 { key: "listenerAzimuth", label: "Azimuth", unit: "deg", scale: 1, step: 5, live: true },
136 { key: "listenerElevation", label: "Elevation", unit: "deg", scale: 1, step: 5, live: true },
137 ],
138 },
139 {
140 title: "Numerics",
141 open: false,
142 selects: [{ key: "rigidProjection", label: "Rigid-body projection while ringing", options: [["false", "Off"], ["true", "On (every 32 steps)"]] }],
143 fields: [
144 { key: "spacing", label: "Grid spacing", unit: "mm", scale: 1e-3, step: 0.5 },
145 { key: "inPlaneMass", label: "In-plane mass factor", unit: "", scale: 1, step: 1 },
146 { key: "fs", label: "Audio sample rate", unit: "Hz", scale: 1, step: 100 },
147 { key: "fps", label: "Movie frame rate", unit: "fps", scale: 1, step: 10 },
148 { key: "settleKE", label: "Settled below KE", unit: "J", scale: 1, step: 1e-9 },
149 ],
150 note: "numerics",
151 },
152];
154const SIZE_SCALE: Record<SizeUnit, number> = { in: 0.0254, mm: 1e-3 };
155const selectKeys: (keyof PhysParams)[] = ["shape", "excitation", "hitSequence", "rigidProjection"];
156const selectValues: Record<string, string[]> = {
157 shape: ["rectangle", "ellipse"],
158 excitation: ["push", "strike"],
159 hitSequence: ["single", "steady", "crescendo", "decrescendo"],
160 rigidProjection: ["false", "true"],
161};
163/** Parses a select value (URL or form) into the parameter's type. */
164function parseSelect(key: keyof PhysParams, v: string): string | boolean {
165 return key === "rigidProjection" ? v === "true" || v === "1" : v;
168export function formatNumber(v: number): string {
169 if (v === 0) return "0";
170 const a = Math.abs(v);
171 if (a >= 1e5 || a < 1e-3) return v.toExponential(3);
172 return String(+v.toPrecision(6));
175/** A number in SI for a link: shortest exact-enough decimal. */
176function formatSI(v: number): string {
177 return v === 0 ? "0" : String(+v.toPrecision(10));
180export class ParamForm {
181 readonly params: PhysParams;
182 sizeUnit: SizeUnit = "in";
183 /** Height scale of the random shapes drawn by Randomize [m]. */
184 randomScale = 3e-3;
185 /** Called after any change; `live` tells whether the field may change during a run. */
186 onChange: (key: keyof PhysParams, live: boolean) => void = () => {};
188 private inputs = new Map<keyof PhysParams, HTMLInputElement | HTMLSelectElement>();
189 private sizeUnitSelect = document.createElement("select");
190 private sizeUnitLabels: HTMLElement[] = [];
191 private conditional: { el: HTMLElement; show: (p: PhysParams) => boolean }[] = [];
192 private gridNote: HTMLElement | null = null;
193 private hitsNote: HTMLElement | null = null;
194 private dampingNote: HTMLElement | null = null;
195 private numericsNote: HTMLElement | null = null;
196 private shapeNote: HTMLElement | null = null;
197 private featureList: HTMLElement | null = null;
198 private featureControls: (HTMLInputElement | HTMLSelectElement | HTMLButtonElement)[] = [];
199 private omegaCache: { key: string; omega: number } | null = null;
200 private liveKeys = new Set<keyof PhysParams>();
201 private enabled = true;
203 constructor(root: HTMLElement, params: PhysParams) {
204 this.params = params;
205 for (const g of groups) {
206 const det = document.createElement("details");
207 det.open = g.open;
208 const sum = document.createElement("summary");
209 sum.textContent = g.title;
210 det.appendChild(sum);
211 for (const s of g.selects ?? []) {
212 const row = this.selectRow(s);
213 if (s.show) this.conditional.push({ el: row, show: s.show });
214 det.appendChild(row);
215 }
216 for (const f of g.fields) {
217 const row = this.fieldRow(f);
218 if (f.show) this.conditional.push({ el: row, show: f.show });
219 if (f.live) this.liveKeys.add(f.key);
220 det.appendChild(row);
221 }
222 if (g.features) det.appendChild(this.featureSection());
223 if (g.note) {
224 const note = document.createElement("p");
225 note.className = "sub";
226 if (g.note === "grid") this.gridNote = note;
227 else if (g.note === "hits") this.hitsNote = note;
228 else if (g.note === "damping") this.dampingNote = note;
229 else if (g.note === "numerics") this.numericsNote = note;
230 else if (g.note === "shape") this.shapeNote = note;
231 else note.textContent = g.note;
232 det.appendChild(note);
233 }
234 root.appendChild(det);
235 }
236 this.updateVisibility();
237 this.updateNotes();
238 }
240 private selectRow(s: SelectSpec): HTMLElement {
241 const row = document.createElement("div");
242 row.className = "field";
243 const l = document.createElement("label");
244 l.textContent = s.label;
245 const sel = document.createElement("select");
246 for (const [v, t] of s.options) {
247 const o = document.createElement("option");
248 o.value = v; o.textContent = t;
249 sel.appendChild(o);
250 }
251 sel.value = String(this.params[s.key]);
252 sel.addEventListener("change", () => {
253 (this.params as unknown as Record<string, string | boolean>)[s.key] = parseSelect(s.key, sel.value);
254 this.updateVisibility();
255 this.updateNotes();
256 this.onChange(s.key, false);
257 });
258 row.append(l, sel, document.createElement("span"));
259 this.inputs.set(s.key, sel);
260 return row;
261 }
263 private fieldRow(f: FieldSpec): HTMLElement {
264 const row = document.createElement("div");
265 row.className = "field";
266 const label = document.createElement("label");
267 label.textContent = f.label;
268 const inp = document.createElement("input");
269 inp.type = "number";
270 inp.step = String(f.step ?? 1);
271 inp.value = formatNumber((this.params[f.key] as number) / this.scale(f));
272 inp.addEventListener("change", () => {
273 const v = parseFloat(inp.value);
274 if (!isFinite(v)) return;
275 (this.params as unknown as Record<string, number>)[f.key] = v * this.scale(f);
276 this.updateVisibility();
277 this.updateNotes();
278 this.onChange(f.key, !!f.live);
279 });
280 this.inputs.set(f.key, inp);
281 const unit = document.createElement("span");
282 unit.className = "unit";
283 if (f.unit === "size") {
284 // Width and height share one unit selector (inches by default), shown on the first of them.
285 const sel = this.sizeUnitSelect;
286 if (sel.options.length === 0) {
287 for (const u of ["in", "mm"] as SizeUnit[]) {
288 const o = document.createElement("option");
289 o.value = u; o.textContent = u;
290 sel.appendChild(o);
291 }
292 sel.value = this.sizeUnit;
293 sel.className = "unit-select";
294 sel.addEventListener("change", () => {
295 this.sizeUnit = sel.value as SizeUnit;
296 for (const el of this.sizeUnitLabels) el.textContent = this.sizeUnit;
297 this.refresh();
298 });
299 row.append(label, inp, sel);
300 return row;
301 }
302 unit.textContent = this.sizeUnit;
303 this.sizeUnitLabels.push(unit);
304 } else {
305 unit.textContent = f.unit;
306 }
307 row.append(label, inp, unit);
308 return row;
309 }
311 private scale(f: FieldSpec): number {
312 return f.unit === "size" ? SIZE_SCALE[this.sizeUnit] : f.scale;
313 }
315 // ---------- Initial-shape feature list ----------
317 /** The list of features, the add row, and the randomize row. */
318 private featureSection(): HTMLElement {
319 const wrap = document.createElement("div");
320 this.featureList = document.createElement("div");
321 wrap.appendChild(this.featureList);
323 const addRow = document.createElement("div");
324 addRow.className = "feature-row";
325 const typeSel = document.createElement("select");
326 for (const t of FEATURE_TYPES) {
327 const o = document.createElement("option");
328 o.value = t; o.textContent = FEATURE_SPECS[t].label;
329 typeSel.appendChild(o);
330 }
331 const btnAdd = document.createElement("button");
332 btnAdd.textContent = "Add";
333 btnAdd.addEventListener("click", () => {
334 this.params.features.push(newFeature(typeSel.value as FeatureType));
335 this.featuresChanged();
336 });
337 const btnFlat = document.createElement("button");
338 btnFlat.textContent = "Clear (flat)";
339 btnFlat.addEventListener("click", () => {
340 this.params.features = [];
341 this.featuresChanged();
342 });
343 addRow.append(typeSel, btnAdd, btnFlat);
344 wrap.appendChild(addRow);
346 const rndRow = document.createElement("div");
347 rndRow.className = "feature-row";
348 const btnRnd = document.createElement("button");
349 btnRnd.textContent = "Randomize";
350 btnRnd.title = "Draw a new random combination of features with heights of the order of the scale";
351 const scaleLabel = document.createElement("label");
352 scaleLabel.textContent = "scale";
353 const scaleInp = document.createElement("input");
354 scaleInp.type = "number";
355 scaleInp.step = "0.5";
356 scaleInp.value = formatNumber(this.randomScale * 1e3);
357 scaleInp.addEventListener("change", () => {
358 const v = parseFloat(scaleInp.value);
359 if (isFinite(v) && v > 0) this.randomScale = v * 1e-3;
360 });
361 const scaleUnit = document.createElement("span");
362 scaleUnit.className = "unit";
363 scaleUnit.textContent = "mm";
364 btnRnd.addEventListener("click", () => {
365 this.params.features = randomFeatures(this.params, this.randomScale);
366 this.featuresChanged();
367 });
368 rndRow.append(btnRnd, scaleLabel, scaleInp, scaleUnit);
369 wrap.appendChild(rndRow);
370 this.persistentFeatureControls = [typeSel, btnAdd, btnFlat, btnRnd, scaleInp];
371 this.renderFeatures();
372 return wrap;
373 }
375 private persistentFeatureControls: (HTMLInputElement | HTMLSelectElement | HTMLButtonElement)[] = [];
377 /** Rebuilds the feature rows from params.features. */
378 private renderFeatures(): void {
379 const list = this.featureList;
380 if (!list) return;
381 list.replaceChildren();
382 this.featureControls = [...this.persistentFeatureControls];
383 const features = this.params.features;
384 if (features.length === 0) {
385 const p = document.createElement("p");
386 p.className = "sub";
387 p.textContent = "Flat sheet (no features).";
388 list.appendChild(p);
389 }
390 features.forEach((feat, index) => {
391 const spec = FEATURE_SPECS[feat.type];
392 const box = document.createElement("div");
393 box.className = "feature";
394 box.title = spec.description;
395 const head = document.createElement("div");
396 head.className = "feature-head";
397 const title = document.createElement("span");
398 title.textContent = `${index + 1}. ${spec.label}`;
399 const btnRemove = document.createElement("button");
400 btnRemove.className = "small";
401 btnRemove.textContent = "Remove";
402 btnRemove.addEventListener("click", () => {
403 this.params.features.splice(index, 1);
404 this.featuresChanged();
405 });
406 head.append(title, btnRemove);
407 box.appendChild(head);
408 this.featureControls.push(btnRemove);
409 const rec = feat as unknown as Record<string, number | string>;
410 for (const fld of spec.fields) {
411 const row = document.createElement("div");
412 row.className = "field";
413 const label = document.createElement("label");
414 label.textContent = fld.label;
415 const unit = document.createElement("span");
416 unit.className = "unit";
417 unit.textContent = fld.unit;
418 if (fld.options) {
419 const sel = document.createElement("select");
420 for (const [v, t] of fld.options) {
421 const o = document.createElement("option");
422 o.value = v; o.textContent = t;
423 sel.appendChild(o);
424 }
425 sel.value = String(rec[fld.key]);
426 sel.addEventListener("change", () => {
427 rec[fld.key] = sel.value;
428 this.featuresChanged(false);
429 });
430 row.append(label, sel, unit);
431 this.featureControls.push(sel);
432 } else {
433 const inp = document.createElement("input");
434 inp.type = "number";
435 inp.step = String(fld.step);
436 inp.value = formatNumber((rec[fld.key] as number) / fld.scale);
437 inp.addEventListener("change", () => {
438 const v = parseFloat(inp.value);
439 if (!isFinite(v)) return;
440 rec[fld.key] = v * fld.scale;
441 this.featuresChanged(false);
442 });
443 row.append(label, inp, unit);
444 this.featureControls.push(inp);
445 }
446 box.appendChild(row);
447 }
448 list.appendChild(box);
449 });
450 for (const el of this.featureControls) el.disabled = !this.enabled;
451 }
453 /** After a change of the feature list: rebuild the rows (unless only a value changed), refresh the note, and notify. */
454 private featuresChanged(rebuild = true): void {
455 if (rebuild) this.renderFeatures();
456 this.updateNotes();
457 this.onChange("features", false);
458 }
460 /** Shows the fields that apply to the selected excitation and hit sequence only. */
461 private updateVisibility(): void {
462 for (const c of this.conditional) c.el.hidden = !c.show(this.params);
463 }
465 /** Rewrites every input from the current parameter values. */
466 refresh(): void {
467 for (const g of groups) {
468 for (const f of g.fields) {
469 const inp = this.inputs.get(f.key) as HTMLInputElement;
470 inp.value = formatNumber((this.params[f.key] as number) / this.scale(f));
471 }
472 }
473 for (const k of selectKeys) (this.inputs.get(k) as HTMLSelectElement).value = String(this.params[k]);
474 this.renderFeatures();
475 this.updateVisibility();
476 this.updateNotes();
477 }
479 /** Enables or disables the fields that cannot change while a simulation runs. */
480 setEnabled(enabled: boolean): void {
481 this.enabled = enabled;
482 for (const [key, el] of this.inputs) if (!this.liveKeys.has(key)) el.disabled = !enabled;
483 for (const el of this.featureControls) el.disabled = !enabled;
484 }
486 /**
487 * Applies URL parameters: any PhysParams key in SI units (booleans as 0/1 or
488 * false/true), `features` in the text form of shape.ts, and the earlier
489 * `bowShape` and `bowAmplitude` keys. Returns the keys that were set.
490 */
491 applyUrl(q: URLSearchParams): (keyof PhysParams)[] {
492 const set: (keyof PhysParams)[] = [];
493 for (const [k, v] of q) {
494 if (k === "features") {
495 const f = parseFeatures(v);
496 if (f) { this.params.features = f; set.push("features"); }
497 continue;
498 }
499 if (!(k in this.params)) continue;
500 const key = k as keyof PhysParams;
501 if (selectKeys.includes(key)) {
502 const norm = v === "1" ? "true" : v === "0" ? "false" : v;
503 if (selectValues[key].includes(norm)) {
504 (this.params as unknown as Record<string, string | boolean>)[key] = parseSelect(key, norm);
505 set.push(key);
506 }
507 } else {
508 const num = parseFloat(v);
509 if (isFinite(num)) {
510 (this.params as unknown as Record<string, number>)[key] = num;
511 set.push(key);
512 }
513 }
514 }
515 if (!set.includes("features") && (q.has("bowShape") || q.has("bowAmplitude"))) {
516 const amp = parseFloat(q.get("bowAmplitude") ?? "");
517 this.params.features = legacyFeatures(q.get("bowShape") ?? "width", isFinite(amp) ? amp : 3e-3);
518 set.push("features");
519 }
520 this.refresh();
521 return set;
522 }
524 /** Query string (without the leading ?) of every parameter that differs from its default, in the form applyUrl reads. */
525 toQuery(): string {
526 const parts: string[] = [];
527 const enc = (s: string) => encodeURIComponent(s).replace(/%3A/gi, ":").replace(/%2C/gi, ",").replace(/%3B/gi, ";");
528 for (const k of Object.keys(defaultParams) as (keyof PhysParams)[]) {
529 if (k === "features") {
530 const e = encodeFeatures(this.params.features);
531 if (e !== encodeFeatures(defaultParams.features)) parts.push(`features=${enc(e)}`);
532 } else if (this.params[k] !== defaultParams[k]) {
533 const v = this.params[k];
534 parts.push(`${k}=${enc(typeof v === "number" ? formatSI(v) : String(v))}`);
535 }
536 }
537 return parts.join("&");
538 }
540 gridInfo(): { grid: Grid; inside: number } {
541 const grid = makeGrid(this.params);
542 return { grid, inside: countInside(grid) };
543 }
545 /** Rewrites the derived-quantity notes; call after a change made outside the form (the recorded duration). */
546 updateNotes(): void {
547 const p = this.params;
548 if (this.hitsNote) {
549 if (p.excitation !== "strike") {
550 this.hitsNote.textContent = "";
551 } else {
552 const hits = hitSchedule(p);
553 const last = hits[hits.length - 1];
554 const decay = p.duration - last.t;
555 const speeds = hits.length <= 8 ? hits.map((h) => h.v.toFixed(1)).join(", ") : `${hits[0].v.toFixed(1)} ... ${last.v.toFixed(1)}`;
556 this.hitsNote.textContent =
557 hits.length === 1
558 ? `One hit at ${speeds} m/s; the recorded duration of ${p.duration} s starts at the impact.`
559 : `${hits.length} hits at ${speeds} m/s, the last at ${last.t.toFixed(2)} s. ` +
560 (decay > 0
561 ? `The recorded duration of ${p.duration} s leaves ${decay.toFixed(2)} s of ring after the last hit.`
562 : `The recorded duration of ${p.duration} s ends before the last hit; raise it to hear the whole series.`);
563 }
564 }
565 if (this.gridNote) {
566 const { grid, inside } = this.gridInfo();
567 const m = makeMaterial(p);
568 const f3 = dispersionFrequency(m, grid, 0.03), f10 = dispersionFrequency(m, grid, 0.1);
569 const outline = p.shape === "ellipse" ? "ellipse with a stepped edge" : "rectangle";
570 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).`;
571 }
572 if (this.shapeNote) {
573 const { grid } = this.gridInfo();
574 const { peakToPeak, maxSlope } = shapeStats(initialShape(p, grid), grid);
575 const base = "Real sheet metal is never flat. The features are summed and the mean plane is removed; only the slopes matter to the physics. A negative push amount pushes against a bow across the width.";
576 const stats = p.features.length === 0 ? "" : ` This shape spans ${(peakToPeak * 1e3).toFixed(2)} mm peak to peak with a largest slope of ${maxSlope.toFixed(3)}`;
577 const warn = maxSlope > SLOPE_LIMIT ? `, beyond the shallow-shell range of about ${SLOPE_LIMIT}; reduce the heights or widen the features.` : stats ? "." : "";
578 this.shapeNote.textContent = base + stats + warn;
579 this.shapeNote.className = maxSlope > SLOPE_LIMIT ? "warn" : "sub";
580 }
581 if (this.dampingNote) {
582 const m = makeMaterial(p);
583 const f = (hz: number) => t60(m, p, 2 * Math.PI * hz).toFixed(2);
584 this.dampingNote.textContent = `T60: ${f(100)} s at 100 Hz, ${f(1000)} s at 1 kHz, ${f(5000)} s at 5 kHz.`;
585 }
586 if (this.numericsNote) {
587 const { grid } = this.gridInfo();
588 const m = makeMaterial(p);
589 const s = Math.max(1, p.inPlaneMass);
590 // The power iteration takes a moment, so its result is kept until the spacing, the material, or the factor changes.
591 const key = [grid.hx, grid.hy, m.D, m.C, m.G, m.nu, m.rhoh, s].join("|");
592 if (!this.omegaCache || this.omegaCache.key !== key) this.omegaCache = { key, omega: maxAngularFrequency(grid, m, s) };
593 const stepsPerSample = Math.ceil(1 / (p.fs * ((CFL * 2) / this.omegaCache.omega)));
594 const dt = 1 / (p.fs * stepsPerSample);
595 const cp = Math.sqrt(m.C / m.rhoh / s);
596 this.numericsNote.textContent =
597 `Time step ${(dt * 1e6).toFixed(2)} µs (${stepsPerSample} per sample), set by the in-plane wave speed of ${cp.toFixed(0)} m/s. ` +
598 `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. ` +
599 `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.`;
600 }
601 }
603 get excitation(): Excitation {
604 return this.params.excitation;
605 }
606 get shape(): PlateShape {
607 return this.params.shape;
608 }
609 get features(): ShapeFeature[] {
610 return this.params.features;
611 }
↑↓ move↵openescclose