This commit is contained in:
2026-05-20 16:53:23 -04:00
commit dd138d5c4f
77 changed files with 973678 additions and 0 deletions

89
src/scene/artifacts.js Normal file
View File

@@ -0,0 +1,89 @@
/* ============================================================
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.)
============================================================ */
import { gauss } from '../rng.js';
export 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;
}

51
src/scene/bubbles.js Normal file
View File

@@ -0,0 +1,51 @@
/* ============================================================
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).
============================================================ */
import { gauss } from '../rng.js';
export function sampleBubbles(track, params, rng) {
const bubbles = [];
const pts = track.pts;
if (pts.length < 2) return bubbles;
for (let i = 1; i < pts.length; i++) {
const a = pts[i - 1], b = pts[i];
const dx = b.x - a.x, dy = b.y - a.y;
const segLen = Math.hypot(dx, dy);
if (segLen === 0) continue;
const beta = Math.max(a.beta, 0.13);
const lambda = params.density * track.weight * (1 / (beta * beta)) * 420;
const expected = lambda * segLen;
const nb = Math.floor(expected) + (rng() < (expected - Math.floor(expected)) ? 1 : 0);
const nx = -dy / segLen, ny = dx / segLen;
for (let k = 0; k < nb; k++) {
const t = rng();
const px = a.x + dx * t;
const py = a.y + dy * t;
const j = gauss(rng) * 0.0016;
const baseR = (0.0011 + (1 - beta) * 0.0017) * params.size;
// occasional fat bubble (clumped nucleation)
const fat = rng() < 0.05 ? 1.8 + rng() : 1;
const r = baseR * (0.65 + rng() * 0.55) * fat;
bubbles.push({ x: px + nx * j, y: py + ny * j, r });
}
}
return bubbles;
}
/* Mean inverse-β proxy used to scale the faint continuity under-stroke
that renderers draw beneath the bubbles (gives tracks line-like cohesion
without losing the dotted texture). */
export 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;
}

57
src/scene/delta.js Normal file
View File

@@ -0,0 +1,57 @@
/* ============================================================
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.
============================================================ */
import { gauss } from '../rng.js';
/* Build one δ-ray spiral starting at (and tangent to) a parent point. */
export 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;
}

72
src/scene/instrument.js Normal file
View File

@@ -0,0 +1,72 @@
/* ============================================================
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.
============================================================ */
import { gauss } from '../rng.js';
export 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;
}

90
src/scene/params.js Normal file
View File

@@ -0,0 +1,90 @@
/* ============================================================
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.
============================================================ */
import { makeRng } from '../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';
}
export function archetypeOf(seed) {
return pickArchetype(makeRng(seed, 'params'));
}
export function paramsFromSeed(seed) {
const rng = makeRng(seed, 'params');
const arch = pickArchetype(rng);
const r = (lo, hi) => lo + (hi - lo) * rng();
const ri = (lo, hi) => Math.round(r(lo, hi));
const chance = (p) => rng() < p;
// base (archival) defaults
const p = {
seed,
primaries: ri(12, 24), burst: r(0.55, 0.92), vdecay: ri(2, 6),
cosmics: ri(4, 10), sweepers: ri(2, 7),
bfield: r(0.8, 1.6), eloss: r(0.45, 0.75), pspread: r(0.6, 0.85),
deltaRate: r(0.6, 0.95), deltaTight: r(0.6, 1.0),
shock: true, shockIntensity: r(0.6, 0.9), shockSize: r(0.26, 0.4),
shockStriations: r(0.45, 0.85), shockY: r(0.4, 0.6), shockX: r(-0.12, 0.12),
shockStain: Math.pow(rng(), 1.5), // skew cleaner, allow grime
instrument: r(0.25, 0.6),
bgEvents: ri(3, 8), bgIntensity: r(0.35, 0.5),
density: r(1.0, 1.35), size: r(0.9, 1.15),
bloom: r(0.4, 0.65), mottle: r(0.4, 0.7), grain: r(0.4, 0.6),
vign: r(0.35, 0.6), artifacts: r(0.4, 0.8),
showFiducials: chance(0.85), showBoundary: chance(0.7), showHeader: chance(0.9),
invert: true,
};
if (arch === 'dense') {
Object.assign(p, {
primaries: ri(26, 40), burst: r(0.85, 1.0), vdecay: ri(4, 8),
cosmics: ri(8, 14), sweepers: ri(5, 9), bfield: r(1.4, 2.4),
deltaRate: r(0.85, 1.0), deltaTight: r(0.8, 1.2),
bgEvents: ri(7, 11), density: r(1.25, 1.5), shockIntensity: r(0.6, 0.85),
});
} else if (arch === 'cosmic') {
Object.assign(p, {
primaries: ri(4, 10), burst: r(0.25, 0.55), vdecay: ri(0, 2),
cosmics: ri(10, 16), sweepers: ri(7, 11), bfield: r(0.6, 1.1),
pspread: r(0.8, 0.95), deltaRate: r(0.35, 0.6), instrument: r(0.45, 0.7),
shock: chance(0.4), shockIntensity: r(0.3, 0.7), bgEvents: ri(2, 5),
density: r(0.85, 1.1),
});
} else if (arch === 'clean') {
Object.assign(p, {
primaries: ri(8, 14), burst: r(0.4, 0.65), vdecay: ri(1, 3),
cosmics: ri(1, 4), sweepers: ri(1, 4), deltaRate: r(0.35, 0.6),
deltaTight: r(0.55, 0.8), shock: chance(0.6), shockIntensity: r(0.3, 0.7),
shockStain: Math.pow(rng(), 3), // mostly clean disk
instrument: r(0.1, 0.3), bgEvents: ri(0, 3), bgIntensity: r(0.2, 0.35),
bloom: r(0.25, 0.45), mottle: r(0.15, 0.4), grain: r(0.12, 0.35),
vign: r(0.2, 0.4), artifacts: r(0.1, 0.4),
});
} else if (arch === 'negative') {
Object.assign(p, { invert: false, vign: r(0.4, 0.6), shockStain: Math.pow(rng(), 1.3) });
}
p.archetype = arch;
return p;
}

