/* ============================================================ 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 { generateMedia } from './media.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). eventZ = depth in the chamber (0 = focal plane), eventAge = how early in the exposure it happened (0 = current trigger). Both are stamped on every track the event produces so the renderers can vary opacity / size / softness with depth and time rather than treating the event as flat. */ function generateOneEvent(params, vertex, intensity, salt, eventZ = 0, eventAge = 0) { const rng = makeRng(params.seed, 'event:' + salt); const tracks = []; const N = Math.max(2, Math.round(params.primaries * (0.45 + intensity * 0.55))); const burstConc = 0.3 + params.burst * 0.7; const bright = intensity > 0.6; for (let i = 0; i < N; i++) { const baseAngle = (i / N) * Math.PI * 2; const angle = baseAngle + gauss(rng) * 0.4 * (1 - burstConc); const p = sampleMomentum(rng, params.pspread); const q = chance(rng, 0.5) ? +1 : -1; const pts = integrateTrack( { x: vertex.x, y: vertex.y, theta: angle, p, q }, params ); tracks.push({ pts, kind: 'primary', weight: intensity, q }); // δ-rays along bright primaries — abundant, true spirals if (bright) { const dRng = makeRng(params.seed, salt + ':delta' + i); for (let j = 6; j < pts.length; j += 2) { if (dRng() < params.deltaRate * 0.12) { const dpts = spawnDeltaSpiral(pts[j], params, dRng); if (dpts.length > 6) { tracks.push({ pts: dpts, kind: 'delta', weight: intensity * 0.9, q: -1 }); } } } } } // dense interaction "star": stubby tracks bursting from the vertex, with a // few medium prongs reaching further out for a richer burst. if (bright && params.burst > 0.15) { const sRng = makeRng(params.seed, salt + ':star'); const nStar = Math.floor(params.burst * 26); for (let i = 0; i < nStar; i++) { const a = sRng() * Math.PI * 2; const medium = sRng() < 0.25; const p = medium ? (0.5 + sRng() * 0.7) : (0.13 + sRng() * 0.4); const q = chance(sRng, 0.5) ? 1 : -1; const pts = integrateTrack( { x: vertex.x + gauss(sRng) * 0.012, y: vertex.y + gauss(sRng) * 0.012, theta: a, p, q }, params, { maxTravel: medium ? 2.6 : 1.1 } ); tracks.push({ pts, kind: 'primary', weight: intensity, q }); } } // V-decays only for the brightest (foreground) events if (intensity > 0.8) { for (let i = 0; i < params.vdecay; i++) { const daughters = spawnVDecay(vertex, params, makeRng(params.seed, salt + ':vdecay' + i)); daughters.forEach(d => { if (d.pts.length > 10) tracks.push({ pts: d.pts, kind: 'vdecay', weight: intensity * 0.95, q: d.q }); }); } } // stamp depth & age. Tracks of one interaction fan out in 3D, so they reach a // range of depths around the event's base z — this is what gives each trail its // own opacity/softness rather than a flat event. δ-rays get extra spread (they // scatter off into the volume). for (const t of tracks) { const spread = t.kind === 'delta' ? 0.45 : 0.3; t.z = Math.max(-1.2, Math.min(1.2, eventZ + gauss(rng) * spread)); t.age = eventAge; } return tracks; } export function generateScene(params) { const rng = makeRng(params.seed, 'scene'); const tracks = []; // Foreground event, slightly off-centre — near the focal plane, current trigger const fgVertex = { x: (rng() - 0.5) * 0.3, y: (rng() - 0.5) * 0.3 }; const fgZ = gauss(rng) * 0.12; tracks.push(...generateOneEvent(params, fgVertex, 1.0, 'fg', fgZ, 0)); // Background "history" events — scattered through depth and earlier in time const nBg = params.bgEvents || 0; for (let i = 0; i < nBg; i++) { const bgRng = makeRng(params.seed, 'bg' + i); const vx = (bgRng() - 0.5) * 1.7, vy = (bgRng() - 0.5) * 1.7; const intensity = params.bgIntensity * (0.5 + bgRng() * 0.5); const z = (bgRng() * 2 - 1); // anywhere in depth const age = 0.25 + bgRng() * 0.7; // older than the trigger tracks.push(...generateOneEvent( { ...params, primaries: Math.round(params.primaries * 0.6), vdecay: 0 }, { x: vx, y: vy }, intensity, 'bg' + i, z, age )); } // Cosmic/transient straight tracks crossing the frame const nCosmic = Math.round(params.cosmics || 0); for (let i = 0; i < nCosmic; i++) { const cRng = makeRng(params.seed, 'cosmic' + i); const { pts, q } = cosmicTrack(params, cRng); if (pts.length > 8) tracks.push({ pts, kind: 'cosmic', weight: 0.7 + cRng() * 0.3, q, z: gauss(cRng) * 0.5, age: 0.1 + cRng() * 0.3 }); } // Sweepers — big gentle arcs across the frame const nSweep = Math.round(params.sweepers || 0); for (let i = 0; i < nSweep; i++) { const sRng = makeRng(params.seed, 'sweep' + i); const { pts, q } = sweeperTrack(params, sRng); if (pts.length > 8) tracks.push({ pts, kind: 'sweep', weight: 0.75 + sRng() * 0.25, q, z: gauss(sRng) * 0.4, age: 0.05 + sRng() * 0.2 }); } const shock = generateShock(params, makeRng(params.seed, 'shock')); const artifacts = generateArtifacts(params, makeRng(params.seed, 'artifacts')); const instrument = generateInstrument(params, makeRng(params.seed, 'instrument')); // deterministic archival metadata (so exports are reproducible from the seed) const hash = cyrb53(params.seed); const ds = parseInt(hash.slice(0, 8), 16); const plate = (parseInt(hash.slice(-3), 16) % 999).toString().padStart(3, '0'); const year = 1971 + (ds % 11); const month = 1 + ((ds >> 4) % 12); const day = 1 + ((ds >> 8) % 28); const exposure = `${year}.${String(month).padStart(2, '0')}.${String(day).padStart(2, '0')}`; const lab = pick(makeRng(params.seed, 'lab'), LABS); const out = { tracks, vertex: fgVertex, shock, artifacts, instrument, hash, plate, exposure, lab }; // physical-artifact & human layer (grease pencil, film furniture, réseau, splice) out.media = generateMedia(params, out, makeRng(params.seed, 'media')); return out; }