80c0b6dPlate dynamics lab: plates in realizable mounts under gravity, explicit finite differences on WebGPUJeremy Magland 1import { useEffect, useMemo, useRef, useState } from "react";
2import {
3 colormapTable,
4 getColormap,
5 type FieldFrames,
6 type Mount,
7 type PlateResult,
8 type ProblemSpec,
9 type Range,
10} from "@plate-dyn/core";
12export interface Plate3DViewProps {
13 result: PlateResult;
14 spec: ProblemSpec;
15 frameIndex: number;
16 /** Frames that colour the surface (any per-node field) and their colour range */
17 colorFrames?: FieldFrames;
18 colorRange?: Range;
19 colormap?: string;
20 /** Exaggeration of the flexural field and the static sag */
21 flexScale: number;
22 /** Exaggeration of the rigid-body motion (1 is true scale) */
23 rigidScale: number;
24 width?: number;
25 height?: number;
26}
28type Vec3 = [number, number, number];
30const VS = `
31attribute vec3 aPos;
32attribute vec3 aNormal;
33attribute vec3 aColor;
34uniform mat4 uProj;
35uniform mat4 uView;
36varying vec3 vColor;
37varying vec3 vNormal;
38void main() {
39 gl_Position = uProj * uView * vec4(aPos, 1.0);
40 vColor = aColor;
41 vNormal = (uView * vec4(aNormal, 0.0)).xyz;
42}
43`;
44const FS = `
45precision mediump float;
46varying vec3 vColor;
47varying vec3 vNormal;
48uniform float uFlat;
49void main() {
50 vec3 n = normalize(vNormal);
51 float diff = abs(dot(n, normalize(vec3(0.3, 0.6, 1.0))));
52 float shade = mix(0.55 + 0.45 * diff, 1.0, uFlat);
53 gl_FragColor = vec4(vColor * shade, 1.0);
54}
55`;
57function perspective(fovy: number, aspect: number, near: number, far: number): Float32Array {
58 const f = 1 / Math.tan(fovy / 2);
59 const out = new Float32Array(16);
60 out[0] = f / aspect;
61 out[5] = f;
62 out[10] = (far + near) / (near - far);
63 out[11] = -1;
64 out[14] = (2 * far * near) / (near - far);
65 return out;
66}
68function lookAt(eye: Vec3, center: Vec3, up: Vec3): Float32Array {
69 const z = norm(sub(eye, center));
70 const x = norm(cross(up, z));
71 const y = cross(z, x);
72 const out = new Float32Array(16);
73 out[0] = x[0];
74 out[4] = x[1];
75 out[8] = x[2];
76 out[1] = y[0];
77 out[5] = y[1];
78 out[9] = y[2];
79 out[2] = z[0];
80 out[6] = z[1];
81 out[10] = z[2];
82 out[12] = -dot(x, eye);
83 out[13] = -dot(y, eye);
84 out[14] = -dot(z, eye);
85 out[15] = 1;
86 return out;
87}
88const sub = (a: Vec3, b: Vec3): Vec3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
89const add = (a: Vec3, b: Vec3): Vec3 => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
90const scl = (a: Vec3, s: number): Vec3 => [a[0] * s, a[1] * s, a[2] * s];
91const dot = (a: Vec3, b: Vec3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
92const cross = (a: Vec3, b: Vec3): Vec3 => [
93 a[1] * b[2] - a[2] * b[1],
94 a[2] * b[0] - a[0] * b[2],
95 a[0] * b[1] - a[1] * b[0],
96];
97const norm = (a: Vec3): Vec3 => {
98 const l = Math.hypot(a[0], a[1], a[2]) || 1;
99 return [a[0] / l, a[1] / l, a[2] / l];
100};
101/** Rodrigues rotation of v about the unit axis k by angle a. */
102function rotate(v: Vec3, k: Vec3, a: number): Vec3 {
103 const c = Math.cos(a);
104 const s = Math.sin(a);
105 const kv = cross(k, v);
106 const kd = dot(k, v);
107 return [
108 v[0] * c + kv[0] * s + k[0] * kd * (1 - c),
109 v[1] * c + kv[1] * s + k[1] * kd * (1 - c),
110 v[2] * c + kv[2] * s + k[2] * kd * (1 - c),
111 ];
112}
114/**
115 * The plate in its mount, in the laboratory frame: gravity points down the
116 * screen, the plate is turned as it hangs or lies, the threads go up to their
117 * hooks, the pads sit under it, the jaws of the vise grip it. The surface is
118 * displaced by the static sag and the flexural field (exaggerated by
119 * flexScale) and moved as a rigid body by the frame's rigid coordinates
120 * (applied as exact rotations about the pivot, scaled by rigidScale). Drag to
121 * orbit, wheel to zoom.
122 */
123export function Plate3DView(props: Plate3DViewProps) {
124 const {
125 result,
126 spec,
127 frameIndex,
128 colorFrames,
129 colorRange,
130 colormap = "bwr",
131 flexScale,
132 rigidScale,
133 width = 460,
134 height = 340,
135 } = props;
136 const canvasRef = useRef<HTMLCanvasElement>(null);
137 const glRef = useRef<{
138 gl: WebGLRenderingContext;
139 prog: WebGLProgram;
140 buffers: { pos: WebGLBuffer; nrm: WebGLBuffer; col: WebGLBuffer; idx: WebGLBuffer };
141 lines: { pos: WebGLBuffer; col: WebGLBuffer; nrm: WebGLBuffer };
142 loc: Record<string, number | WebGLUniformLocation | null>;
143 } | null>(null);
144 const [cam, setCam] = useState({ az: 0.6, el: 0.35, zoom: 1 });
145 const drag = useRef<{ x: number; y: number } | null>(null);
147 const nx = result.grid.x.length;
148 const ny = result.grid.y.length;
149 const { Lx, Ly } = spec.plate;
150 const size = Math.max(Lx, Ly);
152 // Lab frame: X horizontal in the plate's plane, Y up, Z the rest
153 const frame = useMemo(() => {
154 const s = result.static;
155 const g: Vec3 = [s.gx, s.gy, s.gz];
156 const gl = Math.hypot(...g);
157 const up: Vec3 = gl > 0 ? scl(g, -1 / gl) : [0, 0, 1];
158 const eh: Vec3 = [result.rigid.eh[0], result.rigid.eh[1], 0];
159 const ed: Vec3 = [result.rigid.ed[0], result.rigid.ed[1], 0];
160 let X: Vec3 = Math.abs(dot(eh, up)) < 0.99 ? eh : [1, 0, 0];
161 X = norm(sub(X, scl(up, dot(X, up))));
162 const Z = cross(X, up);
163 const toLab = (p: Vec3): Vec3 => [dot(X, p), dot(up, p), dot(Z, p)];
164 return { toLab, ed, eh, up };
165 }, [result]);
167 // Static geometry of the triangles: which cells are on the plate
168 const mesh = useMemo(() => {
169 const w0 = result.static.w0.data;
170 const inside = (i: number, j: number) => Number.isFinite(w0[i + j * ny]);
171 const idx: number[] = [];
172 for (let j = 0; j < nx - 1; j++)
173 for (let i = 0; i < ny - 1; i++) {
174 if (!(inside(i, j) && inside(i + 1, j) && inside(i, j + 1) && inside(i + 1, j + 1)))
175 continue;
176 const a = i + j * ny;
177 const b = i + 1 + j * ny;
178 const c = i + (j + 1) * ny;
179 const d = i + 1 + (j + 1) * ny;
180 idx.push(a, b, c, b, d, c);
181 }
182 return { index: new Uint16Array(idx), inside };
183 }, [result, nx, ny]);
185 useEffect(() => {
186 const canvas = canvasRef.current;
187 if (!canvas) return;
188 const gl = canvas.getContext("webgl", { antialias: true });
189 if (!gl) return;
190 const compile = (type: number, src: string) => {
191 const sh = gl.createShader(type)!;
192 gl.shaderSource(sh, src);
193 gl.compileShader(sh);
194 return sh;
195 };
196 const prog = gl.createProgram()!;
197 gl.attachShader(prog, compile(gl.VERTEX_SHADER, VS));
198 gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FS));
199 gl.linkProgram(prog);
200 const mk = () => gl.createBuffer()!;
201 glRef.current = {
202 gl,
203 prog,
204 buffers: { pos: mk(), nrm: mk(), col: mk(), idx: mk() },
205 lines: { pos: mk(), col: mk(), nrm: mk() },
206 loc: {
207 aPos: gl.getAttribLocation(prog, "aPos"),
208 aNormal: gl.getAttribLocation(prog, "aNormal"),
209 aColor: gl.getAttribLocation(prog, "aColor"),
210 uProj: gl.getUniformLocation(prog, "uProj"),
211 uView: gl.getUniformLocation(prog, "uView"),
212 uFlat: gl.getUniformLocation(prog, "uFlat"),
213 },
214 };
215 return () => {
216 glRef.current = null;
217 };
218 }, []);
220 useEffect(() => {
221 const r = glRef.current;
222 const canvas = canvasRef.current;
223 if (!r || !canvas) return;
224 const { gl, prog, buffers, lines, loc } = r;
225 const dpr = Math.min(2, window.devicePixelRatio || 1);
226 canvas.width = Math.round(width * dpr);
227 canvas.height = Math.round(height * dpr);
228 gl.viewport(0, 0, canvas.width, canvas.height);
230 // ---- node positions in the lab frame
231 const N = nx * ny;
232 const kf = Math.min(frameIndex, result.frames.t.length - 1);
233 const phi = result.frames.w;
234 const w0 = result.static.w0.data;
235 const c = result.rigid.frames.map((row) => (kf >= 0 ? row[kf] : 0));
236 const pivot: Vec3 = [result.rigid.pivot.x, result.rigid.pivot.y, 0];
237 const { ed, eh, toLab } = frame;
238 const flexOf = (n: number) => {
239 const z0 = Number.isFinite(w0[n]) ? w0[n] : 0;
240 const f = kf >= 0 ? phi.data[n + kf * N] : 0;
241 return flexScale * (z0 + (Number.isFinite(f) ? f : 0));
242 };
243 const rigidPos = (p: Vec3): Vec3 => {
244 if (result.rigid.nr === 0) return p;
245 let q = sub(p, pivot);
246 q = rotate(q, eh, -c[1] * rigidScale);
247 q = rotate(q, ed, c[2] * rigidScale);
248 q = add(q, pivot);
249 return [q[0], q[1], q[2] + c[0] * rigidScale];
250 };
251 const pos = new Float32Array(3 * N);
252 const labPos: Vec3[] = new Array(N);
253 for (let j = 0; j < nx; j++)
254 for (let i = 0; i < ny; i++) {
255 const n = i + j * ny;
256 const p: Vec3 = [result.grid.x[j], result.grid.y[i], flexOf(n)];
257 const lp = toLab(rigidPos(p));
258 labPos[n] = lp;
259 pos[3 * n] = lp[0];
260 pos[3 * n + 1] = lp[1];
261 pos[3 * n + 2] = lp[2];
262 }
263 // normals from neighbouring nodes
264 const nrm = new Float32Array(3 * N);
265 for (let j = 0; j < nx; j++)
266 for (let i = 0; i < ny; i++) {
267 const n = i + j * ny;
268 const jl = Math.max(0, j - 1);
269 const jr = Math.min(nx - 1, j + 1);
270 const il = Math.max(0, i - 1);
271 const ir = Math.min(ny - 1, i + 1);
272 const tx = sub(labPos[i + jr * ny], labPos[i + jl * ny]);
273 const ty = sub(labPos[ir + j * ny], labPos[il + j * ny]);
274 const nn = norm(cross(tx, ty));
275 nrm[3 * n] = nn[0];
276 nrm[3 * n + 1] = nn[1];
277 nrm[3 * n + 2] = nn[2];
278 }
279 // colours
280 const col = new Float32Array(3 * N);
281 const table = colormapTable(getColormap(colormap));
282 const range = colorRange ?? { min: -1, max: 1 };
283 const span = range.max - range.min || 1;
284 for (let n = 0; n < N; n++) {
285 let v = colorFrames && kf >= 0 ? colorFrames.data[n + kf * N] : NaN;
286 if (!Number.isFinite(v)) v = 0.5 * (range.min + range.max);
287 let t = (v - range.min) / span;
288 t = t < 0 ? 0 : t > 1 ? 1 : t;
289 const k = Math.round(t * 255) * 3;
290 col[3 * n] = table[k] / 255;
291 col[3 * n + 1] = table[k + 1] / 255;
292 col[3 * n + 2] = table[k + 2] / 255;
293 }
295 // ---- the mount, as lines and boxes in the lab frame
296 const L: number[] = [];
297 const LC: number[] = [];
298 const line = (a: Vec3, b: Vec3, rgb: Vec3) => {
299 L.push(...a, ...b);
300 LC.push(...rgb, ...rgb);
301 };
302 const grey: Vec3 = [0.35, 0.35, 0.38];
303 const dark: Vec3 = [0.15, 0.15, 0.18];
304 const red: Vec3 = [0.82, 0.29, 0.36];
305 const box = (lo: Vec3, hi: Vec3, rgb: Vec3, toLabFn = toLab) => {
306 const cs: Vec3[] = [];
307 for (let k = 0; k < 8; k++)
308 cs.push(toLabFn([k & 1 ? hi[0] : lo[0], k & 2 ? hi[1] : lo[1], k & 4 ? hi[2] : lo[2]]));
309 const e = [
310 [0, 1],
311 [2, 3],
312 [4, 5],
313 [6, 7],
314 [0, 2],
315 [1, 3],
316 [4, 6],
317 [5, 7],
318 [0, 4],
319 [1, 5],
320 [2, 6],
321 [3, 7],
322 ];
323 for (const [a, b] of e) line(cs[a], cs[b], rgb);
324 };
325 const circle = (centre: Vec3, r: number, rgb: Vec3) => {
326 const n = 32;
327 for (let k = 0; k < n; k++) {
328 const a0 = (2 * Math.PI * k) / n;
329 const a1 = (2 * Math.PI * (k + 1)) / n;
330 line(
331 toLab([centre[0] + r * Math.cos(a0), centre[1] + r * Math.sin(a0), centre[2]]),
332 toLab([centre[0] + r * Math.cos(a1), centre[1] + r * Math.sin(a1), centre[2]]),
333 rgb,
334 );
335 }
336 };
337 const m: Mount = spec.mount;
338 const nodeAt = (x: number, y: number): number => {
339 let best = 0;
340 let bd = Infinity;
341 for (let j = 0; j < nx; j++)
342 for (let i = 0; i < ny; i++) {
343 const d = (result.grid.x[j] - x) ** 2 + (result.grid.y[i] - y) ** 2;
344 if (d < bd && mesh.inside(i, j)) {
345 bd = d;
346 best = i + j * ny;
347 }
348 }
349 return best;
350 };
351 const t = 0.02 * size; // a nominal thickness for the drawn parts
352 if (m.type === "thread") {
353 const n = nodeAt(m.x, m.y);
354 const hole = labPos[n];
355 const hook = add(
356 toLab([result.grid.x[Math.floor(n / ny)], result.grid.y[n % ny], 0]),
357 scl(frame.up, m.length),
358 );
359 line(hole, hook, dark);
360 line(add(hook, [-t, 0, 0]), add(hook, [t, 0, 0]), dark);
361 } else if (m.type === "threads") {
362 for (const [x, y] of [
363 [m.x1, m.y1],
364 [m.x2, m.y2],
365 ]) {
366 const n = nodeAt(x, y);
367 const hole = labPos[n];
368 const hook = add(
369 toLab([result.grid.x[Math.floor(n / ny)], result.grid.y[n % ny], 0]),
370 scl(frame.up, m.length),
371 );
372 line(hole, hook, dark);
373 line(add(hook, [-t, 0, 0]), add(hook, [t, 0, 0]), dark);
374 }
375 } else if (m.type === "pads") {
376 for (const p of m.pads)
377 box([p.x - p.rx, p.y - p.ry, -2 * t], [p.x + p.rx, p.y + p.ry, 0], grey);
378 } else if (m.type === "cymbal") {
379 const cx = Lx / 2;
380 const cy = Ly / 2;
381 circle([cx, cy, -t], m.r_felt, grey);
382 circle([cx, cy, t], m.r_felt, grey);
383 circle([cx, cy, -t], m.r_rod, dark);
384 line(toLab([cx, cy, -6 * t]), toLab([cx, cy, 3 * t]), dark);
385 } else if (m.type === "vise") {
386 const d = m.depth;
387 const lo: Vec3 =
388 m.edge === "x0"
389 ? [-t, -t, -t]
390 : m.edge === "x1"
391 ? [Lx - d, -t, -t]
392 : m.edge === "y0"
393 ? [-t, -t, -t]
394 : [-t, Ly - d, -t];
395 const hi: Vec3 =
396 m.edge === "x0"
397 ? [d, Ly + t, t]
398 : m.edge === "x1"
399 ? [Lx + t, Ly + t, t]
400 : m.edge === "y0"
401 ? [Lx + t, d, t]
402 : [Lx + t, Ly + t, t];
403 box(lo, hi, dark);
404 } else if (m.type === "pinched") {
405 const n = nodeAt(m.x, m.y);
406 const p = labPos[n];
407 const nrmL = norm(
408 cross(
409 sub(labPos[nodeAt(m.x + Lx / 10, m.y)], p),
410 sub(labPos[nodeAt(m.x, m.y + Ly / 10)], p),
411 ),
412 );
413 line(add(p, scl(nrmL, t)), add(p, scl(nrmL, 4 * t)), dark);
414 line(add(p, scl(nrmL, -t)), add(p, scl(nrmL, -4 * t)), dark);
415 }
416 // gravity arrow beside the plate
417 {
418 const corner = add(toLab([Lx * 1.15, Ly * 1.05, 0]), [0, 0, 0]);
419 const tip = add(corner, scl(frame.up, -0.25 * size));
420 line(corner, tip, red);
421 const side = scl(cross(frame.up, [0, 0, 1]), 0.03 * size);
422 line(tip, add(add(tip, scl(frame.up, 0.06 * size)), side), red);
423 line(tip, sub(add(tip, scl(frame.up, 0.06 * size)), side), red);
424 }
425 // outline of the undeformed plate at rest, faint
426 {
427 const pale: Vec3 = [0.75, 0.75, 0.78];
428 if (spec.plate.shape === "ellipse") circle([Lx / 2, Ly / 2, 0], Lx / 2, pale);
429 else box([0, 0, 0], [Lx, Ly, 0], pale);
430 }
432 // ---- camera
433 const centre = toLab([Lx / 2, Ly / 2, 0]);
434 const dist = (2.2 * size) / cam.zoom;
435 const eye: Vec3 = add(centre, [
436 dist * Math.cos(cam.el) * Math.sin(cam.az),
437 dist * Math.sin(cam.el),
438 dist * Math.cos(cam.el) * Math.cos(cam.az),
439 ]);
440 const view = lookAt(eye, centre, [0, 1, 0]);
441 const proj = perspective(0.6, width / height, 0.01 * size, 20 * size);
443 gl.clearColor(0.97, 0.97, 0.975, 1);
444 gl.enable(gl.DEPTH_TEST);
445 gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
446 gl.useProgram(prog);
447 gl.uniformMatrix4fv(loc.uProj as WebGLUniformLocation, false, proj);
448 gl.uniformMatrix4fv(loc.uView as WebGLUniformLocation, false, view);
449 const attr = (buf: WebGLBuffer, data: Float32Array, a: number) => {
450 gl.bindBuffer(gl.ARRAY_BUFFER, buf);
451 gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_DRAW);
452 gl.enableVertexAttribArray(a);
453 gl.vertexAttribPointer(a, 3, gl.FLOAT, false, 0, 0);
454 };
455 gl.uniform1f(loc.uFlat as WebGLUniformLocation, 0);
456 attr(buffers.pos, pos, loc.aPos as number);
457 attr(buffers.nrm, nrm, loc.aNormal as number);
458 attr(buffers.col, col, loc.aColor as number);
459 gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buffers.idx);
460 gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, mesh.index, gl.STATIC_DRAW);
461 gl.drawElements(gl.TRIANGLES, mesh.index.length, gl.UNSIGNED_SHORT, 0);
462 // lines
463 gl.uniform1f(loc.uFlat as WebGLUniformLocation, 1);
464 const lp = new Float32Array(L);
465 attr(lines.pos, lp, loc.aPos as number);
466 attr(lines.nrm, new Float32Array(L.length), loc.aNormal as number);
467 attr(lines.col, new Float32Array(LC), loc.aColor as number);
468 gl.drawArrays(gl.LINES, 0, lp.length / 3);
469 }, [
470 result,
471 spec,
472 frameIndex,
473 colorFrames,
474 colorRange,
475 colormap,
476 flexScale,
477 rigidScale,
478 width,
479 height,
480 cam,
481 frame,
482 mesh,
483 nx,
484 ny,
485 Lx,
486 Ly,
487 size,
488 ]);
490 return (
491 <canvas
492 ref={canvasRef}
493 style={{ width, height, display: "block", cursor: "grab", borderRadius: 4 }}
494 onPointerDown={(e) => {
495 drag.current = { x: e.clientX, y: e.clientY };
496 (e.target as HTMLElement).setPointerCapture(e.pointerId);
497 }}
498 onPointerMove={(e) => {
499 if (!drag.current) return;
500 const dx = e.clientX - drag.current.x;
501 const dy = e.clientY - drag.current.y;
502 drag.current = { x: e.clientX, y: e.clientY };
503 setCam((c) => ({
504 ...c,
505 az: c.az - dx * 0.01,
506 el: Math.max(-1.4, Math.min(1.4, c.el + dy * 0.01)),
507 }));
508 }}
509 onPointerUp={() => {
510 drag.current = null;
511 }}
512 onWheel={(e) => {
513 e.preventDefault();
514 setCam((c) => ({
515 ...c,
516 zoom: Math.max(0.3, Math.min(5, c.zoom * Math.exp(-e.deltaY * 0.001))),
517 }));
518 }}
519 aria-label="the plate in its mount"
520 />
521 );
522}