Files
bubblechambersimart/bubble_chamber.html
2026-05-20 17:10:32 -04:00

2345 lines
96 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Bubble Chamber — parametric generator</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0a0a0a; --panel: #141414; --panel-2: #1c1c1c; --line: #2a2a2a;
--ink: #e8e4d8; --ink-dim: #8a8578; --ink-mute: #555049;
--accent: #d4a574; --warn: #c87a4a;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
body {
background: var(--bg); color: var(--ink);
font-family: 'JetBrains Mono', monospace; font-size: 12px;
display: grid; grid-template-columns: 330px 1fr; height: 100vh;
}
/* ---------- Sidebar ---------- */
aside { background: var(--panel); border-right: 1px solid var(--line); overflow-y: auto; padding: 20px 18px; }
aside::-webkit-scrollbar { width: 6px; }
aside::-webkit-scrollbar-thumb { background: var(--line); }
.brand { font-family: 'Cormorant Garamond', serif; font-style: italic; font-size: 22px; letter-spacing: 0.02em; margin-bottom: 2px; }
.subtitle { font-size: 10px; text-transform: uppercase; letter-spacing: 0.2em; color: var(--ink-mute); margin-bottom: 22px; }
.group { border-top: 1px solid var(--line); padding: 16px 0 8px; }
.group-title { font-size: 9px; text-transform: uppercase; letter-spacing: 0.25em; color: var(--accent); margin-bottom: 14px; }
.row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; gap: 10px; }
.row label { font-size: 11px; color: var(--ink-dim); flex: 1; }
.row .val { font-size: 11px; color: var(--ink); min-width: 50px; text-align: right; font-variant-numeric: tabular-nums; }
input[type=range] { width: 100%; -webkit-appearance: none; appearance: none; background: transparent; height: 18px; margin-bottom: 6px; }
input[type=range]::-webkit-slider-runnable-track { height: 1px; background: var(--line); }
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; height: 10px; width: 10px; background: var(--ink); border-radius: 0; margin-top: -4px; cursor: ew-resize; transition: background 0.15s; }
input[type=range]:hover::-webkit-slider-thumb { background: var(--accent); }
input[type=range]::-moz-range-track { height: 1px; background: var(--line); }
input[type=range]::-moz-range-thumb { height: 10px; width: 10px; background: var(--ink); border: none; border-radius: 0; cursor: ew-resize; }
.seed-row { display: grid; grid-template-columns: 1fr auto; gap: 6px; align-items: center; }
.seed-input, select.preset {
background: var(--panel-2); border: 1px solid var(--line); color: var(--ink);
padding: 8px 10px; font-family: 'JetBrains Mono', monospace; font-size: 11px; width: 100%; letter-spacing: 0.05em;
}
.seed-input:focus, select.preset:focus { outline: none; border-color: var(--accent); }
select.preset { margin-top: 8px; cursor: pointer; }
button {
background: transparent; border: 1px solid var(--line); color: var(--ink);
padding: 8px 12px; font-family: 'JetBrains Mono', monospace; font-size: 10px;
text-transform: uppercase; letter-spacing: 0.15em; cursor: pointer; transition: all 0.15s;
}
button:hover { border-color: var(--accent); color: var(--accent); }
button.primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
button.primary:hover { background: var(--accent); border-color: var(--accent); color: var(--bg); }
.btn-row { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 8px; }
.btn-row-full { display: grid; gap: 6px; margin-top: 8px; }
.checkbox-row { display: flex; align-items: center; gap: 8px; margin: 4px 0 8px; cursor: pointer; }
.checkbox-row input { accent-color: var(--accent); }
.checkbox-row span { font-size: 11px; color: var(--ink-dim); }
/* ---------- Stage ---------- */
main { position: relative; background: #050505; overflow: hidden; display: flex; align-items: center; justify-content: center; }
.stage-frame { position: relative; background: #0a0a0a; box-shadow: 0 30px 80px rgba(0,0,0,0.6), 0 0 0 1px var(--line); }
#preview { display: block; width: min(88vh, calc(100vw - 400px)); height: min(88vh, calc(100vw - 400px)); image-rendering: -webkit-optimize-contrast; }
.stage-meta { position: absolute; bottom: 16px; left: 18px; font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--ink-mute); }
.stage-meta .hash { color: var(--accent); letter-spacing: 0.1em; text-transform: none; }
.stage-corner { position: absolute; top: 16px; right: 18px; font-family: 'Cormorant Garamond', serif; font-style: italic; font-size: 14px; color: var(--ink-mute); text-align: right; line-height: 1.4; }
.stage-corner .sm { font-family: 'JetBrains Mono', monospace; font-style: normal; font-size: 9px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--ink-mute); margin-top: 2px; }
.toast { position: absolute; bottom: 16px; right: 18px; background: var(--panel); border: 1px solid var(--accent); color: var(--accent); padding: 8px 14px; font-size: 10px; letter-spacing: 0.15em; text-transform: uppercase; opacity: 0; transition: opacity 0.3s; pointer-events: none; }
.toast.show { opacity: 1; }
</style>
</head>
<body>
<aside>
<div class="brand">Bubble Chamber</div>
<div class="subtitle">Parametric Generator · v3 (built)</div>
<div class="group" style="border-top:none; padding-top:0;">
<div class="group-title">Seed</div>
<div class="seed-row">
<input class="seed-input" id="seedInput" value="ENTROPY-001" spellcheck="false">
<button id="randomSeed" title="Randomize seed"></button>
</div>
<div class="btn-row-full"><button id="regen" class="primary">Regenerate</button></div>
<div class="btn-row-full"><button id="fromSeed">Derive all params from seed</button></div>
<select class="preset" id="presets"><option value="">— Preset —</option></select>
</div>
<div id="controls"></div>
<div class="group">
<div class="group-title">Export</div>
<div class="btn-row">
<button id="exportSVG">SVG</button>
<button id="exportPDF">PDF</button>
</div>
<div class="btn-row-full"><button id="exportPNG">PNG · high-res</button></div>
<div style="margin-top:14px; font-size:10px; color:var(--ink-mute); line-height:1.5;">
Print target: 24″ × 24″<br>
SVG/PDF scale infinitely (clean vector).<br>
PNG carries the full film texture.
</div>
</div>
</aside>
<main>
<div class="stage-frame">
<canvas id="preview" width="1000" height="1000"></canvas>
<div class="stage-corner">
<span id="labName">BEBC · CERN</span><br>
Plate <span id="plateNum">001</span><br>
<span class="sm">Exposed · <span id="exposureDate"></span></span>
</div>
<div class="stage-meta">Seed: <span class="hash" id="hashDisplay"></span></div>
</div>
<div class="toast" id="toast">Exported</div>
</main>
<script>
(function () {
const __cache = {};
const __reg = {};
function __require(id) {
if (__cache[id]) return __cache[id].exports;
const module = { exports: {} }; __cache[id] = module;
__reg[id](module, module.exports, __require);
return module.exports;
}
__reg["src/main.js"] = function (module, exports, __require) {
/* ============================================================
main.js — app wiring.
Builds the control panel from controls.js, reads params,
drives generate→render, and handles exports. The preview is
always the photographic renderer; SVG/PDF are the vector path.
============================================================ */
const { cyrb53 } = __require("src/rng.js");
const { generateScene } = __require("src/scene/scene.js");
const { renderCanvasPhoto } = __require("src/render/canvasPhoto.js");
const { renderSVG } = __require("src/render/svgVector.js");
const { buildPDF } = __require("src/render/pdf.js");
const { GROUPS, TOGGLES, FIXED, PRESETS } = __require("src/ui/controls.js");
const { paramsFromSeed } = __require("src/scene/params.js");
const PREVIEW = 1000; // internal preview resolution
const EXPORT_PNG = 7200; // hi-res raster — 24" @ 300 DPI
/* ---------- build the panel ---------- */
const panel = document.getElementById('controls');
const sliderDefs = {};
const toggleDefs = {};
function buildPanel() {
for (const g of GROUPS) {
const grp = el('div', 'group');
grp.appendChild(el('div', 'group-title', g.title));
for (const c of g.controls) {
sliderDefs[c.id] = c;
const row = el('div', 'row');
row.appendChild(el('label', null, c.label));
const val = el('span', 'val');
val.id = c.id + 'Val';
row.appendChild(val);
grp.appendChild(row);
const input = document.createElement('input');
input.type = 'range';
input.id = c.id;
input.min = c.min; input.max = c.max; input.step = c.step; input.value = c.value;
grp.appendChild(input);
}
// toggles belonging to a group title go after Film & Plate / Shock
panel.appendChild(grp);
if (g.title === 'Shock-wave Disk') { addToggle(grp, 'shock'); addToggle(grp, 'diskBubbles'); }
if (g.title === 'Film & Plate') {
addToggle(grp, 'showFiducials');
addToggle(grp, 'showBoundary');
addToggle(grp, 'showHeader');
addToggle(grp, 'invert');
}
}
}
function addToggle(grp, id) {
const def = TOGGLES.find(t => t.id === id);
toggleDefs[id] = def;
const row = el('label', 'checkbox-row');
const cb = document.createElement('input');
cb.type = 'checkbox'; cb.id = id; cb.checked = def.value;
const span = el('span', null, def.label);
row.appendChild(cb); row.appendChild(span);
grp.appendChild(row);
}
function el(tag, cls, text) {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
}
/* ---------- params ---------- */
function readParams() {
const p = { ...FIXED, seed: document.getElementById('seedInput').value || 'DEFAULT' };
for (const [id, def] of Object.entries(sliderDefs)) {
const v = parseFloat(document.getElementById(id).value);
p[id] = def.int ? Math.round(v) : v;
}
for (const id of Object.keys(toggleDefs)) p[id] = document.getElementById(id).checked;
return p;
}
function updateLabels() {
for (const [id, def] of Object.entries(sliderDefs)) {
const v = parseFloat(document.getElementById(id).value);
document.getElementById(id + 'Val').textContent = def.int ? String(Math.round(v)) : v.toFixed(2);
}
}
/* ---------- render loop ---------- */
const canvas = document.getElementById('preview');
canvas.width = canvas.height = PREVIEW;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
let scene = null, params = null, timer = null;
function regen() {
params = readParams();
scene = generateScene(params);
renderCanvasPhoto(ctx, PREVIEW, PREVIEW, scene, params, { preview: true });
document.getElementById('hashDisplay').textContent = scene.hash.toUpperCase();
document.getElementById('plateNum').textContent = scene.plate;
document.getElementById('exposureDate').textContent = scene.exposure;
document.getElementById('labName').textContent = scene.lab;
}
function rerender() {
if (!scene) return regen();
params = readParams();
renderCanvasPhoto(ctx, PREVIEW, PREVIEW, scene, params, { preview: true });
}
function schedule(needsRegen) {
clearTimeout(timer);
timer = setTimeout(() => (needsRegen ? regen() : rerender()), 28);
}
/* ---------- wiring ---------- */
function wire() {
for (const [id, def] of Object.entries(sliderDefs)) {
document.getElementById(id).addEventListener('input', () => {
updateLabels();
schedule(def.mode === 'scene');
});
}
for (const [id, def] of Object.entries(toggleDefs)) {
document.getElementById(id).addEventListener('change', () => schedule(def.mode === 'scene'));
}
document.getElementById('seedInput').addEventListener('change', regen);
document.getElementById('regen').addEventListener('click', regen);
document.getElementById('fromSeed').addEventListener('click', deriveFromSeed);
document.getElementById('randomSeed').addEventListener('click', randomSeed);
// presets
const ps = document.getElementById('presets');
for (const name of Object.keys(PRESETS)) {
const o = document.createElement('option'); o.value = name; o.textContent = name; ps.appendChild(o);
}
ps.addEventListener('change', () => { applyPreset(ps.value); ps.selectedIndex = 0; });
document.getElementById('exportSVG').addEventListener('click', exportSVG);
document.getElementById('exportPDF').addEventListener('click', exportPDF);
document.getElementById('exportPNG').addEventListener('click', exportPNG);
}
const WORDS = ['MUON', 'KAON', 'PION', 'LAMBDA', 'SIGMA', 'XI', 'OMEGA', 'TAU', 'GLUON', 'QUARK', 'HADRON', 'BARYON', 'LEPTON', 'NEUTRINO', 'BOSON'];
function randomSeed() {
const w = WORDS[Math.floor(Math.random() * WORDS.length)];
const n = Math.floor(Math.random() * 9000 + 1000);
document.getElementById('seedInput').value = `${w}-${n}`;
regen();
}
function applyParams(obj) {
for (const [k, v] of Object.entries(obj)) {
if (k === 'seed') { document.getElementById('seedInput').value = v; continue; }
const sl = document.getElementById(k);
if (!sl) continue; // ignore derived-only keys (archetype, shockX…)
if (sl.type === 'checkbox') sl.checked = !!v;
else sl.value = v;
}
updateLabels();
regen();
}
function applyPreset(name) {
if (PRESETS[name]) applyParams(PRESETS[name]);
}
function deriveFromSeed() {
const seed = document.getElementById('seedInput').value || 'ENTROPY-001';
applyParams(paramsFromSeed(seed));
}
/* ---------- exports ---------- */
function showToast(msg) {
const t = document.getElementById('toast');
t.textContent = msg; t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 1800);
}
function download(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function exportSVG() {
if (!scene) regen();
download(new Blob([renderSVG(scene, params, 4800)], { type: 'image/svg+xml' }),
`bubble-chamber-${params.seed}.svg`);
showToast('SVG exported');
}
function exportPDF() {
if (!scene) regen();
download(new Blob([buildPDF(scene, params)], { type: 'application/pdf' }),
`bubble-chamber-${params.seed}.pdf`);
showToast('PDF exported');
}
function exportPNG() {
if (!scene) regen();
showToast('Rendering hi-res…');
setTimeout(() => {
const off = document.createElement('canvas');
off.width = off.height = EXPORT_PNG;
const octx = off.getContext('2d', { willReadFrequently: true });
renderCanvasPhoto(octx, EXPORT_PNG, EXPORT_PNG, scene, params, { preview: false });
off.toBlob(b => { download(b, `bubble-chamber-${params.seed}.png`); showToast('PNG exported'); }, 'image/png');
}, 50);
}
/* ---------- init ---------- */
function init() {
buildPanel();
wire();
updateLabels();
regen(); // exposure date & lab now derive deterministically from the seed
}
init();
};
__reg["src/rng.js"] = function (module, exports, __require) {
/* ============================================================
rng.js — deterministic seeding & sampling
Every stochastic decision in the generator draws from a
salted sub-stream so that changing one parameter does not
reshuffle unrelated parts of the scene.
============================================================ */
/* cyrb53 — fast 53-bit string hash. Stable across runs/machines. */
function cyrb53(str, seed = 0) {
let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed;
for (let i = 0; i < str.length; i++) {
const ch = str.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16).padStart(13, '0');
}
/* mulberry32 — tiny fast PRNG, returns [0,1). */
function mulberry32(seed) {
let a = seed >>> 0;
return function () {
a = (a + 0x6D2B79F5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/* makeRng — seed string + salt → independent PRNG stream. */
function makeRng(seedStr, salt = '') {
const h = cyrb53(seedStr + '::' + salt);
return mulberry32(parseInt(h.slice(0, 8), 16));
}
/* ---------- Distributions ---------- */
/* Standard normal via Box-Muller. */
function gauss(rng) {
const u = 1 - rng(), v = rng();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}
/* Log-normal — heavy-tailed positive sample. */
function logNormal(rng, mu, sigma) {
return Math.exp(mu + sigma * gauss(rng));
}
/* Uniform in [lo, hi). */
function range(rng, lo, hi) {
return lo + (hi - lo) * rng();
}
/* Random int in [lo, hi]. */
function randInt(rng, lo, hi) {
return lo + Math.floor(rng() * (hi - lo + 1));
}
/* Pick one element. */
function pick(rng, arr) {
return arr[Math.floor(rng() * arr.length)];
}
/* Bernoulli. */
function chance(rng, p) {
return rng() < p;
}
Object.assign(exports, { cyrb53, mulberry32, makeRng, gauss, logNormal, range, randInt, pick, chance });
};
__reg["src/scene/scene.js"] = function (module, exports, __require) {
/* ============================================================
scene.js — the single source of truth.
generateScene(params) returns a pure, renderer-agnostic data
model. Every renderer (photographic raster, vector SVG/PDF)
consumes exactly this. Deterministic from params.seed.
============================================================ */
const { makeRng, gauss, chance, pick } = __require("src/rng.js");
const { integrateTrack, sampleMomentum, cosmicTrack, sweeperTrack } = __require("src/scene/track.js");
const { spawnDeltaSpiral } = __require("src/scene/delta.js");
const { spawnVDecay } = __require("src/scene/vdecay.js");
const { generateShock } = __require("src/scene/shock.js");
const { generateArtifacts } = __require("src/scene/artifacts.js");
const { generateInstrument } = __require("src/scene/instrument.js");
const { cyrb53 } = __require("src/rng.js");
const LABS = ['BEBC · CERN', 'GARGAMELLE · CERN', '2m HBC · CERN', '82" HBC · SLAC', 'MIRABELLE · IHEP'];
/* One event: a vertex with primaries (+ δ-rays, V-decays for bright events). */
function generateOneEvent(params, vertex, intensity, salt) {
const rng = makeRng(params.seed, 'event:' + salt);
const tracks = [];
const N = Math.max(2, Math.round(params.primaries * (0.45 + intensity * 0.55)));
const burstConc = 0.3 + params.burst * 0.7;
const bright = intensity > 0.6;
for (let i = 0; i < N; i++) {
const baseAngle = (i / N) * Math.PI * 2;
const angle = baseAngle + gauss(rng) * 0.4 * (1 - burstConc);
const p = sampleMomentum(rng, params.pspread);
const q = chance(rng, 0.5) ? +1 : -1;
const pts = integrateTrack(
{ x: vertex.x, y: vertex.y, theta: angle, p, q }, params
);
tracks.push({ pts, kind: 'primary', weight: intensity });
// δ-rays along bright primaries — abundant, true spirals
if (bright) {
const dRng = makeRng(params.seed, salt + ':delta' + i);
for (let j = 6; j < pts.length; j += 2) {
if (dRng() < params.deltaRate * 0.12) {
const dpts = spawnDeltaSpiral(pts[j], params, dRng);
if (dpts.length > 6) {
tracks.push({ pts: dpts, kind: 'delta', weight: intensity * 0.9 });
}
}
}
}
}
// dense interaction "star": stubby tracks bursting from the vertex, with a
// few medium prongs reaching further out for a richer burst.
if (bright && params.burst > 0.15) {
const sRng = makeRng(params.seed, salt + ':star');
const nStar = Math.floor(params.burst * 26);
for (let i = 0; i < nStar; i++) {
const a = sRng() * Math.PI * 2;
const medium = sRng() < 0.25;
const p = medium ? (0.5 + sRng() * 0.7) : (0.13 + sRng() * 0.4);
const q = chance(sRng, 0.5) ? 1 : -1;
const pts = integrateTrack(
{ x: vertex.x + gauss(sRng) * 0.012, y: vertex.y + gauss(sRng) * 0.012, theta: a, p, q },
params, { maxTravel: medium ? 2.6 : 1.1 }
);
tracks.push({ pts, kind: 'primary', weight: intensity });
}
}
// V-decays only for the brightest (foreground) events
if (intensity > 0.8) {
for (let i = 0; i < params.vdecay; i++) {
const daughters = spawnVDecay(vertex, params, makeRng(params.seed, salt + ':vdecay' + i));
daughters.forEach(d => {
if (d.length > 10) tracks.push({ pts: d, kind: 'vdecay', weight: intensity * 0.95 });
});
}
}
return tracks;
}
function generateScene(params) {
const rng = makeRng(params.seed, 'scene');
const tracks = [];
// Foreground event, slightly off-centre
const fgVertex = { x: (rng() - 0.5) * 0.3, y: (rng() - 0.5) * 0.3 };
tracks.push(...generateOneEvent(params, fgVertex, 1.0, 'fg'));
// Background "history" events
const nBg = params.bgEvents || 0;
for (let i = 0; i < nBg; i++) {
const bgRng = makeRng(params.seed, 'bg' + i);
const vx = (bgRng() - 0.5) * 1.7, vy = (bgRng() - 0.5) * 1.7;
const intensity = params.bgIntensity * (0.5 + bgRng() * 0.5);
tracks.push(...generateOneEvent(
{ ...params, primaries: Math.round(params.primaries * 0.6), vdecay: 0 },
{ x: vx, y: vy }, intensity, 'bg' + i
));
}
// Cosmic/transient straight tracks crossing the frame
const nCosmic = Math.round(params.cosmics || 0);
for (let i = 0; i < nCosmic; i++) {
const cRng = makeRng(params.seed, 'cosmic' + i);
const pts = cosmicTrack(params, cRng);
if (pts.length > 8) tracks.push({ pts, kind: 'cosmic', weight: 0.7 + cRng() * 0.3 });
}
// Sweepers — big gentle arcs across the frame
const nSweep = Math.round(params.sweepers || 0);
for (let i = 0; i < nSweep; i++) {
const sRng = makeRng(params.seed, 'sweep' + i);
const pts = sweeperTrack(params, sRng);
if (pts.length > 8) tracks.push({ pts, kind: 'sweep', weight: 0.75 + sRng() * 0.25 });
}
const shock = generateShock(params, makeRng(params.seed, 'shock'));
const artifacts = generateArtifacts(params, makeRng(params.seed, 'artifacts'));
const instrument = generateInstrument(params, makeRng(params.seed, 'instrument'));
// deterministic archival metadata (so exports are reproducible from the seed)
const hash = cyrb53(params.seed);
const ds = parseInt(hash.slice(0, 8), 16);
const plate = (parseInt(hash.slice(-3), 16) % 999).toString().padStart(3, '0');
const year = 1971 + (ds % 11);
const month = 1 + ((ds >> 4) % 12);
const day = 1 + ((ds >> 8) % 28);
const exposure = `${year}.${String(month).padStart(2, '0')}.${String(day).padStart(2, '0')}`;
const lab = pick(makeRng(params.seed, 'lab'), LABS);
return { tracks, vertex: fgVertex, shock, artifacts, instrument, hash, plate, exposure, lab };
}
Object.assign(exports, { generateScene });
};
__reg["src/scene/track.js"] = function (module, exports, __require) {
/* ============================================================
track.js — charged-particle trajectory integration.
A particle in a uniform B field (out of page) curves with
radius r = p/(qB). Energy loss (Bethe-Bloch-ish, ∝1/β²)
shrinks p as it travels, so the radius tightens toward the
end of range — the characteristic inward spiral.
============================================================ */
const { gauss, logNormal } = __require("src/rng.js");
const MASS = 1.0; // particle mass, arbitrary units
const BASE_STEP = 0.0035; // nominal arc-length step (logical units)
const MAX_DTHETA = 0.16; // cap turn-per-step → smooth tight spirals
/* Integrate one track. Returns {x,y,beta,theta} sample points.
The step length is shortened in tight curvature so terminal
spirals stay smooth instead of going polygonal. */
function integrateTrack(state, params, opts = {}) {
const pts = [];
let { x, y, theta, p, q } = state;
const B = params.bfield;
const maxSteps = opts.maxSteps ?? 2600;
const maxTravel = opts.maxTravel ?? 8;
const elossScale = opts.elossScale ?? 1;
const stopP = opts.stopP ?? 0.035;
const bound = opts.bound ?? 1.15;
let traveled = 0;
for (let i = 0; i < maxSteps; i++) {
const beta = p / Math.sqrt(p * p + MASS * MASS);
pts.push({ x, y, beta, theta });
// curvature magnitude (1/radius) = qB/p
const curv = Math.abs(q) * B / Math.max(p, 0.02);
// shorten step when curvature is high so dtheta stays bounded
let ds = BASE_STEP;
if (curv * ds > MAX_DTHETA) ds = MAX_DTHETA / curv;
x += Math.cos(theta) * ds;
y += Math.sin(theta) * ds;
theta += Math.sign(q) * curv * ds;
// energy loss ∝ 1/β²
const loss = params.eloss * elossScale * ds / Math.max(beta * beta, 0.04);
p -= loss;
traveled += ds;
if (p < stopP) break;
if (Math.abs(x) > bound || Math.abs(y) > bound) break;
if (traveled > maxTravel) break;
}
return pts;
}
/* Heavy-tailed momentum. Most tracks moderate; a meaningful tail of
high-p (long, nearly straight) and a tail of low-p (tight spirals). */
function sampleMomentum(rng, spread) {
const sigma = 0.55 + spread * 1.35; // wider tail than v2
const mu = -0.15;
return logNormal(rng, mu, sigma);
}
/* Pick an entry point on the frame edge aimed roughly across. */
function edgeEntry(rng) {
const edge = Math.floor(rng() * 4);
const t = rng() * 2 - 1;
if (edge === 0) return { x: -1.15, y: t, theta: (rng() - 0.5) * 0.7 };
if (edge === 1) return { x: 1.15, y: t, theta: Math.PI + (rng() - 0.5) * 0.7 };
if (edge === 2) return { x: t, y: -1.15, theta: Math.PI / 2 + (rng() - 0.5) * 0.7 };
return { x: t, y: 1.15, theta: -Math.PI / 2 + (rng() - 0.5) * 0.7 };
}
/* A "cosmic"/transient straight track: very high momentum, crosses the whole
frame as a near-straight line. These give the reference its long diagonals
that ignore the central vertex. */
function cosmicTrack(params, rng) {
const e = edgeEntry(rng);
const p = 6 + rng() * 14; // very stiff → nearly straight
const q = rng() < 0.5 ? 1 : -1;
return integrateTrack(
{ x: e.x, y: e.y, theta: e.theta, p, q },
{ ...params, eloss: params.eloss * 0.15 },
{ maxTravel: 4, bound: 1.2 }
);
}
/* A "sweeper": moderate-high momentum with low energy loss, so it traces a
large, gentle arc all the way across the frame — the big graceful curves
between the straight cosmics and the tight curls. */
function sweeperTrack(params, rng) {
const e = edgeEntry(rng);
const p = 1.6 + rng() * 3.2;
const q = rng() < 0.5 ? 1 : -1;
return integrateTrack(
{ x: e.x, y: e.y, theta: e.theta, p, q },
{ ...params, eloss: params.eloss * 0.3 },
{ maxTravel: 5.5, bound: 1.2 }
);
}
Object.assign(exports, { integrateTrack, sampleMomentum, cosmicTrack, sweeperTrack });
};
__reg["src/scene/delta.js"] = function (module, exports, __require) {
/* ============================================================
delta.js — δ-rays, the iconic curly bits.
A δ-ray is a low-energy electron knocked off an atom by a
passing track. In the B field it makes a tight spiral that
loses energy and winds inward to a point. We model it
directly as a logarithmic spiral (radius decays exponentially
with wind angle) because that converges cleanly to a point
and reads as the textbook curl — far more reliable than
tuning the generic integrator down to sub-percent radii.
β decreases toward the centre, so bubbles get denser & fatter
as the curl tightens — exactly as in real plates.
============================================================ */
const { gauss } = __require("src/rng.js");
/* Build one δ-ray spiral starting at (and tangent to) a parent point. */
function spawnDeltaSpiral(parentPt, params, rng) {
const tight = params.deltaTight; // 0.1..1.5 ; higher = smaller curls
const dir = rng() < 0.5 ? 1 : -1; // wind direction (charge sign)
// revolutions: most curls do 1.54 turns
const turns = 1.2 + rng() * rng() * 3.2;
const phiMax = turns * Math.PI * 2;
// starting radius: a small fraction of the chamber, smaller when tighter
const r0 = (0.010 + rng() * 0.045) / (0.6 + tight);
// exponential decay per radian → converges to centre
const decay = (0.07 + rng() * 0.13);
// launch direction: roughly perpendicular to parent (knock-on kinematics)
const launch = parentPt.theta + dir * Math.PI / 2 + gauss(rng) * 0.35;
// spiral centre placed so the curve starts exactly at the parent point
const cx = parentPt.x - Math.cos(launch) * r0;
const cy = parentPt.y - Math.sin(launch) * r0;
const pts = [];
// adaptive angular step: finer as radius shrinks to keep it smooth
let phi = 0;
while (phi <= phiMax) {
const r = r0 * Math.exp(-decay * phi);
const ang = launch + dir * phi;
const x = cx + Math.cos(ang) * r;
const y = cy + Math.sin(ang) * r;
// β falls as the electron slows toward the centre
const frac = phi / phiMax;
const beta = 0.85 * Math.exp(-1.4 * frac) + 0.12;
// tangent heading (for downstream use)
const theta = ang + dir * Math.PI / 2;
pts.push({ x, y, beta, theta });
if (r < 0.0008) break; // arrived at the point
// step in angle, larger when radius is big, smaller when tight
const dPhi = Math.min(0.4, 0.012 / Math.max(r, 0.001) + 0.05);
phi += dPhi;
}
return pts;
}
Object.assign(exports, { spawnDeltaSpiral });
};
__reg["src/scene/vdecay.js"] = function (module, exports, __require) {
/* ============================================================
vdecay.js — neutral-particle decay signatures.
A neutral (invisible) particle drifts from the vertex, then
decays into two oppositely-charged daughters: the classic "V".
============================================================ */
const { integrateTrack } = __require("src/scene/track.js");
function spawnVDecay(originPt, params, rng) {
const dist = 0.12 + rng() * 0.4;
const ghostTheta = rng() * Math.PI * 2;
const vx = originPt.x + Math.cos(ghostTheta) * dist;
const vy = originPt.y + Math.sin(ghostTheta) * dist;
if (Math.abs(vx) > 0.9 || Math.abs(vy) > 0.9) return [];
const opening = 0.25 + rng() * 0.8;
const p1 = 0.4 + rng() * 0.9;
const p2 = 0.4 + rng() * 0.9;
const t1 = integrateTrack(
{ x: vx, y: vy, theta: ghostTheta - opening / 2, p: p1, q: +1 }, params
);
const t2 = integrateTrack(
{ x: vx, y: vy, theta: ghostTheta + opening / 2, p: p2, q: -1 }, params
);
return [t1, t2];
}
Object.assign(exports, { spawnVDecay });
};
__reg["src/scene/shock.js"] = function (module, exports, __require) {
/* ============================================================
shock.js — the pressure-piston / shock-wave disk.
In the reference photo this is the dominant compositional
element: a dark disk near the bottom, a hard rim, and a dense
sunburst of fine radial striations bursting outward, with a
textured core. Not physics — it's a chamber/window artifact,
but visually it anchors the whole plate.
============================================================ */
const { gauss } = __require("src/rng.js");
function generateShock(params, rng) {
if (!params.shock || params.shockIntensity <= 0) return null;
// default placement: bottom-centre, jittered by seed
const x = (params.shockX ?? 0) + gauss(rng) * 0.05;
const y = (params.shockY ?? 0.52) + gauss(rng) * 0.04;
const r = params.shockSize * (0.9 + rng() * 0.2);
const I = params.shockIntensity;
// 0 = pristine clean rim, 1 = heavily stained / eroded / degraded disk
const stain = Math.max(0, Math.min(1, params.shockStain ?? 0.35));
// radial striations — fine lines that burst OUTWARD from a ring, leaving
// the centre as a textured (not solid) core. Lengths vary a lot: a few
// long rays shoot well past the rim, most are short tufts near it.
const striations = [];
const n = Math.floor(140 + params.shockStriations * 560);
for (let i = 0; i < n; i++) {
const a = rng() * Math.PI * 2;
// start in the outer half of the disk so rays don't pile into the centre
const inner = r * (0.45 + rng() * 0.5);
const long = rng() < 0.16;
const reach = long ? (1.15 + rng() * 1.6) : (0.92 + rng() * 0.4);
const outer = r * reach;
striations.push({
a,
inner,
outer,
width: (long ? 0.5 : 0.3) + rng() * (long ? 1.1 : 0.7),
opacity: (0.1 + rng() * 0.45) * I * (long ? 1.1 : 0.85),
wobble: gauss(rng) * 0.012, // slight curvature
});
}
// The rim as arc SEGMENTS rather than one clean ring: when clean it reads as
// a continuous hard edge; when stained the rim erodes — gaps appear, widths
// and darkness vary, so the circle degrades like a real worn plate.
const rimSegs = [];
const k = 48;
for (let i = 0; i < k; i++) {
if (rng() < 0.02 + stain * 0.45) continue; // eroded gap
const a0 = (i / k) * Math.PI * 2;
const a1 = ((i + 1) / k) * Math.PI * 2;
rimSegs.push({
a0, a1,
width: (2.6 + 3 * I) * (1 + (rng() - 0.5) * stain * 1.4),
opacity: (0.55 + rng() * 0.35) * I * (1 - stain * 0.35),
});
}
// concentric inner pressure fronts
const rings = [];
const nRings = 2 + Math.floor(rng() * 3);
for (let i = 0; i < nRings; i++) {
rings.push({
rr: r * (0.5 + rng() * 0.9),
width: 0.6 + rng() * 1.6,
opacity: (0.06 + rng() * 0.18) * I,
});
}
// staining: soft blotches over and around the disk. Dark ones are grime /
// chemical deposit; light ones are washed/lifted emulsion (clean spots).
const stains = [];
const nStain = Math.floor(stain * 22 + rng() * stain * 12);
for (let i = 0; i < nStain; i++) {
const a = rng() * Math.PI * 2;
const rad = r * Math.sqrt(rng()) * 1.2;
stains.push({
x: x + Math.cos(a) * rad,
y: y + Math.sin(a) * rad,
r: r * (0.05 + rng() * 0.28),
opacity: (0.06 + rng() * 0.22) * (0.5 + stain),
dark: rng() < 0.65,
});
}
// textured core: short radial cracks filling the inner zone so the centre
// reads as fine detail rather than a black hub. Density falls toward centre,
// leaving a small brighter middle.
const core = [];
const nCore = Math.floor((120 + 420 * I) * (1 + stain * 0.6)); // dirtier = busier core
for (let i = 0; i < nCore; i++) {
const a = rng() * Math.PI * 2;
// bias toward the outer core (sqrt) so the very centre stays open
const rad = r * Math.sqrt(rng()) * 0.6;
const len = r * (0.03 + rng() * 0.16) * (0.4 + rad / (r * 0.6)); // longer outward
const ca = a + gauss(rng) * 0.25; // mostly radial cracks
core.push({
x1: x + Math.cos(a) * rad,
y1: y + Math.sin(a) * rad,
x2: x + Math.cos(a) * rad + Math.cos(ca) * len,
y2: y + Math.sin(a) * rad + Math.sin(ca) * len,
width: 0.35 + rng() * 0.9,
opacity: (0.25 + rng() * 0.5) * I,
});
}
const shock = { x, y, r, intensity: I, stain, striations, rimSegs, rings, stains, core, bright: r * 0.12 };
// Describe the disk's line work with the SAME bubble-nucleation method as the
// particle tracks, so it shares their granular texture instead of reading as
// clean vector strokes. Each entry is a track-like polyline for sampleBubbles.
shock.bubbleStrokes = buildBubbleStrokes(shock);
return shock;
}
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
function buildBubbleStrokes(sh) {
const { x, y, r } = sh;
const out = [];
// radial striations → short streaks (perpendicular jitter gives them width)
for (const s of sh.striations) {
out.push({
pts: [
{ x: x + Math.cos(s.a) * s.inner, y: y + Math.sin(s.a) * s.inner, beta: 0.58 },
{ x: x + Math.cos(s.a) * s.outer, y: y + Math.sin(s.a) * s.outer, beta: 0.5 },
],
weight: clamp(s.opacity * 2.0, 0.35, 0.95),
densityScale: 0.22, sizeScale: 0.9, jitter: 0.0045,
});
}
// eroded rim segments → dense arcs of bubbles
for (const seg of sh.rimSegs) {
const steps = Math.max(3, Math.round((seg.a1 - seg.a0) / 0.06));
const pts = [];
for (let i = 0; i <= steps; i++) {
const a = seg.a0 + (seg.a1 - seg.a0) * (i / steps);
pts.push({ x: x + Math.cos(a) * r, y: y + Math.sin(a) * r, beta: 0.45 });
}
out.push({ pts, weight: clamp(seg.opacity * 1.6, 0.4, 0.95), densityScale: 0.34, sizeScale: 1.0, jitter: 0.004 });
}
// concentric pressure-front rings → faint dotted circles
for (const ring of sh.rings) {
const n = Math.max(40, Math.round(ring.rr * 200));
const pts = [];
for (let i = 0; i <= n; i++) {
const a = (i / n) * Math.PI * 2;
pts.push({ x: x + Math.cos(a) * ring.rr, y: y + Math.sin(a) * ring.rr, beta: 0.7 });
}
out.push({ pts, weight: clamp(ring.opacity * 2.6, 0.3, 0.8), densityScale: 0.12, sizeScale: 0.85, jitter: 0.003 });
}
// textured core cracks → short bubble streaks
for (const k of sh.core) {
out.push({
pts: [{ x: k.x1, y: k.y1, beta: 0.42 }, { x: k.x2, y: k.y2, beta: 0.42 }],
weight: clamp(k.opacity * 1.6, 0.35, 0.9),
densityScale: 0.3, sizeScale: 0.95, jitter: 0.0045,
});
}
return out;
}
Object.assign(exports, { generateShock });
};
__reg["src/scene/artifacts.js"] = function (module, exports, __require) {
/* ============================================================
artifacts.js — plate damage & emulsion remnants.
Imperfections every archival plate carries: scratches, stray
hairs, dust, water/chemical rings, fingerprints, and broad
development stains. These sell the "analog remnant" feel.
(Broad tonal mottle itself is generated in the renderer's
noise layer; here we model the discrete marks.)
============================================================ */
const { gauss } = __require("src/rng.js");
function generateArtifacts(params, rng) {
const out = { scratches: [], hairs: [], specks: [], rings: [], fingerprints: [] };
const A = params.artifacts;
if (!A) return out;
// --- Scratches: long, very thin, near-straight lines ---
const nScratch = Math.floor(A * 7 + rng() * A * 5);
for (let i = 0; i < nScratch; i++) {
const cx = (rng() - 0.5) * 2, cy = (rng() - 0.5) * 2;
const ang = rng() * Math.PI * 2;
const len = 0.3 + rng() * 1.6;
out.scratches.push({
x1: cx - Math.cos(ang) * len / 2, y1: cy - Math.sin(ang) * len / 2,
x2: cx + Math.cos(ang) * len / 2, y2: cy + Math.sin(ang) * len / 2,
opacity: (0.1 + rng() * 0.28) * A,
width: 0.3 + rng() * 0.5,
bright: rng() < 0.4, // some scratches read as light (emulsion lifted)
});
}
// --- Hairs: a few curved stray fibres (multi-point wiggly polylines) ---
const nHair = Math.floor(A * 2 + rng() * 2);
for (let i = 0; i < nHair; i++) {
let x = (rng() - 0.5) * 1.8, y = (rng() - 0.5) * 1.8;
let ang = rng() * Math.PI * 2;
const pts = [{ x, y }];
const segs = 18 + Math.floor(rng() * 26);
const step = 0.02 + rng() * 0.02;
for (let s = 0; s < segs; s++) {
ang += gauss(rng) * 0.45;
x += Math.cos(ang) * step; y += Math.sin(ang) * step;
pts.push({ x, y });
}
out.hairs.push({ pts, opacity: (0.18 + rng() * 0.3) * A, width: 0.5 + rng() * 0.8 });
}
// --- Dust specks: small dark spots, clumped ---
const nClumps = Math.floor(A * 14 + rng() * A * 10);
for (let c = 0; c < nClumps; c++) {
const clx = (rng() - 0.5) * 2, cly = (rng() - 0.5) * 2;
const inClump = 1 + Math.floor(rng() * 7);
for (let k = 0; k < inClump; k++) {
out.specks.push({
x: clx + gauss(rng) * 0.03,
y: cly + gauss(rng) * 0.03,
r: 0.0006 + rng() * 0.0028,
opacity: (0.25 + rng() * 0.5) * A,
});
}
}
// --- Water / chemical rings ---
const nRings = Math.floor(A * 3);
for (let i = 0; i < nRings; i++) {
out.rings.push({
x: (rng() - 0.5) * 1.6, y: (rng() - 0.5) * 1.6,
r: 0.05 + rng() * 0.18,
opacity: (0.06 + rng() * 0.12) * A,
width: 0.6 + rng() * 1.6,
});
}
// --- Fingerprint: a single faint loop family in a corner, sometimes ---
if (rng() < A * 0.5) {
const fx = (rng() < 0.5 ? -1 : 1) * (0.55 + rng() * 0.3);
const fy = (rng() < 0.5 ? -1 : 1) * (0.55 + rng() * 0.3);
const loops = [];
const nLoops = 5 + Math.floor(rng() * 5);
const baseR = 0.04 + rng() * 0.03;
const phase = rng() * Math.PI * 2;
for (let l = 0; l < nLoops; l++) {
const rr = baseR + l * (0.012 + rng() * 0.006);
loops.push({ rr, squash: 0.55 + rng() * 0.3, rot: phase + gauss(rng) * 0.1 });
}
out.fingerprints.push({ x: fx, y: fy, loops, opacity: (0.05 + rng() * 0.06) * A });
}
return out;
}
Object.assign(exports, { generateArtifacts });
};
__reg["src/scene/instrument.js"] = function (module, exports, __require) {
/* ============================================================
instrument.js — chamber optics / structural geometry.
The big straight lines and broad arcs in real plates are not
particle tracks: they're the chamber's optical boundaries —
illumination window edges, mirror frames, camera-view limits,
the curved chamber wall. They form a faint geometric scaffold
the organic tracks sit on top of. Optional, seeded.
============================================================ */
const { gauss } = __require("src/rng.js");
function generateInstrument(params, rng) {
const out = { lines: [], arcs: [] };
const I = params.instrument;
if (!I) return out;
// --- fans of straight lines radiating from an off-frame apex ---
const nFans = 1 + (rng() < 0.55 ? 1 : 0);
for (let f = 0; f < nFans; f++) {
const apex = {
x: (rng() < 0.5 ? -1 : 1) * (1.0 + rng() * 0.7),
y: (rng() < 0.5 ? -1 : 1) * (0.7 + rng() * 0.8),
};
const toCentre = Math.atan2(-apex.y, -apex.x);
const spread = 0.45 + rng() * 0.7;
const m = 4 + Math.floor(rng() * 5);
for (let i = 0; i < m; i++) {
const ang = toCentre + ((i / (m - 1)) - 0.5) * spread + gauss(rng) * 0.02;
const len = 3.2;
out.lines.push({
x1: apex.x, y1: apex.y,
x2: apex.x + Math.cos(ang) * len, y2: apex.y + Math.sin(ang) * len,
opacity: (0.07 + rng() * 0.14) * I,
width: 0.4 + rng() * 0.6,
});
}
}
// --- a few independent chords across the frame ---
const nCh = 2 + Math.floor(rng() * 3);
for (let i = 0; i < nCh; i++) {
const ang = rng() * Math.PI;
const off = (rng() - 0.5) * 1.6;
const nx = Math.cos(ang + Math.PI / 2), ny = Math.sin(ang + Math.PI / 2);
const cxp = nx * off, cyp = ny * off;
out.lines.push({
x1: cxp - Math.cos(ang) * 2.2, y1: cyp - Math.sin(ang) * 2.2,
x2: cxp + Math.cos(ang) * 2.2, y2: cyp + Math.sin(ang) * 2.2,
opacity: (0.06 + rng() * 0.12) * I,
width: 0.4 + rng() * 0.7,
});
}
// --- broad arcs (chamber wall / lens curvature) as polylines ---
const nArc = (rng() < 0.7 ? 1 : 0) + (rng() < 0.4 ? 1 : 0);
for (let i = 0; i < nArc; i++) {
const R = 1.6 + rng() * 2.4;
const side = rng() * Math.PI * 2;
const cx = Math.cos(side) * (R * 0.85);
const cy = Math.sin(side) * (R * 0.85);
const a0 = Math.atan2(-cy, -cx) - 0.6 - rng() * 0.4;
const a1 = a0 + 0.9 + rng() * 0.8;
const pts = [];
const steps = 60;
for (let s = 0; s <= steps; s++) {
const a = a0 + (a1 - a0) * (s / steps);
pts.push({ x: cx + Math.cos(a) * R, y: cy + Math.sin(a) * R });
}
out.arcs.push({ pts, opacity: (0.06 + rng() * 0.1) * I, width: 0.5 + rng() * 0.8 });
}
return out;
}
Object.assign(exports, { generateInstrument });
};
__reg["src/render/canvasPhoto.js"] = function (module, exports, __require) {
/* ============================================================
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).
============================================================ */
const { makeRng } = __require("src/rng.js");
const { sampleBubbles, trackInkWeight } = __require("src/scene/bubbles.js");
const { mottleCanvas, grainCanvas } = __require("src/render/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;
}
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, params, sprite);
/* ---------- 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, params, sprite) {
const [r, g, b] = P.ink;
const px = tx(shock.x), py = ty(shock.y), R = shock.r * scale;
const useBubbles = params.diskBubbles !== false;
// 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);
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();
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);
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);
}
}
c.globalAlpha = 1;
} else {
// legacy: clean vector strokes
c.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();
}
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();
}
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();
}
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();
}
}
// 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';
}
// 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();
}
}
}
Object.assign(exports, { renderCanvasPhoto });
};
__reg["src/scene/bubbles.js"] = function (module, exports, __require) {
/* ============================================================
bubbles.js — turn a track polyline into discrete bubbles.
Shared by every renderer so bubble positions are identical
across the canvas preview, SVG and PDF. Density ∝ 1/β²
(slow particles ionise more), radius grows as β falls
(fatter tracks at end of range).
============================================================ */
const { gauss } = __require("src/rng.js");
function sampleBubbles(track, params, rng) {
const bubbles = [];
const pts = track.pts;
if (pts.length < 2) return bubbles;
// per-track scales let non-track features (e.g. the shock disk) reuse the
// exact same nucleation method with their own density/size/jitter.
const dScale = track.densityScale ?? 1;
const sScale = track.sizeScale ?? 1;
const jitter = track.jitter ?? 0.0016;
for (let i = 1; i < pts.length; i++) {
const a = pts[i - 1], b = pts[i];
const dx = b.x - a.x, dy = b.y - a.y;
const segLen = Math.hypot(dx, dy);
if (segLen === 0) continue;
const beta = Math.max(a.beta, 0.13);
const lambda = params.density * track.weight * dScale * (1 / (beta * beta)) * 420;
const expected = lambda * segLen;
const nb = Math.floor(expected) + (rng() < (expected - Math.floor(expected)) ? 1 : 0);
const nx = -dy / segLen, ny = dx / segLen;
for (let k = 0; k < nb; k++) {
const t = rng();
const px = a.x + dx * t;
const py = a.y + dy * t;
const j = gauss(rng) * jitter;
const baseR = (0.0011 + (1 - beta) * 0.0017) * params.size * sScale;
// occasional fat bubble (clumped nucleation)
const fat = rng() < 0.05 ? 1.8 + rng() : 1;
const r = baseR * (0.65 + rng() * 0.55) * fat;
bubbles.push({ x: px + nx * j, y: py + ny * j, r });
}
}
return bubbles;
}
/* Mean inverse-β proxy used to scale the faint continuity under-stroke
that renderers draw beneath the bubbles (gives tracks line-like cohesion
without losing the dotted texture). */
function trackInkWeight(track) {
const pts = track.pts;
if (!pts.length) return 0;
let s = 0;
for (const p of pts) s += 1 / Math.max(p.beta * p.beta, 0.04);
return s / pts.length;
}
Object.assign(exports, { sampleBubbles, trackInkWeight });
};
__reg["src/render/noise.js"] = function (module, exports, __require) {
/* ============================================================
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).
============================================================ */
const { mulberry32, cyrb53 } = __require("src/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]. */
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). */
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. */
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;
}
Object.assign(exports, { fbm, mottleCanvas, grainCanvas });
};
__reg["src/render/svgVector.js"] = function (module, exports, __require) {
/* ============================================================
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.
============================================================ */
const { makeRng, cyrb53 } = __require("src/rng.js");
const { sampleBubbles, trackInkWeight } = __require("src/scene/bubbles.js");
const MARGIN = 0.02;
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;
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 });
};
__reg["src/render/pdf.js"] = function (module, exports, __require) {
/* ============================================================
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.
============================================================ */
const { makeRng } = __require("src/rng.js");
const { sampleBubbles, trackInkWeight } = __require("src/scene/bubbles.js");
const MARGIN = 0.02;
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);
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) {
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));
}
c += `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';
}
c += `Q\n`;
} else {
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));
}
Object.assign(exports, { buildPDF });
};
__reg["src/ui/controls.js"] = function (module, exports, __require) {
/* ============================================================
controls.js — declarative UI config.
One place defines every control: its range, default, label,
and whether changing it needs a scene regen ('scene') or just
a re-render ('render'). main.js builds the panel from this and
reads params back out of it.
============================================================ */
const GROUPS = [
{
title: 'Event',
controls: [
{ id: 'primaries', label: 'Primary tracks', min: 3, max: 48, step: 1, value: 14, int: true, mode: 'scene' },
{ id: 'burst', label: 'Burst intensity', min: 0, max: 1, step: 0.01, value: 0.7, mode: 'scene' },
{ id: 'vdecay', label: 'V-decay vertices', min: 0, max: 10, step: 1, value: 3, int: true, mode: 'scene' },
{ id: 'cosmics', label: 'Cosmic / transient tracks', min: 0, max: 16, step: 1, value: 6, int: true, mode: 'scene' },
{ id: 'sweepers', label: 'Sweeping arcs', min: 0, max: 12, step: 1, value: 3, int: true, mode: 'scene' },
],
},
{
title: 'Field & Trajectory',
controls: [
{ id: 'bfield', label: 'Magnetic field |B|', min: 0.2, max: 3, step: 0.01, value: 1.0, mode: 'scene' },
{ id: 'eloss', label: 'Energy-loss rate', min: 0, max: 1.5, step: 0.01, value: 0.55, mode: 'scene' },
{ id: 'pspread', label: 'Momentum spread', min: 0, max: 1, step: 0.01, value: 0.6, mode: 'scene' },
],
},
{
title: 'δ-Rays (curly bits)',
controls: [
{ id: 'deltaRate', label: 'Spawn rate', min: 0, max: 1, step: 0.01, value: 0.6, mode: 'scene' },
{ id: 'deltaTight', label: 'Tightness', min: 0.1, max: 1.5, step: 0.01, value: 0.7, mode: 'scene' },
],
},
{
title: 'Shock-wave Disk',
controls: [
{ id: 'shockIntensity', label: 'Intensity', min: 0, max: 1, step: 0.01, value: 0.7, mode: 'scene' },
{ 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: 'shockY', label: 'Vertical position', min: -0.7, max: 0.7, step: 0.01, value: 0.52, mode: 'scene' },
],
},
{
title: 'Chamber Optics',
controls: [
{ id: 'instrument', label: 'Structural geometry', min: 0, max: 1, step: 0.01, value: 0.35, mode: 'scene' },
],
},
{
title: 'Event Layers',
controls: [
{ id: 'bgEvents', label: 'Background events', min: 0, max: 12, step: 1, value: 4, int: true, mode: 'scene' },
{ id: 'bgIntensity', label: 'Background intensity', min: 0, max: 1, step: 0.01, value: 0.4, mode: 'scene' },
],
},
{
title: 'Film & Plate',
controls: [
{ id: 'density', label: 'Bubble density', min: 0.2, max: 2.5, step: 0.01, value: 1.1, mode: 'render' },
{ id: 'size', label: 'Bubble size', min: 0.3, max: 2.5, step: 0.01, value: 1.0, mode: 'render' },
{ id: 'bloom', label: 'Halation / bloom', min: 0, max: 1, step: 0.01, value: 0.5, mode: 'render' },
{ id: 'mottle', label: 'Tonal mottle', min: 0, max: 1, step: 0.01, value: 0.45, mode: 'render' },
{ id: 'grain', label: 'Film grain', min: 0, max: 1, step: 0.01, value: 0.4, mode: 'render' },
{ id: 'vign', label: 'Vignette', min: 0, max: 1, step: 0.01, value: 0.4, mode: 'render' },
{ id: 'artifacts', label: 'Plate damage', min: 0, max: 1, step: 0.01, value: 0.5, mode: 'scene' },
],
},
];
const TOGGLES = [
{ id: 'shock', label: 'Shock-wave disk', value: true, mode: 'scene' },
{ id: 'diskBubbles', label: 'Disk as bubbles', value: true, 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' },
{ id: 'invert', label: 'Invert · photographic positive', value: true, mode: 'render' },
];
/* Fixed (non-UI) params with sensible defaults. */
const FIXED = { shockX: 0 };
/* Curated presets. Each overrides a subset of params. */
const PRESETS = {
'BEBC Archival': {
seed: 'BEBC-1973', primaries: 18, burst: 0.8, vdecay: 4, cosmics: 8, sweepers: 4,
bfield: 1.1, eloss: 0.6, pspread: 0.7, deltaRate: 0.75, deltaTight: 0.8,
shock: true, shockIntensity: 0.85, shockSize: 0.34, shockStriations: 0.7, shockY: 0.55,
instrument: 0.4, bgEvents: 6, bgIntensity: 0.45, density: 1.2, size: 1.0,
bloom: 0.55, mottle: 0.6, grain: 0.5, vign: 0.5, artifacts: 0.7, invert: true,
},
'Clean Study': {
primaries: 10, burst: 0.5, vdecay: 2, cosmics: 2, sweepers: 2,
deltaRate: 0.4, deltaTight: 0.6, shockIntensity: 0, instrument: 0.15,
bgEvents: 1, bgIntensity: 0.25, bloom: 0.3, mottle: 0.2, grain: 0.15,
vign: 0.25, artifacts: 0.15, invert: true,
},
'Dense Chaos': {
primaries: 34, burst: 0.95, vdecay: 7, cosmics: 12, sweepers: 7,
bfield: 1.8, deltaRate: 0.95, deltaTight: 1.0, instrument: 0.5,
shock: true, shockIntensity: 0.7, bgEvents: 9, bgIntensity: 0.5,
density: 1.4, bloom: 0.6, mottle: 0.5, grain: 0.5, artifacts: 0.7, invert: true,
},
'Cosmic Sheet': {
seed: 'COSMIC-RAY', primaries: 6, burst: 0.3, vdecay: 1, cosmics: 14, sweepers: 9,
bfield: 0.7, eloss: 0.4, pspread: 0.85, deltaRate: 0.45, deltaTight: 0.7,
shock: false, shockIntensity: 0, instrument: 0.6,
bgEvents: 3, bgIntensity: 0.4, density: 0.9, bloom: 0.45, mottle: 0.55,
grain: 0.55, vign: 0.45, artifacts: 0.6, invert: true,
},
'Negative Plate': {
seed: 'GLASS-NEG', primaries: 16, burst: 0.75, vdecay: 3, cosmics: 7, sweepers: 4,
deltaRate: 0.7, deltaTight: 0.8, shock: true, shockIntensity: 0.7,
instrument: 0.35, bgEvents: 5, bgIntensity: 0.4, density: 1.1,
bloom: 0.5, mottle: 0.5, grain: 0.45, vign: 0.55, artifacts: 0.55, invert: false,
},
};
Object.assign(exports, { GROUPS, TOGGLES, FIXED, PRESETS });
};
__reg["src/scene/params.js"] = function (module, exports, __require) {
/* ============================================================
params.js — derive a full, tasteful parameter set from a seed.
This makes the seed the COMPLETE fingerprint of a plate: the
same seed always yields the same parameters, so an inspiration
thumbnail and its print-resolution master are identical. Pass
a seed anywhere and get the same image at any size.
A seed first selects a weighted "archetype" (overall character)
then jitters within it, so the gallery is varied but every
plate stays plausible.
============================================================ */
const { makeRng } = __require("src/rng.js");
const ARCHETYPES = [
{ name: 'archival', w: 0.42 },
{ name: 'dense', w: 0.20 },
{ name: 'cosmic', w: 0.16 },
{ name: 'clean', w: 0.12 },
{ name: 'negative', w: 0.10 },
];
function pickArchetype(rng) {
const total = ARCHETYPES.reduce((s, a) => s + a.w, 0);
let t = rng() * total;
for (const a of ARCHETYPES) { if ((t -= a.w) <= 0) return a.name; }
return 'archival';
}
function archetypeOf(seed) {
return pickArchetype(makeRng(seed, 'params'));
}
function paramsFromSeed(seed) {
const rng = makeRng(seed, 'params');
const arch = pickArchetype(rng);
const r = (lo, hi) => lo + (hi - lo) * rng();
const ri = (lo, hi) => Math.round(r(lo, hi));
const chance = (p) => rng() < p;
// base (archival) defaults
const p = {
seed,
primaries: ri(12, 24), burst: r(0.55, 0.92), vdecay: ri(2, 6),
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),
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
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),
bloom: r(0.4, 0.65), mottle: r(0.4, 0.7), grain: r(0.4, 0.6),
vign: r(0.35, 0.6), artifacts: r(0.4, 0.8),
showFiducials: chance(0.85), showBoundary: chance(0.7), showHeader: chance(0.9),
invert: true,
};
if (arch === 'dense') {
Object.assign(p, {
primaries: ri(26, 40), burst: r(0.85, 1.0), vdecay: ri(4, 8),
cosmics: ri(8, 14), sweepers: ri(5, 9), bfield: r(1.4, 2.4),
deltaRate: r(0.85, 1.0), deltaTight: r(0.8, 1.2),
bgEvents: ri(7, 11), density: r(1.25, 1.5), shockIntensity: r(0.6, 0.85),
});
} else if (arch === 'cosmic') {
Object.assign(p, {
primaries: ri(4, 10), burst: r(0.25, 0.55), vdecay: ri(0, 2),
cosmics: ri(10, 16), sweepers: ri(7, 11), bfield: r(0.6, 1.1),
pspread: r(0.8, 0.95), deltaRate: r(0.35, 0.6), instrument: r(0.45, 0.7),
shock: chance(0.4), shockIntensity: r(0.3, 0.7), bgEvents: ri(2, 5),
density: r(0.85, 1.1),
});
} else if (arch === 'clean') {
Object.assign(p, {
primaries: ri(8, 14), burst: r(0.4, 0.65), vdecay: ri(1, 3),
cosmics: ri(1, 4), sweepers: ri(1, 4), deltaRate: r(0.35, 0.6),
deltaTight: r(0.55, 0.8), shock: chance(0.6), shockIntensity: r(0.3, 0.7),
shockStain: Math.pow(rng(), 3), // mostly clean disk
instrument: r(0.1, 0.3), bgEvents: ri(0, 3), bgIntensity: r(0.2, 0.35),
bloom: r(0.25, 0.45), mottle: r(0.15, 0.4), grain: r(0.12, 0.35),
vign: r(0.2, 0.4), artifacts: r(0.1, 0.4),
});
} else if (arch === 'negative') {
Object.assign(p, { invert: false, vign: r(0.4, 0.6), shockStain: Math.pow(rng(), 1.3) });
}
p.archetype = arch;
return p;
}
Object.assign(exports, { archetypeOf, paramsFromSeed });
};
__require("src/main.js");
})();
</script>
</body>
</html>