132
src/scene/scene.js Normal file
View File

@@ -0,0 +1,132 @@
/* ============================================================
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.
============================================================ */
import { makeRng, gauss, chance, pick } from '../rng.js';
import { integrateTrack, sampleMomentum, cosmicTrack, sweeperTrack } from './track.js';
import { spawnDeltaSpiral } from './delta.js';
import { spawnVDecay } from './vdecay.js';
import { generateShock } from './shock.js';
import { generateArtifacts } from './artifacts.js';
import { generateInstrument } from './instrument.js';
import { cyrb53 } from '../rng.js';
const LABS = ['BEBC · CERN', 'GARGAMELLE · CERN', '2m HBC · CERN', '82" HBC · SLAC', 'MIRABELLE · IHEP'];
/* One event: a vertex with primaries (+ δ-rays, V-decays for bright events). */
function generateOneEvent(params, vertex, intensity, salt) {
const rng = makeRng(params.seed, 'event:' + salt);
const tracks = [];
const N = Math.max(2, Math.round(params.primaries * (0.45 + intensity * 0.55)));
const burstConc = 0.3 + params.burst * 0.7;
const bright = intensity > 0.6;
for (let i = 0; i < N; i++) {
const baseAngle = (i / N) * Math.PI * 2;
const angle = baseAngle + gauss(rng) * 0.4 * (1 - burstConc);
const p = sampleMomentum(rng, params.pspread);
const q = chance(rng, 0.5) ? +1 : -1;
const pts = integrateTrack(
{ x: vertex.x, y: vertex.y, theta: angle, p, q }, params
);
tracks.push({ pts, kind: 'primary', weight: intensity });
// δ-rays along bright primaries — abundant, true spirals
if (bright) {
const dRng = makeRng(params.seed, salt + ':delta' + i);
for (let j = 6; j < pts.length; j += 2) {
if (dRng() < params.deltaRate * 0.12) {
const dpts = spawnDeltaSpiral(pts[j], params, dRng);
if (dpts.length > 6) {
tracks.push({ pts: dpts, kind: 'delta', weight: intensity * 0.9 });
}
}
}
}
}
// dense interaction "star": stubby tracks bursting from the vertex, with a
// few medium prongs reaching further out for a richer burst.
if (bright && params.burst > 0.15) {
const sRng = makeRng(params.seed, salt + ':star');
const nStar = Math.floor(params.burst * 26);
for (let i = 0; i < nStar; i++) {
const a = sRng() * Math.PI * 2;
const medium = sRng() < 0.25;
const p = medium ? (0.5 + sRng() * 0.7) : (0.13 + sRng() * 0.4);
const q = chance(sRng, 0.5) ? 1 : -1;
const pts = integrateTrack(
{ x: vertex.x + gauss(sRng) * 0.012, y: vertex.y + gauss(sRng) * 0.012, theta: a, p, q },
params, { maxTravel: medium ? 2.6 : 1.1 }
);
tracks.push({ pts, kind: 'primary', weight: intensity });
}
}
// V-decays only for the brightest (foreground) events
if (intensity > 0.8) {
for (let i = 0; i < params.vdecay; i++) {
const daughters = spawnVDecay(vertex, params, makeRng(params.seed, salt + ':vdecay' + i));
daughters.forEach(d => {
if (d.length > 10) tracks.push({ pts: d, kind: 'vdecay', weight: intensity * 0.95 });
});
}
}
return tracks;
}
export function generateScene(params) {
const rng = makeRng(params.seed, 'scene');
const tracks = [];
// Foreground event, slightly off-centre
const fgVertex = { x: (rng() - 0.5) * 0.3, y: (rng() - 0.5) * 0.3 };
tracks.push(...generateOneEvent(params, fgVertex, 1.0, 'fg'));
// Background "history" events
const nBg = params.bgEvents || 0;
for (let i = 0; i < nBg; i++) {
const bgRng = makeRng(params.seed, 'bg' + i);
const vx = (bgRng() - 0.5) * 1.7, vy = (bgRng() - 0.5) * 1.7;
const intensity = params.bgIntensity * (0.5 + bgRng() * 0.5);
tracks.push(...generateOneEvent(
{ ...params, primaries: Math.round(params.primaries * 0.6), vdecay: 0 },
{ x: vx, y: vy }, intensity, 'bg' + i
));
}
// Cosmic/transient straight tracks crossing the frame
const nCosmic = Math.round(params.cosmics || 0);
for (let i = 0; i < nCosmic; i++) {
const cRng = makeRng(params.seed, 'cosmic' + i);
const pts = cosmicTrack(params, cRng);
if (pts.length > 8) tracks.push({ pts, kind: 'cosmic', weight: 0.7 + cRng() * 0.3 });
}
// Sweepers — big gentle arcs across the frame
const nSweep = Math.round(params.sweepers || 0);
for (let i = 0; i < nSweep; i++) {
const sRng = makeRng(params.seed, 'sweep' + i);
const pts = sweeperTrack(params, sRng);
if (pts.length > 8) tracks.push({ pts, kind: 'sweep', weight: 0.75 + sRng() * 0.25 });
}
const shock = generateShock(params, makeRng(params.seed, 'shock'));
const artifacts = generateArtifacts(params, makeRng(params.seed, 'artifacts'));
const instrument = generateInstrument(params, makeRng(params.seed, 'instrument'));
// deterministic archival metadata (so exports are reproducible from the seed)
const hash = cyrb53(params.seed);
const ds = parseInt(hash.slice(0, 8), 16);
const plate = (parseInt(hash.slice(-3), 16) % 999).toString().padStart(3, '0');
const year = 1971 + (ds % 11);
const month = 1 + ((ds >> 4) % 12);
const day = 1 + ((ds >> 8) % 28);
const exposure = `${year}.${String(month).padStart(2, '0')}.${String(day).padStart(2, '0')}`;
const lab = pick(makeRng(params.seed, 'lab'), LABS);
return { tracks, vertex: fgVertex, shock, artifacts, instrument, hash, plate, exposure, lab };
}

