// Parameter form: numeric fields in convenient units, a few selects, the // editable list of initial-shape features, and the derived-quantity notes. // Everything is stored in SI in `params`. import { type Excitation, type Grid, hitSchedule, type PhysParams, type PlateShape, countInside, defaultParams, dispersionFrequency, makeGrid, makeMaterial, t60, CFL, } from "../params.ts"; import { maxAngularFrequency } from "../sim/cpuRef.ts"; import { FEATURE_SPECS, FEATURE_TYPES, type FeatureType, type ShapeFeature, encodeFeatures, initialShape, legacyFeatures, newFeature, parseFeatures, randomFeatures, shapeStats, } from "../shape.ts"; export type SizeUnit = "in" | "mm"; interface FieldSpec { key: keyof PhysParams; label: string; unit: string; // "size" means the shared width/height unit scale: number; // form value = SI value / scale step?: number; live?: boolean; // may be changed while a simulation runs show?: (p: PhysParams) => boolean; // shown only when this holds } interface SelectSpec { key: keyof PhysParams; label: string; options: [string, string][]; show?: (p: PhysParams) => boolean; } interface Group { title: string; open: boolean; selects?: SelectSpec[]; // shown at the top of the group fields: FieldSpec[]; features?: boolean; // the group holds the initial-shape feature list note?: string | "grid" | "damping" | "numerics" | "hits" | "shape"; } const push = (p: PhysParams) => p.excitation === "push"; const strike = (p: PhysParams) => p.excitation === "strike"; const series = (p: PhysParams) => strike(p) && p.hitSequence !== "single"; const graded = (p: PhysParams) => strike(p) && (p.hitSequence === "crescendo" || p.hitSequence === "decrescendo"); /** Slope above which the shallow-shell treatment of the initial shape is doubtful. */ const SLOPE_LIMIT = 0.1; const groups: Group[] = [ { title: "Sheet", open: true, selects: [{ key: "shape", label: "Outline", options: [["rectangle", "Rectangle"], ["ellipse", "Ellipse (inscribed)"]] }], fields: [ { key: "Lx", label: "Width", unit: "size", scale: 1, step: 0.5 }, { key: "Ly", label: "Height", unit: "size", scale: 1, step: 0.5 }, { key: "h", label: "Thickness", unit: "mm", scale: 1e-3, step: 0.01 }, { key: "E", label: "Young's modulus", unit: "GPa", scale: 1e9, step: 1 }, { key: "nu", label: "Poisson ratio", unit: "", scale: 1, step: 0.01 }, { key: "rho", label: "Density", unit: "kg/m³", scale: 1, step: 10 }, { key: "holeInset", label: "Hole inset", unit: "mm", scale: 1e-3, step: 1 }, ], note: "grid", }, { title: "Initial shape", open: true, fields: [], features: true, note: "shape", }, { title: "Excitation", open: true, selects: [ { key: "excitation", label: "Type", options: [["strike", "Drumstick strike"], ["push", "Push a corner and release"]] }, { key: "hitSequence", label: "Hits", options: [["single", "One hit"], ["steady", "Series, same speed"], ["crescendo", "Series, soft to loud"], ["decrescendo", "Series, loud to soft"]], show: strike }, ], fields: [ { key: "pushAmount", label: "Push amount", unit: "mm", scale: 1e-3, step: 0.5, show: push }, { key: "fingerRadius", label: "Finger radius", unit: "mm", scale: 1e-3, step: 1, show: push }, { key: "rampTime", label: "Push ramp", unit: "s", scale: 1, step: 0.01, show: push }, { key: "holdMax", label: "Max settle time", unit: "s", scale: 1, step: 0.1, show: push }, { key: "releaseTime", label: "Release time", unit: "ms", scale: 1e-3, step: 1, show: push }, { key: "settleSigma0", label: "Settle damping σ₀", unit: "1/s", scale: 1, step: 10, show: push }, { key: "strikeFracX", label: "Strike x (fraction of width)", unit: "", scale: 1, step: 0.05, show: strike }, { key: "strikeFracY", label: "Strike y (fraction of height)", unit: "", scale: 1, step: 0.05, show: strike }, { key: "strikeVelocity", label: "Stick speed", unit: "m/s", scale: 1, step: 0.5, show: strike }, { key: "strikeVelocityMin", label: "Softest stick speed", unit: "m/s", scale: 1, step: 0.5, show: graded }, { key: "hitCount", label: "Number of hits", unit: "", scale: 1, step: 1, show: series }, { key: "hitInterval", label: "Time between hits", unit: "s", scale: 1, step: 0.1, show: series }, { key: "stickMass", label: "Stick mass", unit: "g", scale: 1e-3, step: 5, show: strike }, { key: "contactStiffness", label: "Contact stiffness K", unit: "N/mᵅ", scale: 1, step: 1e8, show: strike }, { key: "contactExponent", label: "Contact exponent α", unit: "", scale: 1, step: 0.1, show: strike }, { key: "contactRadius", label: "Tip radius", unit: "mm", scale: 1e-3, step: 1, show: strike }, ], note: "hits", }, { title: "Losses", open: true, fields: [ { key: "sigma0", label: "σ₀ (frequency independent)", unit: "1/s", scale: 1, step: 0.1, live: true }, { key: "sigma1", label: "σ₁ (∝ frequency)", unit: "m²/s", scale: 1, step: 1e-5, live: true }, { key: "sigmaIn", label: "In-plane loss", unit: "1/s", scale: 1, step: 5, live: true }, ], note: "damping", }, { title: "Listener", open: true, fields: [ { key: "listenerDist", label: "Distance", unit: "m", scale: 1, step: 0.1, live: true }, { key: "listenerAzimuth", label: "Azimuth", unit: "deg", scale: 1, step: 5, live: true }, { key: "listenerElevation", label: "Elevation", unit: "deg", scale: 1, step: 5, live: true }, ], }, { title: "Numerics", open: false, selects: [{ key: "rigidProjection", label: "Rigid-body projection while ringing", options: [["false", "Off"], ["true", "On (every 32 steps)"]] }], fields: [ { key: "spacing", label: "Grid spacing", unit: "mm", scale: 1e-3, step: 0.5 }, { key: "inPlaneMass", label: "In-plane mass factor", unit: "", scale: 1, step: 1 }, { key: "fs", label: "Audio sample rate", unit: "Hz", scale: 1, step: 100 }, { key: "fps", label: "Movie frame rate", unit: "fps", scale: 1, step: 10 }, { key: "settleKE", label: "Settled below KE", unit: "J", scale: 1, step: 1e-9 }, ], note: "numerics", }, ]; const SIZE_SCALE: Record = { in: 0.0254, mm: 1e-3 }; const selectKeys: (keyof PhysParams)[] = ["shape", "excitation", "hitSequence", "rigidProjection"]; const selectValues: Record = { shape: ["rectangle", "ellipse"], excitation: ["push", "strike"], hitSequence: ["single", "steady", "crescendo", "decrescendo"], rigidProjection: ["false", "true"], }; /** Parses a select value (URL or form) into the parameter's type. */ function parseSelect(key: keyof PhysParams, v: string): string | boolean { return key === "rigidProjection" ? v === "true" || v === "1" : v; } export function formatNumber(v: number): string { if (v === 0) return "0"; const a = Math.abs(v); if (a >= 1e5 || a < 1e-3) return v.toExponential(3); return String(+v.toPrecision(6)); } /** A number in SI for a link: shortest exact-enough decimal. */ function formatSI(v: number): string { return v === 0 ? "0" : String(+v.toPrecision(10)); } export class ParamForm { readonly params: PhysParams; sizeUnit: SizeUnit = "in"; /** Height scale of the random shapes drawn by Randomize [m]. */ randomScale = 3e-3; /** Called after any change; `live` tells whether the field may change during a run. */ onChange: (key: keyof PhysParams, live: boolean) => void = () => {}; private inputs = new Map(); private sizeUnitSelect = document.createElement("select"); private sizeUnitLabels: HTMLElement[] = []; private conditional: { el: HTMLElement; show: (p: PhysParams) => boolean }[] = []; private gridNote: HTMLElement | null = null; private hitsNote: HTMLElement | null = null; private dampingNote: HTMLElement | null = null; private numericsNote: HTMLElement | null = null; private shapeNote: HTMLElement | null = null; private featureList: HTMLElement | null = null; private featureControls: (HTMLInputElement | HTMLSelectElement | HTMLButtonElement)[] = []; private omegaCache: { key: string; omega: number } | null = null; private liveKeys = new Set(); private enabled = true; constructor(root: HTMLElement, params: PhysParams) { this.params = params; for (const g of groups) { const det = document.createElement("details"); det.open = g.open; const sum = document.createElement("summary"); sum.textContent = g.title; det.appendChild(sum); for (const s of g.selects ?? []) { const row = this.selectRow(s); if (s.show) this.conditional.push({ el: row, show: s.show }); det.appendChild(row); } for (const f of g.fields) { const row = this.fieldRow(f); if (f.show) this.conditional.push({ el: row, show: f.show }); if (f.live) this.liveKeys.add(f.key); det.appendChild(row); } if (g.features) det.appendChild(this.featureSection()); if (g.note) { const note = document.createElement("p"); note.className = "sub"; if (g.note === "grid") this.gridNote = note; else if (g.note === "hits") this.hitsNote = note; else if (g.note === "damping") this.dampingNote = note; else if (g.note === "numerics") this.numericsNote = note; else if (g.note === "shape") this.shapeNote = note; else note.textContent = g.note; det.appendChild(note); } root.appendChild(det); } this.updateVisibility(); this.updateNotes(); } private selectRow(s: SelectSpec): HTMLElement { const row = document.createElement("div"); row.className = "field"; const l = document.createElement("label"); l.textContent = s.label; const sel = document.createElement("select"); for (const [v, t] of s.options) { const o = document.createElement("option"); o.value = v; o.textContent = t; sel.appendChild(o); } sel.value = String(this.params[s.key]); sel.addEventListener("change", () => { (this.params as unknown as Record)[s.key] = parseSelect(s.key, sel.value); this.updateVisibility(); this.updateNotes(); this.onChange(s.key, false); }); row.append(l, sel, document.createElement("span")); this.inputs.set(s.key, sel); return row; } private fieldRow(f: FieldSpec): HTMLElement { const row = document.createElement("div"); row.className = "field"; const label = document.createElement("label"); label.textContent = f.label; const inp = document.createElement("input"); inp.type = "number"; inp.step = String(f.step ?? 1); inp.value = formatNumber((this.params[f.key] as number) / this.scale(f)); inp.addEventListener("change", () => { const v = parseFloat(inp.value); if (!isFinite(v)) return; (this.params as unknown as Record)[f.key] = v * this.scale(f); this.updateVisibility(); this.updateNotes(); this.onChange(f.key, !!f.live); }); this.inputs.set(f.key, inp); const unit = document.createElement("span"); unit.className = "unit"; if (f.unit === "size") { // Width and height share one unit selector (inches by default), shown on the first of them. const sel = this.sizeUnitSelect; if (sel.options.length === 0) { for (const u of ["in", "mm"] as SizeUnit[]) { const o = document.createElement("option"); o.value = u; o.textContent = u; sel.appendChild(o); } sel.value = this.sizeUnit; sel.className = "unit-select"; sel.addEventListener("change", () => { this.sizeUnit = sel.value as SizeUnit; for (const el of this.sizeUnitLabels) el.textContent = this.sizeUnit; this.refresh(); }); row.append(label, inp, sel); return row; } unit.textContent = this.sizeUnit; this.sizeUnitLabels.push(unit); } else { unit.textContent = f.unit; } row.append(label, inp, unit); return row; } private scale(f: FieldSpec): number { return f.unit === "size" ? SIZE_SCALE[this.sizeUnit] : f.scale; } // ---------- Initial-shape feature list ---------- /** The list of features, the add row, and the randomize row. */ private featureSection(): HTMLElement { const wrap = document.createElement("div"); this.featureList = document.createElement("div"); wrap.appendChild(this.featureList); const addRow = document.createElement("div"); addRow.className = "feature-row"; const typeSel = document.createElement("select"); for (const t of FEATURE_TYPES) { const o = document.createElement("option"); o.value = t; o.textContent = FEATURE_SPECS[t].label; typeSel.appendChild(o); } const btnAdd = document.createElement("button"); btnAdd.textContent = "Add"; btnAdd.addEventListener("click", () => { this.params.features.push(newFeature(typeSel.value as FeatureType)); this.featuresChanged(); }); const btnFlat = document.createElement("button"); btnFlat.textContent = "Clear (flat)"; btnFlat.addEventListener("click", () => { this.params.features = []; this.featuresChanged(); }); addRow.append(typeSel, btnAdd, btnFlat); wrap.appendChild(addRow); const rndRow = document.createElement("div"); rndRow.className = "feature-row"; const btnRnd = document.createElement("button"); btnRnd.textContent = "Randomize"; btnRnd.title = "Draw a new random combination of features with heights of the order of the scale"; const scaleLabel = document.createElement("label"); scaleLabel.textContent = "scale"; const scaleInp = document.createElement("input"); scaleInp.type = "number"; scaleInp.step = "0.5"; scaleInp.value = formatNumber(this.randomScale * 1e3); scaleInp.addEventListener("change", () => { const v = parseFloat(scaleInp.value); if (isFinite(v) && v > 0) this.randomScale = v * 1e-3; }); const scaleUnit = document.createElement("span"); scaleUnit.className = "unit"; scaleUnit.textContent = "mm"; btnRnd.addEventListener("click", () => { this.params.features = randomFeatures(this.params, this.randomScale); this.featuresChanged(); }); rndRow.append(btnRnd, scaleLabel, scaleInp, scaleUnit); wrap.appendChild(rndRow); this.persistentFeatureControls = [typeSel, btnAdd, btnFlat, btnRnd, scaleInp]; this.renderFeatures(); return wrap; } private persistentFeatureControls: (HTMLInputElement | HTMLSelectElement | HTMLButtonElement)[] = []; /** Rebuilds the feature rows from params.features. */ private renderFeatures(): void { const list = this.featureList; if (!list) return; list.replaceChildren(); this.featureControls = [...this.persistentFeatureControls]; const features = this.params.features; if (features.length === 0) { const p = document.createElement("p"); p.className = "sub"; p.textContent = "Flat sheet (no features)."; list.appendChild(p); } features.forEach((feat, index) => { const spec = FEATURE_SPECS[feat.type]; const box = document.createElement("div"); box.className = "feature"; box.title = spec.description; const head = document.createElement("div"); head.className = "feature-head"; const title = document.createElement("span"); title.textContent = `${index + 1}. ${spec.label}`; const btnRemove = document.createElement("button"); btnRemove.className = "small"; btnRemove.textContent = "Remove"; btnRemove.addEventListener("click", () => { this.params.features.splice(index, 1); this.featuresChanged(); }); head.append(title, btnRemove); box.appendChild(head); this.featureControls.push(btnRemove); const rec = feat as unknown as Record; for (const fld of spec.fields) { const row = document.createElement("div"); row.className = "field"; const label = document.createElement("label"); label.textContent = fld.label; const unit = document.createElement("span"); unit.className = "unit"; unit.textContent = fld.unit; if (fld.options) { const sel = document.createElement("select"); for (const [v, t] of fld.options) { const o = document.createElement("option"); o.value = v; o.textContent = t; sel.appendChild(o); } sel.value = String(rec[fld.key]); sel.addEventListener("change", () => { rec[fld.key] = sel.value; this.featuresChanged(false); }); row.append(label, sel, unit); this.featureControls.push(sel); } else { const inp = document.createElement("input"); inp.type = "number"; inp.step = String(fld.step); inp.value = formatNumber((rec[fld.key] as number) / fld.scale); inp.addEventListener("change", () => { const v = parseFloat(inp.value); if (!isFinite(v)) return; rec[fld.key] = v * fld.scale; this.featuresChanged(false); }); row.append(label, inp, unit); this.featureControls.push(inp); } box.appendChild(row); } list.appendChild(box); }); for (const el of this.featureControls) el.disabled = !this.enabled; } /** After a change of the feature list: rebuild the rows (unless only a value changed), refresh the note, and notify. */ private featuresChanged(rebuild = true): void { if (rebuild) this.renderFeatures(); this.updateNotes(); this.onChange("features", false); } /** Shows the fields that apply to the selected excitation and hit sequence only. */ private updateVisibility(): void { for (const c of this.conditional) c.el.hidden = !c.show(this.params); } /** Rewrites every input from the current parameter values. */ refresh(): void { for (const g of groups) { for (const f of g.fields) { const inp = this.inputs.get(f.key) as HTMLInputElement; inp.value = formatNumber((this.params[f.key] as number) / this.scale(f)); } } for (const k of selectKeys) (this.inputs.get(k) as HTMLSelectElement).value = String(this.params[k]); this.renderFeatures(); this.updateVisibility(); this.updateNotes(); } /** Enables or disables the fields that cannot change while a simulation runs. */ setEnabled(enabled: boolean): void { this.enabled = enabled; for (const [key, el] of this.inputs) if (!this.liveKeys.has(key)) el.disabled = !enabled; for (const el of this.featureControls) el.disabled = !enabled; } /** * Applies URL parameters: any PhysParams key in SI units (booleans as 0/1 or * false/true), `features` in the text form of shape.ts, and the earlier * `bowShape` and `bowAmplitude` keys. Returns the keys that were set. */ applyUrl(q: URLSearchParams): (keyof PhysParams)[] { const set: (keyof PhysParams)[] = []; for (const [k, v] of q) { if (k === "features") { const f = parseFeatures(v); if (f) { this.params.features = f; set.push("features"); } continue; } if (!(k in this.params)) continue; const key = k as keyof PhysParams; if (selectKeys.includes(key)) { const norm = v === "1" ? "true" : v === "0" ? "false" : v; if (selectValues[key].includes(norm)) { (this.params as unknown as Record)[key] = parseSelect(key, norm); set.push(key); } } else { const num = parseFloat(v); if (isFinite(num)) { (this.params as unknown as Record)[key] = num; set.push(key); } } } if (!set.includes("features") && (q.has("bowShape") || q.has("bowAmplitude"))) { const amp = parseFloat(q.get("bowAmplitude") ?? ""); this.params.features = legacyFeatures(q.get("bowShape") ?? "width", isFinite(amp) ? amp : 3e-3); set.push("features"); } this.refresh(); return set; } /** Query string (without the leading ?) of every parameter that differs from its default, in the form applyUrl reads. */ toQuery(): string { const parts: string[] = []; const enc = (s: string) => encodeURIComponent(s).replace(/%3A/gi, ":").replace(/%2C/gi, ",").replace(/%3B/gi, ";"); for (const k of Object.keys(defaultParams) as (keyof PhysParams)[]) { if (k === "features") { const e = encodeFeatures(this.params.features); if (e !== encodeFeatures(defaultParams.features)) parts.push(`features=${enc(e)}`); } else if (this.params[k] !== defaultParams[k]) { const v = this.params[k]; parts.push(`${k}=${enc(typeof v === "number" ? formatSI(v) : String(v))}`); } } return parts.join("&"); } gridInfo(): { grid: Grid; inside: number } { const grid = makeGrid(this.params); return { grid, inside: countInside(grid) }; } /** Rewrites the derived-quantity notes; call after a change made outside the form (the recorded duration). */ updateNotes(): void { const p = this.params; if (this.hitsNote) { if (p.excitation !== "strike") { this.hitsNote.textContent = ""; } else { const hits = hitSchedule(p); const last = hits[hits.length - 1]; const decay = p.duration - last.t; const speeds = hits.length <= 8 ? hits.map((h) => h.v.toFixed(1)).join(", ") : `${hits[0].v.toFixed(1)} ... ${last.v.toFixed(1)}`; this.hitsNote.textContent = hits.length === 1 ? `One hit at ${speeds} m/s; the recorded duration of ${p.duration} s starts at the impact.` : `${hits.length} hits at ${speeds} m/s, the last at ${last.t.toFixed(2)} s. ` + (decay > 0 ? `The recorded duration of ${p.duration} s leaves ${decay.toFixed(2)} s of ring after the last hit.` : `The recorded duration of ${p.duration} s ends before the last hit; raise it to hear the whole series.`); } } if (this.gridNote) { const { grid, inside } = this.gridInfo(); const m = makeMaterial(p); const f3 = dispersionFrequency(m, grid, 0.03), f10 = dispersionFrequency(m, grid, 0.1); const outline = p.shape === "ellipse" ? "ellipse with a stepped edge" : "rectangle"; 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).`; } if (this.shapeNote) { const { grid } = this.gridInfo(); const { peakToPeak, maxSlope } = shapeStats(initialShape(p, grid), grid); 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."; 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)}`; const warn = maxSlope > SLOPE_LIMIT ? `, beyond the shallow-shell range of about ${SLOPE_LIMIT}; reduce the heights or widen the features.` : stats ? "." : ""; this.shapeNote.textContent = base + stats + warn; this.shapeNote.className = maxSlope > SLOPE_LIMIT ? "warn" : "sub"; } if (this.dampingNote) { const m = makeMaterial(p); const f = (hz: number) => t60(m, p, 2 * Math.PI * hz).toFixed(2); this.dampingNote.textContent = `T60: ${f(100)} s at 100 Hz, ${f(1000)} s at 1 kHz, ${f(5000)} s at 5 kHz.`; } if (this.numericsNote) { const { grid } = this.gridInfo(); const m = makeMaterial(p); const s = Math.max(1, p.inPlaneMass); // The power iteration takes a moment, so its result is kept until the spacing, the material, or the factor changes. const key = [grid.hx, grid.hy, m.D, m.C, m.G, m.nu, m.rhoh, s].join("|"); if (!this.omegaCache || this.omegaCache.key !== key) this.omegaCache = { key, omega: maxAngularFrequency(grid, m, s) }; const stepsPerSample = Math.ceil(1 / (p.fs * ((CFL * 2) / this.omegaCache.omega))); const dt = 1 / (p.fs * stepsPerSample); const cp = Math.sqrt(m.C / m.rhoh / s); this.numericsNote.textContent = `Time step ${(dt * 1e6).toFixed(2)} µs (${stepsPerSample} per sample), set by the in-plane wave speed of ${cp.toFixed(0)} m/s. ` + `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. ` + `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.`; } } get excitation(): Excitation { return this.params.excitation; } get shape(): PlateShape { return this.params.shape; } get features(): ShapeFeature[] { return this.params.features; } }