Files
bubblechambersimart/bubble_chamber.html

2242 lines
92 KiB
HTML
Raw Normal View History

2026-05-20 16:53:23 -04:00
<!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');
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,
});
}
return { x, y, r, intensity: I, stain, striations, rimSegs, rings, stains, core, bright: r * 0.12 };
}
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);
/* ---------- Pass 4: halation / bloom ---------- */
// composite a blurred copy of the ink under the sharp ink for soft spread
if (params.bloom > 0) {
ctx.save();
ctx.globalCompositeOperation = P.inkBlend;
ctx.globalAlpha = Math.min(0.9, params.bloom * 0.7);
ctx.filter = blur(pre ? 2.4 : 3.0);
ctx.drawImage(ink, 0, 0);
ctx.filter = 'none';
ctx.restore();
}
/* ---------- Pass 5: composite sharp ink onto paper (one soft-focus blur) ---------- */
ctx.save();
ctx.globalCompositeOperation = P.inkBlend;
ctx.globalAlpha = 1;
ctx.filter = blur(pre ? 0.5 : 0.7); // single whole-layer blur, not per-stamp
ctx.drawImage(ink, 0, 0);
ctx.filter = 'none';
ctx.restore();
/* ---------- Pass 6: plate damage ---------- */
drawDamage(ctx, scene.artifacts, tx, ty, scale, u, P, inv);
/* ---------- Pass 7: fiducials, boundary & archival header ---------- */
drawFiducialsBoundary(ctx, w, h, params, scale, cx, cy, tx, ty, u, P, inv);
if (params.showHeader) drawHeader(ctx, w, h, scene, params, u, P);
/* ---------- Pass 8: vignette ---------- */
if (params.vign > 0) {
const vg = ctx.createRadialGradient(w / 2, h / 2, w * 0.28, w / 2, h / 2, w * 0.72);
const [vr, vgc, vb] = P.vign;
vg.addColorStop(0, `rgba(${vr},${vgc},${vb},0)`);
vg.addColorStop(1, `rgba(${vr},${vgc},${vb},${params.vign * (inv ? 0.5 : 0.85)})`);
ctx.fillStyle = vg;
ctx.fillRect(0, 0, w, h);
}
/* ---------- Pass 9: film grain ---------- */
if (params.grain > 0) {
ctx.save();
ctx.globalCompositeOperation = 'overlay';
ctx.globalAlpha = Math.min(0.85, params.grain * 0.7);
// tile the grain canvas across at ~1:1.6 so clumps are supra-pixel
const gsz = noiseCache.grain.width;
const tile = gsz * 1.6;
for (let gy = 0; gy < h; gy += tile) {
for (let gx = 0; gx < w; gx += tile) {
ctx.drawImage(noiseCache.grain, gx, gy, tile, tile);
}
}
ctx.restore();
}
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
}
/* ---- shock disk ---- */
function drawShock(c, shock, tx, ty, scale, u, P) {
const [r, g, b] = P.ink;
const px = tx(shock.x), py = ty(shock.y), R = shock.r * scale;
// disk body: dark annulus that keeps a lighter, detailed centre
const core = c.createRadialGradient(px, py, 0, px, py, R);
core.addColorStop(0.0, `rgba(${r},${g},${b},${0.06 * shock.intensity})`);
core.addColorStop(0.35, `rgba(${r},${g},${b},${0.18 * shock.intensity})`);
core.addColorStop(0.72, `rgba(${r},${g},${b},${0.38 * shock.intensity})`);
core.addColorStop(0.94, `rgba(${r},${g},${b},${0.34 * shock.intensity})`);
core.addColorStop(1, `rgba(${r},${g},${b},0)`);
c.fillStyle = core;
c.beginPath(); c.arc(px, py, R, 0, Math.PI * 2); c.fill();
// radial striations (the sunburst)
c.lineCap = 'round';
for (const s of shock.striations) {
const ix = px + Math.cos(s.a) * s.inner * scale;
const iy = py + Math.sin(s.a) * s.inner * scale;
const ox = px + Math.cos(s.a) * s.outer * scale;
const oy = py + Math.sin(s.a) * s.outer * scale;
const mx = (ix + ox) / 2 + Math.cos(s.a + Math.PI / 2) * s.wobble * scale;
const my = (iy + oy) / 2 + Math.sin(s.a + Math.PI / 2) * s.wobble * scale;
c.strokeStyle = `rgba(${r},${g},${b},${s.opacity})`;
c.lineWidth = s.width * u;
c.beginPath();
c.moveTo(ix, iy);
c.quadraticCurveTo(mx, my, ox, oy);
c.stroke();
}
// inner pressure-front rings
for (const ring of shock.rings) {
c.strokeStyle = `rgba(${r},${g},${b},${ring.opacity})`;
c.lineWidth = ring.width * u;
c.beginPath(); c.arc(px, py, ring.rr * scale, 0, Math.PI * 2); c.stroke();
}
// rim as eroded arc segments
if (shock.rimSegs) {
for (const seg of shock.rimSegs) {
c.strokeStyle = `rgba(${r},${g},${b},${seg.opacity})`;
c.lineWidth = seg.width * u;
c.beginPath(); c.arc(px, py, shock.r * scale, seg.a0, seg.a1); c.stroke();
}
}
// staining blotches: dark = grime, light = lifted/washed clean spots
if (shock.stains) {
for (const st of shock.stains) {
const sr = st.r * scale;
const grad = c.createRadialGradient(tx(st.x), ty(st.y), 0, tx(st.x), ty(st.y), sr);
if (st.dark) {
c.globalCompositeOperation = 'source-over';
grad.addColorStop(0, `rgba(${r},${g},${b},${st.opacity})`);
grad.addColorStop(1, `rgba(${r},${g},${b},0)`);
c.fillStyle = grad;
} else {
c.globalCompositeOperation = 'destination-out';
grad.addColorStop(0, `rgba(0,0,0,${st.opacity * 1.4})`);
grad.addColorStop(1, 'rgba(0,0,0,0)');
c.fillStyle = grad;
}
c.beginPath(); c.arc(tx(st.x), ty(st.y), sr, 0, Math.PI * 2); c.fill();
}
c.globalCompositeOperation = 'source-over';
}
// textured core chords
for (const k of shock.core) {
c.strokeStyle = `rgba(${r},${g},${b},${k.opacity})`;
c.lineWidth = k.width * u;
c.beginPath(); c.moveTo(tx(k.x1), ty(k.y1)); c.lineTo(tx(k.x2), ty(k.y2)); c.stroke();
}
// keep a bright, detailed centre by clearing a soft hole in the ink
if (shock.bright) {
c.save();
c.globalCompositeOperation = 'destination-out';
const hole = c.createRadialGradient(px, py, 0, px, py, shock.bright * scale);
hole.addColorStop(0, 'rgba(0,0,0,0.85)');
hole.addColorStop(1, 'rgba(0,0,0,0)');
c.fillStyle = hole;
c.beginPath(); c.arc(px, py, shock.bright * scale, 0, Math.PI * 2); c.fill();
c.restore();
}
}
/* ---- plate damage ---- */
function drawDamage(ctx, A, tx, ty, scale, u, P, inv) {
if (!A) return;
const [r, g, b] = P.ink;
ctx.save();
// water rings (faint, under)
ctx.globalCompositeOperation = inv ? 'multiply' : 'screen';
for (const ring of A.rings) {
ctx.strokeStyle = `rgba(${r},${g},${b},${ring.opacity})`;
ctx.lineWidth = ring.width * u;
ctx.beginPath(); ctx.arc(tx(ring.x), ty(ring.y), ring.r * scale, 0, Math.PI * 2); ctx.stroke();
}
// fingerprints
for (const fp of A.fingerprints) {
ctx.strokeStyle = `rgba(${r},${g},${b},${fp.opacity})`;
ctx.lineWidth = 0.7 * u;
for (const lp of fp.loops) {
ctx.save();
ctx.translate(tx(fp.x), ty(fp.y));
ctx.rotate(lp.rot);
ctx.scale(1, lp.squash);
ctx.beginPath(); ctx.arc(0, 0, lp.rr * scale, 0, Math.PI * 2); ctx.stroke();
ctx.restore();
}
}
// scratches: dark (multiply) and bright (lift)
for (const s of A.scratches) {
ctx.globalCompositeOperation = s.bright ? P.lift : (inv ? 'multiply' : 'screen');
const col = s.bright ? (inv ? '245,242,232' : '0,0,0') : `${r},${g},${b}`;
ctx.strokeStyle = `rgba(${col},${s.opacity})`;
ctx.lineWidth = s.width * u;
ctx.beginPath(); ctx.moveTo(tx(s.x1), ty(s.y1)); ctx.lineTo(tx(s.x2), ty(s.y2)); ctx.stroke();
}
// hairs
ctx.globalCompositeOperation = inv ? 'multiply' : 'screen';
for (const hair of A.hairs) {
ctx.strokeStyle = `rgba(${r},${g},${b},${hair.opacity})`;
ctx.lineWidth = hair.width * u;
ctx.beginPath();
ctx.moveTo(tx(hair.pts[0].x), ty(hair.pts[0].y));
for (let i = 1; i < hair.pts.length; i++) ctx.lineTo(tx(hair.pts[i].x), ty(hair.pts[i].y));
ctx.stroke();
}
// dust specks (over)
for (const sp of A.specks) {
ctx.fillStyle = `rgba(${r},${g},${b},${sp.opacity})`;
ctx.beginPath(); ctx.arc(tx(sp.x), ty(sp.y), Math.max(sp.r * scale, 0.4), 0, Math.PI * 2); ctx.fill();
}
ctx.restore();
}
/* ---- chamber optics / structural geometry ---- */
function drawInstrument(ctx, inst, tx, ty, scale, u, P, inv) {
const [r, g, b] = P.ink;
ctx.save();
ctx.globalCompositeOperation = inv ? 'multiply' : 'screen';
ctx.lineCap = 'round';
ctx.filter = `blur(${(0.4 * u).toFixed(2)}px)`;
for (const l of inst.lines) {
ctx.strokeStyle = `rgba(${r},${g},${b},${l.opacity})`;
ctx.lineWidth = l.width * u;
ctx.beginPath(); ctx.moveTo(tx(l.x1), ty(l.y1)); ctx.lineTo(tx(l.x2), ty(l.y2)); ctx.stroke();
}
for (const a of inst.arcs) {
ctx.strokeStyle = `rgba(${r},${g},${b},${a.opacity})`;
ctx.lineWidth = a.width * u;
ctx.beginPath();
ctx.moveTo(tx(a.pts[0].x), ty(a.pts[0].y));
for (let i = 1; i < a.pts.length; i++) ctx.lineTo(tx(a.pts[i].x), ty(a.pts[i].y));
ctx.stroke();
}
ctx.restore();
}
/* ---- archival header (rendered text, baked into the plate) ---- */
function drawHeader(ctx, w, h, scene, params, u, P) {
const [r, g, b] = P.ink;
ctx.save();
ctx.globalCompositeOperation = params.invert ? 'multiply' : 'screen';
ctx.fillStyle = `rgba(${r},${g},${b},0.62)`;
const pad = 26 * u;
ctx.font = `${11 * u}px 'JetBrains Mono', ui-monospace, monospace`;
ctx.textBaseline = 'top';
ctx.fillText(scene.lab.toUpperCase(), pad, pad);
ctx.font = `${9 * u}px 'JetBrains Mono', ui-monospace, monospace`;
ctx.fillStyle = `rgba(${r},${g},${b},0.5)`;
ctx.fillText(`SEED ${params.seed}`, pad, pad + 16 * u);
ctx.textAlign = 'right';
ctx.textBaseline = 'bottom';
ctx.font = `${10 * u}px 'JetBrains Mono', ui-monospace, monospace`;
ctx.fillStyle = `rgba(${r},${g},${b},0.58)`;
ctx.fillText(`PLATE ${scene.plate}`, w - pad, h - pad - 13 * u);
ctx.fillText(`EXPOSED ${scene.exposure}`, w - pad, h - pad);
ctx.restore();
}
/* ---- fiducials & chamber boundary ---- */
function drawFiducialsBoundary(ctx, w, h, params, scale, cx, cy, tx, ty, u, P, inv) {
const [r, g, b] = P.ink;
if (params.showBoundary) {
ctx.strokeStyle = `rgba(${r},${g},${b},0.4)`;
ctx.lineWidth = 1.4 * u;
ctx.beginPath();
ctx.arc(cx, cy + h * 0.35, w * 0.45, Math.PI * 0.15, Math.PI - Math.PI * 0.15, false);
ctx.stroke();
}
if (params.showFiducials) {
ctx.strokeStyle = `rgba(${r},${g},${b},0.55)`;
ctx.lineWidth = 1 * u;
const fids = [[-0.85, -0.85], [0.85, -0.85], [-0.85, 0.85], [0.85, 0.85], [0, -0.85], [-0.85, 0], [0.85, 0]];
const s = 6 * u;
for (const [fx, fy] of fids) {
const px = tx(fx), py = ty(fy);
ctx.beginPath();
ctx.moveTo(px - s, py); ctx.lineTo(px + s, py);
ctx.moveTo(px, py - s); ctx.lineTo(px, py + s);
ctx.stroke();
}
}
}
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;
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 * (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) * 0.0016;
const baseR = (0.0011 + (1 - beta) * 0.0017) * params.size;
// 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;
s += `<circle cx="${px}" cy="${py}" r="${R.toFixed(1)}" fill="url(#shockcore)"/>\n`;
s += `<g stroke="${ink}" stroke-linecap="round" fill="none">\n`;
for (const st of sh.striations) {
const ix = px + Math.cos(st.a) * st.inner * scale, iy = py + Math.sin(st.a) * st.inner * scale;
const ox = px + Math.cos(st.a) * st.outer * scale, oy = py + Math.sin(st.a) * st.outer * scale;
s += `<line x1="${ix.toFixed(1)}" y1="${iy.toFixed(1)}" x2="${ox.toFixed(1)}" y2="${oy.toFixed(1)}" stroke-opacity="${st.opacity.toFixed(3)}" stroke-width="${(st.width * u).toFixed(2)}"/>`;
}
for (const ring of sh.rings) {
s += `<circle cx="${px}" cy="${py}" r="${(ring.rr * scale).toFixed(1)}" stroke-opacity="${ring.opacity.toFixed(3)}" stroke-width="${(ring.width * u).toFixed(2)}"/>`;
}
// eroded rim segments
for (const seg of (sh.rimSegs || [])) {
const x0 = px + Math.cos(seg.a0) * R, y0 = py + Math.sin(seg.a0) * R;
const x1a = px + Math.cos(seg.a1) * R, y1a = py + Math.sin(seg.a1) * R;
s += `<path d="M ${x0.toFixed(1)} ${y0.toFixed(1)} A ${R.toFixed(1)} ${R.toFixed(1)} 0 0 1 ${x1a.toFixed(1)} ${y1a.toFixed(1)}" stroke-opacity="${seg.opacity.toFixed(3)}" stroke-width="${(seg.width * u).toFixed(2)}"/>`;
}
for (const k of sh.core) {
s += `<line x1="${tx(k.x1)}" y1="${ty(k.y1)}" x2="${tx(k.x2)}" y2="${ty(k.y2)}" stroke-opacity="${k.opacity.toFixed(3)}" stroke-width="${(k.width * u).toFixed(2)}"/>`;
}
s += `\n</g>\n`;
// staining blotches (dark grime / light washed spots)
for (const st of (sh.stains || [])) {
const sr = (st.r * scale).toFixed(1);
s += `<circle cx="${tx(st.x)}" cy="${ty(st.y)}" r="${sr}" fill="url(#${st.dark ? 'shockstain' : 'shockclean'})" fill-opacity="${st.opacity.toFixed(3)}"/>`;
}
s += `\n`;
}
// bubbles, bucketed by opacity
const buckets = new Map();
const bubbleRng = makeRng(params.seed, 'bubbles');
for (const track of scene.tracks) {
const key = Math.round(Math.min(1, 0.45 + track.weight * 0.5) * 20) / 20;
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key).push(...sampleBubbles(track, params, bubbleRng));
}
for (const [alpha, bubs] of buckets) {
s += `<g fill="url(#bub)" fill-opacity="${alpha}">`;
for (const b of bubs) {
const r = Math.max(b.r * scale * 1.15, 0.5);
s += `<circle cx="${tx(b.x)}" cy="${ty(b.y)}" r="${r.toFixed(2)}"/>`;
}
s += `</g>\n`;
}
// plate damage
const A = scene.artifacts;
if (A) {
s += `<g stroke="${ink}" fill="none" stroke-linecap="round">\n`;
for (const ring of A.rings)
s += `<circle cx="${tx(ring.x)}" cy="${ty(ring.y)}" r="${(ring.r * scale).toFixed(1)}" stroke-opacity="${ring.opacity.toFixed(3)}" stroke-width="${(ring.width * u).toFixed(2)}"/>`;
for (const sc of A.scratches)
s += `<line x1="${tx(sc.x1)}" y1="${ty(sc.y1)}" x2="${tx(sc.x2)}" y2="${ty(sc.y2)}" stroke-opacity="${sc.opacity.toFixed(3)}" stroke-width="${(sc.width * u).toFixed(2)}"/>`;
for (const hair of A.hairs) {
let d = `M ${tx(hair.pts[0].x)} ${ty(hair.pts[0].y)}`;
for (let i = 1; i < hair.pts.length; i++) d += ` L ${tx(hair.pts[i].x)} ${ty(hair.pts[i].y)}`;
s += `<path d="${d}" stroke-opacity="${hair.opacity.toFixed(3)}" stroke-width="${(hair.width * u).toFixed(2)}"/>`;
}
s += `\n</g>\n`;
s += `<g fill="${ink}">`;
for (const sp of A.specks)
s += `<circle cx="${tx(sp.x)}" cy="${ty(sp.y)}" r="${Math.max(sp.r * scale, 0.5).toFixed(2)}" fill-opacity="${sp.opacity.toFixed(3)}"/>`;
s += `</g>\n`;
}
// fiducials
if (params.showFiducials) {
s += `<g stroke="${ink}" stroke-opacity="0.55" stroke-width="${1.2 * u}">\n`;
const fids = [[-0.85, -0.85], [0.85, -0.85], [-0.85, 0.85], [0.85, 0.85], [0, -0.85], [-0.85, 0], [0.85, 0]];
const sz = 9 * u;
for (const [fx, fy] of fids) {
const px = +tx(fx), py = +ty(fy);
s += `<line x1="${px - sz}" y1="${py}" x2="${px + sz}" y2="${py}"/><line x1="${px}" y1="${py - sz}" x2="${px}" y2="${py + sz}"/>`;
}
s += `\n</g>\n`;
}
if (params.vign > 0) s += `<rect width="${w}" height="${h}" fill="url(#vign)"/>\n`;
// archival header
if (params.showHeader) {
const pad = 26 * u;
const esc = (t) => String(t).replace(/[<&]/g, c => (c === '<' ? '&lt;' : '&amp;'));
s += `<g fill="${ink}" font-family="'JetBrains Mono', monospace">\n`;
s += `<text x="${pad.toFixed(0)}" y="${(pad + 11 * u).toFixed(0)}" font-size="${(11 * u).toFixed(0)}" fill-opacity="0.62">${esc(scene.lab.toUpperCase())}</text>`;
s += `<text x="${pad.toFixed(0)}" y="${(pad + 27 * u).toFixed(0)}" font-size="${(9 * u).toFixed(0)}" fill-opacity="0.5">SEED ${esc(params.seed)}</text>`;
s += `<text x="${(w - pad).toFixed(0)}" y="${(h - pad - 13 * u).toFixed(0)}" font-size="${(10 * u).toFixed(0)}" text-anchor="end" fill-opacity="0.58">PLATE ${scene.plate}</text>`;
s += `<text x="${(w - pad).toFixed(0)}" y="${(h - pad).toFixed(0)}" font-size="${(10 * u).toFixed(0)}" text-anchor="end" fill-opacity="0.58">EXPOSED ${scene.exposure}</text>`;
s += `\n</g>\n`;
}
s += `</svg>\n`;
return s;
}
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);
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: '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, 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>