This commit is contained in:
2026-05-20 16:53:23 -04:00
commit dd138d5c4f
77 changed files with 973678 additions and 0 deletions

411
src/render/canvasPhoto.js Normal file
View File

@@ -0,0 +1,411 @@
/* ============================================================
canvasPhoto.js — the photographic raster renderer.
A multi-pass compositor that turns the pure scene model into
something that reads as a worn bubble-chamber plate:
paper + gas-glow → tonal mottle → ink (soft blooming
bubbles that merge into lines) → halation → shock disk →
plate damage → vignette → film grain.
Geometry is identical to the vector renderer; only the look
differs. Same code path serves the live preview and the
high-resolution print render (effect radii scale with size).
============================================================ */
import { makeRng } from '../rng.js';
import { sampleBubbles, trackInkWeight } from '../scene/bubbles.js';
import { mottleCanvas, grainCanvas } from './noise.js';
const MARGIN = 0.02;
/* ---- palettes ---- */
function palette(inv) {
return inv
? { paperFlat: '#d3ccb8', glowIn: '#e4ddc9', glowOut: '#b3aa92',
ink: [28, 24, 19], inkBlend: 'multiply',
lift: 'screen', vign: [56, 46, 32] }
: { paperFlat: '#0e0d0b', glowIn: '#211e18', glowOut: '#070605',
ink: [233, 228, 214], inkBlend: 'screen',
lift: 'multiply', vign: [0, 0, 0] };
}
/* cached noise canvases (rebuilt only when seed/size change) */
const noiseCache = { key: '', mottleA: null, mottleB: null, grain: null, sprite: null, spriteKey: '' };
function softDotSprite(inkRGB) {
const key = inkRGB.join(',');
if (noiseCache.sprite && noiseCache.spriteKey === key) return noiseCache.sprite;
const S = 64;
const c = document.createElement('canvas');
c.width = c.height = S;
const g = c.getContext('2d');
const grad = g.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2);
const [r, gr, b] = inkRGB;
grad.addColorStop(0.0, `rgba(${r},${gr},${b},1)`);
grad.addColorStop(0.30, `rgba(${r},${gr},${b},0.92)`);
grad.addColorStop(0.62, `rgba(${r},${gr},${b},0.40)`);
grad.addColorStop(1.0, `rgba(${r},${gr},${b},0)`);
g.fillStyle = grad;
g.fillRect(0, 0, S, S);
noiseCache.sprite = c; noiseCache.spriteKey = key;
return c;
}
export function renderCanvasPhoto(ctx, w, h, scene, params, opts = {}) {
const pre = opts.preview !== false;
const inv = params.invert;
const P = palette(inv);
const scale = (w / 2) * (1 - MARGIN);
const cx = w / 2, cy = h / 2;
const tx = (x) => cx + x * scale;
const ty = (y) => cy + y * scale;
const u = w / 1000; // unit scale relative to a 1000px render
const blur = (px) => `blur(${(px * u).toFixed(2)}px)`;
// ---- noise cache key ----
const nKey = `${params.seed}|${w}`;
if (noiseCache.key !== nKey) {
noiseCache.key = nKey;
noiseCache.mottleA = mottleCanvas(params.seed, Math.min(512, w), 4, 4); // broad blotches
noiseCache.mottleB = mottleCanvas(params.seed + '#b', Math.min(512, w), 14, 4); // medium
noiseCache.grain = grainCanvas(params.seed, Math.max(256, Math.round(w / 2.2)), 1.15);
}
/* ---------- Pass 1: paper + gas glow ---------- */
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
ctx.fillStyle = P.paperFlat;
ctx.fillRect(0, 0, w, h);
const glow = ctx.createRadialGradient(w * 0.5, h * 0.42, 0, w * 0.5, h * 0.5, w * 0.72);
glow.addColorStop(0, P.glowIn);
glow.addColorStop(1, P.glowOut);
ctx.fillStyle = glow;
ctx.fillRect(0, 0, w, h);
/* ---------- Pass 2: tonal mottle (uneven development) ---------- */
if (params.mottle > 0) {
ctx.save();
ctx.globalCompositeOperation = inv ? 'multiply' : 'screen';
ctx.globalAlpha = params.mottle * 0.5;
ctx.drawImage(noiseCache.mottleA, 0, 0, w, h);
ctx.globalAlpha = params.mottle * 0.28;
ctx.drawImage(noiseCache.mottleB, 0, 0, w, h);
ctx.restore();
}
/* ---------- Pass 2.5: chamber optics / structural geometry ---------- */
if (scene.instrument) drawInstrument(ctx, scene.instrument, tx, ty, scale, u, P, inv);
/* ---------- Pass 3: ink layer (offscreen, transparent) ---------- */
const ink = document.createElement('canvas');
ink.width = w; ink.height = h;
const ic = ink.getContext('2d');
const [ir, ig, ib] = P.ink;
const sprite = softDotSprite(P.ink);
// NOTE: never set ic.filter here — a filter set before the bubble loop is
// re-applied to every single drawImage stamp (tens of thousands), which is
// catastrophically slow and scales with size. Softness is the sprite's job,
// plus one whole-layer blur at composite time (Pass 5).
// 3a. continuity under-stroke beneath each track
ic.lineCap = 'round'; ic.lineJoin = 'round';
for (const track of scene.tracks) {
if (track.pts.length < 2) continue;
const iw = trackInkWeight(track);
const wgt = track.weight;
const lw = Math.min(2.6, (0.25 + Math.sqrt(iw) * 0.12)) * u * params.size * wgt;
if (lw < 0.2 * u) continue;
ic.strokeStyle = `rgba(${ir},${ig},${ib},${0.14 * wgt})`;
ic.lineWidth = lw;
ic.beginPath();
ic.moveTo(tx(track.pts[0].x), ty(track.pts[0].y));
for (let i = 1; i < track.pts.length; i++) ic.lineTo(tx(track.pts[i].x), ty(track.pts[i].y));
ic.stroke();
}
// 3b. soft blooming bubbles (stamped sprite; overlaps merge into lines)
const bubbleRng = makeRng(params.seed, 'bubbles');
for (const track of scene.tracks) {
const bubs = sampleBubbles(track, params, bubbleRng);
ic.globalAlpha = Math.min(1, 0.45 + track.weight * 0.5);
for (const b of bubs) {
const rr = Math.max(b.r * scale, 0.45);
const d = rr * 2.4; // sprite footprint a touch larger than core
ic.drawImage(sprite, tx(b.x) - d / 2, ty(b.y) - d / 2, d, d);
}
}
ic.globalAlpha = 1;
// 3c. shock disk drawn into the ink layer so bloom catches it
if (scene.shock) drawShock(ic, scene.shock, tx, ty, scale, u, P);
/* ---------- Pass 4: halation / bloom ---------- */
// composite a blurred copy of the ink under the sharp ink for soft spread
if (params.bloom > 0) {
ctx.save();
ctx.globalCompositeOperation = P.inkBlend;
ctx.globalAlpha = Math.min(0.9, params.bloom * 0.7);
ctx.filter = blur(pre ? 2.4 : 3.0);
ctx.drawImage(ink, 0, 0);
ctx.filter = 'none';
ctx.restore();
}
/* ---------- Pass 5: composite sharp ink onto paper (one soft-focus blur) ---------- */
ctx.save();
ctx.globalCompositeOperation = P.inkBlend;
ctx.globalAlpha = 1;
ctx.filter = blur(pre ? 0.5 : 0.7); // single whole-layer blur, not per-stamp
ctx.drawImage(ink, 0, 0);
ctx.filter = 'none';
ctx.restore();
/* ---------- Pass 6: plate damage ---------- */
drawDamage(ctx, scene.artifacts, tx, ty, scale, u, P, inv);
/* ---------- Pass 7: fiducials, boundary & archival header ---------- */
drawFiducialsBoundary(ctx, w, h, params, scale, cx, cy, tx, ty, u, P, inv);
if (params.showHeader) drawHeader(ctx, w, h, scene, params, u, P);
/* ---------- Pass 8: vignette ---------- */
if (params.vign > 0) {
const vg = ctx.createRadialGradient(w / 2, h / 2, w * 0.28, w / 2, h / 2, w * 0.72);
const [vr, vgc, vb] = P.vign;
vg.addColorStop(0, `rgba(${vr},${vgc},${vb},0)`);
vg.addColorStop(1, `rgba(${vr},${vgc},${vb},${params.vign * (inv ? 0.5 : 0.85)})`);
ctx.fillStyle = vg;
ctx.fillRect(0, 0, w, h);
}
/* ---------- Pass 9: film grain ---------- */
if (params.grain > 0) {
ctx.save();
ctx.globalCompositeOperation = 'overlay';
ctx.globalAlpha = Math.min(0.85, params.grain * 0.7);
// tile the grain canvas across at ~1:1.6 so clumps are supra-pixel
const gsz = noiseCache.grain.width;
const tile = gsz * 1.6;
for (let gy = 0; gy < h; gy += tile) {
for (let gx = 0; gx < w; gx += tile) {
ctx.drawImage(noiseCache.grain, gx, gy, tile, tile);
}
}
ctx.restore();
}
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
}
/* ---- shock disk ---- */
function drawShock(c, shock, tx, ty, scale, u, P) {
const [r, g, b] = P.ink;
const px = tx(shock.x), py = ty(shock.y), R = shock.r * scale;
// disk body: dark annulus that keeps a lighter, detailed centre
const core = c.createRadialGradient(px, py, 0, px, py, R);
core.addColorStop(0.0, `rgba(${r},${g},${b},${0.06 * shock.intensity})`);
core.addColorStop(0.35, `rgba(${r},${g},${b},${0.18 * shock.intensity})`);
core.addColorStop(0.72, `rgba(${r},${g},${b},${0.38 * shock.intensity})`);
core.addColorStop(0.94, `rgba(${r},${g},${b},${0.34 * shock.intensity})`);
core.addColorStop(1, `rgba(${r},${g},${b},0)`);
c.fillStyle = core;
c.beginPath(); c.arc(px, py, R, 0, Math.PI * 2); c.fill();
// radial striations (the sunburst)
c.lineCap = 'round';
for (const s of shock.striations) {
const ix = px + Math.cos(s.a) * s.inner * scale;
const iy = py + Math.sin(s.a) * s.inner * scale;
const ox = px + Math.cos(s.a) * s.outer * scale;
const oy = py + Math.sin(s.a) * s.outer * scale;
const mx = (ix + ox) / 2 + Math.cos(s.a + Math.PI / 2) * s.wobble * scale;
const my = (iy + oy) / 2 + Math.sin(s.a + Math.PI / 2) * s.wobble * scale;
c.strokeStyle = `rgba(${r},${g},${b},${s.opacity})`;
c.lineWidth = s.width * u;
c.beginPath();
c.moveTo(ix, iy);
c.quadraticCurveTo(mx, my, ox, oy);
c.stroke();
}
// inner pressure-front rings
for (const ring of shock.rings) {
c.strokeStyle = `rgba(${r},${g},${b},${ring.opacity})`;
c.lineWidth = ring.width * u;
c.beginPath(); c.arc(px, py, ring.rr * scale, 0, Math.PI * 2); c.stroke();
}
// rim as eroded arc segments
if (shock.rimSegs) {
for (const seg of shock.rimSegs) {
c.strokeStyle = `rgba(${r},${g},${b},${seg.opacity})`;
c.lineWidth = seg.width * u;
c.beginPath(); c.arc(px, py, shock.r * scale, seg.a0, seg.a1); c.stroke();
}
}
// staining blotches: dark = grime, light = lifted/washed clean spots
if (shock.stains) {
for (const st of shock.stains) {
const sr = st.r * scale;
const grad = c.createRadialGradient(tx(st.x), ty(st.y), 0, tx(st.x), ty(st.y), sr);
if (st.dark) {
c.globalCompositeOperation = 'source-over';
grad.addColorStop(0, `rgba(${r},${g},${b},${st.opacity})`);
grad.addColorStop(1, `rgba(${r},${g},${b},0)`);
c.fillStyle = grad;
} else {
c.globalCompositeOperation = 'destination-out';
grad.addColorStop(0, `rgba(0,0,0,${st.opacity * 1.4})`);
grad.addColorStop(1, 'rgba(0,0,0,0)');
c.fillStyle = grad;
}
c.beginPath(); c.arc(tx(st.x), ty(st.y), sr, 0, Math.PI * 2); c.fill();
}
c.globalCompositeOperation = 'source-over';
}
// textured core chords
for (const k of shock.core) {
c.strokeStyle = `rgba(${r},${g},${b},${k.opacity})`;
c.lineWidth = k.width * u;
c.beginPath(); c.moveTo(tx(k.x1), ty(k.y1)); c.lineTo(tx(k.x2), ty(k.y2)); c.stroke();
}
// keep a bright, detailed centre by clearing a soft hole in the ink
if (shock.bright) {
c.save();
c.globalCompositeOperation = 'destination-out';
const hole = c.createRadialGradient(px, py, 0, px, py, shock.bright * scale);
hole.addColorStop(0, 'rgba(0,0,0,0.85)');
hole.addColorStop(1, 'rgba(0,0,0,0)');
c.fillStyle = hole;
c.beginPath(); c.arc(px, py, shock.bright * scale, 0, Math.PI * 2); c.fill();
c.restore();
}
}
/* ---- plate damage ---- */
function drawDamage(ctx, A, tx, ty, scale, u, P, inv) {
if (!A) return;
const [r, g, b] = P.ink;
ctx.save();
// water rings (faint, under)
ctx.globalCompositeOperation = inv ? 'multiply' : 'screen';
for (const ring of A.rings) {
ctx.strokeStyle = `rgba(${r},${g},${b},${ring.opacity})`;
ctx.lineWidth = ring.width * u;
ctx.beginPath(); ctx.arc(tx(ring.x), ty(ring.y), ring.r * scale, 0, Math.PI * 2); ctx.stroke();
}
// fingerprints
for (const fp of A.fingerprints) {
ctx.strokeStyle = `rgba(${r},${g},${b},${fp.opacity})`;
ctx.lineWidth = 0.7 * u;
for (const lp of fp.loops) {
ctx.save();
ctx.translate(tx(fp.x), ty(fp.y));
ctx.rotate(lp.rot);
ctx.scale(1, lp.squash);
ctx.beginPath(); ctx.arc(0, 0, lp.rr * scale, 0, Math.PI * 2); ctx.stroke();
ctx.restore();
}
}
// scratches: dark (multiply) and bright (lift)
for (const s of A.scratches) {
ctx.globalCompositeOperation = s.bright ? P.lift : (inv ? 'multiply' : 'screen');
const col = s.bright ? (inv ? '245,242,232' : '0,0,0') : `${r},${g},${b}`;
ctx.strokeStyle = `rgba(${col},${s.opacity})`;
ctx.lineWidth = s.width * u;
ctx.beginPath(); ctx.moveTo(tx(s.x1), ty(s.y1)); ctx.lineTo(tx(s.x2), ty(s.y2)); ctx.stroke();
}
// hairs
ctx.globalCompositeOperation = inv ? 'multiply' : 'screen';
for (const hair of A.hairs) {
ctx.strokeStyle = `rgba(${r},${g},${b},${hair.opacity})`;
ctx.lineWidth = hair.width * u;
ctx.beginPath();
ctx.moveTo(tx(hair.pts[0].x), ty(hair.pts[0].y));
for (let i = 1; i < hair.pts.length; i++) ctx.lineTo(tx(hair.pts[i].x), ty(hair.pts[i].y));
ctx.stroke();
}
// dust specks (over)
for (const sp of A.specks) {
ctx.fillStyle = `rgba(${r},${g},${b},${sp.opacity})`;
ctx.beginPath(); ctx.arc(tx(sp.x), ty(sp.y), Math.max(sp.r * scale, 0.4), 0, Math.PI * 2); ctx.fill();
}
ctx.restore();
}
/* ---- chamber optics / structural geometry ---- */
function drawInstrument(ctx, inst, tx, ty, scale, u, P, inv) {
const [r, g, b] = P.ink;
ctx.save();
ctx.globalCompositeOperation = inv ? 'multiply' : 'screen';
ctx.lineCap = 'round';
ctx.filter = `blur(${(0.4 * u).toFixed(2)}px)`;
for (const l of inst.lines) {
ctx.strokeStyle = `rgba(${r},${g},${b},${l.opacity})`;
ctx.lineWidth = l.width * u;
ctx.beginPath(); ctx.moveTo(tx(l.x1), ty(l.y1)); ctx.lineTo(tx(l.x2), ty(l.y2)); ctx.stroke();
}
for (const a of inst.arcs) {
ctx.strokeStyle = `rgba(${r},${g},${b},${a.opacity})`;
ctx.lineWidth = a.width * u;
ctx.beginPath();
ctx.moveTo(tx(a.pts[0].x), ty(a.pts[0].y));
for (let i = 1; i < a.pts.length; i++) ctx.lineTo(tx(a.pts[i].x), ty(a.pts[i].y));
ctx.stroke();
}
ctx.restore();
}
/* ---- archival header (rendered text, baked into the plate) ---- */
function drawHeader(ctx, w, h, scene, params, u, P) {
const [r, g, b] = P.ink;
ctx.save();
ctx.globalCompositeOperation = params.invert ? 'multiply' : 'screen';
ctx.fillStyle = `rgba(${r},${g},${b},0.62)`;
const pad = 26 * u;
ctx.font = `${11 * u}px 'JetBrains Mono', ui-monospace, monospace`;
ctx.textBaseline = 'top';
ctx.fillText(scene.lab.toUpperCase(), pad, pad);
ctx.font = `${9 * u}px 'JetBrains Mono', ui-monospace, monospace`;
ctx.fillStyle = `rgba(${r},${g},${b},0.5)`;
ctx.fillText(`SEED ${params.seed}`, pad, pad + 16 * u);
ctx.textAlign = 'right';
ctx.textBaseline = 'bottom';
ctx.font = `${10 * u}px 'JetBrains Mono', ui-monospace, monospace`;
ctx.fillStyle = `rgba(${r},${g},${b},0.58)`;
ctx.fillText(`PLATE ${scene.plate}`, w - pad, h - pad - 13 * u);
ctx.fillText(`EXPOSED ${scene.exposure}`, w - pad, h - pad);
ctx.restore();
}
/* ---- fiducials & chamber boundary ---- */
function drawFiducialsBoundary(ctx, w, h, params, scale, cx, cy, tx, ty, u, P, inv) {
const [r, g, b] = P.ink;
if (params.showBoundary) {
ctx.strokeStyle = `rgba(${r},${g},${b},0.4)`;
ctx.lineWidth = 1.4 * u;
ctx.beginPath();
ctx.arc(cx, cy + h * 0.35, w * 0.45, Math.PI * 0.15, Math.PI - Math.PI * 0.15, false);
ctx.stroke();
}
if (params.showFiducials) {
ctx.strokeStyle = `rgba(${r},${g},${b},0.55)`;
ctx.lineWidth = 1 * u;
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 s = 6 * u;
for (const [fx, fy] of fids) {
const px = tx(fx), py = ty(fy);
ctx.beginPath();
ctx.moveTo(px - s, py); ctx.lineTo(px + s, py);
ctx.moveTo(px, py - s); ctx.lineTo(px, py + s);
ctx.stroke();
}
}
}

