import type { ReactNode } from "react"; import { MATERIAL_PRESETS, PAD_PRESETS, PLATE_PRESETS, THREAD_PRESETS, defaultMount, type Excitation, type Mount, type MountType, type Pad, type PlateShape, type ProblemSpec, type ValidationIssue, type ViseEdge, } from "@plate-dyn/core"; import { NumberField } from "./NumberField"; import { CflSlider } from "./CflSlider"; import type { WebGpuSupport } from "../hooks/useWebGpu"; export type ParameterSection = | "plate" | "material" | "mount" | "gravity" | "damping" | "excitation" | "probe" | "listener" | "output" | "fd"; export const ALL_SECTIONS: ParameterSection[] = [ "plate", "material", "mount", "gravity", "damping", "excitation", "probe", "listener", "output", "fd", ]; export interface ParameterPanelProps { spec: ProblemSpec; onChange: (spec: ProblemSpec) => void; /** Sections to show; default all */ sections?: ParameterSection[]; issues?: ValidationIssue[]; /** Largest stable step, when known, for the CFL slider */ fdDtMax?: number; /** Sections open initially; default all */ open?: ParameterSection[]; /** The GPU option of the time loop: whether it is on, and what this browser offers */ fdGpu?: { enabled: boolean; onChange: (v: boolean) => void; support: WebGpuSupport }; } const MOUNT_LABELS: Record = { thread: "hanging from one thread", threads: "hanging from two threads", pads: "resting on pads", cymbal: "on a cymbal stand", vise: "one edge in a vise", pinched: "pinched between fingers", }; /** Controls that edit the spec. Every control maps to one field of the `params` struct. */ export function ParameterPanel(props: ParameterPanelProps) { const { spec, onChange, sections = ALL_SECTIONS, issues = [], fdDtMax, open, fdGpu } = props; const set = (patch: Partial) => onChange({ ...spec, ...patch }); const issue = (path: string) => issues.find((i) => i.path === path || i.path.startsWith(path + ".")); const invalid = (path: string) => issue(path)?.severity === "error"; const show = (s: ParameterSection) => sections.includes(s); const isOpen = (s: ParameterSection) => (open ? open.includes(s) : true); const e = spec.excitation; const m = spec.mount; const setMount = (patch: Partial) => set({ mount: { ...m, ...patch } as Mount }); const materialPresetId = MATERIAL_PRESETS.find( (p) => p.material.name === spec.material.name && p.material.E === spec.material.E, )?.id; const platePresetId = PLATE_PRESETS.find( (p) => p.plate.shape === spec.plate.shape && p.plate.Lx === spec.plate.Lx && p.plate.Ly === spec.plate.Ly && p.plate.h === spec.plate.h, )?.id; const ellipse = spec.plate.shape === "ellipse"; return (
{show("plate") && (
{platePresetId && (
{PLATE_PRESETS.find((p) => p.id === platePresetId)?.note}
)}
set({ plate: { ...spec.plate, Lx: v } })} /> set({ plate: { ...spec.plate, Ly: v } })} /> set({ plate: { ...spec.plate, h: v } })} /> {ellipse && (
The ellipse fills the Lx by Ly box, and positions are still measured from the box's corner. On the grid the edge becomes a staircase of nodes, first order in the spacing.
)}
)} {show("material") && (
{materialPresetId && (
{MATERIAL_PRESETS.find((p) => p.id === materialPresetId)?.source} Handbook values, not a certificate.
)} set({ material: { ...spec.material, E: v, name: customName(spec.material.name) } }) } /> set({ material: { ...spec.material, nu: v, name: customName(spec.material.name) } }) } /> set({ material: { ...spec.material, rho: v, name: customName(spec.material.name) } }) } />
)} {show("mount") && (
)} {show("gravity") && ( set({ gravity: { g: v } })} />
Zero switches gravity off: a hung plate then has no pendulum stiffness, a plate on pads no weight to hold it down. Its direction is set by the mount.
)} {show("damping") && ( set({ damping: { ...spec.damping, sigma0: v } })} /> set({ damping: { ...spec.damping, sigma1: v } })} />
A mode with Laplacian eigenvalue λ decays at rate σ₀ + σ₁λ; σ₀ also slows the pendulum motion, as air would. Illustrative values; the mount's own dashpots are set with it.
)} {show("excitation") && (
set({ excitation: { ...e, x: v } })} /> set({ excitation: { ...e, y: v } })} /> {e.type === "impulse" ? ( <> set({ excitation: { ...e, force: v } })} /> set({ excitation: { ...e, duration: v } })} /> ) : ( set({ excitation: { ...e, displacement: v } })} /> )} set({ excitation: { ...e, radius: v } })} />
{e.type === "impulse" ? "A finite footprint stands in for the contact patch; a point load would give infinite stress under the hammer." : "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."}
)} {show("probe") && ( set({ probe: { ...spec.probe, x: v } })} /> set({ probe: { ...spec.probe, y: v } })} />
Time series and spectra are recorded here, at the nearest grid node with a full stencil.
)} {show("listener") && (
set({ listener: { ...spec.listener, x: v } })} /> set({ listener: { ...spec.listener, y: v } })} /> set({ listener: { ...spec.listener, z: v } })} />
In plate coordinates, z along the normal. Either model is an estimate of the waveform, not a calibrated level; the unbaffled one is a rough account of the front-back cancellation.
)} {show("output") && ( set({ output: { ...spec.output, fs: v } })} /> set({ output: { ...spec.output, nframes: v } })} /> )} {show("fd") && ( set({ fd: { ...spec.fd, nx: v } })} /> set({ fd: { ...spec.fd, ny: v } })} /> set({ fd: { ...spec.fd, duration: v } })} />
Time step
set({ fd: { ...spec.fd, cfl: v } })} /> {fdGpu && }
)}
); } function MountFields({ mount: m, spec, setMount, invalid, }: { mount: Mount; spec: ProblemSpec; setMount: (patch: Partial) => void; invalid: (path: string) => boolean; }) { const padPresetId = (k: number, c: number) => PAD_PRESETS.find((p) => p.k === k && p.c === c)?.id; switch (m.type) { case "thread": { const threadId = THREAD_PRESETS.find( (t) => t.thread.diameter === m.thread.diameter && t.thread.G === m.thread.G, )?.id; return ( <> setMount({ x: v })} /> setMount({ y: v })} /> setMount({ length: v })} />
setMount({ thread: { ...m.thread, diameter: v, name: customName(m.thread.name) } }) } /> setMount({ thread: { ...m.thread, G: v, name: customName(m.thread.name) } }) } />
The plate turns in its plane until its centre of mass hangs below the hole. The thread is a transverse spring of stiffness T/L at the hole; its torsion, from the diameter and modulus, is the only thing restraining the spin.
); } case "threads": return ( <> setMount({ x1: v })} /> setMount({ y1: v })} /> setMount({ x2: v })} /> setMount({ y2: v })} /> setMount({ length: v })} />
Two threads of one length from hooks at one height: the line of the holes hangs horizontal and statics shares the weight. The centre of mass must lie between the holes.
); case "pads": { const presetId = padPresetId(m.k, m.c); const setPad = (k: number, patch: Partial) => setMount({ pads: m.pads.map((p, i) => (i === k ? { ...p, ...patch } : p)) }); return ( <>
{presetId && (
{PAD_PRESETS.find((p) => p.id === presetId)?.note}
)} setMount({ k: v })} /> setMount({ c: v })} /> {m.pads.map((p, k) => (
Pad {k + 1}
setPad(k, { x: v })} /> setPad(k, { y: v })} /> setPad(k, { rx: v })} /> setPad(k, { ry: v })} />
))}
The plate lies flat on the pads. A pad pushes only while compressed, so a hard strike can lift the plate off it. A long narrow pad is a knife edge or a strip of felt.
); } case "cymbal": { const presetId = padPresetId(m.k, m.c); return ( <> setMount({ r_rod: v })} /> setMount({ r_felt: v })} />
setMount({ k: v })} /> setMount({ c: v })} /> setMount({ preload: v })} />
A felt below and one above the plate, each compressed by the preload at rest: the centre is held two-sided until a strike separates one felt. The rod, the hole and the cymbal's bow are not modelled.
); } case "vise": return ( <>
setMount({ depth: v })} /> setMount({ tilt: v })} />
0° hangs the plate straight down from the jaw, 90° is a horizontal cantilever, 180° stands it up with its weight in compression. The nodes within the jaws are held still and the clamped condition follows from the bending energy.
); case "pinched": return ( <> setMount({ x: v })} /> setMount({ y: v })} /> setMount({ radius: v })} /> setMount({ k: v })} /> setMount({ c: v })} /> setMount({ tilt: v })} />
A two-sided spring and dashpot over the patch, both guesses of the right order for a hand; the patch is also fixed in the plane. 0° hangs the plate below the hand, 90° holds it flat. The plate hangs with its centre of mass below the pinch.
); } } /** * The time loop has a WebGPU port, on by default: a run of a few seconds is * hundreds of thousands of steps, which numbl takes minutes over and the GPU * seconds. The double-precision loop in numbl remains the reference. */ function GpuToggle({ enabled, onChange, support, }: { enabled: boolean; onChange: (v: boolean) => void; support: WebGpuSupport; }) { const available = support.status === "available"; return (
{support.status === "checking" && "Looking for WebGPU…"} {support.status === "unavailable" && `Not available here (${support.reason}); the loop runs in numbl, which takes minutes for a run of seconds.`} {support.status === "available" && (enabled ? `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.` : `Available on ${support.adapter}. Off, the double-precision loop runs in numbl, typically 30 to 100 times slower.`)}
); } function customName(name: string): string { return name.endsWith(" (edited)") ? name : `${name} (edited)`; } function Group(props: { title: string; prefix: string; issues: ValidationIssue[]; open: boolean; disabled?: boolean; children: ReactNode; }) { const related = props.issues.filter( (i) => i.path === props.prefix || i.path.startsWith(props.prefix + "."), ); return (
{props.title} {related.some((i) => i.severity === "error") && ( check )} {props.children} {related.map((i) => (
{i.path}: {i.message}
))}
); }