Files
bubblechambersimart/bubble_chamber.html

3258 lines
145 KiB
HTML
Raw Normal View History

2026-05-20 16:53:23 -04:00
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Bubble Chamber — parametric generator</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0a0a0a; --panel: #141414; --panel-2: #1c1c1c; --line: #2a2a2a;
--ink: #e8e4d8; --ink-dim: #8a8578; --ink-mute: #555049;
--accent: #d4a574; --warn: #c87a4a;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
body {
background: var(--bg); color: var(--ink);
font-family: 'JetBrains Mono', monospace; font-size: 12px;
display: grid; grid-template-columns: 330px 1fr; height: 100vh;
}
/* ---------- Sidebar ---------- */
aside { background: var(--panel); border-right: 1px solid var(--line); overflow-y: auto; padding: 20px 18px; }
aside::-webkit-scrollbar { width: 6px; }
aside::-webkit-scrollbar-thumb { background: var(--line); }
.brand { font-family: 'Cormorant Garamond', serif; font-style: italic; font-size: 22px; letter-spacing: 0.02em; margin-bottom: 2px; }
.subtitle { font-size: 10px; text-transform: uppercase; letter-spacing: 0.2em; color: var(--ink-mute); margin-bottom: 22px; }
.group { border-top: 1px solid var(--line); padding: 16px 0 8px; }
.group-title { font-size: 9px; text-transform: uppercase; letter-spacing: 0.25em; color: var(--accent); margin-bottom: 14px; }
.row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; gap: 10px; }
.row label { font-size: 11px; color: var(--ink-dim); flex: 1; }
.row .val { font-size: 11px; color: var(--ink); min-width: 50px; text-align: right; font-variant-numeric: tabular-nums; }
input[type=range] { width: 100%; -webkit-appearance: none; appearance: none; background: transparent; height: 18px; margin-bottom: 6px; }
input[type=range]::-webkit-slider-runnable-track { height: 1px; background: var(--line); }
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; height: 10px; width: 10px; background: var(--ink); border-radius: 0; margin-top: -4px; cursor: ew-resize; transition: background 0.15s; }
input[type=range]:hover::-webkit-slider-thumb { background: var(--accent); }
input[type=range]::-moz-range-track { height: 1px; background: var(--line); }
input[type=range]::-moz-range-thumb { height: 10px; width: 10px; background: var(--ink); border: none; border-radius: 0; cursor: ew-resize; }
.seed-row { display: grid; grid-template-columns: 1fr auto; gap: 6px; align-items: center; }
.seed-input, select.preset {
background: var(--panel-2); border: 1px solid var(--line); color: var(--ink);
padding: 8px 10px; font-family: 'JetBrains Mono', monospace; font-size: 11px; width: 100%; letter-spacing: 0.05em;
}
.seed-input:focus, select.preset:focus { outline: none; border-color: var(--accent); }
select.preset { margin-top: 8px; cursor: pointer; }
button {
background: transparent; border: 1px solid var(--line); color: var(--ink);
padding: 8px 12px; font-family: 'JetBrains Mono', monospace; font-size: 10px;
text-transform: uppercase; letter-spacing: 0.15em; cursor: pointer; transition: all 0.15s;
}
button:hover { border-color: var(--accent); color: var(--accent); }
button.primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
button.primary:hover { background: var(--accent); border-color: var(--accent); color: var(--bg); }
.btn-row { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 8px; }
.btn-row-full { display: grid; gap: 6px; margin-top: 8px; }
.checkbox-row { display: flex; align-items: center; gap: 8px; margin: 4px 0 8px; cursor: pointer; }
.checkbox-row input { accent-color: var(--accent); }
.checkbox-row span { font-size: 11px; color: var(--ink-dim); }
/* ---------- Stage ---------- */
main { position: relative; background: #050505; overflow: hidden; display: flex; align-items: center; justify-content: center; }
.stage-frame { position: relative; background: #0a0a0a; box-shadow: 0 30px 80px rgba(0,0,0,0.6), 0 0 0 1px var(--line); }
#preview { display: block; width: min(88vh, calc(100vw - 400px)); height: min(88vh, calc(100vw - 400px)); image-rendering: -webkit-optimize-contrast; }
.stage-meta { position: absolute; bottom: 16px; left: 18px; font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--ink-mute); }
.stage-meta .hash { color: var(--accent); letter-spacing: 0.1em; text-transform: none; }
.stage-corner { position: absolute; top: 16px; right: 18px; font-family: 'Cormorant Garamond', serif; font-style: italic; font-size: 14px; color: var(--ink-mute); text-align: right; line-height: 1.4; }
.stage-corner .sm { font-family: 'JetBrains Mono', monospace; font-style: normal; font-size: 9px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--ink-mute); margin-top: 2px; }
.toast { position: absolute; bottom: 16px; right: 18px; background: var(--panel); border: 1px solid var(--accent); color: var(--accent); padding: 8px 14px; font-size: 10px; letter-spacing: 0.15em; text-transform: uppercase; opacity: 0; transition: opacity 0.3s; pointer-events: none; }
.toast.show { opacity: 1; }
</style>
</head>
<body>
<aside>
<div class="brand">Bubble Chamber</div>
<div class="subtitle">Parametric Generator · v3 (built)</div>
<div class="group" style="border-top:none; padding-top:0;">
<div class="group-title">Seed</div>
<div class="seed-row">
<input class="seed-input" id="seedInput" value="ENTROPY-001" spellcheck="false">
<button id="randomSeed" title="Randomize seed"></button>
</div>
<div class="btn-row-full"><button id="regen" class="primary">Regenerate</button></div>
<div class="btn-row-full"><button id="fromSeed">Derive all params from seed</button></div>
<select class="preset" id="presets"><option value="">— Preset —</option></select>
</div>
<div id="controls"></div>
<div class="group">
<div class="group-title">Export</div>
<div class="btn-row">
<button id="exportSVG">SVG</button>
<button id="exportPDF">PDF</button>
</div>
<div class="btn-row-full"><button id="exportPNG">PNG · high-res</button></div>
<div style="margin-top:14px; font-size:10px; color:var(--ink-mute); line-height:1.5;">
Print target: 24″ × 24″<br>
SVG/PDF scale infinitely (clean vector).<br>
PNG carries the full film texture.
</div>
</div>
</aside>
<main>
<div class="stage-frame">
<canvas id="preview" width="1000" height="1000"></canvas>
<div class="stage-corner">
<span id="labName">BEBC · CERN</span><br>
Plate <span id="plateNum">001</span><br>
<span class="sm">Exposed · <span id="exposureDate"></span></span>
</div>
<div class="stage-meta">Seed: <span class="hash" id="hashDisplay"></span></div>
</div>
<div class="toast" id="toast">Exported</div>
</main>
<script>
(function () {
const __cache = {};
const __reg = {};
function __require(id) {
if (__cache[id]) return __cache[id].exports;
const module = { exports: {} }; __cache[id] = module;
__reg[id](module, module.exports, __require);
return module.exports;
}
__reg["src/main.js"] = function (module, exports, __require) {
/* ============================================================
main.js — app wiring.
Builds the control panel from controls.js, reads params,
drives generate→render, and handles exports. The preview is
always the photographic renderer; SVG/PDF are the vector path.
============================================================ */
const { cyrb53 } = __require("src/rng.js");
const { generateScene } = __require("src/scene/scene.js");
const { renderCanvasPhoto } = __require("src/render/canvasPhoto.js");
const { renderSVG } = __require("src/render/svgVector.js");
const { buildPDF } = __require("src/render/pdf.js");
const { GROUPS, TOGGLES, SELECTS, FIXED, PRESETS } = __require("src/ui/controls.js");
2026-05-20 16:53:23 -04:00
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 = {};
2026-05-20 16:53:23 -04:00
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);
2026-05-20 17:10:32 -04:00
if (g.title === 'Shock-wave Disk') { addToggle(grp, 'shock'); addToggle(grp, 'diskBubbles'); }
2026-05-20 16:53:23 -04:00
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');
}
2026-05-22 07:50:39 -04:00
if (g.title === 'Media & Hand') {
addToggle(grp, 'filmEdge');
addToggle(grp, 'splice');
}
2026-05-20 16:53:23 -04:00
}
}
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);
}
2026-05-20 16:53:23 -04:00
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;
2026-05-20 16:53:23 -04:00
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'));
}
2026-05-20 16:53:23 -04:00
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");
2026-05-22 07:50:39 -04:00
const { generateMedia } = __require("src/scene/media.js");
2026-05-20 16:53:23 -04:00
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) {
2026-05-20 16:53:23 -04:00
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 });
2026-05-20 16:53:23 -04:00
// δ-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 });
2026-05-20 16:53:23 -04:00
}
}
}
}
}
// 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 });
2026-05-20 16:53:23 -04:00
}
}
// 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 });
2026-05-20 16:53:23 -04:00
});
}
}
// 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;
}
2026-05-20 16:53:23 -04:00
return tracks;
}
function generateScene(params) {
const rng = makeRng(params.seed, 'scene');
const tracks = [];
// Foreground event, slightly off-centre — near the focal plane, current trigger
2026-05-20 16:53:23 -04:00
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));
2026-05-20 16:53:23 -04:00
// Background "history" events — scattered through depth and earlier in time
2026-05-20 16:53:23 -04:00
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
2026-05-20 16:53:23 -04:00
tracks.push(...generateOneEvent(
{ ...params, primaries: Math.round(params.primaries * 0.6), vdecay: 0 },
{ x: vx, y: vy }, intensity, 'bg' + i, z, age
2026-05-20 16:53:23 -04:00
));
}
// 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 });
2026-05-20 16:53:23 -04:00
}
// 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 });
2026-05-20 16:53:23 -04:00
}
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);
2026-05-22 07:50:39 -04:00
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;
2026-05-20 16:53:23 -04:00
}
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(
2026-05-20 16:53:23 -04:00
{ 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 };
2026-05-20 16:53:23 -04:00
}
/* 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(
2026-05-20 16:53:23 -04:00
{ 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 };
2026-05-20 16:53:23 -04:00
}
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 }];
2026-05-20 16:53:23 -04:00
}
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,
});
}
2026-05-20 17:10:32 -04:00
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;
2026-05-20 16:53:23 -04:00
}
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 });
2026-05-22 07:50:39 -04:00
};
__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: `Nº ${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 });
2026-05-20 16:53:23 -04:00
};
__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");
2026-05-20 16:53:23 -04:00
const { mottleCanvas, grainCanvas } = __require("src/render/noise.js");
const { resolvePalette, paperTone, rgbCss, bubbleStops, bubbleFoot, hslToRgb, mix } = __require("src/render/palette.js");
2026-05-20 16:53:23 -04:00
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');
2026-05-20 16:53:23 -04:00
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})`);
2026-05-20 16:53:23 -04:00
g.fillStyle = grad;
g.fillRect(0, 0, S, S);
spriteCache.set(key, c);
2026-05-20 16:53:23 -04:00
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,
2026-05-29 15:40:42 -04:00
traceHue: params.traceHue ?? 0, diskHue: params.diskHue ?? 0.06, diskSat: params.diskSat ?? 0.82,
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
2026-05-20 16:53:23 -04:00
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);
2026-05-20 16:53:23 -04:00
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));
2026-05-20 16:53:23 -04:00
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';
2026-05-20 16:53:23 -04:00
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);
2026-05-20 16:53:23 -04:00
/* ---------- 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)
2026-05-20 16:53:23 -04:00
// 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).
2026-05-20 16:53:23 -04:00
// 3a. continuity under-stroke beneath each track (opacity from depth/age tone)
2026-05-20 16:53:23 -04:00
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;
2026-05-20 16:53:23 -04:00
if (lw < 0.2 * u) continue;
ic.strokeStyle = `rgba(${col[0]},${col[1]},${col[2]},${0.14 * df.tone})`;
2026-05-20 16:53:23 -04:00
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).
2026-05-20 16:53:23 -04:00
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);
2026-05-20 16:53:23 -04:00
for (const b of bubs) {
const sp = spTrack || softDotSprite(pal.bubbleInk(track, b.life, b.beta), eff);
2026-05-20 16:53:23 -04:00
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);
2026-05-20 16:53:23 -04:00
}
}
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();
}
2026-05-20 16:53:23 -04:00
/* ---------- 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;
2026-05-20 16:53:23 -04:00
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;
2026-05-20 16:53:23 -04:00
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);
2026-05-20 16:53:23 -04:00
/* ---------- 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);
2026-05-20 16:53:23 -04:00
/* ---------- 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();
2026-05-20 16:53:23 -04:00
vg.addColorStop(0, `rgba(${vr},${vgc},${vb},0)`);
vg.addColorStop(1, `rgba(${vr},${vgc},${vb},${params.vign * (light ? 0.5 : 0.85)})`);
2026-05-20 16:53:23 -04:00
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;
2026-05-22 07:50:39 -04:00
/* ---------- 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();
}
2026-05-20 16:53:23 -04:00
}
/* ---- shock disk ---- */
2026-05-20 17:10:32 -04:00
function drawShock(c, shock, tx, ty, scale, u, P, params, sprite) {
2026-05-20 16:53:23 -04:00
const [r, g, b] = P.ink;
const px = tx(shock.x), py = ty(shock.y), R = shock.r * scale;
2026-05-20 17:10:32 -04:00
const useBubbles = params.diskBubbles !== false;
2026-05-20 16:53:23 -04:00
2026-05-21 05:59:44 -04:00
// 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');
}
2026-05-20 17:10:32 -04:00
// 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;
2026-05-21 05:59:44 -04:00
const core = T.createRadialGradient(px, py, 0, px, py, R);
2026-05-20 17:10:32 -04:00
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})`);
2026-05-20 16:53:23 -04:00
core.addColorStop(1, `rgba(${r},${g},${b},0)`);
2026-05-21 05:59:44 -04:00
T.fillStyle = core;
T.beginPath(); T.arc(px, py, R, 0, Math.PI * 2); T.fill();
2026-05-20 16:53:23 -04:00
2026-05-20 17:10:32 -04:00
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);
2026-05-21 05:59:44 -04:00
T.globalAlpha = Math.min(1, 0.45 + stroke.weight * 0.5);
2026-05-20 17:10:32 -04:00
for (const bb of bubs) {
const rr = Math.max(bb.r * scale, 0.45);
const d = rr * 2.4;
2026-05-21 05:59:44 -04:00
T.drawImage(sprite, tx(bb.x) - d / 2, ty(bb.y) - d / 2, d, d);
2026-05-20 17:10:32 -04:00
}
}
2026-05-21 05:59:44 -04:00
T.globalAlpha = 1;
2026-05-20 17:10:32 -04:00
} 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})`;
2026-05-21 05:59:44 -04:00
T.lineCap = 'round';
2026-05-20 17:10:32 -04:00
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);
2026-05-21 05:59:44 -04:00
T.lineWidth = s.width * u;
T.beginPath(); T.moveTo(ix, iy); T.quadraticCurveTo(mx, my, ox, oy); T.stroke();
2026-05-20 17:10:32 -04:00
}
for (const ring of shock.rings) {
T.strokeStyle = rs(specCol(ring.rr / shock.r), ring.opacity);
2026-05-21 05:59:44 -04:00
T.lineWidth = ring.width * u;
T.beginPath(); T.arc(px, py, ring.rr * scale, 0, Math.PI * 2); T.stroke();
2026-05-20 17:10:32 -04:00
}
for (const seg of (shock.rimSegs || [])) {
T.strokeStyle = rs(specCol(seg.a0 / TWO_PI), seg.opacity);
2026-05-21 05:59:44 -04:00
T.lineWidth = seg.width * u;
T.beginPath(); T.arc(px, py, shock.r * scale, seg.a0, seg.a1); T.stroke();
2026-05-20 16:53:23 -04:00
}
2026-05-20 17:10:32 -04:00
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);
2026-05-21 05:59:44 -04:00
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();
2026-05-20 17:10:32 -04:00
}
2026-05-20 16:53:23 -04:00
}
// staining blotches: dark = grime, light = lifted/washed clean spots
if (shock.stains) {
for (const st of shock.stains) {
const sr = st.r * scale;
2026-05-21 05:59:44 -04:00
const grad = T.createRadialGradient(tx(st.x), ty(st.y), 0, tx(st.x), ty(st.y), sr);
2026-05-20 16:53:23 -04:00
if (st.dark) {
2026-05-21 05:59:44 -04:00
T.globalCompositeOperation = 'source-over';
2026-05-20 16:53:23 -04:00
grad.addColorStop(0, `rgba(${r},${g},${b},${st.opacity})`);
grad.addColorStop(1, `rgba(${r},${g},${b},0)`);
2026-05-21 05:59:44 -04:00
T.fillStyle = grad;
2026-05-20 16:53:23 -04:00
} else {
2026-05-21 05:59:44 -04:00
T.globalCompositeOperation = 'destination-out';
2026-05-20 16:53:23 -04:00
grad.addColorStop(0, `rgba(0,0,0,${st.opacity * 1.4})`);
grad.addColorStop(1, 'rgba(0,0,0,0)');
2026-05-21 05:59:44 -04:00
T.fillStyle = grad;
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
T.beginPath(); T.arc(tx(st.x), ty(st.y), sr, 0, Math.PI * 2); T.fill();
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
T.globalCompositeOperation = 'source-over';
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
// keep a bright, detailed centre by clearing a soft hole
2026-05-20 16:53:23 -04:00
if (shock.bright) {
2026-05-21 05:59:44 -04:00
T.save();
T.globalCompositeOperation = 'destination-out';
const hole = T.createRadialGradient(px, py, 0, px, py, shock.bright * scale);
2026-05-20 16:53:23 -04:00
hole.addColorStop(0, 'rgba(0,0,0,0.85)');
hole.addColorStop(1, 'rgba(0,0,0,0)');
2026-05-21 05:59:44 -04:00
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';
2026-05-20 16:53:23 -04:00
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) {
2026-05-20 16:53:23 -04:00
const [r, g, b] = P.ink;
ctx.save();
ctx.globalCompositeOperation = light ? 'multiply' : 'screen';
2026-05-20 16:53:23 -04:00
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;
2026-05-20 17:10:32 -04:00
// 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;
2026-05-20 16:53:23 -04:00
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);
2026-05-20 17:10:32 -04:00
const lambda = params.density * track.weight * dScale * (1 / (beta * beta)) * 420;
2026-05-20 16:53:23 -04:00
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);
2026-05-20 16:53:23 -04:00
for (let k = 0; k < nb; k++) {
const t = rng();
const px = a.x + dx * t;
const py = a.y + dy * t;
2026-05-20 17:10:32 -04:00
const j = gauss(rng) * jitter;
const baseR = (0.0011 + (1 - beta) * 0.0017) * params.size * sScale;
2026-05-20 16:53:23 -04:00
// 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 });
2026-05-20 16:53:23 -04:00
}
}
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 };
}
2026-05-20 16:53:23 -04:00
/* 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 });
2026-05-20 16:53:23 -04:00
};
__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 };
2026-05-29 15:40:42 -04:00
// the contrasting feature/disk accent — a single monochrome hue. Defaults to the
// burnt-orange that pairs with the magenta family; `magentarise` exposes hue/sat as
// dials (diskHue/diskSat) so the trace-vs-disk colour relationship is sweepable.
const featureHue = (inv, hue = 0.06, sat = 0.82) => hslToRgb(hue, sat, 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);
},
},
2026-05-29 15:40:42 -04:00
// a complementary "two-colour relationship": type → a shade within a trace family
// (default the purple/magenta/pink band), intensity rises to glowing deaths
// (kindrise), with a monochrome contrasting disk (default burnt orange). The whole
// trace family rotates by `traceHue` (keeping the by-type spread); the disk hue &
// saturation are `diskHue`/`diskSat` — so any trace-vs-disk pairing is sweepable.
magentarise: {
2026-05-29 15:40:42 -04:00
id: 'magentarise', label: 'Trace family · contrasting 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
2026-05-29 15:40:42 -04:00
const h = (((MAG_HUE[b.track.kind] ?? 0.88) + (e.traceHue ?? 0)) % 1 + 1) % 1;
return hslToRgb(h, 0.55 + 0.30 * t, lo + (hi - lo) * t);
},
2026-05-29 15:40:42 -04:00
feature: (e) => featureHue(e.inv, e.diskHue ?? 0.06, e.diskSat ?? 0.82), // disk + furniture
},
// 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 });
2026-05-20 16:53:23 -04:00
};
__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.
2026-05-20 16:53:23 -04:00
============================================================ */
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");
2026-05-20 16:53:23 -04:00
const MARGIN = 0.02;
2026-05-21 05:59:44 -04:00
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'] },
];
2026-05-20 16:53:23 -04:00
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,
2026-05-29 15:40:42 -04:00
traceHue: params.traceHue ?? 0, diskHue: params.diskHue ?? 0.06, diskSat: params.diskSat ?? 0.82,
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
2026-05-20 16:53:23 -04:00
2026-05-21 05:59:44 -04:00
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)"/>`;
2026-05-20 16:53:23 -04:00
/* ---------- Chamber optics ---------- */
2026-05-21 05:59:44 -04:00
let optics = '';
2026-05-20 16:53:23 -04:00
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;
2026-05-21 05:59:44 -04:00
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`;
2026-05-20 16:53:23 -04:00
}
if (scene.instrument) {
const inst = scene.instrument;
2026-05-21 05:59:44 -04:00
let g = `<g fill="none" stroke="${ink}" stroke-linecap="round">`;
2026-05-20 16:53:23 -04:00
for (const l of inst.lines)
2026-05-21 05:59:44 -04:00
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)}"/>`;
2026-05-20 16:53:23 -04:00
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)}`;
2026-05-21 05:59:44 -04:00
g += `<path d="${d}" stroke-opacity="${a.opacity.toFixed(3)}" stroke-width="${(a.width * u).toFixed(2)}"/>`;
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
optics += g + `</g>`;
2026-05-20 16:53:23 -04:00
}
/* ---------- Shock disk (feature ink) ---------- */
2026-05-21 05:59:44 -04:00
let shock = '';
2026-05-20 16:53:23 -04:00
if (scene.shock) {
const sh = scene.shock;
const px = +tx(sh.x), py = +ty(sh.y), R = sh.r * scale;
2026-05-20 17:10:32 -04:00
const bodyOpacity = (params.diskBubbles !== false) ? 0.6 : 1;
2026-05-21 05:59:44 -04:00
shock += `<circle cx="${px}" cy="${py}" r="${R.toFixed(1)}" fill="url(#shockcore)" fill-opacity="${bodyOpacity}"/>\n`;
2026-05-20 17:10:32 -04:00
if (params.diskBubbles !== false && sh.bubbleStrokes) {
const dRng = makeRng(params.seed, 'diskbubbles');
const dbk = new Map();
for (const stroke of sh.bubbleStrokes) {
const key = Math.round(Math.min(1, 0.45 + stroke.weight * 0.5) * 20) / 20;
if (!dbk.has(key)) dbk.set(key, []);
dbk.get(key).push(...sampleBubbles(stroke, params, dRng));
}
for (const [alpha, bubs] of dbk) {
shock += `<g fill="url(#bub-${featKey})" fill-opacity="${alpha}">`;
2026-05-21 05:59:44 -04:00
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`;
2026-05-20 17:10:32 -04:00
}
} 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">`;
2026-05-20 17:10:32 -04:00
for (const st of sh.striations) {
const ix = px + Math.cos(st.a) * st.inner * scale, iy = py + Math.sin(st.a) * st.inner * scale;
const ox = px + Math.cos(st.a) * st.outer * scale, oy = py + Math.sin(st.a) * st.outer * scale;
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)}"/>`;
2026-05-20 17:10:32 -04:00
}
2026-05-21 05:59:44 -04:00
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)}"/>`;
2026-05-20 17:10:32 -04:00
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)}"/>`;
2026-05-20 17:10:32 -04:00
}
2026-05-21 05:59:44 -04:00
shock += g + `</g>\n`;
2026-05-20 16:53:23 -04:00
}
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)}"/>`;
2026-05-20 16:53:23 -04:00
}
}
/* ---------- Tracks (per-trail colour via palette; positions unchanged) ---------- */
2026-05-21 05:59:44 -04:00
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})
2026-05-21 05:59:44 -04:00
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
2026-05-20 16:53:23 -04:00
for (const track of scene.tracks) {
2026-05-21 05:59:44 -04:00
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)
2026-05-21 05:59:44 -04:00
// 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)}"/>`);
2026-05-21 05:59:44 -04:00
}
// 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;
2026-05-21 05:59:44 -04:00
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)}"/>`);
2026-05-20 16:53:23 -04:00
}
}
2026-05-21 05:59:44 -04:00
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`;
2026-05-21 05:59:44 -04:00
}
return layer(L.id, L.label, content);
}).join('');
2026-05-20 16:53:23 -04:00
/* ---------- Plate damage (feature ink) ---------- */
2026-05-21 05:59:44 -04:00
let damage = '';
2026-05-20 16:53:23 -04:00
const A = scene.artifacts;
if (A) {
2026-05-21 05:59:44 -04:00
let g = `<g stroke="${ink}" fill="none" stroke-linecap="round">`;
2026-05-20 16:53:23 -04:00
for (const ring of A.rings)
2026-05-21 05:59:44 -04:00
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)}"/>`;
2026-05-20 16:53:23 -04:00
for (const sc of A.scratches)
2026-05-21 05:59:44 -04:00
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)}"/>`;
2026-05-20 16:53:23 -04:00
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)}`;
2026-05-21 05:59:44 -04:00
g += `<path d="${d}" stroke-opacity="${hair.opacity.toFixed(3)}" stroke-width="${(hair.width * u).toFixed(2)}"/>`;
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
g += `</g><g fill="${ink}">`;
2026-05-20 16:53:23 -04:00
for (const sp of A.specks)
2026-05-21 05:59:44 -04:00
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>`;
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
/* ---------- Fiducials ---------- */
let fids = '';
2026-05-20 16:53:23 -04:00
if (params.showFiducials) {
2026-05-21 05:59:44 -04:00
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]];
2026-05-20 16:53:23 -04:00
const sz = 9 * u;
2026-05-21 05:59:44 -04:00
for (const [fx, fy] of F) {
2026-05-20 16:53:23 -04:00
const px = +tx(fx), py = +ty(fy);
2026-05-21 05:59:44 -04:00
g += `<line x1="${px - sz}" y1="${py}" x2="${px + sz}" y2="${py}"/><line x1="${px}" y1="${py - sz}" x2="${px}" y2="${py + sz}"/>`;
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
fids = g + `</g>`;
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
/* ---------- Vignette ---------- */
const vign = params.vign > 0 ? `<rect width="${w}" height="${h}" fill="url(#vign)"/>` : '';
2026-05-20 16:53:23 -04:00
2026-05-21 05:59:44 -04:00
/* ---------- Header ---------- */
let header = '';
2026-05-20 16:53:23 -04:00
if (params.showHeader) {
const pad = 26 * u;
const esc = (t) => String(t).replace(/[<&]/g, c => (c === '<' ? '&lt;' : '&amp;'));
2026-05-21 05:59:44 -04:00
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>`;
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
2026-05-22 07:50:39 -04:00
/* ---------- 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>`;
}
}
2026-05-21 05:59:44 -04:00
/* ---------- 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 });
2026-05-21 05:59:44 -04:00
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);
2026-05-22 07:50:39 -04:00
s += layer('media', 'Media & hand', media);
2026-05-20 16:53:23 -04:00
s += `</svg>\n`;
return s;
}
function defs({ paperC, ink, baseVign, params, u, colorMap }) {
2026-05-21 05:59:44 -04:00
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);
2026-05-21 05:59:44 -04:00
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}"/>
2026-05-21 05:59:44 -04:00
</radialGradient>
${bubGrads}
2026-05-21 05:59:44 -04:00
<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"/>
2026-05-21 05:59:44 -04:00
</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}"/>
2026-05-21 05:59:44 -04:00
</radialGradient>
</defs>\n`;
}
2026-05-20 16:53:23 -04:00
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;
2026-05-21 05:59:44 -04:00
• ExtGState soft-mask alpha (/ca,/CA) for true opacity;
• Optional Content Groups (/OCG) so the file opens with
toggleable LAYERS in Acrobat / Illustrator / Preview;
2026-05-20 16:53:23 -04:00
• a base-14 Helvetica archival header (no font embedding).
Geometry comes from the same scene model as every renderer.
2026-05-21 05:59:44 -04:00
(Note: the disk-soften Gaussian is raster/SVG only — PDF keeps
the disk crisp.)
2026-05-20 16:53:23 -04:00
============================================================ */
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");
2026-05-20 16:53:23 -04:00
const MARGIN = 0.02;
2026-05-21 05:59:44 -04:00
const KIND_LAYER = {
primary: 'Tracks - primary',
cosmic: 'Tracks - cosmic & sweepers',
sweep: 'Tracks - cosmic & sweepers',
vdecay: 'Tracks - V-decays',
delta: 'Tracks - delta-rays',
};
const TRACK_ORDER = ['Tracks - primary', 'Tracks - cosmic & sweepers', 'Tracks - V-decays', 'Tracks - delta-rays'];
2026-05-20 16:53:23 -04:00
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,
2026-05-29 15:40:42 -04:00
traceHue: params.traceHue ?? 0, diskHue: params.diskHue ?? 0.06, diskSat: params.diskSat ?? 0.82,
baseInk: inv ? [28, 24, 20] : [233, 228, 214],
basePaper: { flat: pt.flat, glowIn: pt.glowIn, glowOut: pt.glowOut }, baseVign: [0, 0, 0],
});
// paper is CMYK at the default cream (print-grade rich); toned papers or palette
// chemistries (cyanotype) emit RGB
const toned = pal.hasPaper || (params.paperTone && params.paperTone !== 'cream') || (params.toneStrength ?? 1) !== 1 || (params.paperBright ?? 1) !== 1 || (params.glow ?? 0.5) !== 0.5;
const paperOp = toned ? `${rgb01(pal.paper().flat).join(' ')} rg` : `${paperCMYK} k`;
const bubbleFill = (track, b) => {
if (mono) return { op: `${inkCMYK} k`, key: 'k' };
const c = pal.bubbleInk(track, b.life, b.beta);
return { op: `${rgb01(c).join(' ')} rg`, key: rgbKey(c) };
};
const trackStroke = (track) => mono
? `${inkCMYK} K\n`
: `${rgb01(pal.ink(track)).join(' ')} RG\n`;
2026-05-20 16:53:23 -04:00
const gsSet = new Map();
const gs = (alpha) => {
const k = Math.max(0, Math.min(100, Math.round(alpha * 100)));
if (!gsSet.has(k)) gsSet.set(k, `GS${k}`);
return `/GS${k} gs\n`;
};
2026-05-21 05:59:44 -04:00
// optional-content layer accumulation
const ocgNames = [];
let content = '';
const emit = (name, ops) => {
if (!ops || !ops.trim()) return;
const i = ocgNames.length;
ocgNames.push(name);
content += `/OC /OC${i} BDC\n${ops}EMC\n`;
};
2026-05-20 16:53:23 -04:00
2026-05-21 05:59:44 -04:00
/* ---------- Background ---------- */
emit('Background', `${paperOp}\n0 0 ${pageSize} ${pageSize} re f\n`);
2026-05-20 16:53:23 -04:00
2026-05-21 05:59:44 -04:00
/* ---------- Chamber optics ---------- */
{
let o = '';
if (params.showBoundary) {
o += `q\n${gs(0.4)}${inkCMYK} K\n${(2 * u).toFixed(2)} w\n`;
const bcx = cx, bcy = cy - pageSize * 0.35, br = pageSize * 0.45;
const a1 = Math.PI * 0.15, a2 = Math.PI - Math.PI * 0.15, steps = 90;
for (let i = 0; i <= steps; i++) {
const a = Math.PI + a1 + (a2 - a1) * (i / steps);
const px = bcx + Math.cos(a) * br, py = bcy - Math.sin(a) * br;
o += `${px.toFixed(1)} ${py.toFixed(1)} ${i === 0 ? 'm' : 'l'}\n`;
}
o += `S\nQ\n`;
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
if (scene.instrument) {
o += `q\n${inkCMYK} K\n1 J\n`;
for (const l of scene.instrument.lines)
o += `${gs(l.opacity)}${(l.width * u).toFixed(2)} w\n${tx(l.x1).toFixed(1)} ${ty(l.y1).toFixed(1)} m ${tx(l.x2).toFixed(1)} ${ty(l.y2).toFixed(1)} l S\n`;
for (const a of scene.instrument.arcs) {
o += `${gs(a.opacity)}${(a.width * u).toFixed(2)} w\n${tx(a.pts[0].x).toFixed(1)} ${ty(a.pts[0].y).toFixed(1)} m\n`;
for (let i = 1; i < a.pts.length; i++) o += `${tx(a.pts[i].x).toFixed(1)} ${ty(a.pts[i].y).toFixed(1)} l\n`;
o += `S\n`;
}
o += `Q\n`;
}
emit('Chamber optics', o);
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
/* ---------- Shock disk ---------- */
2026-05-20 16:53:23 -04:00
if (scene.shock) {
2026-05-21 05:59:44 -04:00
const sh = scene.shock, px = tx(sh.x), py = ty(sh.y);
let o = '';
2026-05-20 17:10:32 -04:00
if (params.diskBubbles !== false && sh.bubbleStrokes) {
const dRng = makeRng(params.seed, 'diskbubbles');
const dbk = new Map();
for (const stroke of sh.bubbleStrokes) {
const key = Math.round(Math.min(1, 0.45 + stroke.weight * 0.5) * 20) / 20;
if (!dbk.has(key)) dbk.set(key, []);
dbk.get(key).push(...sampleBubbles(stroke, params, dRng));
}
2026-05-21 05:59:44 -04:00
o += `q\n${inkCMYK} k\n`;
2026-05-20 17:10:32 -04:00
for (const [alpha, bubs] of dbk) {
2026-05-21 05:59:44 -04:00
o += gs(alpha);
for (const b of bubs) o += circlePath(tx(b.x), ty(b.y), Math.max(b.r * scale, 0.5)) + 'f\n';
2026-05-20 17:10:32 -04:00
}
2026-05-21 05:59:44 -04:00
o += `Q\n`;
2026-05-20 17:10:32 -04:00
} else {
// optionally iridescent — per-element RGB stroke across the sunburst
const spec = params.diskSpectrum || 0;
const featRGB = pal.feature();
const TWO_PI = Math.PI * 2;
const sStroke = (frac) => spec <= 0 ? `${inkCMYK} K\n`
: `${rgb01(mix(featRGB, hslToRgb((((frac + (params.hueShift || 0)) % 1) + 1) % 1, 0.85 * (params.saturation ?? 1), 0.52), spec)).join(' ')} RG\n`;
o += `q\n1 J\n`;
2026-05-20 17:10:32 -04:00
for (const st of sh.striations) {
const ix = px + Math.cos(st.a) * st.inner * scale, iy = py - Math.sin(st.a) * st.inner * scale;
const ox = px + Math.cos(st.a) * st.outer * scale, oy = py - Math.sin(st.a) * st.outer * scale;
o += `${sStroke(st.a / TWO_PI)}${gs(st.opacity)}${(st.width * u).toFixed(2)} w\n${ix.toFixed(1)} ${iy.toFixed(1)} m ${ox.toFixed(1)} ${oy.toFixed(1)} l S\n`;
2026-05-20 17:10:32 -04:00
}
for (const k of sh.core) {
const ca = Math.atan2(k.y1 - sh.y, k.x1 - sh.x);
o += `${sStroke(ca / TWO_PI)}${gs(k.opacity)}${(k.width * u).toFixed(2)} w\n${tx(k.x1).toFixed(1)} ${ty(k.y1).toFixed(1)} m ${tx(k.x2).toFixed(1)} ${ty(k.y2).toFixed(1)} l S\n`;
}
for (const ring of sh.rings) o += `${sStroke(ring.rr / sh.r)}${gs(ring.opacity)}${(ring.width * u).toFixed(2)} w\n` + strokeCircle(px, py, ring.rr * scale);
for (const seg of (sh.rimSegs || [])) o += `${sStroke(seg.a0 / TWO_PI)}${gs(seg.opacity)}${(seg.width * u).toFixed(2)} w\n` + arcStroke(px, py, sh.r * scale, -seg.a0, -seg.a1);
2026-05-21 05:59:44 -04:00
o += `Q\n`;
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
emit('Shock disk', o);
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
/* ---------- Tracks (split by kind; bubble rng order preserved) ---------- */
const under = new Map(); // layer -> stroke ops (per-track colour set inline)
const buck = new Map(); // layer -> Map(colourKey|alpha -> {op, alpha, arr})
2026-05-20 16:53:23 -04:00
const bRng = makeRng(params.seed, 'bubbles');
for (const t of scene.tracks) {
2026-05-21 05:59:44 -04:00
const name = KIND_LAYER[t.kind] || 'Tracks - primary';
if (!under.has(name)) { under.set(name, ''); buck.set(name, new Map()); }
const df = depthFactors(t, params);
2026-05-21 05:59:44 -04:00
if (t.pts.length >= 2) {
const lw = Math.min(2.6, 0.25 + Math.sqrt(trackInkWeight(t)) * 0.12) * u * params.size * t.weight;
if (lw >= 0.2 * u) {
let su = `${trackStroke(t)}${gs(0.14 * df.tone)}${lw.toFixed(2)} w\n${tx(t.pts[0].x).toFixed(2)} ${ty(t.pts[0].y).toFixed(2)} m\n`;
2026-05-21 05:59:44 -04:00
for (let i = 1; i < t.pts.length; i++) su += `${tx(t.pts[i].x).toFixed(2)} ${ty(t.pts[i].y).toFixed(2)} l\n`;
under.set(name, under.get(name) + su + `S\n`);
}
}
const alpha = Math.round(Math.min(1, 0.36 + df.tone * 0.58) * 20) / 20;
2026-05-21 05:59:44 -04:00
const m = buck.get(name);
for (const b of sampleBubbles({ ...t, sizeScale: df.sizeScale }, params, bRng)) {
const fill = bubbleFill(t, b);
const bkey = fill.key + '|' + alpha;
if (!m.has(bkey)) m.set(bkey, { op: fill.op, alpha, arr: [] });
m.get(bkey).arr.push(circlePath(tx(b.x), ty(b.y), Math.max(b.r * scale, 0.5)) + 'f\n');
}
2026-05-21 05:59:44 -04:00
}
for (const name of TRACK_ORDER) {
if (!under.has(name)) continue;
let o = '';
const us = under.get(name);
if (us) o += `q\n1 J\n1 j\n${us}Q\n`;
2026-05-21 05:59:44 -04:00
let bub = '';
for (const { op, alpha, arr } of buck.get(name).values()) if (arr.length) bub += `${op}\n${gs(alpha)}${arr.join('')}`;
if (bub) o += `q\n${bub}Q\n`;
2026-05-21 05:59:44 -04:00
emit(name, o);
}
/* ---------- Plate damage ---------- */
2026-05-20 16:53:23 -04:00
const A = scene.artifacts;
if (A) {
2026-05-21 05:59:44 -04:00
let o = `q\n${inkCMYK} K\n1 J\n`;
for (const sc of A.scratches) o += `${gs(sc.opacity)}${(sc.width * u).toFixed(2)} w\n${tx(sc.x1).toFixed(1)} ${ty(sc.y1).toFixed(1)} m ${tx(sc.x2).toFixed(1)} ${ty(sc.y2).toFixed(1)} l S\n`;
2026-05-20 16:53:23 -04:00
for (const hair of A.hairs) {
2026-05-21 05:59:44 -04:00
o += `${gs(hair.opacity)}${(hair.width * u).toFixed(2)} w\n${tx(hair.pts[0].x).toFixed(1)} ${ty(hair.pts[0].y).toFixed(1)} m\n`;
for (let i = 1; i < hair.pts.length; i++) o += `${tx(hair.pts[i].x).toFixed(1)} ${ty(hair.pts[i].y).toFixed(1)} l\n`;
o += `S\n`;
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
for (const ring of A.rings) o += `${gs(ring.opacity)}${(ring.width * u).toFixed(2)} w\n` + strokeCircle(tx(ring.x), ty(ring.y), ring.r * scale);
o += `Q\nq\n${inkCMYK} k\n`;
for (const sp of A.specks) o += gs(sp.opacity) + circlePath(tx(sp.x), ty(sp.y), Math.max(sp.r * scale, 0.5)) + 'f\n';
o += `Q\n`;
emit('Plate damage', o);
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
/* ---------- Fiducials ---------- */
2026-05-20 16:53:23 -04:00
if (params.showFiducials) {
2026-05-21 05:59:44 -04:00
let o = `q\n${gs(0.55)}${inkCMYK} K\n${(1.2 * u).toFixed(2)} w\n`;
2026-05-20 16:53:23 -04:00
const fids = [[-0.85, -0.85], [0.85, -0.85], [-0.85, 0.85], [0.85, 0.85], [0, -0.85], [-0.85, 0], [0.85, 0]];
const s = 8 * u;
for (const [fx, fy] of fids) {
const px = tx(fx), py = ty(fy);
2026-05-21 05:59:44 -04:00
o += `${px - s} ${py} m ${px + s} ${py} l S\n${px} ${py - s} m ${px} ${py + s} l S\n`;
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
o += `Q\n`;
emit('Fiducials', o);
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
/* ---------- Archival header ---------- */
2026-05-20 16:53:23 -04:00
if (params.showHeader) {
const pad = 26 * u;
2026-05-21 05:59:44 -04:00
let o = `q\n${gs(0.6)}${inkCMYK} k\nBT\n/F0 ${(11 * u).toFixed(1)} Tf\n1 0 0 1 ${pad.toFixed(1)} ${(pageSize - pad - 11 * u).toFixed(1)} Tm (${pdfStr(scene.lab.toUpperCase())}) Tj\nET\n`;
o += `BT\n/F0 ${(9 * u).toFixed(1)} Tf\n1 0 0 1 ${pad.toFixed(1)} ${(pageSize - pad - 27 * u).toFixed(1)} Tm (SEED ${pdfStr(params.seed)}) Tj\nET\n`;
2026-05-20 16:53:23 -04:00
const fs = 10 * u;
const rt = (txt, yoff) => {
const wEst = txt.length * fs * 0.5;
2026-05-21 05:59:44 -04:00
o += `BT\n/F0 ${fs.toFixed(1)} Tf\n1 0 0 1 ${(pageSize - pad - wEst).toFixed(1)} ${yoff.toFixed(1)} Tm (${pdfStr(txt)}) Tj\nET\n`;
2026-05-20 16:53:23 -04:00
};
rt(`PLATE ${scene.plate}`, pad + 13 * u);
rt(`EXPOSED ${scene.exposure}`, pad);
2026-05-21 05:59:44 -04:00
o += `Q\n`;
emit('Archival header', o);
2026-05-20 16:53:23 -04:00
}
let extg = '';
for (const [k, name] of gsSet) extg += `/${name} << /ca ${(k / 100).toFixed(2)} /CA ${(k / 100).toFixed(2)} >> `;
2026-05-21 05:59:44 -04:00
return assemblePDF(content, pageSize, extg, ocgNames);
}
/* a stroked arc approximated by line segments (for rim segments) */
function arcStroke(px, py, r, a0, a1) {
const steps = Math.max(2, Math.round(Math.abs(a1 - a0) / 0.12));
let s = '';
for (let i = 0; i <= steps; i++) {
const a = a0 + (a1 - a0) * (i / steps);
s += `${(px + Math.cos(a) * r).toFixed(1)} ${(py + Math.sin(a) * r).toFixed(1)} ${i === 0 ? 'm' : 'l'}\n`;
}
return s + 'S\n';
2026-05-20 16:53:23 -04:00
}
function circlePath(px, py, r) {
const k = 0.5522847498 * r;
return `${(px - r).toFixed(2)} ${py.toFixed(2)} m\n` +
`${(px - r).toFixed(2)} ${(py + k).toFixed(2)} ${(px - k).toFixed(2)} ${(py + r).toFixed(2)} ${px.toFixed(2)} ${(py + r).toFixed(2)} c\n` +
`${(px + k).toFixed(2)} ${(py + r).toFixed(2)} ${(px + r).toFixed(2)} ${(py + k).toFixed(2)} ${(px + r).toFixed(2)} ${py.toFixed(2)} c\n` +
`${(px + r).toFixed(2)} ${(py - k).toFixed(2)} ${(px + k).toFixed(2)} ${(py - r).toFixed(2)} ${px.toFixed(2)} ${(py - r).toFixed(2)} c\n` +
`${(px - k).toFixed(2)} ${(py - r).toFixed(2)} ${(px - r).toFixed(2)} ${(py - k).toFixed(2)} ${(px - r).toFixed(2)} ${py.toFixed(2)} c\n`;
}
function strokeCircle(px, py, r) {
const k = 0.5522847498 * r;
return `${(px - r).toFixed(2)} ${py.toFixed(2)} m ` +
`${(px - r).toFixed(2)} ${(py + k).toFixed(2)} ${(px - k).toFixed(2)} ${(py + r).toFixed(2)} ${px.toFixed(2)} ${(py + r).toFixed(2)} c ` +
`${(px + k).toFixed(2)} ${(py + r).toFixed(2)} ${(px + r).toFixed(2)} ${(py + k).toFixed(2)} ${(px + r).toFixed(2)} ${py.toFixed(2)} c ` +
`${(px + r).toFixed(2)} ${(py - k).toFixed(2)} ${(px + k).toFixed(2)} ${(py - r).toFixed(2)} ${px.toFixed(2)} ${(py - r).toFixed(2)} c ` +
`${(px - k).toFixed(2)} ${(py - r).toFixed(2)} ${(px - r).toFixed(2)} ${(py - k).toFixed(2)} ${(px - r).toFixed(2)} ${py.toFixed(2)} c S\n`;
}
function pdfStr(s) { return String(s).replace(/([()\\])/g, '\\$1'); }
2026-05-21 05:59:44 -04:00
function assemblePDF(content, pageSize, extg, ocgNames) {
2026-05-20 16:53:23 -04:00
const enc = new TextEncoder();
const contentBytes = enc.encode(content);
2026-05-21 05:59:44 -04:00
let body = `%PDF-1.5\n%\xC3\xA0\xC3\xA1\xC3\xA2\xC3\xA3\n`;
2026-05-20 16:53:23 -04:00
const offsets = [];
const addObj = (s) => { offsets.push(body.length); body += s; };
2026-05-21 05:59:44 -04:00
const ocgRefs = ocgNames.map((_, i) => `${5 + i} 0 R`).join(' ');
const props = ocgNames.map((_, i) => `/OC${i} ${5 + i} 0 R`).join(' ');
const resources = `<< /ExtGState << ${extg} >> /Font << /F0 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /Properties << ${props} >> >>`;
addObj(`1 0 obj\n<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [${ocgRefs}] /D << /Order [${ocgRefs}] >> >> >>\nendobj\n`);
2026-05-20 16:53:23 -04:00
addObj(`2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n`);
addObj(`3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageSize} ${pageSize}] /Contents 4 0 R /Resources ${resources} >>\nendobj\n`);
addObj(`4 0 obj\n<< /Length ${contentBytes.length} >>\nstream\n${content}\nendstream\nendobj\n`);
2026-05-21 05:59:44 -04:00
ocgNames.forEach((name) => addObj(`${offsets.length + 1} 0 obj\n<< /Type /OCG /Name (${pdfStr(name)}) >>\nendobj\n`));
const size = 4 + ocgNames.length + 1;
2026-05-20 16:53:23 -04:00
const xref = body.length;
2026-05-21 05:59:44 -04:00
body += `xref\n0 ${size}\n0000000000 65535 f \n`;
2026-05-20 16:53:23 -04:00
for (const o of offsets) body += String(o).padStart(10, '0') + ' 00000 n \n';
2026-05-21 05:59:44 -04:00
body += `trailer\n<< /Size ${size} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
2026-05-20 16:53:23 -04:00
return new Uint8Array(enc.encode(body));
}
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' },
2026-05-21 05:59:44 -04:00
{ 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' },
2026-05-20 16:53:23 -04:00
{ 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',
2026-05-20 16:53:23 -04:00
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' },
2026-05-20 16:53:23 -04:00
],
},
{
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' },
2026-05-20 16:53:23 -04:00
{ 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' },
2026-05-29 15:40:42 -04:00
{ id: 'traceHue', label: 'Trace family hue (magentarise)', min: 0, max: 1, step: 0.005, value: 0, mode: 'render' },
{ id: 'diskHue', label: 'Disk accent hue (magentarise)', min: 0, max: 1, step: 0.005, value: 0.06, mode: 'render' },
{ id: 'diskSat', label: 'Disk accent saturation (magentarise)', min: 0, max: 1, step: 0.01, value: 0.82, mode: 'render' },
],
},
2026-05-22 07:50:39 -04:00
{
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',
2026-05-29 15:40:42 -04:00
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', 'Trace family · contrasting 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']],
},
2026-05-20 16:53:23 -04:00
];
const TOGGLES = [
{ id: 'shock', label: 'Shock-wave disk', value: true, mode: 'scene' },
2026-05-21 05:59:44 -04:00
{ id: 'diskBubbles', label: 'Disk as bubbles', value: false, mode: 'scene' },
2026-05-20 16:53:23 -04:00
{ 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' },
2026-05-22 07:50:39 -04:00
{ id: 'filmEdge', label: 'Film edge (sprockets, data box)', value: false, mode: 'scene' },
{ id: 'splice', label: 'Tape splice', value: false, mode: 'scene' },
2026-05-20 16:53:23 -04:00
];
/* 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 });
2026-05-20 16:53:23 -04:00
};
__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),
2026-05-21 05:59:44 -04:00
shock: true, diskBubbles: false, shockIntensity: r(0.6, 0.9), shockSize: r(0.26, 0.4),
2026-05-20 16:53:23 -04:00
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
2026-05-21 05:59:44 -04:00
diskSoften: r(0.4, 1.1), // gaussian softening of disk edges
2026-05-20 16:53:23 -04:00
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),
2026-05-20 16:53:23 -04:00
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,
2026-05-29 15:40:42 -04:00
traceHue: 0, diskHue: 0.06, diskSat: 0.82, // magentarise: trace-family rotation + disk accent hue/sat
paperTone: 'cream', toneStrength: 1.0, paperBright: 1.0, glow: 0.5,
2026-05-22 07:50:39 -04:00
annotate: 0, reseau: 0, filmEdge: false, splice: false, // media & hand layer (opt-in)
2026-05-20 16:53:23 -04:00
};
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>