/* ============================================================
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.
Output is organised into named LAYERS using the Inkscape
convention (inkscape:groupmode="layer" + inkscape:label), so
the file opens as toggleable layers in Inkscape / Affinity and
as named groups in Illustrator — for easy post manipulation.
============================================================ */
import { makeRng, cyrb53 } from '../rng.js';
import { sampleBubbles, trackInkWeight } from '../scene/bubbles.js';
const MARGIN = 0.02;
// which named track layer each particle kind belongs to (z-order: later = on top)
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;
const paper = inv ? '#cfc8b4' : '#0e0d0b';
const glowIn = inv ? '#e2dbc7' : '#211e18';
const glowOut = inv ? '#b3aa92' : '#070605';
const ink = inv ? '#1c1814' : '#e9e4d6';
// wrap content as a named, Inkscape/Affinity-compatible layer
const layer = (id, label, content) =>
content ? `\n${content}\n\n` : '';
/* ---------- Background ---------- */
const bg = `\n`;
/* ---------- Chamber optics (boundary + structural geometry) ---------- */
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 ---------- */
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 {
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)
g += ``;
shock += g + `\n`;
}
let st2 = '';
for (const st of (sh.stains || [])) {
const sr = (st.r * scale).toFixed(1);
st2 += ``;
}
shock += st2;
}
/* ---------- Tracks (split by particle kind, bubble 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
const buckets = new Map(); // layerId -> Map(alpha -> circles[])
const ensure = (id) => { if (!under.has(id)) { under.set(id, ''); buckets.set(id, new Map()); } };
const bubbleRng = makeRng(params.seed, 'bubbles'); // consumed in 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);
// 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
const alpha = Math.round(Math.min(1, 0.45 + track.weight * 0.5) * 20) / 20;
const m = buckets.get(id);
if (!m.has(alpha)) m.set(alpha, []);
const arr = m.get(alpha);
for (const b of sampleBubbles(track, params, bubbleRng)) {
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 [alpha, circles] of buckets.get(L.id)) {
if (circles.length) content += `${circles.join('')}\n`;
}
return layer(L.id, L.label, content);
}).join('');
/* ---------- Plate damage ---------- */
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}`
+ ``;
}
/* ---------- Assemble ---------- */
let s = `\n`;
s += `\n`;
return s;
}
function defs(inv, paper, glowIn, glowOut, ink, params) {
return `
\n`;
}