1257 lines
44 KiB
HTML
1257 lines
44 KiB
HTML
<!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: 320px 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;
|
||
color: var(--ink);
|
||
margin-bottom: 2px;
|
||
}
|
||
.subtitle {
|
||
font-size: 10px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.2em;
|
||
color: var(--ink-mute);
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.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: 12px;
|
||
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;
|
||
}
|
||
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 {
|
||
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 { outline: none; border-color: var(--accent); }
|
||
|
||
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-bottom: 10px;
|
||
cursor: pointer;
|
||
}
|
||
.checkbox-row input { accent-color: var(--accent); }
|
||
.checkbox-row label { font-size: 11px; color: var(--ink-dim); cursor: pointer; }
|
||
|
||
/* ---------- 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;
|
||
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);
|
||
font-family: 'JetBrains Mono', monospace;
|
||
}
|
||
.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 · v1</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>
|
||
|
||
<div class="group">
|
||
<div class="group-title">Event</div>
|
||
<div class="row">
|
||
<label>Primary tracks</label>
|
||
<span class="val" id="primariesVal">12</span>
|
||
</div>
|
||
<input type="range" id="primaries" min="3" max="40" value="12" step="1">
|
||
|
||
<div class="row">
|
||
<label>Burst intensity</label>
|
||
<span class="val" id="burstVal">0.7</span>
|
||
</div>
|
||
<input type="range" id="burst" min="0" max="1" value="0.7" step="0.01">
|
||
|
||
<div class="row">
|
||
<label>V-decay vertices</label>
|
||
<span class="val" id="vdecayVal">3</span>
|
||
</div>
|
||
<input type="range" id="vdecay" min="0" max="10" value="3" step="1">
|
||
</div>
|
||
|
||
<div class="group">
|
||
<div class="group-title">Field & Trajectory</div>
|
||
<div class="row">
|
||
<label>Magnetic field |B|</label>
|
||
<span class="val" id="bfieldVal">1.0</span>
|
||
</div>
|
||
<input type="range" id="bfield" min="0.2" max="3" value="1.0" step="0.01">
|
||
|
||
<div class="row">
|
||
<label>Energy-loss rate</label>
|
||
<span class="val" id="elossVal">0.4</span>
|
||
</div>
|
||
<input type="range" id="eloss" min="0" max="1.5" value="0.4" step="0.01">
|
||
|
||
<div class="row">
|
||
<label>Momentum spread</label>
|
||
<span class="val" id="pspreadVal">0.5</span>
|
||
</div>
|
||
<input type="range" id="pspread" min="0" max="1" value="0.5" step="0.01">
|
||
</div>
|
||
|
||
<div class="group">
|
||
<div class="group-title">δ-Rays (curly bits)</div>
|
||
<div class="row">
|
||
<label>Spawn rate</label>
|
||
<span class="val" id="deltaRateVal">0.35</span>
|
||
</div>
|
||
<input type="range" id="deltaRate" min="0" max="1" value="0.35" step="0.01">
|
||
|
||
<div class="row">
|
||
<label>Tightness</label>
|
||
<span class="val" id="deltaTightVal">0.6</span>
|
||
</div>
|
||
<input type="range" id="deltaTight" min="0.1" max="1.5" value="0.6" step="0.01">
|
||
</div>
|
||
|
||
<div class="group">
|
||
<div class="group-title">Rendering</div>
|
||
<div class="row">
|
||
<label>Bubble density</label>
|
||
<span class="val" id="densityVal">1.0</span>
|
||
</div>
|
||
<input type="range" id="density" min="0.2" max="2.5" value="1.0" step="0.01">
|
||
|
||
<div class="row">
|
||
<label>Bubble size</label>
|
||
<span class="val" id="sizeVal">1.0</span>
|
||
</div>
|
||
<input type="range" id="size" min="0.3" max="2.5" value="1.0" step="0.01">
|
||
|
||
<div class="row">
|
||
<label>Film grain</label>
|
||
<span class="val" id="grainVal">0.25</span>
|
||
</div>
|
||
<input type="range" id="grain" min="0" max="1" value="0.25" step="0.01">
|
||
|
||
<div class="row">
|
||
<label>Vignette</label>
|
||
<span class="val" id="vignVal">0.4</span>
|
||
</div>
|
||
<input type="range" id="vign" min="0" max="1" value="0.4" step="0.01">
|
||
|
||
<div class="checkbox-row">
|
||
<input type="checkbox" id="showFiducials" checked>
|
||
<label for="showFiducials">Fiducial marks</label>
|
||
</div>
|
||
<div class="checkbox-row">
|
||
<input type="checkbox" id="showBoundary" checked>
|
||
<label for="showBoundary">Chamber boundary</label>
|
||
</div>
|
||
<div class="checkbox-row">
|
||
<input type="checkbox" id="invert" checked>
|
||
<label for="invert">Invert · photographic positive</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="group">
|
||
<div class="group-title">Event Layers</div>
|
||
<div class="row">
|
||
<label>Background events</label>
|
||
<span class="val" id="bgEventsVal">4</span>
|
||
</div>
|
||
<input type="range" id="bgEvents" min="0" max="12" value="4" step="1">
|
||
|
||
<div class="row">
|
||
<label>Background intensity</label>
|
||
<span class="val" id="bgIntensityVal">0.35</span>
|
||
</div>
|
||
<input type="range" id="bgIntensity" min="0" max="1" value="0.35" step="0.01">
|
||
|
||
<div class="row">
|
||
<label>Emulsion artifacts</label>
|
||
<span class="val" id="artifactsVal">0.5</span>
|
||
</div>
|
||
<input type="range" id="artifacts" min="0" max="1" value="0.5" step="0.01">
|
||
</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″ @ 300 DPI<br>
|
||
Vector exports scale infinitely.
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<main>
|
||
<div class="stage-frame">
|
||
<canvas id="preview" width="900" height="900"></canvas>
|
||
<div class="stage-corner">
|
||
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>
|
||
/* ============================================================
|
||
BUBBLE CHAMBER — parametric particle track generator
|
||
Deterministic from a seed string. Renders to canvas (preview)
|
||
and emits SVG / PDF / PNG with identical geometry.
|
||
============================================================ */
|
||
|
||
/* ---------- Seeded PRNG (mulberry32 + cyrb53 hash) ---------- */
|
||
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');
|
||
}
|
||
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;
|
||
};
|
||
}
|
||
function makeRng(seedStr, salt = '') {
|
||
const h = cyrb53(seedStr + '::' + salt);
|
||
return mulberry32(parseInt(h.slice(0, 8), 16));
|
||
}
|
||
const gauss = (rng) => {
|
||
// Box-Muller
|
||
const u = 1 - rng(), v = rng();
|
||
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
|
||
};
|
||
|
||
/* ---------- Geometry primitives ---------- */
|
||
// All geometry computed in a logical unit square [-1,1] x [-1,1].
|
||
// Rendering scales to whatever resolution we want.
|
||
|
||
/* Compute a helical/curved track polyline given starting state.
|
||
Returns array of {x, y, beta} sample points.
|
||
Physics:
|
||
- charged particle in uniform B (out of page) curves with radius r = p/(qB)
|
||
- momentum decreases via dE/dx ∝ 1/β² (simplified)
|
||
- β tracked as proxy for ionization → bubble density & track thickness */
|
||
function integrateTrack(state, params, rng, maxSteps = 1200) {
|
||
const pts = [];
|
||
let { x, y, theta, p, q } = state;
|
||
const m = 1.0; // particle mass (arbitrary units)
|
||
const B = params.bfield;
|
||
const stepLen = 0.0035;
|
||
let traveled = 0;
|
||
for (let i = 0; i < maxSteps; i++) {
|
||
// β from momentum: β = p / sqrt(p² + m²)
|
||
const beta = p / Math.sqrt(p * p + m * m);
|
||
pts.push({ x, y, beta, theta });
|
||
// step
|
||
x += Math.cos(theta) * stepLen;
|
||
y += Math.sin(theta) * stepLen;
|
||
// curvature: dθ/ds = qB/p (sign of q sets direction)
|
||
const dtheta = (q * B / Math.max(p, 0.05)) * stepLen;
|
||
theta += dtheta;
|
||
// energy loss (Bethe-Bloch-ish): dp/ds ∝ 1/β²
|
||
const loss = params.eloss * stepLen / Math.max(beta * beta, 0.05);
|
||
p -= loss;
|
||
traveled += stepLen;
|
||
// termination
|
||
if (p < 0.04) break;
|
||
if (Math.abs(x) > 1.15 || Math.abs(y) > 1.15) break;
|
||
if (traveled > 8) break; // prevent runaway spirals, allows long high-p tracks
|
||
}
|
||
return pts;
|
||
}
|
||
|
||
/* Log-normal momentum sample: gives the heavy tail real bubble chambers have.
|
||
Most tracks moderate p; a few have very high p (long straight lines);
|
||
a few have very low p (tight terminal spirals). */
|
||
function sampleMomentum(rng, spread) {
|
||
// mean ~ 0.7, sigma controlled by `spread`
|
||
const sigma = 0.4 + spread * 1.2;
|
||
const mu = -0.2;
|
||
return Math.exp(mu + sigma * gauss(rng));
|
||
}
|
||
|
||
/* Spawn δ-ray — tuned for visibility. Higher base momentum so it completes
|
||
at least one full curl, tighter loss so it spirals all the way in. */
|
||
function spawnDeltaRay(parentPt, params, rng) {
|
||
const tightness = params.deltaTight;
|
||
// p0 chosen so radius ~ (0.02 to 0.08) of chamber — small but visible
|
||
const p0 = 0.10 + rng() * 0.20 / tightness;
|
||
const perp = parentPt.theta + (rng() < 0.5 ? Math.PI / 2 : -Math.PI / 2);
|
||
const theta = perp + gauss(rng) * 0.5;
|
||
const q = -1;
|
||
// boosted B and lower loss → tight curls that spiral in cleanly
|
||
return integrateTrack(
|
||
{ x: parentPt.x, y: parentPt.y, theta, p: p0, q },
|
||
{ ...params, bfield: params.bfield * 2.5, eloss: params.eloss * 0.6 },
|
||
rng,
|
||
900
|
||
);
|
||
}
|
||
|
||
/* V-decay: a neutral particle (invisible) travels then decays into
|
||
two oppositely-charged daughters. */
|
||
function spawnVDecay(originPt, params, rng) {
|
||
const dist = 0.15 + rng() * 0.35;
|
||
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.85 || Math.abs(vy) > 0.85) return [];
|
||
const opening = 0.3 + rng() * 0.7;
|
||
const p1 = 0.4 + rng() * 0.8;
|
||
const p2 = 0.4 + rng() * 0.8;
|
||
const t1 = integrateTrack(
|
||
{ x: vx, y: vy, theta: ghostTheta - opening / 2, p: p1, q: +1 }, params, rng
|
||
);
|
||
const t2 = integrateTrack(
|
||
{ x: vx, y: vy, theta: ghostTheta + opening / 2, p: p2, q: -1 }, params, rng
|
||
);
|
||
return [t1, t2];
|
||
}
|
||
|
||
/* ---------- Event generation ---------- */
|
||
/* Generate a single event at a given vertex with a given intensity.
|
||
Intensity = 1.0 for foreground events, lower for background/historical.
|
||
Returns array of tracks (each carries its own intensity in `weight`). */
|
||
function generateOneEvent(params, vertex, intensity, eventSalt) {
|
||
const rng = makeRng(params.seed, 'event:' + eventSalt);
|
||
const tracks = [];
|
||
const N = Math.max(2, Math.round(params.primaries * (0.4 + intensity * 0.6)));
|
||
const burstConcentration = 0.3 + params.burst * 0.7;
|
||
|
||
for (let i = 0; i < N; i++) {
|
||
const baseAngle = (i / N) * Math.PI * 2;
|
||
const angle = baseAngle + gauss(rng) * 0.4 * (1 - burstConcentration);
|
||
const p = sampleMomentum(rng, params.pspread);
|
||
const q = rng() < 0.5 ? +1 : -1;
|
||
const pts = integrateTrack(
|
||
{ x: vertex.x, y: vertex.y, theta: angle, p, q },
|
||
params, makeRng(params.seed, eventSalt + ':primary' + i)
|
||
);
|
||
tracks.push({ pts, kind: 'primary', weight: intensity });
|
||
|
||
// δ-rays — only on foreground/near-foreground events
|
||
if (intensity > 0.6) {
|
||
const deltaRng = makeRng(params.seed, eventSalt + ':delta' + i);
|
||
// sample roughly every other step
|
||
for (let j = 5; j < pts.length; j += 2) {
|
||
if (deltaRng() < params.deltaRate * 0.08) {
|
||
const dpts = spawnDeltaRay(pts[j], params, deltaRng);
|
||
if (dpts.length > 8) tracks.push({ pts: dpts, kind: 'delta', weight: intensity * 0.85 });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// V-decays only for foreground
|
||
if (intensity > 0.8) {
|
||
for (let i = 0; i < params.vdecay; i++) {
|
||
const daughters = spawnVDecay(vertex, params, makeRng(params.seed, eventSalt + ':vdecay' + i));
|
||
daughters.forEach(d => {
|
||
if (d.length > 10) tracks.push({ pts: d, kind: 'vdecay', weight: intensity * 0.95 });
|
||
});
|
||
}
|
||
}
|
||
|
||
return tracks;
|
||
}
|
||
|
||
/* Top-level scene: one foreground event + N background events at lower intensity.
|
||
Backgrounds are scattered through the chamber volume and represent the
|
||
"history" of the chamber — cosmic rays, prior pulses, etc. */
|
||
function generateScene(params) {
|
||
const rng = makeRng(params.seed, 'scene');
|
||
const allTracks = [];
|
||
|
||
// Foreground event near (slightly off) center
|
||
const fgVertex = { x: (rng() - 0.5) * 0.25, y: (rng() - 0.5) * 0.25 };
|
||
allTracks.push(...generateOneEvent(params, fgVertex, 1.0, 'fg'));
|
||
|
||
// Background events — random vertices, varied intensity
|
||
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;
|
||
const vy = (bgRng() - 0.5) * 1.7;
|
||
// vary intensity within the user's bgIntensity ceiling
|
||
const intensity = params.bgIntensity * (0.5 + bgRng() * 0.5);
|
||
allTracks.push(...generateOneEvent(
|
||
{ ...params, primaries: Math.round(params.primaries * 0.6), vdecay: 0 },
|
||
{ x: vx, y: vy }, intensity, 'bg' + i
|
||
));
|
||
}
|
||
|
||
// Emulsion artifacts — long straight scratches, dust specks
|
||
const artifacts = generateArtifacts(params);
|
||
|
||
return { tracks: allTracks, vertex: fgVertex, artifacts };
|
||
}
|
||
|
||
/* Emulsion artifacts: scratches, dust specks, water spots.
|
||
These are not particle tracks — they're imperfections in the photographic
|
||
plate and chamber window that real archival images always have. */
|
||
function generateArtifacts(params) {
|
||
const out = { scratches: [], specks: [], rings: [] };
|
||
if (!params.artifacts) return out;
|
||
const rng = makeRng(params.seed, 'artifacts');
|
||
const A = params.artifacts;
|
||
|
||
// Scratches: long, very thin, straight lines at low angles
|
||
const nScratch = Math.floor(A * 6 + rng() * A * 4);
|
||
for (let i = 0; i < nScratch; i++) {
|
||
const cx = (rng() - 0.5) * 2;
|
||
const cy = (rng() - 0.5) * 2;
|
||
const ang = rng() * Math.PI * 2;
|
||
const len = 0.3 + rng() * 1.5;
|
||
const opacity = 0.15 + rng() * 0.3;
|
||
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: opacity * A,
|
||
width: 0.3 + rng() * 0.4
|
||
});
|
||
}
|
||
|
||
// Dust specks: small irregular dark spots
|
||
const nSpecks = Math.floor(A * 80 + rng() * A * 60);
|
||
for (let i = 0; i < nSpecks; i++) {
|
||
out.specks.push({
|
||
x: (rng() - 0.5) * 2,
|
||
y: (rng() - 0.5) * 2,
|
||
r: 0.0008 + rng() * 0.003,
|
||
opacity: 0.3 + rng() * 0.5 * A
|
||
});
|
||
}
|
||
|
||
// Water/chemical rings: occasional faint circles
|
||
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.15,
|
||
opacity: 0.08 + rng() * 0.12 * A,
|
||
width: 0.5 + rng() * 1.5
|
||
});
|
||
}
|
||
|
||
return out;
|
||
}
|
||
|
||
/* ---------- Bubble sampling ---------- */
|
||
/* Convert a polyline into discrete bubbles along it.
|
||
Density modulated by β (slow particles ionize more → denser bubbles). */
|
||
function sampleBubbles(track, params, rng) {
|
||
const bubbles = [];
|
||
const pts = track.pts;
|
||
if (pts.length < 2) return bubbles;
|
||
// Walk the polyline
|
||
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);
|
||
// bubble density ∝ 1/β² × user density × track weight
|
||
const beta = Math.max(a.beta, 0.15);
|
||
const lambda = params.density * track.weight * (1 / (beta * beta)) * 380;
|
||
const expected = lambda * segLen;
|
||
// Poisson approximation: use uniform sampling for speed
|
||
const nb = Math.floor(expected) + (rng() < (expected - Math.floor(expected)) ? 1 : 0);
|
||
for (let k = 0; k < nb; k++) {
|
||
const t = rng();
|
||
const px = a.x + dx * t;
|
||
const py = a.y + dy * t;
|
||
// tiny perpendicular jitter
|
||
const nx = -dy / segLen, ny = dx / segLen;
|
||
const j = gauss(rng) * 0.0015;
|
||
// bubble radius modulated by β and user size
|
||
const baseR = (0.0010 + (1 - beta) * 0.0014) * params.size;
|
||
const r = baseR * (0.7 + rng() * 0.5);
|
||
bubbles.push({ x: px + nx * j, y: py + ny * j, r });
|
||
}
|
||
}
|
||
return bubbles;
|
||
}
|
||
|
||
/* ---------- Canvas renderer (preview) ---------- */
|
||
function renderCanvas(ctx, w, h, scene, params) {
|
||
// Palette: inverted = light cream background + dark ink (photographic positive)
|
||
// normal = dark + light (the original AI-default look)
|
||
const inv = params.invert;
|
||
const bgInner = inv ? '#d8d2c0' : '#1c1a16';
|
||
const bgOuter = inv ? '#b8b09c' : '#080706';
|
||
const bgFlat = inv ? '#cfc8b4' : '#0e0d0b';
|
||
const ink = inv ? '#1a1815' : '#e8e4d7';
|
||
const inkRGB = inv ? '26,24,21' : '232,228,215';
|
||
const boundaryStroke = inv ? 'rgba(40,36,30,0.5)' : 'rgba(200,195,180,0.4)';
|
||
const fidStroke = inv ? 'rgba(40,36,30,0.6)' : 'rgba(232,228,215,0.55)';
|
||
const spokeStroke = inv ? 'rgba(40,36,30,0.22)' : 'rgba(200,195,180,0.18)';
|
||
|
||
// background
|
||
ctx.fillStyle = bgFlat;
|
||
ctx.fillRect(0, 0, w, h);
|
||
|
||
// Subtle radial gradient — gas glow
|
||
const grad = ctx.createRadialGradient(w/2, h/2, 0, w/2, h/2, w*0.7);
|
||
grad.addColorStop(0, bgInner);
|
||
grad.addColorStop(1, bgOuter);
|
||
ctx.fillStyle = grad;
|
||
ctx.fillRect(0, 0, w, h);
|
||
|
||
const margin = 0.04;
|
||
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;
|
||
|
||
// Chamber boundary
|
||
if (params.showBoundary) {
|
||
ctx.strokeStyle = boundaryStroke;
|
||
ctx.lineWidth = 1.5;
|
||
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();
|
||
ctx.strokeStyle = spokeStroke;
|
||
ctx.lineWidth = 0.5;
|
||
const spokeRng = makeRng(params.seed, 'spokes');
|
||
for (let i = 0; i < 60; i++) {
|
||
const a = Math.PI + Math.PI * (0.15 + 0.7 * i / 59);
|
||
const r1 = w * 0.42;
|
||
const r2 = w * 0.46 + spokeRng() * 6;
|
||
const ox = cx, oy = cy + h * 0.35;
|
||
ctx.beginPath();
|
||
ctx.moveTo(ox + Math.cos(a) * r1, oy + Math.sin(a) * r1);
|
||
ctx.lineTo(ox + Math.cos(a) * r2, oy + Math.sin(a) * r2);
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
|
||
// Artifacts layer FIRST (so tracks draw over them) — water rings + scratches under,
|
||
// dust specks over.
|
||
if (scene.artifacts) {
|
||
// Water rings (very faint, broad)
|
||
for (const r of scene.artifacts.rings) {
|
||
ctx.strokeStyle = `rgba(${inkRGB},${r.opacity})`;
|
||
ctx.lineWidth = r.width;
|
||
ctx.beginPath();
|
||
ctx.arc(tx(r.x), ty(r.y), r.r * scale, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
}
|
||
// Scratches — long thin lines
|
||
for (const s of scene.artifacts.scratches) {
|
||
ctx.strokeStyle = `rgba(${inkRGB},${s.opacity})`;
|
||
ctx.lineWidth = s.width;
|
||
ctx.beginPath();
|
||
ctx.moveTo(tx(s.x1), ty(s.y1));
|
||
ctx.lineTo(tx(s.x2), ty(s.y2));
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
|
||
// Bubbles — opacity & size scale with track weight (background events fainter)
|
||
const bubbleRng = makeRng(params.seed, 'bubbles');
|
||
for (const track of scene.tracks) {
|
||
const bubs = sampleBubbles(track, params, bubbleRng);
|
||
const alpha = 0.55 + track.weight * 0.4; // foreground darker, background lighter
|
||
ctx.fillStyle = `rgba(${inkRGB},${alpha})`;
|
||
for (const b of bubs) {
|
||
const r = b.r * scale;
|
||
ctx.beginPath();
|
||
ctx.arc(tx(b.x), ty(b.y), Math.max(r, 0.35), 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
}
|
||
|
||
// Dust specks — on top of everything
|
||
if (scene.artifacts) {
|
||
for (const sp of scene.artifacts.specks) {
|
||
ctx.fillStyle = `rgba(${inkRGB},${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();
|
||
}
|
||
}
|
||
|
||
// Fiducial marks
|
||
if (params.showFiducials) {
|
||
ctx.strokeStyle = fidStroke;
|
||
ctx.lineWidth = 1;
|
||
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]
|
||
];
|
||
for (const [fx, fy] of fids) {
|
||
const px = tx(fx), py = ty(fy);
|
||
const s = 6;
|
||
ctx.beginPath();
|
||
ctx.moveTo(px - s, py); ctx.lineTo(px + s, py);
|
||
ctx.moveTo(px, py - s); ctx.lineTo(px, py + s);
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
|
||
// Vignette
|
||
if (params.vign > 0) {
|
||
const vignColor = inv ? `rgba(60,50,38,${params.vign * 0.55})` : `rgba(0,0,0,${params.vign * 0.85})`;
|
||
const vg = ctx.createRadialGradient(w/2, h/2, w*0.25, w/2, h/2, w*0.72);
|
||
vg.addColorStop(0, inv ? 'rgba(60,50,38,0)' : 'rgba(0,0,0,0)');
|
||
vg.addColorStop(1, vignColor);
|
||
ctx.fillStyle = vg;
|
||
ctx.fillRect(0, 0, w, h);
|
||
}
|
||
|
||
// Film grain
|
||
if (params.grain > 0) {
|
||
const img = ctx.getImageData(0, 0, w, h);
|
||
const d = img.data;
|
||
const amount = params.grain * 35;
|
||
const grng = makeRng(params.seed, 'grain');
|
||
for (let i = 0; i < d.length; i += 4) {
|
||
const n = (grng() - 0.5) * amount;
|
||
d[i] = Math.max(0, Math.min(255, d[i] + n));
|
||
d[i+1] = Math.max(0, Math.min(255, d[i+1] + n));
|
||
d[i+2] = Math.max(0, Math.min(255, d[i+2] + n));
|
||
}
|
||
ctx.putImageData(img, 0, 0);
|
||
}
|
||
}
|
||
|
||
/* ---------- SVG renderer ---------- */
|
||
function renderSVG(scene, params, sizePx = 2400) {
|
||
const w = sizePx, h = sizePx;
|
||
const margin = 0.04;
|
||
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 inv = params.invert;
|
||
const bgInner = inv ? '#d8d2c0' : '#1c1a16';
|
||
const bgOuter = inv ? '#b8b09c' : '#080706';
|
||
const bgFlat = inv ? '#cfc8b4' : '#0e0d0b';
|
||
const ink = inv ? '#1a1815' : '#e8e4d7';
|
||
const boundaryStroke = inv ? '#28241e' : '#c8c3b4';
|
||
const boundaryOpacity = inv ? 0.5 : 0.4;
|
||
const spokeOpacity = inv ? 0.22 : 0.18;
|
||
const fidOpacity = inv ? 0.6 : 0.55;
|
||
const vignColor = inv ? '#3c3226' : '#000000';
|
||
const vignOpacity = inv ? params.vign * 0.55 : params.vign * 0.85;
|
||
|
||
let svg = `<?xml version="1.0" encoding="UTF-8"?>\n`;
|
||
svg += `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">\n`;
|
||
svg += `<metadata>Bubble Chamber Generator · seed=${params.seed} · hash=${cyrb53(params.seed)}</metadata>\n`;
|
||
svg += `<defs>
|
||
<radialGradient id="bg" cx="50%" cy="50%" r="70%">
|
||
<stop offset="0%" stop-color="${bgInner}"/>
|
||
<stop offset="100%" stop-color="${bgOuter}"/>
|
||
</radialGradient>
|
||
<radialGradient id="vign" cx="50%" cy="50%" r="72%">
|
||
<stop offset="35%" stop-color="${vignColor}" stop-opacity="0"/>
|
||
<stop offset="100%" stop-color="${vignColor}" stop-opacity="${vignOpacity}"/>
|
||
</radialGradient>
|
||
</defs>\n`;
|
||
svg += `<rect width="${w}" height="${h}" fill="${bgFlat}"/>\n`;
|
||
svg += `<rect width="${w}" height="${h}" fill="url(#bg)"/>\n`;
|
||
|
||
// Boundary
|
||
if (params.showBoundary) {
|
||
const bcx = cx, bcy = cy + h * 0.35, br = w * 0.45;
|
||
const a1 = Math.PI * 0.15, a2 = Math.PI - Math.PI * 0.15;
|
||
const x1 = bcx + Math.cos(Math.PI + a1) * br;
|
||
const y1 = bcy + Math.sin(Math.PI + a1) * br;
|
||
const x2 = bcx + Math.cos(Math.PI + a2) * br;
|
||
const y2 = bcy + Math.sin(Math.PI + a2) * br;
|
||
svg += `<path d="M ${x1.toFixed(1)} ${y1.toFixed(1)} A ${br} ${br} 0 0 1 ${x2.toFixed(1)} ${y2.toFixed(1)}" `;
|
||
svg += `fill="none" stroke="${boundaryStroke}" stroke-opacity="${boundaryOpacity}" stroke-width="2"/>\n`;
|
||
const spokeRng = makeRng(params.seed, 'spokes');
|
||
for (let i = 0; i < 60; i++) {
|
||
const a = Math.PI + Math.PI * (0.15 + 0.7 * i / 59);
|
||
const r1 = w * 0.42;
|
||
const r2 = w * 0.46 + spokeRng() * 8;
|
||
const sx1 = bcx + Math.cos(a) * r1, sy1 = bcy + Math.sin(a) * r1;
|
||
const sx2 = bcx + Math.cos(a) * r2, sy2 = bcy + Math.sin(a) * r2;
|
||
svg += `<line x1="${sx1.toFixed(1)}" y1="${sy1.toFixed(1)}" x2="${sx2.toFixed(1)}" y2="${sy2.toFixed(1)}" stroke="${boundaryStroke}" stroke-opacity="${spokeOpacity}" stroke-width="0.7"/>\n`;
|
||
}
|
||
}
|
||
|
||
// Artifacts: rings + scratches under, dust over
|
||
if (scene.artifacts) {
|
||
svg += `<g fill="none" stroke="${ink}">\n`;
|
||
for (const r of scene.artifacts.rings) {
|
||
svg += `<circle cx="${tx(r.x)}" cy="${ty(r.y)}" r="${(r.r * scale).toFixed(1)}" stroke-opacity="${r.opacity.toFixed(3)}" stroke-width="${r.width}"/>\n`;
|
||
}
|
||
for (const s of scene.artifacts.scratches) {
|
||
svg += `<line x1="${tx(s.x1)}" y1="${ty(s.y1)}" x2="${tx(s.x2)}" y2="${ty(s.y2)}" stroke-opacity="${s.opacity.toFixed(3)}" stroke-width="${s.width}"/>\n`;
|
||
}
|
||
svg += `</g>\n`;
|
||
}
|
||
|
||
// Bubbles — group by weight bucket for varying opacity
|
||
const buckets = new Map();
|
||
const bubbleRng = makeRng(params.seed, 'bubbles');
|
||
for (const track of scene.tracks) {
|
||
const bucketKey = Math.round((0.55 + track.weight * 0.4) * 20) / 20;
|
||
if (!buckets.has(bucketKey)) buckets.set(bucketKey, []);
|
||
const bubs = sampleBubbles(track, params, bubbleRng);
|
||
buckets.get(bucketKey).push(...bubs);
|
||
}
|
||
for (const [alpha, bubs] of buckets) {
|
||
svg += `<g fill="${ink}" fill-opacity="${alpha}">`;
|
||
for (const b of bubs) {
|
||
const r = Math.max(b.r * scale, 0.4);
|
||
svg += `<circle cx="${tx(b.x)}" cy="${ty(b.y)}" r="${r.toFixed(2)}"/>`;
|
||
}
|
||
svg += `</g>\n`;
|
||
}
|
||
|
||
// Dust specks
|
||
if (scene.artifacts) {
|
||
svg += `<g fill="${ink}">\n`;
|
||
for (const sp of scene.artifacts.specks) {
|
||
svg += `<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)}"/>`;
|
||
}
|
||
svg += `</g>\n`;
|
||
}
|
||
|
||
// Fiducials
|
||
if (params.showFiducials) {
|
||
svg += `<g stroke="${ink}" stroke-opacity="${fidOpacity}" stroke-width="1.2">\n`;
|
||
const fids = [[-0.85,-0.85],[0.85,-0.85],[-0.85,0.85],[0.85,0.85],[0,-0.85],[-0.85,0],[0.85,0]];
|
||
for (const [fx, fy] of fids) {
|
||
const px = +tx(fx), py = +ty(fy), s = 9;
|
||
svg += `<line x1="${px-s}" y1="${py}" x2="${px+s}" y2="${py}"/>`;
|
||
svg += `<line x1="${px}" y1="${py-s}" x2="${px}" y2="${py+s}"/>`;
|
||
}
|
||
svg += `</g>\n`;
|
||
}
|
||
|
||
if (params.vign > 0) {
|
||
svg += `<rect width="${w}" height="${h}" fill="url(#vign)"/>\n`;
|
||
}
|
||
svg += `</svg>\n`;
|
||
return svg;
|
||
}
|
||
|
||
/* ---------- App state & wiring ---------- */
|
||
const canvas = document.getElementById('preview');
|
||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||
|
||
const sliders = {
|
||
primaries: { el: 'primaries', valEl: 'primariesVal', fmt: v => v },
|
||
burst: { el: 'burst', valEl: 'burstVal', fmt: v => v.toFixed(2) },
|
||
vdecay: { el: 'vdecay', valEl: 'vdecayVal', fmt: v => v },
|
||
bfield: { el: 'bfield', valEl: 'bfieldVal', fmt: v => v.toFixed(2) },
|
||
eloss: { el: 'eloss', valEl: 'elossVal', fmt: v => v.toFixed(2) },
|
||
pspread: { el: 'pspread', valEl: 'pspreadVal', fmt: v => v.toFixed(2) },
|
||
deltaRate: { el: 'deltaRate', valEl: 'deltaRateVal', fmt: v => v.toFixed(2) },
|
||
deltaTight: { el: 'deltaTight', valEl: 'deltaTightVal', fmt: v => v.toFixed(2) },
|
||
density: { el: 'density', valEl: 'densityVal', fmt: v => v.toFixed(2) },
|
||
size: { el: 'size', valEl: 'sizeVal', fmt: v => v.toFixed(2) },
|
||
grain: { el: 'grain', valEl: 'grainVal', fmt: v => v.toFixed(2) },
|
||
vign: { el: 'vign', valEl: 'vignVal', fmt: v => v.toFixed(2) },
|
||
bgEvents: { el: 'bgEvents', valEl: 'bgEventsVal', fmt: v => v },
|
||
bgIntensity: { el: 'bgIntensity', valEl: 'bgIntensityVal', fmt: v => v.toFixed(2) },
|
||
artifacts: { el: 'artifacts', valEl: 'artifactsVal', fmt: v => v.toFixed(2) },
|
||
};
|
||
|
||
function readParams() {
|
||
const p = { seed: document.getElementById('seedInput').value || 'DEFAULT' };
|
||
for (const [key, cfg] of Object.entries(sliders)) {
|
||
p[key] = parseFloat(document.getElementById(cfg.el).value);
|
||
}
|
||
p.showFiducials = document.getElementById('showFiducials').checked;
|
||
p.showBoundary = document.getElementById('showBoundary').checked;
|
||
p.invert = document.getElementById('invert').checked;
|
||
return p;
|
||
}
|
||
|
||
function updateValueLabels() {
|
||
for (const [key, cfg] of Object.entries(sliders)) {
|
||
const v = parseFloat(document.getElementById(cfg.el).value);
|
||
document.getElementById(cfg.valEl).textContent = cfg.fmt(v);
|
||
}
|
||
}
|
||
|
||
let currentScene = null;
|
||
let currentParams = null;
|
||
let renderTimer = null;
|
||
|
||
function regenerateAndRender() {
|
||
currentParams = readParams();
|
||
currentScene = generateScene(currentParams);
|
||
renderCanvas(ctx, canvas.width, canvas.height, currentScene, currentParams);
|
||
document.getElementById('hashDisplay').textContent = cyrb53(currentParams.seed).toUpperCase();
|
||
const plate = (parseInt(cyrb53(currentParams.seed).slice(-3), 16) % 999).toString().padStart(3, '0');
|
||
document.getElementById('plateNum').textContent = plate;
|
||
}
|
||
|
||
function rerenderOnly() {
|
||
// for slider changes that affect rendering but not the scene (grain, vignette, etc.)
|
||
if (!currentScene) return;
|
||
currentParams = readParams();
|
||
renderCanvas(ctx, canvas.width, canvas.height, currentScene, currentParams);
|
||
}
|
||
|
||
function scheduleRerender(needsRegen) {
|
||
clearTimeout(renderTimer);
|
||
renderTimer = setTimeout(() => {
|
||
if (needsRegen) regenerateAndRender();
|
||
else rerenderOnly();
|
||
}, 30);
|
||
}
|
||
|
||
// Slider wiring
|
||
const sceneAffecting = new Set(['primaries','burst','vdecay','bfield','eloss','pspread','deltaRate','deltaTight','bgEvents','bgIntensity']);
|
||
for (const [key, cfg] of Object.entries(sliders)) {
|
||
const el = document.getElementById(cfg.el);
|
||
el.addEventListener('input', () => {
|
||
updateValueLabels();
|
||
scheduleRerender(sceneAffecting.has(key));
|
||
});
|
||
}
|
||
document.getElementById('seedInput').addEventListener('change', regenerateAndRender);
|
||
document.getElementById('showFiducials').addEventListener('change', rerenderOnly);
|
||
document.getElementById('showBoundary').addEventListener('change', rerenderOnly);
|
||
document.getElementById('invert').addEventListener('change', rerenderOnly);
|
||
document.getElementById('regen').addEventListener('click', regenerateAndRender);
|
||
document.getElementById('randomSeed').addEventListener('click', () => {
|
||
const words = ['MUON','KAON','PION','LAMBDA','SIGMA','XI','OMEGA','TAU','GLUON','QUARK','HADRON','BARYON','LEPTON','NEUTRINO','BOSON'];
|
||
const w = words[Math.floor(Math.random() * words.length)];
|
||
const n = Math.floor(Math.random() * 9000 + 1000);
|
||
document.getElementById('seedInput').value = `${w}-${n}`;
|
||
regenerateAndRender();
|
||
});
|
||
|
||
/* ---------- Export handlers ---------- */
|
||
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);
|
||
}
|
||
document.getElementById('exportSVG').addEventListener('click', () => {
|
||
if (!currentScene) regenerateAndRender();
|
||
const svg = renderSVG(currentScene, currentParams, 4800);
|
||
download(new Blob([svg], { type: 'image/svg+xml' }),
|
||
`bubble-chamber-${currentParams.seed}.svg`);
|
||
showToast('SVG exported');
|
||
});
|
||
document.getElementById('exportPDF').addEventListener('click', () => {
|
||
// PDF: emit a single-page PDF that embeds the SVG geometry as native PDF ops.
|
||
// Simpler & smaller than raster. We just translate circles + paths.
|
||
if (!currentScene) regenerateAndRender();
|
||
const pdf = buildPDF(currentScene, currentParams);
|
||
download(new Blob([pdf], { type: 'application/pdf' }),
|
||
`bubble-chamber-${currentParams.seed}.pdf`);
|
||
showToast('PDF exported');
|
||
});
|
||
document.getElementById('exportPNG').addEventListener('click', () => {
|
||
if (!currentScene) regenerateAndRender();
|
||
// Render at 4× resolution off-screen for sharper PNG
|
||
const off = document.createElement('canvas');
|
||
off.width = 3600; off.height = 3600;
|
||
const octx = off.getContext('2d');
|
||
renderCanvas(octx, off.width, off.height, currentScene, currentParams);
|
||
off.toBlob(b => {
|
||
download(b, `bubble-chamber-${currentParams.seed}.png`);
|
||
showToast('PNG exported');
|
||
}, 'image/png');
|
||
});
|
||
|
||
/* ---------- PDF writer (minimal, single-page vector) ---------- */
|
||
function buildPDF(scene, params) {
|
||
const pageSize = 1728;
|
||
const margin = 0.04;
|
||
const scale = (pageSize / 2) * (1 - margin);
|
||
const cx = pageSize / 2, cy = pageSize / 2;
|
||
const tx = (x) => (cx + x * scale).toFixed(2);
|
||
const ty = (y) => (cy - y * scale).toFixed(2); // flipped
|
||
|
||
const inv = params.invert;
|
||
// PDF colors (0..1 RGB)
|
||
const bgColor = inv ? '0.812 0.784 0.706' : '0.055 0.051 0.043'; // #cfc8b4 / #0e0d0b
|
||
const inkColor = inv ? '0.102 0.094 0.082' : '0.910 0.894 0.843'; // #1a1815 / #e8e4d7
|
||
const inkStroke = inv ? '0.102 0.094 0.082' : '0.910 0.894 0.843';
|
||
const boundaryStroke = inv ? '0.157 0.141 0.118' : '0.784 0.764 0.706';
|
||
|
||
let content = '';
|
||
content += `${bgColor} rg\n`;
|
||
content += `0 0 ${pageSize} ${pageSize} re f\n`;
|
||
|
||
// boundary
|
||
if (params.showBoundary) {
|
||
content += `q\n${boundaryStroke} RG\n0.4 w\n`;
|
||
const bcx = cx, bcy = cy - pageSize * 0.35;
|
||
const br = pageSize * 0.45;
|
||
const a1 = Math.PI * 0.15, a2 = Math.PI - Math.PI * 0.15;
|
||
const steps = 80;
|
||
for (let i = 0; i <= steps; i++) {
|
||
const t = i / steps;
|
||
const a = Math.PI + a1 + (a2 - a1) * t;
|
||
const px = bcx + Math.cos(a) * br;
|
||
const py = bcy - Math.sin(a) * br;
|
||
content += `${px.toFixed(1)} ${py.toFixed(1)} ${i === 0 ? 'm' : 'l'}\n`;
|
||
}
|
||
content += `S\nQ\n`;
|
||
}
|
||
|
||
// artifacts: rings + scratches
|
||
if (scene.artifacts) {
|
||
content += `q\n${inkStroke} RG\n`;
|
||
for (const r of scene.artifacts.rings) {
|
||
content += `${r.width} w\n`;
|
||
const px = parseFloat(tx(r.x)), py = parseFloat(ty(r.y));
|
||
const rr = r.r * scale;
|
||
// approximate circle stroke with bezier
|
||
const k = 0.5522847498 * rr;
|
||
content += `${(px - rr).toFixed(2)} ${py.toFixed(2)} m\n`;
|
||
content += `${(px - rr).toFixed(2)} ${(py + k).toFixed(2)} ${(px - k).toFixed(2)} ${(py + rr).toFixed(2)} ${px.toFixed(2)} ${(py + rr).toFixed(2)} c\n`;
|
||
content += `${(px + k).toFixed(2)} ${(py + rr).toFixed(2)} ${(px + rr).toFixed(2)} ${(py + k).toFixed(2)} ${(px + rr).toFixed(2)} ${py.toFixed(2)} c\n`;
|
||
content += `${(px + rr).toFixed(2)} ${(py - k).toFixed(2)} ${(px + k).toFixed(2)} ${(py - rr).toFixed(2)} ${px.toFixed(2)} ${(py - rr).toFixed(2)} c\n`;
|
||
content += `${(px - k).toFixed(2)} ${(py - rr).toFixed(2)} ${(px - rr).toFixed(2)} ${(py - k).toFixed(2)} ${(px - rr).toFixed(2)} ${py.toFixed(2)} c\n`;
|
||
content += `S\n`;
|
||
}
|
||
for (const s of scene.artifacts.scratches) {
|
||
content += `${s.width} w\n`;
|
||
content += `${tx(s.x1)} ${ty(s.y1)} m ${tx(s.x2)} ${ty(s.y2)} l S\n`;
|
||
}
|
||
content += `Q\n`;
|
||
}
|
||
|
||
// bubbles
|
||
content += `q\n${inkColor} rg\n`;
|
||
const k = 0.5522847498;
|
||
const bubbleRng = makeRng(params.seed, 'bubbles');
|
||
for (const track of scene.tracks) {
|
||
const bubs = sampleBubbles(track, params, bubbleRng);
|
||
for (const b of bubs) {
|
||
const r = Math.max(b.r * scale, 0.5);
|
||
const px = parseFloat(tx(b.x)), py = parseFloat(ty(b.y));
|
||
const rk = r * k;
|
||
content += `${(px - r).toFixed(2)} ${py.toFixed(2)} m\n`;
|
||
content += `${(px - r).toFixed(2)} ${(py + rk).toFixed(2)} ${(px - rk).toFixed(2)} ${(py + r).toFixed(2)} ${px.toFixed(2)} ${(py + r).toFixed(2)} c\n`;
|
||
content += `${(px + rk).toFixed(2)} ${(py + r).toFixed(2)} ${(px + r).toFixed(2)} ${(py + rk).toFixed(2)} ${(px + r).toFixed(2)} ${py.toFixed(2)} c\n`;
|
||
content += `${(px + r).toFixed(2)} ${(py - rk).toFixed(2)} ${(px + rk).toFixed(2)} ${(py - r).toFixed(2)} ${px.toFixed(2)} ${(py - r).toFixed(2)} c\n`;
|
||
content += `${(px - rk).toFixed(2)} ${(py - r).toFixed(2)} ${(px - r).toFixed(2)} ${(py - rk).toFixed(2)} ${(px - r).toFixed(2)} ${py.toFixed(2)} c\n`;
|
||
content += `f\n`;
|
||
}
|
||
}
|
||
content += `Q\n`;
|
||
|
||
// dust specks
|
||
if (scene.artifacts) {
|
||
content += `q\n${inkColor} rg\n`;
|
||
for (const sp of scene.artifacts.specks) {
|
||
const r = Math.max(sp.r * scale, 0.5);
|
||
const px = parseFloat(tx(sp.x)), py = parseFloat(ty(sp.y));
|
||
const rk = r * k;
|
||
content += `${(px - r).toFixed(2)} ${py.toFixed(2)} m\n`;
|
||
content += `${(px - r).toFixed(2)} ${(py + rk).toFixed(2)} ${(px - rk).toFixed(2)} ${(py + r).toFixed(2)} ${px.toFixed(2)} ${(py + r).toFixed(2)} c\n`;
|
||
content += `${(px + rk).toFixed(2)} ${(py + r).toFixed(2)} ${(px + r).toFixed(2)} ${(py + rk).toFixed(2)} ${(px + r).toFixed(2)} ${py.toFixed(2)} c\n`;
|
||
content += `${(px + r).toFixed(2)} ${(py - rk).toFixed(2)} ${(px + rk).toFixed(2)} ${(py - r).toFixed(2)} ${px.toFixed(2)} ${(py - r).toFixed(2)} c\n`;
|
||
content += `${(px - rk).toFixed(2)} ${(py - r).toFixed(2)} ${(px - r).toFixed(2)} ${(py - rk).toFixed(2)} ${(px - r).toFixed(2)} ${py.toFixed(2)} c\n`;
|
||
content += `f\n`;
|
||
}
|
||
content += `Q\n`;
|
||
}
|
||
|
||
if (params.showFiducials) {
|
||
content += `q\n${inkStroke} RG\n1.2 w\n`;
|
||
const fids = [[-0.85,-0.85],[0.85,-0.85],[-0.85,0.85],[0.85,0.85],[0,-0.85],[-0.85,0],[0.85,0]];
|
||
for (const [fx, fy] of fids) {
|
||
const px = parseFloat(tx(fx)), py = parseFloat(ty(fy)), s = 8;
|
||
content += `${px - s} ${py} m ${px + s} ${py} l S\n`;
|
||
content += `${px} ${py - s} m ${px} ${py + s} l S\n`;
|
||
}
|
||
content += `Q\n`;
|
||
}
|
||
|
||
// Assemble PDF
|
||
const enc = new TextEncoder();
|
||
const objects = [];
|
||
const add = (s) => { objects.push(enc.encode(s)); };
|
||
|
||
const contentBytes = enc.encode(content);
|
||
|
||
const header = `%PDF-1.4\n%\xC3\xA0\xC3\xA1\xC3\xA2\xC3\xA3\n`;
|
||
let body = header;
|
||
const offsets = [];
|
||
|
||
const addObj = (s) => {
|
||
offsets.push(body.length);
|
||
body += s;
|
||
};
|
||
|
||
addObj(`1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n`);
|
||
addObj(`2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n`);
|
||
addObj(`3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageSize} ${pageSize}] /Contents 4 0 R /Resources << >> >>\nendobj\n`);
|
||
addObj(`4 0 obj\n<< /Length ${contentBytes.length} >>\nstream\n${content}\nendstream\nendobj\n`);
|
||
|
||
const xrefOffset = body.length;
|
||
body += `xref\n0 5\n0000000000 65535 f \n`;
|
||
for (const o of offsets) {
|
||
body += String(o).padStart(10, '0') + ' 00000 n \n';
|
||
}
|
||
body += `trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
|
||
|
||
return new Uint8Array(enc.encode(body));
|
||
}
|
||
|
||
/* ---------- Init ---------- */
|
||
function init() {
|
||
// exposure date — looks like a real archival plate
|
||
const d = new Date();
|
||
d.setFullYear(1973 + Math.floor(Math.random() * 8));
|
||
d.setMonth(Math.floor(Math.random() * 12));
|
||
d.setDate(1 + Math.floor(Math.random() * 28));
|
||
document.getElementById('exposureDate').textContent =
|
||
d.toISOString().slice(0, 10).replace(/-/g, '.');
|
||
updateValueLabels();
|
||
regenerateAndRender();
|
||
}
|
||
init();
|
||
</script>
|
||
</body>
|
||
</html>
|