/ proof-of-concept / cymsimweb
Sign in
proof-of-concept / cymsimweb
cymsimweb / src / ui / form.ts
124 lines · 8.3 KBCodeBlameHistory
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 1// Form editor of a SimulationConfig, kept in sync with the YAML editor by main.ts.
2import { defaultMallet, defaultStick, type Exciter, type SimulationConfig } from "../config/schema.ts";
4interface Field { key: string; label: string; unit?: string; step?: number }
5interface Group { section: Exclude<keyof SimulationConfig, "hits">; title: string; fields: Field[] }
7const GROUPS: Group[] = [
8 { section: "material", title: "Material", fields: [
9 { key: "E", label: "Young's modulus", unit: "Pa" }, { key: "nu", label: "Poisson ratio" }, { key: "rho", label: "density", unit: "kg/m³" },
10 { key: "sigma0", label: "σ0 (frequency-independent loss)", unit: "1/s" }, { key: "sigma1", label: "σ1 (loss ∝ ω)", unit: "m²/s" } ] },
11 { section: "profile", title: "Profile", fields: [
12 { key: "radius", label: "radius", unit: "m" }, { key: "bell_height", label: "bell height", unit: "m" }, { key: "bell_top_radius", label: "bell plateau radius", unit: "m" },
13 { key: "bell_shoulder_radius", label: "shoulder radius", unit: "m" }, { key: "bow_height", label: "bow height", unit: "m" }, { key: "bow_power", label: "bow exponent" },
14 { 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" },
15 { key: "max_slope", label: "slope warning above" } ] },
16 { section: "imperfections", title: "Imperfections", fields: [
17 { key: "seed", label: "seed", step: 1 }, { key: "n_dents", label: "hammer dents", step: 1 }, { key: "dent_depth", label: "dent depth", unit: "m" },
18 { 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" },
19 { key: "rim_warp_modes", label: "rim warp modes" }, { key: "shoulder_ovality", label: "shoulder ovality" }, { key: "bell_offset", label: "bell offset", unit: "m" } ] },
20 { section: "mesh", title: "Mesh", fields: [ { key: "element_size", label: "element size", unit: "m" }, { key: "smoothing", label: "smoothing passes", step: 1 } ] },
21 { section: "listener", title: "Listener", fields: [
22 { 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³" },
23 { key: "back_diffraction_gain", label: "back-diffraction gain" } ] },
24 { section: "numerics", title: "Numerics", fields: [
25 { key: "fs", label: "sample rate", unit: "Hz", step: 1 }, { key: "duration", label: "duration", unit: "s" }, { key: "cfl_safety", label: "CFL safety" },
26 { key: "rot_inertia_factor", label: "rotational inertia factor" }, { key: "inplane_mass_scale", label: "in-plane mass scale" }, { key: "nonlinear", label: "nonlinear" },
27 { key: "n_progress", label: "progress reports", step: 1 } ] },
28];
30const 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" } ];
31const EXCITER_FIELDS: Record<Exciter["kind"], Field[]> = {
32 stick: [ { key: "mass", label: "mass", unit: "kg" }, { key: "k", label: "contact stiffness k", unit: "N/m^α" }, { key: "alpha", label: "exponent α" },
33 { 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" } ],
34 mallet: [ { key: "mass", label: "mass", unit: "kg" }, { key: "alpha", label: "exponent α" }, { key: "compression", label: "compression at stop", unit: "m" },
35 { key: "radius", label: "head radius", unit: "m" }, { key: "gap", label: "release gap", unit: "m" } ],
36};
38export class ConfigForm {
39 private cfg: SimulationConfig;
40 private readonly root: HTMLElement;
41 private readonly onChange: (cfg: SimulationConfig) => void;
43 constructor(root: HTMLElement, cfg: SimulationConfig, onChange: (cfg: SimulationConfig) => void) {
44 this.root = root; this.cfg = structuredClone(cfg); this.onChange = onChange;
45 this.render();
46 }
48 /** Replace the model (from the YAML side) and rebuild the form. */
49 setConfig(cfg: SimulationConfig) { this.cfg = structuredClone(cfg); this.render(); }
50 get config(): SimulationConfig { return structuredClone(this.cfg); }
52 private emit() { this.onChange(structuredClone(this.cfg)); }
54 private input(obj: Record<string, unknown>, field: Field): HTMLElement {
55 const val = obj[field.key];
56 let el: HTMLInputElement;
57 if (typeof val === "boolean") {
58 el = document.createElement("input"); el.type = "checkbox"; el.checked = val;
59 el.onchange = () => { obj[field.key] = el.checked; this.emit(); };
60 } else if (Array.isArray(val)) {
61 el = document.createElement("input"); el.type = "text"; el.value = val.join(", ");
62 el.onchange = () => {
63 const nums = el.value.split(/[,\s]+/).filter((s) => s.length).map(Number);
64 if (nums.every((x) => Number.isFinite(x))) { obj[field.key] = nums; this.emit(); }
65 };
66 } else if (typeof val === "string") {
67 el = document.createElement("input"); el.type = "text"; el.value = val;
68 el.onchange = () => { obj[field.key] = el.value; this.emit(); };
69 } else {
70 el = document.createElement("input"); el.type = "number"; el.step = field.step ? String(field.step) : "any"; el.value = String(val);
71 el.onchange = () => { const x = Number(el.value); if (Number.isFinite(x)) { obj[field.key] = x; this.emit(); } };
72 }
73 return el;
74 }
76 private grid(obj: Record<string, unknown>, fields: Field[]): HTMLElement {
77 const grid = document.createElement("div"); grid.className = "grid";
78 for (const f of fields) {
79 const label = document.createElement("label"); label.textContent = f.label;
80 if (f.unit) { const u = document.createElement("span"); u.className = "unit"; u.textContent = f.unit; label.appendChild(u); }
81 grid.appendChild(label); grid.appendChild(this.input(obj, f));
82 }
83 return grid;
84 }
86 private render() {
87 this.root.replaceChildren();
88 for (const g of GROUPS) {
89 const det = document.createElement("details"); det.open = g.section === "numerics";
90 const sum = document.createElement("summary"); sum.textContent = g.title; det.appendChild(sum);
91 det.appendChild(this.grid(this.cfg[g.section] as unknown as Record<string, unknown>, g.fields));
92 this.root.appendChild(det);
93 }
94 const det = document.createElement("details"); det.open = true;
95 const sum = document.createElement("summary"); sum.textContent = `Hits (${this.cfg.hits.length})`; det.appendChild(sum);
96 this.cfg.hits.forEach((hit, i) => {
97 const box = document.createElement("div"); box.className = "hit";
98 const head = document.createElement("div"); head.className = "head";
99 const title = document.createElement("span"); title.textContent = `hit ${i + 1}`;
100 const kind = document.createElement("select");
101 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); }
102 kind.onchange = () => { hit.exciter = kind.value === "stick" ? defaultStick() : defaultMallet(); this.emit(); this.render(); };
103 const rm = document.createElement("button"); rm.textContent = "remove";
104 rm.onclick = () => { this.cfg.hits.splice(i, 1); this.emit(); this.render(); };
105 head.append(title, kind, rm); box.appendChild(head);
106 box.appendChild(this.grid(hit as unknown as Record<string, unknown>, HIT_FIELDS));
107 box.appendChild(this.grid(hit.exciter as unknown as Record<string, unknown>, EXCITER_FIELDS[hit.exciter.kind]));
108 det.appendChild(box);
109 });
110 const add = document.createElement("div"); add.className = "row buttons";
111 for (const k of ["mallet", "stick"] as const) {
112 const b = document.createElement("button"); b.textContent = `+ ${k} hit`;
113 b.onclick = () => {
114 const last = this.cfg.hits[this.cfg.hits.length - 1];
115 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,
116 exciter: k === "stick" ? defaultStick() : defaultMallet() });
117 this.emit(); this.render();
118 };
119 add.appendChild(b);
120 }
121 det.appendChild(add);
122 this.root.appendChild(det);
123 }
↑↓ move↵openescclose