Files
bubblechambersimart/bubble_chamber.html
2026-05-22 07:50:39 -04:00

3244 lines
144 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, SELECTS, 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 = {};
const selectDefs = {};
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');
}
if (g.title === 'Colour & Paper') {
addSelect(grp, 'palette');
addSelect(grp, 'paperTone');
}
if (g.title === 'Media & Hand') {
addToggle(grp, 'filmEdge');
addToggle(grp, 'splice');
}
}
}
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 addSelect(grp, id) {
const def = SELECTS.find(s => s.id === id);
selectDefs[id] = def;
const row = el('div', 'row');
row.appendChild(el('label', null, def.label));
grp.appendChild(row);
const sel = document.createElement('select');
sel.className = 'preset'; sel.id = id;
for (const [v, label] of def.options) {
const o = document.createElement('option'); o.value = v; o.textContent = label;
if (v === def.value) o.selected = true;
sel.appendChild(o);
}
grp.appendChild(sel);
}
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;
for (const id of Object.keys(selectDefs)) p[id] = document.getElementById(id).value;
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'));
}
for (const [id, def] of Object.entries(selectDefs)) {
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 { generateMedia } = __require("src/scene/media.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).
eventZ = depth in the chamber (0 = focal plane), eventAge = how early in the
exposure it happened (0 = current trigger). Both are stamped on every track the
event produces so the renderers can vary opacity / size / softness with depth
and time rather than treating the event as flat. */
function generateOneEvent(params, vertex, intensity, salt, eventZ = 0, eventAge = 0) {
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, q });
// δ-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, q: -1 });
}
}
}
}
}
// 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, q });
}
}
// 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.pts.length > 10) tracks.push({ pts: d.pts, kind: 'vdecay', weight: intensity * 0.95, q: d.q });
});
}
}
// stamp depth & age. Tracks of one interaction fan out in 3D, so they reach a
// range of depths around the event's base z — this is what gives each trail its
// own opacity/softness rather than a flat event. δ-rays get extra spread (they
// scatter off into the volume).
for (const t of tracks) {
const spread = t.kind === 'delta' ? 0.45 : 0.3;
t.z = Math.max(-1.2, Math.min(1.2, eventZ + gauss(rng) * spread));
t.age = eventAge;
}
return tracks;
}
function generateScene(params) {
const rng = makeRng(params.seed, 'scene');
const tracks = [];
// Foreground event, slightly off-centre — near the focal plane, current trigger
const fgVertex = { x: (rng() - 0.5) * 0.3, y: (rng() - 0.5) * 0.3 };
const fgZ = gauss(rng) * 0.12;
tracks.push(...generateOneEvent(params, fgVertex, 1.0, 'fg', fgZ, 0));
// Background "history" events — scattered through depth and earlier in time
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);
const z = (bgRng() * 2 - 1); // anywhere in depth
const age = 0.25 + bgRng() * 0.7; // older than the trigger
tracks.push(...generateOneEvent(
{ ...params, primaries: Math.round(params.primaries * 0.6), vdecay: 0 },
{ x: vx, y: vy }, intensity, 'bg' + i, z, age
));
}
// 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, q } = cosmicTrack(params, cRng);
if (pts.length > 8) tracks.push({ pts, kind: 'cosmic', weight: 0.7 + cRng() * 0.3, q, z: gauss(cRng) * 0.5, age: 0.1 + 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, q } = sweeperTrack(params, sRng);
if (pts.length > 8) tracks.push({ pts, kind: 'sweep', weight: 0.75 + sRng() * 0.25, q, z: gauss(sRng) * 0.4, age: 0.05 + sRng() * 0.2 });
}
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);
const out = { tracks, vertex: fgVertex, shock, artifacts, instrument, hash, plate, exposure, lab };
// physical-artifact & human layer (grease pencil, film furniture, réseau, splice)
out.media = generateMedia(params, out, makeRng(params.seed, 'media'));
return out;
}
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;
const pts = integrateTrack(
{ x: e.x, y: e.y, theta: e.theta, p, q },
{ ...params, eloss: params.eloss * 0.15 },
{ maxTravel: 4, bound: 1.2 }
);
return { pts, q };
}
/* 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;
const pts = integrateTrack(
{ x: e.x, y: e.y, theta: e.theta, p, q },
{ ...params, eloss: params.eloss * 0.3 },
{ maxTravel: 5.5, bound: 1.2 }
);
return { pts, q };
}
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 [{ pts: t1, q: +1 }, { pts: t2, q: -1 }];
}
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/scene/media.js"] = function (module, exports, __require) {
/* ============================================================
media.js — the physical-artifact & human layer.
What a real bubble-chamber frame carried beyond the tracks:
• grease-pencil (chinagraph) hand marks — a scanner ringing a
"good event", an arrow, the event number, an angle/ticks;
• film furniture — sprocket holes, a data box (roll/frame/
date), edge stock printing, a frame rebate;
• a réseau — the precise grid of small crosses imaged for
distortion correction;
• a tape splice — long rolls were cut and joined, so a strip
is literally taped together, often slightly misregistered.
All seeded. Geometry in the logical [-1,1] square.
============================================================ */
const { gauss, pick, chance } = __require("src/rng.js");
const TWO_PI = Math.PI * 2;
function wobblyRing(cx, cy, r, rng, wob = 0.07) {
const n = 52, pts = [];
const start = rng() * TWO_PI;
const turn = TWO_PI * (1.04 + rng() * 0.13); // overshoots — doesn't close cleanly
const dx = gauss(rng) * r * 0.05, dy = gauss(rng) * r * 0.05;
for (let i = 0; i <= n; i++) {
const a = start + turn * (i / n);
const rr = r * (1 + gauss(rng) * wob);
pts.push({ x: cx + Math.cos(a) * rr + dx * (i / n), y: cy + Math.sin(a) * rr + dy * (i / n) });
}
return pts;
}
function wobblyLine(x1, y1, x2, y2, rng, seg = 9, amp = 0.006) {
const pts = [];
for (let i = 0; i <= seg; i++) {
const t = i / seg;
pts.push({ x: x1 + (x2 - x1) * t + gauss(rng) * amp, y: y1 + (y2 - y1) * t + gauss(rng) * amp });
}
return pts;
}
function generateMedia(params, scene, rng) {
const out = { grease: [], reseau: null, film: null, splice: null };
/* ---- grease-pencil (chinagraph) hand marks ---- */
if ((params.annotate ?? 0) > 0) {
const full = params.annotate >= 0.66; // restrained vs studied
const v = scene.vertex || { x: 0, y: 0 };
// ring a δ-ray curl if there is one, else the vertex region
const deltas = scene.tracks.filter(t => t.kind === 'delta' && t.pts.length > 6);
let cx, cy, cr;
if (deltas.length && chance(rng, 0.75)) {
const d = pick(rng, deltas), p = d.pts[Math.floor(d.pts.length * 0.4)];
cx = p.x; cy = p.y; cr = 0.05 + rng() * 0.035;
} else { cx = v.x + gauss(rng) * 0.05; cy = v.y + gauss(rng) * 0.05; cr = 0.11 + rng() * 0.05; }
out.grease.push({ kind: 'ring', pts: wobblyRing(cx, cy, cr, rng), width: 2.4 });
// arrow from open space toward the ring
const side = cx <= 0 ? 1 : -1;
const ax = cx + side * (0.34 + rng() * 0.12), ay = cy - (0.30 + rng() * 0.12);
const tipx = cx - side * cr * 1.25, tipy = cy - cr * 1.15;
const ang = Math.atan2(tipy - ay, tipx - ax);
out.grease.push({ kind: 'arrow', shaft: wobblyLine(ax, ay, tipx, tipy, rng), tip: { x: tipx, y: tipy, ang }, width: 2.0 });
// event number by the ring
out.grease.push({ kind: 'text', x: cx + cr * 1.05, y: cy - cr * 1.05, text: `${scene.plate}`, size: 0.045, rot: -0.05 + gauss(rng) * 0.03 });
if (full) {
// angle arc + reading at the vertex
const a0 = rng() * TWO_PI, a1 = a0 + (0.5 + rng() * 0.6);
out.grease.push({ kind: 'arc', x: v.x, y: v.y, r: 0.12 + rng() * 0.05, a0, a1, width: 1.6 });
out.grease.push({ kind: 'text', x: v.x + 0.15, y: v.y + 0.03, text: `${Math.round((a1 - a0) * 57)}°`, size: 0.034, rot: 0 });
// a few scale ticks along a primary track
const prim = scene.tracks.find(t => t.kind === 'primary' && t.pts.length > 30);
if (prim) {
for (let k = 0; k < 4; k++) {
const p = prim.pts[Math.floor(prim.pts.length * (0.3 + k * 0.15))];
const th = (p.theta ?? 0) + Math.PI / 2, tl = 0.018;
out.grease.push({ kind: 'tick', x1: p.x - Math.cos(th) * tl, y1: p.y - Math.sin(th) * tl, x2: p.x + Math.cos(th) * tl, y2: p.y + Math.sin(th) * tl, width: 1.2 });
}
}
// a margin scrawl
out.grease.push({ kind: 'text', x: -0.92, y: 0.9, text: pick(rng, ['good event', 'check θ', 'V⁰ ?', 'measure', 're-scan']), size: 0.04, rot: 0.04 });
}
}
/* ---- réseau grid (distortion-correction crosses) ---- */
if ((params.reseau ?? 0) > 0) {
const step = 0.1818, marks = [];
for (let gx = -0.9; gx <= 0.901; gx += step)
for (let gy = -0.9; gy <= 0.901; gy += step) marks.push({ x: gx, y: gy });
out.reseau = { marks, size: 0.011, opacity: 0.32 * params.reseau };
}
/* ---- film furniture ---- */
if (params.filmEdge) {
const n = 9, sprockets = [];
for (let i = 0; i < n; i++) sprockets.push(-0.9 + 1.8 * (i / (n - 1)));
const roll = (parseInt(scene.hash.slice(0, 4), 16) % 900 + 100);
out.film = {
sprockets,
edgeText: 'KODAK SAFETY FILM',
dataBox: [`ROLL ${roll}`, `FRAME ${scene.plate}`, scene.exposure],
};
}
/* ---- tape splice ---- */
if (params.splice) {
out.splice = { y: -0.05 + gauss(rng) * 0.28, h: 0.05 + rng() * 0.025, tilt: gauss(rng) * 0.02, opacity: 0.16 + rng() * 0.06 };
}
return out;
}
Object.assign(exports, { generateMedia });
};
__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, depthFactors } = __require("src/scene/bubbles.js");
const { mottleCanvas, grainCanvas } = __require("src/render/noise.js");
const { resolvePalette, paperTone, rgbCss, bubbleStops, bubbleFoot, hslToRgb, mix } = __require("src/render/palette.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 };
/* soft-dot sprite at a continuous softness (base bubble-edge softness + per-track
depth-of-field defocus folded into one value). Cached per ink colour + softness. */
const spriteCache = new Map();
function softDotSprite(inkRGB, soft = 0.3) {
const sq = Math.round(Math.max(0, Math.min(1.6, soft)) * 10) / 10; // quantise → bounded cache
const key = inkRGB.join(',') + '|' + sq;
if (spriteCache.has(key)) return spriteCache.get(key);
const S = 64, 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;
for (const [off, a] of bubbleStops(sq)) grad.addColorStop(off, `rgba(${r},${gr},${b},${a})`);
g.fillStyle = grad;
g.fillRect(0, 0, S, S);
spriteCache.set(key, c);
return c;
}
function renderCanvasPhoto(ctx, w, h, scene, params, opts = {}) {
const pre = opts.preview !== false;
const inv = params.invert;
const P = palette(inv);
// background toning/shading (independent of the ink palette) + colour "feel"
const pt = paperTone(params, inv);
const pal = resolvePalette(params.palette, {
inv, sat: params.saturation ?? 1, hue: (params.hueShift ?? 0) * 360, cycles: params.hueCycles ?? 3,
baseInk: P.ink,
basePaper: { flat: pt.flat, glowIn: pt.glowIn, glowOut: pt.glowOut }, // rgb arrays
baseVign: pt.vign,
});
const pap = pal.paper(); // {flat,glowIn,glowOut} rgb arrays
// pick blend by the actual ground luminance (not the invert flag) so palette
// chemistries (e.g. cyanotype: light ink on dark blue) composite correctly.
const light = !pal.darkGround();
const inkBlend = light ? 'multiply' : 'screen';
const Pf = { ...P, ink: pal.feature() }; // palette for non-particle features
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 = rgbCss(pap.flat);
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, rgbCss(pap.glowIn));
glow.addColorStop(1, rgbCss(pap.glowOut));
ctx.fillStyle = glow;
ctx.fillRect(0, 0, w, h);
/* ---------- Pass 2: tonal mottle (uneven development) ---------- */
if (params.mottle > 0) {
ctx.save();
ctx.globalCompositeOperation = light ? '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, Pf, light);
/* ---------- Pass 3: ink layer (offscreen, transparent) ---------- */
const ink = document.createElement('canvas');
ink.width = w; ink.height = h;
const ic = ink.getContext('2d');
const baseSoft = params.bubbleSoft ?? 0.3; // global bubble-edge softness
const sprite = softDotSprite(pal.feature(), baseSoft); // for the shock disk (a feature)
// 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
// (depth-of-field via pre-blurred sprite bands), plus one whole-layer blur at
// composite time (Pass 5).
// 3a. continuity under-stroke beneath each track (opacity from depth/age tone)
ic.lineCap = 'round'; ic.lineJoin = 'round';
for (const track of scene.tracks) {
if (track.pts.length < 2) continue;
const iw = trackInkWeight(track);
const df = depthFactors(track, params);
const col = pal.ink(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;
ic.strokeStyle = `rgba(${col[0]},${col[1]},${col[2]},${0.14 * df.tone})`;
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. opacity ← depth/age tone; size ← depth; the
// sprite sharpness band ← depth-of-field softness (no per-stamp filter).
const bubbleRng = makeRng(params.seed, 'bubbles');
for (const track of scene.tracks) {
const df = depthFactors(track, params);
const bubs = sampleBubbles({ ...track, sizeScale: df.sizeScale }, params, bubbleRng);
// base edge softness + depth-of-field defocus → one continuous softness
const eff = Math.min(1.5, baseSoft + df.softness * 0.4);
const foot = bubbleFoot(eff);
const spTrack = pal.perBubble ? null : softDotSprite(pal.ink(track), eff);
ic.globalAlpha = Math.min(1, 0.36 + df.tone * 0.58);
for (const b of bubs) {
const sp = spTrack || softDotSprite(pal.bubbleInk(track, b.life, b.beta), eff);
const rr = Math.max(b.r * scale, 0.45);
const d = rr * foot;
ic.drawImage(sp, 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, Pf, params, sprite);
/* ---------- Pass 3.5: chromatic halo (a hue-shifted aura around trails) ---------- */
// recolour a copy of the ink to the halo hue, then composite it wide+blurred
// beneath the sharp ink — a coloured drop-shadow on light grounds, glow on dark.
if (params.halo > 0) {
const haloCol = hslToRgb((((params.haloHue ?? 0.55) % 1) + 1) % 1, 0.72, light ? 0.45 : 0.62);
const hcv = document.createElement('canvas');
hcv.width = w; hcv.height = h;
const hcx = hcv.getContext('2d');
hcx.drawImage(ink, 0, 0);
hcx.globalCompositeOperation = 'source-in';
hcx.fillStyle = rgbCss(haloCol);
hcx.fillRect(0, 0, w, h);
ctx.save();
ctx.globalCompositeOperation = inkBlend;
ctx.globalAlpha = Math.min(0.92, params.halo * 0.85);
ctx.filter = blur((4 + params.halo * 13) * (pre ? 1 : 1.3));
ctx.drawImage(hcv, 0, 0);
ctx.filter = 'none';
ctx.restore();
}
/* ---------- 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 = 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 = 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, Pf, light);
/* ---------- Pass 7: fiducials, boundary & archival header ---------- */
drawFiducialsBoundary(ctx, w, h, params, scale, cx, cy, tx, ty, u, Pf, light);
if (params.showHeader) drawHeader(ctx, w, h, scene, params, u, Pf, light);
/* ---------- 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] = pal.vign();
vg.addColorStop(0, `rgba(${vr},${vgc},${vb},0)`);
vg.addColorStop(1, `rgba(${vr},${vgc},${vb},${params.vign * (light ? 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;
/* ---------- Pass 10: media & hand (réseau, splice, film furniture, grease) ---------- */
if (scene.media) drawMedia(ctx, scene.media, w, h, tx, ty, scale, u, Pf, light);
}
/* ---- media & hand layer ---- */
function drawMedia(ctx, media, w, h, tx, ty, scale, u, P, light) {
const [fr, fg, fb] = P.ink;
const feat = (a) => `rgba(${fr},${fg},${fb},${a})`;
// réseau crosses (imaged grid)
if (media.reseau) {
ctx.save();
ctx.strokeStyle = feat(media.reseau.opacity);
ctx.lineWidth = 1 * u;
const s = media.reseau.size * scale;
for (const m of media.reseau.marks) {
const x = tx(m.x), y = ty(m.y);
ctx.beginPath(); ctx.moveTo(x - s, y); ctx.lineTo(x + s, y); ctx.moveTo(x, y - s); ctx.lineTo(x, y + s); ctx.stroke();
}
ctx.restore();
}
// tape splice
if (media.splice) {
const sp = media.splice, yc = ty(sp.y), hh = sp.h * scale;
ctx.save();
ctx.translate(w / 2, yc); ctx.rotate(sp.tilt);
ctx.globalCompositeOperation = light ? 'multiply' : 'screen';
ctx.fillStyle = feat(sp.opacity);
ctx.fillRect(-w, -hh, w * 2, hh * 2);
ctx.globalCompositeOperation = 'source-over';
ctx.strokeStyle = feat(Math.min(0.6, sp.opacity * 2)); ctx.lineWidth = 1 * u;
ctx.beginPath(); ctx.moveTo(-w, -hh); ctx.lineTo(w, -hh); ctx.moveTo(-w, hh); ctx.lineTo(w, hh); ctx.stroke();
ctx.restore();
}
// film furniture
if (media.film) {
const f = media.film;
ctx.save();
ctx.strokeStyle = feat(0.5); ctx.lineWidth = 2 * u;
const m = 0.965;
ctx.strokeRect(tx(-m), ty(-m), tx(m) - tx(-m), ty(m) - ty(-m));
const sw = 0.028 * scale, sh = 0.05 * scale, rr = 4 * u;
for (const y of f.sprockets) {
for (const sx of [tx(-0.985), tx(0.985)]) {
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(sx - sw / 2, ty(y) - sh / 2, sw, sh, rr);
else ctx.rect(sx - sw / 2, ty(y) - sh / 2, sw, sh);
ctx.fillStyle = light ? 'rgba(250,248,240,0.7)' : 'rgba(18,16,13,0.85)';
ctx.fill();
ctx.strokeStyle = feat(0.4); ctx.lineWidth = 1 * u; ctx.stroke();
}
}
ctx.fillStyle = feat(0.45);
ctx.font = `${7 * u}px 'JetBrains Mono', monospace`;
ctx.textAlign = 'center';
ctx.save(); ctx.translate(tx(-0.935), ty(0)); ctx.rotate(-Math.PI / 2); ctx.fillText(f.edgeText, 0, 0); ctx.restore();
ctx.textAlign = 'left';
ctx.font = `${9 * u}px 'JetBrains Mono', monospace`;
f.dataBox.forEach((line, i) => ctx.fillText(line, tx(-0.9), ty(0.8) + i * 12 * u));
ctx.restore();
}
// grease pencil (chinagraph) on top
if (media.grease && media.grease.length) {
const c = light ? [156, 30, 30] : [240, 226, 150];
const stroke = (a) => `rgba(${c[0]},${c[1]},${c[2]},${a})`;
ctx.save();
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.strokeStyle = stroke(0.85); ctx.fillStyle = stroke(0.9);
for (const g of media.grease) {
if (g.kind === 'ring' || g.kind === 'arrow') {
const pts = g.pts || g.shaft;
ctx.lineWidth = g.width * u;
ctx.beginPath(); ctx.moveTo(tx(pts[0].x), ty(pts[0].y));
for (let i = 1; i < pts.length; i++) ctx.lineTo(tx(pts[i].x), ty(pts[i].y));
ctx.stroke();
if (g.kind === 'arrow') {
const { x, y, ang } = g.tip, hl = 0.03 * scale;
ctx.beginPath();
ctx.moveTo(tx(x), ty(y)); ctx.lineTo(tx(x) - Math.cos(ang - 0.4) * hl, ty(y) - Math.sin(ang - 0.4) * hl);
ctx.moveTo(tx(x), ty(y)); ctx.lineTo(tx(x) - Math.cos(ang + 0.4) * hl, ty(y) - Math.sin(ang + 0.4) * hl);
ctx.stroke();
}
} else if (g.kind === 'arc') {
ctx.lineWidth = g.width * u; ctx.beginPath(); ctx.arc(tx(g.x), ty(g.y), g.r * scale, g.a0, g.a1); ctx.stroke();
} else if (g.kind === 'tick') {
ctx.lineWidth = g.width * u; ctx.beginPath(); ctx.moveTo(tx(g.x1), ty(g.y1)); ctx.lineTo(tx(g.x2), ty(g.y2)); ctx.stroke();
} else if (g.kind === 'text') {
ctx.save(); ctx.translate(tx(g.x), ty(g.y)); ctx.rotate(g.rot || 0);
ctx.font = `${(g.size * scale).toFixed(1)}px 'Bradley Hand','Segoe Script','Comic Sans MS',cursive`;
ctx.fillText(g.text, 0, 0); ctx.restore();
}
}
ctx.restore();
}
}
/* ---- 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;
// when softening, draw the whole disk to its own offscreen and composite it
// back through a single Gaussian blur — softens just this layer's edges.
const soften = (params.diskSoften || 0) * u;
let T = c, tmp = null;
if (soften > 0) {
tmp = document.createElement('canvas');
tmp.width = c.canvas.width; tmp.height = c.canvas.height;
T = tmp.getContext('2d');
}
// disk body: dark annulus that keeps a lighter, detailed centre.
// softer when the line work is bubbles (the bubbles carry the density).
const bodyK = useBubbles ? 0.6 : 1.0;
const core = T.createRadialGradient(px, py, 0, px, py, R);
core.addColorStop(0.0, `rgba(${r},${g},${b},${0.06 * shock.intensity * bodyK})`);
core.addColorStop(0.35, `rgba(${r},${g},${b},${0.18 * shock.intensity * bodyK})`);
core.addColorStop(0.72, `rgba(${r},${g},${b},${0.38 * shock.intensity * bodyK})`);
core.addColorStop(0.94, `rgba(${r},${g},${b},${0.34 * shock.intensity * bodyK})`);
core.addColorStop(1, `rgba(${r},${g},${b},0)`);
T.fillStyle = core;
T.beginPath(); T.arc(px, py, R, 0, Math.PI * 2); T.fill();
if (useBubbles && shock.bubbleStrokes) {
// describe striations / rings / rim / core with the particle method
const dRng = makeRng(params.seed, 'diskbubbles');
for (const stroke of shock.bubbleStrokes) {
const bubs = sampleBubbles(stroke, params, dRng);
T.globalAlpha = Math.min(1, 0.45 + stroke.weight * 0.5);
for (const bb of bubs) {
const rr = Math.max(bb.r * scale, 0.45);
const d = rr * 2.4;
T.drawImage(sprite, tx(bb.x) - d / 2, ty(bb.y) - d / 2, d, d);
}
}
T.globalAlpha = 1;
} else {
// clean vector strokes — optionally iridescent (hue across the sunburst)
const spec = params.diskSpectrum || 0;
const TWO_PI = Math.PI * 2;
const specCol = (frac) => spec <= 0 ? [r, g, b]
: mix([r, g, b], hslToRgb((((frac + (params.hueShift || 0)) % 1) + 1) % 1, 0.85 * (params.saturation ?? 1), 0.52), spec);
const rs = (col, op) => `rgba(${col[0]},${col[1]},${col[2]},${op})`;
T.lineCap = 'round';
for (const s of shock.striations) {
const ix = px + Math.cos(s.a) * s.inner * scale, iy = py + Math.sin(s.a) * s.inner * scale;
const ox = px + Math.cos(s.a) * s.outer * scale, oy = py + Math.sin(s.a) * s.outer * scale;
const mx = (ix + ox) / 2 + Math.cos(s.a + Math.PI / 2) * s.wobble * scale;
const my = (iy + oy) / 2 + Math.sin(s.a + Math.PI / 2) * s.wobble * scale;
T.strokeStyle = rs(specCol(s.a / TWO_PI), s.opacity);
T.lineWidth = s.width * u;
T.beginPath(); T.moveTo(ix, iy); T.quadraticCurveTo(mx, my, ox, oy); T.stroke();
}
for (const ring of shock.rings) {
T.strokeStyle = rs(specCol(ring.rr / shock.r), ring.opacity);
T.lineWidth = ring.width * u;
T.beginPath(); T.arc(px, py, ring.rr * scale, 0, Math.PI * 2); T.stroke();
}
for (const seg of (shock.rimSegs || [])) {
T.strokeStyle = rs(specCol(seg.a0 / TWO_PI), seg.opacity);
T.lineWidth = seg.width * u;
T.beginPath(); T.arc(px, py, shock.r * scale, seg.a0, seg.a1); T.stroke();
}
for (const k of shock.core) {
const ca = Math.atan2(k.y1 - shock.y, k.x1 - shock.x);
T.strokeStyle = rs(specCol(ca / TWO_PI), k.opacity);
T.lineWidth = k.width * u;
T.beginPath(); T.moveTo(tx(k.x1), ty(k.y1)); T.lineTo(tx(k.x2), ty(k.y2)); T.stroke();
}
}
// 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 = T.createRadialGradient(tx(st.x), ty(st.y), 0, tx(st.x), ty(st.y), sr);
if (st.dark) {
T.globalCompositeOperation = 'source-over';
grad.addColorStop(0, `rgba(${r},${g},${b},${st.opacity})`);
grad.addColorStop(1, `rgba(${r},${g},${b},0)`);
T.fillStyle = grad;
} else {
T.globalCompositeOperation = 'destination-out';
grad.addColorStop(0, `rgba(0,0,0,${st.opacity * 1.4})`);
grad.addColorStop(1, 'rgba(0,0,0,0)');
T.fillStyle = grad;
}
T.beginPath(); T.arc(tx(st.x), ty(st.y), sr, 0, Math.PI * 2); T.fill();
}
T.globalCompositeOperation = 'source-over';
}
// keep a bright, detailed centre by clearing a soft hole
if (shock.bright) {
T.save();
T.globalCompositeOperation = 'destination-out';
const hole = T.createRadialGradient(px, py, 0, px, py, shock.bright * scale);
hole.addColorStop(0, 'rgba(0,0,0,0.85)');
hole.addColorStop(1, 'rgba(0,0,0,0)');
T.fillStyle = hole;
T.beginPath(); T.arc(px, py, shock.bright * scale, 0, Math.PI * 2); T.fill();
T.restore();
}
// composite the (optionally blurred) disk back onto the ink layer
if (tmp) {
c.save();
c.filter = `blur(${soften.toFixed(2)}px)`;
c.drawImage(tmp, 0, 0);
c.filter = 'none';
c.restore();
}
}
/* ---- 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, light = true) {
const [r, g, b] = P.ink;
ctx.save();
ctx.globalCompositeOperation = light ? '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;
const denom = Math.max(1, pts.length - 1);
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;
// lifecycle: fractional progress along the track (0 = birth, 1 = death/rest)
// and the local β at this point — for per-bubble colour palettes.
const life = (i - 1 + t) / denom;
bubbles.push({ x: px + nx * j, y: py + ny * j, r, life, beta: a.beta });
}
}
return bubbles;
}
/* Depth & exposure → render scalars for one track.
Driven by the track's chamber depth z (0 = focal plane) and age (0 = current
trigger), modulated by the user's `depth` (focus falloff) and `aging` dials.
Returns:
tone 0..1 ink strength (→ opacity); compounds event intensity (weight)
sizeScale bubble-size multiplier (near = larger, far = smaller)
softness defocus hint in px @1000px (raster only) */
function depthFactors(track, params) {
const z = track.z || 0, age = track.age || 0;
const d = params.depth ?? 0; // focus / depth falloff strength
const ag = params.aging ?? 0; // how strongly older events fade
const dz = Math.abs(z);
const focus = 1 - d * dz * 0.6; // out-of-focus tracks dim
const lit = 1 - d * Math.max(0, z) * 0.3; // far side a touch fainter
const ageFade = 1 - age * ag * 0.7;
const tone = Math.max(0.04, Math.min(1, track.weight * focus * lit * ageFade));
const sizeScale = 1 - d * z * 0.18;
const softness = d * dz * 1.7 + age * ag * 0.8;
return { tone, sizeScale, softness };
}
/* 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, depthFactors, 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/palette.js"] = function (module, exports, __require) {
/* ============================================================
palette.js — pluggable colour "feels".
A palette maps the scene's physics to colour. Renderers resolve
a palette once, then ask it for colours; everything else is a
default that falls back to the monochrome look. Adding a new
feel = adding one entry to PALETTES. Hooks (all optional):
ink(track, env) → [r,g,b] per-track ink (the particle trails)
paper(env) → {flat, glowIn, glowOut} background tones
feature(env) → [r,g,b] non-particle marks (disk, optics, damage,
fiducials, header)
vign(env) → [r,g,b] vignette tint
env = { inv, baseInk:[r,g,b], basePaper:{flat,glowIn,glowOut}, baseVign:[r,g,b] }
Track attributes available to ink(): kind, q (charge ±1), weight, z, age,
pts[i].beta (velocity along the path).
============================================================ */
/* ---------- colour helpers ---------- */
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const lerp = (a, b, t) => a + (b - a) * t;
const mix = (c1, c2, t) => [Math.round(lerp(c1[0], c2[0], t)), Math.round(lerp(c1[1], c2[1], t)), Math.round(lerp(c1[2], c2[2], t))];
/* scale colour saturation/intensity about its own luminance.
s=1 unchanged · s=0 greyscale · s>1 boosted (clamped). */
function saturate(c, s) {
if (s === 1) return c;
const L = 0.30 * c[0] + 0.59 * c[1] + 0.11 * c[2];
return [L + (c[0] - L) * s, L + (c[1] - L) * s, L + (c[2] - L) * s].map(v => clamp(Math.round(v), 0, 255));
}
/* rotate hue by `deg` (CSS hue-rotate matrix). Greys are unchanged, so it only
affects already-coloured palettes — a global "recolour everything" dial. */
function rotateHue(c, deg) {
if (!deg) return c;
const a = deg * Math.PI / 180, cs = Math.cos(a), sn = Math.sin(a);
const m = [
0.213 + cs * 0.787 - sn * 0.213, 0.715 - cs * 0.715 - sn * 0.715, 0.072 - cs * 0.072 + sn * 0.928,
0.213 - cs * 0.213 + sn * 0.143, 0.715 + cs * 0.285 + sn * 0.140, 0.072 - cs * 0.072 - sn * 0.283,
0.213 - cs * 0.213 - sn * 0.787, 0.715 - cs * 0.715 + sn * 0.715, 0.072 + cs * 0.928 + sn * 0.072,
];
const [r, g, b] = c;
return [
clamp(Math.round(r * m[0] + g * m[1] + b * m[2]), 0, 255),
clamp(Math.round(r * m[3] + g * m[4] + b * m[5]), 0, 255),
clamp(Math.round(r * m[6] + g * m[7] + b * m[8]), 0, 255),
];
}
const luminance = (c) => 0.30 * c[0] + 0.59 * c[1] + 0.11 * c[2];
function hslToRgb(h, s, l) {
h = ((h % 1) + 1) % 1;
const a = s * Math.min(l, 1 - l);
const f = (n) => {
const k = (n + h * 12) % 12;
return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
const rgbHex = (c) => '#' + c.map(v => clamp(Math.round(v), 0, 255).toString(16).padStart(2, '0')).join('');
const rgbCss = (c) => 'rgb(' + c.map(v => clamp(Math.round(v), 0, 255)).join(',') + ')';
const rgb01 = (c) => c.map(v => (clamp(v, 0, 255) / 255).toFixed(3));
const rgbKey = (c) => c[0] + '_' + c[1] + '_' + c[2];
/* ---------- background "paper" toning & shading ----------
The substrate is independent of the ink palette: a photographic toner (tint
relative to the default cream), a brightness, and the gas-glow strength. Tints
are multipliers relative to cream, so `cream` at full strength reproduces the
committed default exactly, and the same maths tones a negative (dark) ground. */
// Absolute target paper colours (light ground). Defined outright — not as tints
// of cream — so each tone is genuinely distinct. `cream` is the exact committed
// default; toneStrength blends cream → chosen tone (0 = cream, 1 = full tone).
const TONES = {
cream: { flat: [207, 200, 180], gin: [226, 219, 199], gout: [179, 170, 146], vign: [56, 46, 32] },
sepia: { flat: [205, 173, 128], gin: [224, 195, 150], gout: [168, 134, 92], vign: [60, 42, 22] },
selenium: { flat: [190, 181, 197], gin: [210, 201, 217], gout: [156, 146, 166], vign: [42, 38, 54] },
cool: { flat: [180, 194, 209], gin: [199, 213, 227], gout: [146, 160, 178], vign: [30, 40, 56] },
olive: { flat: [194, 197, 160], gin: [213, 216, 178], gout: [158, 161, 122], vign: [42, 46, 26] },
neutral: { flat: [197, 196, 194], gin: [216, 215, 213], gout: [166, 165, 162], vign: [46, 45, 43] },
};
function paperTone(params, inv) {
const tone = TONES[params.paperTone] || TONES.cream;
const strength = params.toneStrength ?? 1;
const bright = params.paperBright ?? 1;
const g = (params.glow ?? 0.5) / 0.5; // 1 = the default gas-glow strength
// blend cream → tone (cream stays cream at any strength)
let flat = mix(TONES.cream.flat, tone.flat, strength);
let gin = mix(TONES.cream.gin, tone.gin, strength);
let gout = mix(TONES.cream.gout, tone.gout, strength);
let vign = mix(TONES.cream.vign, tone.vign, strength);
if (!inv) {
// negative ground: tint the dark substrate by the tone's hue
const m = (flat[0] + flat[1] + flat[2]) / 3 || 1;
const hue = flat.map(v => v / m);
const dark = (lvl) => hue.map(h => h * lvl);
flat = dark(14); gin = dark(32); gout = dark(6.5); vign = [0, 0, 0];
}
const ap = (c, b = bright) => c.map(v => clamp(Math.round(v * b), 0, 255));
const glowMix = (c) => ap(flat.map((f, i) => f + (c[i] - f) * g));
return { flat: ap(flat), glowIn: glowMix(gin), glowOut: glowMix(gout), vign: ap(vign, 1) };
}
const repBeta = (track) => clamp((track.pts && track.pts[0] && track.pts[0].beta) ?? 0.5, 0, 1);
/* Bubble edge profile, shared by the raster sprite and the SVG gradient so the
softness matches. soft 0 = crisp core (legacy) … ~1.5 = very soft/defocused.
Returns radial-gradient stops [[offset, opacity], …]. */
function bubbleStops(soft) {
const s = clamp(soft, 0, 1.6);
const coreA = 1 - 0.16 * Math.min(s, 1);
const midOff = Math.max(0.18, 0.55 - 0.22 * s);
const midA = Math.max(0.15, 0.9 - 0.32 * s);
return [[0, coreA], [midOff, midA], [1, 0]];
}
const bubbleFoot = (soft) => 2.4 + clamp(soft, 0, 1.6) * 1.5; // sprite footprint grows with softness
/* ---------- the colour mappings ---------- */
// charge duotone: positive = warm, negative = cool (like a field map)
function chargeColor(q, inv) {
const h = q >= 0 ? 0.035 : 0.58;
return hslToRgb(h, 0.7, inv ? 0.36 : 0.62);
}
// velocity spectral: slow β → warm red, fast β → cool blue
function betaColor(beta, inv) {
const b = Math.round(beta * 24) / 24; // quantise for bounded grouping
const h = 0.66 * b;
return hslToRgb(h, 0.68, inv ? (0.42 - 0.08 * (1 - b)) : 0.6);
}
// one distinct hue per particle kind (categorical)
const KIND_HUE = { primary: 0.08, delta: 0.50, cosmic: 0.62, sweep: 0.34, vdecay: 0.85 };
function kindColor(kind, inv) {
return hslToRgb(KIND_HUE[kind] ?? 0.08, 0.72, inv ? 0.42 : 0.60);
}
// a restricted analogous scheme: each particle type a distinct shade within the
// purple→magenta→pink band (for the simplified "magenta family" feel)
// purple→magenta→pink band, nudged off the blue end (less blue in the purples)
const MAG_HUE = { primary: 0.90, delta: 0.95, cosmic: 0.82, sweep: 0.86, vdecay: 0.79 };
const burntOrange = (inv) => hslToRgb(0.06, 0.82, inv ? 0.42 : 0.54);
// lifecycle: colour follows the particle from birth (life 0) to death (life 1).
// birth = cool blue, ageing through cyan/green/gold, death = deep red.
function lifeColor(life, inv) {
const t = Math.round(clamp(life, 0, 1) * 24) / 24;
const h = 0.66 * (1 - t);
return hslToRgb(h, 0.72, (inv ? 0.44 : 0.62) - 0.07 * t);
}
/* ---------- the registry: each entry is a "feel" ---------- */
const PALETTES = {
mono: { id: 'mono', label: 'Monochrome' }, // all defaults → B&W
charge: {
id: 'charge', label: 'Charge duotone',
ink: (t, e) => chargeColor(t.q ?? 1, e.inv),
},
beta: {
id: 'beta', label: 'Velocity (β) spectral',
ink: (t, e) => betaColor(repBeta(t), e.inv),
},
// one colour per particle type — a legend of the event's physics
kind: {
id: 'kind', label: 'By particle type',
ink: (t, e) => kindColor(t.kind, e.inv),
},
// per-bubble: type sets the HUE, lifecycle sets the INTENSITY — each trail keeps
// its particle-type colour but fades from a strong birth (vertex) to a faint
// death (end of range / spiral rest).
kindlife: {
id: 'kindlife', label: 'By type · faded by life',
bubbleInk: (b, e) => {
const t = Math.round(clamp(b.life, 0, 1) * 16) / 16; // quantise for bounded grouping
const lo = e.inv ? 0.42 : 0.60; // birth: strong
const hi = e.inv ? 0.82 : 0.16; // death: fades toward the ground
return hslToRgb(KIND_HUE[b.track.kind] ?? 0.08, 0.72 * (1 - 0.45 * t), lo + (hi - lo) * t);
},
},
// reverse of kindlife: faint birth → strong, glowing death (end of range / spiral
// core). Type sets the hue; intensity & saturation rise along the life.
kindrise: {
id: 'kindrise', label: 'By type · intensify by life',
bubbleInk: (b, e) => {
const t = Math.round(clamp(b.life, 0, 1) * 16) / 16;
const lo = e.inv ? 0.82 : 0.16; // birth: faint, toward ground
const hi = e.inv ? 0.42 : 0.60; // death: strong
return hslToRgb(KIND_HUE[b.track.kind] ?? 0.08, 0.72 * (0.55 + 0.45 * t), lo + (hi - lo) * t);
},
},
// per-bubble: colour evolves along each particle's life. bubbleInk receives
// { track, life, beta } for the individual bubble.
lifecycle: {
id: 'lifecycle', label: 'Lifecycle (birth → death)',
bubbleInk: (b, e) => lifeColor(b.life, e.inv),
},
// per-bubble: full hue cycles along each trail (rainbow wakes). `cycles` = how
// many hue rotations per particle life.
psychedelic: {
id: 'psychedelic', label: 'Psychedelic (hue cycle)',
bubbleInk: (b, e) => {
const h = Math.round((((b.life * (e.cycles ?? 3)) % 1) + 1) % 1 * 36) / 36;
return hslToRgb(h, 0.9, e.inv ? 0.5 : 0.6);
},
},
// simplified feel: type → a shade of purple/magenta/pink, intensity rises to
// glowing deaths (kindrise), with a monochrome burnt-orange disk as the contrast.
magentarise: {
id: 'magentarise', label: 'Magenta by type · burnt-orange disk',
bubbleInk: (b, e) => {
const t = Math.round(clamp(b.life, 0, 1) * 16) / 16;
const lo = e.inv ? 0.82 : 0.18; // faint birth
const hi = e.inv ? 0.46 : 0.62; // glowing death
return hslToRgb(MAG_HUE[b.track.kind] ?? 0.88, 0.55 + 0.30 * t, lo + (hi - lo) * t);
},
feature: (e) => burntOrange(e.inv), // disk + furniture: burnt orange
},
// a complete "chemistry": overrides paper + ink together (deep Prussian-blue
// ground, pale lines) — the blueprint/cyanotype look.
cyanotype: {
id: 'cyanotype', label: 'Cyanotype (blueprint)',
paper: () => ({ flat: [12, 39, 70], glowIn: [22, 58, 95], glowOut: [6, 22, 44] }),
ink: () => [206, 224, 240],
feature: () => [120, 160, 200],
vign: () => [3, 10, 22],
},
/* --- to add a feel, copy this shape and register it ---
cyanotype: {
id: 'cyanotype', label: 'Cyanotype',
paper: (e) => ({ flat: '#d8e2ea', glowIn: '#e6eef4', glowOut: '#b9cad8' }),
ink: (e) => [20, 50, 92], // Prussian blue trails
feature: (e) => [30, 62, 104],
vign: (e) => [18, 40, 78],
},
*/
};
function listPalettes() {
return Object.values(PALETTES).map(p => ({ id: p.id, label: p.label }));
}
/* Resolve a palette id + render environment into a normalised object whose
methods always return a value (defaults applied), so renderers stay simple.
ink() is quantised to integer channels for bounded grouping / sprite caching. */
function resolvePalette(id, env) {
const p = PALETTES[id] || PALETTES.mono;
const sat = env.sat ?? 1; // global colour saturation / intensity
const hue = env.hue ?? 0; // global hue rotation (degrees)
const plain = (c) => [Math.round(c[0]), Math.round(c[1]), Math.round(c[2])];
const intRGB = (c) => saturate(rotateHue(plain(c), hue), sat); // ink: hue-rotated + sat-scaled
// representative per-track ink (under-strokes, features, single-colour palettes)
const trackInk = (track) => {
if (p.ink) return intRGB(p.ink(track, env) || env.baseInk);
if (p.bubbleInk) return intRGB(p.bubbleInk({ track, life: 0.5, beta: repBeta(track) }, env));
return intRGB(env.baseInk);
};
const paper = () => (p.paper ? p.paper(env) : env.basePaper);
return {
id: p.id,
label: p.label,
perBubble: !!p.bubbleInk, // true → colour varies along each track
hasPaper: !!p.paper, // true → palette sets its own ground
ink: trackInk,
// per-bubble colour; falls back to the per-track ink when the palette has no
// bubbleInk hook, so renderers can always call this uniformly.
bubbleInk: (track, life, beta) =>
p.bubbleInk ? intRGB(p.bubbleInk({ track, life, beta }, env)) : trackInk(track),
paper,
// is the ground dark? (drives blend-mode choice instead of the invert flag,
// so chemistries like cyanotype composite correctly)
darkGround: () => luminance(paper().flat) < 128,
feature: () => plain((p.feature ? p.feature(env) : null) || env.baseInk),
vign: () => plain((p.vign ? p.vign(env) : null) || env.baseVign),
};
}
Object.assign(exports, { mix, saturate, rotateHue, luminance, hslToRgb, rgbHex, rgbCss, rgb01, rgbKey, paperTone, bubbleStops, bubbleFoot, PALETTES, listPalettes, resolvePalette });
};
__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 radial-gradient fills; tracks get a faint continuity
under-stroke. Grain/mottle are intentionally omitted
(raster-only effects); this is the graphic version.
Output is organised into named LAYERS (Inkscape convention),
and coloured through the shared palette abstraction — track
ink can vary per-trail (charge, β, …); a gradient is emitted
per distinct ink colour.
============================================================ */
const { makeRng, cyrb53 } = __require("src/rng.js");
const { sampleBubbles, trackInkWeight, depthFactors } = __require("src/scene/bubbles.js");
const { resolvePalette, paperTone, rgbHex, rgbKey, bubbleStops, hslToRgb, mix } = __require("src/render/palette.js");
const MARGIN = 0.02;
const TRACK_LAYERS = [
{ id: 'tracks-primary', label: 'Tracks · primary', kinds: ['primary'] },
{ id: 'tracks-cosmic', label: 'Tracks · cosmic & sweepers', kinds: ['cosmic', 'sweep'] },
{ id: 'tracks-vdecay', label: 'Tracks · V-decays', kinds: ['vdecay'] },
{ id: 'tracks-delta', label: 'Tracks · δ-rays (curls)', kinds: ['delta'] },
];
function renderSVG(scene, params, sizePx = 4800) {
const w = sizePx, h = sizePx;
const scale = (w / 2) * (1 - MARGIN);
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;
// base ink (mono), toned paper (independent of ink palette), then resolve feel
const baseInk = inv ? [28, 24, 20] : [233, 228, 214];
const pt = paperTone(params, inv);
const pal = resolvePalette(params.palette, {
inv, sat: params.saturation ?? 1, hue: (params.hueShift ?? 0) * 360, cycles: params.hueCycles ?? 3,
baseInk, basePaper: { flat: pt.flat, glowIn: pt.glowIn, glowOut: pt.glowOut }, baseVign: pt.vign,
});
const paperRGB = pal.paper(); // {flat,glowIn,glowOut} rgb arrays
const paperC = { flat: rgbHex(paperRGB.flat), glowIn: rgbHex(paperRGB.glowIn), glowOut: rgbHex(paperRGB.glowOut) };
const ink = rgbHex(pal.feature()); // non-particle marks (optics, disk, damage, header)
const featKey = rgbKey(pal.feature());
const colorMap = new Map([[featKey, pal.feature()]]); // distinct bubble colours → gradients
const attr = (t) => String(t).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
const layer = (id, label, content, extra = '') =>
content ? `<g id="${id}" inkscape:groupmode="layer" inkscape:label="${attr(label)}" style="display:inline"${extra ? ' ' + extra : ''}>\n${content}\n</g>\n` : '';
/* ---------- Background ---------- */
const bg = `<rect width="${w}" height="${h}" fill="${paperC.flat}"/>\n<rect width="${w}" height="${h}" fill="url(#paper)"/>`;
/* ---------- Chamber optics ---------- */
let optics = '';
if (params.showBoundary) {
const bcx = cx, bcy = cy + h * 0.35, br = w * 0.45;
const a1 = Math.PI * 0.15, a2 = Math.PI - Math.PI * 0.15;
const x1 = bcx + Math.cos(Math.PI + a1) * br, y1 = bcy + Math.sin(Math.PI + a1) * br;
const x2 = bcx + Math.cos(Math.PI + a2) * br, y2 = bcy + Math.sin(Math.PI + a2) * br;
optics += `<path d="M ${x1.toFixed(1)} ${y1.toFixed(1)} A ${br} ${br} 0 0 1 ${x2.toFixed(1)} ${y2.toFixed(1)}" fill="none" stroke="${ink}" stroke-opacity="0.4" stroke-width="${2 * u}"/>\n`;
}
if (scene.instrument) {
const inst = scene.instrument;
let g = `<g fill="none" stroke="${ink}" stroke-linecap="round">`;
for (const l of inst.lines)
g += `<line x1="${tx(l.x1)}" y1="${ty(l.y1)}" x2="${tx(l.x2)}" y2="${ty(l.y2)}" stroke-opacity="${l.opacity.toFixed(3)}" stroke-width="${(l.width * u).toFixed(2)}"/>`;
for (const a of inst.arcs) {
let d = `M ${tx(a.pts[0].x)} ${ty(a.pts[0].y)}`;
for (let i = 1; i < a.pts.length; i++) d += ` L ${tx(a.pts[i].x)} ${ty(a.pts[i].y)}`;
g += `<path d="${d}" stroke-opacity="${a.opacity.toFixed(3)}" stroke-width="${(a.width * u).toFixed(2)}"/>`;
}
optics += g + `</g>`;
}
/* ---------- Shock disk (feature ink) ---------- */
let shock = '';
if (scene.shock) {
const sh = scene.shock;
const px = +tx(sh.x), py = +ty(sh.y), R = sh.r * scale;
const bodyOpacity = (params.diskBubbles !== false) ? 0.6 : 1;
shock += `<circle cx="${px}" cy="${py}" r="${R.toFixed(1)}" fill="url(#shockcore)" fill-opacity="${bodyOpacity}"/>\n`;
if (params.diskBubbles !== false && sh.bubbleStrokes) {
const dRng = makeRng(params.seed, 'diskbubbles');
const dbk = new Map();
for (const stroke of sh.bubbleStrokes) {
const key = Math.round(Math.min(1, 0.45 + stroke.weight * 0.5) * 20) / 20;
if (!dbk.has(key)) dbk.set(key, []);
dbk.get(key).push(...sampleBubbles(stroke, params, dRng));
}
for (const [alpha, bubs] of dbk) {
shock += `<g fill="url(#bub-${featKey})" fill-opacity="${alpha}">`;
for (const b of bubs) shock += `<circle cx="${tx(b.x)}" cy="${ty(b.y)}" r="${Math.max(b.r * scale * 1.15, 0.5).toFixed(2)}"/>`;
shock += `</g>\n`;
}
} else {
// optionally iridescent: hue across the sunburst (per-element stroke colour)
const spec = params.diskSpectrum || 0;
const featRGB = pal.feature();
const TWO_PI = Math.PI * 2;
const sCol = (frac) => spec <= 0 ? ink
: rgbHex(mix(featRGB, hslToRgb((((frac + (params.hueShift || 0)) % 1) + 1) % 1, 0.85 * (params.saturation ?? 1), 0.52), spec));
let g = `<g stroke-linecap="round" fill="none">`;
for (const st of sh.striations) {
const ix = px + Math.cos(st.a) * st.inner * scale, iy = py + Math.sin(st.a) * st.inner * scale;
const ox = px + Math.cos(st.a) * st.outer * scale, oy = py + Math.sin(st.a) * st.outer * scale;
g += `<line x1="${ix.toFixed(1)}" y1="${iy.toFixed(1)}" x2="${ox.toFixed(1)}" y2="${oy.toFixed(1)}" stroke="${sCol(st.a / TWO_PI)}" stroke-opacity="${st.opacity.toFixed(3)}" stroke-width="${(st.width * u).toFixed(2)}"/>`;
}
for (const ring of sh.rings)
g += `<circle cx="${px}" cy="${py}" r="${(ring.rr * scale).toFixed(1)}" stroke="${sCol(ring.rr / sh.r)}" stroke-opacity="${ring.opacity.toFixed(3)}" stroke-width="${(ring.width * u).toFixed(2)}"/>`;
for (const seg of (sh.rimSegs || [])) {
const x0 = px + Math.cos(seg.a0) * R, y0 = py + Math.sin(seg.a0) * R;
const x1a = px + Math.cos(seg.a1) * R, y1a = py + Math.sin(seg.a1) * R;
g += `<path d="M ${x0.toFixed(1)} ${y0.toFixed(1)} A ${R.toFixed(1)} ${R.toFixed(1)} 0 0 1 ${x1a.toFixed(1)} ${y1a.toFixed(1)}" stroke="${sCol(seg.a0 / TWO_PI)}" stroke-opacity="${seg.opacity.toFixed(3)}" stroke-width="${(seg.width * u).toFixed(2)}"/>`;
}
for (const k of sh.core) {
const ca = Math.atan2(k.y1 - sh.y, k.x1 - sh.x);
g += `<line x1="${tx(k.x1)}" y1="${ty(k.y1)}" x2="${tx(k.x2)}" y2="${ty(k.y2)}" stroke="${sCol(ca / TWO_PI)}" stroke-opacity="${k.opacity.toFixed(3)}" stroke-width="${(k.width * u).toFixed(2)}"/>`;
}
shock += g + `</g>\n`;
}
for (const st of (sh.stains || [])) {
const sr = (st.r * scale).toFixed(1);
shock += `<circle cx="${tx(st.x)}" cy="${ty(st.y)}" r="${sr}" fill="url(#${st.dark ? 'shockstain' : 'shockclean'})" fill-opacity="${st.opacity.toFixed(3)}"/>`;
}
}
/* ---------- Tracks (per-trail colour via palette; positions unchanged) ---------- */
const labelFor = {};
for (const L of TRACK_LAYERS) for (const k of L.kinds) labelFor[k] = L.id;
const under = new Map(); // layerId -> understroke string (per-path stroke colour)
const buckets = new Map(); // layerId -> Map(colorKey|alpha -> {key, alpha, arr})
const ensure = (id) => { if (!under.has(id)) { under.set(id, ''); buckets.set(id, new Map()); } };
const bubbleRng = makeRng(params.seed, 'bubbles'); // scene.tracks order → deterministic
for (const track of scene.tracks) {
if (track.pts.length < 2) continue;
const id = labelFor[track.kind] || 'tracks-primary';
ensure(id);
const df = depthFactors(track, params);
const repCol = pal.ink(track); // representative ink (under-stroke)
// continuity under-stroke
const iw = trackInkWeight(track);
const lw = Math.min(2.6, 0.25 + Math.sqrt(iw) * 0.12) * u * params.size * track.weight;
if (lw >= 0.2 * u) {
let d = `M ${tx(track.pts[0].x)} ${ty(track.pts[0].y)}`;
for (let i = 1; i < track.pts.length; i++) d += ` L ${tx(track.pts[i].x)} ${ty(track.pts[i].y)}`;
under.set(id, under.get(id) + `<path d="${d}" stroke="${rgbHex(repCol)}" stroke-opacity="${(0.14 * df.tone).toFixed(3)}" stroke-width="${lw.toFixed(2)}"/>`);
}
// bubbles — opacity from depth/age tone, colour per-bubble from palette
const alpha = Math.round(Math.min(1, 0.36 + df.tone * 0.58) * 20) / 20;
const m = buckets.get(id);
for (const b of sampleBubbles({ ...track, sizeScale: df.sizeScale }, params, bubbleRng)) {
const bcol = pal.bubbleInk(track, b.life, b.beta), bkc = rgbKey(bcol);
if (!colorMap.has(bkc)) colorMap.set(bkc, bcol);
const bkey = bkc + '|' + alpha;
if (!m.has(bkey)) m.set(bkey, { key: bkc, alpha, arr: [] });
m.get(bkey).arr.push(`<circle cx="${tx(b.x)}" cy="${ty(b.y)}" r="${Math.max(b.r * scale * 1.15, 0.5).toFixed(2)}"/>`);
}
}
const trackLayers = TRACK_LAYERS.map(L => {
if (!under.has(L.id)) return '';
let content = '';
const us = under.get(L.id);
if (us) content += `<g fill="none" stroke-linecap="round" stroke-linejoin="round">${us}</g>\n`;
for (const { key, alpha, arr } of buckets.get(L.id).values()) {
if (arr.length) content += `<g fill="url(#bub-${key})" fill-opacity="${alpha}">${arr.join('')}</g>\n`;
}
return layer(L.id, L.label, content);
}).join('');
/* ---------- Plate damage (feature ink) ---------- */
let damage = '';
const A = scene.artifacts;
if (A) {
let g = `<g stroke="${ink}" fill="none" stroke-linecap="round">`;
for (const ring of A.rings)
g += `<circle cx="${tx(ring.x)}" cy="${ty(ring.y)}" r="${(ring.r * scale).toFixed(1)}" stroke-opacity="${ring.opacity.toFixed(3)}" stroke-width="${(ring.width * u).toFixed(2)}"/>`;
for (const sc of A.scratches)
g += `<line x1="${tx(sc.x1)}" y1="${ty(sc.y1)}" x2="${tx(sc.x2)}" y2="${ty(sc.y2)}" stroke-opacity="${sc.opacity.toFixed(3)}" stroke-width="${(sc.width * u).toFixed(2)}"/>`;
for (const hair of A.hairs) {
let d = `M ${tx(hair.pts[0].x)} ${ty(hair.pts[0].y)}`;
for (let i = 1; i < hair.pts.length; i++) d += ` L ${tx(hair.pts[i].x)} ${ty(hair.pts[i].y)}`;
g += `<path d="${d}" stroke-opacity="${hair.opacity.toFixed(3)}" stroke-width="${(hair.width * u).toFixed(2)}"/>`;
}
g += `</g><g fill="${ink}">`;
for (const sp of A.specks)
g += `<circle cx="${tx(sp.x)}" cy="${ty(sp.y)}" r="${Math.max(sp.r * scale, 0.5).toFixed(2)}" fill-opacity="${sp.opacity.toFixed(3)}"/>`;
damage = g + `</g>`;
}
/* ---------- Fiducials ---------- */
let fids = '';
if (params.showFiducials) {
let g = `<g stroke="${ink}" stroke-opacity="0.55" stroke-width="${1.2 * u}">`;
const F = [[-0.85, -0.85], [0.85, -0.85], [-0.85, 0.85], [0.85, 0.85], [0, -0.85], [-0.85, 0], [0.85, 0]];
const sz = 9 * u;
for (const [fx, fy] of F) {
const px = +tx(fx), py = +ty(fy);
g += `<line x1="${px - sz}" y1="${py}" x2="${px + sz}" y2="${py}"/><line x1="${px}" y1="${py - sz}" x2="${px}" y2="${py + sz}"/>`;
}
fids = g + `</g>`;
}
/* ---------- Vignette ---------- */
const vign = params.vign > 0 ? `<rect width="${w}" height="${h}" fill="url(#vign)"/>` : '';
/* ---------- Header ---------- */
let header = '';
if (params.showHeader) {
const pad = 26 * u;
const esc = (t) => String(t).replace(/[<&]/g, c => (c === '<' ? '&lt;' : '&amp;'));
header = `<g fill="${ink}" font-family="'JetBrains Mono', monospace">`
+ `<text x="${pad.toFixed(0)}" y="${(pad + 11 * u).toFixed(0)}" font-size="${(11 * u).toFixed(0)}" fill-opacity="0.62">${esc(scene.lab.toUpperCase())}</text>`
+ `<text x="${pad.toFixed(0)}" y="${(pad + 27 * u).toFixed(0)}" font-size="${(9 * u).toFixed(0)}" fill-opacity="0.5">SEED ${esc(params.seed)}</text>`
+ `<text x="${(w - pad).toFixed(0)}" y="${(h - pad - 13 * u).toFixed(0)}" font-size="${(10 * u).toFixed(0)}" text-anchor="end" fill-opacity="0.58">PLATE ${scene.plate}</text>`
+ `<text x="${(w - pad).toFixed(0)}" y="${(h - pad).toFixed(0)}" font-size="${(10 * u).toFixed(0)}" text-anchor="end" fill-opacity="0.58">EXPOSED ${scene.exposure}</text>`
+ `</g>`;
}
/* ---------- Media & hand (réseau, splice, film furniture, grease pencil) ---------- */
let media = '';
if (scene.media) {
const M = scene.media;
const nx = (x) => (cx + x * scale), ny = (y) => (cy + y * scale);
const esc = (t) => String(t).replace(/[<&]/g, c => (c === '<' ? '&lt;' : '&amp;'));
if (M.reseau) {
let g = `<g stroke="${ink}" stroke-opacity="${M.reseau.opacity.toFixed(3)}" stroke-width="${1 * u}">`;
const ss = M.reseau.size * scale;
for (const m of M.reseau.marks) {
const x = nx(m.x), y = ny(m.y);
g += `<line x1="${(x - ss).toFixed(1)}" y1="${y.toFixed(1)}" x2="${(x + ss).toFixed(1)}" y2="${y.toFixed(1)}"/><line x1="${x.toFixed(1)}" y1="${(y - ss).toFixed(1)}" x2="${x.toFixed(1)}" y2="${(y + ss).toFixed(1)}"/>`;
}
media += g + `</g>`;
}
if (M.splice) {
const yc = ny(M.splice.y), hh = M.splice.h * scale, deg = M.splice.tilt * 57.3;
media += `<g transform="rotate(${deg.toFixed(2)} ${(w / 2).toFixed(1)} ${yc.toFixed(1)})">`
+ `<rect x="0" y="${(yc - hh).toFixed(1)}" width="${w}" height="${(hh * 2).toFixed(1)}" fill="${ink}" fill-opacity="${M.splice.opacity.toFixed(3)}"/>`
+ `<line x1="0" y1="${(yc - hh).toFixed(1)}" x2="${w}" y2="${(yc - hh).toFixed(1)}" stroke="${ink}" stroke-opacity="${Math.min(0.6, M.splice.opacity * 2).toFixed(3)}" stroke-width="${u}"/>`
+ `<line x1="0" y1="${(yc + hh).toFixed(1)}" x2="${w}" y2="${(yc + hh).toFixed(1)}" stroke="${ink}" stroke-opacity="${Math.min(0.6, M.splice.opacity * 2).toFixed(3)}" stroke-width="${u}"/></g>`;
}
if (M.film) {
const f = M.film, m = 0.965;
media += `<rect x="${nx(-m).toFixed(1)}" y="${ny(-m).toFixed(1)}" width="${((nx(m) - nx(-m))).toFixed(1)}" height="${((ny(m) - ny(-m))).toFixed(1)}" fill="none" stroke="${ink}" stroke-opacity="0.5" stroke-width="${2 * u}"/>`;
const sw = 0.028 * scale, sh = 0.05 * scale, rx = 4 * u, holeFill = inv ? '#faf8f0' : '#12100d';
let g = '';
for (const y of f.sprockets) for (const sx of [nx(-0.985), nx(0.985)]) {
g += `<rect x="${(sx - sw / 2).toFixed(1)}" y="${(ny(y) - sh / 2).toFixed(1)}" width="${sw.toFixed(1)}" height="${sh.toFixed(1)}" rx="${rx.toFixed(1)}" fill="${holeFill}" fill-opacity="0.7" stroke="${ink}" stroke-opacity="0.4" stroke-width="${u}"/>`;
}
media += g;
media += `<g fill="${ink}" font-family="'JetBrains Mono', monospace">`
+ `<text x="${nx(-0.935).toFixed(1)}" y="${ny(0).toFixed(1)}" font-size="${(7 * u).toFixed(0)}" fill-opacity="0.45" text-anchor="middle" transform="rotate(-90 ${nx(-0.935).toFixed(1)} ${ny(0).toFixed(1)})">${esc(f.edgeText)}</text>`;
f.dataBox.forEach((line, i) => { media += `<text x="${nx(-0.9).toFixed(1)}" y="${(ny(0.8) + i * 12 * u).toFixed(1)}" font-size="${(9 * u).toFixed(0)}" fill-opacity="0.5">${esc(line)}</text>`; });
media += `</g>`;
}
if (M.grease && M.grease.length) {
const ch = inv ? '#9c1e1e' : '#f0e296';
let g = `<g stroke="${ch}" fill="${ch}" stroke-linecap="round" stroke-linejoin="round">`;
for (const gm of M.grease) {
if (gm.kind === 'ring' || gm.kind === 'arrow') {
const pts = gm.pts || gm.shaft;
let d = `M ${nx(pts[0].x).toFixed(1)} ${ny(pts[0].y).toFixed(1)}`;
for (let i = 1; i < pts.length; i++) d += ` L ${nx(pts[i].x).toFixed(1)} ${ny(pts[i].y).toFixed(1)}`;
g += `<path d="${d}" fill="none" stroke-opacity="0.85" stroke-width="${(gm.width * u).toFixed(2)}"/>`;
if (gm.kind === 'arrow') {
const { x, y, ang } = gm.tip, hl = 0.03 * scale;
g += `<path d="M ${nx(x).toFixed(1)} ${ny(y).toFixed(1)} L ${(nx(x) - Math.cos(ang - 0.4) * hl).toFixed(1)} ${(ny(y) - Math.sin(ang - 0.4) * hl).toFixed(1)} M ${nx(x).toFixed(1)} ${ny(y).toFixed(1)} L ${(nx(x) - Math.cos(ang + 0.4) * hl).toFixed(1)} ${(ny(y) - Math.sin(ang + 0.4) * hl).toFixed(1)}" fill="none" stroke-opacity="0.85" stroke-width="${(gm.width * u).toFixed(2)}"/>`;
}
} else if (gm.kind === 'arc') {
const x0 = nx(gm.x) + Math.cos(gm.a0) * gm.r * scale, y0 = ny(gm.y) + Math.sin(gm.a0) * gm.r * scale;
const x1 = nx(gm.x) + Math.cos(gm.a1) * gm.r * scale, y1 = ny(gm.y) + Math.sin(gm.a1) * gm.r * scale;
g += `<path d="M ${x0.toFixed(1)} ${y0.toFixed(1)} A ${(gm.r * scale).toFixed(1)} ${(gm.r * scale).toFixed(1)} 0 0 1 ${x1.toFixed(1)} ${y1.toFixed(1)}" fill="none" stroke-opacity="0.85" stroke-width="${(gm.width * u).toFixed(2)}"/>`;
} else if (gm.kind === 'tick') {
g += `<line x1="${nx(gm.x1).toFixed(1)}" y1="${ny(gm.y1).toFixed(1)}" x2="${nx(gm.x2).toFixed(1)}" y2="${ny(gm.y2).toFixed(1)}" stroke-opacity="0.85" stroke-width="${(gm.width * u).toFixed(2)}"/>`;
} else if (gm.kind === 'text') {
g += `<text x="${nx(gm.x).toFixed(1)}" y="${ny(gm.y).toFixed(1)}" font-size="${(gm.size * scale).toFixed(0)}" font-family="'Bradley Hand','Segoe Script','Comic Sans MS',cursive" fill-opacity="0.9" transform="rotate(${((gm.rot || 0) * 57.3).toFixed(1)} ${nx(gm.x).toFixed(1)} ${ny(gm.y).toFixed(1)})">${esc(gm.text)}</text>`;
}
}
media += g + `</g>`;
}
}
/* ---------- Assemble ---------- */
let s = `<?xml version="1.0" encoding="UTF-8"?>\n`;
s += `<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">\n`;
s += `<metadata>Bubble Chamber · seed=${params.seed} · hash=${cyrb53(params.seed)} · palette=${params.palette || 'mono'}</metadata>\n`;
s += defs({ paperC, ink, baseVign: pal.vign(), params, u, colorMap });
s += layer('background', 'Background', bg);
s += layer('optics', 'Chamber optics', optics);
s += layer('shock', 'Shock disk', shock, params.diskSoften > 0 ? 'filter="url(#soften)"' : '');
s += trackLayers;
s += layer('damage', 'Plate damage', damage);
s += layer('fiducials', 'Fiducials', fids);
s += layer('vignette', 'Vignette', vign);
s += layer('header', 'Archival header', header);
s += layer('media', 'Media & hand', media);
s += `</svg>\n`;
return s;
}
function defs({ paperC, ink, baseVign, params, u, colorMap }) {
const soften = params.diskSoften > 0
? `<filter id="soften" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="${(params.diskSoften * u).toFixed(2)}"/></filter>`
: '';
// one soft-bubble gradient per distinct ink colour used (edge profile shared
// with the raster sprite via bubbleStops)
const stops = bubbleStops(params.bubbleSoft ?? 0.3);
let bubGrads = '';
for (const [key, col] of colorMap) {
const c = rgbHex(col);
let st = '';
for (const [off, a] of stops) st += `<stop offset="${(off * 100).toFixed(0)}%" stop-color="${c}" stop-opacity="${a.toFixed(3)}"/>`;
bubGrads += `<radialGradient id="bub-${key}" cx="50%" cy="50%" r="50%">${st}</radialGradient>`;
}
const vignHex = rgbHex(baseVign);
return `<defs>
${soften}
<radialGradient id="paper" cx="50%" cy="42%" r="72%">
<stop offset="0%" stop-color="${paperC.glowIn}"/><stop offset="100%" stop-color="${paperC.glowOut}"/>
</radialGradient>
${bubGrads}
<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="${paperC.flat}" stop-opacity="0.9"/>
<stop offset="100%" stop-color="${paperC.flat}" stop-opacity="0"/>
</radialGradient>
<radialGradient id="vign" cx="50%" cy="50%" r="72%">
<stop offset="30%" stop-color="${vignHex}" stop-opacity="0"/>
<stop offset="100%" stop-color="${vignHex}" stop-opacity="${(params.invert ? 0.5 : 0.85) * params.vign}"/>
</radialGradient>
</defs>\n`;
}
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;
• Optional Content Groups (/OCG) so the file opens with
toggleable LAYERS in Acrobat / Illustrator / Preview;
• a base-14 Helvetica archival header (no font embedding).
Geometry comes from the same scene model as every renderer.
(Note: the disk-soften Gaussian is raster/SVG only — PDF keeps
the disk crisp.)
============================================================ */
const { makeRng } = __require("src/rng.js");
const { sampleBubbles, trackInkWeight, depthFactors } = __require("src/scene/bubbles.js");
const { resolvePalette, paperTone, rgb01, rgbKey, hslToRgb, mix } = __require("src/render/palette.js");
const MARGIN = 0.02;
const KIND_LAYER = {
primary: 'Tracks - primary',
cosmic: 'Tracks - cosmic & sweepers',
sweep: 'Tracks - cosmic & sweepers',
vdecay: 'Tracks - V-decays',
delta: 'Tracks - delta-rays',
};
const TRACK_ORDER = ['Tracks - primary', 'Tracks - cosmic & sweepers', 'Tracks - V-decays', 'Tracks - delta-rays'];
function buildPDF(scene, params, pageSize = 1728) {
const scale = (pageSize / 2) * (1 - MARGIN);
const cx = pageSize / 2, cy = pageSize / 2;
const tx = (x) => (cx + x * scale);
const ty = (y) => (cy - y * scale); // PDF y-up
const u = pageSize / 1000;
const inv = params.invert;
const paperCMYK = inv ? '0.04 0.06 0.16 0.00' : '0.30 0.30 0.32 1.00';
const inkCMYK = inv ? '0.30 0.30 0.32 0.98' : '0.03 0.05 0.14 0.00';
// palette: mono keeps CMYK rich black (print-grade); colour feels use RGB for
// the particle trails. Features/paper stay CMYK.
const mono = (params.palette || 'mono') === 'mono';
const pt = paperTone(params, inv);
const pal = resolvePalette(params.palette, {
inv, sat: params.saturation ?? 1, hue: (params.hueShift ?? 0) * 360, cycles: params.hueCycles ?? 3,
baseInk: inv ? [28, 24, 20] : [233, 228, 214],
basePaper: { flat: pt.flat, glowIn: pt.glowIn, glowOut: pt.glowOut }, baseVign: [0, 0, 0],
});
// paper is CMYK at the default cream (print-grade rich); toned papers or palette
// chemistries (cyanotype) emit RGB
const toned = pal.hasPaper || (params.paperTone && params.paperTone !== 'cream') || (params.toneStrength ?? 1) !== 1 || (params.paperBright ?? 1) !== 1 || (params.glow ?? 0.5) !== 0.5;
const paperOp = toned ? `${rgb01(pal.paper().flat).join(' ')} rg` : `${paperCMYK} k`;
const bubbleFill = (track, b) => {
if (mono) return { op: `${inkCMYK} k`, key: 'k' };
const c = pal.bubbleInk(track, b.life, b.beta);
return { op: `${rgb01(c).join(' ')} rg`, key: rgbKey(c) };
};
const trackStroke = (track) => mono
? `${inkCMYK} K\n`
: `${rgb01(pal.ink(track)).join(' ')} RG\n`;
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`;
};
// optional-content layer accumulation
const ocgNames = [];
let content = '';
const emit = (name, ops) => {
if (!ops || !ops.trim()) return;
const i = ocgNames.length;
ocgNames.push(name);
content += `/OC /OC${i} BDC\n${ops}EMC\n`;
};
/* ---------- Background ---------- */
emit('Background', `${paperOp}\n0 0 ${pageSize} ${pageSize} re f\n`);
/* ---------- Chamber optics ---------- */
{
let o = '';
if (params.showBoundary) {
o += `q\n${gs(0.4)}${inkCMYK} K\n${(2 * u).toFixed(2)} w\n`;
const bcx = cx, bcy = cy - pageSize * 0.35, br = pageSize * 0.45;
const a1 = Math.PI * 0.15, a2 = Math.PI - Math.PI * 0.15, steps = 90;
for (let i = 0; i <= steps; i++) {
const a = Math.PI + a1 + (a2 - a1) * (i / steps);
const px = bcx + Math.cos(a) * br, py = bcy - Math.sin(a) * br;
o += `${px.toFixed(1)} ${py.toFixed(1)} ${i === 0 ? 'm' : 'l'}\n`;
}
o += `S\nQ\n`;
}
if (scene.instrument) {
o += `q\n${inkCMYK} K\n1 J\n`;
for (const l of scene.instrument.lines)
o += `${gs(l.opacity)}${(l.width * u).toFixed(2)} w\n${tx(l.x1).toFixed(1)} ${ty(l.y1).toFixed(1)} m ${tx(l.x2).toFixed(1)} ${ty(l.y2).toFixed(1)} l S\n`;
for (const a of scene.instrument.arcs) {
o += `${gs(a.opacity)}${(a.width * u).toFixed(2)} w\n${tx(a.pts[0].x).toFixed(1)} ${ty(a.pts[0].y).toFixed(1)} m\n`;
for (let i = 1; i < a.pts.length; i++) o += `${tx(a.pts[i].x).toFixed(1)} ${ty(a.pts[i].y).toFixed(1)} l\n`;
o += `S\n`;
}
o += `Q\n`;
}
emit('Chamber optics', o);
}
/* ---------- Shock disk ---------- */
if (scene.shock) {
const sh = scene.shock, px = tx(sh.x), py = ty(sh.y);
let o = '';
if (params.diskBubbles !== false && sh.bubbleStrokes) {
const dRng = makeRng(params.seed, 'diskbubbles');
const dbk = new Map();
for (const stroke of sh.bubbleStrokes) {
const key = Math.round(Math.min(1, 0.45 + stroke.weight * 0.5) * 20) / 20;
if (!dbk.has(key)) dbk.set(key, []);
dbk.get(key).push(...sampleBubbles(stroke, params, dRng));
}
o += `q\n${inkCMYK} k\n`;
for (const [alpha, bubs] of dbk) {
o += gs(alpha);
for (const b of bubs) o += circlePath(tx(b.x), ty(b.y), Math.max(b.r * scale, 0.5)) + 'f\n';
}
o += `Q\n`;
} else {
// optionally iridescent — per-element RGB stroke across the sunburst
const spec = params.diskSpectrum || 0;
const featRGB = pal.feature();
const TWO_PI = Math.PI * 2;
const sStroke = (frac) => spec <= 0 ? `${inkCMYK} K\n`
: `${rgb01(mix(featRGB, hslToRgb((((frac + (params.hueShift || 0)) % 1) + 1) % 1, 0.85 * (params.saturation ?? 1), 0.52), spec)).join(' ')} RG\n`;
o += `q\n1 J\n`;
for (const st of sh.striations) {
const ix = px + Math.cos(st.a) * st.inner * scale, iy = py - Math.sin(st.a) * st.inner * scale;
const ox = px + Math.cos(st.a) * st.outer * scale, oy = py - Math.sin(st.a) * st.outer * scale;
o += `${sStroke(st.a / TWO_PI)}${gs(st.opacity)}${(st.width * u).toFixed(2)} w\n${ix.toFixed(1)} ${iy.toFixed(1)} m ${ox.toFixed(1)} ${oy.toFixed(1)} l S\n`;
}
for (const k of sh.core) {
const ca = Math.atan2(k.y1 - sh.y, k.x1 - sh.x);
o += `${sStroke(ca / TWO_PI)}${gs(k.opacity)}${(k.width * u).toFixed(2)} w\n${tx(k.x1).toFixed(1)} ${ty(k.y1).toFixed(1)} m ${tx(k.x2).toFixed(1)} ${ty(k.y2).toFixed(1)} l S\n`;
}
for (const ring of sh.rings) o += `${sStroke(ring.rr / sh.r)}${gs(ring.opacity)}${(ring.width * u).toFixed(2)} w\n` + strokeCircle(px, py, ring.rr * scale);
for (const seg of (sh.rimSegs || [])) o += `${sStroke(seg.a0 / TWO_PI)}${gs(seg.opacity)}${(seg.width * u).toFixed(2)} w\n` + arcStroke(px, py, sh.r * scale, -seg.a0, -seg.a1);
o += `Q\n`;
}
emit('Shock disk', o);
}
/* ---------- Tracks (split by kind; bubble rng order preserved) ---------- */
const under = new Map(); // layer -> stroke ops (per-track colour set inline)
const buck = new Map(); // layer -> Map(colourKey|alpha -> {op, alpha, arr})
const bRng = makeRng(params.seed, 'bubbles');
for (const t of scene.tracks) {
const name = KIND_LAYER[t.kind] || 'Tracks - primary';
if (!under.has(name)) { under.set(name, ''); buck.set(name, new Map()); }
const df = depthFactors(t, params);
if (t.pts.length >= 2) {
const lw = Math.min(2.6, 0.25 + Math.sqrt(trackInkWeight(t)) * 0.12) * u * params.size * t.weight;
if (lw >= 0.2 * u) {
let su = `${trackStroke(t)}${gs(0.14 * df.tone)}${lw.toFixed(2)} w\n${tx(t.pts[0].x).toFixed(2)} ${ty(t.pts[0].y).toFixed(2)} m\n`;
for (let i = 1; i < t.pts.length; i++) su += `${tx(t.pts[i].x).toFixed(2)} ${ty(t.pts[i].y).toFixed(2)} l\n`;
under.set(name, under.get(name) + su + `S\n`);
}
}
const alpha = Math.round(Math.min(1, 0.36 + df.tone * 0.58) * 20) / 20;
const m = buck.get(name);
for (const b of sampleBubbles({ ...t, sizeScale: df.sizeScale }, params, bRng)) {
const fill = bubbleFill(t, b);
const bkey = fill.key + '|' + alpha;
if (!m.has(bkey)) m.set(bkey, { op: fill.op, alpha, arr: [] });
m.get(bkey).arr.push(circlePath(tx(b.x), ty(b.y), Math.max(b.r * scale, 0.5)) + 'f\n');
}
}
for (const name of TRACK_ORDER) {
if (!under.has(name)) continue;
let o = '';
const us = under.get(name);
if (us) o += `q\n1 J\n1 j\n${us}Q\n`;
let bub = '';
for (const { op, alpha, arr } of buck.get(name).values()) if (arr.length) bub += `${op}\n${gs(alpha)}${arr.join('')}`;
if (bub) o += `q\n${bub}Q\n`;
emit(name, o);
}
/* ---------- Plate damage ---------- */
const A = scene.artifacts;
if (A) {
let o = `q\n${inkCMYK} K\n1 J\n`;
for (const sc of A.scratches) o += `${gs(sc.opacity)}${(sc.width * u).toFixed(2)} w\n${tx(sc.x1).toFixed(1)} ${ty(sc.y1).toFixed(1)} m ${tx(sc.x2).toFixed(1)} ${ty(sc.y2).toFixed(1)} l S\n`;
for (const hair of A.hairs) {
o += `${gs(hair.opacity)}${(hair.width * u).toFixed(2)} w\n${tx(hair.pts[0].x).toFixed(1)} ${ty(hair.pts[0].y).toFixed(1)} m\n`;
for (let i = 1; i < hair.pts.length; i++) o += `${tx(hair.pts[i].x).toFixed(1)} ${ty(hair.pts[i].y).toFixed(1)} l\n`;
o += `S\n`;
}
for (const ring of A.rings) o += `${gs(ring.opacity)}${(ring.width * u).toFixed(2)} w\n` + strokeCircle(tx(ring.x), ty(ring.y), ring.r * scale);
o += `Q\nq\n${inkCMYK} k\n`;
for (const sp of A.specks) o += gs(sp.opacity) + circlePath(tx(sp.x), ty(sp.y), Math.max(sp.r * scale, 0.5)) + 'f\n';
o += `Q\n`;
emit('Plate damage', o);
}
/* ---------- Fiducials ---------- */
if (params.showFiducials) {
let o = `q\n${gs(0.55)}${inkCMYK} K\n${(1.2 * u).toFixed(2)} w\n`;
const fids = [[-0.85, -0.85], [0.85, -0.85], [-0.85, 0.85], [0.85, 0.85], [0, -0.85], [-0.85, 0], [0.85, 0]];
const s = 8 * u;
for (const [fx, fy] of fids) {
const px = tx(fx), py = ty(fy);
o += `${px - s} ${py} m ${px + s} ${py} l S\n${px} ${py - s} m ${px} ${py + s} l S\n`;
}
o += `Q\n`;
emit('Fiducials', o);
}
/* ---------- Archival header ---------- */
if (params.showHeader) {
const pad = 26 * u;
let o = `q\n${gs(0.6)}${inkCMYK} k\nBT\n/F0 ${(11 * u).toFixed(1)} Tf\n1 0 0 1 ${pad.toFixed(1)} ${(pageSize - pad - 11 * u).toFixed(1)} Tm (${pdfStr(scene.lab.toUpperCase())}) Tj\nET\n`;
o += `BT\n/F0 ${(9 * u).toFixed(1)} Tf\n1 0 0 1 ${pad.toFixed(1)} ${(pageSize - pad - 27 * u).toFixed(1)} Tm (SEED ${pdfStr(params.seed)}) Tj\nET\n`;
const fs = 10 * u;
const rt = (txt, yoff) => {
const wEst = txt.length * fs * 0.5;
o += `BT\n/F0 ${fs.toFixed(1)} Tf\n1 0 0 1 ${(pageSize - pad - wEst).toFixed(1)} ${yoff.toFixed(1)} Tm (${pdfStr(txt)}) Tj\nET\n`;
};
rt(`PLATE ${scene.plate}`, pad + 13 * u);
rt(`EXPOSED ${scene.exposure}`, pad);
o += `Q\n`;
emit('Archival header', o);
}
let extg = '';
for (const [k, name] of gsSet) extg += `/${name} << /ca ${(k / 100).toFixed(2)} /CA ${(k / 100).toFixed(2)} >> `;
return assemblePDF(content, pageSize, extg, ocgNames);
}
/* a stroked arc approximated by line segments (for rim segments) */
function arcStroke(px, py, r, a0, a1) {
const steps = Math.max(2, Math.round(Math.abs(a1 - a0) / 0.12));
let s = '';
for (let i = 0; i <= steps; i++) {
const a = a0 + (a1 - a0) * (i / steps);
s += `${(px + Math.cos(a) * r).toFixed(1)} ${(py + Math.sin(a) * r).toFixed(1)} ${i === 0 ? 'm' : 'l'}\n`;
}
return s + 'S\n';
}
function circlePath(px, py, r) {
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, ocgNames) {
const enc = new TextEncoder();
const contentBytes = enc.encode(content);
let body = `%PDF-1.5\n%\xC3\xA0\xC3\xA1\xC3\xA2\xC3\xA3\n`;
const offsets = [];
const addObj = (s) => { offsets.push(body.length); body += s; };
const ocgRefs = ocgNames.map((_, i) => `${5 + i} 0 R`).join(' ');
const props = ocgNames.map((_, i) => `/OC${i} ${5 + i} 0 R`).join(' ');
const resources = `<< /ExtGState << ${extg} >> /Font << /F0 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /Properties << ${props} >> >>`;
addObj(`1 0 obj\n<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [${ocgRefs}] /D << /Order [${ocgRefs}] >> >> >>\nendobj\n`);
addObj(`2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n`);
addObj(`3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageSize} ${pageSize}] /Contents 4 0 R /Resources ${resources} >>\nendobj\n`);
addObj(`4 0 obj\n<< /Length ${contentBytes.length} >>\nstream\n${content}\nendstream\nendobj\n`);
ocgNames.forEach((name) => addObj(`${offsets.length + 1} 0 obj\n<< /Type /OCG /Name (${pdfStr(name)}) >>\nendobj\n`));
const size = 4 + ocgNames.length + 1;
const xref = body.length;
body += `xref\n0 ${size}\n0000000000 65535 f \n`;
for (const o of offsets) body += String(o).padStart(10, '0') + ' 00000 n \n';
body += `trailer\n<< /Size ${size} /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: 'diskSoften', label: 'Disk edge softness', min: 0, max: 3, step: 0.05, value: 0.7, mode: 'render' },
{ id: 'diskSpectrum', label: 'Iridescent disk', min: 0, max: 1, step: 0.01, value: 0, mode: 'render' },
{ 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 & Depth',
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' },
{ id: 'depth', label: 'Depth / focus falloff', min: 0, max: 1, step: 0.01, value: 0.55, mode: 'render' },
{ id: 'aging', label: 'Exposure aging', min: 0, max: 1, step: 0.01, value: 0.55, mode: 'render' },
],
},
{
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: 'bubbleSoft', label: 'Bubble edge softness', min: 0, max: 1, step: 0.01, value: 0.3, 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' },
],
},
{
title: 'Colour & Paper',
controls: [
{ id: 'saturation', label: 'Colour saturation', min: 0, max: 1.5, step: 0.01, value: 1.0, mode: 'render' },
{ id: 'hueShift', label: 'Hue shift', min: 0, max: 1, step: 0.005, value: 0, mode: 'render' },
{ id: 'hueCycles', label: 'Hue cycles (psychedelic)', min: 0.5, max: 8, step: 0.1, value: 3, mode: 'render' },
{ id: 'toneStrength', label: 'Paper tone strength', min: 0, max: 1, step: 0.01, value: 1.0, mode: 'render' },
{ id: 'paperBright', label: 'Paper brightness', min: 0.7, max: 1.25, step: 0.01, value: 1.0, mode: 'render' },
{ id: 'glow', label: 'Gas glow', min: 0, max: 1, step: 0.01, value: 0.5, mode: 'render' },
{ id: 'halo', label: 'Chromatic halo', min: 0, max: 1, step: 0.01, value: 0, mode: 'render' },
{ id: 'haloHue', label: 'Halo hue', min: 0, max: 1, step: 0.005, value: 0.55, mode: 'render' },
],
},
{
title: 'Media & Hand',
controls: [
{ id: 'annotate', label: 'Grease-pencil marks', min: 0, max: 1, step: 0.01, value: 0, mode: 'scene' },
{ id: 'reseau', label: 'Réseau grid', min: 0, max: 1, step: 0.01, value: 0, mode: 'scene' },
],
},
];
const SELECTS = [
{
id: 'palette', label: 'Ink palette (colour feel)', value: 'mono', mode: 'render',
options: [['mono', 'Monochrome'], ['charge', 'Charge duotone'], ['beta', 'Velocity (β) spectral'], ['kind', 'By particle type'], ['kindlife', 'By type · faded by life'], ['kindrise', 'By type · intensify by life'], ['magentarise', 'Magenta family · burnt-orange disk'], ['lifecycle', 'Lifecycle (birth → death)'], ['psychedelic', 'Psychedelic (hue cycle)'], ['cyanotype', 'Cyanotype (blueprint)']],
},
{
id: 'paperTone', label: 'Paper tone', value: 'cream', mode: 'render',
options: [['cream', 'Cream'], ['sepia', 'Sepia'], ['selenium', 'Selenium'], ['cool', 'Cool grey'], ['olive', 'Olive'], ['neutral', 'Neutral']],
},
];
const TOGGLES = [
{ id: 'shock', label: 'Shock-wave disk', value: true, mode: 'scene' },
{ id: 'diskBubbles', label: 'Disk as bubbles', value: false, mode: 'scene' },
{ id: 'showFiducials', label: 'Fiducial marks', value: true, mode: 'render' },
{ id: 'showBoundary', label: 'Chamber boundary', value: true, mode: 'render' },
{ id: 'showHeader', label: 'Archival header', value: true, mode: 'render' },
{ id: 'invert', label: 'Invert · photographic positive', value: true, mode: 'render' },
{ id: 'filmEdge', label: 'Film edge (sprockets, data box)', value: false, mode: 'scene' },
{ id: 'splice', label: 'Tape splice', value: false, mode: 'scene' },
];
/* 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, SELECTS, 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: false, shockIntensity: r(0.6, 0.9), shockSize: r(0.26, 0.4),
shockStriations: r(0.45, 0.85), shockY: r(0.4, 0.6), shockX: r(-0.12, 0.12),
shockStain: Math.pow(rng(), 1.5), // skew cleaner, allow grime
diskSoften: r(0.4, 1.1), // gaussian softening of disk edges
instrument: r(0.25, 0.6),
bgEvents: ri(3, 8), bgIntensity: r(0.35, 0.5),
depth: r(0.4, 0.8), aging: r(0.4, 0.8), // chamber depth + exposure age
density: r(1.0, 1.35), size: r(0.9, 1.15), bubbleSoft: r(0.25, 0.45),
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, palette: 'mono', saturation: 1.0, // canonical look is B&W; colour is opt-in
hueShift: 0, hueCycles: 3, diskSpectrum: 0, halo: 0, haloHue: 0.55,
paperTone: 'cream', toneStrength: 1.0, paperBright: 1.0, glow: 0.5,
annotate: 0, reseau: 0, filmEdge: false, splice: false, // media & hand layer (opt-in)
};
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>