109
src/scene/shock.js Normal file
View File

@@ -0,0 +1,109 @@
/* ============================================================
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.
============================================================ */
import { gauss } from '../rng.js';
export function generateShock(params, rng) {
if (!params.shock || params.shockIntensity <= 0) return null;
// default placement: bottom-centre, jittered by seed
const x = (params.shockX ?? 0) + gauss(rng) * 0.05;
const y = (params.shockY ?? 0.52) + gauss(rng) * 0.04;
const r = params.shockSize * (0.9 + rng() * 0.2);
const I = params.shockIntensity;
// 0 = pristine clean rim, 1 = heavily stained / eroded / degraded disk
const stain = Math.max(0, Math.min(1, params.shockStain ?? 0.35));
// radial striations — fine lines that burst OUTWARD from a ring, leaving
// the centre as a textured (not solid) core. Lengths vary a lot: a few
// long rays shoot well past the rim, most are short tufts near it.
const striations = [];
const n = Math.floor(140 + params.shockStriations * 560);
for (let i = 0; i < n; i++) {
const a = rng() * Math.PI * 2;
// start in the outer half of the disk so rays don't pile into the centre
const inner = r * (0.45 + rng() * 0.5);
const long = rng() < 0.16;
const reach = long ? (1.15 + rng() * 1.6) : (0.92 + rng() * 0.4);
const outer = r * reach;
striations.push({
a,
inner,
outer,
width: (long ? 0.5 : 0.3) + rng() * (long ? 1.1 : 0.7),
opacity: (0.1 + rng() * 0.45) * I * (long ? 1.1 : 0.85),
wobble: gauss(rng) * 0.012, // slight curvature
});
}
// The rim as arc SEGMENTS rather than one clean ring: when clean it reads as
// a continuous hard edge; when stained the rim erodes — gaps appear, widths
// and darkness vary, so the circle degrades like a real worn plate.
const rimSegs = [];
const k = 48;
for (let i = 0; i < k; i++) {
if (rng() < 0.02 + stain * 0.45) continue; // eroded gap
const a0 = (i / k) * Math.PI * 2;
const a1 = ((i + 1) / k) * Math.PI * 2;
rimSegs.push({
a0, a1,
width: (2.6 + 3 * I) * (1 + (rng() - 0.5) * stain * 1.4),
opacity: (0.55 + rng() * 0.35) * I * (1 - stain * 0.35),
});
}
// concentric inner pressure fronts
const rings = [];
const nRings = 2 + Math.floor(rng() * 3);
for (let i = 0; i < nRings; i++) {
rings.push({
rr: r * (0.5 + rng() * 0.9),
width: 0.6 + rng() * 1.6,
opacity: (0.06 + rng() * 0.18) * I,
});
}
// staining: soft blotches over and around the disk. Dark ones are grime /
// chemical deposit; light ones are washed/lifted emulsion (clean spots).
const stains = [];
const nStain = Math.floor(stain * 22 + rng() * stain * 12);
for (let i = 0; i < nStain; i++) {
const a = rng() * Math.PI * 2;
const rad = r * Math.sqrt(rng()) * 1.2;
stains.push({
x: x + Math.cos(a) * rad,
y: y + Math.sin(a) * rad,
r: r * (0.05 + rng() * 0.28),
opacity: (0.06 + rng() * 0.22) * (0.5 + stain),
dark: rng() < 0.65,
});
}
// textured core: short radial cracks filling the inner zone so the centre
// reads as fine detail rather than a black hub. Density falls toward centre,
// leaving a small brighter middle.
const core = [];
const nCore = Math.floor((120 + 420 * I) * (1 + stain * 0.6)); // dirtier = busier core
for (let i = 0; i < nCore; i++) {
const a = rng() * Math.PI * 2;
// bias toward the outer core (sqrt) so the very centre stays open
const rad = r * Math.sqrt(rng()) * 0.6;
const len = r * (0.03 + rng() * 0.16) * (0.4 + rad / (r * 0.6)); // longer outward
const ca = a + gauss(rng) * 0.25; // mostly radial cracks
core.push({
x1: x + Math.cos(a) * rad,
y1: y + Math.sin(a) * rad,
x2: x + Math.cos(a) * rad + Math.cos(ca) * len,
y2: y + Math.sin(a) * rad + Math.sin(ca) * len,
width: 0.35 + rng() * 0.9,
opacity: (0.25 + rng() * 0.5) * I,
});
}
return { x, y, r, intensity: I, stain, striations, rimSegs, rings, stains, core, bright: r * 0.12 };
}

