// The design panel: every parameter of the cymbal that the player exposes, in two tiers. // // A "live" parameter enters the modal bank analytically and takes effect while the slider moves: // the two loss coefficients, Young's modulus, the density and the listener. A "structural" // parameter changes the eigenproblem itself, so editing it marks the modes stale and the user // presses Recompute. The tiers are explained in precompute/pipeline.ts. // // Values are stored in SI, as the cymsim schema has them, and displayed in whatever unit reads // best (millimetres for the geometry, gigapascals for the modulus), through a per-field factor. import type { SimulationConfig } from "./config/schema.ts"; export type Tier = "live" | "structural"; export interface Field { section: "material" | "profile" | "imperfections" | "listener"; key: string; index?: number; // component of a vector field (the listener position) label: string; unit: string; scale: number; // displayed = stored * scale min: number; // in displayed units max: number; step: number; tier: Tier; title?: string; } const f = (section: Field["section"], key: string, label: string, unit: string, scale: number, min: number, max: number, step: number, tier: Tier, title?: string): Field => ({ section, key, label, unit, scale, min, max, step, tier, title }); export const GROUPS: { name: string; note: string; fields: Field[] }[] = [ { name: "Sustain and material", note: "live: these change the bank without a new eigensolve", fields: [ f("material", "sigma0", "uniform loss", "1/s", 1, 0.05, 20, 0.05, "live", "frequency-independent decay; the 60 dB time of the low modes is about 6.9 / sigma0"), f("material", "sigma1", "loss rise with frequency", "1e-4 m^2/s", 1e4, 0, 60, 0.25, "live", "adds 2 sigma1 k^2 to the decay rate, so high modes die first; this sets how dark the tail is"), f("material", "E", "Young's modulus", "GPa", 1e-9, 20, 260, 1, "live", "scales every frequency as sqrt(E); B20 bronze is 110 GPa"), f("material", "rho", "density", "kg/m^3", 1, 2000, 12000, 50, "live", "scales every frequency as 1/sqrt(rho); B20 bronze is 8700"), f("material", "nu", "Poisson ratio", "", 1, 0.0, 0.48, 0.01, "structural"), ], }, { name: "Size and profile", note: "structural: a change needs the modes recomputed", fields: [ f("profile", "radius", "radius", "mm", 1e3, 100, 400, 1, "structural", "18 inch is 228.6 mm"), f("profile", "h_rim", "thickness at the rim", "mm", 1e3, 0.3, 4, 0.01, "structural", "the single strongest control on pitch and weight"), f("profile", "h_shoulder", "thickness at the shoulder", "mm", 1e3, 0.3, 5, 0.01, "structural"), f("profile", "h_bell", "thickness in the bell", "mm", 1e3, 0.3, 6, 0.01, "structural"), f("profile", "bow_height", "bow rise", "mm", 1e3, 0, 80, 0.5, "structural", "height of the shoulder above the rim; more curvature stiffens the low modes"), f("profile", "bow_power", "bow shape", "", 1, 0.5, 4, 0.05, "structural", "1 is a cone; above 1 the bow curves near the rim and flattens at the shoulder"), f("profile", "bell_height", "bell height", "mm", 1e3, 0, 40, 0.5, "structural"), f("profile", "bell_top_radius", "bell plateau radius", "mm", 1e3, 5, 60, 1, "structural"), f("profile", "bell_shoulder_radius", "shoulder radius", "mm", 1e3, 20, 150, 1, "structural"), ], }, { name: "Hammering and asymmetry", note: "structural: these break the symmetry and give the cymbal its character", fields: [ f("imperfections", "seed", "seed", "", 1, 0, 999, 1, "structural", "a different draw of the same statistics"), f("imperfections", "n_dents", "hammer marks", "", 1, 0, 1500, 10, "structural"), f("imperfections", "dent_depth", "mark depth", "mm", 1e3, 0, 1.5, 0.01, "structural"), f("imperfections", "dent_sigma", "mark width", "mm", 1e3, 2, 30, 0.5, "structural", "keep this at or above the playback element size or the mesh cannot see the marks"), f("imperfections", "rim_warp", "rim warp", "mm", 1e3, 0, 5, 0.05, "structural", "low azimuthal harmonics that split the degenerate mode pairs"), f("imperfections", "shoulder_ovality", "shoulder ovality", "", 1, 0, 0.25, 0.005, "structural"), f("imperfections", "bell_offset", "bell off-centre", "mm", 1e3, 0, 10, 0.1, "structural"), ], }, { name: "Listener", note: "live: the gains are redone in the background, in a fraction of a second", fields: [ { ...f("listener", "position", "listener x", "m", 1, -3, 3, 0.05, "live"), index: 0 }, { ...f("listener", "position", "listener y", "m", 1, -3, 3, 0.05, "live"), index: 1 }, { ...f("listener", "position", "listener z", "m", 1, -3, 3, 0.05, "live"), index: 2 }, f("listener", "back_diffraction_gain", "back-side cancellation", "", 1, 0, 1.5, 0.05, "live", "0 is a baffled plate, 1 the full rim-diffraction estimate; raising it thins the low end"), f("listener", "c_air", "speed of sound", "m/s", 1, 300, 380, 1, "live"), f("listener", "rho_air", "air density", "kg/m^3", 1, 0.7, 1.6, 0.01, "live"), ], }, ]; export const ALL_FIELDS = GROUPS.flatMap((g) => g.fields); export function getValue(cfg: SimulationConfig, field: Field): number { const section = cfg[field.section] as unknown as Record; const v = section[field.key]; return field.index === undefined ? (v as number) : (v as number[])[field.index]; } export function setValue(cfg: SimulationConfig, field: Field, value: number): void { const section = cfg[field.section] as unknown as Record; if (field.index === undefined) section[field.key] = value; else (section[field.key] as number[])[field.index] = value; } function fmt(v: number, step: number): string { const digits = Math.max(0, Math.min(6, Math.ceil(-Math.log10(step) + 0.001))); return v.toFixed(digits); } /** Build the panel. ``onChange`` fires on every input event with the tier that changed. */ export class DesignPanel { private rows = new Map(); private cfg: () => SimulationConfig; private onChange: (field: Field, tier: Tier) => void; constructor(container: HTMLElement, cfg: () => SimulationConfig, onChange: (field: Field, tier: Tier) => void) { this.cfg = cfg; this.onChange = onChange; for (const group of GROUPS) { const box = document.createElement("details"); box.className = "group"; const sum = document.createElement("summary"); sum.textContent = group.name; box.appendChild(sum); const note = document.createElement("div"); note.className = "note"; note.textContent = group.note; box.appendChild(note); for (const field of group.fields) { box.appendChild(this.row(field)); } container.appendChild(box); } } private row(field: Field): HTMLElement { const row = document.createElement("div"); row.className = `field ${field.tier}`; const label = document.createElement("label"); label.textContent = field.label + (field.unit ? ` [${field.unit}]` : ""); if (field.title) label.title = field.title; const range = document.createElement("input"); range.type = "range"; range.min = String(field.min); range.max = String(field.max); range.step = String(field.step); const num = document.createElement("input"); num.type = "number"; num.min = String(field.min); num.max = String(field.max); num.step = String(field.step); const apply = (raw: number, from: HTMLInputElement) => { const v = Math.min(field.max, Math.max(field.min, raw)); setValue(this.cfg(), field, v / field.scale); if (from !== range) range.value = String(v); if (from !== num) num.value = fmt(v, field.step); this.onChange(field, field.tier); }; range.addEventListener("input", () => apply(Number(range.value), range)); num.addEventListener("change", () => apply(Number(num.value), num)); row.append(label, range, num); this.rows.set(field, { range, num }); return row; } /** Push the configuration into the inputs (after loading a preset or a YAML file). */ refresh(): void { for (const [field, els] of this.rows) { const v = getValue(this.cfg(), field) * field.scale; els.range.value = String(v); els.num.value = fmt(v, field.step); } } }