/* ============================================================ svgVector.js — clean vector renderer for print. Same scene model as the photographic renderer, emitted as resolution-independent SVG. Soft bubbles are approximated with radial-gradient fills; tracks get a faint continuity under-stroke. Grain/mottle are intentionally omitted (raster-only effects); this is the graphic version. Output is organised into named LAYERS (Inkscape convention), and coloured through the shared palette abstraction — track ink can vary per-trail (charge, β, …); a gradient is emitted per distinct ink colour. ============================================================ */ import { makeRng, cyrb53 } from '../rng.js'; import { sampleBubbles, trackInkWeight, depthFactors } from '../scene/bubbles.js'; import { resolvePalette, paperTone, rgbHex, rgbKey, bubbleStops, hslToRgb, mix } from './palette.js'; const MARGIN = 0.02; const TRACK_LAYERS = [ { id: 'tracks-primary', label: 'Tracks · primary', kinds: ['primary'] }, { id: 'tracks-cosmic', label: 'Tracks · cosmic & sweepers', kinds: ['cosmic', 'sweep'] }, { id: 'tracks-vdecay', label: 'Tracks · V-decays', kinds: ['vdecay'] }, { id: 'tracks-delta', label: 'Tracks · δ-rays (curls)', kinds: ['delta'] }, ]; 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; // base ink (mono), toned paper (independent of ink palette), then resolve feel const baseInk = inv ? [28, 24, 20] : [233, 228, 214]; const pt = paperTone(params, inv); const pal = resolvePalette(params.palette, { inv, sat: params.saturation ?? 1, hue: (params.hueShift ?? 0) * 360, cycles: params.hueCycles ?? 3, traceHue: params.traceHue ?? 0, diskHue: params.diskHue ?? 0.06, diskSat: params.diskSat ?? 0.82, baseInk, basePaper: { flat: pt.flat, glowIn: pt.glowIn, glowOut: pt.glowOut }, baseVign: pt.vign, }); const paperRGB = pal.paper(); // {flat,glowIn,glowOut} rgb arrays const paperC = { flat: rgbHex(paperRGB.flat), glowIn: rgbHex(paperRGB.glowIn), glowOut: rgbHex(paperRGB.glowOut) }; const ink = rgbHex(pal.feature()); // non-particle marks (optics, disk, damage, header) const featKey = rgbKey(pal.feature()); const colorMap = new Map([[featKey, pal.feature()]]); // distinct bubble colours → gradients const attr = (t) => String(t).replace(/&/g, '&').replace(/ content ? `\n${content}\n\n` : ''; /* ---------- Background ---------- */ const bg = `\n`; /* ---------- Chamber optics ---------- */ let optics = ''; 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; optics += `\n`; } if (scene.instrument) { const inst = scene.instrument; let g = ``; for (const l of inst.lines) g += ``; 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)}`; g += ``; } optics += g + ``; } /* ---------- Shock disk (feature ink) ---------- */ let shock = ''; if (scene.shock) { const sh = scene.shock; const px = +tx(sh.x), py = +ty(sh.y), R = sh.r * scale; const bodyOpacity = (params.diskBubbles !== false) ? 0.6 : 1; shock += `\n`; if (params.diskBubbles !== false && sh.bubbleStrokes) { const dRng = makeRng(params.seed, 'diskbubbles'); const dbk = new Map(); for (const stroke of sh.bubbleStrokes) { const key = Math.round(Math.min(1, 0.45 + stroke.weight * 0.5) * 20) / 20; if (!dbk.has(key)) dbk.set(key, []); dbk.get(key).push(...sampleBubbles(stroke, params, dRng)); } for (const [alpha, bubs] of dbk) { shock += ``; for (const b of bubs) shock += ``; shock += `\n`; } } else { // optionally iridescent: hue across the sunburst (per-element stroke colour) const spec = params.diskSpectrum || 0; const featRGB = pal.feature(); const TWO_PI = Math.PI * 2; const sCol = (frac) => spec <= 0 ? ink : rgbHex(mix(featRGB, hslToRgb((((frac + (params.hueShift || 0)) % 1) + 1) % 1, 0.85 * (params.saturation ?? 1), 0.52), spec)); let g = ``; 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; g += ``; } for (const ring of sh.rings) g += ``; 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; g += ``; } for (const k of sh.core) { const ca = Math.atan2(k.y1 - sh.y, k.x1 - sh.x); g += ``; } shock += g + `\n`; } for (const st of (sh.stains || [])) { const sr = (st.r * scale).toFixed(1); shock += ``; } } /* ---------- Tracks (per-trail colour via palette; positions unchanged) ---------- */ const labelFor = {}; for (const L of TRACK_LAYERS) for (const k of L.kinds) labelFor[k] = L.id; const under = new Map(); // layerId -> understroke string (per-path stroke colour) const buckets = new Map(); // layerId -> Map(colorKey|alpha -> {key, alpha, arr}) const ensure = (id) => { if (!under.has(id)) { under.set(id, ''); buckets.set(id, new Map()); } }; const bubbleRng = makeRng(params.seed, 'bubbles'); // scene.tracks order → deterministic for (const track of scene.tracks) { if (track.pts.length < 2) continue; const id = labelFor[track.kind] || 'tracks-primary'; ensure(id); const df = depthFactors(track, params); const repCol = pal.ink(track); // representative ink (under-stroke) // continuity under-stroke 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) { 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)}`; under.set(id, under.get(id) + ``); } // bubbles — opacity from depth/age tone, colour per-bubble from palette const alpha = Math.round(Math.min(1, 0.36 + df.tone * 0.58) * 20) / 20; const m = buckets.get(id); for (const b of sampleBubbles({ ...track, sizeScale: df.sizeScale }, params, bubbleRng)) { const bcol = pal.bubbleInk(track, b.life, b.beta), bkc = rgbKey(bcol); if (!colorMap.has(bkc)) colorMap.set(bkc, bcol); const bkey = bkc + '|' + alpha; if (!m.has(bkey)) m.set(bkey, { key: bkc, alpha, arr: [] }); m.get(bkey).arr.push(``); } } const trackLayers = TRACK_LAYERS.map(L => { if (!under.has(L.id)) return ''; let content = ''; const us = under.get(L.id); if (us) content += `${us}\n`; for (const { key, alpha, arr } of buckets.get(L.id).values()) { if (arr.length) content += `${arr.join('')}\n`; } return layer(L.id, L.label, content); }).join(''); /* ---------- Plate damage (feature ink) ---------- */ let damage = ''; const A = scene.artifacts; if (A) { let g = ``; for (const ring of A.rings) g += ``; for (const sc of A.scratches) g += ``; 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)}`; g += ``; } g += ``; for (const sp of A.specks) g += ``; damage = g + ``; } /* ---------- Fiducials ---------- */ let fids = ''; if (params.showFiducials) { let g = ``; const F = [[-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 F) { const px = +tx(fx), py = +ty(fy); g += ``; } fids = g + ``; } /* ---------- Vignette ---------- */ const vign = params.vign > 0 ? `` : ''; /* ---------- Header ---------- */ let header = ''; if (params.showHeader) { const pad = 26 * u; const esc = (t) => String(t).replace(/[<&]/g, c => (c === '<' ? '<' : '&')); header = `` + `${esc(scene.lab.toUpperCase())}` + `SEED ${esc(params.seed)}` + `PLATE ${scene.plate}` + `EXPOSED ${scene.exposure}` + ``; } /* ---------- Media & hand (réseau, splice, film furniture, grease pencil) ---------- */ let media = ''; if (scene.media) { const M = scene.media; const nx = (x) => (cx + x * scale), ny = (y) => (cy + y * scale); const esc = (t) => String(t).replace(/[<&]/g, c => (c === '<' ? '<' : '&')); if (M.reseau) { let g = ``; const ss = M.reseau.size * scale; for (const m of M.reseau.marks) { const x = nx(m.x), y = ny(m.y); g += ``; } media += g + ``; } if (M.splice) { const yc = ny(M.splice.y), hh = M.splice.h * scale, deg = M.splice.tilt * 57.3; media += `` + `` + `` + ``; } if (M.film) { const f = M.film, m = 0.965; media += ``; const sw = 0.028 * scale, sh = 0.05 * scale, rx = 4 * u, holeFill = inv ? '#faf8f0' : '#12100d'; let g = ''; for (const y of f.sprockets) for (const sx of [nx(-0.985), nx(0.985)]) { g += ``; } media += g; media += `` + `${esc(f.edgeText)}`; f.dataBox.forEach((line, i) => { media += `${esc(line)}`; }); media += ``; } if (M.grease && M.grease.length) { const ch = inv ? '#9c1e1e' : '#f0e296'; let g = ``; for (const gm of M.grease) { if (gm.kind === 'ring' || gm.kind === 'arrow') { const pts = gm.pts || gm.shaft; let d = `M ${nx(pts[0].x).toFixed(1)} ${ny(pts[0].y).toFixed(1)}`; for (let i = 1; i < pts.length; i++) d += ` L ${nx(pts[i].x).toFixed(1)} ${ny(pts[i].y).toFixed(1)}`; g += ``; if (gm.kind === 'arrow') { const { x, y, ang } = gm.tip, hl = 0.03 * scale; g += ``; } } else if (gm.kind === 'arc') { const x0 = nx(gm.x) + Math.cos(gm.a0) * gm.r * scale, y0 = ny(gm.y) + Math.sin(gm.a0) * gm.r * scale; const x1 = nx(gm.x) + Math.cos(gm.a1) * gm.r * scale, y1 = ny(gm.y) + Math.sin(gm.a1) * gm.r * scale; g += ``; } else if (gm.kind === 'tick') { g += ``; } else if (gm.kind === 'text') { g += `${esc(gm.text)}`; } } media += g + ``; } } /* ---------- Assemble ---------- */ let s = `\n`; s += `\n`; s += `Bubble Chamber · seed=${params.seed} · hash=${cyrb53(params.seed)} · palette=${params.palette || 'mono'}\n`; s += defs({ paperC, ink, baseVign: pal.vign(), params, u, colorMap }); s += layer('background', 'Background', bg); s += layer('optics', 'Chamber optics', optics); s += layer('shock', 'Shock disk', shock, params.diskSoften > 0 ? 'filter="url(#soften)"' : ''); s += trackLayers; s += layer('damage', 'Plate damage', damage); s += layer('fiducials', 'Fiducials', fids); s += layer('vignette', 'Vignette', vign); s += layer('header', 'Archival header', header); s += layer('media', 'Media & hand', media); s += `\n`; return s; } function defs({ paperC, ink, baseVign, params, u, colorMap }) { const soften = params.diskSoften > 0 ? `` : ''; // one soft-bubble gradient per distinct ink colour used (edge profile shared // with the raster sprite via bubbleStops) const stops = bubbleStops(params.bubbleSoft ?? 0.3); let bubGrads = ''; for (const [key, col] of colorMap) { const c = rgbHex(col); let st = ''; for (const [off, a] of stops) st += ``; bubGrads += `${st}`; } const vignHex = rgbHex(baseVign); return ` ${soften} ${bubGrads} \n`; }