195 lines
10 KiB
JavaScript
195 lines
10 KiB
JavaScript
|
|
/* ============================================================
|
||
|
|
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 = `<?xml version="1.0" encoding="UTF-8"?>\n`;
|
||
|
|
s += `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">\n`;
|
||
|
|
s += `<metadata>Bubble Chamber · seed=${params.seed} · hash=${cyrb53(params.seed)}</metadata>\n`;
|
||
|
|
s += `<defs>
|
||
|
|
<radialGradient id="paper" cx="50%" cy="42%" r="72%">
|
||
|
|
<stop offset="0%" stop-color="${glowIn}"/><stop offset="100%" stop-color="${glowOut}"/>
|
||
|
|
</radialGradient>
|
||
|
|
<radialGradient id="bub" cx="50%" cy="50%" r="50%">
|
||
|
|
<stop offset="0%" stop-color="${ink}" stop-opacity="1"/>
|
||
|
|
<stop offset="55%" stop-color="${ink}" stop-opacity="0.92"/>
|
||
|
|
<stop offset="100%" stop-color="${ink}" stop-opacity="0"/>
|
||
|
|
</radialGradient>
|
||
|
|
<radialGradient id="shockcore" cx="50%" cy="50%" r="50%">
|
||
|
|
<stop offset="0%" stop-color="${ink}" stop-opacity="0.5"/>
|
||
|
|
<stop offset="60%" stop-color="${ink}" stop-opacity="0.28"/>
|
||
|
|
<stop offset="100%" stop-color="${ink}" stop-opacity="0"/>
|
||
|
|
</radialGradient>
|
||
|
|
<radialGradient id="shockstain" cx="50%" cy="50%" r="50%">
|
||
|
|
<stop offset="0%" stop-color="${ink}" stop-opacity="0.9"/>
|
||
|
|
<stop offset="100%" stop-color="${ink}" stop-opacity="0"/>
|
||
|
|
</radialGradient>
|
||
|
|
<radialGradient id="shockclean" cx="50%" cy="50%" r="50%">
|
||
|
|
<stop offset="0%" stop-color="${paper}" stop-opacity="0.9"/>
|
||
|
|
<stop offset="100%" stop-color="${paper}" stop-opacity="0"/>
|
||
|
|
</radialGradient>
|
||
|
|
<radialGradient id="vign" cx="50%" cy="50%" r="72%">
|
||
|
|
<stop offset="30%" stop-color="${inv ? '#382e1e' : '#000'}" stop-opacity="0"/>
|
||
|
|
<stop offset="100%" stop-color="${inv ? '#382e1e' : '#000'}" stop-opacity="${(inv ? 0.5 : 0.85) * params.vign}"/>
|
||
|
|
</radialGradient>
|
||
|
|
</defs>\n`;
|
||
|
|
s += `<rect width="${w}" height="${h}" fill="${paper}"/>\n`;
|
||
|
|
s += `<rect width="${w}" height="${h}" fill="url(#paper)"/>\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 += `<path d="M ${x1.toFixed(1)} ${y1.toFixed(1)} A ${br} ${br} 0 0 1 ${x2.toFixed(1)} ${y2.toFixed(1)}" fill="none" stroke="${ink}" stroke-opacity="0.4" stroke-width="${2 * u}"/>\n`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// chamber optics / structural geometry
|
||
|
|
if (scene.instrument) {
|
||
|
|
const inst = scene.instrument;
|
||
|
|
s += `<g fill="none" stroke="${ink}" stroke-linecap="round">\n`;
|
||
|
|
for (const l of inst.lines)
|
||
|
|
s += `<line x1="${tx(l.x1)}" y1="${ty(l.y1)}" x2="${tx(l.x2)}" y2="${ty(l.y2)}" stroke-opacity="${l.opacity.toFixed(3)}" stroke-width="${(l.width * u).toFixed(2)}"/>`;
|
||
|
|
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 += `<path d="${d}" stroke-opacity="${a.opacity.toFixed(3)}" stroke-width="${(a.width * u).toFixed(2)}"/>`;
|
||
|
|
}
|
||
|
|
s += `\n</g>\n`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// continuity under-strokes
|
||
|
|
s += `<g fill="none" stroke="${ink}" stroke-linecap="round" stroke-linejoin="round">\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 += `<path d="${d}" stroke-opacity="${(0.14 * track.weight).toFixed(3)}" stroke-width="${lw.toFixed(2)}"/>`;
|
||
|
|
}
|
||
|
|
s += `\n</g>\n`;
|
||
|
|
|
||
|
|
// shock disk
|
||
|
|
if (scene.shock) {
|
||
|
|
const sh = scene.shock;
|
||
|
|
const px = +tx(sh.x), py = +ty(sh.y), R = sh.r * scale;
|
||
|
|
s += `<circle cx="${px}" cy="${py}" r="${R.toFixed(1)}" fill="url(#shockcore)"/>\n`;
|
||
|
|
s += `<g stroke="${ink}" stroke-linecap="round" fill="none">\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 += `<line x1="${ix.toFixed(1)}" y1="${iy.toFixed(1)}" x2="${ox.toFixed(1)}" y2="${oy.toFixed(1)}" stroke-opacity="${st.opacity.toFixed(3)}" stroke-width="${(st.width * u).toFixed(2)}"/>`;
|
||
|
|
}
|
||
|
|
for (const ring of sh.rings) {
|
||
|
|
s += `<circle cx="${px}" cy="${py}" r="${(ring.rr * scale).toFixed(1)}" stroke-opacity="${ring.opacity.toFixed(3)}" stroke-width="${(ring.width * u).toFixed(2)}"/>`;
|
||
|
|
}
|
||
|
|
// 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 += `<path d="M ${x0.toFixed(1)} ${y0.toFixed(1)} A ${R.toFixed(1)} ${R.toFixed(1)} 0 0 1 ${x1a.toFixed(1)} ${y1a.toFixed(1)}" stroke-opacity="${seg.opacity.toFixed(3)}" stroke-width="${(seg.width * u).toFixed(2)}"/>`;
|
||
|
|
}
|
||
|
|
for (const k of sh.core) {
|
||
|
|
s += `<line x1="${tx(k.x1)}" y1="${ty(k.y1)}" x2="${tx(k.x2)}" y2="${ty(k.y2)}" stroke-opacity="${k.opacity.toFixed(3)}" stroke-width="${(k.width * u).toFixed(2)}"/>`;
|
||
|
|
}
|
||
|
|
s += `\n</g>\n`;
|
||
|
|
// staining blotches (dark grime / light washed spots)
|
||
|
|
for (const st of (sh.stains || [])) {
|
||
|
|
const sr = (st.r * scale).toFixed(1);
|
||
|
|
s += `<circle cx="${tx(st.x)}" cy="${ty(st.y)}" r="${sr}" fill="url(#${st.dark ? 'shockstain' : 'shockclean'})" fill-opacity="${st.opacity.toFixed(3)}"/>`;
|
||
|
|
}
|
||
|
|
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 += `<g fill="url(#bub)" fill-opacity="${alpha}">`;
|
||
|
|
for (const b of bubs) {
|
||
|
|
const r = Math.max(b.r * scale * 1.15, 0.5);
|
||
|
|
s += `<circle cx="${tx(b.x)}" cy="${ty(b.y)}" r="${r.toFixed(2)}"/>`;
|
||
|
|
}
|
||
|
|
s += `</g>\n`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// plate damage
|
||
|
|
const A = scene.artifacts;
|
||
|
|
if (A) {
|
||
|
|
s += `<g stroke="${ink}" fill="none" stroke-linecap="round">\n`;
|
||
|
|
for (const ring of A.rings)
|
||
|
|
s += `<circle cx="${tx(ring.x)}" cy="${ty(ring.y)}" r="${(ring.r * scale).toFixed(1)}" stroke-opacity="${ring.opacity.toFixed(3)}" stroke-width="${(ring.width * u).toFixed(2)}"/>`;
|
||
|
|
for (const sc of A.scratches)
|
||
|
|
s += `<line x1="${tx(sc.x1)}" y1="${ty(sc.y1)}" x2="${tx(sc.x2)}" y2="${ty(sc.y2)}" stroke-opacity="${sc.opacity.toFixed(3)}" stroke-width="${(sc.width * u).toFixed(2)}"/>`;
|
||
|
|
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 += `<path d="${d}" stroke-opacity="${hair.opacity.toFixed(3)}" stroke-width="${(hair.width * u).toFixed(2)}"/>`;
|
||
|
|
}
|
||
|
|
s += `\n</g>\n`;
|
||
|
|
s += `<g fill="${ink}">`;
|
||
|
|
for (const sp of A.specks)
|
||
|
|
s += `<circle cx="${tx(sp.x)}" cy="${ty(sp.y)}" r="${Math.max(sp.r * scale, 0.5).toFixed(2)}" fill-opacity="${sp.opacity.toFixed(3)}"/>`;
|
||
|
|
s += `</g>\n`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// fiducials
|
||
|
|
if (params.showFiducials) {
|
||
|
|
s += `<g stroke="${ink}" stroke-opacity="0.55" stroke-width="${1.2 * u}">\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 += `<line x1="${px - sz}" y1="${py}" x2="${px + sz}" y2="${py}"/><line x1="${px}" y1="${py - sz}" x2="${px}" y2="${py + sz}"/>`;
|
||
|
|
}
|
||
|
|
s += `\n</g>\n`;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (params.vign > 0) s += `<rect width="${w}" height="${h}" fill="url(#vign)"/>\n`;
|
||
|
|
|
||
|
|
// archival header
|
||
|
|
if (params.showHeader) {
|
||
|
|
const pad = 26 * u;
|
||
|
|
const esc = (t) => String(t).replace(/[<&]/g, c => (c === '<' ? '<' : '&'));
|
||
|
|
s += `<g fill="${ink}" font-family="'JetBrains Mono', monospace">\n`;
|
||
|
|
s += `<text x="${pad.toFixed(0)}" y="${(pad + 11 * u).toFixed(0)}" font-size="${(11 * u).toFixed(0)}" fill-opacity="0.62">${esc(scene.lab.toUpperCase())}</text>`;
|
||
|
|
s += `<text x="${pad.toFixed(0)}" y="${(pad + 27 * u).toFixed(0)}" font-size="${(9 * u).toFixed(0)}" fill-opacity="0.5">SEED ${esc(params.seed)}</text>`;
|
||
|
|
s += `<text x="${(w - pad).toFixed(0)}" y="${(h - pad - 13 * u).toFixed(0)}" font-size="${(10 * u).toFixed(0)}" text-anchor="end" fill-opacity="0.58">PLATE ${scene.plate}</text>`;
|
||
|
|
s += `<text x="${(w - pad).toFixed(0)}" y="${(h - pad).toFixed(0)}" font-size="${(10 * u).toFixed(0)}" text-anchor="end" fill-opacity="0.58">EXPOSED ${scene.exposure}</text>`;
|
||
|
|
s += `\n</g>\n`;
|
||
|
|
}
|
||
|
|
s += `</svg>\n`;
|
||
|
|
return s;
|
||
|
|
}
|