7540997Save the run as a movie: the scene, its caption and the soundJeremy Magland 1import { useEffect, useMemo, useRef, useState } from "react";
80c0b6dPlate dynamics lab: plates in realizable mounts under gravity, explicit finite differences on WebGPUJeremy Magland 2import {
80c0b6dPlate dynamics lab: plates in realizable mounts under gravity, explicit finite differences on WebGPUJeremy Magland 4 rangeOf,
5 symmetricRange,
6 type Field2D,
7 type FieldFrames,
8 type MethodDefinition,
9 type PlateResult,
10 type ProblemSpec,
11 type Range,
12} from "@plate-dyn/core";
13import {
14 AudioPlayer,
15 FieldView,
16 FramePlayer,
17 LinePlot,
80c0b6dPlate dynamics lab: plates in realizable mounts under gravity, explicit finite differences on WebGPUJeremy Magland 19 Plate3DView,
20 ValidityNotes,
21 useFramePlayer,
22 useSharedAxis,
23 type FieldArrow,
24 type FieldMarker,
25 type FieldRect,
7540997Save the run as a movie: the scene, its caption and the soundJeremy Magland 26 type MovieOverlay,
27 type Plate3DHandle,
80c0b6dPlate dynamics lab: plates in realizable mounts under gravity, explicit finite differences on WebGPUJeremy Magland 28} from "@plate-dyn/components";
30type Quantity = "w" | "w_total" | "vm" | "sp" | "env_vm" | "env_sp" | "w0" | "nvm";
32const QUANTITIES: {
33 id: Quantity;
34 label: string;
35 unit: string;
36 scale: number;
37 symmetric: boolean;
38 frames: boolean;
39}[] = [
40 {
41 id: "w",
42 label: "Flexural displacement",
43 unit: "µm",
44 scale: 1e6,
45 symmetric: true,
46 frames: true,
47 },
48 {
49 id: "w_total",
50 label: "Displacement with the rigid-body motion",
51 unit: "mm",
52 scale: 1e3,
53 symmetric: true,
54 frames: true,
55 },
56 {
57 id: "vm",
58 label: "Von Mises stress (surface)",
59 unit: "MPa",
60 scale: 1e-6,
61 symmetric: false,
62 frames: true,
63 },
64 {
65 id: "sp",
66 label: "Principal stress, signed (surface)",
67 unit: "MPa",
68 scale: 1e-6,
69 symmetric: true,
70 frames: true,
71 },
72 {
73 id: "env_vm",
74 label: "Peak von Mises over time",
75 unit: "MPa",
76 scale: 1e-6,
77 symmetric: false,
78 frames: false,
79 },
80 {
81 id: "env_sp",
82 label: "Peak |principal stress| over time",
83 unit: "MPa",
84 scale: 1e-6,
85 symmetric: false,
86 frames: false,
87 },
88 {
89 id: "w0",
90 label: "Static sag under gravity",
91 unit: "mm",
92 scale: 1e3,
93 symmetric: true,
94 frames: false,
95 },
96 {
97 id: "nvm",
98 label: "Self-weight in-plane stress (von Mises)",
99 unit: "kPa",
100 scale: 1e-3,
101 symmetric: false,
102 frames: false,
103 },
104];
106export interface ResultsViewProps {
107 result: PlateResult;
108 spec: ProblemSpec;
109 method: MethodDefinition;
110 fieldWidth?: number;
111}
113/** The plate in its mount, the fields, the rigid-body and support series, the probe, the sound. */
114export function ResultsView({ result, spec, method, fieldWidth = 460 }: ResultsViewProps) {
115 const [quantity, setQuantity] = useState<Quantity>("w");
116 const [rangeMode, setRangeMode] = useState<"global" | "frame">("global");
117 const [flexScale, setFlexScale] = useState(200);
118 const [rigidScale, setRigidScale] = useState(1);
119 const q = QUANTITIES.find((x) => x.id === quantity)!;
120 const nf = result.frames.t.length;
121 const player = useFramePlayer(nf, 30, false);
122 const startFrame = useMemo(() => peakFrame(result.frames.w), [result]);
123 const setIndex = player.setIndex;
124 useEffect(() => setIndex(startFrame), [startFrame, setIndex]);
7540997Save the run as a movie: the scene, its caption and the soundJeremy Magland 126 const sceneRef = useRef<Plate3DHandle>(null);
80c0b6dPlate dynamics lab: plates in realizable mounts under gravity, explicit finite differences on WebGPUJeremy Magland 127 const nvm = useMemo(() => inPlaneVonMises(result), [result]);
128 const frames: FieldFrames | undefined =
129 quantity === "w"
130 ? result.frames.w
131 : quantity === "w_total"
132 ? result.frames.w_total
133 : quantity === "vm"
134 ? result.frames.vm
135 : quantity === "sp"
136 ? result.frames.sp
137 : undefined;
138 const field: Field2D | undefined =
139 quantity === "env_vm"
140 ? result.envelope.vm
141 : quantity === "env_sp"
142 ? result.envelope.sp
143 : quantity === "w0"
144 ? result.static.w0
145 : quantity === "nvm"
146 ? nvm
147 : undefined;
148 const tFrame = result.frames.t[Math.min(player.index, nf - 1)] ?? 0;
150 // the 3D surface is coloured by the same quantity when it is a frame stack
151 const colorRange: Range | undefined = useMemo(() => {
152 if (!frames) return undefined;
153 const r = rangeOf(frames.data);
154 return q.symmetric ? symmetricRange(r) : r;
155 }, [frames, q.symmetric]);
157 const markers: FieldMarker[] = useMemo(() => {
158 const m: FieldMarker[] = [
159 {
160 x: spec.excitation.x,
161 y: spec.excitation.y,
162 label: spec.excitation.type === "impulse" ? "strike" : "pull",
163 color: "#d1495b",
164 },
165 { x: result.probe.x, y: result.probe.y, label: "probe", color: "#1f5fa8" },
166 ];
167 const mt = spec.mount;
168 if (mt.type === "thread") m.push({ x: mt.x, y: mt.y, label: "hole", color: "#2a9d8f" });
169 if (mt.type === "threads") {
170 m.push({ x: mt.x1, y: mt.y1, label: "hole 1", color: "#2a9d8f" });
171 m.push({ x: mt.x2, y: mt.y2, label: "hole 2", color: "#2a9d8f" });
172 }
173 if (mt.type === "pinched") m.push({ x: mt.x, y: mt.y, label: "fingers", color: "#2a9d8f" });
174 if (mt.type === "cymbal")
175 m.push({ x: spec.plate.Lx / 2, y: spec.plate.Ly / 2, label: "felts", color: "#2a9d8f" });
176 return m;
177 }, [spec, result.probe.x, result.probe.y]);
178 const rects: FieldRect[] = useMemo(() => {
179 const mt = spec.mount;
180 if (mt.type === "pads")
181 return mt.pads.map((p, k) => ({
182 x0: p.x - p.rx,
183 y0: p.y - p.ry,
184 x1: p.x + p.rx,
185 y1: p.y + p.ry,
186 label: `pad ${k + 1}`,
187 }));
188 if (mt.type === "vise") {
189 const { Lx, Ly } = spec.plate;
190 const d = mt.depth;
191 const r =
192 mt.edge === "x0"
193 ? { x0: 0, y0: 0, x1: d, y1: Ly }
194 : mt.edge === "x1"
195 ? { x0: Lx - d, y0: 0, x1: Lx, y1: Ly }
196 : mt.edge === "y0"
197 ? { x0: 0, y0: 0, x1: Lx, y1: d }
198 : { x0: 0, y0: Ly - d, x1: Lx, y1: Ly };
199 return [{ ...r, label: "jaws" }];
200 }
201 return [];
202 }, [spec]);
203 const arrow: FieldArrow | undefined = useMemo(() => {
204 const s = result.static;
205 if (s.g === 0) return undefined;
206 if (s.gx === 0 && s.gy === 0) return undefined;
207 return { x: s.xc, y: s.yc, dx: s.gx, dy: s.gy, label: "down" };
208 }, [result.static]);
210 const onPlotClick = (t: number) => {
211 let best = 0;
212 for (let k = 1; k < nf; k++)
213 if (Math.abs(result.frames.t[k] - t) < Math.abs(result.frames.t[best] - t)) best = k;
214 player.setIndex(best);
215 player.setPlaying(false);
216 };
218 const axis = useSharedAxis();
219 const spectrumAxis = useSharedAxis();
220 const audioT = useMemo(
221 () => Float64Array.from({ length: result.audio.p.length }, (_, i) => i / result.audio.fs),
222 [result.audio],
223 );
224 const rigidUnits = result.rigid.names.map((_, k) => (k === 0 ? "mm" : "mrad"));
225 const rigidColors = ["#1f5fa8", "#d1495b", "#2a9d8f"];
226 const supportColors = [
227 "#1f5fa8",
228 "#d1495b",
229 "#2a9d8f",
230 "#e9a300",
231 "#7b4fa6",
232 "#555",
233 "#a0522d",
234 "#008b8b",
235 ];
236 const orientation = describeOrientation(result, spec);
7540997Save the run as a movie: the scene, its caption and the soundJeremy Magland 237 // The movie records the scene as it stands: same orientation, same colouring,
238 // the sound the player and the WAV export use, and a caption that says what
239 // is drawn and by how much it is exaggerated.
240 const audioPrep = useMemo(() => prepareAudio(result.audio.p, result.audio.fs), [result.audio]);
241 const movieSource = useMemo(
242 () => ({
243 canvas: () => sceneRef.current?.canvas() ?? null,
244 drawFrame: (k: number, size?: { width: number; height: number }) =>
245 sceneRef.current?.drawFrame(k, size),
246 }),
247 [],
248 );
249 const movieOverlay: MovieOverlay = useMemo(
250 () => ({
251 title: `${describePlate(spec)}, ${MOUNT_TEXT[spec.mount.type]}`,
252 note: `flexure ×${flexScale}, rigid motion ×${rigidScale}`,
253 legend:
254 frames && colorRange
255 ? {
256 label: q.label,
257 unit: q.unit,
258 min: colorRange.min,
259 max: colorRange.max,
260 scale: q.scale,
261 colormap: q.symmetric ? "bwr" : "viridis",
262 }
263 : undefined,
264 }),
265 [spec, flexScale, rigidScale, frames, colorRange, q],
266 );
80c0b6dPlate dynamics lab: plates in realizable mounts under gravity, explicit finite differences on WebGPUJeremy Magland 267
268 return (
269 <div className="results">
270 <div className="results-toolbar sound-bar">
271 <AudioPlayer
272 p={result.audio.p}
273 fs={result.audio.fs}
274 label="sound"
275 filename={`plate-${spec.mount.type}`}
276 />
277 </div>
279 <div className="pl-scene">
280 <div className="pl-plot-title">The plate in its mount</div>
281 <Plate3DView
7540997Save the run as a movie: the scene, its caption and the soundJeremy Magland 282 ref={sceneRef}
80c0b6dPlate dynamics lab: plates in realizable mounts under gravity, explicit finite differences on WebGPUJeremy Magland 283 result={result}
284 spec={spec}
285 frameIndex={player.index}
286 colorFrames={frames}
287 colorRange={colorRange}
288 colormap={q.symmetric ? "bwr" : "viridis"}
289 flexScale={flexScale}
290 rigidScale={rigidScale}
291 width={fieldWidth}
292 height={Math.round(fieldWidth * 0.7)}
293 />
294 <div className="pl-scene-controls">
295 <label>
296 flexure ×{flexScale}
297 <input
298 type="range"
299 min={0}
300 max={4}
301 step={0.05}
302 value={Math.log10(Math.max(1, flexScale))}
303 onChange={(e) => setFlexScale(Math.round(10 ** Number(e.target.value)))}
304 />
305 </label>
306 <label>
307 rigid motion ×{rigidScale}
308 <input
309 type="range"
310 min={0}
311 max={2}
312 step={0.05}
313 value={Math.log10(Math.max(1, rigidScale))}
314 onChange={(e) => setRigidScale(Math.round(10 ** Number(e.target.value)))}
315 />
316 </label>
317 <span className="pl-muted">drag to orbit, wheel to zoom</span>
318 </div>
320 source={movieSource}
321 frameTimes={result.frames.t}
322 audio={audioPrep}
323 overlay={movieOverlay}
324 aspect={0.7}
325 filename={`plate-${spec.mount.type}`}
326 restoreFrame={player.index}
327 onRecording={(on) => on && player.setPlaying(false)}
328 />
80c0b6dPlate dynamics lab: plates in realizable mounts under gravity, explicit finite differences on WebGPUJeremy Magland 329 <div className="pl-small pl-muted">
330 {orientation} The static sag and the flexural field are exaggerated by the first factor,
331 the swing and sway by the second (1 is true scale); the colour is the quantity chosen
332 below.
333 </div>
334 </div>
336 {frames && <FramePlayer {...player} times={result.frames.t} timeScale={1} timeUnit="s" />}
338 <div className="results-toolbar">
339 <label className="pl-small pl-muted">show</label>
340 <select
341 className="pl-select"
342 style={{ width: "auto" }}
343 value={quantity}
344 onChange={(e) => setQuantity(e.target.value as Quantity)}
345 >
346 {QUANTITIES.filter((x) => x.id !== "nvm" || nvm).map((x) => (
347 <option key={x.id} value={x.id}>
348 {x.label}
349 </option>
350 ))}
351 </select>
352 {frames && (
353 <label className="pl-small pl-muted">
354 colour range{" "}
355 <select
356 className="pl-select"
357 style={{ width: "auto" }}
358 value={rangeMode}
359 onChange={(e) => setRangeMode(e.target.value as "global" | "frame")}
360 >
361 <option value="global">fixed over all frames</option>
362 <option value="frame">rescaled each frame</option>
363 </select>
364 </label>
365 )}
366 {!result.info.stable && (
367 <span className="pl-badge pl-badge-error">
368 diverged at step {result.info.diverged_step}: frames stop there
369 </span>
370 )}
371 </div>
372 <FieldView
373 frames={frames}
374 field={field}
375 frameIndex={player.index}
376 Lx={spec.plate.Lx}
377 Ly={spec.plate.Ly}
378 shape={spec.plate.shape}
379 title={q.label}
380 unit={q.unit}
381 scale={q.scale}
382 symmetric={q.symmetric}
383 range={rangeMode}
384 markers={markers}
385 rects={rects}
386 arrow={arrow}
387 width={fieldWidth}
388 caption={captionFor(quantity)}
389 />
390 {spec.plate.shape === "ellipse" && (
391 <div className="pl-small pl-muted">
392 The grid spans the box around the ellipse; the edge is a staircase of grid nodes, drawn
393 here clipped to the true outline. Stress on the outermost ring is copied from the nearest
394 node with a full stencil.
395 </div>
396 )}
398 {result.rigid.nr > 0 && (
399 <>
400 <LinePlot
401 title="Rigid-body motion about the pivot"
402 series={result.rigid.series.map((s, k) => ({
403 x: result.t,
404 y: s,
405 label: `${result.rigid.names[k]} [${rigidUnits[k]}]`,
406 color: rigidColors[k],
407 }))}
408 yScale={1e3}
409 xLabel="time [s]"
410 yLabel="mm, mrad"
411 yUnit=""
412 cursorX={tFrame}
413 onClick={onPlotClick}
414 height={170}
415 {...axis}
416 />
417 <div className="pl-small pl-muted">
418 The translation along the normal at the pivot (
419 {(result.rigid.pivot.x * 1000).toFixed(0)}, {(result.rigid.pivot.y * 1000).toFixed(0)})
420 mm in millimetres, the two rotations in milliradians; all measured from the static
421 equilibrium. Rigid-body modes of this mount:{" "}
422 {result.rigid.hz
423 .map(
424 (h, k) =>
425 `${Number.isFinite(h) ? h.toFixed(2) + " Hz" : "unstable"} (${modeName(result, k)})`,
426 )
427 .join(", ")}
428 .
429 </div>
430 </>
431 )}
433 {result.supports.names.length > 0 && (
434 <>
435 <LinePlot
436 title="Force the supports apply to the plate"
437 series={result.supports.series.map((s, k) => ({
438 x: result.t,
439 y: s,
440 label: result.supports.names[k],
441 color: supportColors[k % supportColors.length],
442 }))}
443 xLabel="time [s]"
444 yLabel="F [N]"
445 yUnit="N"
446 cursorX={tFrame}
447 onClick={onPlotClick}
448 height={150}
449 {...axis}
450 />
451 <div className="pl-small pl-muted">
452 Positive pushes the plate towards +z (a pad or the lower felt), negative pulls it back
453 (a thread's transverse spring, the upper felt). At rest:{" "}
454 {result.supports.names
455 .map((n, k) => `${n} ${result.supports.static[k].toFixed(2)} N`)
456 .join(", ")}
457 . A contact at zero has lost touch with the plate.
458 </div>
459 </>
460 )}
462 <LinePlot
463 title={`Displacement at the probe (${(result.probe.x * 1000).toFixed(0)}, ${(result.probe.y * 1000).toFixed(0)}) mm`}
464 series={[{ x: result.t, y: result.probe.w, label: "w (rigid + flexural)" }]}
465 yScale={1e6}
466 xLabel="time [s]"
467 yLabel="w [µm]"
468 yUnit="µm"
469 cursorX={tFrame}
470 onClick={onPlotClick}
471 height={170}
472 {...axis}
473 />
474 <LinePlot
475 title="Surface stress at the probe"
476 series={[
477 { x: result.t, y: result.probe.vm, label: "von Mises" },
478 { x: result.t, y: result.probe.sp, label: "principal (signed)", color: "#d1495b" },
479 ]}
480 yScale={1e-6}
481 xLabel="time [s]"
482 yLabel="σ [MPa]"
483 yUnit="MPa"
484 cursorX={tFrame}
485 onClick={onPlotClick}
486 height={170}
487 {...axis}
488 />
489 <LinePlot
490 title="Amplitude spectrum at the probe (Hann window)"
491 series={[
492 { x: result.spectrum.f, y: result.spectrum.w, label: "w [m]" },
493 {
494 x: result.spectrum.f,
495 y: result.spectrum.vm,
496 label: "von Mises [Pa]",
497 color: "#d1495b",
498 },
499 ]}
500 defaultXRange={[0, Math.min(result.info.fs / 2, 5000)]}
501 logY
502 xLabel="frequency [Hz]"
503 yLabel="amplitude"
504 height={170}
505 {...spectrumAxis}
506 />
507 <div className="pl-small pl-muted">
508 Frequency resolution {(1 / (result.t[result.t.length - 1] - result.t[0])).toFixed(2)} Hz
509 from a {(result.t[result.t.length - 1] - result.t[0]).toFixed(2)} s record. The pendulum
510 modes sit at the far left; zoom in to see them.
511 </div>
513 <div>
514 <div className="pl-plot-title">Radiated sound at the listener</div>
515 <LinePlot
516 series={[{ x: audioT, y: result.audio.p, label: "pressure" }]}
517 xLabel="time after first arrival [s]"
518 yLabel="p [Pa]"
519 yUnit="Pa"
520 height={120}
521 {...axis}
522 />
523 <div className="pl-small pl-muted">
524 {result.audio.model === "unbaffled"
525 ? "Unbaffled estimate: the back face's contribution, taken around the nearest edge, cancels the front at low frequency. "
526 : "Rayleigh integral in an infinite baffle. "}
527 Listener {spec.listener.z} m from the plate; propagation delay of the nearest patch
528 removed.
529 </div>
530 </div>
532 <details>
533 <summary className="pl-small">What this result idealizes</summary>
534 <ValidityNotes method={method} result={result} />
535 </details>
536 </div>
537 );
538}
7540997Save the run as a movie: the scene, its caption and the soundJeremy Magland 540const MOUNT_TEXT: Record<string, string> = {
541 thread: "hanging from one thread",
542 threads: "hanging from two threads",
543 pads: "resting on pads",
544 cymbal: "on a cymbal stand",
545 vise: "one edge in a vise",
546 pinched: "pinched between fingers",
547};
549/** The stock, for the movie's title: "300 × 200 × 2 mm sheet, A36 structural steel". */
550function describePlate(spec: ProblemSpec): string {
551 const p = spec.plate;
552 const mm = (x: number) => Number((x * 1000).toPrecision(3)).toString();
553 const round = p.shape === "ellipse" && p.Lx === p.Ly;
554 const noun = p.shape === "rectangle" ? "sheet" : round ? "disk" : "ellipse";
555 const dims = round ? `${mm(p.Lx)} × ${mm(p.h)}` : `${mm(p.Lx)} × ${mm(p.Ly)} × ${mm(p.h)}`;
556 return `${dims} mm ${noun}, ${spec.material.name}`;
557}
80c0b6dPlate dynamics lab: plates in realizable mounts under gravity, explicit finite differences on WebGPUJeremy Magland 559function captionFor(q: Quantity): string {
560 switch (q) {
561 case "w":
562 return "The flexural part of the departure from rest: the rigid-body swing and sway are taken out. Positive along the plate normal.";
563 case "w_total":
564 return "Flexural field plus the rigid-body motion, measured from the static equilibrium; the swing dominates on a hung plate.";
565 case "vm":
566 case "sp":
567 return "Surface stress at z = h/2 from the curvature of the flexural field; the opposite face carries the opposite sign.";
568 case "env_vm":
569 case "env_sp":
570 return "Peak over the saved frames, so a sample of the true peak. Surface z = h/2.";
571 case "w0":
572 return "Deflection at rest under gravity: the sag of a horizontal plate into its pads or from its clamp. A hanging plate has none.";
573 case "nvm":
574 return "Von Mises equivalent of the in-plane stress the plate's own weight puts in it, from the plane-stress solve; largest near the hole or the jaws.";
575 }
576}
578function modeName(result: PlateResult, k: number): string {
579 return result.rigid.modeNames[k] ?? `mode ${k + 1}`;
580}
582function describeOrientation(result: PlateResult, spec: ProblemSpec): string {
583 const s = result.static;
584 const m = spec.mount;
585 if (s.g === 0) return "Gravity is off.";
586 if (m.type === "thread" || m.type === "threads")
587 return `The plate hangs vertically; it is turned ${(Math.atan2(s.gx, -s.gy) * (180 / Math.PI)).toFixed(1)}° in its plane so its centre of mass is below the ${m.type === "thread" ? "hole" : "line of the holes"}.`;
588 if (m.type === "pads" || m.type === "cymbal") return "The plate lies horizontal on its supports.";
589 if (m.type === "vise")
590 return `The plate is tilted ${m.tilt}° from hanging straight down out of the jaws.`;
591 return `The plate is held at ${m.tilt}° from hanging straight below the fingers.`;
592}
594/** Von Mises equivalent of the self-weight stress resultants, per unit thickness. */
595function inPlaneVonMises(result: PlateResult): Field2D | undefined {
596 const s = result.static;
597 if (!s.Nxx || !s.Nyy || !s.Nxy) return undefined;
598 const n = s.Nxx.data.length;
599 const out = new Float64Array(n);
600 for (let i = 0; i < n; i++) {
601 const a = s.Nxx.data[i];
602 const b = s.Nyy.data[i];
603 const c = s.Nxy.data[i];
604 out[i] = Math.sqrt(a * a - a * b + b * b + 3 * c * c);
605 }
606 return { nx: s.Nxx.nx, ny: s.Nxx.ny, data: out };
607}
609/**
610 * The frame of largest flexural displacement. The player starts here so a run
611 * that has just finished shows the plate in motion.
612 */
613function peakFrame(f: FieldFrames | undefined): number {
614 if (!f) return 0;
615 const n = f.nx * f.ny;
616 let best = 0;
617 let bestv = -1;
618 for (let k = 0; k < f.nf; k++) {
619 let v = 0;
620 for (let i = k * n; i < (k + 1) * n; i++) {
621 const a = Math.abs(f.data[i]);
622 if (a > v) v = a;
623 }
624 if (v > bestv) {
625 bestv = v;
626 best = k;
627 }
628 }
629 return best;
630}