1// Drawing of the plate: a 2D color map, or (WebGPU only) a lit 3D height
2// field of the displayed field plus, optionally, the stress-free profile,
3// colored by the height or by the thickness. Both views paint the strike
4// point and the listener as markers. Without WebGPU the map is
5// drawn with the 2D canvas API and the 3D mode falls back to the map.
6//
7// On the GPU the plate is one indexed mesh: a static vertex buffer of node
8// positions (x, y), a per-frame vertex buffer of height, slope and color per
9// node, and an index buffer of two triangles per cell of the plate built once
10// per setPlate. The 2D map is the same mesh under an orthographic camera with
11// the heights zeroed. Markers are a small triangle mesh rebuilt on the CPU
12// whenever the markers, the view or the field change.
14import { type Plate, insideCell, insideNode, nodeX, nodeY, thicknessRange } from "../params.ts";
15import { type Mat4, type Vec3, add, cross, dot, lookAt, multiply, ortho, perspective, scale, unit } from "./mat4.ts";
16import shaderSource from "./shaders.wgsl?raw";
18export type ViewMode = "2d" | "3d";
19export type ColorMode = "height" | "thickness";
21export interface ViewOptions {
22 mode: ViewMode;
23 color: ColorMode;
24 /** Add the stress-free profile w0 to the displayed field. */
25 showProfile: boolean;
26 /** Vertical scale factor of the 3D height field (1 = true scale). */
27 exaggeration: number;
28 /** Color range in meters (symmetric +-range for the height); 0 = automatic from the field. */
29 range: number;
30}
32export interface Markers {
33 /** Plate coordinates [m]. */
34 strike: { x: number; y: number; radius: number } | null;
35 /** Relative to the plate center [m], z along the normal. */
36 listener: { x: number; y: number; z: number } | null;
37}
39type RGB = [number, number, number];
41const BG: RGB = [0.11, 0.12, 0.14];
42const COLOR_STRIKE: RGB = [0.95, 0.78, 0.12];
43const COLOR_LINE: RGB = [0.55, 0.55, 0.55];
44const COLOR_MIC_HEAD: RGB = [0.32, 0.32, 0.35];
45const COLOR_MIC_BODY: RGB = [0.72, 0.73, 0.76];
46const LIGHT: Vec3 = [0.35, -0.5, 0.8];
48/** Vertical field of view of the 3D camera [rad] and the framing margin. */
49const FOV = 0.7;
50const FIT_MARGIN = 1.08;
51/** The drawn listener distance saturates at this many plate radii from the center (about one radius outside the edge). */
52const LISTENER_REACH = 1.8;
53/** Fill fraction of the canvas taken by the plate in the 2D map. */
54const FILL_2D = 0.82;
55const RING_SEGMENTS = 64;
56const MESH_STRIDE = 16; // bytes per node in the per-frame buffer: z, slope x, slope y, packed color
57const MARKER_STRIDE = 28; // bytes per marker vertex: position, normal, packed color
58const AUTO_RANGE_FLOOR = 1e-6;
60// ---------- Color maps (shared by the GPU path and the 2D fallback) ----------
62/** Diverging blue-white-red for t in [-1, 1]. */
63function diverging(t: number): RGB {
64 const s = Math.max(-1, Math.min(1, t));
65 const mid: RGB = [0.95, 0.95, 0.95];
66 const end: RGB = s < 0 ? [0.1, 0.35, 0.85] : [0.85, 0.2, 0.12];
67 const a = Math.abs(s);
68 return [mid[0] + (end[0] - mid[0]) * a, mid[1] + (end[1] - mid[1]) * a, mid[2] + (end[2] - mid[2]) * a];
69}
71const VIRIDIS: RGB[] = [
72 [0.267, 0.005, 0.329], [0.283, 0.141, 0.458], [0.254, 0.265, 0.530], [0.207, 0.372, 0.553], [0.164, 0.471, 0.558],
73 [0.128, 0.567, 0.551], [0.135, 0.659, 0.518], [0.267, 0.749, 0.441], [0.478, 0.821, 0.318], [0.741, 0.873, 0.150], [0.993, 0.906, 0.144],
74];
76/** Sequential viridis-like map for t in [0, 1]. */
77function viridis(t: number): RGB {
78 const s = Math.max(0, Math.min(1, t)) * (VIRIDIS.length - 1);
79 const i = Math.min(VIRIDIS.length - 2, Math.floor(s));
80 const f = s - i;
81 const a = VIRIDIS[i], b = VIRIDIS[i + 1];
82 return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f];
83}
85/** RGBA8 packed as the little-endian u32 a vertex attribute of format unorm8x4 reads. */
86function pack(c: RGB): number {
87 const r = Math.round(Math.max(0, Math.min(1, c[0])) * 255);
88 const g = Math.round(Math.max(0, Math.min(1, c[1])) * 255);
89 const b = Math.round(Math.max(0, Math.min(1, c[2])) * 255);
90 return (r | (g << 8) | (b << 16) | (255 << 24)) >>> 0;
91}
93function css(c: RGB): string {
94 return `rgb(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)})`;
95}
97// ---------- Marker geometry ----------
99/** A growable triangle list in the marker vertex layout (position, flat normal, packed color). */
100class MarkerMesh {
101 f = new Float32Array(7 * 4096);
102 u = new Uint32Array(this.f.buffer);
103 count = 0;
105 clear(): void {
106 this.count = 0;
107 }
108 get byteLength(): number {
109 return this.count * MARKER_STRIDE;
110 }
111 private ensure(nVerts: number): void {
112 const need = (this.count + nVerts) * 7;
113 if (need <= this.f.length) return;
114 const f = new Float32Array(Math.max(need, this.f.length * 2));
115 f.set(this.f.subarray(0, this.count * 7));
116 this.f = f;
117 this.u = new Uint32Array(f.buffer);
118 }
119 tri(a: Vec3, b: Vec3, c: Vec3, color: number): void {
120 this.ensure(3);
121 const n = unit(cross([b[0] - a[0], b[1] - a[1], b[2] - a[2]], [c[0] - a[0], c[1] - a[1], c[2] - a[2]]));
122 for (const p of [a, b, c]) {
123 const o = this.count * 7;
124 this.f[o] = p[0]; this.f[o + 1] = p[1]; this.f[o + 2] = p[2];
125 this.f[o + 3] = n[0]; this.f[o + 4] = n[1]; this.f[o + 5] = n[2];
126 this.u[o + 6] = color;
127 this.count++;
128 }
129 }
130 quad(a: Vec3, b: Vec3, c: Vec3, d: Vec3, color: number): void {
131 this.tri(a, b, c, color);
132 this.tri(a, c, d, color);
133 }
134 /** An annulus of mean radius r and half-width hw about (cx, cy), lifted to zAt(x, y). */
135 ring(cx: number, cy: number, r: number, hw: number, zAt: (x: number, y: number) => number, color: number): void {
136 const ri = Math.max(0, r - hw), ro = r + hw;
137 for (let s = 0; s < RING_SEGMENTS; s++) {
138 const a0 = (2 * Math.PI * s) / RING_SEGMENTS, a1 = (2 * Math.PI * (s + 1)) / RING_SEGMENTS;
139 const p = (rad: number, a: number): Vec3 => {
140 const x = cx + rad * Math.cos(a), y = cy + rad * Math.sin(a);
141 return [x, y, zAt(x, y)];
142 };
143 this.quad(p(ri, a0), p(ro, a0), p(ro, a1), p(ri, a1), color);
144 }
145 }
146 /** A closed cylinder from p0 to p1. */
147 cylinder(p0: Vec3, p1: Vec3, radius: number, segments: number, color: number): void {
148 const axis = unit([p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]]);
149 const [e1, e2] = frame(axis);
150 const rim = (p: Vec3, a: number): Vec3 => add(p, add(scale(e1, radius * Math.cos(a)), scale(e2, radius * Math.sin(a))));
151 for (let s = 0; s < segments; s++) {
152 const a0 = (2 * Math.PI * s) / segments, a1 = (2 * Math.PI * (s + 1)) / segments;
153 this.quad(rim(p0, a0), rim(p1, a0), rim(p1, a1), rim(p0, a1), color);
154 this.tri(p0, rim(p0, a1), rim(p0, a0), color);
155 this.tri(p1, rim(p1, a0), rim(p1, a1), color);
156 }
157 }
158 /** A box centered at c with the unit axes (ax, ay, az) and half sizes h. */
159 box(c: Vec3, ax: Vec3, ay: Vec3, az: Vec3, h: Vec3, color: number): void {
160 const corner = (sx: number, sy: number, sz: number): Vec3 =>
161 add(c, add(scale(ax, sx * h[0]), add(scale(ay, sy * h[1]), scale(az, sz * h[2]))));
162 const faces: [Vec3, Vec3, Vec3, Vec3][] = [
163 [corner(-1, -1, 1), corner(1, -1, 1), corner(1, 1, 1), corner(-1, 1, 1)],
164 [corner(-1, -1, -1), corner(-1, 1, -1), corner(1, 1, -1), corner(1, -1, -1)],
165 [corner(1, -1, -1), corner(1, 1, -1), corner(1, 1, 1), corner(1, -1, 1)],
166 [corner(-1, -1, -1), corner(-1, -1, 1), corner(-1, 1, 1), corner(-1, 1, -1)],
167 [corner(-1, 1, -1), corner(-1, 1, 1), corner(1, 1, 1), corner(1, 1, -1)],
168 [corner(-1, -1, -1), corner(1, -1, -1), corner(1, -1, 1), corner(-1, -1, 1)],
169 ];
170 for (const [a, b, cc, d] of faces) this.quad(a, b, cc, d, color);
171 }
172}
174/** Two unit vectors completing a right-handed frame with the unit vector u. */
175function frame(u: Vec3): [Vec3, Vec3] {
176 const t: Vec3 = Math.abs(u[2]) < 0.9 ? [0, 0, 1] : [1, 0, 0];
177 const e1 = unit(cross(t, u));
178 return [e1, cross(u, e1)];
179}
181/**
182 * The distance at which a listener is drawn, given its true distance d: it
183 * follows d at first and saturates at `limit`, so a listener a meter away
184 * still appears just outside the plate.
185 */
186function saturate(d: number, limit: number): number {
187 return limit * Math.tanh(d / limit);
188}
190// ---------- Renderer ----------
192export class PlateRenderer {
193 readonly has3d: boolean;
195 private canvas: HTMLCanvasElement;
196 private device: GPUDevice | null;
197 private gpuCtx: GPUCanvasContext | null = null;
198 private ctx2d: CanvasRenderingContext2D | null = null;
199 private format: GPUTextureFormat = "bgra8unorm";
200 private meshPipeline: GPURenderPipeline | null = null;
201 private markerPipeline: GPURenderPipeline | null = null;
202 private uniformBuf: GPUBuffer | null = null;
203 private bindGroup: GPUBindGroup | null = null;
204 private xyBuf: GPUBuffer | null = null;
205 private nodeBuf: GPUBuffer | null = null;
206 private indexBuf: GPUBuffer | null = null;
207 private indexCount = 0;
208 private markerBuf: GPUBuffer | null = null;
209 private markerCapacity = 0;
210 private depth: GPUTexture | null = null;
212 private plate: Plate | null = null;
213 private field: Float32Array | Float64Array | null = null;
214 private opts: ViewOptions = { mode: "2d", color: "height", showProfile: true, exaggeration: 1, range: 0 };
215 private markers: Markers = { strike: null, listener: null };
217 // Derived per plate
218 private hRange = { min: 0, max: 1 };
219 private w0Max = 0;
220 /** Largest in-plane distance of a plate node from the center [m], and half of the larger box side. */
221 private plateRadius = 0.1;
222 private boxRadius = 0.1;
223 private runMax = 0;
225 // Per-frame node records: display value, height (exaggerated), and the packed vertex records.
226 private val = new Float32Array(0);
227 private z = new Float32Array(0);
228 private nodeData = new ArrayBuffer(0);
229 private zMax = 0;
230 private markerMesh = new MarkerMesh();
232 private geomDirty = true;
233 private fieldDirty = true;
234 private markerDirty = true;
235 private imageDirty = true;
236 private image: HTMLCanvasElement | null = null;
238 // Orbit camera of the 3D view
239 private azimuth = -Math.PI / 2 + 0.3;
240 private elevation = Math.PI / 6;
241 private zoom = 1;
242 private frameRequested = false;
243 private destroyed = false;
244 private listeners: [string, EventListener, AddEventListenerOptions | undefined][] = [];
246 constructor(canvas: HTMLCanvasElement, device: GPUDevice | null) {
247 this.canvas = canvas;
248 this.device = device;
249 this.has3d = device !== null;
250 if (device) {
251 this.gpuCtx = canvas.getContext("webgpu") as GPUCanvasContext | null;
252 if (!this.gpuCtx) throw new Error("WebGPU canvas context unavailable");
253 this.format = navigator.gpu.getPreferredCanvasFormat();
254 this.gpuCtx.configure({ device, format: this.format, alphaMode: "opaque" });
255 this.createPipelines(device);
256 } else {
257 this.ctx2d = canvas.getContext("2d");
258 if (!this.ctx2d) throw new Error("2D canvas context unavailable");
259 }
260 this.installControls();
261 }
263 private createPipelines(device: GPUDevice): void {
264 const module = device.createShaderModule({ code: shaderSource });
265 this.uniformBuf = device.createBuffer({ size: 96, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
266 const bgLayout = device.createBindGroupLayout({
267 entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }],
268 });
269 this.bindGroup = device.createBindGroup({ layout: bgLayout, entries: [{ binding: 0, resource: { buffer: this.uniformBuf } }] });
270 const layout = device.createPipelineLayout({ bindGroupLayouts: [bgLayout] });
271 const depthStencil: GPUDepthStencilState = { format: "depth24plus", depthWriteEnabled: true, depthCompare: "less" };
272 const fragment: GPUFragmentState = { module, entryPoint: "fsMain", targets: [{ format: this.format }] };
273 this.meshPipeline = device.createRenderPipeline({
274 layout,
275 vertex: {
276 module,
277 entryPoint: "vsMesh",
278 buffers: [
279 { arrayStride: 8, attributes: [{ shaderLocation: 0, offset: 0, format: "float32x2" }] },
280 {
281 arrayStride: MESH_STRIDE,
282 attributes: [
283 { shaderLocation: 1, offset: 0, format: "float32" },
284 { shaderLocation: 2, offset: 4, format: "float32x2" },
285 { shaderLocation: 3, offset: 12, format: "unorm8x4" },
286 ],
287 },
288 ],
289 },
290 fragment,
291 primitive: { topology: "triangle-list", cullMode: "none" },
292 depthStencil,
293 });
294 this.markerPipeline = device.createRenderPipeline({
295 layout,
296 vertex: {
297 module,
298 entryPoint: "vsMarker",
299 buffers: [
300 {
301 arrayStride: MARKER_STRIDE,
302 attributes: [
303 { shaderLocation: 0, offset: 0, format: "float32x3" },
304 { shaderLocation: 1, offset: 12, format: "float32x3" },
305 { shaderLocation: 2, offset: 24, format: "unorm8x4" },
306 ],
307 },
308 ],
309 },
310 fragment,
311 primitive: { topology: "triangle-list", cullMode: "none" },
312 depthStencil,
313 });
314 }
316 private installControls(): void {
317 if (!this.has3d) return;
318 let dragging = false, lastX = 0, lastY = 0;
319 const on = <K extends keyof HTMLElementEventMap>(type: K, fn: (e: HTMLElementEventMap[K]) => void, opts?: AddEventListenerOptions) => {
320 this.canvas.addEventListener(type, fn, opts);
321 this.listeners.push([type, fn as EventListener, opts]);
322 };
323 on("pointerdown", (e) => {
324 if (this.opts.mode !== "3d") return;
325 dragging = true; lastX = e.clientX; lastY = e.clientY;
326 this.canvas.setPointerCapture(e.pointerId);
327 });
328 on("pointerup", () => { dragging = false; });
329 on("pointercancel", () => { dragging = false; });
330 on("pointermove", (e) => {
331 if (!dragging || this.opts.mode !== "3d") return;
332 this.azimuth -= (e.clientX - lastX) * 0.008;
333 this.elevation = Math.max(-1.45, Math.min(1.45, this.elevation + (e.clientY - lastY) * 0.008));
334 lastX = e.clientX; lastY = e.clientY;
335 this.markerDirty = true;
336 this.requestFrame();
337 });
338 on("wheel", (e) => {
339 if (this.opts.mode !== "3d") return;
340 e.preventDefault();
341 this.zoom = Math.max(0.05, Math.min(20, this.zoom * Math.exp(e.deltaY * 0.001)));
342 this.requestFrame();
343 }, { passive: false });
344 on("dblclick", () => {
345 if (this.opts.mode !== "3d") return;
346 this.azimuth = -Math.PI / 2 + 0.3;
347 this.elevation = Math.PI / 6;
348 this.zoom = 1;
349 this.requestFrame();
350 });
351 }
353 private requestFrame(): void {
354 if (this.frameRequested) return;
355 this.frameRequested = true;
356 requestAnimationFrame(() => {
357 this.frameRequested = false;
358 this.render();
359 });
360 }
362 // ---------- Public API ----------
364 /** New geometry (grid, mask, thickness, profile); resets the automatic color range and the field (to zeros). */
365 setPlate(plate: Plate): void {
366 this.plate = plate;
367 const g = plate.grid;
368 this.hRange = thicknessRange(plate);
369 let w0Max = 0, rp = 0;
370 for (let j = 0; j < g.ny; j++) {
371 for (let i = 0; i < g.nx; i++) {
372 if (!insideNode(g, i, j)) continue;
373 w0Max = Math.max(w0Max, Math.abs(plate.w0[j * g.nx + i]));
374 rp = Math.max(rp, Math.hypot(nodeX(g, i), nodeY(g, j)));
375 }
376 }
377 this.w0Max = w0Max;
378 this.plateRadius = Math.max(rp, 1e-3);
379 this.boxRadius = Math.max(plate.Lx, plate.Ly) / 2 || 1e-3;
380 this.runMax = 0;
381 this.field = null;
382 this.val = new Float32Array(g.n);
383 this.z = new Float32Array(g.n);
384 this.nodeData = new ArrayBuffer(g.n * MESH_STRIDE);
385 this.image = null;
386 this.geomDirty = true;
387 this.fieldDirty = true;
388 this.markerDirty = true;
389 this.imageDirty = true;
390 this.updateRunMax();
391 }
393 /** The displayed field per grid node [m]; null shows zeros (the design preview). The array is read at render time. */
394 setField(w: Float32Array | Float64Array | null): void {
395 this.field = w && this.plate && w.length >= this.plate.grid.n ? w : null;
396 this.updateRunMax();
397 this.fieldDirty = true;
398 this.markerDirty = true;
399 this.imageDirty = true;
400 }
402 setOptions(opts: Partial<ViewOptions>): void {
403 this.opts = { ...this.opts, ...opts };
404 this.fieldDirty = true;
405 this.markerDirty = true;
406 this.imageDirty = true;
407 }
409 setMarkers(m: Markers): void {
410 this.markers = m;
411 this.markerDirty = true;
412 }
414 /** Matches the canvas to its CSS size and devicePixelRatio; recreates the depth buffer when the size changes. */
415 resize(): void {
416 const dpr = window.devicePixelRatio || 1;
417 const w = Math.max(1, Math.round(this.canvas.clientWidth * dpr));
418 const h = Math.max(1, Math.round(this.canvas.clientHeight * dpr));
419 if (this.canvas.width === w && this.canvas.height === h && (this.depth || !this.device)) return;
420 this.canvas.width = w;
421 this.canvas.height = h;
422 if (this.device) {
423 this.depth?.destroy();
424 this.depth = this.device.createTexture({ size: [w, h], format: "depth24plus", usage: GPUTextureUsage.RENDER_ATTACHMENT });
425 }
426 this.markerDirty = true;
427 }
429 render(): void {
430 if (this.destroyed) return;
431 this.resize();
432 if (this.device) this.renderGpu(this.device);
433 else this.renderFallback();
434 }
436 destroy(): void {
437 this.destroyed = true;
438 for (const [type, fn, opts] of this.listeners) this.canvas.removeEventListener(type, fn, opts);
439 this.listeners = [];
440 this.xyBuf?.destroy(); this.nodeBuf?.destroy(); this.indexBuf?.destroy();
441 this.markerBuf?.destroy(); this.uniformBuf?.destroy(); this.depth?.destroy();
442 this.xyBuf = this.nodeBuf = this.indexBuf = this.markerBuf = this.uniformBuf = null;
443 this.depth = null;
444 this.gpuCtx?.unconfigure();
445 this.gpuCtx = null;
446 this.device = null;
447 this.plate = null;
448 }
450 // ---------- Field and color ----------
452 private updateRunMax(): void {
453 const pl = this.plate, w = this.field;
454 if (!pl || !w) return;
455 const g = pl.grid;
456 let m = 0;
457 if (g.mask) {
458 for (let k = 0; k < g.n; k++) if (g.mask[k]) m = Math.max(m, Math.abs(w[k]));
459 } else {
460 for (let k = 0; k < g.n; k++) m = Math.max(m, Math.abs(w[k]));
461 }
462 if (isFinite(m)) this.runMax = Math.max(this.runMax, m);
463 }
465 /** The color range in use for the height coloring [m]. */
466 private colorRange(): number {
467 if (this.opts.range > 0) return this.opts.range;
468 return Math.max(this.runMax + (this.opts.showProfile ? this.w0Max : 0), AUTO_RANGE_FLOOR);
469 }
471 private colorOf(val: number, h: number, range: number): RGB {
472 if (this.opts.color === "thickness") return viridis((h - this.hRange.min) / Math.max(this.hRange.max - this.hRange.min, 1e-9));
473 return diverging(val / range);
474 }
476 /** Recomputes the displayed value, the height and the vertex records of every node. */
477 private rebuildField(): void {
478 const pl = this.plate!;
479 const g = pl.grid;
480 const w = this.field;
481 const exag = this.opts.exaggeration;
482 const range = this.colorRange();
483 const val = this.val, z = this.z;
484 let zMax = 0;
485 for (let k = 0; k < g.n; k++) {
486 const v = (w ? w[k] : 0) + (this.opts.showProfile ? pl.w0[k] : 0);
487 val[k] = v;
488 z[k] = v * exag;
489 if (!g.mask || g.mask[k]) zMax = Math.max(zMax, Math.abs(z[k]));
490 }
491 this.zMax = zMax;
492 const f = new Float32Array(this.nodeData), u = new Uint32Array(this.nodeData);
493 for (let j = 0; j < g.ny; j++) {
494 for (let i = 0; i < g.nx; i++) {
495 const k = j * g.nx + i;
496 const o = k * 4;
497 if (!insideNode(g, i, j)) { f[o] = 0; f[o + 1] = 0; f[o + 2] = 0; u[o + 3] = 0; continue; }
498 f[o] = z[k];
499 f[o + 1] = this.slope(i, j, 1, 0, g.hx);
500 f[o + 2] = this.slope(i, j, 0, 1, g.hy);
501 u[o + 3] = pack(this.colorOf(val[k], pl.h[k], range));
502 }
503 }
504 this.fieldDirty = false;
505 }
507 /** Slope of the displayed height along one axis, one-sided where a neighbor lies outside the plate. */
508 private slope(i: number, j: number, di: number, dj: number, h: number): number {
509 const g = this.plate!.grid, z = this.z;
510 const hasP = insideNode(g, i + di, j + dj), hasM = insideNode(g, i - di, j - dj);
511 const k = j * g.nx + i, kp = (j + dj) * g.nx + i + di, km = (j - dj) * g.nx + i - di;
512 if (hasP && hasM) return (z[kp] - z[km]) / (2 * h);
513 if (hasP) return (z[kp] - z[k]) / h;
514 if (hasM) return (z[k] - z[km]) / h;
515 return 0;
516 }
518 /**
519 * The displayed height at (x, y), bilinear over the plate nodes of its cell
520 * (missing corners dropped), or `fallback` when no corner is in the plate.
521 */
522 private surfaceZ(x: number, y: number, fallback: number): number {
523 const g = this.plate!.grid;
524 const fi = x / g.hx + (g.nx - 1) / 2, fj = y / g.hy + (g.ny - 1) / 2;
525 const i0 = Math.max(0, Math.min(g.nx - 2, Math.floor(fi))), j0 = Math.max(0, Math.min(g.ny - 2, Math.floor(fj)));
526 const a = Math.max(0, Math.min(1, fi - i0)), b = Math.max(0, Math.min(1, fj - j0));
527 let s = 0, wsum = 0;
528 const acc = (i: number, j: number, wt: number) => {
529 if (wt <= 0 || !insideNode(g, i, j)) return;
530 s += wt * this.z[j * g.nx + i]; wsum += wt;
531 };
532 acc(i0, j0, (1 - a) * (1 - b)); acc(i0 + 1, j0, a * (1 - b)); acc(i0, j0 + 1, (1 - a) * b); acc(i0 + 1, j0 + 1, a * b);
533 return wsum > 0 ? s / wsum : fallback;
534 }
536 // ---------- Listener placement ----------
538 /** Where the listener is drawn in the 2D map: its in-plane direction, at a saturated distance from the center. */
539 private listener2d(): { x: number; y: number; dir: [number, number] } | null {
540 const L = this.markers.listener;
541 if (!L) return null;
542 const rho = Math.hypot(L.x, L.y);
543 const dir: [number, number] = rho > 1e-9 ? [L.x / rho, L.y / rho] : [0, -1];
544 const d = saturate(rho, 1.15 * this.boxRadius);
545 return { x: dir[0] * d, y: dir[1] * d, dir };
546 }
548 /** Where the listener is drawn in 3D: its true direction, at a distance saturating about one plate radius outside the edge. */
549 private listener3d(): { p: Vec3; u: Vec3 } | null {
550 const L = this.markers.listener;
551 if (!L) return null;
552 const d = Math.hypot(L.x, L.y, L.z);
553 if (d < 1e-9) return null;
554 const u: Vec3 = [L.x / d, L.y / d, L.z / d];
555 return { p: scale(u, saturate(d, LISTENER_REACH * this.plateRadius)), u };
556 }
558 // ---------- Markers ----------
560 private rebuildMarkers(is3d: boolean): void {
561 const mesh = this.markerMesh;
562 mesh.clear();
563 const R = this.boxRadius;
564 const lw = 0.012 * R; // width of the ring lines
565 const lift = 0.004 * R; // markers float this far above the surface
566 const { strike, listener } = this.markers;
567 const zAt = is3d ? (x: number, y: number) => this.surfaceZ(x, y, 0) + lift : () => lift;
568 if (strike) mesh.ring(strike.x, strike.y, Math.max(strike.radius, 2 * lw), lw / 2, zAt, pack(COLOR_STRIKE));
569 if (listener) {
570 if (is3d) {
571 const l = this.listener3d();
572 if (l) {
573 const [e1, e2] = frame(l.u);
574 const head = 0.035 * R, body = 0.025 * R, len = 0.06 * R;
575 const zc = this.surfaceZ(0, 0, 0);
576 const start: Vec3 = [0, 0, zc + lift];
577 const end = add(l.p, scale(l.u, -head));
578 if (dot(end, l.u) > dot(start, l.u)) mesh.cylinder(start, end, 0.003 * R, 6, pack(COLOR_LINE));
579 mesh.box(l.p, e1, e2, l.u, [head, head, head], pack(COLOR_MIC_HEAD));
580 mesh.box(add(l.p, scale(l.u, head + len)), e1, e2, l.u, [body, body, len], pack(COLOR_MIC_BODY));
581 }
582 } else {
583 const l = this.listener2d();
584 if (l) {
585 const rm = 0.045 * R;
586 const z1 = lift, z2 = 2 * lift;
587 // Line from the plate center, then the symbol: a round head and a stem pointing away from the center.
588 const [dx, dy] = l.dir;
589 const nx = -dy, ny = dx, hw = 0.3 * lw;
590 mesh.quad([nx * hw, ny * hw, z1], [-nx * hw, -ny * hw, z1], [l.x - nx * hw, l.y - ny * hw, z1], [l.x + nx * hw, l.y + ny * hw, z1], pack(COLOR_LINE));
591 mesh.ring(l.x, l.y, rm / 2, rm / 2, () => z2, pack(COLOR_MIC_BODY));
592 const sw = 0.28 * rm, s0 = 0.8 * rm, s1 = 2.2 * rm;
593 mesh.quad(
594 [l.x + dx * s0 + nx * sw, l.y + dy * s0 + ny * sw, z2], [l.x + dx * s0 - nx * sw, l.y + dy * s0 - ny * sw, z2],
595 [l.x + dx * s1 - nx * sw, l.y + dy * s1 - ny * sw, z2], [l.x + dx * s1 + nx * sw, l.y + dy * s1 + ny * sw, z2],
596 pack(COLOR_MIC_HEAD),
597 );
598 }
599 }
600 }
601 this.markerDirty = false;
602 }
604 // ---------- Cameras ----------
606 /** Half extents of the orthographic 2D view in plate coordinates. */
607 private extent2d(aspect: number): { W: number; H: number } {
608 const pl = this.plate!;
609 let need = Math.max(pl.Lx / 2, (pl.Ly / 2) * aspect);
610 const l = this.listener2d();
611 if (l) {
612 const m = 0.12 * this.boxRadius; // extent of the microphone symbol
613 need = Math.max(need, Math.abs(l.x) + m, (Math.abs(l.y) + m) * aspect);
614 }
615 const W = need / FILL_2D;
616 return { W, H: W / aspect };
617 }
619 private camera2d(aspect: number): Mat4 {
620 const { W, H } = this.extent2d(aspect);
621 return multiply(ortho(-W, W, -H, H, 0.5, 1.5), lookAt([0, 0, 1], [0, 0, 0], [0, 1, 0]));
622 }
624 /**
625 * The orbit camera about the plate center. The distance is fitted, unless
626 * the user has zoomed, so that the plate (an in-plane circle of the plate
627 * radius at the displayed heights) and the microphone fill the view; the
628 * frustum is then shifted so that the fitted scene, not the plate center,
629 * sits in the middle of the canvas, which matters when the microphone is
630 * high above the plate.
631 */
632 private camera3d(aspect: number): { mvp: Mat4; eye: Vec3 } {
633 const el = this.elevation, az = this.azimuth;
634 const dir: Vec3 = [Math.cos(el) * Math.cos(az), Math.cos(el) * Math.sin(az), Math.sin(el)];
635 const fwd = scale(dir, -1);
636 const right = unit(cross(fwd, [0, 0, 1]));
637 const up = cross(right, fwd);
638 const horiz = unit(Math.hypot(fwd[0], fwd[1]) > 1e-6 ? [fwd[0], fwd[1], 0] : [1, 0, 0]);
639 const Rp = this.plateRadius, zm = Math.max(this.zMax, 1e-4);
640 const pts: [Vec3, number][] = [];
641 for (const s of [-1, 1]) for (const sz of [-1, 1]) {
642 pts.push([add(scale(right, s * Rp), [0, 0, sz * zm]), 0]);
643 pts.push([add(scale(horiz, s * Rp), [0, 0, sz * zm]), 0]);
644 }
645 const l = this.listener3d();
646 if (l) pts.push([l.p, 0.12 * this.boxRadius]);
647 const tanH = Math.tan(FOV / 2), tanW = tanH * aspect;
648 // Screen bounds of the points, as tangents of the angles from the view axis, seen from distance D.
649 const bounds = (D: number) => {
650 let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
651 for (const [p, r] of pts) {
652 const depth = D - dot(p, dir);
653 if (depth <= 1e-6) return null;
654 const px = dot(p, right) / depth, py = dot(p, up) / depth, pr = r / depth;
655 x0 = Math.min(x0, px - pr); x1 = Math.max(x1, px + pr);
656 y0 = Math.min(y0, py - pr); y1 = Math.max(y1, py + pr);
657 }
658 return { x0, x1, y0, y1 };
659 };
660 const fits = (D: number) => {
661 const b = bounds(D);
662 return !!b && b.x1 - b.x0 <= (2 * tanW) / FIT_MARGIN && b.y1 - b.y0 <= (2 * tanH) / FIT_MARGIN;
663 };
664 // The symmetric fit about the center always holds; bisect down from it toward the depth of the scene.
665 let hi = 0, lo = 0, reach = 0;
666 for (const [p, r] of pts) {
667 const v = (Math.abs(dot(p, up)) + r) / tanH, h = (Math.abs(dot(p, right)) + r) / tanW;
668 hi = Math.max(hi, dot(p, dir) + Math.max(v, h));
669 lo = Math.max(lo, dot(p, dir) + r);
670 reach = Math.max(reach, Math.hypot(p[0], p[1], p[2]) + r);
671 }
672 hi = Math.max(hi * FIT_MARGIN, 1e-3);
673 lo = Math.min(lo + 1e-3, hi);
674 for (let it = 0; it < 24; it++) {
675 const mid = (lo + hi) / 2;
676 if (fits(mid)) hi = mid; else lo = mid;
677 }
678 const auto = hi;
679 const b = bounds(auto);
680 const sx = b ? (b.x0 + b.x1) / 2 / tanW : 0, sy = b ? (b.y0 + b.y1) / 2 / tanH : 0;
681 const shift = new Float32Array(16);
682 shift[0] = shift[5] = shift[10] = shift[15] = 1;
683 shift[12] = -sx; shift[13] = -sy;
684 const dist = Math.max(1e-3, auto * this.zoom);
685 const eye = scale(dir, dist);
686 const near = Math.max(1e-4, 0.02 * dist), far = dist + 4 * reach + 1;
687 const mvp = multiply(shift, multiply(perspective(FOV, aspect, near, far), lookAt(eye, [0, 0, 0], [0, 0, 1])));
688 return { mvp, eye };
689 }
691 // ---------- WebGPU path ----------
693 private uploadGeometry(device: GPUDevice): void {
694 const g = this.plate!.grid;
695 const xy = new Float32Array(g.n * 2);
696 for (let j = 0; j < g.ny; j++) for (let i = 0; i < g.nx; i++) { xy[2 * (j * g.nx + i)] = nodeX(g, i); xy[2 * (j * g.nx + i) + 1] = nodeY(g, j); }
697 const idx = new Uint32Array((g.nx - 1) * (g.ny - 1) * 6);
698 let c = 0;
699 for (let cj = 0; cj < g.ny - 1; cj++) {
700 for (let ci = 0; ci < g.nx - 1; ci++) {
701 if (!insideCell(g, ci, cj)) continue;
702 const a = cj * g.nx + ci, b = a + 1, d = a + g.nx, e = d + 1;
703 idx[c++] = a; idx[c++] = b; idx[c++] = e;
704 idx[c++] = a; idx[c++] = e; idx[c++] = d;
705 }
706 }
707 this.indexCount = c;
708 this.xyBuf?.destroy(); this.nodeBuf?.destroy(); this.indexBuf?.destroy();
709 this.xyBuf = device.createBuffer({ size: Math.max(16, xy.byteLength), usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
710 device.queue.writeBuffer(this.xyBuf, 0, xy);
711 this.nodeBuf = device.createBuffer({ size: Math.max(16, g.n * MESH_STRIDE), usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
712 this.indexBuf = device.createBuffer({ size: Math.max(16, c * 4), usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST });
713 if (c > 0) device.queue.writeBuffer(this.indexBuf, 0, idx, 0, c);
714 this.geomDirty = false;
715 }
717 private renderGpu(device: GPUDevice): void {
718 const ctx = this.gpuCtx;
719 if (!ctx || !this.depth || !this.meshPipeline || !this.markerPipeline || !this.uniformBuf || !this.bindGroup) return;
720 const is3d = this.opts.mode === "3d";
721 const aspect = this.canvas.width / this.canvas.height;
722 const clear = { r: BG[0], g: BG[1], b: BG[2], a: 1 };
723 const encoder = device.createCommandEncoder();
724 const pass = encoder.beginRenderPass({
725 colorAttachments: [{ view: ctx.getCurrentTexture().createView(), clearValue: clear, loadOp: "clear", storeOp: "store" }],
726 depthStencilAttachment: { view: this.depth.createView(), depthClearValue: 1, depthLoadOp: "clear", depthStoreOp: "store" },
727 });
728 if (this.plate) {
729 if (this.geomDirty) this.uploadGeometry(device);
730 if (this.fieldDirty) {
731 this.rebuildField();
732 device.queue.writeBuffer(this.nodeBuf!, 0, this.nodeData);
733 }
734 if (this.markerDirty) {
735 this.rebuildMarkers(is3d);
736 const bytes = this.markerMesh.byteLength;
737 if (bytes > this.markerCapacity) {
738 this.markerBuf?.destroy();
739 this.markerCapacity = Math.max(1024, bytes * 2);
740 this.markerBuf = device.createBuffer({ size: this.markerCapacity, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
741 }
742 if (bytes > 0) device.queue.writeBuffer(this.markerBuf!, 0, this.markerMesh.f.buffer, 0, bytes);
743 }
744 let mvp: Mat4, eye: Vec3;
745 if (is3d) ({ mvp, eye } = this.camera3d(aspect));
746 else { mvp = this.camera2d(aspect); eye = [0, 0, 1]; }
747 const u = new Float32Array(24);
748 u.set(mvp, 0);
749 u[16] = eye[0]; u[17] = eye[1]; u[18] = eye[2]; u[19] = is3d ? 1 : 0;
750 u[20] = LIGHT[0]; u[21] = LIGHT[1]; u[22] = LIGHT[2]; u[23] = is3d ? 1 : 0;
751 device.queue.writeBuffer(this.uniformBuf, 0, u);
753 pass.setBindGroup(0, this.bindGroup);
754 if (this.indexCount > 0) {
755 pass.setPipeline(this.meshPipeline);
756 pass.setVertexBuffer(0, this.xyBuf!);
757 pass.setVertexBuffer(1, this.nodeBuf!);
758 pass.setIndexBuffer(this.indexBuf!, "uint32");
759 pass.drawIndexed(this.indexCount);
760 }
761 if (this.markerMesh.count > 0 && this.markerBuf) {
762 pass.setPipeline(this.markerPipeline);
763 pass.setVertexBuffer(0, this.markerBuf);
764 pass.draw(this.markerMesh.count);
765 }
766 }
767 pass.end();
768 device.queue.submit([encoder.finish()]);
769 }
771 // ---------- 2D canvas fallback ----------
773 /** The cell colors as an image of (nx - 1) by (ny - 1) pixels, row 0 at the top (+y up). */
774 private rebuildImage(): void {
775 const pl = this.plate!;
776 const g = pl.grid;
777 const cw = g.nx - 1, ch = g.ny - 1;
778 if (!this.image) {
779 this.image = document.createElement("canvas");
780 this.image.width = cw; this.image.height = ch;
781 }
782 const ictx = this.image.getContext("2d")!;
783 const img = ictx.createImageData(cw, ch);
784 const range = this.colorRange();
785 const val = this.val;
786 for (let cj = 0; cj < ch; cj++) {
787 for (let ci = 0; ci < cw; ci++) {
788 if (!insideCell(g, ci, cj)) continue;
789 const a = cj * g.nx + ci, b = a + 1, c = a + g.nx, d = c + 1;
790 const v = 0.25 * (val[a] + val[b] + val[c] + val[d]);
791 const h = 0.25 * (pl.h[a] + pl.h[b] + pl.h[c] + pl.h[d]);
792 const rgb = this.colorOf(v, h, range);
793 const o = 4 * ((ch - 1 - cj) * cw + ci);
794 img.data[o] = Math.round(rgb[0] * 255); img.data[o + 1] = Math.round(rgb[1] * 255);
795 img.data[o + 2] = Math.round(rgb[2] * 255); img.data[o + 3] = 255;
796 }
797 }
798 ictx.putImageData(img, 0, 0);
799 this.imageDirty = false;
800 }
802 private renderFallback(): void {
803 const ctx = this.ctx2d!;
804 const W = this.canvas.width, H = this.canvas.height;
805 ctx.setTransform(1, 0, 0, 1, 0, 0);
806 ctx.fillStyle = css(BG);
807 ctx.fillRect(0, 0, W, H);
808 const pl = this.plate;
809 if (!pl) return;
810 if (this.fieldDirty) this.rebuildField();
811 if (this.imageDirty) this.rebuildImage();
812 const { W: hw } = this.extent2d(W / H);
813 const s = W / (2 * hw); // pixels per meter
814 const px = (x: number) => W / 2 + x * s, py = (y: number) => H / 2 - y * s;
815 ctx.imageSmoothingEnabled = false;
816 ctx.drawImage(this.image!, px(-pl.Lx / 2), py(pl.Ly / 2), pl.Lx * s, pl.Ly * s);
818 const R = this.boxRadius;
819 const lw = 0.012 * R * s;
820 const ring = (x: number, y: number, r: number, color: RGB) => {
821 ctx.beginPath();
822 ctx.arc(px(x), py(y), r * s, 0, 2 * Math.PI);
823 ctx.strokeStyle = css(color); ctx.lineWidth = lw;
824 ctx.stroke();
825 };
826 const { strike } = this.markers;
827 if (strike) ring(strike.x, strike.y, Math.max(strike.radius, 0.024 * R), COLOR_STRIKE);
828 const l = this.listener2d();
829 if (l) {
830 const rm = 0.045 * R * s;
831 ctx.beginPath(); ctx.moveTo(px(0), py(0)); ctx.lineTo(px(l.x), py(l.y));
832 ctx.strokeStyle = css(COLOR_LINE); ctx.lineWidth = 0.6 * lw; ctx.stroke();
833 const [dx, dy] = l.dir;
834 ctx.beginPath(); ctx.moveTo(px(l.x) + dx * 0.8 * rm, py(l.y) - dy * 0.8 * rm); ctx.lineTo(px(l.x) + dx * 2.2 * rm, py(l.y) - dy * 2.2 * rm);
835 ctx.strokeStyle = css(COLOR_MIC_HEAD); ctx.lineWidth = 0.56 * rm; ctx.stroke();
836 ctx.beginPath(); ctx.arc(px(l.x), py(l.y), rm, 0, 2 * Math.PI);
837 ctx.fillStyle = css(COLOR_MIC_BODY); ctx.fill();
838 }
839 }
840}