/** * The line work over the volume: the string, the body's wireframe, the sound * hole, the bridge and nut. * * A 2D canvas stacked on the WebGPU one, redrawn each frame from the string * readback and the same camera the ray march uses (cameraFrame), so its lines * land on the volume's pixels. Lines composited over a volume have no correct * depth — the overlay is always on top — which is the price of keeping the * march simple; these are markers and guides, not things in the scene. * * The string's actual displacement is millimetres on a half-metre picture, so * it is drawn exaggerated (the same factor the string plot states), and it is * the one honest exaggeration in the app: everything else is to scale. */ import type { CameraFrame } from './volume.ts'; export interface OverlayGeometry { /** String extent (centred on x) and height above the top plate. */ Ls: number; stringZ: number; /** Body box: length, width, depth below z = 0. */ boxl: number; boxw: number; boxd: number; /** Sound hole. */ holer: number; holex: number; } export interface OverlayOptions { frame: CameraFrame; geometry: OverlayGeometry; /** String displacement, metres, at the ns nodes; null hides the string. */ string: Float32Array | null; /** Displacement multiplier for visibility. */ exaggerate: number; wireframe: boolean; } type P3 = [number, number, number]; export class Overlay { readonly canvas: HTMLCanvasElement; #ctx: CanvasRenderingContext2D; constructor(canvas: HTMLCanvasElement) { this.canvas = canvas; const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('overlay canvas has no 2d context'); this.#ctx = ctx; } resize(): void { const rect = this.canvas.getBoundingClientRect(); const w = Math.max(1, Math.round(rect.width)); const h = Math.max(1, Math.round(rect.height)); if (this.canvas.width !== w || this.canvas.height !== h) { this.canvas.width = w; this.canvas.height = h; } } clear(): void { this.#ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); } draw(o: OverlayOptions): void { const ctx = this.#ctx; const { width, height } = this.canvas; ctx.clearRect(0, 0, width, height); const f = o.frame; /** World point -> pixel, or null when behind the camera. */ const project = (q: P3): [number, number] | null => { const dx = q[0] - f.eye[0]; const dy = q[1] - f.eye[1]; const dz = q[2] - f.eye[2]; const zc = dx * f.fwd[0] + dy * f.fwd[1] + dz * f.fwd[2]; if (zc < 1e-6) return null; const xc = dx * f.right[0] + dy * f.right[1] + dz * f.right[2]; const yc = dx * f.up[0] + dy * f.up[1] + dz * f.up[2]; const nx = xc / (zc * f.tanHalfFov * f.aspect); const ny = yc / (zc * f.tanHalfFov); return [((nx + 1) / 2) * width, ((1 - ny) / 2) * height]; }; const polyline = (pts: P3[], close = false): void => { ctx.beginPath(); let started = false; for (const q of pts) { const s = project(q); if (!s) { started = false; continue; } if (started) ctx.lineTo(s[0], s[1]); else { ctx.moveTo(s[0], s[1]); started = true; } } if (close) ctx.closePath(); ctx.stroke(); }; const g = o.geometry; if (o.wireframe) { // The body: a box from z = -boxd to 0. const hx = g.boxl / 2; const hy = g.boxw / 2; ctx.lineWidth = 1; ctx.strokeStyle = 'rgba(160, 170, 185, 0.65)'; const corners = (zv: number): P3[] => [ [-hx, -hy, zv], [hx, -hy, zv], [hx, hy, zv], [-hx, hy, zv], ]; polyline(corners(0), true); polyline(corners(-g.boxd), true); for (let i = 0; i < 4; i++) { const top = corners(0)[i]; const bot = corners(-g.boxd)[i]; polyline([top, bot]); } // The sound hole, on the top plate. if (g.holer > 0) { const circle: P3[] = []; for (let i = 0; i <= 48; i++) { const a = (2 * Math.PI * i) / 48; circle.push([g.holex + g.holer * Math.cos(a), g.holer * Math.sin(a), 0]); } polyline(circle); } } if (o.string) { const u = o.string; const ns = u.length; const hs = g.Ls / (ns - 1); // Displacement is in y (the pluck direction), exaggerated to be seen. const pts: P3[] = []; for (let i = 0; i < ns; i++) { pts.push([-g.Ls / 2 + i * hs, o.exaggerate * u[i], g.stringZ]); } ctx.lineWidth = 1.6; ctx.strokeStyle = 'rgba(255, 214, 130, 0.95)'; polyline(pts); // Nut and bridge: short posts down to the plate. ctx.lineWidth = 2; ctx.strokeStyle = 'rgba(220, 226, 235, 0.8)'; polyline([[-g.Ls / 2, 0, g.stringZ], [-g.Ls / 2, 0, 0]]); polyline([[g.Ls / 2, 0, g.stringZ], [g.Ls / 2, 0, 0]]); } } }