93
src/render/noise.js Normal file
View File

@@ -0,0 +1,93 @@
/* ============================================================
noise.js — value noise / fbm helpers for the analog layer.
Used for film grain structure and low-frequency tonal mottle
(uneven gas glow & development stains).
============================================================ */
import { mulberry32, cyrb53 } from '../rng.js';
/* Hash-based value noise on an integer lattice, smoothstep-interpolated. */
function lattice(seedInt) {
const cache = new Map();
return (ix, iy) => {
const key = ix * 73856093 ^ iy * 19349663;
let v = cache.get(key);
if (v === undefined) {
let h = (seedInt ^ Math.imul(ix, 374761393) ^ Math.imul(iy, 668265263)) >>> 0;
h = Math.imul(h ^ (h >>> 13), 1274126177) >>> 0;
v = (h >>> 0) / 4294967296;
cache.set(key, v);
}
return v;
};
}
const smooth = (t) => t * t * (3 - 2 * t);
function valueNoise2D(lat, x, y) {
const x0 = Math.floor(x), y0 = Math.floor(y);
const fx = smooth(x - x0), fy = smooth(y - y0);
const v00 = lat(x0, y0), v10 = lat(x0 + 1, y0);
const v01 = lat(x0, y0 + 1), v11 = lat(x0 + 1, y0 + 1);
const a = v00 + (v10 - v00) * fx;
const b = v01 + (v11 - v01) * fx;
return a + (b - a) * fy;
}
/* Fractal (fbm) value noise, octaves of valueNoise2D. Returns [0,1]. */
export function fbm(lat, x, y, octaves = 4, lacunarity = 2, gain = 0.5) {
let amp = 1, freq = 1, sum = 0, norm = 0;
for (let o = 0; o < octaves; o++) {
sum += amp * valueNoise2D(lat, x * freq, y * freq);
norm += amp;
amp *= gain;
freq *= lacunarity;
}
return sum / norm;
}
/* Build a low-frequency tonal mottle canvas (uneven illumination + stains).
`cells` ≈ how many noise cells across the image (low = broad blotches). */
export function mottleCanvas(seedStr, size, cells = 5, octaves = 4) {
const c = document.createElement('canvas');
c.width = c.height = size;
const cx = c.getContext('2d');
const img = cx.createImageData(size, size);
const d = img.data;
const lat = lattice(parseInt(cyrb53(seedStr + '::mottle').slice(0, 8), 16));
const s = cells / size;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
let n = fbm(lat, x * s, y * s, octaves, 2.1, 0.55);
// bias toward mid, widen contrast a touch
n = Math.min(1, Math.max(0, (n - 0.5) * 1.6 + 0.5));
const v = Math.round(n * 255);
const i = (y * size + x) * 4;
d[i] = d[i + 1] = d[i + 2] = v;
d[i + 3] = 255;
}
}
cx.putImageData(img, 0, 0);
return c;
}
/* Build a film-grain tile. Grain is generated at `grainSize` then meant to be
drawn upscaled, giving clumps larger than one device pixel (real emulsion
grain is not per-pixel). Returns an offscreen canvas of grain in alpha. */
export function grainCanvas(seedStr, grainSize, contrast = 1) {
const c = document.createElement('canvas');
c.width = c.height = grainSize;
const cx = c.getContext('2d');
const img = cx.createImageData(grainSize, grainSize);
const d = img.data;
const rng = mulberry32(parseInt(cyrb53(seedStr + '::grain').slice(0, 8), 16));
for (let i = 0; i < d.length; i += 4) {
// signed grain centred on 0.5, gaussian-ish via two uniforms
let g = (rng() + rng() - 1) * 0.5 + 0.5;
g = Math.min(1, Math.max(0, (g - 0.5) * contrast + 0.5));
const v = Math.round(g * 255);
d[i] = d[i + 1] = d[i + 2] = v;
d[i + 3] = 255;
}
cx.putImageData(img, 0, 0);
return c;
}

