import { useEffect, useMemo, useRef, useState } from "react"; import { holePosition, prepareAudio, rangeOf, symmetricRange, type Field2D, type FieldFrames, type MethodDefinition, type PlateResult, type ProblemSpec, type Range, } from "@plate-dyn/core"; import { AudioPlayer, FieldView, FramePlayer, LinePlot, MovieExport, Plate3DView, ValidityNotes, useFramePlayer, useSharedAxis, type FieldMarker, type MovieOverlay, type Plate3DHandle, } from "@plate-dyn/components"; import { ModeTable } from "./ModeTable"; import { ContactPlots } from "./ContactPlots"; type Quantity = "w" | "w_total" | "vm" | "sp" | "env_vm" | "env_sp" | "mode"; const QUANTITIES: { id: Quantity; label: string; unit: string; scale: number; symmetric: boolean; frames: boolean; }[] = [ { id: "w", label: "Displacement (audible modes)", unit: "µm", scale: 1e6, symmetric: true, frames: true, }, { id: "w_total", label: "Displacement with the rigid-body modes", unit: "µm", scale: 1e6, symmetric: true, frames: true, }, { id: "vm", label: "Von Mises stress (surface)", unit: "MPa", scale: 1e-6, symmetric: false, frames: true, }, { id: "sp", label: "Principal stress, signed (surface)", unit: "MPa", scale: 1e-6, symmetric: true, frames: true, }, { id: "env_vm", label: "Peak von Mises over the frames", unit: "MPa", scale: 1e-6, symmetric: false, frames: false, }, { id: "env_sp", label: "Peak |principal stress| over the frames", unit: "MPa", scale: 1e-6, symmetric: false, frames: false, }, { id: "mode", label: "A mode shape", unit: "", scale: 1, symmetric: true, frames: false }, ]; export interface ResultsViewProps { result: PlateResult; spec: ProblemSpec; method: MethodDefinition; fieldWidth?: number; } /** The plate on its thread, the fields, the modes, the contact, the probe, the sound. */ export function ResultsView({ result, spec, method, fieldWidth = 460 }: ResultsViewProps) { const [quantity, setQuantity] = useState("w"); const [rangeMode, setRangeMode] = useState<"global" | "frame">("global"); const [exaggeration, setExaggeration] = useState(300); const [mode, setMode] = useState(firstAudible(result)); const q = QUANTITIES.find((x) => x.id === quantity)!; const nf = result.frames.t.length; const player = useFramePlayer(nf, 30, false); const startFrame = useMemo(() => peakFrame(result.frames.w), [result]); const setIndex = player.setIndex; useEffect(() => setIndex(startFrame), [startFrame, setIndex]); useEffect(() => setMode(firstAudible(result)), [result]); const sceneRef = useRef(null); const W = spec.plate.width; const H = spec.plate.height; const size = Math.max(W, H); // mode shapes, each scaled to unit peak, for the picture and the scene const shapes: FieldFrames = useMemo(() => normalizeShapes(result.modes.shapes), [result]); const isMode = quantity === "mode"; const frameIndex = isMode ? mode : player.index; const frames: FieldFrames | undefined = quantity === "w" ? result.frames.w : quantity === "w_total" ? result.frames.w_total : quantity === "vm" ? result.frames.vm : quantity === "sp" ? result.frames.sp : isMode ? shapes : undefined; const field: Field2D | undefined = quantity === "env_vm" ? result.envelope.vm : quantity === "env_sp" ? result.envelope.sp : undefined; const tFrame = result.frames.t[Math.min(player.index, nf - 1)] ?? 0; // the scene is displaced by the displacement field (or the shape) and coloured by the quantity const deformFrames = isMode ? shapes : quantity === "w_total" ? result.frames.w_total : result.frames.w; const sceneScale = isMode ? 0.08 * size : exaggeration; const colorRange: Range | undefined = useMemo(() => { if (!frames) return undefined; if (isMode) return { min: -1, max: 1 }; const r = rangeOf(frames.data); return q.symmetric ? symmetricRange(r) : r; }, [frames, q.symmetric, isMode]); const hole = holePosition(spec); const markers: FieldMarker[] = useMemo( () => [ { x: spec.strike.x + W / 2, y: spec.strike.y + H / 2, label: "strike", color: "#d1495b" }, { x: result.probe.x + W / 2, y: result.probe.y + H / 2, label: "probe", color: "#1f5fa8" }, { x: hole.x + W / 2, y: hole.y + H / 2, label: "hole", color: "#2a9d8f" }, ], [spec, result.probe.x, result.probe.y, hole.x, hole.y, W, H], ); const onPlotClick = (t: number) => { let best = 0; for (let k = 1; k < nf; k++) if (Math.abs(result.frames.t[k] - t) < Math.abs(result.frames.t[best] - t)) best = k; player.setIndex(best); player.setPlaying(false); }; const axis = useSharedAxis(); const spectrumAxis = useSharedAxis(); const audioT = useMemo( () => Float64Array.from({ length: result.audio.p.length }, (_, i) => i / result.audio.fs), [result.audio], ); const audioPrep = useMemo( () => prepareAudio(result.audio.p, result.audio.fs, 10 ** (result.audio.normalize_dbfs / 20)), [result.audio], ); const movieSource = useMemo( () => ({ canvas: () => sceneRef.current?.canvas() ?? null, drawFrame: (k: number, size?: { width: number; height: number }) => sceneRef.current?.drawFrame(k, size), }), [], ); const movieOverlay: MovieOverlay = useMemo( () => ({ title: `${describePlate(spec)}, ${method.shortName}`, note: isMode ? `mode ${mode + 1}, ${result.modes.freq[mode].toFixed(0)} Hz` : `displacement ×${exaggeration}`, legend: frames && colorRange && !isMode ? { label: q.label, unit: q.unit, min: colorRange.min, max: colorRange.max, scale: q.scale, colormap: q.symmetric ? "bwr" : "viridis", } : undefined, }), [spec, method.shortName, exaggeration, frames, colorRange, q, isMode, mode, result.modes.freq], ); const modeLines = useMemo( () => Array.from(result.modes.freq) .filter((_, k) => result.modes.audible[k]) .slice(0, 40), [result.modes], ); return (
The plate on its thread
drag to orbit, wheel to zoom
on && player.setPlaying(false)} />
The hammer's tip sits at the strike point on the near face; the thread rises from the hole. The surface is displaced by{" "} {isMode ? "the chosen mode shape" : "the displacement, exaggerated by the slider"}, and coloured by the quantity chosen below.
{frames && !isMode && ( )}
{isMode && ( )} {frames && !isMode && ( )}
{ setMode(k); setQuantity("mode"); }} />
The first 20 ms are shown; drag to zoom, double-click to reset. The whole{" "} {result.t[result.t.length - 1].toFixed(1)} s record is there.
({ x: Float64Array.of(f, f), y: Float64Array.of(1e-12, 1), color: "rgba(42,157,143,0.45)", width: 1, dash: [3, 3], })), ]} defaultXRange={[0, result.info.f_max]} logY xLabel="frequency [Hz]" yLabel="amplitude" height={190} {...spectrumAxis} />
Dashed lines mark the audible modes' frequencies. Resolution{" "} {(1 / (result.t[result.t.length - 1] - result.t[0])).toFixed(2)} Hz from a{" "} {(result.t[result.t.length - 1] - result.t[0]).toFixed(2)} s record; modes the strike does not reach show no peak.
Pressure at the microphone
Compact-source estimate {spec.mic.distance * 1000} mm from the plate on its normal, with the front/back cancellation of an unbaffled plate below{" "} {(result.info.critical_frequency / 1000).toFixed(1)} kHz. The first 5 ms are shown. Peak{" "} {result.audio.peak_pa.toExponential(2)} Pa before normalization.
What this result idealizes
); } /** The stock, for the movie's title: "30 × 30 × 1 mm, 6061-T6 aluminium". */ function describePlate(spec: ProblemSpec): string { const p = spec.plate; const mm = (x: number) => Number((x * 1000).toPrecision(3)).toString(); return `${mm(p.width)} × ${mm(p.height)} × ${mm(p.thickness)} mm, ${spec.material.name}`; } function captionFor(q: Quantity, result: PlateResult): string { switch (q) { case "w": return "The sum of the audible modes: what radiates. Positive along the plate normal, towards the hammer."; case "w_total": return result.info.n_rigid > 0 ? "With the three rigid-body modes the finite-element method keeps: the recoil and the rotation the hammer leaves the whole plate with." : "This method has no rigid-body modes, so this is the same field as the audible one."; case "vm": case "sp": return "Surface stress at z = h/2 from the modal curvatures; the opposite face carries the opposite sign."; case "env_vm": case "env_sp": return "Peak over the saved frames, which cover the strike transient; a sample of the true peak."; case "mode": return "The mass-normalized shape scaled to unit peak; the table below gives its part in this strike."; } } function firstAudible(result: PlateResult): number { const k = result.modes.audible.findIndex((a) => a); return k < 0 ? 0 : k; } /** Each shape divided by its own peak, so that all are drawn at the same amplitude. */ function normalizeShapes(shapes: FieldFrames): FieldFrames { const n = shapes.nx * shapes.ny; const data = new Float64Array(shapes.data.length); for (let k = 0; k < shapes.nf; k++) { let peak = 0; for (let i = k * n; i < (k + 1) * n; i++) peak = Math.max(peak, Math.abs(shapes.data[i])); const g = peak > 0 ? 1 / peak : 1; for (let i = k * n; i < (k + 1) * n; i++) data[i] = shapes.data[i] * g; } return { ...shapes, data }; } /** * The frame of largest displacement. The player starts here so a run that * has just finished shows the plate in motion. */ function peakFrame(f: FieldFrames | undefined): number { if (!f) return 0; const n = f.nx * f.ny; let best = 0; let bestv = -1; for (let k = 0; k < f.nf; k++) { let v = 0; for (let i = k * n; i < (k + 1) * n; i++) { const a = Math.abs(f.data[i]); if (a > v) v = a; } if (v > bestv) { bestv = v; best = k; } } return best; }