/ proof-of-concept / plate-dynamics-lab
Sign in
proof-of-concept / plate-dynamics-lab
plate-dynamics-lab / packages / components / src / params / ParameterPanel.tsx
1007 lines · 33.1 KBBlameHistoryRaw
1import type { ReactNode } from "react";
2import {
3 MATERIAL_PRESETS,
4 PAD_PRESETS,
5 PLATE_PRESETS,
6 THREAD_PRESETS,
7 defaultMount,
8 type Excitation,
9 type Mount,
10 type MountType,
11 type Pad,
12 type PlateShape,
13 type ProblemSpec,
14 type ValidationIssue,
15 type ViseEdge,
16} from "@plate-dyn/core";
17import { NumberField } from "./NumberField";
18import { CflSlider } from "./CflSlider";
19import type { WebGpuSupport } from "../hooks/useWebGpu";
21export type ParameterSection =
22 | "plate"
23 | "material"
24 | "mount"
25 | "gravity"
26 | "damping"
27 | "excitation"
28 | "probe"
29 | "listener"
30 | "output"
31 | "fd";
33export const ALL_SECTIONS: ParameterSection[] = [
34 "plate",
35 "material",
36 "mount",
37 "gravity",
38 "damping",
39 "excitation",
40 "probe",
41 "listener",
42 "output",
43 "fd",
44];
46export interface ParameterPanelProps {
47 spec: ProblemSpec;
48 onChange: (spec: ProblemSpec) => void;
49 /** Sections to show; default all */
50 sections?: ParameterSection[];
51 issues?: ValidationIssue[];
52 /** Largest stable step, when known, for the CFL slider */
53 fdDtMax?: number;
54 /** Sections open initially; default all */
55 open?: ParameterSection[];
56 /** The GPU option of the time loop: whether it is on, and what this browser offers */
57 fdGpu?: { enabled: boolean; onChange: (v: boolean) => void; support: WebGpuSupport };
60const MOUNT_LABELS: Record<MountType, string> = {
61 thread: "hanging from one thread",
62 threads: "hanging from two threads",
63 pads: "resting on pads",
64 cymbal: "on a cymbal stand",
65 vise: "one edge in a vise",
66 pinched: "pinched between fingers",
67};
69/** Controls that edit the spec. Every control maps to one field of the `params` struct. */
70export function ParameterPanel(props: ParameterPanelProps) {
71 const { spec, onChange, sections = ALL_SECTIONS, issues = [], fdDtMax, open, fdGpu } = props;
72 const set = (patch: Partial<ProblemSpec>) => onChange({ ...spec, ...patch });
73 const issue = (path: string) =>
74 issues.find((i) => i.path === path || i.path.startsWith(path + "."));
75 const invalid = (path: string) => issue(path)?.severity === "error";
76 const show = (s: ParameterSection) => sections.includes(s);
77 const isOpen = (s: ParameterSection) => (open ? open.includes(s) : true);
78 const e = spec.excitation;
79 const m = spec.mount;
80 const setMount = (patch: Partial<Mount>) => set({ mount: { ...m, ...patch } as Mount });
82 const materialPresetId = MATERIAL_PRESETS.find(
83 (p) => p.material.name === spec.material.name && p.material.E === spec.material.E,
84 )?.id;
85 const platePresetId = PLATE_PRESETS.find(
86 (p) =>
87 p.plate.shape === spec.plate.shape &&
88 p.plate.Lx === spec.plate.Lx &&
89 p.plate.Ly === spec.plate.Ly &&
90 p.plate.h === spec.plate.h,
91 )?.id;
92 const ellipse = spec.plate.shape === "ellipse";
94 return (
95 <div className="pl-params">
96 {show("plate") && (
97 <Group title="Plate" issues={issues} prefix="plate" open={isOpen("plate")}>
98 <div className="pl-field pl-field-wide">
99 <label>Stock</label>
100 <select
101 className="pl-select"
102 value={platePresetId ?? ""}
103 onChange={(ev) => {
104 const p = PLATE_PRESETS.find((x) => x.id === ev.target.value);
105 if (p) set({ plate: { ...p.plate } });
106 }}
107 >
108 <option value="">custom</option>
109 {PLATE_PRESETS.map((p) => (
110 <option key={p.id} value={p.id}>
111 {p.name}
112 </option>
113 ))}
114 </select>
115 </div>
116 {platePresetId && (
117 <div className="pl-small pl-muted">
118 {PLATE_PRESETS.find((p) => p.id === platePresetId)?.note}
119 </div>
120 )}
121 <div className="pl-field pl-field-wide">
122 <label>Shape</label>
123 <select
124 className="pl-select"
125 value={spec.plate.shape}
126 onChange={(ev) =>
127 set({ plate: { ...spec.plate, shape: ev.target.value as PlateShape } })
128 }
129 >
130 <option value="rectangle">rectangle</option>
131 <option value="ellipse">ellipse (a disk when Lx = Ly)</option>
132 </select>
133 </div>
134 <NumberField
135 label={ellipse ? "Diameter along x, Lx" : "Length Lx"}
136 value={spec.plate.Lx}
137 scale={1000}
138 unit="mm"
139 invalid={invalid("plate.Lx")}
140 onChange={(v) => set({ plate: { ...spec.plate, Lx: v } })}
141 />
142 <NumberField
143 label={ellipse ? "Diameter along y, Ly" : "Width Ly"}
144 value={spec.plate.Ly}
145 scale={1000}
146 unit="mm"
147 invalid={invalid("plate.Ly")}
148 onChange={(v) => set({ plate: { ...spec.plate, Ly: v } })}
149 />
150 <NumberField
151 label="Thickness h"
152 value={spec.plate.h}
153 scale={1000}
154 unit="mm"
155 step={0.1}
156 invalid={invalid("plate.h")}
157 onChange={(v) => set({ plate: { ...spec.plate, h: v } })}
158 />
159 {ellipse && (
160 <div className="pl-small pl-muted">
161 The ellipse fills the Lx by Ly box, and positions are still measured from the box's
162 corner. On the grid the edge becomes a staircase of nodes, first order in the spacing.
163 </div>
164 )}
165 </Group>
166 )}
167 {show("material") && (
168 <Group title="Material" issues={issues} prefix="material" open={isOpen("material")}>
169 <div className="pl-field pl-field-wide">
170 <label>Preset</label>
171 <select
172 className="pl-select"
173 value={materialPresetId ?? ""}
174 onChange={(ev) => {
175 const p = MATERIAL_PRESETS.find((x) => x.id === ev.target.value);
176 if (p) set({ material: { ...p.material }, damping: { ...p.damping } });
177 }}
178 >
179 <option value="">custom</option>
180 {MATERIAL_PRESETS.map((p) => (
181 <option key={p.id} value={p.id}>
182 {p.material.name}
183 </option>
184 ))}
185 </select>
186 </div>
187 {materialPresetId && (
188 <div className="pl-small pl-muted">
189 {MATERIAL_PRESETS.find((p) => p.id === materialPresetId)?.source} Handbook values, not
190 a certificate.
191 </div>
192 )}
193 <NumberField
194 label="Young's modulus E"
195 value={spec.material.E}
196 scale={1e-9}
197 unit="GPa"
198 invalid={invalid("material.E")}
199 onChange={(v) =>
200 set({ material: { ...spec.material, E: v, name: customName(spec.material.name) } })
201 }
202 />
203 <NumberField
204 label="Poisson's ratio ν"
205 value={spec.material.nu}
206 step={0.01}
207 invalid={invalid("material.nu")}
208 onChange={(v) =>
209 set({ material: { ...spec.material, nu: v, name: customName(spec.material.name) } })
210 }
211 />
212 <NumberField
213 label="Density ρ"
214 value={spec.material.rho}
215 unit="kg/m³"
216 invalid={invalid("material.rho")}
217 onChange={(v) =>
218 set({ material: { ...spec.material, rho: v, name: customName(spec.material.name) } })
219 }
220 />
221 </Group>
222 )}
223 {show("mount") && (
224 <Group title="Mount" issues={issues} prefix="mount" open={isOpen("mount")}>
225 <div className="pl-field pl-field-wide">
226 <label>Held</label>
227 <select
228 className="pl-select"
229 value={m.type}
230 onChange={(ev) =>
231 set({ mount: defaultMount(ev.target.value as MountType, spec.plate, m) })
232 }
233 >
234 {(Object.keys(MOUNT_LABELS) as MountType[]).map((t) => (
235 <option key={t} value={t}>
236 {MOUNT_LABELS[t]}
237 </option>
238 ))}
239 </select>
240 </div>
241 <MountFields mount={m} spec={spec} setMount={setMount} invalid={invalid} />
242 </Group>
243 )}
244 {show("gravity") && (
245 <Group title="Gravity" issues={issues} prefix="gravity" open={isOpen("gravity")}>
246 <NumberField
247 label="g"
248 value={spec.gravity.g}
249 unit="m/s²"
250 step={0.01}
251 min={0}
252 invalid={invalid("gravity.g")}
253 onChange={(v) => set({ gravity: { g: v } })}
254 />
255 <div className="pl-small pl-muted">
256 Zero switches gravity off: a hung plate then has no pendulum stiffness, a plate on pads
257 no weight to hold it down. Its direction is set by the mount.
258 </div>
259 </Group>
260 )}
261 {show("damping") && (
262 <Group title="Damping" issues={issues} prefix="damping" open={isOpen("damping")}>
263 <NumberField
264 label="σ₀ (all frequencies)"
265 value={spec.damping.sigma0}
266 unit="1/s"
267 step={0.1}
268 invalid={invalid("damping")}
269 onChange={(v) => set({ damping: { ...spec.damping, sigma0: v } })}
270 />
271 <NumberField
272 label="σ₁ (rises with frequency)"
273 value={spec.damping.sigma1}
274 unit="m²/s"
275 step={0.001}
276 invalid={invalid("damping")}
277 onChange={(v) => set({ damping: { ...spec.damping, sigma1: v } })}
278 />
279 <div className="pl-small pl-muted">
280 A mode with Laplacian eigenvalue λ decays at rate σ₀ + σ₁λ; σ₀ also slows the pendulum
281 motion, as air would. Illustrative values; the mount's own dashpots are set with it.
282 </div>
283 </Group>
284 )}
285 {show("excitation") && (
286 <Group title="Excitation" issues={issues} prefix="excitation" open={isOpen("excitation")}>
287 <div className="pl-field pl-field-wide">
288 <label>Type</label>
289 <select
290 className="pl-select"
291 value={e.type}
292 onChange={(ev) => {
293 const type = ev.target.value as Excitation["type"];
294 const next: Excitation =
295 type === "impulse"
296 ? { type, x: e.x, y: e.y, force: 50, duration: 1e-3, radius: e.radius }
297 : { type, x: e.x, y: e.y, displacement: 0.002, radius: e.radius };
298 set({ excitation: next });
299 }}
300 >
301 <option value="impulse">hammer strike (half-sine pulse)</option>
302 <option value="release">pull aside and let go</option>
303 </select>
304 </div>
305 <NumberField
306 label={e.type === "impulse" ? "Strike x" : "Pull x"}
307 value={e.x}
308 scale={1000}
309 unit="mm"
310 invalid={invalid("excitation")}
311 onChange={(v) => set({ excitation: { ...e, x: v } })}
312 />
313 <NumberField
314 label={e.type === "impulse" ? "Strike y" : "Pull y"}
315 value={e.y}
316 scale={1000}
317 unit="mm"
318 invalid={invalid("excitation")}
319 onChange={(v) => set({ excitation: { ...e, y: v } })}
320 />
321 {e.type === "impulse" ? (
322 <>
323 <NumberField
324 label="Peak force"
325 value={e.force}
326 unit="N"
327 invalid={invalid("excitation.force")}
328 onChange={(v) => set({ excitation: { ...e, force: v } })}
329 />
330 <NumberField
331 label="Contact time"
332 value={e.duration}
333 scale={1000}
334 unit="ms"
335 step={0.1}
336 invalid={invalid("excitation.duration")}
337 onChange={(v) => set({ excitation: { ...e, duration: v } })}
338 />
339 </>
340 ) : (
341 <NumberField
342 label="Pull, then release"
343 value={e.displacement}
344 scale={1000}
345 unit="mm"
346 step={0.5}
347 invalid={invalid("excitation.displacement")}
348 onChange={(v) => set({ excitation: { ...e, displacement: v } })}
349 />
350 )}
351 <NumberField
352 label="Footprint radius"
353 value={e.radius}
354 scale={1000}
355 unit="mm"
356 step={1}
357 invalid={invalid("excitation.radius")}
358 onChange={(v) => set({ excitation: { ...e, radius: v } })}
359 />
360 <div className="pl-small pl-muted">
361 {e.type === "impulse"
362 ? "A finite footprint stands in for the contact patch; a point load would give infinite stress under the hammer."
363 : "The plate is held in the static shape a force at this point produces, scaled to the displacement asked for, and let go at t = 0. On one thread the pull is applied without twisting the plate."}
364 </div>
365 </Group>
366 )}
367 {show("probe") && (
368 <Group title="Probe" issues={issues} prefix="probe" open={isOpen("probe")}>
369 <NumberField
370 label="x"
371 value={spec.probe.x}
372 scale={1000}
373 unit="mm"
374 invalid={invalid("probe")}
375 onChange={(v) => set({ probe: { ...spec.probe, x: v } })}
376 />
377 <NumberField
378 label="y"
379 value={spec.probe.y}
380 scale={1000}
381 unit="mm"
382 invalid={invalid("probe")}
383 onChange={(v) => set({ probe: { ...spec.probe, y: v } })}
384 />
385 <div className="pl-small pl-muted">
386 Time series and spectra are recorded here, at the nearest grid node with a full stencil.
387 </div>
388 </Group>
389 )}
390 {show("listener") && (
391 <Group title="Listener (sound)" issues={issues} prefix="listener" open={isOpen("listener")}>
392 <div className="pl-field pl-field-wide">
393 <label>Model</label>
394 <select
395 className="pl-select"
396 value={spec.listener.model}
397 onChange={(ev) =>
398 set({
399 listener: { ...spec.listener, model: ev.target.value as "baffled" | "unbaffled" },
400 })
401 }
402 >
403 <option value="unbaffled">
404 unbaffled: plate in free air (front and back cancel)
405 </option>
406 <option value="baffled">baffled: plate set in a large table or wall</option>
407 </select>
408 </div>
409 <NumberField
410 label="x"
411 value={spec.listener.x}
412 scale={1000}
413 unit="mm"
414 onChange={(v) => set({ listener: { ...spec.listener, x: v } })}
415 />
416 <NumberField
417 label="y"
418 value={spec.listener.y}
419 scale={1000}
420 unit="mm"
421 onChange={(v) => set({ listener: { ...spec.listener, y: v } })}
422 />
423 <NumberField
424 label="Distance from the plate z"
425 value={spec.listener.z}
426 unit="m"
427 step={0.1}
428 invalid={invalid("listener.z")}
429 onChange={(v) => set({ listener: { ...spec.listener, z: v } })}
430 />
431 <div className="pl-small pl-muted">
432 In plate coordinates, z along the normal. Either model is an estimate of the waveform,
433 not a calibrated level; the unbaffled one is a rough account of the front-back
434 cancellation.
435 </div>
436 </Group>
437 )}
438 {show("output") && (
439 <Group title="Output" issues={issues} prefix="output" open={isOpen("output")}>
440 <NumberField
441 label="Sample rate"
442 value={spec.output.fs}
443 unit="Hz"
444 integer
445 invalid={invalid("output.fs")}
446 onChange={(v) => set({ output: { ...spec.output, fs: v } })}
447 />
448 <NumberField
449 label="Animation frames"
450 value={spec.output.nframes}
451 integer
452 invalid={invalid("output.nframes")}
453 onChange={(v) => set({ output: { ...spec.output, nframes: v } })}
454 />
455 </Group>
456 )}
457 {show("fd") && (
458 <Group title="Grid and time" issues={issues} prefix="fd" open={isOpen("fd")}>
459 <NumberField
460 label="Grid nx"
461 value={spec.fd.nx}
462 integer
463 min={5}
464 invalid={invalid("fd")}
465 onChange={(v) => set({ fd: { ...spec.fd, nx: v } })}
466 />
467 <NumberField
468 label="Grid ny"
469 value={spec.fd.ny}
470 integer
471 min={5}
472 invalid={invalid("fd")}
473 onChange={(v) => set({ fd: { ...spec.fd, ny: v } })}
474 />
475 <NumberField
476 label="Simulated time"
477 value={spec.fd.duration}
478 unit="s"
479 step={0.1}
480 invalid={invalid("fd.duration")}
481 onChange={(v) => set({ fd: { ...spec.fd, duration: v } })}
482 />
483 <div className="pl-small" style={{ marginTop: 6 }}>
484 Time step
485 </div>
486 <CflSlider
487 value={spec.fd.cfl}
488 dtMax={fdDtMax}
489 onChange={(v) => set({ fd: { ...spec.fd, cfl: v } })}
490 />
491 {fdGpu && <GpuToggle {...fdGpu} />}
492 </Group>
493 )}
494 </div>
495 );
498function MountFields({
499 mount: m,
500 spec,
501 setMount,
502 invalid,
503}: {
504 mount: Mount;
505 spec: ProblemSpec;
506 setMount: (patch: Partial<Mount>) => void;
507 invalid: (path: string) => boolean;
508}) {
509 const padPresetId = (k: number, c: number) => PAD_PRESETS.find((p) => p.k === k && p.c === c)?.id;
510 switch (m.type) {
511 case "thread": {
512 const threadId = THREAD_PRESETS.find(
513 (t) => t.thread.diameter === m.thread.diameter && t.thread.G === m.thread.G,
514 )?.id;
515 return (
516 <>
517 <NumberField
518 label="Hole x"
519 value={m.x}
520 scale={1000}
521 unit="mm"
522 invalid={invalid("mount")}
523 onChange={(v) => setMount({ x: v })}
524 />
525 <NumberField
526 label="Hole y"
527 value={m.y}
528 scale={1000}
529 unit="mm"
530 invalid={invalid("mount")}
531 onChange={(v) => setMount({ y: v })}
532 />
533 <NumberField
534 label="Thread length"
535 value={m.length}
536 scale={1000}
537 unit="mm"
538 step={10}
539 invalid={invalid("mount.length")}
540 onChange={(v) => setMount({ length: v })}
541 />
542 <div className="pl-field pl-field-wide">
543 <label>Thread</label>
544 <select
545 className="pl-select"
546 value={threadId ?? ""}
547 onChange={(ev) => {
548 const t = THREAD_PRESETS.find((x) => x.id === ev.target.value);
549 if (t) setMount({ thread: { ...t.thread } });
550 }}
551 >
552 <option value="">custom</option>
553 {THREAD_PRESETS.map((t) => (
554 <option key={t.id} value={t.id}>
555 {t.thread.name}
556 </option>
557 ))}
558 </select>
559 </div>
560 <NumberField
561 label="Thread diameter"
562 value={m.thread.diameter}
563 scale={1000}
564 unit="mm"
565 step={0.1}
566 invalid={invalid("mount.thread")}
567 onChange={(v) =>
568 setMount({ thread: { ...m.thread, diameter: v, name: customName(m.thread.name) } })
569 }
570 />
571 <NumberField
572 label="Thread shear modulus"
573 value={m.thread.G}
574 scale={1e-9}
575 unit="GPa"
576 step={0.1}
577 invalid={invalid("mount.thread")}
578 onChange={(v) =>
579 setMount({ thread: { ...m.thread, G: v, name: customName(m.thread.name) } })
580 }
581 />
582 <div className="pl-small pl-muted">
583 The plate turns in its plane until its centre of mass hangs below the hole. The thread
584 is a transverse spring of stiffness T/L at the hole; its torsion, from the diameter and
585 modulus, is the only thing restraining the spin.
586 </div>
587 </>
588 );
589 }
590 case "threads":
591 return (
592 <>
593 <NumberField
594 label="Hole 1 x"
595 value={m.x1}
596 scale={1000}
597 unit="mm"
598 invalid={invalid("mount")}
599 onChange={(v) => setMount({ x1: v })}
600 />
601 <NumberField
602 label="Hole 1 y"
603 value={m.y1}
604 scale={1000}
605 unit="mm"
606 invalid={invalid("mount")}
607 onChange={(v) => setMount({ y1: v })}
608 />
609 <NumberField
610 label="Hole 2 x"
611 value={m.x2}
612 scale={1000}
613 unit="mm"
614 invalid={invalid("mount")}
615 onChange={(v) => setMount({ x2: v })}
616 />
617 <NumberField
618 label="Hole 2 y"
619 value={m.y2}
620 scale={1000}
621 unit="mm"
622 invalid={invalid("mount")}
623 onChange={(v) => setMount({ y2: v })}
624 />
625 <NumberField
626 label="Thread length"
627 value={m.length}
628 scale={1000}
629 unit="mm"
630 step={10}
631 invalid={invalid("mount.length")}
632 onChange={(v) => setMount({ length: v })}
633 />
634 <div className="pl-small pl-muted">
635 Two threads of one length from hooks at one height: the line of the holes hangs
636 horizontal and statics shares the weight. The centre of mass must lie between the holes.
637 </div>
638 </>
639 );
640 case "pads": {
641 const presetId = padPresetId(m.k, m.c);
642 const setPad = (k: number, patch: Partial<Pad>) =>
643 setMount({ pads: m.pads.map((p, i) => (i === k ? { ...p, ...patch } : p)) });
644 return (
645 <>
646 <div className="pl-field pl-field-wide">
647 <label>Pad material</label>
648 <select
649 className="pl-select"
650 value={presetId ?? ""}
651 onChange={(ev) => {
652 const p = PAD_PRESETS.find((x) => x.id === ev.target.value);
653 if (p) setMount({ k: p.k, c: p.c });
654 }}
655 >
656 <option value="">custom</option>
657 {PAD_PRESETS.map((p) => (
658 <option key={p.id} value={p.id}>
659 {p.name}
660 </option>
661 ))}
662 </select>
663 </div>
664 {presetId && (
665 <div className="pl-small pl-muted">
666 {PAD_PRESETS.find((p) => p.id === presetId)?.note}
667 </div>
668 )}
669 <NumberField
670 label="Stiffness per area"
671 value={m.k}
672 scale={1e-6}
673 unit="MN/m³"
674 step={0.1}
675 invalid={invalid("mount.k")}
676 onChange={(v) => setMount({ k: v })}
677 />
678 <NumberField
679 label="Damping per area"
680 value={m.c}
681 scale={1e-3}
682 unit="kN·s/m³"
683 step={0.1}
684 invalid={invalid("mount.c")}
685 onChange={(v) => setMount({ c: v })}
686 />
687 {m.pads.map((p, k) => (
688 <div key={k} className="pl-subgroup">
689 <div className="pl-row pl-small" style={{ justifyContent: "space-between" }}>
690 <strong>Pad {k + 1}</strong>
691 <button
692 className="pl-linkbtn"
693 onClick={() => setMount({ pads: m.pads.filter((_, i) => i !== k) })}
694 disabled={m.pads.length <= 1}
695 >
696 remove
697 </button>
698 </div>
699 <NumberField
700 label="Centre x"
701 value={p.x}
702 scale={1000}
703 unit="mm"
704 onChange={(v) => setPad(k, { x: v })}
705 />
706 <NumberField
707 label="Centre y"
708 value={p.y}
709 scale={1000}
710 unit="mm"
711 onChange={(v) => setPad(k, { y: v })}
712 />
713 <NumberField
714 label="Half-width along x"
715 value={p.rx}
716 scale={1000}
717 unit="mm"
718 step={1}
719 onChange={(v) => setPad(k, { rx: v })}
720 />
721 <NumberField
722 label="Half-width along y"
723 value={p.ry}
724 scale={1000}
725 unit="mm"
726 step={1}
727 onChange={(v) => setPad(k, { ry: v })}
728 />
729 </div>
730 ))}
731 <button
732 className="pl-btn"
733 onClick={() =>
734 setMount({
735 pads: [
736 ...m.pads,
737 { x: spec.plate.Lx / 2, y: spec.plate.Ly / 2, rx: 0.02, ry: 0.02 },
738 ],
739 })
740 }
741 >
742 Add a pad
743 </button>
744 <div className="pl-small pl-muted">
745 The plate lies flat on the pads. A pad pushes only while compressed, so a hard strike
746 can lift the plate off it. A long narrow pad is a knife edge or a strip of felt.
747 </div>
748 </>
749 );
750 }
751 case "cymbal": {
752 const presetId = padPresetId(m.k, m.c);
753 return (
754 <>
755 <NumberField
756 label="Rod radius"
757 value={m.r_rod}
758 scale={1000}
759 unit="mm"
760 step={0.5}
761 invalid={invalid("mount")}
762 onChange={(v) => setMount({ r_rod: v })}
763 />
764 <NumberField
765 label="Felt outer radius"
766 value={m.r_felt}
767 scale={1000}
768 unit="mm"
769 step={1}
770 invalid={invalid("mount")}
771 onChange={(v) => setMount({ r_felt: v })}
772 />
773 <div className="pl-field pl-field-wide">
774 <label>Felt</label>
775 <select
776 className="pl-select"
777 value={presetId ?? ""}
778 onChange={(ev) => {
779 const p = PAD_PRESETS.find((x) => x.id === ev.target.value);
780 if (p) setMount({ k: p.k, c: p.c });
781 }}
782 >
783 <option value="">custom</option>
784 {PAD_PRESETS.map((p) => (
785 <option key={p.id} value={p.id}>
786 {p.name}
787 </option>
788 ))}
789 </select>
790 </div>
791 <NumberField
792 label="Felt stiffness per area"
793 value={m.k}
794 scale={1e-6}
795 unit="MN/m³"
796 step={1}
797 invalid={invalid("mount.k")}
798 onChange={(v) => setMount({ k: v })}
799 />
800 <NumberField
801 label="Felt damping per area"
802 value={m.c}
803 scale={1e-3}
804 unit="kN·s/m³"
805 step={1}
806 invalid={invalid("mount.c")}
807 onChange={(v) => setMount({ c: v })}
808 />
809 <NumberField
810 label="Wing-nut preload"
811 value={m.preload}
812 scale={1000}
813 unit="mm"
814 step={0.1}
815 invalid={invalid("mount.preload")}
816 onChange={(v) => setMount({ preload: v })}
817 />
818 <div className="pl-small pl-muted">
819 A felt below and one above the plate, each compressed by the preload at rest: the centre
820 is held two-sided until a strike separates one felt. The rod, the hole and the cymbal's
821 bow are not modelled.
822 </div>
823 </>
824 );
825 }
826 case "vise":
827 return (
828 <>
829 <div className="pl-field pl-field-wide">
830 <label>Edge in the jaws</label>
831 <select
832 className="pl-select"
833 value={m.edge}
834 onChange={(ev) => setMount({ edge: ev.target.value as ViseEdge })}
835 >
836 <option value="x0">x = 0 (left)</option>
837 <option value="x1">x = Lx (right)</option>
838 <option value="y0">y = 0 (bottom)</option>
839 <option value="y1">y = Ly (top)</option>
840 </select>
841 </div>
842 <NumberField
843 label="Jaw depth"
844 value={m.depth}
845 scale={1000}
846 unit="mm"
847 step={1}
848 invalid={invalid("mount.depth")}
849 onChange={(v) => setMount({ depth: v })}
850 />
851 <NumberField
852 label="Tilt"
853 value={m.tilt}
854 unit="°"
855 step={5}
856 min={0}
857 max={180}
858 invalid={invalid("mount.tilt")}
859 onChange={(v) => setMount({ tilt: v })}
860 />
861 <div className="pl-small pl-muted">
862 0° hangs the plate straight down from the jaw, 90° is a horizontal cantilever, 180°
863 stands it up with its weight in compression. The nodes within the jaws are held still
864 and the clamped condition follows from the bending energy.
865 </div>
866 </>
867 );
868 case "pinched":
869 return (
870 <>
871 <NumberField
872 label="Pinch x"
873 value={m.x}
874 scale={1000}
875 unit="mm"
876 invalid={invalid("mount")}
877 onChange={(v) => setMount({ x: v })}
878 />
879 <NumberField
880 label="Pinch y"
881 value={m.y}
882 scale={1000}
883 unit="mm"
884 invalid={invalid("mount")}
885 onChange={(v) => setMount({ y: v })}
886 />
887 <NumberField
888 label="Patch radius"
889 value={m.radius}
890 scale={1000}
891 unit="mm"
892 step={1}
893 invalid={invalid("mount.radius")}
894 onChange={(v) => setMount({ radius: v })}
895 />
896 <NumberField
897 label="Grip stiffness"
898 value={m.k}
899 unit="N/m"
900 step={100}
901 invalid={invalid("mount.k")}
902 onChange={(v) => setMount({ k: v })}
903 />
904 <NumberField
905 label="Grip damping"
906 value={m.c}
907 unit="N·s/m"
908 step={1}
909 invalid={invalid("mount.c")}
910 onChange={(v) => setMount({ c: v })}
911 />
912 <NumberField
913 label="Tilt"
914 value={m.tilt}
915 unit="°"
916 step={5}
917 min={0}
918 max={90}
919 invalid={invalid("mount.tilt")}
920 onChange={(v) => setMount({ tilt: v })}
921 />
922 <div className="pl-small pl-muted">
923 A two-sided spring and dashpot over the patch, both guesses of the right order for a
924 hand; the patch is also fixed in the plane. 0° hangs the plate below the hand, 90° holds
925 it flat. The plate hangs with its centre of mass below the pinch.
926 </div>
927 </>
928 );
929 }
932/**
933 * The time loop has a WebGPU port, on by default: a run of a few seconds is
934 * hundreds of thousands of steps, which numbl takes minutes over and the GPU
935 * seconds. The double-precision loop in numbl remains the reference.
936 */
937function GpuToggle({
938 enabled,
939 onChange,
940 support,
941}: {
942 enabled: boolean;
943 onChange: (v: boolean) => void;
944 support: WebGpuSupport;
945}) {
946 const available = support.status === "available";
947 return (
948 <div className="pl-gpu">
949 <label className={`pl-check${available ? "" : " pl-disabled"}`}>
950 <input
951 type="checkbox"
952 checked={enabled && available}
953 disabled={!available}
954 onChange={(ev) => onChange(ev.target.checked)}
955 />
956 Run the time loop on the GPU (WebGPU)
957 </label>
958 <div className="pl-small pl-muted">
959 {support.status === "checking" && "Looking for WebGPU…"}
960 {support.status === "unavailable" &&
961 `Not available here (${support.reason}); the loop runs in numbl, which takes minutes for a run of seconds.`}
962 {support.status === "available" &&
963 (enabled
964 ? `plate_fd_loop.m runs as its WebGPU port on ${support.adapter}, in single precision; setup, statics, stresses, sound and spectra stay in numbl. Editing plate_fd_loop.m sends the loop back to numbl.`
965 : `Available on ${support.adapter}. Off, the double-precision loop runs in numbl, typically 30 to 100 times slower.`)}
966 </div>
967 </div>
968 );
971function customName(name: string): string {
972 return name.endsWith(" (edited)") ? name : `${name} (edited)`;
975function Group(props: {
976 title: string;
977 prefix: string;
978 issues: ValidationIssue[];
979 open: boolean;
980 disabled?: boolean;
981 children: ReactNode;
982}) {
983 const related = props.issues.filter(
984 (i) => i.path === props.prefix || i.path.startsWith(props.prefix + "."),
985 );
986 return (
987 <details className={`pl-group${props.disabled ? " pl-disabled" : ""}`} open={props.open}>
988 <summary>
989 {props.title}
990 {related.some((i) => i.severity === "error") && (
991 <span className="pl-badge pl-badge-error" style={{ marginLeft: 6 }}>
992 check
993 </span>
994 )}
995 </summary>
996 {props.children}
997 {related.map((i) => (
998 <div
999 key={i.path + i.message}
1000 className={`pl-issue${i.severity === "warning" ? " pl-issue-warning" : ""}`}
1002 {i.path}: {i.message}
1003 </div>
1004 ))}
1005 </details>
1006 );
↑↓ move↵openescclose