1/**
2 * The line work over the volume: the string, the body's wireframe, the sound
3 * hole, the bridge and nut.
4 *
5 * A 2D canvas stacked on the WebGPU one, redrawn each frame from the string
6 * readback and the same camera the ray march uses (cameraFrame), so its lines
7 * land on the volume's pixels. Lines composited over a volume have no correct
8 * depth — the overlay is always on top — which is the price of keeping the
9 * march simple; these are markers and guides, not things in the scene.
10 *
11 * The string's actual displacement is millimetres on a half-metre picture, so
12 * it is drawn exaggerated (the same factor the string plot states), and it is
13 * the one honest exaggeration in the app: everything else is to scale.
14 */
15import type { CameraFrame } from './volume.ts';
17export interface OverlayGeometry {
18 /** String extent (centred on x) and height above the top plate. */
19 Ls: number;
20 stringZ: number;
21 /** Body box: length, width, depth below z = 0. */
22 boxl: number;
23 boxw: number;
24 boxd: number;
25 /** Sound hole. */
26 holer: number;
27 holex: number;
28}
30export interface OverlayOptions {
31 frame: CameraFrame;
32 geometry: OverlayGeometry;
33 /** String displacement, metres, at the ns nodes; null hides the string. */
34 string: Float32Array | null;
35 /** Displacement multiplier for visibility. */
36 exaggerate: number;
37 wireframe: boolean;
38}
40type P3 = [number, number, number];
42export class Overlay {
43 readonly canvas: HTMLCanvasElement;
44 #ctx: CanvasRenderingContext2D;
46 constructor(canvas: HTMLCanvasElement) {
47 this.canvas = canvas;
48 const ctx = canvas.getContext('2d');
49 if (!ctx) throw new Error('overlay canvas has no 2d context');
50 this.#ctx = ctx;
51 }
53 resize(): void {
54 const rect = this.canvas.getBoundingClientRect();
55 const w = Math.max(1, Math.round(rect.width));
56 const h = Math.max(1, Math.round(rect.height));
57 if (this.canvas.width !== w || this.canvas.height !== h) {
58 this.canvas.width = w;
59 this.canvas.height = h;
60 }
61 }
63 clear(): void {
64 this.#ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
65 }
67 draw(o: OverlayOptions): void {
68 const ctx = this.#ctx;
69 const { width, height } = this.canvas;
70 ctx.clearRect(0, 0, width, height);
72 const f = o.frame;
73 /** World point -> pixel, or null when behind the camera. */
74 const project = (q: P3): [number, number] | null => {
75 const dx = q[0] - f.eye[0];
76 const dy = q[1] - f.eye[1];
77 const dz = q[2] - f.eye[2];
78 const zc = dx * f.fwd[0] + dy * f.fwd[1] + dz * f.fwd[2];
79 if (zc < 1e-6) return null;
80 const xc = dx * f.right[0] + dy * f.right[1] + dz * f.right[2];
81 const yc = dx * f.up[0] + dy * f.up[1] + dz * f.up[2];
82 const nx = xc / (zc * f.tanHalfFov * f.aspect);
83 const ny = yc / (zc * f.tanHalfFov);
84 return [((nx + 1) / 2) * width, ((1 - ny) / 2) * height];
85 };
87 const polyline = (pts: P3[], close = false): void => {
88 ctx.beginPath();
89 let started = false;
90 for (const q of pts) {
91 const s = project(q);
92 if (!s) {
93 started = false;
94 continue;
95 }
96 if (started) ctx.lineTo(s[0], s[1]);
97 else {
98 ctx.moveTo(s[0], s[1]);
99 started = true;
100 }
101 }
102 if (close) ctx.closePath();
103 ctx.stroke();
104 };
106 const g = o.geometry;
108 if (o.wireframe) {
109 // The body: a box from z = -boxd to 0.
110 const hx = g.boxl / 2;
111 const hy = g.boxw / 2;
112 ctx.lineWidth = 1;
113 ctx.strokeStyle = 'rgba(160, 170, 185, 0.65)';
114 const corners = (zv: number): P3[] => [
115 [-hx, -hy, zv], [hx, -hy, zv], [hx, hy, zv], [-hx, hy, zv],
116 ];
117 polyline(corners(0), true);
118 polyline(corners(-g.boxd), true);
119 for (let i = 0; i < 4; i++) {
120 const top = corners(0)[i];
121 const bot = corners(-g.boxd)[i];
122 polyline([top, bot]);
123 }
124 // The sound hole, on the top plate.
125 if (g.holer > 0) {
126 const circle: P3[] = [];
127 for (let i = 0; i <= 48; i++) {
128 const a = (2 * Math.PI * i) / 48;
129 circle.push([g.holex + g.holer * Math.cos(a), g.holer * Math.sin(a), 0]);
130 }
131 polyline(circle);
132 }
133 }
135 if (o.string) {
136 const u = o.string;
137 const ns = u.length;
138 const hs = g.Ls / (ns - 1);
139 // Displacement is in y (the pluck direction), exaggerated to be seen.
140 const pts: P3[] = [];
141 for (let i = 0; i < ns; i++) {
142 pts.push([-g.Ls / 2 + i * hs, o.exaggerate * u[i], g.stringZ]);
143 }
144 ctx.lineWidth = 1.6;
145 ctx.strokeStyle = 'rgba(255, 214, 130, 0.95)';
146 polyline(pts);
147 // Nut and bridge: short posts down to the plate.
148 ctx.lineWidth = 2;
149 ctx.strokeStyle = 'rgba(220, 226, 235, 0.8)';
150 polyline([[-g.Ls / 2, 0, g.stringZ], [-g.Ls / 2, 0, 0]]);
151 polyline([[g.Ls / 2, 0, g.stringZ], [g.Ls / 2, 0, 0]]);
152 }
153 }
154}