blur layers

This commit is contained in:
2026-05-21 05:59:44 -04:00
parent 3983cf2e0d
commit 687a968522
42 changed files with 2895070 additions and 920162 deletions

View File

@@ -1306,57 +1306,67 @@ function drawShock(c, shock, tx, ty, scale, u, P, params, sprite) {
const px = tx(shock.x), py = ty(shock.y), R = shock.r * scale;
const useBubbles = params.diskBubbles !== false;
// when softening, draw the whole disk to its own offscreen and composite it
// back through a single Gaussian blur — softens just this layer's edges.
const soften = (params.diskSoften || 0) * u;
let T = c, tmp = null;
if (soften > 0) {
tmp = document.createElement('canvas');
tmp.width = c.canvas.width; tmp.height = c.canvas.height;
T = tmp.getContext('2d');
}
// disk body: dark annulus that keeps a lighter, detailed centre.
// softer when the line work is bubbles (the bubbles carry the density).
const bodyK = useBubbles ? 0.6 : 1.0;
const core = c.createRadialGradient(px, py, 0, px, py, R);
const core = T.createRadialGradient(px, py, 0, px, py, R);
core.addColorStop(0.0, `rgba(${r},${g},${b},${0.06 * shock.intensity * bodyK})`);
core.addColorStop(0.35, `rgba(${r},${g},${b},${0.18 * shock.intensity * bodyK})`);
core.addColorStop(0.72, `rgba(${r},${g},${b},${0.38 * shock.intensity * bodyK})`);
core.addColorStop(0.94, `rgba(${r},${g},${b},${0.34 * shock.intensity * bodyK})`);
core.addColorStop(1, `rgba(${r},${g},${b},0)`);
c.fillStyle = core;
c.beginPath(); c.arc(px, py, R, 0, Math.PI * 2); c.fill();
T.fillStyle = core;
T.beginPath(); T.arc(px, py, R, 0, Math.PI * 2); T.fill();
if (useBubbles && shock.bubbleStrokes) {
// describe striations / rings / rim / core with the particle method
const dRng = makeRng(params.seed, 'diskbubbles');
for (const stroke of shock.bubbleStrokes) {
const bubs = sampleBubbles(stroke, params, dRng);
c.globalAlpha = Math.min(1, 0.45 + stroke.weight * 0.5);
T.globalAlpha = Math.min(1, 0.45 + stroke.weight * 0.5);
for (const bb of bubs) {
const rr = Math.max(bb.r * scale, 0.45);
const d = rr * 2.4;
c.drawImage(sprite, tx(bb.x) - d / 2, ty(bb.y) - d / 2, d, d);
T.drawImage(sprite, tx(bb.x) - d / 2, ty(bb.y) - d / 2, d, d);
}
}
c.globalAlpha = 1;
T.globalAlpha = 1;
} else {
// legacy: clean vector strokes
c.lineCap = 'round';
// clean vector strokes
T.lineCap = 'round';
for (const s of shock.striations) {
const ix = px + Math.cos(s.a) * s.inner * scale, iy = py + Math.sin(s.a) * s.inner * scale;
const ox = px + Math.cos(s.a) * s.outer * scale, 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();
T.strokeStyle = `rgba(${r},${g},${b},${s.opacity})`;
T.lineWidth = s.width * u;
T.beginPath(); T.moveTo(ix, iy); T.quadraticCurveTo(mx, my, ox, oy); T.stroke();
}
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();
T.strokeStyle = `rgba(${r},${g},${b},${ring.opacity})`;
T.lineWidth = ring.width * u;
T.beginPath(); T.arc(px, py, ring.rr * scale, 0, Math.PI * 2); T.stroke();
}
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();
T.strokeStyle = `rgba(${r},${g},${b},${seg.opacity})`;
T.lineWidth = seg.width * u;
T.beginPath(); T.arc(px, py, shock.r * scale, seg.a0, seg.a1); T.stroke();
}
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();
T.strokeStyle = `rgba(${r},${g},${b},${k.opacity})`;
T.lineWidth = k.width * u;
T.beginPath(); T.moveTo(tx(k.x1), ty(k.y1)); T.lineTo(tx(k.x2), ty(k.y2)); T.stroke();
}
}
@@ -1364,32 +1374,41 @@ function drawShock(c, shock, tx, ty, scale, u, P, params, sprite) {
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);
const grad = T.createRadialGradient(tx(st.x), ty(st.y), 0, tx(st.x), ty(st.y), sr);
if (st.dark) {
c.globalCompositeOperation = 'source-over';
T.globalCompositeOperation = 'source-over';
grad.addColorStop(0, `rgba(${r},${g},${b},${st.opacity})`);
grad.addColorStop(1, `rgba(${r},${g},${b},0)`);
c.fillStyle = grad;
T.fillStyle = grad;
} else {
c.globalCompositeOperation = 'destination-out';
T.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;
T.fillStyle = grad;
}
c.beginPath(); c.arc(tx(st.x), ty(st.y), sr, 0, Math.PI * 2); c.fill();
T.beginPath(); T.arc(tx(st.x), ty(st.y), sr, 0, Math.PI * 2); T.fill();
}
c.globalCompositeOperation = 'source-over';
T.globalCompositeOperation = 'source-over';
}
// keep a bright, detailed centre by clearing a soft hole in the ink
// keep a bright, detailed centre by clearing a soft hole
if (shock.bright) {
c.save();
c.globalCompositeOperation = 'destination-out';
const hole = c.createRadialGradient(px, py, 0, px, py, shock.bright * scale);
T.save();
T.globalCompositeOperation = 'destination-out';
const hole = T.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();
T.fillStyle = hole;
T.beginPath(); T.arc(px, py, shock.bright * scale, 0, Math.PI * 2); T.fill();
T.restore();
}
// composite the (optionally blurred) disk back onto the ink layer
if (tmp) {
c.save();
c.filter = `blur(${soften.toFixed(2)}px)`;
c.drawImage(tmp, 0, 0);
c.filter = 'none';
c.restore();
}
}
@@ -1692,12 +1711,25 @@ Object.assign(exports, { fbm, mottleCanvas, grainCanvas });
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.
============================================================ */
const { makeRng, cyrb53 } = __require("src/rng.js");
const { sampleBubbles, trackInkWeight } = __require("src/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'] },
];
function renderSVG(scene, params, sizePx = 4800) {
const w = sizePx, h = sizePx;
const scale = (w / 2) * (1 - MARGIN);
@@ -1711,12 +1743,196 @@ function renderSVG(scene, params, sizePx = 4800) {
const glowIn = inv ? '#e2dbc7' : '#211e18';
const glowOut = inv ? '#b3aa92' : '#070605';
const ink = inv ? '#1c1814' : '#e9e4d6';
const inkRGB = inv ? '28,24,20' : '233,228,214';
// wrap content as a named, Inkscape/Affinity-compatible layer (label is an
// XML attribute → escape entities)
const attr = (t) => String(t).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
const layer = (id, label, content, extra = '') =>
content ? `<g id="${id}" inkscape:groupmode="layer" inkscape:label="${attr(label)}" style="display:inline"${extra ? ' ' + extra : ''}>\n${content}\n</g>\n` : '';
/* ---------- Background ---------- */
const bg = `<rect width="${w}" height="${h}" fill="${paper}"/>\n<rect width="${w}" height="${h}" fill="url(#paper)"/>`;
/* ---------- 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 += `<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`;
}
if (scene.instrument) {
const inst = scene.instrument;
let g = `<g fill="none" stroke="${ink}" stroke-linecap="round">`;
for (const l of inst.lines)
g += `<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)}`;
g += `<path d="${d}" stroke-opacity="${a.opacity.toFixed(3)}" stroke-width="${(a.width * u).toFixed(2)}"/>`;
}
optics += g + `</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 += `<circle cx="${px}" cy="${py}" r="${R.toFixed(1)}" fill="url(#shockcore)" fill-opacity="${bodyOpacity}"/>\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 += `<g fill="url(#bub)" fill-opacity="${alpha}">`;
for (const b of bubs) shock += `<circle cx="${tx(b.x)}" cy="${ty(b.y)}" r="${Math.max(b.r * scale * 1.15, 0.5).toFixed(2)}"/>`;
shock += `</g>\n`;
}
} else {
let g = `<g stroke="${ink}" stroke-linecap="round" fill="none">`;
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 += `<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)
g += `<circle cx="${px}" cy="${py}" r="${(ring.rr * scale).toFixed(1)}" stroke-opacity="${ring.opacity.toFixed(3)}" stroke-width="${(ring.width * u).toFixed(2)}"/>`;
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 += `<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)
g += `<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)}"/>`;
shock += g + `</g>\n`;
}
let st2 = '';
for (const st of (sh.stains || [])) {
const sr = (st.r * scale).toFixed(1);
st2 += `<circle cx="${tx(st.x)}" cy="${ty(st.y)}" r="${sr}" fill="url(#${st.dark ? 'shockstain' : 'shockclean'})" fill-opacity="${st.opacity.toFixed(3)}"/>`;
}
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) + `<path d="${d}" stroke-opacity="${(0.14 * track.weight).toFixed(3)}" stroke-width="${lw.toFixed(2)}"/>`);
}
// 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(`<circle cx="${tx(b.x)}" cy="${ty(b.y)}" r="${Math.max(b.r * scale * 1.15, 0.5).toFixed(2)}"/>`);
}
}
const trackLayers = TRACK_LAYERS.map(L => {
if (!under.has(L.id)) return '';
let content = '';
const us = under.get(L.id);
if (us) content += `<g fill="none" stroke="${ink}" stroke-linecap="round" stroke-linejoin="round">${us}</g>\n`;
for (const [alpha, circles] of buckets.get(L.id)) {
if (circles.length) content += `<g fill="url(#bub)" fill-opacity="${alpha}">${circles.join('')}</g>\n`;
}
return layer(L.id, L.label, content);
}).join('');
/* ---------- Plate damage ---------- */
let damage = '';
const A = scene.artifacts;
if (A) {
let g = `<g stroke="${ink}" fill="none" stroke-linecap="round">`;
for (const ring of A.rings)
g += `<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)
g += `<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)}`;
g += `<path d="${d}" stroke-opacity="${hair.opacity.toFixed(3)}" stroke-width="${(hair.width * u).toFixed(2)}"/>`;
}
g += `</g><g fill="${ink}">`;
for (const sp of A.specks)
g += `<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)}"/>`;
damage = g + `</g>`;
}
/* ---------- Fiducials ---------- */
let fids = '';
if (params.showFiducials) {
let g = `<g stroke="${ink}" stroke-opacity="0.55" stroke-width="${1.2 * u}">`;
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 += `<line x1="${px - sz}" y1="${py}" x2="${px + sz}" y2="${py}"/><line x1="${px}" y1="${py - sz}" x2="${px}" y2="${py + sz}"/>`;
}
fids = g + `</g>`;
}
/* ---------- Vignette ---------- */
const vign = params.vign > 0 ? `<rect width="${w}" height="${h}" fill="url(#vign)"/>` : '';
/* ---------- Header ---------- */
let header = '';
if (params.showHeader) {
const pad = 26 * u;
const esc = (t) => String(t).replace(/[<&]/g, c => (c === '<' ? '&lt;' : '&amp;'));
header = `<g fill="${ink}" font-family="'JetBrains Mono', monospace">`
+ `<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>`
+ `<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>`
+ `<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>`
+ `<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>`
+ `</g>`;
}
/* ---------- Assemble ---------- */
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 += `<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 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>
s += defs(inv, paper, glowIn, glowOut, ink, params, u);
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 += `</svg>\n`;
return s;
}
function defs(inv, paper, glowIn, glowOut, ink, params, u) {
const soften = params.diskSoften > 0
? `<filter id="soften" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="${(params.diskSoften * u).toFixed(2)}"/></filter>`
: '';
return `<defs>
${soften}
<radialGradient id="paper" cx="50%" cy="42%" r="72%">
<stop offset="0%" stop-color="${glowIn}"/><stop offset="100%" stop-color="${glowOut}"/>
</radialGradient>
@@ -1743,158 +1959,6 @@ function renderSVG(scene, params, sizePx = 4800) {
<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;
const bodyOpacity = (params.diskBubbles !== false) ? 0.6 : 1;
s += `<circle cx="${px}" cy="${py}" r="${R.toFixed(1)}" fill="url(#shockcore)" fill-opacity="${bodyOpacity}"/>\n`;
if (params.diskBubbles !== false && sh.bubbleStrokes) {
// describe the disk line work with bubbles (same method as tracks)
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) {
s += `<g fill="url(#bub)" fill-opacity="${alpha}">`;
for (const b of bubs) s += `<circle cx="${tx(b.x)}" cy="${ty(b.y)}" r="${Math.max(b.r * scale * 1.15, 0.5).toFixed(2)}"/>`;
s += `</g>\n`;
}
} else {
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)}"/>`;
}
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;
}
Object.assign(exports, { renderSVG });
@@ -1906,16 +1970,28 @@ Object.assign(exports, { renderSVG });
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;
• ExtGState soft-mask alpha (/ca,/CA) for true opacity;
• Optional Content Groups (/OCG) so the file opens with
toggleable LAYERS in Acrobat / Illustrator / Preview;
• a base-14 Helvetica archival header (no font embedding).
Geometry comes from the same scene model as every renderer.
(Note: the disk-soften Gaussian is raster/SVG only — PDF keeps
the disk crisp.)
============================================================ */
const { makeRng } = __require("src/rng.js");
const { sampleBubbles, trackInkWeight } = __require("src/scene/bubbles.js");
const MARGIN = 0.02;
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'];
function buildPDF(scene, params, pageSize = 1728) {
const scale = (pageSize / 2) * (1 - MARGIN);
const cx = pageSize / 2, cy = pageSize / 2;
@@ -1924,11 +2000,9 @@ function buildPDF(scene, params, pageSize = 1728) {
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)));
@@ -1936,55 +2010,52 @@ function buildPDF(scene, params, pageSize = 1728) {
return `/GS${k} gs\n`;
};
let c = '';
c += `${paperCMYK} k\n0 0 ${pageSize} ${pageSize} re f\n`;
// 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`;
};
// 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`;
/* ---------- Background ---------- */
emit('Background', `${paperCMYK} k\n0 0 ${pageSize} ${pageSize} re f\n`);
/* ---------- 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`;
}
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`;
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`;
}
c += `Q\n`;
emit('Chamber optics', o);
}
// 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
/* ---------- Shock disk ---------- */
if (scene.shock) {
const sh = scene.shock;
const px = tx(sh.x), py = ty(sh.y);
const sh = scene.shock, px = tx(sh.x), py = ty(sh.y);
let o = '';
if (params.diskBubbles !== false && sh.bubbleStrokes) {
// disk line work as bubbles (same method as tracks)
const dRng = makeRng(params.seed, 'diskbubbles');
const dbk = new Map();
for (const stroke of sh.bubbleStrokes) {
@@ -1992,90 +2063,121 @@ function buildPDF(scene, params, pageSize = 1728) {
if (!dbk.has(key)) dbk.set(key, []);
dbk.get(key).push(...sampleBubbles(stroke, params, dRng));
}
c += `q\n${inkCMYK} k\n`;
o += `q\n${inkCMYK} k\n`;
for (const [alpha, bubs] of dbk) {
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';
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';
}
c += `Q\n`;
o += `Q\n`;
} else {
c += `q\n${inkCMYK} K\n1 J\n`;
o += `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`;
o += `${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`;
for (const k of sh.core)
o += `${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 += `${gs(ring.opacity)}${(ring.width * u).toFixed(2)} w\n` + strokeCircle(px, py, ring.rr * scale);
for (const seg of (sh.rimSegs || [])) o += `${gs(seg.opacity)}${(seg.width * u).toFixed(2)} w\n` + arcStroke(px, py, sh.r * scale, -seg.a0, -seg.a1);
o += `Q\n`;
}
emit('Shock disk', o);
}
// bubbles (bucketed by alpha)
const buckets = new Map();
/* ---------- Tracks (split by kind; bubble rng order preserved) ---------- */
const under = new Map(); // layer -> stroke ops
const buck = new Map(); // layer -> Map(alpha -> circle ops[])
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));
const name = KIND_LAYER[t.kind] || 'Tracks - primary';
if (!under.has(name)) { under.set(name, ''); buck.set(name, new Map()); }
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 = `${gs(0.14 * t.weight)}${lw.toFixed(2)} w\n${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++) 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.45 + t.weight * 0.5) * 20) / 20;
const m = buck.get(name);
if (!m.has(alpha)) m.set(alpha, []);
const arr = m.get(alpha);
for (const b of sampleBubbles(t, params, bRng)) arr.push(circlePath(tx(b.x), ty(b.y), Math.max(b.r * scale, 0.5)) + 'f\n');
}
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';
for (const name of TRACK_ORDER) {
if (!under.has(name)) continue;
let o = '';
const us = under.get(name);
if (us) o += `q\n${inkCMYK} K\n1 J\n1 j\n${us}Q\n`;
let bub = '';
for (const [alpha, arr] of buck.get(name)) if (arr.length) bub += gs(alpha) + arr.join('');
if (bub) o += `q\n${inkCMYK} k\n${bub}Q\n`;
emit(name, o);
}
c += `Q\n`;
// plate damage
/* ---------- 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`;
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`;
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`;
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`;
}
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`;
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);
}
// fiducials
/* ---------- Fiducials ---------- */
if (params.showFiducials) {
c += `q\n${gs(0.55)}${inkCMYK} K\n${(1.2 * u).toFixed(2)} w\n`;
let o = `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`;
o += `${px - s} ${py} m ${px + s} ${py} l S\n${px} ${py - s} m ${px} ${py + s} l S\n`;
}
c += `Q\n`;
o += `Q\n`;
emit('Fiducials', o);
}
// archival header (Helvetica)
/* ---------- Archival header ---------- */
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`;
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`;
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`;
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`;
};
rt(`PLATE ${scene.plate}`, pad + 13 * u);
rt(`EXPOSED ${scene.exposure}`, pad);
c += `Q\n`;
o += `Q\n`;
emit('Archival header', o);
}
// 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);
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';
}
function circlePath(px, py, r) {
@@ -2098,21 +2200,28 @@ function strokeCircle(px, py, r) {
function pdfStr(s) { return String(s).replace(/([()\\])/g, '\\$1'); }
function assemblePDF(content, pageSize, extg) {
function assemblePDF(content, pageSize, extg, ocgNames) {
const enc = new TextEncoder();
const contentBytes = enc.encode(content);
let body = `%PDF-1.4\n%\xC3\xA0\xC3\xA1\xC3\xA2\xC3\xA3\n`;
let body = `%PDF-1.5\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`);
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`);
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`);
ocgNames.forEach((name) => addObj(`${offsets.length + 1} 0 obj\n<< /Type /OCG /Name (${pdfStr(name)}) >>\nendobj\n`));
const size = 4 + ocgNames.length + 1;
const xref = body.length;
body += `xref\n0 5\n0000000000 65535 f \n`;
body += `xref\n0 ${size}\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`;
body += `trailer\n<< /Size ${size} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
return new Uint8Array(enc.encode(body));
}
@@ -2161,6 +2270,7 @@ const GROUPS = [
{ id: 'shockSize', label: 'Size', min: 0.1, max: 0.6, step: 0.01, value: 0.3, mode: 'scene' },
{ id: 'shockStriations', label: 'Striation density', min: 0, max: 1, step: 0.01, value: 0.5, mode: 'scene' },
{ id: 'shockStain', label: 'Staining / erosion', min: 0, max: 1, step: 0.01, value: 0.35, mode: 'scene' },
{ id: 'diskSoften', label: 'Disk edge softness', min: 0, max: 3, step: 0.05, value: 0.7, mode: 'render' },
{ id: 'shockY', label: 'Vertical position', min: -0.7, max: 0.7, step: 0.01, value: 0.52, mode: 'scene' },
],
},
@@ -2193,7 +2303,7 @@ const GROUPS = [
const TOGGLES = [
{ id: 'shock', label: 'Shock-wave disk', value: true, mode: 'scene' },
{ id: 'diskBubbles', label: 'Disk as bubbles', value: true, mode: 'scene' },
{ id: 'diskBubbles', label: 'Disk as bubbles', value: false, mode: 'scene' },
{ id: 'showFiducials', label: 'Fiducial marks', value: true, mode: 'render' },
{ id: 'showBoundary', label: 'Chamber boundary', value: true, mode: 'render' },
{ id: 'showHeader', label: 'Archival header', value: true, mode: 'render' },
@@ -2289,9 +2399,10 @@ function paramsFromSeed(seed) {
cosmics: ri(4, 10), sweepers: ri(2, 7),
bfield: r(0.8, 1.6), eloss: r(0.45, 0.75), pspread: r(0.6, 0.85),
deltaRate: r(0.6, 0.95), deltaTight: r(0.6, 1.0),
shock: true, diskBubbles: true, shockIntensity: r(0.6, 0.9), shockSize: r(0.26, 0.4),
shock: true, diskBubbles: false, shockIntensity: r(0.6, 0.9), shockSize: r(0.26, 0.4),
shockStriations: r(0.45, 0.85), shockY: r(0.4, 0.6), shockX: r(-0.12, 0.12),
shockStain: Math.pow(rng(), 1.5), // skew cleaner, allow grime
diskSoften: r(0.4, 1.1), // gaussian softening of disk edges
instrument: r(0.25, 0.6),
bgEvents: ri(3, 8), bgIntensity: r(0.35, 0.5),
density: r(1.0, 1.35), size: r(0.9, 1.15),