// Form editor of a SimulationConfig, kept in sync with the YAML editor by main.ts. import { defaultMallet, defaultStick, type Exciter, type SimulationConfig } from "../config/schema.ts"; interface Field { key: string; label: string; unit?: string; step?: number } interface Group { section: Exclude; title: string; fields: Field[] } const GROUPS: Group[] = [ { section: "material", title: "Material", fields: [ { key: "E", label: "Young's modulus", unit: "Pa" }, { key: "nu", label: "Poisson ratio" }, { key: "rho", label: "density", unit: "kg/m³" }, { key: "sigma0", label: "σ0 (frequency-independent loss)", unit: "1/s" }, { key: "sigma1", label: "σ1 (loss ∝ ω)", unit: "m²/s" } ] }, { section: "profile", title: "Profile", fields: [ { key: "radius", label: "radius", unit: "m" }, { key: "bell_height", label: "bell height", unit: "m" }, { key: "bell_top_radius", label: "bell plateau radius", unit: "m" }, { key: "bell_shoulder_radius", label: "shoulder radius", unit: "m" }, { key: "bow_height", label: "bow height", unit: "m" }, { key: "bow_power", label: "bow exponent" }, { key: "h_rim", label: "thickness at rim", unit: "m" }, { key: "h_shoulder", label: "thickness at shoulder", unit: "m" }, { key: "h_bell", label: "thickness in bell", unit: "m" }, { key: "max_slope", label: "slope warning above" } ] }, { section: "imperfections", title: "Imperfections", fields: [ { key: "seed", label: "seed", step: 1 }, { key: "n_dents", label: "hammer dents", step: 1 }, { key: "dent_depth", label: "dent depth", unit: "m" }, { key: "dent_sigma", label: "dent width σ", unit: "m" }, { key: "dent_margin", label: "dent margin", unit: "m" }, { key: "rim_warp", label: "rim warp (m = 2)", unit: "m" }, { key: "rim_warp_modes", label: "rim warp modes" }, { key: "shoulder_ovality", label: "shoulder ovality" }, { key: "bell_offset", label: "bell offset", unit: "m" } ] }, { section: "mesh", title: "Mesh", fields: [ { key: "element_size", label: "element size", unit: "m" }, { key: "smoothing", label: "smoothing passes", step: 1 } ] }, { section: "listener", title: "Listener", fields: [ { key: "position", label: "position x, y, z", unit: "m" }, { key: "c_air", label: "speed of sound", unit: "m/s" }, { key: "rho_air", label: "air density", unit: "kg/m³" }, { key: "back_diffraction_gain", label: "back-diffraction gain" } ] }, { section: "numerics", title: "Numerics", fields: [ { key: "fs", label: "sample rate", unit: "Hz", step: 1 }, { key: "duration", label: "duration", unit: "s" }, { key: "cfl_safety", label: "CFL safety" }, { key: "rot_inertia_factor", label: "rotational inertia factor" }, { key: "inplane_mass_scale", label: "in-plane mass scale" }, { key: "nonlinear", label: "nonlinear" }, { key: "n_progress", label: "progress reports", step: 1 } ] }, { section: "output", title: "Output (.cymsim file)", fields: [ { key: "record_acceleration", label: "record normal acceleration (audio rate, about 0.4 GB/s at 4700 nodes)" }, { key: "displacement_rate", label: "displacement frames", unit: "fps" } ] }, ]; const HIT_FIELDS: Field[] = [ { key: "time", label: "time", unit: "s" }, { key: "r", label: "radius r", unit: "m" }, { key: "phi", label: "azimuth φ", unit: "rad" }, { key: "velocity", label: "velocity", unit: "m/s" } ]; const EXCITER_FIELDS: Record = { stick: [ { key: "mass", label: "mass", unit: "kg" }, { key: "k", label: "contact stiffness k", unit: "N/m^α" }, { key: "alpha", label: "exponent α" }, { key: "patch_length", label: "patch length", unit: "m" }, { key: "patch_points", label: "patch points", step: 1 }, { key: "patch_angle", label: "patch angle", unit: "rad" }, { key: "gap", label: "release gap", unit: "m" } ], mallet: [ { key: "mass", label: "mass", unit: "kg" }, { key: "alpha", label: "exponent α" }, { key: "compression", label: "compression at stop", unit: "m" }, { key: "radius", label: "head radius", unit: "m" }, { key: "gap", label: "release gap", unit: "m" } ], }; export class ConfigForm { private cfg: SimulationConfig; private readonly root: HTMLElement; private readonly onChange: (cfg: SimulationConfig) => void; constructor(root: HTMLElement, cfg: SimulationConfig, onChange: (cfg: SimulationConfig) => void) { this.root = root; this.cfg = structuredClone(cfg); this.onChange = onChange; this.render(); } /** Replace the model (from the YAML side) and rebuild the form. */ setConfig(cfg: SimulationConfig) { this.cfg = structuredClone(cfg); this.render(); } get config(): SimulationConfig { return structuredClone(this.cfg); } private emit() { this.onChange(structuredClone(this.cfg)); } private input(obj: Record, field: Field): HTMLElement { const val = obj[field.key]; let el: HTMLInputElement; if (typeof val === "boolean") { el = document.createElement("input"); el.type = "checkbox"; el.checked = val; el.onchange = () => { obj[field.key] = el.checked; this.emit(); }; } else if (Array.isArray(val)) { el = document.createElement("input"); el.type = "text"; el.value = val.join(", "); el.onchange = () => { const nums = el.value.split(/[,\s]+/).filter((s) => s.length).map(Number); if (nums.every((x) => Number.isFinite(x))) { obj[field.key] = nums; this.emit(); } }; } else if (typeof val === "string") { el = document.createElement("input"); el.type = "text"; el.value = val; el.onchange = () => { obj[field.key] = el.value; this.emit(); }; } else { el = document.createElement("input"); el.type = "number"; el.step = field.step ? String(field.step) : "any"; el.value = String(val); el.onchange = () => { const x = Number(el.value); if (Number.isFinite(x)) { obj[field.key] = x; this.emit(); } }; } return el; } private grid(obj: Record, fields: Field[]): HTMLElement { const grid = document.createElement("div"); grid.className = "grid"; for (const f of fields) { const label = document.createElement("label"); label.textContent = f.label; if (f.unit) { const u = document.createElement("span"); u.className = "unit"; u.textContent = f.unit; label.appendChild(u); } grid.appendChild(label); grid.appendChild(this.input(obj, f)); } return grid; } private render() { this.root.replaceChildren(); for (const g of GROUPS) { const det = document.createElement("details"); det.open = g.section === "numerics"; const sum = document.createElement("summary"); sum.textContent = g.title; det.appendChild(sum); det.appendChild(this.grid(this.cfg[g.section] as unknown as Record, g.fields)); this.root.appendChild(det); } const det = document.createElement("details"); det.open = true; const sum = document.createElement("summary"); sum.textContent = `Hits (${this.cfg.hits.length})`; det.appendChild(sum); this.cfg.hits.forEach((hit, i) => { const box = document.createElement("div"); box.className = "hit"; const head = document.createElement("div"); head.className = "head"; const title = document.createElement("span"); title.textContent = `hit ${i + 1}`; const kind = document.createElement("select"); for (const k of ["mallet", "stick"]) { const o = document.createElement("option"); o.value = k; o.textContent = k; o.selected = hit.exciter.kind === k; kind.appendChild(o); } kind.onchange = () => { hit.exciter = kind.value === "stick" ? defaultStick() : defaultMallet(); this.emit(); this.render(); }; const rm = document.createElement("button"); rm.textContent = "remove"; rm.onclick = () => { this.cfg.hits.splice(i, 1); this.emit(); this.render(); }; head.append(title, kind, rm); box.appendChild(head); box.appendChild(this.grid(hit as unknown as Record, HIT_FIELDS)); box.appendChild(this.grid(hit.exciter as unknown as Record, EXCITER_FIELDS[hit.exciter.kind])); det.appendChild(box); }); const add = document.createElement("div"); add.className = "row buttons"; for (const k of ["mallet", "stick"] as const) { const b = document.createElement("button"); b.textContent = `+ ${k} hit`; b.onclick = () => { const last = this.cfg.hits[this.cfg.hits.length - 1]; this.cfg.hits.push({ time: last ? last.time + 0.5 : 0, r: k === "stick" ? 0.194 : 0.137, phi: 0.3, velocity: k === "stick" ? 7 : 8, exciter: k === "stick" ? defaultStick() : defaultMallet() }); this.emit(); this.render(); }; add.appendChild(b); } det.appendChild(add); this.root.appendChild(det); } }