98
src/scene/track.js Normal file
View File

@@ -0,0 +1,98 @@
/* ============================================================
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.
============================================================ */
import { gauss, logNormal } from '../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. */
export 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). */
export 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. */
export function cosmicTrack(params, rng) {
const e = edgeEntry(rng);
const p = 6 + rng() * 14; // very stiff → nearly straight
const q = rng() < 0.5 ? 1 : -1;
return integrateTrack(
{ x: e.x, y: e.y, theta: e.theta, p, q },
{ ...params, eloss: params.eloss * 0.15 },
{ maxTravel: 4, bound: 1.2 }
);
}
/* A "sweeper": moderate-high momentum with low energy loss, so it traces a
large, gentle arc all the way across the frame — the big graceful curves
between the straight cosmics and the tight curls. */
export function sweeperTrack(params, rng) {
const e = edgeEntry(rng);
const p = 1.6 + rng() * 3.2;
const q = rng() < 0.5 ? 1 : -1;
return integrateTrack(
{ x: e.x, y: e.y, theta: e.theta, p, q },
{ ...params, eloss: params.eloss * 0.3 },
{ maxTravel: 5.5, bound: 1.2 }
);
}

25
src/scene/vdecay.js Normal file
View File

@@ -0,0 +1,25 @@
/* ============================================================
vdecay.js — neutral-particle decay signatures.
A neutral (invisible) particle drifts from the vertex, then
decays into two oppositely-charged daughters: the classic "V".
============================================================ */
import { integrateTrack } from './track.js';
export function spawnVDecay(originPt, params, rng) {
const dist = 0.12 + rng() * 0.4;
const ghostTheta = rng() * Math.PI * 2;
const vx = originPt.x + Math.cos(ghostTheta) * dist;
const vy = originPt.y + Math.sin(ghostTheta) * dist;
if (Math.abs(vx) > 0.9 || Math.abs(vy) > 0.9) return [];
const opening = 0.25 + rng() * 0.8;
const p1 = 0.4 + rng() * 0.9;
const p2 = 0.4 + rng() * 0.9;
const t1 = integrateTrack(
{ x: vx, y: vy, theta: ghostTheta - opening / 2, p: p1, q: +1 }, params
);
const t2 = integrateTrack(
{ x: vx, y: vy, theta: ghostTheta + opening / 2, p: p2, q: -1 }, params
);
return [t1, t2];
}