/** * The line work over the volume: every object, the body's wireframe, the * bridge and nut posts. * * A 2D canvas stacked on the WebGPU one, redrawn each frame from the object * readbacks 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. * * Displacements are millimetres on a half-metre picture, so they are drawn * exaggerated (the factor the object plot states), and that is the one * honest exaggeration in the app: everything else is to scale. A line * object's displacement is drawn in y, a membrane's in z (a drum head moves * up and down); each object is drawn in its own colour, the same one its * partials wear in the spectrum. */ import type { CameraFrame } from './volume.ts'; /** One line object (string or bar), as drawn. */ export interface OverlayLine { kind: 'line'; color: string; L1: number; oy: number; oz: number; /** Displacement at the nodes, or null before the first readback. */ u: Float32Array | null; /** Draw the nut/bridge posts down to z = 0 (strings). */ posts: boolean; } /** One membrane, as drawn: the rim, plus its two deformed centre lines. */ export interface OverlayRect { kind: 'rect'; color: string; L1: number; L2: number; n1: number; n2: number; oz: number; u: Float32Array | null; } export type OverlayObject = OverlayLine | OverlayRect; export interface OverlayBody { type: 'none' | 'box' | 'shell'; /** box: boxl, boxw, boxd, holer, holex; shell: shellr, shelld. */ params: Record; } export interface OverlayOptions { frame: CameraFrame; objects: OverlayObject[]; body: OverlayBody; /** Displacement multiplier for visibility. */ exaggerate: number; wireframe: boolean; showObjects: 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 circle = (r: number, cx: number, zv: number): P3[] => { const pts: P3[] = []; for (let i = 0; i <= 48; i++) { const a = (2 * Math.PI * i) / 48; pts.push([cx + r * Math.cos(a), r * Math.sin(a), zv]); } return pts; }; if (o.wireframe && o.body.type !== 'none') { ctx.lineWidth = 1; ctx.strokeStyle = 'rgba(160, 170, 185, 0.65)'; const bp = o.body.params; if (o.body.type === 'box') { const hx = (bp.boxl ?? 0) / 2; const hy = (bp.boxw ?? 0) / 2; const corners = (zv: number): P3[] => [ [-hx, -hy, zv], [hx, -hy, zv], [hx, hy, zv], [-hx, hy, zv], ]; polyline(corners(0), true); polyline(corners(-(bp.boxd ?? 0)), true); for (let i = 0; i < 4; i++) { polyline([corners(0)[i], corners(-(bp.boxd ?? 0))[i]]); } if ((bp.holer ?? 0) > 0) polyline(circle(bp.holer, bp.holex ?? 0, 0)); } else { const r = bp.shellr ?? 0; const d = bp.shelld ?? 0; polyline(circle(r, 0, 0)); polyline(circle(r, 0, -d)); for (let i = 0; i < 4; i++) { const a = (Math.PI * i) / 2; polyline([ [r * Math.cos(a), r * Math.sin(a), 0], [r * Math.cos(a), r * Math.sin(a), -d], ]); } } } if (!o.showObjects) return; for (const obj of o.objects) { ctx.strokeStyle = obj.color; if (obj.kind === 'line') { const pts: P3[] = []; const n = obj.u?.length ?? 2; for (let i = 0; i < n; i++) { const xw = -obj.L1 / 2 + (obj.L1 * i) / (n - 1); const d = obj.u ? o.exaggerate * obj.u[i] : 0; pts.push([xw, obj.oy + d, obj.oz]); } ctx.lineWidth = 1.6; polyline(pts); if (obj.posts) { ctx.lineWidth = 2; ctx.strokeStyle = 'rgba(220, 226, 235, 0.8)'; polyline([[-obj.L1 / 2, obj.oy, obj.oz], [-obj.L1 / 2, obj.oy, 0]]); polyline([[obj.L1 / 2, obj.oy, obj.oz], [obj.L1 / 2, obj.oy, 0]]); } } else { // The rim, then the two centre lines carrying the motion. const hx = obj.L1 / 2; const hy = obj.L2 / 2; ctx.lineWidth = 1.2; polyline([[-hx, -hy, obj.oz], [hx, -hy, obj.oz], [hx, hy, obj.oz], [-hx, hy, obj.oz]], true); if (obj.u) { ctx.lineWidth = 1.6; const midJ = Math.floor(obj.n2 / 2); const row: P3[] = []; for (let i = 0; i < obj.n1; i++) { row.push([ -hx + (obj.L1 * i) / (obj.n1 - 1), (midJ / (obj.n2 - 1)) * obj.L2 - hy, obj.oz + o.exaggerate * obj.u[i + obj.n1 * midJ], ]); } polyline(row); const midI = Math.floor(obj.n1 / 2); const col: P3[] = []; for (let j = 0; j < obj.n2; j++) { col.push([ (midI / (obj.n1 - 1)) * obj.L1 - hx, -hy + (obj.L2 * j) / (obj.n2 - 1), obj.oz + o.exaggerate * obj.u[midI + obj.n1 * j], ]); } polyline(col); } } } } }