70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 2 * The three planes as 2D images, redrawn every frame so that the pressure can
3 * be animated over one period. Clicking a plane moves the source (the listener,
4 * for the cymbal) to that point, whose third coordinate is the plane's. For the
5 * cymbal, its outline is drawn over the planes.
7import { DIVERGING, SEQUENTIAL } from './colormaps.ts';
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 8import { colorParam, type Field, type Mode, type SliceField } from './field.ts';
10const AXIS = ['x', 'y', 'z'];
12export class SliceView {
13 private canvases: HTMLCanvasElement[] = [];
14 private titles: HTMLElement[] = [];
15 private img: ImageData[] = [];
16 private field: Field | null = null;
18 constructor(
19 container: HTMLElement,
20 private onPick: (x: [number, number, number]) => void,
21 private onHover: (text: string) => void,
22 ) {
23 for (let s = 0; s < 3; s++) {
24 const fig = document.createElement('figure');
25 fig.className = 'slice';
26 const cap = document.createElement('figcaption');
27 const cv = document.createElement('canvas');
28 cv.width = 300;
29 cv.height = 300;
30 fig.append(cv, cap);
31 container.append(fig);
32 this.canvases.push(cv);
33 this.titles.push(cap);
34 cv.addEventListener('click', (ev) => this.pick(s, ev));
35 cv.addEventListener('pointermove', (ev) => this.hover(s, ev));
36 cv.addEventListener('pointerleave', () => this.onHover(''));
37 }
38 }
40 /** Blank the planes, for while a new field is computed. */
41 clear() {
42 this.field = null;
43 this.img = [];
44 for (const [s, cv] of this.canvases.entries()) {
45 cv.getContext('2d')!.clearRect(0, 0, cv.width, cv.height);
46 this.titles[s].textContent = '';
47 }
48 this.onHover('');
49 }
51 setField(f: Field) {
52 this.field = f;
53 this.img = f.slices.map(() => new ImageData(f.ngrid, f.ngrid));
54 f.slices.forEach(({ slice }, s) => {
55 const [i, j] = slice.axes;
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 56 this.titles[s].textContent = `${slice.name}-plane at ${AXIS[slice.fixed]} = ${slice.value.toFixed(2)} (${AXIS[i]} across, ${AXIS[j]} up)`;
58 }
60 /** Coordinates of a pointer event in the room, for slice s. */
61 private at(s: number, ev: PointerEvent | MouseEvent): { x: [number, number, number]; q: number } | null {
62 const f = this.field;
63 if (!f) return null;
64 const cv = this.canvases[s];
65 const r = cv.getBoundingClientRect();
66 const fx = (ev.clientX - r.left) / r.width;
67 const fy = 1 - (ev.clientY - r.top) / r.height;
68 const { slice } = f.slices[s];
70 const x: [number, number, number] = [0, 0, 0];
71 x[slice.fixed] = slice.value;
72 x[slice.axes[0]] = ea[0] + (ea[1] - ea[0]) * fx;
73 x[slice.axes[1]] = eb[0] + (eb[1] - eb[0]) * fy;
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 74 const col = Math.round(fx * (f.ngrid - 1));
75 const row = Math.round(fy * (f.ngrid - 1));
76 if (col < 0 || row < 0 || col >= f.ngrid || row >= f.ngrid) return null;
77 return { x, q: row * f.ngrid + col };
78 }
80 private pick(s: number, ev: MouseEvent) {
81 const p = this.at(s, ev);
82 if (p) this.onPick(p.x);
83 }
85 private hover(s: number, ev: PointerEvent) {
86 const p = this.at(s, ev);
87 const f = this.field;
88 if (!p || !f) return;
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 89 const { u, slice, ref } = f.slices[s];
90 const at = `(${p.x.map((v) => v.toFixed(2)).join(', ')})`;
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 91 if (!slice.mask[p.q]) {
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 92 this.onHover(`${at}: ${f.outline ? 'inside the cymbal' : 'outside the room'}`);
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 93 return;
94 }
95 const re = u[2 * p.q];
96 const im = u[2 * p.q + 1];
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 97 const db = 20 * Math.log10(Math.hypot(re, im) / (ref ? ref[p.q] : f.ref));
98 this.onHover(`${at}: u = ${re.toExponential(3)} ${im < 0 ? '−' : '+'} ${Math.abs(im).toExponential(3)}i, level ${db.toFixed(1)} dB ${f.level.label} · click to move the ${f.outline ? 'listener' : 'source'} here`);
101 draw(mode: Mode, phase: number) {
102 const f = this.field;
103 if (!f) return;
104 const c = Math.cos(phase);
105 const sn = Math.sin(phase);
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 106 const map = mode === 'pressure' || f.level.diverging ? DIVERGING : SEQUENTIAL;
107 f.slices.forEach(({ slice, u, ref }, s) => {
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 108 const img = this.img[s];
109 const n = f.ngrid;
110 for (let row = 0; row < n; row++) {
111 for (let col = 0; col < n; col++) {
112 const q = row * n + col;
113 const o = 4 * ((n - 1 - row) * n + col);
114 if (!slice.mask[q]) {
115 img.data[o + 3] = 0;
116 continue;
117 }
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 118 const t = colorParam(u[2 * q], u[2 * q + 1], mode, c, sn, f.umax, ref ? ref[q] : f.ref, f.level);
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 119 const i = Math.max(0, Math.min(255, Math.round(t * 255))) * 4;
120 img.data[o] = map[i];
121 img.data[o + 1] = map[i + 1];
122 img.data[o + 2] = map[i + 2];
123 img.data[o + 3] = 255;
124 }
125 }
126 const cv = this.canvases[s];
127 const ctx = cv.getContext('2d')!;
128 const tmp = new OffscreenCanvas(n, n);
129 tmp.getContext('2d')!.putImageData(img, 0, 0);
130 ctx.clearRect(0, 0, cv.width, cv.height);
131 ctx.imageSmoothingEnabled = true;
132 ctx.imageSmoothingQuality = 'high';
133 ctx.drawImage(tmp, 0, 0, cv.width, cv.height);
134 const [i, j] = slice.axes;
136 const X = (a: number) => ((a - ea[0]) / (ea[1] - ea[0])) * cv.width;
137 const Y = (b: number) => (1 - (b - eb[0]) / (eb[1] - eb[0])) * cv.height;
138 if (f.outline) drawOutline(ctx, slice, f.outline, X, Y);
139 // the source, when it lies on this plane
140 if (Math.abs(f.x0[slice.fixed] - slice.value) > 1e-9) return;
141 const px = X(f.x0[i]);
142 const py = Y(f.x0[j]);
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 143 ctx.beginPath();
144 ctx.arc(px, py, 5, 0, 2 * Math.PI);
145 ctx.lineWidth = 1.5;
146 ctx.strokeStyle = '#111';
147 ctx.stroke();
148 ctx.beginPath();
149 ctx.arc(px, py, 6.5, 0, 2 * Math.PI);
150 ctx.strokeStyle = '#fff';
151 ctx.lineWidth = 1;
152 ctx.stroke();
153 });
154 }
155}
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 156
157/** The cymbal: its cross section on a vertical plane, the rim (dashed) on a horizontal one. */
158export function drawOutline(ctx: CanvasRenderingContext2D, slice: SliceField['slice'], o: NonNullable<Field['outline']>, X: (a: number) => number, Y: (b: number) => number) {
159 ctx.save();
160 ctx.strokeStyle = '#222';
161 ctx.fillStyle = 'rgba(40, 40, 40, 0.85)';
162 ctx.lineWidth = 1;
163 if (slice.fixed === 2) {
164 ctx.setLineDash([4, 3]);
165 ctx.beginPath();
166 ctx.arc(X(0), Y(0), Math.abs(X(o.radius) - X(0)), 0, 2 * Math.PI);
167 ctx.stroke();
168 } else if (Math.abs(slice.value) < o.radius) {
169 // the plane is at distance |value| from the axis: horizontal coordinate +-sqrt(r^2 - value^2)
170 for (const sign of [1, -1]) {
171 ctx.beginPath();
172 let started = false;
173 for (let q = 0; q < o.r.length; q++) {
174 if (o.r[q] < Math.abs(slice.value)) continue;
175 const a = sign * Math.sqrt(o.r[q] ** 2 - slice.value ** 2);
176 if (started) ctx.lineTo(X(a), Y(o.z[q]));
177 else ctx.moveTo(X(a), Y(o.z[q]));
178 started = true;
179 }
180 ctx.closePath();
181 ctx.fill();
182 }
183 }
184 ctx.restore();
185}