/ proof-of-concept / cymsimweb
Sign in
proof-of-concept / cymsimweb
cymsimweb / src / ui / form.ts
127 lines · 8.6 KBBlameHistoryRaw
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 { section: "output", title: "Output (.cymsim file)", fields: [
29 { key: "record_acceleration", label: "record normal acceleration (audio rate, about 0.4 GB/s at 4700 nodes)" },
30 { key: "displacement_rate", label: "displacement frames", unit: "fps" } ] },
31];
33const 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" } ];
34const EXCITER_FIELDS: Record<Exciter["kind"], Field[]> = {
35 stick: [ { key: "mass", label: "mass", unit: "kg" }, { key: "k", label: "contact stiffness k", unit: "N/m^α" }, { key: "alpha", label: "exponent α" },
36 { 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" } ],
37 mallet: [ { key: "mass", label: "mass", unit: "kg" }, { key: "alpha", label: "exponent α" }, { key: "compression", label: "compression at stop", unit: "m" },
38 { key: "radius", label: "head radius", unit: "m" }, { key: "gap", label: "release gap", unit: "m" } ],
39};
41export class ConfigForm {
42 private cfg: SimulationConfig;
43 private readonly root: HTMLElement;
44 private readonly onChange: (cfg: SimulationConfig) => void;
46 constructor(root: HTMLElement, cfg: SimulationConfig, onChange: (cfg: SimulationConfig) => void) {
47 this.root = root; this.cfg = structuredClone(cfg); this.onChange = onChange;
48 this.render();
49 }
51 /** Replace the model (from the YAML side) and rebuild the form. */
52 setConfig(cfg: SimulationConfig) { this.cfg = structuredClone(cfg); this.render(); }
53 get config(): SimulationConfig { return structuredClone(this.cfg); }
55 private emit() { this.onChange(structuredClone(this.cfg)); }
57 private input(obj: Record<string, unknown>, field: Field): HTMLElement {
58 const val = obj[field.key];
59 let el: HTMLInputElement;
60 if (typeof val === "boolean") {
61 el = document.createElement("input"); el.type = "checkbox"; el.checked = val;
62 el.onchange = () => { obj[field.key] = el.checked; this.emit(); };
63 } else if (Array.isArray(val)) {
64 el = document.createElement("input"); el.type = "text"; el.value = val.join(", ");
65 el.onchange = () => {
66 const nums = el.value.split(/[,\s]+/).filter((s) => s.length).map(Number);
67 if (nums.every((x) => Number.isFinite(x))) { obj[field.key] = nums; this.emit(); }
68 };
69 } else if (typeof val === "string") {
70 el = document.createElement("input"); el.type = "text"; el.value = val;
71 el.onchange = () => { obj[field.key] = el.value; this.emit(); };
72 } else {
73 el = document.createElement("input"); el.type = "number"; el.step = field.step ? String(field.step) : "any"; el.value = String(val);
74 el.onchange = () => { const x = Number(el.value); if (Number.isFinite(x)) { obj[field.key] = x; this.emit(); } };
75 }
76 return el;
77 }
79 private grid(obj: Record<string, unknown>, fields: Field[]): HTMLElement {
80 const grid = document.createElement("div"); grid.className = "grid";
81 for (const f of fields) {
82 const label = document.createElement("label"); label.textContent = f.label;
83 if (f.unit) { const u = document.createElement("span"); u.className = "unit"; u.textContent = f.unit; label.appendChild(u); }
84 grid.appendChild(label); grid.appendChild(this.input(obj, f));
85 }
86 return grid;
87 }
89 private render() {
90 this.root.replaceChildren();
91 for (const g of GROUPS) {
92 const det = document.createElement("details"); det.open = g.section === "numerics";
93 const sum = document.createElement("summary"); sum.textContent = g.title; det.appendChild(sum);
94 det.appendChild(this.grid(this.cfg[g.section] as unknown as Record<string, unknown>, g.fields));
95 this.root.appendChild(det);
96 }
97 const det = document.createElement("details"); det.open = true;
98 const sum = document.createElement("summary"); sum.textContent = `Hits (${this.cfg.hits.length})`; det.appendChild(sum);
99 this.cfg.hits.forEach((hit, i) => {
100 const box = document.createElement("div"); box.className = "hit";
101 const head = document.createElement("div"); head.className = "head";
102 const title = document.createElement("span"); title.textContent = `hit ${i + 1}`;
103 const kind = document.createElement("select");
104 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); }
105 kind.onchange = () => { hit.exciter = kind.value === "stick" ? defaultStick() : defaultMallet(); this.emit(); this.render(); };
106 const rm = document.createElement("button"); rm.textContent = "remove";
107 rm.onclick = () => { this.cfg.hits.splice(i, 1); this.emit(); this.render(); };
108 head.append(title, kind, rm); box.appendChild(head);
109 box.appendChild(this.grid(hit as unknown as Record<string, unknown>, HIT_FIELDS));
110 box.appendChild(this.grid(hit.exciter as unknown as Record<string, unknown>, EXCITER_FIELDS[hit.exciter.kind]));
111 det.appendChild(box);
112 });
113 const add = document.createElement("div"); add.className = "row buttons";
114 for (const k of ["mallet", "stick"] as const) {
115 const b = document.createElement("button"); b.textContent = `+ ${k} hit`;
116 b.onclick = () => {
117 const last = this.cfg.hits[this.cfg.hits.length - 1];
118 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,
119 exciter: k === "stick" ? defaultStick() : defaultMallet() });
120 this.emit(); this.render();
121 };
122 add.appendChild(b);
123 }
124 det.appendChild(add);
125 this.root.appendChild(det);
126 }
↑↓ move↵openescclose