197
src/render/pdf.js Normal file
View File

@@ -0,0 +1,197 @@
/* ============================================================
pdf.js — single-page vector PDF writer for the print shop.
Native PDF drawing ops (no embedded raster). Uses:
• DeviceCMYK colour with a controllable rich black, so a
print shop gets proper separations rather than RGB;
• ExtGState soft-mask alpha (/ca,/CA) for true opacity on
strokes and fills;
• a base-14 Helvetica archival header (no font embedding).
Geometry comes from the same scene model as every renderer.
============================================================ */
import { makeRng } from '../rng.js';
import { sampleBubbles, trackInkWeight } from '../scene/bubbles.js';
const MARGIN = 0.02;
export function buildPDF(scene, params, pageSize = 1728) {
const scale = (pageSize / 2) * (1 - MARGIN);
const cx = pageSize / 2, cy = pageSize / 2;
const tx = (x) => (cx + x * scale);
const ty = (y) => (cy - y * scale); // PDF y-up
const u = pageSize / 1000;
const inv = params.invert;
// CMYK colours: warm cream paper, warm rich black ink (or swapped)
const paperCMYK = inv ? '0.04 0.06 0.16 0.00' : '0.30 0.30 0.32 1.00';
const inkCMYK = inv ? '0.30 0.30 0.32 0.98' : '0.03 0.05 0.14 0.00';
// ExtGState alpha registry
const gsSet = new Map();
const gs = (alpha) => {
const k = Math.max(0, Math.min(100, Math.round(alpha * 100)));
if (!gsSet.has(k)) gsSet.set(k, `GS${k}`);
return `/GS${k} gs\n`;
};
let c = '';
c += `${paperCMYK} k\n0 0 ${pageSize} ${pageSize} re f\n`;
// chamber boundary
if (params.showBoundary) {
c += `q\n${gs(0.4)}${inkCMYK} K\n${(2 * u).toFixed(2)} w\n`;
const bcx = cx, bcy = cy - pageSize * 0.35, br = pageSize * 0.45;
const a1 = Math.PI * 0.15, a2 = Math.PI - Math.PI * 0.15, steps = 90;
for (let i = 0; i <= steps; i++) {
const a = Math.PI + a1 + (a2 - a1) * (i / steps);
const px = bcx + Math.cos(a) * br, py = bcy - Math.sin(a) * br;
c += `${px.toFixed(1)} ${py.toFixed(1)} ${i === 0 ? 'm' : 'l'}\n`;
}
c += `S\nQ\n`;
}
// instrument geometry
if (scene.instrument) {
c += `q\n${inkCMYK} K\n1 J\n`;
for (const l of scene.instrument.lines)
c += `${gs(l.opacity)}${(l.width * u).toFixed(2)} w\n${tx(l.x1).toFixed(1)} ${ty(l.y1).toFixed(1)} m ${tx(l.x2).toFixed(1)} ${ty(l.y2).toFixed(1)} l S\n`;
for (const a of scene.instrument.arcs) {
c += `${gs(a.opacity)}${(a.width * u).toFixed(2)} w\n${tx(a.pts[0].x).toFixed(1)} ${ty(a.pts[0].y).toFixed(1)} m\n`;
for (let i = 1; i < a.pts.length; i++) c += `${tx(a.pts[i].x).toFixed(1)} ${ty(a.pts[i].y).toFixed(1)} l\n`;
c += `S\n`;
}
c += `Q\n`;
}
// continuity under-strokes (bucketed by alpha)
c += `q\n${inkCMYK} K\n1 J\n1 j\n`;
for (const t of scene.tracks) {
if (t.pts.length < 2) continue;
const iw = trackInkWeight(t);
const lw = Math.min(2.6, 0.25 + Math.sqrt(iw) * 0.12) * u * params.size * t.weight;
if (lw < 0.2 * u) continue;
c += `${gs(0.14 * t.weight)}${lw.toFixed(2)} w\n`;
c += `${tx(t.pts[0].x).toFixed(2)} ${ty(t.pts[0].y).toFixed(2)} m\n`;
for (let i = 1; i < t.pts.length; i++) c += `${tx(t.pts[i].x).toFixed(2)} ${ty(t.pts[i].y).toFixed(2)} l\n`;
c += `S\n`;
}
c += `Q\n`;
// shock
if (scene.shock) {
const sh = scene.shock;
const px = tx(sh.x), py = ty(sh.y);
c += `q\n${inkCMYK} K\n1 J\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;
c += `${gs(st.opacity)}${(st.width * u).toFixed(2)} w\n${ix.toFixed(1)} ${iy.toFixed(1)} m ${ox.toFixed(1)} ${oy.toFixed(1)} l S\n`;
}
for (const k of sh.core) {
c += `${gs(k.opacity)}${(k.width * u).toFixed(2)} w\n${tx(k.x1).toFixed(1)} ${ty(k.y1).toFixed(1)} m ${tx(k.x2).toFixed(1)} ${ty(k.y2).toFixed(1)} l S\n`;
}
for (const ring of sh.rings) c += `${gs(ring.opacity)}${(ring.width * u).toFixed(2)} w\n` + strokeCircle(px, py, ring.rr * scale);
c += `Q\n`;
}
// bubbles (bucketed by alpha)
const buckets = new Map();
const bRng = makeRng(params.seed, 'bubbles');
for (const t of scene.tracks) {
const key = Math.round(Math.min(1, 0.45 + t.weight * 0.5) * 20) / 20;
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key).push(...sampleBubbles(t, params, bRng));
}
c += `q\n${inkCMYK} k\n`;
for (const [alpha, bubs] of buckets) {
c += gs(alpha);
for (const b of bubs) c += circlePath(tx(b.x), ty(b.y), Math.max(b.r * scale, 0.5)) + 'f\n';
}
c += `Q\n`;
// plate damage
const A = scene.artifacts;
if (A) {
c += `q\n${inkCMYK} K\n1 J\n`;
for (const sc of A.scratches) c += `${gs(sc.opacity)}${(sc.width * u).toFixed(2)} w\n${tx(sc.x1).toFixed(1)} ${ty(sc.y1).toFixed(1)} m ${tx(sc.x2).toFixed(1)} ${ty(sc.y2).toFixed(1)} l S\n`;
for (const hair of A.hairs) {
c += `${gs(hair.opacity)}${(hair.width * u).toFixed(2)} w\n${tx(hair.pts[0].x).toFixed(1)} ${ty(hair.pts[0].y).toFixed(1)} m\n`;
for (let i = 1; i < hair.pts.length; i++) c += `${tx(hair.pts[i].x).toFixed(1)} ${ty(hair.pts[i].y).toFixed(1)} l\n`;
c += `S\n`;
}
for (const ring of A.rings) c += `${gs(ring.opacity)}${(ring.width * u).toFixed(2)} w\n` + strokeCircle(tx(ring.x), ty(ring.y), ring.r * scale);
c += `Q\nq\n${inkCMYK} k\n`;
for (const sp of A.specks) c += gs(sp.opacity) + circlePath(tx(sp.x), ty(sp.y), Math.max(sp.r * scale, 0.5)) + 'f\n';
c += `Q\n`;
}
// fiducials
if (params.showFiducials) {
c += `q\n${gs(0.55)}${inkCMYK} K\n${(1.2 * u).toFixed(2)} w\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 s = 8 * u;
for (const [fx, fy] of fids) {
const px = tx(fx), py = ty(fy);
c += `${px - s} ${py} m ${px + s} ${py} l S\n${px} ${py - s} m ${px} ${py + s} l S\n`;
}
c += `Q\n`;
}
// archival header (Helvetica)
if (params.showHeader) {
const pad = 26 * u;
c += `q\n${gs(0.6)}${inkCMYK} k\nBT\n/F0 ${(11 * u).toFixed(1)} Tf\n1 0 0 1 ${pad.toFixed(1)} ${(pageSize - pad - 11 * u).toFixed(1)} Tm (${pdfStr(scene.lab.toUpperCase())}) Tj\nET\n`;
c += `BT\n/F0 ${(9 * u).toFixed(1)} Tf\n1 0 0 1 ${pad.toFixed(1)} ${(pageSize - pad - 27 * u).toFixed(1)} Tm (SEED ${pdfStr(params.seed)}) Tj\nET\n`;
const fs = 10 * u;
const rt = (txt, yoff) => {
const wEst = txt.length * fs * 0.5;
c += `BT\n/F0 ${fs.toFixed(1)} Tf\n1 0 0 1 ${(pageSize - pad - wEst).toFixed(1)} ${yoff.toFixed(1)} Tm (${pdfStr(txt)}) Tj\nET\n`;
};
rt(`PLATE ${scene.plate}`, pad + 13 * u);
rt(`EXPOSED ${scene.exposure}`, pad);
c += `Q\n`;
}
// ExtGState dict
let extg = '';
for (const [k, name] of gsSet) extg += `/${name} << /ca ${(k / 100).toFixed(2)} /CA ${(k / 100).toFixed(2)} >> `;
return assemblePDF(c, pageSize, extg);
}
function circlePath(px, py, r) {
const k = 0.5522847498 * r;
return `${(px - r).toFixed(2)} ${py.toFixed(2)} m\n` +
`${(px - r).toFixed(2)} ${(py + k).toFixed(2)} ${(px - k).toFixed(2)} ${(py + r).toFixed(2)} ${px.toFixed(2)} ${(py + r).toFixed(2)} c\n` +
`${(px + k).toFixed(2)} ${(py + r).toFixed(2)} ${(px + r).toFixed(2)} ${(py + k).toFixed(2)} ${(px + r).toFixed(2)} ${py.toFixed(2)} c\n` +
`${(px + r).toFixed(2)} ${(py - k).toFixed(2)} ${(px + k).toFixed(2)} ${(py - r).toFixed(2)} ${px.toFixed(2)} ${(py - r).toFixed(2)} c\n` +
`${(px - k).toFixed(2)} ${(py - r).toFixed(2)} ${(px - r).toFixed(2)} ${(py - k).toFixed(2)} ${(px - r).toFixed(2)} ${py.toFixed(2)} c\n`;
}
function strokeCircle(px, py, r) {
const k = 0.5522847498 * r;
return `${(px - r).toFixed(2)} ${py.toFixed(2)} m ` +
`${(px - r).toFixed(2)} ${(py + k).toFixed(2)} ${(px - k).toFixed(2)} ${(py + r).toFixed(2)} ${px.toFixed(2)} ${(py + r).toFixed(2)} c ` +
`${(px + k).toFixed(2)} ${(py + r).toFixed(2)} ${(px + r).toFixed(2)} ${(py + k).toFixed(2)} ${(px + r).toFixed(2)} ${py.toFixed(2)} c ` +
`${(px + r).toFixed(2)} ${(py - k).toFixed(2)} ${(px + k).toFixed(2)} ${(py - r).toFixed(2)} ${px.toFixed(2)} ${(py - r).toFixed(2)} c ` +
`${(px - k).toFixed(2)} ${(py - r).toFixed(2)} ${(px - r).toFixed(2)} ${(py - k).toFixed(2)} ${(px - r).toFixed(2)} ${py.toFixed(2)} c S\n`;
}
function pdfStr(s) { return String(s).replace(/([()\\])/g, '\\$1'); }
function assemblePDF(content, pageSize, extg) {
const enc = new TextEncoder();
const contentBytes = enc.encode(content);
let body = `%PDF-1.4\n%\xC3\xA0\xC3\xA1\xC3\xA2\xC3\xA3\n`;
const offsets = [];
const addObj = (s) => { offsets.push(body.length); body += s; };
const resources = `<< /ExtGState << ${extg} >> /Font << /F0 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >>`;
addObj(`1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n`);
addObj(`2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n`);
addObj(`3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageSize} ${pageSize}] /Contents 4 0 R /Resources ${resources} >>\nendobj\n`);
addObj(`4 0 obj\n<< /Length ${contentBytes.length} >>\nstream\n${content}\nendstream\nendobj\n`);
const xref = body.length;
body += `xref\n0 5\n0000000000 65535 f \n`;
for (const o of offsets) body += String(o).padStart(10, '0') + ' 00000 n \n';
body += `trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
return new Uint8Array(enc.encode(body));
}

194
src/render/svgVector.js Normal file
View File

@@ -0,0 +1,194 @@
/* ============================================================
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 === '<' ? '&lt;' : '&amp;'));
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;
}