7540997Save the run as a movie: the scene, its caption and the soundJeremy Magland 1import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
80c0b6dPlate dynamics lab: plates in realizable mounts under gravity, explicit finite differences on WebGPUJeremy Magland 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 */
7540997Save the run as a movie: the scene, its caption and the soundJeremy Magland 123/** What the movie recorder needs of the scene: its canvas, and a frame drawn on demand. */
124export interface Plate3DHandle {
125 canvas: () => HTMLCanvasElement | null;
126 /** Draw frame k; `size` overrides the backing store, for recording at a larger size */
127 drawFrame: (k: number, size?: { width: number; height: number }) => void;
128}
80c0b6dPlate dynamics lab: plates in realizable mounts under gravity, explicit finite differences on WebGPUJeremy Magland 129
7540997Save the run as a movie: the scene, its caption and the soundJeremy Magland 130export const Plate3DView = forwardRef<Plate3DHandle, Plate3DViewProps>(
131 function Plate3DView(props, ref) {
132 const {
133 result,
134 spec,
135 frameIndex,
136 colorFrames,
137 colorRange,
138 colormap = "bwr",
139 flexScale,
140 rigidScale,
141 width = 460,
142 height = 340,
143 } = props;
144 const canvasRef = useRef<HTMLCanvasElement>(null);
145 const glRef = useRef<{
146 gl: WebGLRenderingContext;
147 prog: WebGLProgram;
148 buffers: { pos: WebGLBuffer; nrm: WebGLBuffer; col: WebGLBuffer; idx: WebGLBuffer };
149 lines: { pos: WebGLBuffer; col: WebGLBuffer; nrm: WebGLBuffer };
150 loc: Record<string, number | WebGLUniformLocation | null>;
151 } | null>(null);
152 const [cam, setCam] = useState({ az: 0.6, el: 0.35, zoom: 1 });
153 const drag = useRef<{ x: number; y: number } | null>(null);
80c0b6dPlate dynamics lab: plates in realizable mounts under gravity, explicit finite differences on WebGPUJeremy Magland 154
7540997Save the run as a movie: the scene, its caption and the soundJeremy Magland 155 const nx = result.grid.x.length;
156 const ny = result.grid.y.length;
157 const { Lx, Ly } = spec.plate;
158 const size = Math.max(Lx, Ly);
80c0b6dPlate dynamics lab: plates in realizable mounts under gravity, explicit finite differences on WebGPUJeremy Magland 159
7540997Save the run as a movie: the scene, its caption and the soundJeremy Magland 160 // Lab frame: X horizontal in the plate's plane, Y up, Z the rest
161 const frame = useMemo(() => {
162 const s = result.static;
163 const g: Vec3 = [s.gx, s.gy, s.gz];
164 const gl = Math.hypot(...g);
165 const up: Vec3 = gl > 0 ? scl(g, -1 / gl) : [0, 0, 1];
166 const eh: Vec3 = [result.rigid.eh[0], result.rigid.eh[1], 0];
167 const ed: Vec3 = [result.rigid.ed[0], result.rigid.ed[1], 0];
168 let X: Vec3 = Math.abs(dot(eh, up)) < 0.99 ? eh : [1, 0, 0];
169 X = norm(sub(X, scl(up, dot(X, up))));
170 const Z = cross(X, up);
171 const toLab = (p: Vec3): Vec3 => [dot(X, p), dot(up, p), dot(Z, p)];
172 return { toLab, ed, eh, up };
173 }, [result]);
80c0b6dPlate dynamics lab: plates in realizable mounts under gravity, explicit finite differences on WebGPUJeremy Magland 174
7540997Save the run as a movie: the scene, its caption and the soundJeremy Magland 175 // Static geometry of the triangles: which cells are on the plate
176 const mesh = useMemo(() => {
177 const w0 = result.static.w0.data;
178 const inside = (i: number, j: number) => Number.isFinite(w0[i + j * ny]);
179 const idx: number[] = [];
180 for (let j = 0; j < nx - 1; j++)
181 for (let i = 0; i < ny - 1; i++) {
182 if (!(inside(i, j) && inside(i + 1, j) && inside(i, j + 1) && inside(i + 1, j + 1)))
183 continue;
184 const a = i + j * ny;
185 const b = i + 1 + j * ny;
186 const c = i + (j + 1) * ny;
187 const d = i + 1 + (j + 1) * ny;
188 idx.push(a, b, c, b, d, c);
189 }
190 return { index: new Uint16Array(idx), inside };
191 }, [result, nx, ny]);
80c0b6dPlate dynamics lab: plates in realizable mounts under gravity, explicit finite differences on WebGPUJeremy Magland 192
7540997Save the run as a movie: the scene, its caption and the soundJeremy Magland 193 useEffect(() => {
194 const canvas = canvasRef.current;
195 if (!canvas) return;
196 const gl = canvas.getContext("webgl", { antialias: true });
197 if (!gl) return;
198 const compile = (type: number, src: string) => {
199 const sh = gl.createShader(type)!;
200 gl.shaderSource(sh, src);
201 gl.compileShader(sh);
202 return sh;
203 };
204 const prog = gl.createProgram()!;
205 gl.attachShader(prog, compile(gl.VERTEX_SHADER, VS));
206 gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FS));
207 gl.linkProgram(prog);
208 const mk = () => gl.createBuffer()!;
209 glRef.current = {
210 gl,
211 prog,
212 buffers: { pos: mk(), nrm: mk(), col: mk(), idx: mk() },
213 lines: { pos: mk(), col: mk(), nrm: mk() },
214 loc: {
215 aPos: gl.getAttribLocation(prog, "aPos"),
216 aNormal: gl.getAttribLocation(prog, "aNormal"),
217 aColor: gl.getAttribLocation(prog, "aColor"),
218 uProj: gl.getUniformLocation(prog, "uProj"),
219 uView: gl.getUniformLocation(prog, "uView"),
220 uFlat: gl.getUniformLocation(prog, "uFlat"),
221 },
222 };
223 return () => {
224 glRef.current = null;
225 };
226 }, []);
80c0b6dPlate dynamics lab: plates in realizable mounts under gravity, explicit finite differences on WebGPUJeremy Magland 227
229 * Draw one frame. `out` overrides the backing store, which the movie
230 * recorder uses to render the scene larger than it appears here; the next
231 * on-screen draw puts it back.
232 */
233 const draw = (kIndex: number, out?: { width: number; height: number }) => {
234 const r = glRef.current;
235 const canvas = canvasRef.current;
236 if (!r || !canvas) return;
237 const { gl, prog, buffers, lines, loc } = r;
238 const vw = out?.width ?? width;
239 const vh = out?.height ?? height;
240 const dpr = out ? 1 : Math.min(2, window.devicePixelRatio || 1);
241 canvas.width = Math.round(vw * dpr);
242 canvas.height = Math.round(vh * dpr);
243 gl.viewport(0, 0, canvas.width, canvas.height);
80c0b6dPlate dynamics lab: plates in realizable mounts under gravity, explicit finite differences on WebGPUJeremy Magland 244
7540997Save the run as a movie: the scene, its caption and the soundJeremy Magland 245 // ---- node positions in the lab frame
246 const N = nx * ny;
247 const kf = Math.min(kIndex, result.frames.t.length - 1);
248 const phi = result.frames.w;
249 const w0 = result.static.w0.data;
250 const c = result.rigid.frames.map((row) => (kf >= 0 ? row[kf] : 0));
251 const pivot: Vec3 = [result.rigid.pivot.x, result.rigid.pivot.y, 0];
252 const { ed, eh, toLab } = frame;
253 const flexOf = (n: number) => {
254 const z0 = Number.isFinite(w0[n]) ? w0[n] : 0;
255 const f = kf >= 0 ? phi.data[n + kf * N] : 0;
256 return flexScale * (z0 + (Number.isFinite(f) ? f : 0));
257 };
258 const rigidPos = (p: Vec3): Vec3 => {
259 if (result.rigid.nr === 0) return p;
260 let q = sub(p, pivot);
261 q = rotate(q, eh, -c[1] * rigidScale);
262 q = rotate(q, ed, c[2] * rigidScale);
263 q = add(q, pivot);
264 return [q[0], q[1], q[2] + c[0] * rigidScale];
265 };
266 const pos = new Float32Array(3 * N);
267 const labPos: Vec3[] = new Array(N);
80c0b6dPlate dynamics lab: plates in realizable mounts under gravity, explicit finite differences on WebGPUJeremy Magland 268 for (let j = 0; j < nx; j++)
269 for (let i = 0; i < ny; i++) {
7540997Save the run as a movie: the scene, its caption and the soundJeremy Magland 270 const n = i + j * ny;
271 const p: Vec3 = [result.grid.x[j], result.grid.y[i], flexOf(n)];
272 const lp = toLab(rigidPos(p));
273 labPos[n] = lp;
274 pos[3 * n] = lp[0];
275 pos[3 * n + 1] = lp[1];
276 pos[3 * n + 2] = lp[2];
80c0b6dPlate dynamics lab: plates in realizable mounts under gravity, explicit finite differences on WebGPUJeremy Magland 277 }
7540997Save the run as a movie: the scene, its caption and the soundJeremy Magland 278 // normals from neighbouring nodes
279 const nrm = new Float32Array(3 * N);
280 for (let j = 0; j < nx; j++)
281 for (let i = 0; i < ny; i++) {
282 const n = i + j * ny;
283 const jl = Math.max(0, j - 1);
284 const jr = Math.min(nx - 1, j + 1);
285 const il = Math.max(0, i - 1);
286 const ir = Math.min(ny - 1, i + 1);
287 const tx = sub(labPos[i + jr * ny], labPos[i + jl * ny]);
288 const ty = sub(labPos[ir + j * ny], labPos[il + j * ny]);
289 const nn = norm(cross(tx, ty));
290 nrm[3 * n] = nn[0];
291 nrm[3 * n + 1] = nn[1];
292 nrm[3 * n + 2] = nn[2];
293 }
294 // colours
295 const col = new Float32Array(3 * N);
296 const table = colormapTable(getColormap(colormap));
297 const range = colorRange ?? { min: -1, max: 1 };
298 const span = range.max - range.min || 1;
299 for (let n = 0; n < N; n++) {
300 let v = colorFrames && kf >= 0 ? colorFrames.data[n + kf * N] : NaN;
301 if (!Number.isFinite(v)) v = 0.5 * (range.min + range.max);
302 let t = (v - range.min) / span;
303 t = t < 0 ? 0 : t > 1 ? 1 : t;
304 const k = Math.round(t * 255) * 3;
305 col[3 * n] = table[k] / 255;
306 col[3 * n + 1] = table[k + 1] / 255;
307 col[3 * n + 2] = table[k + 2] / 255;
308 }
310 // ---- the mount, as lines and boxes in the lab frame
311 const L: number[] = [];
312 const LC: number[] = [];
313 const line = (a: Vec3, b: Vec3, rgb: Vec3) => {
314 L.push(...a, ...b);
315 LC.push(...rgb, ...rgb);
316 };
317 const grey: Vec3 = [0.35, 0.35, 0.38];
318 const dark: Vec3 = [0.15, 0.15, 0.18];
319 const red: Vec3 = [0.82, 0.29, 0.36];
320 const box = (lo: Vec3, hi: Vec3, rgb: Vec3, toLabFn = toLab) => {
321 const cs: Vec3[] = [];
322 for (let k = 0; k < 8; k++)
323 cs.push(toLabFn([k & 1 ? hi[0] : lo[0], k & 2 ? hi[1] : lo[1], k & 4 ? hi[2] : lo[2]]));
324 const e = [
325 [0, 1],
326 [2, 3],
327 [4, 5],
328 [6, 7],
329 [0, 2],
330 [1, 3],
331 [4, 6],
332 [5, 7],
333 [0, 4],
334 [1, 5],
335 [2, 6],
336 [3, 7],
337 ];
338 for (const [a, b] of e) line(cs[a], cs[b], rgb);
339 };
340 const circle = (centre: Vec3, r: number, rgb: Vec3) => {
341 const n = 32;
342 for (let k = 0; k < n; k++) {
343 const a0 = (2 * Math.PI * k) / n;
344 const a1 = (2 * Math.PI * (k + 1)) / n;
345 line(
346 toLab([centre[0] + r * Math.cos(a0), centre[1] + r * Math.sin(a0), centre[2]]),
347 toLab([centre[0] + r * Math.cos(a1), centre[1] + r * Math.sin(a1), centre[2]]),
348 rgb,
349 );
350 }
351 };
352 const m: Mount = spec.mount;
353 const nodeAt = (x: number, y: number): number => {
354 let best = 0;
355 let bd = Infinity;
356 for (let j = 0; j < nx; j++)
357 for (let i = 0; i < ny; i++) {
358 const d = (result.grid.x[j] - x) ** 2 + (result.grid.y[i] - y) ** 2;
359 if (d < bd && mesh.inside(i, j)) {
360 bd = d;
361 best = i + j * ny;
362 }
363 }
364 return best;
365 };
366 const t = 0.02 * size; // a nominal thickness for the drawn parts
367 if (m.type === "thread") {
368 const n = nodeAt(m.x, m.y);
80c0b6dPlate dynamics lab: plates in realizable mounts under gravity, explicit finite differences on WebGPUJeremy Magland 369 const hole = labPos[n];
370 const hook = add(
371 toLab([result.grid.x[Math.floor(n / ny)], result.grid.y[n % ny], 0]),
372 scl(frame.up, m.length),
373 );
374 line(hole, hook, dark);
375 line(add(hook, [-t, 0, 0]), add(hook, [t, 0, 0]), dark);
7540997Save the run as a movie: the scene, its caption and the soundJeremy Magland 376 } else if (m.type === "threads") {
377 for (const [x, y] of [
378 [m.x1, m.y1],
379 [m.x2, m.y2],
380 ]) {
381 const n = nodeAt(x, y);
382 const hole = labPos[n];
383 const hook = add(
384 toLab([result.grid.x[Math.floor(n / ny)], result.grid.y[n % ny], 0]),
385 scl(frame.up, m.length),
386 );
387 line(hole, hook, dark);
388 line(add(hook, [-t, 0, 0]), add(hook, [t, 0, 0]), dark);
389 }
390 } else if (m.type === "pads") {
391 for (const p of m.pads)
392 box([p.x - p.rx, p.y - p.ry, -2 * t], [p.x + p.rx, p.y + p.ry, 0], grey);
393 } else if (m.type === "cymbal") {
394 const cx = Lx / 2;
395 const cy = Ly / 2;
396 circle([cx, cy, -t], m.r_felt, grey);
397 circle([cx, cy, t], m.r_felt, grey);
398 circle([cx, cy, -t], m.r_rod, dark);
399 line(toLab([cx, cy, -6 * t]), toLab([cx, cy, 3 * t]), dark);
400 } else if (m.type === "vise") {
401 const d = m.depth;
402 const lo: Vec3 =
403 m.edge === "x0"
404 ? [-t, -t, -t]
405 : m.edge === "x1"
406 ? [Lx - d, -t, -t]
407 : m.edge === "y0"
408 ? [-t, -t, -t]
409 : [-t, Ly - d, -t];
410 const hi: Vec3 =
411 m.edge === "x0"
412 ? [d, Ly + t, t]
413 : m.edge === "x1"
414 ? [Lx + t, Ly + t, t]
415 : m.edge === "y0"
416 ? [Lx + t, d, t]
417 : [Lx + t, Ly + t, t];
418 box(lo, hi, dark);
419 } else if (m.type === "pinched") {
420 const n = nodeAt(m.x, m.y);
421 const p = labPos[n];
422 const nrmL = norm(
423 cross(
424 sub(labPos[nodeAt(m.x + Lx / 10, m.y)], p),
425 sub(labPos[nodeAt(m.x, m.y + Ly / 10)], p),
426 ),
427 );
428 line(add(p, scl(nrmL, t)), add(p, scl(nrmL, 4 * t)), dark);
429 line(add(p, scl(nrmL, -t)), add(p, scl(nrmL, -4 * t)), dark);
430 }
431 // gravity arrow beside the plate
432 {
433 const corner = add(toLab([Lx * 1.15, Ly * 1.05, 0]), [0, 0, 0]);
434 const tip = add(corner, scl(frame.up, -0.25 * size));
435 line(corner, tip, red);
436 const side = scl(cross(frame.up, [0, 0, 1]), 0.03 * size);
437 line(tip, add(add(tip, scl(frame.up, 0.06 * size)), side), red);
438 line(tip, sub(add(tip, scl(frame.up, 0.06 * size)), side), red);
439 }
440 // outline of the undeformed plate at rest, faint
441 {
442 const pale: Vec3 = [0.75, 0.75, 0.78];
443 if (spec.plate.shape === "ellipse") circle([Lx / 2, Ly / 2, 0], Lx / 2, pale);
444 else box([0, 0, 0], [Lx, Ly, 0], pale);
80c0b6dPlate dynamics lab: plates in realizable mounts under gravity, explicit finite differences on WebGPUJeremy Magland 445 }
7540997Save the run as a movie: the scene, its caption and the soundJeremy Magland 447 // ---- camera
448 const centre = toLab([Lx / 2, Ly / 2, 0]);
449 const dist = (2.2 * size) / cam.zoom;
450 const eye: Vec3 = add(centre, [
451 dist * Math.cos(cam.el) * Math.sin(cam.az),
452 dist * Math.sin(cam.el),
453 dist * Math.cos(cam.el) * Math.cos(cam.az),
454 ]);
455 const view = lookAt(eye, centre, [0, 1, 0]);
456 const proj = perspective(0.6, vw / vh, 0.01 * size, 20 * size);
80c0b6dPlate dynamics lab: plates in realizable mounts under gravity, explicit finite differences on WebGPUJeremy Magland 457
7540997Save the run as a movie: the scene, its caption and the soundJeremy Magland 458 gl.clearColor(0.97, 0.97, 0.975, 1);
459 gl.enable(gl.DEPTH_TEST);
460 gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
461 gl.useProgram(prog);
462 gl.uniformMatrix4fv(loc.uProj as WebGLUniformLocation, false, proj);
463 gl.uniformMatrix4fv(loc.uView as WebGLUniformLocation, false, view);
464 const attr = (buf: WebGLBuffer, data: Float32Array, a: number) => {
465 gl.bindBuffer(gl.ARRAY_BUFFER, buf);
466 gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_DRAW);
467 gl.enableVertexAttribArray(a);
468 gl.vertexAttribPointer(a, 3, gl.FLOAT, false, 0, 0);
469 };
470 gl.uniform1f(loc.uFlat as WebGLUniformLocation, 0);
471 attr(buffers.pos, pos, loc.aPos as number);
472 attr(buffers.nrm, nrm, loc.aNormal as number);
473 attr(buffers.col, col, loc.aColor as number);
474 gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buffers.idx);
475 gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, mesh.index, gl.STATIC_DRAW);
476 gl.drawElements(gl.TRIANGLES, mesh.index.length, gl.UNSIGNED_SHORT, 0);
477 // lines
478 gl.uniform1f(loc.uFlat as WebGLUniformLocation, 1);
479 const lp = new Float32Array(L);
480 attr(lines.pos, lp, loc.aPos as number);
481 attr(lines.nrm, new Float32Array(L.length), loc.aNormal as number);
482 attr(lines.col, new Float32Array(LC), loc.aColor as number);
483 gl.drawArrays(gl.LINES, 0, lp.length / 3);
80c0b6dPlate dynamics lab: plates in realizable mounts under gravity, explicit finite differences on WebGPUJeremy Magland 484 };
7540997Save the run as a movie: the scene, its caption and the soundJeremy Magland 486 // The latest draw, so that the recorder can call it between renders; a
487 // re-render with unchanged props leaves the effect alone, so the two never
488 // fight over the canvas.
489 const drawRef = useRef(draw);
490 drawRef.current = draw;
491 useImperativeHandle(
492 ref,
493 () => ({
494 canvas: () => canvasRef.current,
495 drawFrame: (k: number, out?: { width: number; height: number }) => drawRef.current(k, out),
496 }),
497 [],
498 );
500 useEffect(() => {
501 drawRef.current(frameIndex);
502 }, [
503 result,
504 spec,
505 frameIndex,
506 colorFrames,
507 colorRange,
508 colormap,
509 flexScale,
510 rigidScale,
511 width,
512 height,
513 cam,
514 frame,
515 mesh,
516 nx,
517 ny,
518 Lx,
519 Ly,
520 size,
521 ]);
523 return (
524 <canvas
525 ref={canvasRef}
526 style={{ width, height, display: "block", cursor: "grab", borderRadius: 4 }}
527 onPointerDown={(e) => {
528 drag.current = { x: e.clientX, y: e.clientY };
529 (e.target as HTMLElement).setPointerCapture(e.pointerId);
530 }}
531 onPointerMove={(e) => {
532 if (!drag.current) return;
533 const dx = e.clientX - drag.current.x;
534 const dy = e.clientY - drag.current.y;
535 drag.current = { x: e.clientX, y: e.clientY };
536 setCam((c) => ({
537 ...c,
538 az: c.az - dx * 0.01,
539 el: Math.max(-1.4, Math.min(1.4, c.el + dy * 0.01)),
540 }));
541 }}
542 onPointerUp={() => {
543 drag.current = null;
544 }}
545 onWheel={(e) => {
546 e.preventDefault();
547 setCam((c) => ({
548 ...c,
549 zoom: Math.max(0.3, Math.min(5, c.zoom * Math.exp(-e.deltaY * 0.001))),
550 }));
551 }}
552 aria-label="the plate in its mount"
553 />
554 );
555 },
556);