/ proof-of-concept / plate-dynamics-lab-2
Sign in
proof-of-concept / plate-dynamics-lab-2
plate-dynamics-lab-2 / packages / components / src / params / ParameterPanel.tsx
531 lines · 17.9 KBBlameHistoryRaw
1import type { ReactNode } from "react";
2import {
3 HAMMER_PRESETS,
4 MATERIAL_PRESETS,
5 PLATE_PRESETS,
6 type ProblemSpec,
7 type ValidationIssue,
8} from "@plate-dyn/core";
9import { NumberField } from "./NumberField";
11export type ParameterSection =
12 | "plate"
13 | "material"
14 | "suspension"
15 | "hammer"
16 | "strike"
17 | "probe"
18 | "mic"
19 | "render"
20 | "rr"
21 | "fem";
23export const ALL_SECTIONS: ParameterSection[] = [
24 "plate",
25 "material",
26 "suspension",
27 "hammer",
28 "strike",
29 "probe",
30 "mic",
31 "render",
32 "rr",
33 "fem",
34];
36export interface ParameterPanelProps {
37 spec: ProblemSpec;
38 onChange: (spec: ProblemSpec) => void;
39 /** Sections to show; default all */
40 sections?: ParameterSection[];
41 issues?: ValidationIssue[];
42 /** Sections open initially; default all */
43 open?: ParameterSection[];
46/** Controls that edit the spec. Every control maps to one field of the `params` struct. */
47export function ParameterPanel(props: ParameterPanelProps) {
48 const { spec, onChange, sections = ALL_SECTIONS, issues = [], open } = props;
49 const set = (patch: Partial<ProblemSpec>) => onChange({ ...spec, ...patch });
50 const issue = (path: string) =>
51 issues.find((i) => i.path === path || i.path.startsWith(path + "."));
52 const invalid = (path: string) => issue(path)?.severity === "error";
53 const show = (s: ParameterSection) => sections.includes(s);
54 const isOpen = (s: ParameterSection) => (open ? open.includes(s) : true);
56 const materialPresetId = MATERIAL_PRESETS.find(
57 (p) => p.material.name === spec.material.name && p.material.E === spec.material.E,
58 )?.id;
59 const platePresetId = PLATE_PRESETS.find(
60 (p) =>
61 p.plate.width === spec.plate.width &&
62 p.plate.height === spec.plate.height &&
63 p.plate.thickness === spec.plate.thickness,
64 )?.id;
65 const hammerPresetId = HAMMER_PRESETS.find(
66 (p) =>
67 p.hammer.mass === spec.hammer.mass &&
68 p.hammer.tip_radius === spec.hammer.tip_radius &&
69 p.hammer.E === spec.hammer.E,
70 )?.id;
71 const half = { x: spec.plate.width / 2, y: spec.plate.height / 2 };
73 return (
74 <div className="pl-params">
75 {show("plate") && (
76 <Group title="Plate" issues={issues} prefix="plate" open={isOpen("plate")}>
77 <div className="pl-field pl-field-wide">
78 <label>Stock</label>
79 <select
80 className="pl-select"
81 value={platePresetId ?? ""}
82 onChange={(ev) => {
83 const p = PLATE_PRESETS.find((x) => x.id === ev.target.value);
84 if (p) set({ plate: { ...p.plate } });
85 }}
86 >
87 <option value="">custom</option>
88 {PLATE_PRESETS.map((p) => (
89 <option key={p.id} value={p.id}>
90 {p.name}
91 </option>
92 ))}
93 </select>
94 </div>
95 {platePresetId && (
96 <div className="pl-small pl-muted">
97 {PLATE_PRESETS.find((p) => p.id === platePresetId)?.note}
98 </div>
99 )}
100 <NumberField
101 label="Width (x)"
102 value={spec.plate.width}
103 scale={1000}
104 unit="mm"
105 invalid={invalid("plate.width")}
106 onChange={(v) => set({ plate: { ...spec.plate, width: v } })}
107 />
108 <NumberField
109 label="Height (y, the thread at the top)"
110 value={spec.plate.height}
111 scale={1000}
112 unit="mm"
113 invalid={invalid("plate.height")}
114 onChange={(v) => set({ plate: { ...spec.plate, height: v } })}
115 />
116 <NumberField
117 label="Thickness"
118 value={spec.plate.thickness}
119 scale={1000}
120 unit="mm"
121 invalid={invalid("plate.thickness")}
122 onChange={(v) => set({ plate: { ...spec.plate, thickness: v } })}
123 />
124 </Group>
125 )}
127 {show("material") && (
128 <Group title="Material" issues={issues} prefix="material" open={isOpen("material")}>
129 <div className="pl-field pl-field-wide">
130 <label>Alloy</label>
131 <select
132 className="pl-select"
133 value={materialPresetId ?? ""}
134 onChange={(ev) => {
135 const p = MATERIAL_PRESETS.find((x) => x.id === ev.target.value);
136 if (p) set({ material: { ...p.material } });
137 }}
138 >
139 <option value="">custom</option>
140 {MATERIAL_PRESETS.map((p) => (
141 <option key={p.id} value={p.id}>
142 {p.material.name}
143 </option>
144 ))}
145 </select>
146 </div>
147 {materialPresetId && (
148 <div className="pl-small pl-muted">
149 {MATERIAL_PRESETS.find((p) => p.id === materialPresetId)?.source}
150 </div>
151 )}
152 <NumberField
153 label="Young's modulus E"
154 value={spec.material.E}
155 scale={1e-9}
156 unit="GPa"
157 invalid={invalid("material.E")}
158 onChange={(v) =>
159 set({ material: { ...spec.material, E: v, name: customName(spec.material.name) } })
160 }
161 />
162 <NumberField
163 label="Poisson's ratio ν"
164 value={spec.material.nu}
165 step={0.01}
166 invalid={invalid("material.nu")}
167 onChange={(v) =>
168 set({ material: { ...spec.material, nu: v, name: customName(spec.material.name) } })
169 }
170 />
171 <NumberField
172 label="Density ρ"
173 value={spec.material.rho}
174 unit="kg/m³"
175 invalid={invalid("material.rho")}
176 onChange={(v) =>
177 set({ material: { ...spec.material, rho: v, name: customName(spec.material.name) } })
178 }
179 />
180 <NumberField
181 label="Structural Q"
182 value={spec.material.Q}
183 invalid={invalid("material.Q")}
184 title="Internal loss alone: 1/Q per mode; air loading and the thread's dashpot are added per mode"
185 onChange={(v) => set({ material: { ...spec.material, Q: v } })}
186 />
187 </Group>
188 )}
190 {show("suspension") && (
191 <Group title="Hole and thread" issues={issues} prefix="hole" open={isOpen("suspension")}>
192 <NumberField
193 label="Hole diameter"
194 value={spec.hole.diameter}
195 scale={1000}
196 unit="mm"
197 invalid={invalid("hole.diameter")}
198 onChange={(v) => set({ hole: { ...spec.hole, diameter: v } })}
199 />
200 <NumberField
201 label="Hole below the top edge"
202 value={spec.hole.edge_offset}
203 scale={1000}
204 unit="mm"
205 invalid={invalid("hole.edge_offset")}
206 onChange={(v) => set({ hole: { ...spec.hole, edge_offset: v } })}
207 />
208 <NumberField
209 label="Thread length"
210 value={spec.thread.length}
211 scale={1000}
212 unit="mm"
213 invalid={invalid("thread.length")}
214 title="Sets the pendulum spring m g / L at the hole"
215 onChange={(v) => set({ thread: { ...spec.thread, length: v } })}
216 />
217 <NumberField
218 label="Dashpot at the hole"
219 value={spec.thread.dashpot}
220 scale={1000}
221 unit="mN s/m"
222 invalid={invalid("thread.dashpot")}
223 onChange={(v) => set({ thread: { ...spec.thread, dashpot: v } })}
224 />
225 <div className="pl-small pl-muted">
226 The thread is a soft point spring at the hole, not a clamp; the plate is free to rotate
227 about it. The dashpot is the loss where the thread rubs the hole.
228 </div>
229 </Group>
230 )}
232 {show("hammer") && (
233 <Group title="Hammer" issues={issues} prefix="hammer" open={isOpen("hammer")}>
234 <div className="pl-field pl-field-wide">
235 <label>Hammer</label>
236 <select
237 className="pl-select"
238 value={hammerPresetId ?? ""}
239 onChange={(ev) => {
240 const p = HAMMER_PRESETS.find((x) => x.id === ev.target.value);
241 if (p) set({ hammer: { ...p.hammer, speed: spec.hammer.speed } });
242 }}
243 >
244 <option value="">custom</option>
245 {HAMMER_PRESETS.map((p) => (
246 <option key={p.id} value={p.id}>
247 {p.name}
248 </option>
249 ))}
250 </select>
251 </div>
252 {hammerPresetId && (
253 <div className="pl-small pl-muted">
254 {HAMMER_PRESETS.find((p) => p.id === hammerPresetId)?.note}
255 </div>
256 )}
257 <NumberField
258 label="Mass"
259 value={spec.hammer.mass}
260 scale={1000}
261 unit="g"
262 invalid={invalid("hammer.mass")}
263 onChange={(v) => set({ hammer: { ...spec.hammer, mass: v } })}
264 />
265 <NumberField
266 label="Tip radius"
267 value={spec.hammer.tip_radius}
268 scale={1000}
269 unit="mm"
270 invalid={invalid("hammer.tip_radius")}
271 onChange={(v) => set({ hammer: { ...spec.hammer, tip_radius: v } })}
272 />
273 <NumberField
274 label="Approach speed"
275 value={spec.hammer.speed}
276 step={0.05}
277 unit="m/s"
278 invalid={invalid("hammer.speed")}
279 onChange={(v) => set({ hammer: { ...spec.hammer, speed: v } })}
280 />
281 <NumberField
282 label="Restitution e"
283 value={spec.hammer.restitution}
284 step={0.02}
285 invalid={invalid("hammer.restitution")}
286 title="Nominal: sets the impulse of the prescribed pulse and the loss of the coupled contact"
287 onChange={(v) => set({ hammer: { ...spec.hammer, restitution: v } })}
288 />
289 <NumberField
290 label="Tip modulus E"
291 value={spec.hammer.E}
292 scale={1e-9}
293 unit="GPa"
294 invalid={invalid("hammer.E")}
295 onChange={(v) => set({ hammer: { ...spec.hammer, E: v } })}
296 />
297 <NumberField
298 label="Tip Poisson's ratio"
299 value={spec.hammer.nu}
300 step={0.01}
301 invalid={invalid("hammer.nu")}
302 onChange={(v) => set({ hammer: { ...spec.hammer, nu: v } })}
303 />
304 </Group>
305 )}
307 {show("strike") && (
308 <Group title="Strike point" issues={issues} prefix="strike" open={isOpen("strike")}>
309 <NumberField
310 label="x from the centre"
311 value={spec.strike.x}
312 scale={1000}
313 unit="mm"
314 min={-half.x * 1000}
315 max={half.x * 1000}
316 invalid={invalid("strike")}
317 onChange={(v) => set({ strike: { ...spec.strike, x: v } })}
318 />
319 <NumberField
320 label="y from the centre"
321 value={spec.strike.y}
322 scale={1000}
323 unit="mm"
324 min={-half.y * 1000}
325 max={half.y * 1000}
326 invalid={invalid("strike")}
327 title="Positive towards the threaded top edge"
328 onChange={(v) => set({ strike: { ...spec.strike, y: v } })}
329 />
330 <div className="pl-small pl-muted">
331 The centre lies on both symmetry axes and leaves most asymmetric modes silent; a few
332 millimetres off centre wakes them.
333 </div>
334 </Group>
335 )}
337 {show("probe") && (
338 <Group title="Probe" issues={issues} prefix="probe" open={isOpen("probe")}>
339 <NumberField
340 label="x from the centre"
341 value={spec.probe.x}
342 scale={1000}
343 unit="mm"
344 invalid={invalid("probe")}
345 onChange={(v) => set({ probe: { ...spec.probe, x: v } })}
346 />
347 <NumberField
348 label="y from the centre"
349 value={spec.probe.y}
350 scale={1000}
351 unit="mm"
352 invalid={invalid("probe")}
353 onChange={(v) => set({ probe: { ...spec.probe, y: v } })}
354 />
355 </Group>
356 )}
358 {show("mic") && (
359 <Group title="Microphone" issues={issues} prefix="mic" open={isOpen("mic")}>
360 <NumberField
361 label="Distance on the normal"
362 value={spec.mic.distance}
363 scale={1000}
364 unit="mm"
365 invalid={invalid("mic.distance")}
366 onChange={(v) => set({ mic: { ...spec.mic, distance: v } })}
367 />
368 <NumberField
369 label="Air density"
370 value={spec.mic.air_density}
371 step={0.01}
372 unit="kg/m³"
373 invalid={invalid("mic.air_density")}
374 onChange={(v) => set({ mic: { ...spec.mic, air_density: v } })}
375 />
376 <NumberField
377 label="Speed of sound"
378 value={spec.mic.sound_speed}
379 unit="m/s"
380 invalid={invalid("mic.sound_speed")}
381 onChange={(v) => set({ mic: { ...spec.mic, sound_speed: v } })}
382 />
383 </Group>
384 )}
386 {show("render") && (
387 <Group title="Render" issues={issues} prefix="render" open={isOpen("render")}>
388 <NumberField
389 label="Sample rate"
390 value={spec.render.fs}
391 unit="Hz"
392 integer
393 invalid={invalid("render.fs")}
394 title="Modes are kept below 0.47 of this; 96 kHz reaches this small plate's ultrasonic partials"
395 onChange={(v) => set({ render: { ...spec.render, fs: v } })}
396 />
397 <NumberField
398 label="Duration"
399 value={spec.render.duration}
400 step={0.5}
401 unit="s"
402 invalid={invalid("render.duration")}
403 onChange={(v) => set({ render: { ...spec.render, duration: v } })}
404 />
405 <NumberField
406 label="Modes kept, at most"
407 value={spec.render.modes}
408 integer
409 invalid={invalid("render.modes")}
410 onChange={(v) => set({ render: { ...spec.render, modes: v } })}
411 />
412 <NumberField
413 label="Export peak"
414 value={spec.render.normalize_dbfs}
415 step={0.5}
416 unit="dBFS"
417 invalid={invalid("render.normalize_dbfs")}
418 onChange={(v) => set({ render: { ...spec.render, normalize_dbfs: v } })}
419 />
420 <NumberField
421 label="Animation frames"
422 value={spec.render.nframes}
423 integer
424 invalid={invalid("render.nframes")}
425 onChange={(v) => set({ render: { ...spec.render, nframes: v } })}
426 />
427 <NumberField
428 label="Frames cover the first"
429 value={spec.render.frame_span}
430 scale={1000}
431 step={0.5}
432 unit="ms"
433 invalid={invalid("render.frame_span")}
434 title="The strike transient: a wave leaving the hammer and filling the plate"
435 onChange={(v) => set({ render: { ...spec.render, frame_span: v } })}
436 />
437 </Group>
438 )}
440 {show("rr") && (
441 <Group title="Rayleigh-Ritz numerics" issues={issues} prefix="rr" open={isOpen("rr")}>
442 <NumberField
443 label="Legendre order per side"
444 value={spec.rr.basis_order}
445 integer
446 invalid={invalid("rr.basis_order")}
447 title="(order + 1)^2 coefficients; 12 was stable to 0.01 Hz against 14 on the first ten modes"
448 onChange={(v) => set({ rr: { ...spec.rr, basis_order: v } })}
449 />
450 <NumberField
451 label="Quadrature points per side"
452 value={spec.rr.quadrature_order}
453 integer
454 invalid={invalid("rr.quadrature_order")}
455 onChange={(v) => set({ rr: { ...spec.rr, quadrature_order: v } })}
456 />
457 <NumberField
458 label="Display grid per side"
459 value={spec.rr.grid}
460 integer
461 invalid={invalid("rr.grid")}
462 onChange={(v) => set({ rr: { ...spec.rr, grid: v } })}
463 />
464 </Group>
465 )}
467 {show("fem") && (
468 <Group title="Finite-element numerics" issues={issues} prefix="fem" open={isOpen("fem")}>
469 <NumberField
470 label="Elements along x"
471 value={spec.fem.mesh_x}
472 integer
473 invalid={invalid("fem.mesh_x")}
474 onChange={(v) => set({ fem: { ...spec.fem, mesh_x: v } })}
475 />
476 <NumberField
477 label="Elements along y"
478 value={spec.fem.mesh_y}
479 integer
480 invalid={invalid("fem.mesh_y")}
481 onChange={(v) => set({ fem: { ...spec.fem, mesh_y: v } })}
482 />
483 <div className="pl-small pl-muted">
484 Full-integration Q4 locks in shear on a thin plate: the reference used 80 × 80, where
485 the main partial is 1 percent above Rayleigh-Ritz; 48 × 48 is 3 percent above and solves
486 in a few seconds, 80 × 80 in tens of seconds.
487 </div>
488 </Group>
489 )}
490 </div>
491 );
494function customName(name: string): string {
495 return name.endsWith(" (edited)") ? name : `${name} (edited)`;
498function Group(props: {
499 title: string;
500 prefix: string;
501 issues: ValidationIssue[];
502 open: boolean;
503 disabled?: boolean;
504 children: ReactNode;
505}) {
506 const prefixes = props.prefix === "hole" ? ["hole", "thread"] : [props.prefix];
507 const related = props.issues.filter((i) =>
508 prefixes.some((p) => i.path === p || i.path.startsWith(p + ".")),
509 );
510 return (
511 <details className={`pl-group${props.disabled ? " pl-disabled" : ""}`} open={props.open}>
512 <summary>
513 {props.title}
514 {related.some((i) => i.severity === "error") && (
515 <span className="pl-badge pl-badge-error" style={{ marginLeft: 6 }}>
516 check
517 </span>
518 )}
519 </summary>
520 {props.children}
521 {related.map((i) => (
522 <div
523 key={i.path + i.message}
524 className={`pl-issue${i.severity === "warning" ? " pl-issue-warning" : ""}`}
525 >
526 {i.path}: {i.message}
527 </div>
528 ))}
529 </details>
530 );
↑↓ move↵openescclose