Files
bubblechambersimart/src/render/pdf.js

295 lines
14 KiB
JavaScript
Raw Normal View History

2026-05-20 16:53:23 -04:00
/* ============================================================
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;
2026-05-21 05:59:44 -04:00
ExtGState soft-mask alpha (/ca,/CA) for true opacity;
Optional Content Groups (/OCG) so the file opens with
toggleable LAYERS in Acrobat / Illustrator / Preview;
2026-05-20 16:53:23 -04:00
a base-14 Helvetica archival header (no font embedding).
Geometry comes from the same scene model as every renderer.
2026-05-21 05:59:44 -04:00
(Note: the disk-soften Gaussian is raster/SVG only PDF keeps
the disk crisp.)
2026-05-20 16:53:23 -04:00
============================================================ */
import { makeRng } from '../rng.js';
import { sampleBubbles, trackInkWeight, depthFactors } from '../scene/bubbles.js';
import { resolvePalette, paperTone, rgb01, rgbKey, hslToRgb, mix } from './palette.js';
2026-05-20 16:53:23 -04:00
const MARGIN = 0.02;
2026-05-21 05:59:44 -04:00
const KIND_LAYER = {
primary: 'Tracks - primary',
cosmic: 'Tracks - cosmic & sweepers',
sweep: 'Tracks - cosmic & sweepers',
vdecay: 'Tracks - V-decays',
delta: 'Tracks - delta-rays',
};
const TRACK_ORDER = ['Tracks - primary', 'Tracks - cosmic & sweepers', 'Tracks - V-decays', 'Tracks - delta-rays'];
2026-05-20 16:53:23 -04:00
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;
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';
// palette: mono keeps CMYK rich black (print-grade); colour feels use RGB for
// the particle trails. Features/paper stay CMYK.
const mono = (params.palette || 'mono') === 'mono';
const pt = paperTone(params, inv);
const pal = resolvePalette(params.palette, {
inv, sat: params.saturation ?? 1, hue: (params.hueShift ?? 0) * 360, cycles: params.hueCycles ?? 3,
baseInk: inv ? [28, 24, 20] : [233, 228, 214],
basePaper: { flat: pt.flat, glowIn: pt.glowIn, glowOut: pt.glowOut }, baseVign: [0, 0, 0],
});
// paper is CMYK at the default cream (print-grade rich); toned papers or palette
// chemistries (cyanotype) emit RGB
const toned = pal.hasPaper || (params.paperTone && params.paperTone !== 'cream') || (params.toneStrength ?? 1) !== 1 || (params.paperBright ?? 1) !== 1 || (params.glow ?? 0.5) !== 0.5;
const paperOp = toned ? `${rgb01(pal.paper().flat).join(' ')} rg` : `${paperCMYK} k`;
const bubbleFill = (track, b) => {
if (mono) return { op: `${inkCMYK} k`, key: 'k' };
const c = pal.bubbleInk(track, b.life, b.beta);
return { op: `${rgb01(c).join(' ')} rg`, key: rgbKey(c) };
};
const trackStroke = (track) => mono
? `${inkCMYK} K\n`
: `${rgb01(pal.ink(track)).join(' ')} RG\n`;
2026-05-20 16:53:23 -04:00
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`;
};
2026-05-21 05:59:44 -04:00
// optional-content layer accumulation
const ocgNames = [];
let content = '';
const emit = (name, ops) => {
if (!ops || !ops.trim()) return;
const i = ocgNames.length;
ocgNames.push(name);
content += `/OC /OC${i} BDC\n${ops}EMC\n`;
};
2026-05-20 16:53:23 -04:00
2026-05-21 05:59:44 -04:00
/* ---------- Background ---------- */
emit('Background', `${paperOp}\n0 0 ${pageSize} ${pageSize} re f\n`);
2026-05-20 16:53:23 -04:00
2026-05-21 05:59:44 -04:00
/* ---------- Chamber optics ---------- */
{
let o = '';
if (params.showBoundary) {
o += `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;
o += `${px.toFixed(1)} ${py.toFixed(1)} ${i === 0 ? 'm' : 'l'}\n`;
}
o += `S\nQ\n`;
}
if (scene.instrument) {
o += `q\n${inkCMYK} K\n1 J\n`;
for (const l of scene.instrument.lines)
o += `${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) {
o += `${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++) o += `${tx(a.pts[i].x).toFixed(1)} ${ty(a.pts[i].y).toFixed(1)} l\n`;
o += `S\n`;
}
o += `Q\n`;
}
emit('Chamber optics', o);
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
/* ---------- Shock disk ---------- */
2026-05-20 16:53:23 -04:00
if (scene.shock) {
2026-05-21 05:59:44 -04:00
const sh = scene.shock, px = tx(sh.x), py = ty(sh.y);
let o = '';
2026-05-20 17:10:32 -04:00
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));
}
2026-05-21 05:59:44 -04:00
o += `q\n${inkCMYK} k\n`;
2026-05-20 17:10:32 -04:00
for (const [alpha, bubs] of dbk) {
2026-05-21 05:59:44 -04:00
o += gs(alpha);
for (const b of bubs) o += circlePath(tx(b.x), ty(b.y), Math.max(b.r * scale, 0.5)) + 'f\n';
2026-05-20 17:10:32 -04:00
}
2026-05-21 05:59:44 -04:00
o += `Q\n`;
2026-05-20 17:10:32 -04:00
} else {
// optionally iridescent — per-element RGB stroke across the sunburst
const spec = params.diskSpectrum || 0;
const featRGB = pal.feature();
const TWO_PI = Math.PI * 2;
const sStroke = (frac) => spec <= 0 ? `${inkCMYK} K\n`
: `${rgb01(mix(featRGB, hslToRgb((((frac + (params.hueShift || 0)) % 1) + 1) % 1, 0.85 * (params.saturation ?? 1), 0.52), spec)).join(' ')} RG\n`;
o += `q\n1 J\n`;
2026-05-20 17:10:32 -04:00
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;
o += `${sStroke(st.a / TWO_PI)}${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`;
2026-05-20 17:10:32 -04:00
}
for (const k of sh.core) {
const ca = Math.atan2(k.y1 - sh.y, k.x1 - sh.x);
o += `${sStroke(ca / TWO_PI)}${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) o += `${sStroke(ring.rr / sh.r)}${gs(ring.opacity)}${(ring.width * u).toFixed(2)} w\n` + strokeCircle(px, py, ring.rr * scale);
for (const seg of (sh.rimSegs || [])) o += `${sStroke(seg.a0 / TWO_PI)}${gs(seg.opacity)}${(seg.width * u).toFixed(2)} w\n` + arcStroke(px, py, sh.r * scale, -seg.a0, -seg.a1);
2026-05-21 05:59:44 -04:00
o += `Q\n`;
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
emit('Shock disk', o);
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
/* ---------- Tracks (split by kind; bubble rng order preserved) ---------- */
const under = new Map(); // layer -> stroke ops (per-track colour set inline)
const buck = new Map(); // layer -> Map(colourKey|alpha -> {op, alpha, arr})
2026-05-20 16:53:23 -04:00
const bRng = makeRng(params.seed, 'bubbles');
for (const t of scene.tracks) {
2026-05-21 05:59:44 -04:00
const name = KIND_LAYER[t.kind] || 'Tracks - primary';
if (!under.has(name)) { under.set(name, ''); buck.set(name, new Map()); }
const df = depthFactors(t, params);
2026-05-21 05:59:44 -04:00
if (t.pts.length >= 2) {
const lw = Math.min(2.6, 0.25 + Math.sqrt(trackInkWeight(t)) * 0.12) * u * params.size * t.weight;
if (lw >= 0.2 * u) {
let su = `${trackStroke(t)}${gs(0.14 * df.tone)}${lw.toFixed(2)} w\n${tx(t.pts[0].x).toFixed(2)} ${ty(t.pts[0].y).toFixed(2)} m\n`;
2026-05-21 05:59:44 -04:00
for (let i = 1; i < t.pts.length; i++) su += `${tx(t.pts[i].x).toFixed(2)} ${ty(t.pts[i].y).toFixed(2)} l\n`;
under.set(name, under.get(name) + su + `S\n`);
}
}
const alpha = Math.round(Math.min(1, 0.36 + df.tone * 0.58) * 20) / 20;
2026-05-21 05:59:44 -04:00
const m = buck.get(name);
for (const b of sampleBubbles({ ...t, sizeScale: df.sizeScale }, params, bRng)) {
const fill = bubbleFill(t, b);
const bkey = fill.key + '|' + alpha;
if (!m.has(bkey)) m.set(bkey, { op: fill.op, alpha, arr: [] });
m.get(bkey).arr.push(circlePath(tx(b.x), ty(b.y), Math.max(b.r * scale, 0.5)) + 'f\n');
}
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
for (const name of TRACK_ORDER) {
if (!under.has(name)) continue;
let o = '';
const us = under.get(name);
if (us) o += `q\n1 J\n1 j\n${us}Q\n`;
2026-05-21 05:59:44 -04:00
let bub = '';
for (const { op, alpha, arr } of buck.get(name).values()) if (arr.length) bub += `${op}\n${gs(alpha)}${arr.join('')}`;
if (bub) o += `q\n${bub}Q\n`;
2026-05-21 05:59:44 -04:00
emit(name, o);
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
/* ---------- Plate damage ---------- */
2026-05-20 16:53:23 -04:00
const A = scene.artifacts;
if (A) {
2026-05-21 05:59:44 -04:00
let o = `q\n${inkCMYK} K\n1 J\n`;
for (const sc of A.scratches) o += `${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`;
2026-05-20 16:53:23 -04:00
for (const hair of A.hairs) {
2026-05-21 05:59:44 -04:00
o += `${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++) o += `${tx(hair.pts[i].x).toFixed(1)} ${ty(hair.pts[i].y).toFixed(1)} l\n`;
o += `S\n`;
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
for (const ring of A.rings) o += `${gs(ring.opacity)}${(ring.width * u).toFixed(2)} w\n` + strokeCircle(tx(ring.x), ty(ring.y), ring.r * scale);
o += `Q\nq\n${inkCMYK} k\n`;
for (const sp of A.specks) o += gs(sp.opacity) + circlePath(tx(sp.x), ty(sp.y), Math.max(sp.r * scale, 0.5)) + 'f\n';
o += `Q\n`;
emit('Plate damage', o);
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
/* ---------- Fiducials ---------- */
2026-05-20 16:53:23 -04:00
if (params.showFiducials) {
2026-05-21 05:59:44 -04:00
let o = `q\n${gs(0.55)}${inkCMYK} K\n${(1.2 * u).toFixed(2)} w\n`;
2026-05-20 16:53:23 -04:00
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);
2026-05-21 05:59:44 -04:00
o += `${px - s} ${py} m ${px + s} ${py} l S\n${px} ${py - s} m ${px} ${py + s} l S\n`;
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
o += `Q\n`;
emit('Fiducials', o);
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
/* ---------- Archival header ---------- */
2026-05-20 16:53:23 -04:00
if (params.showHeader) {
const pad = 26 * u;
2026-05-21 05:59:44 -04:00
let o = `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`;
o += `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`;
2026-05-20 16:53:23 -04:00
const fs = 10 * u;
const rt = (txt, yoff) => {
const wEst = txt.length * fs * 0.5;
2026-05-21 05:59:44 -04:00
o += `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`;
2026-05-20 16:53:23 -04:00
};
rt(`PLATE ${scene.plate}`, pad + 13 * u);
rt(`EXPOSED ${scene.exposure}`, pad);
2026-05-21 05:59:44 -04:00
o += `Q\n`;
emit('Archival header', o);
2026-05-20 16:53:23 -04:00
}
let extg = '';
for (const [k, name] of gsSet) extg += `/${name} << /ca ${(k / 100).toFixed(2)} /CA ${(k / 100).toFixed(2)} >> `;
2026-05-21 05:59:44 -04:00
return assemblePDF(content, pageSize, extg, ocgNames);
}
/* a stroked arc approximated by line segments (for rim segments) */
function arcStroke(px, py, r, a0, a1) {
const steps = Math.max(2, Math.round(Math.abs(a1 - a0) / 0.12));
let s = '';
for (let i = 0; i <= steps; i++) {
const a = a0 + (a1 - a0) * (i / steps);
s += `${(px + Math.cos(a) * r).toFixed(1)} ${(py + Math.sin(a) * r).toFixed(1)} ${i === 0 ? 'm' : 'l'}\n`;
}
return s + 'S\n';
2026-05-20 16:53:23 -04:00
}
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'); }
2026-05-21 05:59:44 -04:00
function assemblePDF(content, pageSize, extg, ocgNames) {
2026-05-20 16:53:23 -04:00
const enc = new TextEncoder();
const contentBytes = enc.encode(content);
2026-05-21 05:59:44 -04:00
let body = `%PDF-1.5\n%\xC3\xA0\xC3\xA1\xC3\xA2\xC3\xA3\n`;
2026-05-20 16:53:23 -04:00
const offsets = [];
const addObj = (s) => { offsets.push(body.length); body += s; };
2026-05-21 05:59:44 -04:00
const ocgRefs = ocgNames.map((_, i) => `${5 + i} 0 R`).join(' ');
const props = ocgNames.map((_, i) => `/OC${i} ${5 + i} 0 R`).join(' ');
const resources = `<< /ExtGState << ${extg} >> /Font << /F0 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /Properties << ${props} >> >>`;
addObj(`1 0 obj\n<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [${ocgRefs}] /D << /Order [${ocgRefs}] >> >> >>\nendobj\n`);
2026-05-20 16:53:23 -04:00
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`);
2026-05-21 05:59:44 -04:00
ocgNames.forEach((name) => addObj(`${offsets.length + 1} 0 obj\n<< /Type /OCG /Name (${pdfStr(name)}) >>\nendobj\n`));
const size = 4 + ocgNames.length + 1;
2026-05-20 16:53:23 -04:00
const xref = body.length;
2026-05-21 05:59:44 -04:00
body += `xref\n0 ${size}\n0000000000 65535 f \n`;
2026-05-20 16:53:23 -04:00
for (const o of offsets) body += String(o).padStart(10, '0') + ' 00000 n \n';
2026-05-21 05:59:44 -04:00
body += `trailer\n<< /Size ${size} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
2026-05-20 16:53:23 -04:00
return new Uint8Array(enc.encode(body));
}