1/**
2 * The line work over the volume: every object, the body's wireframe, the
3 * bridge and nut posts.
4 *
5 * A 2D canvas stacked on the WebGPU one, redrawn each frame from the object
6 * readbacks and the same camera the ray march uses (cameraFrame), so its
7 * lines land on the volume's pixels. Lines composited over a volume have no
8 * correct depth — the overlay is always on top — which is the price of
9 * keeping the march simple; these are markers and guides, not things in the
10 * scene.
11 *
12 * Displacements are millimetres on a half-metre picture, so they are drawn
13 * exaggerated (the factor the object plot states), and that is the one
14 * honest exaggeration in the app: everything else is to scale. A line
15 * object's displacement is drawn in y, a membrane's in z (a drum head moves
16 * up and down); each object is drawn in its own colour, the same one its
17 * partials wear in the spectrum.
18 */
19import type { CameraFrame } from './volume.ts';
21/** One line object (string or bar), as drawn. */
22export interface OverlayLine {
23 kind: 'line';
24 color: string;
25 L1: number;
26 oy: number;
27 oz: number;
28 /** Displacement at the nodes, or null before the first readback. */
29 u: Float32Array | null;
30 /** Draw the nut/bridge posts down to z = 0 (strings). */
31 posts: boolean;
32}
34/** One membrane, as drawn: the rim, plus its two deformed centre lines. */
35export interface OverlayRect {
36 kind: 'rect';
37 color: string;
38 L1: number;
39 L2: number;
40 n1: number;
41 n2: number;
42 oz: number;
43 u: Float32Array | null;
44}
46export type OverlayObject = OverlayLine | OverlayRect;
48export interface OverlayBody {
49 type: 'none' | 'box' | 'shell';
50 /** box: boxl, boxw, boxd, holer, holex; shell: shellr, shelld. */
51 params: Record<string, number>;
52}
54export interface OverlayOptions {
55 frame: CameraFrame;
56 objects: OverlayObject[];
57 body: OverlayBody;
58 /** Displacement multiplier for visibility. */
59 exaggerate: number;
60 wireframe: boolean;
61 showObjects: boolean;
62}
64type P3 = [number, number, number];
66export class Overlay {
67 readonly canvas: HTMLCanvasElement;
68 #ctx: CanvasRenderingContext2D;
70 constructor(canvas: HTMLCanvasElement) {
71 this.canvas = canvas;
72 const ctx = canvas.getContext('2d');
73 if (!ctx) throw new Error('overlay canvas has no 2d context');
74 this.#ctx = ctx;
75 }
77 resize(): void {
78 const rect = this.canvas.getBoundingClientRect();
79 const w = Math.max(1, Math.round(rect.width));
80 const h = Math.max(1, Math.round(rect.height));
81 if (this.canvas.width !== w || this.canvas.height !== h) {
82 this.canvas.width = w;
83 this.canvas.height = h;
84 }
85 }
87 clear(): void {
88 this.#ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
89 }
91 draw(o: OverlayOptions): void {
92 const ctx = this.#ctx;
93 const { width, height } = this.canvas;
94 ctx.clearRect(0, 0, width, height);
96 const f = o.frame;
97 /** World point -> pixel, or null when behind the camera. */
98 const project = (q: P3): [number, number] | null => {
99 const dx = q[0] - f.eye[0];
100 const dy = q[1] - f.eye[1];
101 const dz = q[2] - f.eye[2];
102 const zc = dx * f.fwd[0] + dy * f.fwd[1] + dz * f.fwd[2];
103 if (zc < 1e-6) return null;
104 const xc = dx * f.right[0] + dy * f.right[1] + dz * f.right[2];
105 const yc = dx * f.up[0] + dy * f.up[1] + dz * f.up[2];
106 const nx = xc / (zc * f.tanHalfFov * f.aspect);
107 const ny = yc / (zc * f.tanHalfFov);
108 return [((nx + 1) / 2) * width, ((1 - ny) / 2) * height];
109 };
111 const polyline = (pts: P3[], close = false): void => {
112 ctx.beginPath();
113 let started = false;
114 for (const q of pts) {
115 const s = project(q);
116 if (!s) {
117 started = false;
118 continue;
119 }
120 if (started) ctx.lineTo(s[0], s[1]);
121 else {
122 ctx.moveTo(s[0], s[1]);
123 started = true;
124 }
125 }
126 if (close) ctx.closePath();
127 ctx.stroke();
128 };
130 const circle = (r: number, cx: number, zv: number): P3[] => {
131 const pts: P3[] = [];
132 for (let i = 0; i <= 48; i++) {
133 const a = (2 * Math.PI * i) / 48;
134 pts.push([cx + r * Math.cos(a), r * Math.sin(a), zv]);
135 }
136 return pts;
137 };
139 if (o.wireframe && o.body.type !== 'none') {
140 ctx.lineWidth = 1;
141 ctx.strokeStyle = 'rgba(160, 170, 185, 0.65)';
142 const bp = o.body.params;
143 if (o.body.type === 'box') {
144 const hx = (bp.boxl ?? 0) / 2;
145 const hy = (bp.boxw ?? 0) / 2;
146 const corners = (zv: number): P3[] => [
147 [-hx, -hy, zv], [hx, -hy, zv], [hx, hy, zv], [-hx, hy, zv],
148 ];
149 polyline(corners(0), true);
150 polyline(corners(-(bp.boxd ?? 0)), true);
151 for (let i = 0; i < 4; i++) {
152 polyline([corners(0)[i], corners(-(bp.boxd ?? 0))[i]]);
153 }
154 if ((bp.holer ?? 0) > 0) polyline(circle(bp.holer, bp.holex ?? 0, 0));
155 } else {
156 const r = bp.shellr ?? 0;
157 const d = bp.shelld ?? 0;
158 polyline(circle(r, 0, 0));
159 polyline(circle(r, 0, -d));
160 for (let i = 0; i < 4; i++) {
161 const a = (Math.PI * i) / 2;
162 polyline([
163 [r * Math.cos(a), r * Math.sin(a), 0],
164 [r * Math.cos(a), r * Math.sin(a), -d],
165 ]);
166 }
167 }
168 }
170 if (!o.showObjects) return;
171 for (const obj of o.objects) {
172 ctx.strokeStyle = obj.color;
173 if (obj.kind === 'line') {
174 const pts: P3[] = [];
175 const n = obj.u?.length ?? 2;
176 for (let i = 0; i < n; i++) {
177 const xw = -obj.L1 / 2 + (obj.L1 * i) / (n - 1);
178 const d = obj.u ? o.exaggerate * obj.u[i] : 0;
179 pts.push([xw, obj.oy + d, obj.oz]);
180 }
181 ctx.lineWidth = 1.6;
182 polyline(pts);
183 if (obj.posts) {
184 ctx.lineWidth = 2;
185 ctx.strokeStyle = 'rgba(220, 226, 235, 0.8)';
186 polyline([[-obj.L1 / 2, obj.oy, obj.oz], [-obj.L1 / 2, obj.oy, 0]]);
187 polyline([[obj.L1 / 2, obj.oy, obj.oz], [obj.L1 / 2, obj.oy, 0]]);
188 }
189 } else {
190 // The rim, then the two centre lines carrying the motion.
191 const hx = obj.L1 / 2;
192 const hy = obj.L2 / 2;
193 ctx.lineWidth = 1.2;
194 polyline([[-hx, -hy, obj.oz], [hx, -hy, obj.oz], [hx, hy, obj.oz], [-hx, hy, obj.oz]], true);
195 if (obj.u) {
196 ctx.lineWidth = 1.6;
197 const midJ = Math.floor(obj.n2 / 2);
198 const row: P3[] = [];
199 for (let i = 0; i < obj.n1; i++) {
200 row.push([
201 -hx + (obj.L1 * i) / (obj.n1 - 1),
202 (midJ / (obj.n2 - 1)) * obj.L2 - hy,
203 obj.oz + o.exaggerate * obj.u[i + obj.n1 * midJ],
204 ]);
205 }
206 polyline(row);
207 const midI = Math.floor(obj.n1 / 2);
208 const col: P3[] = [];
209 for (let j = 0; j < obj.n2; j++) {
210 col.push([
211 (midI / (obj.n1 - 1)) * obj.L1 - hx,
212 -hy + (obj.L2 * j) / (obj.n2 - 1),
213 obj.oz + o.exaggerate * obj.u[midI + obj.n1 * j],
214 ]);
215 }
216 polyline(col);
217 }
218 }
219 }
220 }
221}