Initial
This commit is contained in:
89
src/scene/artifacts.js
Normal file
89
src/scene/artifacts.js
Normal 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
51
src/scene/bubbles.js
Normal 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
57
src/scene/delta.js
Normal 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.5–4 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
72
src/scene/instrument.js
Normal 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
90
src/scene/params.js
Normal 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
132
src/scene/scene.js
Normal 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
109
src/scene/shock.js
Normal 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
98
src/scene/track.js
Normal 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
25
src/scene/vdecay.js
Normal 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];
|
||||
}
|
||||
Reference in New Issue
Block a user