/* ============================================================ svgVector.js — clean vector renderer for print. Same scene model as the photographic renderer, emitted as resolution-independent SVG. Soft bubbles are approximated with a shared radial-gradient fill; tracks get a faint continuity under-stroke. Grain/mottle are intentionally omitted (raster-only effects); this is the graphic version. ============================================================ */ import { makeRng, cyrb53 } from '../rng.js'; import { sampleBubbles, trackInkWeight } from '../scene/bubbles.js'; const MARGIN = 0.02; export function renderSVG(scene, params, sizePx = 4800) { const w = sizePx, h = sizePx; const scale = (w / 2) * (1 - MARGIN); const cx = w / 2, cy = h / 2; const tx = (x) => (cx + x * scale).toFixed(2); const ty = (y) => (cy + y * scale).toFixed(2); const u = w / 1000; const inv = params.invert; const paper = inv ? '#cfc8b4' : '#0e0d0b'; const glowIn = inv ? '#e2dbc7' : '#211e18'; const glowOut = inv ? '#b3aa92' : '#070605'; const ink = inv ? '#1c1814' : '#e9e4d6'; const inkRGB = inv ? '28,24,20' : '233,228,214'; let s = `\n`; s += `\n`; s += `Bubble Chamber · seed=${params.seed} · hash=${cyrb53(params.seed)}\n`; s += ` \n`; s += `\n`; s += `\n`; // boundary if (params.showBoundary) { const bcx = cx, bcy = cy + h * 0.35, br = w * 0.45; const a1 = Math.PI * 0.15, a2 = Math.PI - Math.PI * 0.15; const x1 = bcx + Math.cos(Math.PI + a1) * br, y1 = bcy + Math.sin(Math.PI + a1) * br; const x2 = bcx + Math.cos(Math.PI + a2) * br, y2 = bcy + Math.sin(Math.PI + a2) * br; s += `\n`; } // chamber optics / structural geometry if (scene.instrument) { const inst = scene.instrument; s += `\n`; for (const l of inst.lines) s += ``; for (const a of inst.arcs) { let d = `M ${tx(a.pts[0].x)} ${ty(a.pts[0].y)}`; for (let i = 1; i < a.pts.length; i++) d += ` L ${tx(a.pts[i].x)} ${ty(a.pts[i].y)}`; s += ``; } s += `\n\n`; } // continuity under-strokes s += `\n`; for (const track of scene.tracks) { if (track.pts.length < 2) continue; const iw = trackInkWeight(track); const lw = Math.min(2.6, 0.25 + Math.sqrt(iw) * 0.12) * u * params.size * track.weight; if (lw < 0.2 * u) continue; let d = `M ${tx(track.pts[0].x)} ${ty(track.pts[0].y)}`; for (let i = 1; i < track.pts.length; i++) d += ` L ${tx(track.pts[i].x)} ${ty(track.pts[i].y)}`; s += ``; } s += `\n\n`; // shock disk if (scene.shock) { const sh = scene.shock; const px = +tx(sh.x), py = +ty(sh.y), R = sh.r * scale; s += `\n`; s += `\n`; for (const st of sh.striations) { const ix = px + Math.cos(st.a) * st.inner * scale, iy = py + Math.sin(st.a) * st.inner * scale; const ox = px + Math.cos(st.a) * st.outer * scale, oy = py + Math.sin(st.a) * st.outer * scale; s += ``; } for (const ring of sh.rings) { s += ``; } // eroded rim segments for (const seg of (sh.rimSegs || [])) { const x0 = px + Math.cos(seg.a0) * R, y0 = py + Math.sin(seg.a0) * R; const x1a = px + Math.cos(seg.a1) * R, y1a = py + Math.sin(seg.a1) * R; s += ``; } for (const k of sh.core) { s += ``; } s += `\n\n`; // staining blotches (dark grime / light washed spots) for (const st of (sh.stains || [])) { const sr = (st.r * scale).toFixed(1); s += ``; } s += `\n`; } // bubbles, bucketed by opacity const buckets = new Map(); const bubbleRng = makeRng(params.seed, 'bubbles'); for (const track of scene.tracks) { const key = Math.round(Math.min(1, 0.45 + track.weight * 0.5) * 20) / 20; if (!buckets.has(key)) buckets.set(key, []); buckets.get(key).push(...sampleBubbles(track, params, bubbleRng)); } for (const [alpha, bubs] of buckets) { s += ``; for (const b of bubs) { const r = Math.max(b.r * scale * 1.15, 0.5); s += ``; } s += `\n`; } // plate damage const A = scene.artifacts; if (A) { s += `\n`; for (const ring of A.rings) s += ``; for (const sc of A.scratches) s += ``; for (const hair of A.hairs) { let d = `M ${tx(hair.pts[0].x)} ${ty(hair.pts[0].y)}`; for (let i = 1; i < hair.pts.length; i++) d += ` L ${tx(hair.pts[i].x)} ${ty(hair.pts[i].y)}`; s += ``; } s += `\n\n`; s += ``; for (const sp of A.specks) s += ``; s += `\n`; } // fiducials if (params.showFiducials) { s += `\n`; const fids = [[-0.85, -0.85], [0.85, -0.85], [-0.85, 0.85], [0.85, 0.85], [0, -0.85], [-0.85, 0], [0.85, 0]]; const sz = 9 * u; for (const [fx, fy] of fids) { const px = +tx(fx), py = +ty(fy); s += ``; } s += `\n\n`; } if (params.vign > 0) s += `\n`; // archival header if (params.showHeader) { const pad = 26 * u; const esc = (t) => String(t).replace(/[<&]/g, c => (c === '<' ? '<' : '&')); s += `\n`; s += `${esc(scene.lab.toUpperCase())}`; s += `SEED ${esc(params.seed)}`; s += `PLATE ${scene.plate}`; s += `EXPOSED ${scene.exposure}`; s += `\n\n`; } s += `\n`; return s; }