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

51
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,51 @@
{
// Built-in JS debugger (no extensions needed).
// - "Open built plate" : zero-setup, rebuilds + opens the single file. Great for quick visual checks.
// - "Debug source" : breakpoints in src/*.js via a dev server (the real source-level debug).
// - Node configs : breakpoint straight into the generation logic — no browser involved.
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Open built plate (Chrome, no server)",
"file": "${workspaceFolder}/bubble_chamber.html",
"preLaunchTask": "build",
"webRoot": "${workspaceFolder}"
},
{
"type": "chrome",
"request": "launch",
"name": "Debug source (Chrome + dev server)",
"url": "http://localhost:8000/index.html",
"webRoot": "${workspaceFolder}",
"preLaunchTask": "serve"
},
{
"type": "node",
"request": "launch",
"name": "Debug scene → SVG (Node)",
"program": "${workspaceFolder}/tools/render-svg.mjs",
"args": ["BEBC Archival", "BEBC-1973"],
"skipFiles": ["<node_internals>/**"],
"console": "integratedTerminal"
},
{
"type": "node",
"request": "launch",
"name": "Debug scene → PDF (Node)",
"program": "${workspaceFolder}/tools/render-pdf.mjs",
"args": ["BEBC Archival", "BEBC-1973"],
"skipFiles": ["<node_internals>/**"],
"console": "integratedTerminal"
},
{
"type": "node",
"request": "launch",
"name": "Debug build (Node)",
"program": "${workspaceFolder}/build.mjs",
"skipFiles": ["<node_internals>/**"],
"console": "integratedTerminal"
}
]
}

BIN
.vscode/ruvector.db vendored Normal file

Binary file not shown.

30
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,30 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"type": "shell",
"command": "node build.mjs",
"problemMatcher": [],
"group": { "kind": "build", "isDefault": true },
"presentation": { "reveal": "silent", "panel": "shared" }
},
{
// Static dev server for source-level debugging. Reuses an existing
// server on :8000 if one is already up, otherwise starts one.
"label": "serve",
"type": "shell",
"command": "curl -s localhost:8000 >/dev/null 2>&1 && echo 'Serving HTTP (already up)' || python3 -m http.server 8000",
"isBackground": true,
"problemMatcher": {
"pattern": { "regexp": "^$" },
"background": {
"activeOnStart": true,
"beginsPattern": "Serving HTTP",
"endsPattern": "Serving HTTP"
}
},
"presentation": { "reveal": "silent", "panel": "dedicated" }
}
]
}

156
DESIGN.md Normal file
View File

@@ -0,0 +1,156 @@
# Bubble Chamber — Design Notes
A parametric generator for artificial bubble-chamber plates, aimed at
large-format B&W print. Deterministic from a seed string; tweakable in the
browser; exportable as photographic raster (full analog texture) or clean
vector (SVG/PDF) for print.
The north star is a worn CERN-era plate: a photographic *positive* (dark ink
on milky paper), busy with multi-scale tracks, dozens of tight δ-ray spirals,
a dramatic pressure/shock disk, and the analog remnants of a real plate — film
grain, uneven development, scratches, dust, water rings.
## Core architecture — one scene, many renderers
```
seed ──► generateScene(params) ──┬─► renderCanvasPhoto() photographic raster (preview + hi-res PNG)
(pure data, no DOM) ├─► renderSVG() clean vector for print
└─► buildPDF() vector PDF (24" page)
```
`generateScene()` returns a **renderer-agnostic data model** — tracks (as
polylines of `{x,y,beta,theta}`), the shock disk, plate artifacts, plus derived
metadata (hash, plate number). Every renderer consumes exactly this. This is the
seam to preserve: physics/geometry never touches a drawing API.
Bubble *positions* are sampled by the shared `sampleBubbles()` using a salted
RNG (`makeRng(seed,'bubbles')`), so the raster and vector outputs place bubbles
identically. Only the *look* differs between renderers.
Coordinates are a logical `[-1,1]` square; renderers map to whatever pixel size
they need. Effect radii in the raster renderer scale with output size, so the
live preview (1000px) and the print render (5400px+) look the same.
## Files
```
src/
rng.js cyrb53 hash, mulberry32 PRNG, salted streams, distributions
scene/
scene.js generateScene() — assembles events, cosmics, shock, artifacts
track.js trajectory integrator (B-field + energy loss), momentum sampling, cosmics
delta.js δ-ray log-spirals (the curly bits)
vdecay.js neutral-decay "V" daughters
shock.js pressure/shock disk model
instrument.js chamber optics: structural fan lines, chords, wall arcs
artifacts.js plate damage: scratches, hairs, dust, rings, fingerprints
params.js derive a full tasteful parameter set from a seed (archetypes)
bubbles.js polyline → bubbles (shared by all renderers)
render/
canvasPhoto.js photographic multi-pass raster compositor
svgVector.js clean vector renderer
pdf.js minimal vector PDF writer
noise.js value-noise / fbm for grain + tonal mottle
ui/controls.js declarative control + preset config (single source)
main.js panel build, state, render loop, exports
style.css
index.html dev entry (ES modules; needs a static server)
build.mjs zero-dep bundler → single-file bubble_chamber.html (file://-openable)
tools/
preview.html headless render harness (artwork only, params via URL query)
shoot.sh build+screenshot helper for the visual compare loop
render-svg.mjs CLI SVG render (--seed mode or legacy preset; shares scene module)
render-pdf.mjs CLI PDF render (print-ready CMYK; --seed mode)
inspire.sh randomize seeds → seed-named thumbnails + HTML contact sheet
render.sh one seed → print masters (SVG + PDF + hi-res PNG)
gallery.sh render the curated preset set
```
## Seed-driven workflow (large format)
`paramsFromSeed(seed)` makes the seed the complete fingerprint of a plate, so a
thumbnail and its print master are identical. Browse `tools/inspire.sh` output,
then `tools/render.sh <SEED>` for masters. The full large-format strategy
(vector geometry + raster texture layers, resolution math, assembly) is in
**PRINT.md**. Headless PNG ceiling ≈ 14000 px; SVG/PDF are resolution-independent.
## Physics & geometry decisions
- **Trajectory**: charged particle in a uniform out-of-page B field curves with
radius `r = p/(qB)`. Energy loss `dp/ds ∝ 1/β²` (simplified Bethe-Bloch) shrinks
`p`, tightening the radius toward end-of-range — the inward spiral. The
integrator *shortens its step in tight curvature* (`MAX_DTHETA`) so terminal
spirals stay smooth instead of going polygonal.
- **β as the master variable**: tracked along every polyline; drives both bubble
density (`∝1/β²`) and bubble radius (fatter as β falls). Get the β statistics
right and the image reads as authentic regardless of rendering tricks.
- **Momentum** is log-normal (heavy-tailed): most tracks moderate, a real tail of
very stiff (long, near-straight) and very soft (tight spirals).
- **δ-rays** are modelled directly as **logarithmic spirals** (radius decays
exponentially with wind angle) rather than integrated down to sub-percent
radii. This converges cleanly to a point and reliably reads as the textbook
curl. β falls toward the centre so the curl gets denser and fatter inward.
- **Cosmics/transients**: very stiff tracks entering from the frame edge, crossing
as near-straight diagonals (the long lines in real plates that ignore the vertex).
- **Shock disk**: not physics — a chamber/window pressure artifact. Modelled as a
dark annulus body with a bright textured centre, a hard rim, and fine striations
that burst *outward* from a ring. The strongest compositional anchor.
## The analog (raster) layer
`renderCanvasPhoto` is a multi-pass compositor:
1. paper fill + radial gas-glow gradient
2. low-frequency tonal mottle (fbm noise, multiply) — uneven development/illumination
3. ink layer (offscreen): faint continuity under-stroke per track + soft blooming
bubbles stamped from a cached radial-gradient sprite (overlaps merge into lines)
4. halation/bloom: a blurred copy of the ink composited back for soft spread
5. sharp ink composited onto paper (`multiply` for the positive look)
6. plate damage (dark via multiply; some scratches *lift* via screen)
7. fiducials + chamber boundary
8. vignette
9. structured film grain (supra-pixel clumps, overlay blend)
Grain/mottle/bloom are intentionally **raster-only**. The vector renderer is the
clean graphic version (soft bubbles via a shared radial-gradient fill, no grain).
## Reproducibility
Everything is seeded. New stochastic subsystems must follow the salt pattern:
`makeRng(seed, 'subsystem')`. That's what lets a parameter tweak change one thing
without reshuffling the rest of the plate.
## Build / run
- **Tweak interactively**: `node build.mjs` then open `bubble_chamber.html`
directly (no server needed — it's a single self-contained file). Or for live
module editing, serve the folder (`python3 -m http.server`) and open `index.html`.
- **Visual compare loop**: `tools/shoot.sh /tmp/out.png "preset=BEBC%20Archival&size_px=1200"`
renders the artwork headlessly via Chrome. Any param can be set in the query.
- **Offline SVG / PDF**: `node tools/render-svg.mjs "BEBC Archival" SEED out.svg`
and `node tools/render-pdf.mjs "BEBC Archival" SEED out.pdf`.
> Tip for iteration: drop the target reference image into `reference/` so it can
> be opened side-by-side with renders when tuning toward it.
## Print pipeline (done)
- **PDF**: DeviceCMYK (warm cream + rich black), per-element alpha via ExtGState
(`/ca`,`/CA`), Helvetica archival header. 24″ page (1728pt). Validated against
macOS's native Quartz renderer.
- **PNG**: 7200px (24″ @ 300 DPI), full film texture; effect radii scale with size.
- **SVG**: 4800px clean vector with header + soft-bubble gradients.
- **Archival metadata** (lab, plate no., exposure date) derives deterministically
from the seed, so any export is reproducible and self-labelling.
## Open threads / next ideas
- **Tune toward the reference** — once it's in `reference/`, a side-by-side pass on
density / curl abundance / track weight / tonality.
- **Stereo pairs** — two offset views from one seed, as a diptych.
- **Plate series** — deterministic MUON-001…024 grid (archival-sheet aesthetic).
- **16-bit / TIFF** export for the highest-end print path (browser PNG is 8-bit).
- **Colour stretch goal** — map charge or β to a channel for a stylized variant.
- **Performance** — the 7200px PNG does heavy per-bubble stamping + blur; a WebGL
post stage (grain/bloom/blur) would make hi-res renders near-instant.
```

84
PRINT.md Normal file
View File

@@ -0,0 +1,84 @@
# Large-format print strategy
For a wall-scale piece (you mentioned ~2.5 m × 4 m, assembled in layers with
other artwork), the key idea is: **don't bake one giant flat raster.** Work with
a vector geometry master plus separable raster texture, and let the seed be the
single source of truth.
## 1. The seed is the whole fingerprint
Every parameter is derived deterministically from the seed (`src/scene/params.js`).
So an inspiration thumbnail and its print master are the *same image* at different
sizes — you never track parameters, you just keep a seed.
```
tools/inspire.sh 40 600 # 40 random plates → output/inspiration/ + index.html
# browse output/inspiration/index.html, note the seeds you like
tools/render.sh STRANGENESS-7 # → output/masters/STRANGENESS-7.{svg,pdf,_9000.png}
tools/render.sh STRANGENESS-7 12000 # bigger raster if you want it
```
## 2. Two masters per chosen seed
| master | resolution | carries | use |
|---|---|---|---|
| **SVG** (`<SEED>.svg`) | infinite | geometry: tracks, curls, disk structure, fan lines, header | scale to the full 4 m with zero pixelation — the crisp "ink" |
| **PNG** (`<SEED>_<px>.png`) | up to ~14000 px | the analog layer: grain, bloom, tonal mottle, stains, vignette | the photographic texture/feel |
| **PDF** (`<SEED>.pdf`) | infinite (CMYK) | same geometry, print-shop colour | hand to a print shop as-is |
Why both: vector stays razor-sharp at any size but is "clean"; the raster holds
the worn-film texture but is resolution-limited. At 4 m you want the geometry
crisp *and* the grain present — so use both as layers.
## 3. Recommended assembly (in your compositor)
Think of each plate as a small stack you drop into your larger artwork:
1. **Geometry** — place the **SVG**, scale to final size. Stays sharp at 4 m.
2. **Texture/feel** — place the **PNG** over it; it already contains grain +
bloom + stains. Blend `Multiply` (positive plates) and dial opacity, or
isolate just its grain/mottle if you only want texture.
3. Your **other artistic layers** above/below, blended to taste.
Because the PNG and SVG come from the same seed they register exactly (same
composition, same coordinate frame, same margins).
> Film grain *should* enlarge — real emulsion grain gets bigger on big prints.
> So upscaling the PNG's texture to 4 m is not a defect, it's the look. Render the
> PNG at whatever size is comfortable (9000 px is plenty as a texture source) and
> let it scale up.
## 4. Resolution math
For viewing a wall piece from > 12 m, 100150 DPI is ample (the eye can't
resolve more at that distance).
| print dimension | 100 DPI | 150 DPI |
|---|---|---|
| 2.5 m (98 in) | 9 800 px | 14 700 px |
| 4.0 m (157 in) | 15 700 px | 23 600 px |
- The **SVG/PDF** meet any of these exactly (vector).
- The **PNG** tops out around **14 000 px** in this headless pipeline (Chrome
canvas limit ≈ 16384²; 14000 renders reliably, ~78 s, ~260 MB). That covers the
2.5 m dimension at 140 DPI as a *baked* image; for the 4 m dimension rely on the
SVG for sharpness and let the PNG texture scale.
## 5. If you need a baked raster bigger than ~14000 px
Options, in order of preference:
1. **Use the SVG** as the geometry and only scale the PNG *texture* — almost
always the right answer.
2. **Tiled render** — render the scene in N×N tiles and stitch (a future
`tools/render-tiled.mjs`). Seamless grain/bloom across tiles needs care; ask
and I'll build it.
3. **node-canvas offline** — move the photographic renderer to Node with
`node-canvas` (no Chrome canvas-size limit). Adds a native dependency.
## 6. Tuning a seed before committing
Found a seed that's close? Open `bubble_chamber.html`, type the seed, and nudge
sliders — including **Shock-wave Disk → Staining / erosion** for the disk's
cleanliness. (Note: manual slider tweaks are *not* part of the seed fingerprint;
if you want a tweak to be permanent, tell me the values and I'll fold them into a
preset or the seed-derivation ranges.)

2241
bubble_chamber.html Normal file

File diff suppressed because it is too large Load Diff

1256
bubble_chamber.v2.html Normal file

File diff suppressed because it is too large Load Diff

85
build.mjs Normal file
View File

@@ -0,0 +1,85 @@
/* ============================================================
build.mjs — zero-dependency bundler + inliner.
Walks the ES-module graph from src/main.js, wraps each module
in a tiny require() shim (constrained to the named-import /
named-export subset this project uses), inlines the CSS, and
writes a single self-contained, file://-openable
bubble_chamber.html.
Run: node build.mjs
============================================================ */
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
const ROOT = path.dirname(new URL(import.meta.url).pathname);
const ENTRY = 'src/main.js';
const IMPORT_RE = /^\s*import\s*\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]\s*;?\s*$/;
const EXPORT_RE = /^\s*export\s+(function|const|let|var|class)\s+([A-Za-z0-9_$]+)/;
const modules = new Map(); // id -> { code }
const order = [];
function resolve(fromId, spec) {
const dir = path.posix.dirname(fromId);
let id = path.posix.normalize(path.posix.join(dir, spec));
if (!id.endsWith('.js')) id += '.js';
return id;
}
function load(id) {
if (modules.has(id)) return;
const src = readFileSync(path.join(ROOT, id), 'utf8');
const exports = [];
const deps = [];
const lines = src.split('\n').map((line) => {
const imp = line.match(IMPORT_RE);
if (imp) {
const names = imp[1].split(',').map(s => s.trim()).filter(Boolean);
const depId = resolve(id, imp[2]);
deps.push(depId);
return `const { ${names.join(', ')} } = __require(${JSON.stringify(depId)});`;
}
const exp = line.match(EXPORT_RE);
if (exp) {
exports.push(exp[2]);
return line.replace(/^\s*export\s+/, '');
}
return line;
});
let code = lines.join('\n');
if (exports.length) code += `\nObject.assign(exports, { ${exports.join(', ')} });\n`;
modules.set(id, { code });
for (const d of deps) load(d); // depth-first; registry handles cycles/order
order.push(id);
}
load(ENTRY);
let bundle = `(function () {\n`;
bundle += ` const __cache = {};\n const __reg = {};\n`;
bundle += ` function __require(id) {\n if (__cache[id]) return __cache[id].exports;\n` +
` const module = { exports: {} }; __cache[id] = module;\n` +
` __reg[id](module, module.exports, __require);\n return module.exports;\n }\n`;
for (const id of [...modules.keys()]) {
bundle += ` __reg[${JSON.stringify(id)}] = function (module, exports, __require) {\n`;
bundle += modules.get(id).code + '\n';
bundle += ` };\n`;
}
bundle += ` __require(${JSON.stringify(ENTRY)});\n})();\n`;
// inline into index.html
let html = readFileSync(path.join(ROOT, 'index.html'), 'utf8');
const css = readFileSync(path.join(ROOT, 'src/style.css'), 'utf8');
html = html.replace(
/<link rel="stylesheet" href="src\/style\.css">/,
`<style>\n${css}\n</style>`
);
html = html.replace(
/<script type="module" src="src\/main\.js"><\/script>/,
`<script>\n${bundle}</script>`
);
html = html.replace('Parametric Generator · v3', 'Parametric Generator · v3 (built)');
writeFileSync(path.join(ROOT, 'bubble_chamber.html'), html);
console.log(`built bubble_chamber.html — ${modules.size} modules, ${(html.length / 1024).toFixed(1)} KB`);

60
index.html Normal file
View File

@@ -0,0 +1,60 @@
<!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">
<link rel="stylesheet" href="src/style.css">
</head>
<body>
<aside>
<div class="brand">Bubble Chamber</div>
<div class="subtitle">Parametric Generator · v3</div>
<div class="group" style="border-top:none; padding-top:0;">
<div class="group-title">Seed</div>
<div class="seed-row">
<input class="seed-input" id="seedInput" value="ENTROPY-001" spellcheck="false">
<button id="randomSeed" title="Randomize seed"></button>
</div>
<div class="btn-row-full"><button id="regen" class="primary">Regenerate</button></div>
<div class="btn-row-full"><button id="fromSeed">Derive all params from seed</button></div>
<select class="preset" id="presets"><option value="">— Preset —</option></select>
</div>
<div id="controls"></div>
<div class="group">
<div class="group-title">Export</div>
<div class="btn-row">
<button id="exportSVG">SVG</button>
<button id="exportPDF">PDF</button>
</div>
<div class="btn-row-full"><button id="exportPNG">PNG · high-res</button></div>
<div style="margin-top:14px; font-size:10px; color:var(--ink-mute); line-height:1.5;">
Print target: 24″ × 24″<br>
SVG/PDF scale infinitely (clean vector).<br>
PNG carries the full film texture.
</div>
</div>
</aside>
<main>
<div class="stage-frame">
<canvas id="preview" width="1000" height="1000"></canvas>
<div class="stage-corner">
<span id="labName">BEBC · CERN</span><br>
Plate <span id="plateNum">001</span><br>
<span class="sm">Exposed · <span id="exposureDate"></span></span>
</div>
<div class="stage-meta">Seed: <span class="hash" id="hashDisplay"></span></div>
</div>
<div class="toast" id="toast">Exported</div>
</main>
<script type="module" src="src/main.js"></script>
</body>
</html>

276
originalprompt.md Normal file
View File

@@ -0,0 +1,276 @@
17792395099883716390171249308188.jpg
Please find me high resolution photos of charged particles in bubble chambers. Also, let's brainstorm writing software to make them parametrically.
May 19
What a beautiful image — classic bubble chamber tracks. Let me find you some high-res references and then we'll dig into the parametric side.
Image unavailable
Particle Tracks In Bubble Chamber #3 Photograph by Cern / Science Photo ...
pixelsmerch.com
Cern Bubble Chamber Wallpaper Bubble Chamber Framed Art Prints For
fity.club
7
Results from the web
A few good archives worth bookmarking for higher-res scans than search thumbnails: the CERN Document Server (cds.cern.ch — search "bubble chamber"), Fermilab's image archive, and the Science Photo Library (CERN collection). CERN's CDS in particular has scans of original BEBC, Gargamelle, and 2-meter HBC photographs at full plate resolution, and they're public domain or CC-licensed.
Now the fun part — parametric generation.
The physics you'd want to model
What gives bubble chamber images their character is a small set of physical phenomena layered on top of each other:
Helical trajectories in a uniform magnetic field. Charged particles curve with radius r = p/(qB). The sign of the charge sets the direction, momentum sets the radius. As the particle loses energy through ionization (Bethe-Bloch), r shrinks — that's why you get those gorgeous inward spirals from low-energy electrons (the curly little "δ-rays" knocked off by passing tracks).
Vertices: a primary interaction point where tracks fan out, plus secondary vertices where neutral particles decay (the famous "V" shapes from K⁰ or Λ).
Track thickness scales roughly as 1/β² (slower particles ionize more, leave fatter tracks). End-of-range tracks get visibly heavier.
The bubbles themselves: discrete nucleation sites along the track, Poisson-distributed in density, each rendered as a small dark circle with some blur from the photographic process.
Background: film grain, the chamber's circular boundary, fiducial marks, sometimes the shock wave / piston artifact you can see at the bottom of your reference image.
A sketch of the architecture
I'd build it in layers, each tunable:
Event generator — sample N primary tracks from a vertex with momentum/charge distributions you choose. Optionally spawn secondary vertices with decay kinematics (two-body decay with proper opening angles is enough to look right).
Trajectory integrator — step each particle through (B field, energy loss) with RK4 or just small Euler steps. Output a polyline. Spawn δ-rays stochastically along the way (low-momentum electrons that spiral tightly).
Bubble sampler — walk each polyline and drop bubble centers as a Poisson process with rate λ(β). Jitter perpendicular to the track by a small σ (diffusion).
Renderer — composite bubbles onto a canvas with a slight Gaussian blur per bubble, add film grain (Perlin or just Gaussian noise with a low-pass filter), vignette, and the chamber boundary arc.
Stack options
For a quick exploratory version, Python with NumPy + Pillow or matplotlib gets you a static image in a couple hundred lines. If you want interactive parameter sliders, three.js or p5.js in the browser is satisfying — you can scrub B-field strength and watch the spirals tighten in real time. For really high-resolution print-quality output, render the bubbles as SVG and rasterize at whatever DPI you want.
A nice constraint to keep in mind: the look comes from getting the statistics right, not from rendering tricks. If your bubble density vs. β is correct and your δ-ray spawn rate is calibrated, the image will read as authentic even with crude rendering.
Want me to start with a working Python prototype that renders a single static frame, or jump straight to a browser-based interactive version with parameter sliders? Either is a fun afternoon project — given your DSP and physics background it'd come together fast.
If I start a project from a prompt here, can I continue from Claude inside vscode?
May 19
Short answer: not directly — projects and chats in Claude.ai don't sync to Claude Code in VS Code. They're separate surfaces with separate context.
But the practical workflow works fine. A few options:
Export and hand off. Finish the brainstorm here, then paste the relevant chunks (the architecture sketch, parameter choices, any code we've drafted) into a README.md or DESIGN.md in your repo. Claude Code picks that up as context automatically when you open the project. This is what I'd actually recommend — the act of distilling the chat into a design doc is useful anyway, and it gives Claude Code a cleaner starting point than a raw conversation transcript.
Save code as files in this chat. I can write the prototype straight to files here, you download them, drop them in a repo, and continue in Claude Code from there. Good for getting a running skeleton in your hands fast.
Memory carries some context. Claude.ai's memory of you (your DSP background, .NET work, etc.) is specific to claude.ai. Claude Code has its own context model — it reads your codebase, CLAUDE.md files in the repo, and any docs you point it at. So if you want continuity of project context, put it in the repo. If you want continuity of personal context, that's already there on this side but won't follow you to VS Code.
For the bubble chamber project specifically: I'd suggest we nail down the architecture and maybe a first working Python prototype here, you commit it with a DESIGN.md capturing the physics decisions, and then iterate in Claude Code where having the live codebase in context is a big advantage. Want to go that route?
Yes, let's proceed. One goal is large format print. Vector graphics are ideal. Let's proceed with a tweakable version along with a seed/hash concept for reproducibility. Be creative. This is an art project. Clarify with questions to refine context. Take liberties when inspired.
May 19
Good constraints. Vector + seeded + tweakable + art-first — that's a clear target. Let me ask a few things that'll meaningfully shape the output before I start building, because the answers push the architecture in different directions.
Q: What's the aesthetic target?
A: primary #1. both as stretch goal
Q: Output format priority?
A: SVG + PDF export for print shop
Q: How interactive should the tweaking be?
A: 2 or 3. let's make it interactive and satisfying. lower resolution preview with higher resolution on demand
Q: Which physics elements matter most to you visually? (pick all that appeal)
A: Spiraling δ-rays (the curly bits), V-decay vertices (neutral particle signatures), Energy-loss spiral-in (radius shrinking), Dense primary interaction stars/bursts
May 19
Perfect. Clear picture now: faithful B&W, vector-first for print, browser-based with live sliders, and the four physics elements you picked are exactly the ones that give the iconic bubble chamber look. Let me build this.
Plan: a single HTML file with embedded JS — interactive canvas preview (fast, raster) for live sliders, plus an "Export SVG" button that re-renders the same seeded scene as pure vector for print. The seed/hash controls everything, so any preview you like is reproducible at print resolution.
Let me check the frontend skill first since this is a web UI.
Check frontend design conventions for this environment
Check frontend design conventions for this environment
Good — this maps well. The aesthetic is "scientific instrument": dark workspace, monospace numerics, restrained. Let me build it.
Create the bubble chamber generator as a single self-contained HTML file with interactive canvas preview and SVG/PDF export
Create the bubble chamber generator as a single self-contained HTML file with interactive canvas preview and SVG/PDF export
Let me sanity-check it loads and the seed/hash logic is deterministic before handing it over.
Smoke test the deterministic generator core
Smoke test the deterministic generator core
All checks pass. Deterministic seeding works, scene varies with seed, ~14k bubbles per scene which is in a sweet spot for SVG size (substantial but renders fast).
Ran a command, read a file
Ran a command, read a file
Single HTML file, no build step, no dependencies. Just open it in a browser.
What's in there
Deterministic seed → hash → scene. Type any string, get a reproducible event. The hash (cyrb53) seeds a mulberry32 PRNG, and every stochastic decision (track angles, momentum, δ-ray spawn points, bubble jitter) draws from a salted sub-stream. Change one slider; bubble pattern stays consistent because the bubble sampler uses its own salt.
Physics layer. Tracks are integrated step-by-step through a uniform B-field with simplified Bethe-Bloch energy loss. β is tracked along each polyline and drives both bubble density (1/β²) and bubble radius (slow particles → fatter tracks at end of range). δ-rays spawn stochastically along primaries with their own tight curl. V-decay vertices emit two oppositely-charged daughters from an off-vertex point.
Three output paths. Canvas preview (live, includes grain), SVG (4800px native, scales infinitely), PDF (24″ × 24″ at 72 dpi = 1728pt page, native vector ops not embedded raster). PNG export at 3600px if you ever want one.
Print-honest defaults. Grain is raster-only by design — vector grain looks wrong at print scale, and your eye doesn't need it on a 24″ piece anyway.
Things to play with first
Try MUON-4242 with B-field 0.6, energy-loss 0.7, δ-ray rate 0.5 — long sweeping curves with lots of curl. Then try KAON-0088 with B-field 2.5, primaries 25 — tight chaos. The randomize button (⟳) cycles through particle-name seeds.
Architecture notes for VS Code handoff
A DESIGN.md worth writing before you move to Claude Code:
The geometry/sampling layer is rendering-agnostic — generateScene() and sampleBubbles() return plain data. Canvas/SVG/PDF are three independent consumers of the same scene description. That's the seam to preserve as the project grows.
The PDF writer is intentionally minimal (no font tables, no compression). If you want CMYK separations for a real print shop, that's the place to expand — swap in a library like pdf-lib and add a CMYK color profile.
Salted PRNGs are how reproducibility survives parameter tweaks. If you add new physics, follow the pattern: makeRng(seed, 'mysubsystem').
The weight field on tracks is wired up but underused — that's where you'd hang a future "track emphasis" or "highlight selected particle" feature.
Ideas worth considering as you iterate
Stereo pairs. Real bubble chamber photos came in stereo for 3D track reconstruction. Render two slightly offset views from the same seed — looks gorgeous as a diptych.
Plate series. Generate a deterministic sequence (MUON-001 through MUON-024) for a grid of related events — the boring physics-archive aesthetic is incredibly strong as a large print.
Color stretch goal. Map track charge or β to a color channel for the "stylized" variant. Stays vector, stays printable.
Annotation layer. The fiducials are already there; adding tick scales, a "BEBC / CERN" archival header, plate number, exposure date as actual rendered SVG text turns each piece into a fake-archival document. The plate number and exposure date are already computed deterministically from the seed — they're displayed in the UI but not yet in the export.
Have fun. Drop me a question when you're back in Claude Code and want to iterate.
Bubble chamber
Code · HTML
Really attractive. Great start. Do you have image analysis capability to compare the output of the HTML tool with the original reference image? If so, please analyze the two and describe 5 ways we could improve the tool
May 19
Yes, I can analyze images. Let me run the tool, capture its output, and compare side-by-side with your reference.
Look at the original reference image the user uploaded
Look at the original reference image the user uploaded
Now let me render the tool's output headlessly so I can actually see what it produces.
Check for headless browser availability
Check for headless browser availability
Playwright is available.
Viewed 3 files, created a file, ran a command
Viewed 3 files, created a file, ran a command
Now I can see clearly. Putting the reference and the outputs side by side, the gap is significant and instructive. Honest assessment:
1. Tonality is inverted and washed out. Real bubble chamber plates are light backgrounds with dark tracks — the photographs are positive prints from glass plates, so the gas glow reads as bright milky gray and the bubbles read as dark ink. My version flipped this to dark-on-light because "physics = space = black background" is a reflexive AI cliché. Look at the reference: it has the quality of an old technical photograph, almost like a worn etching. Fixing this is one switch (invert background and bubble colors) but it transforms the entire piece. This is the single highest-impact change.
2. Track curvature is uniform; reality is wildly multi-scale. In the reference you see tracks ranging from nearly-straight high-momentum projectiles (the long diagonal lines at top) all the way down to tiny tight curls a few millimeters across. My output has too narrow a range — most tracks have similar radius. The momentum distribution needs a much heavier tail (probably log-normal, not Gaussian), and the energy-loss spirals need to actually close in on themselves into those characteristic tight terminal curls. Right now tracks just stop when momentum drops; they should spiral down to a point.
3. δ-rays barely appear, and that's the iconic visual element. The little curly bits scattered everywhere in the reference — those tight perfect spirals — are the signature of bubble chamber photos. In my output they're sparse and underdeveloped because (a) the spawn probability per step is too low, (b) when they do spawn they exit the field of view before completing a full curl, and (c) the tightness range doesn't go tight enough. I need them to be abundant, spiral all the way down, and exist at a smaller spatial scale than primary tracks.
4. The image lacks layered history and density. Real bubble chamber photos are exposures — you see many events overlaid: the current trigger event plus background cosmic rays, residual tracks from prior pulses, scratches on the glass, dust, water marks. The reference image is busy in a way mine isn't, with multiple visual layers at different "depths" of contrast. I should add a low-density background layer of faint old tracks at lower opacity, plus emulsion artifacts (scratches as long thin straight lines at random angles, dust specks, water rings). This is what gives the archival feeling.
5. Bubble rendering is too uniform and too small. Looking closely at the reference, bubbles have varied character — some clearly resolved as dots, some merged into continuous-looking lines, some clumped where the track lingered. Mine are all the same dot pattern. I should: (a) merge consecutive bubbles into stroked path segments when density × β is high enough (gives the continuous-line look), (b) vary bubble size more dramatically, (c) add slight blur halos around heavier bubbles to simulate photographic bloom, and (d) introduce occasional "splat" clusters where the track end terminates — visible in the reference as dark blob terminations.
One bonus observation worth flagging: the dramatic circular shock pattern at the bottom of your reference — that's not standard. It's actually the chamber's pressure piston or a window edge artifact in that particular photograph, and it gives the image enormous compositional power. I included a faint version, but I should make it a prominent optional feature — it's such a strong visual anchor that for a print piece it could be the centerpiece. Worth a dedicated slider.
If you want, I can implement these in priority order — I'd start with #1 (tonal inversion) since it's the foundation everything else sits on, then #3 (δ-rays), then #2 (multi-scale curvature). #4 and #5 are polish that pays off most at print size. Want me to push a v2?
Let's make an inversion flag. Them proceed in order. Multiple events with different intensities feels important
May 19
Good call on the inversion flag — keeps both aesthetics accessible from the same seed. And yes, multiple events at different intensities is exactly the "layered history" point — let me build that in properly as a first-class concept rather than just a background dimmer.
Let me think about how to structure this before coding. The architecture I want:
Inversion is a render-time flag — geometry unchanged, just swap palette. Trivial.
Multi-scale momentum — replace the Gaussian with a heavy-tailed distribution (log-normal). Tracks with very high p stay nearly straight across the whole chamber; tracks with very low p make tight spirals. Same physics integrator, just better sampling.
δ-rays done right — higher base spawn rate, longer integration limit so they complete their curls, tighter range. They should be everywhere.
Multiple events — this is the big one. Currently there's one primary vertex. I'll add a concept of "event layers": a foreground event at full intensity, and N background events at reduced intensity scattered through the chamber. Each layer is its own salted sub-scene with its own seed, vertex, parameter set. The foreground event reads as "this is what the photo captured"; backgrounds read as "the chamber has been running."
Emulsion artifacts — long straight scratches, dust specks, water rings. These layer on top and are also seeded.
Let me build it.
Refresh on the current file structure before editing
Refresh on the current file structure before editing
Let me make the edits surgically. I'll do them in order.
Edit 1: Add the inversion checkbox and a "Background events" slider to the UI
Add inversion flag and background events slider to the UI panel
Add inversion flag and background events slider to the UI panel
Edit 2: Wire up the new sliders in JS state
Edited 3 files
Edited 3 files
Edit 3: Restructure generateScene to produce event layers with intensity, and use heavy-tailed momentum, and let δ-rays run longer
Replace generateScene with layered version that produces multiple events at different intensities, plus heavy-tailed momentum and longer-living δ-rays
Replace generateScene with layered version that produces multiple events at different intensities, plus heavy-tailed momentum and longer-living δ-rays
Edit 4: Update integrator to allow longer tracks for the heavy-tailed high-momentum samples
Bump max travel for high-momentum tracks; allow them to traverse the chamber
Bump max travel for high-momentum tracks; allow them to traverse the chamber
Edit 5: Now the big one — rewrite the canvas renderer to support inversion and draw artifacts
Rewrite canvas renderer to support inversion (light background + dark bubbles) and render emulsion artifacts
Rewrite canvas renderer to support inversion (light background + dark bubbles) and render emulsion artifacts
Edit 6: Update the SVG renderer for inversion and artifacts
Rewrite SVG renderer for inversion and artifacts
Rewrite SVG renderer for inversion and artifacts
Edit 7: Update the PDF writer for inversion
Update PDF writer to honor inversion flag
Update PDF writer to honor inversion flag
Now let me capture screenshots and see what we've got.
Ran a command, viewed a file
Ran a command, viewed a file
Huge improvement. The inversion alone transforms the feel completely — it now reads as an archival photograph. I can see the background events at lower intensity (the faint tracks across the bottom), water rings, dust specks, and the chamber boundary shock pattern is much more visible. Let me check the other seeds.
Viewed 2 files
Viewed 2 files
This is way closer to the reference. Let me look at all three side by side and compare to the original.
Major progress on what's working:
Tonality: the photographic-positive look is transformative. It now reads as an archival image, not a sci-fi screen.
Background events: you can see the faint historical tracks across the chamber at low intensity — exactly the "layered exposure" feel from the reference.
Heavy-tailed momentum: in MUON-4242 you can see both long sweeping arcs and shorter tight curves in the same frame.
Artifacts: the water ring (top center of default), scratches (faint diagonal lines), and dust specks scattered everywhere all read correctly.
What still falls short of the reference:
δ-rays are still underdeveloped — I made them more numerous but they're rendering as short J-shaped hooks rather than the iconic tight perfect spirals. The integrator is killing them too early. I need to keep them alive longer and let them complete multiple revolutions.
High-momentum tracks aren't long enough — the reference has tracks that traverse the entire image as nearly-straight lines. Mine still curve too much because the momentum tail isn't heavy enough.
Track-endpoint terminal spirals are missing — in the reference, low-momentum tracks visibly spiral inward to a single point. Mine just fade out.
The UI sidebar got long enough that you may need to scroll. Let me verify the file works and ship this version, since the next round of improvements needs the same kind of analyze-and-iterate cycle.
Ran a command, read a file
Ran a command, read a file
v2 shipped. Major changes:
Inversion flag (default ON, photographic positive). Toggles the entire palette — canvas, SVG, and PDF all honor it.
Event Layers section with two new sliders: Background events (012 historical events scattered through the chamber at lower intensity) and Background intensity (how dim they read). Each background event has its own salted seed, so they stay reproducible.
Heavy-tailed momentum via log-normal sampling — you'll see more variety in track scale within a single seed now.
Emulsion artifacts slider — adds scratches, dust specks, and occasional water/chemical rings to the plate. Set to 0 for clean output, crank up for archival-distressed feel.
δ-rays boosted — higher spawn rate, tighter local B-field, lower energy loss, longer integration. Still not quite where they need to be, but closer.
Honest assessment of where we still are vs. the reference:
The biggest remaining gap is that δ-rays render as J-hooks rather than the iconic tight multi-revolution spirals. The integrator either kills them too early or they exit the visible region before completing a curl. I think the fix is to make their local B-field stronger still (so their radius is genuinely tiny — like 1-2% of chamber) and decouple their energy loss from the global slider. Second remaining gap: the highest-momentum tracks should traverse the whole chamber as nearly straight lines, and the log-normal tail isn't heavy enough yet.
These are the right things to tackle in the next pass, where having Claude Code looking at the actual code while you tweak in the browser will be much more productive than me iterating blind in this chat. I'd suggest:
Open the HTML in your browser, play with the new sliders, and find which seed/parameter combinations come closest to the reference feel.
Drop the file in a repo with a DESIGN.md capturing where we are and what's next (δ-ray tightness, momentum tail, terminal spiral-in).
Continue in Claude Code from there. The architectural seams (generateScene → sampleBubbles → renderers) are stable, so it can iterate on the physics without disturbing the rendering pipeline.
Fun project. Send me a print photo when you put one on a wall.
Bubble chamber
Code · HTML
Claude is AI and can make mistakes. Please double-check responses.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 MiB

BIN
output/04_cosmic-sheet.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 679 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 579 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 685 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 520 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 729 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 661 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 729 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 701 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 735 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 701 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 668 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 564 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 701 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 705 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 664 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 709 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 668 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 690 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 588 KiB

View File

@@ -0,0 +1,35 @@
<!DOCTYPE html><html><head><meta charset="utf-8"><title>Bubble Chamber — inspiration</title>
<style>body{margin:0;background:#111;color:#bbb;font:12px/1.4 ui-monospace,Menlo,monospace;padding:24px}
h1{font-weight:400;letter-spacing:.2em;text-transform:uppercase;font-size:13px;color:#d4a574}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:18px;margin-top:18px}
figure{margin:0;background:#000;border:1px solid #2a2a2a}img{width:100%;display:block}
figcaption{padding:6px 8px;display:flex;justify-content:space-between;align-items:center}
.s{color:#e8e4d8;letter-spacing:.05em}.c{color:#555;user-select:all}</style></head><body>
<h1>Bubble Chamber · inspiration · 2026-05-20</h1>
<p>Click a seed to select it, then run <span class="c">tools/render.sh &lt;SEED&gt;</span> for the print master.</p>
<div class="grid">
<figure><img src="BOSON-8669.png" loading="lazy"><figcaption><span class="s">BOSON-8669</span><span class="c">render.sh BOSON-8669</span></figcaption></figure>
<figure><img src="CHARM-9063.png" loading="lazy"><figcaption><span class="s">CHARM-9063</span><span class="c">render.sh CHARM-9063</span></figcaption></figure>
<figure><img src="LAMBDA-2648.png" loading="lazy"><figcaption><span class="s">LAMBDA-2648</span><span class="c">render.sh LAMBDA-2648</span></figcaption></figure>
<figure><img src="NEUTRINO-9243.png" loading="lazy"><figcaption><span class="s">NEUTRINO-9243</span><span class="c">render.sh NEUTRINO-9243</span></figcaption></figure>
<figure><img src="GLUEBALL-7284.png" loading="lazy"><figcaption><span class="s">GLUEBALL-7284</span><span class="c">render.sh GLUEBALL-7284</span></figcaption></figure>
<figure><img src="RESONANCE-1584.png" loading="lazy"><figcaption><span class="s">RESONANCE-1584</span><span class="c">render.sh RESONANCE-1584</span></figcaption></figure>
<figure><img src="ANTIPROTON-5808.png" loading="lazy"><figcaption><span class="s">ANTIPROTON-5808</span><span class="c">render.sh ANTIPROTON-5808</span></figcaption></figure>
<figure><img src="NUCLEON-8802.png" loading="lazy"><figcaption><span class="s">NUCLEON-8802</span><span class="c">render.sh NUCLEON-8802</span></figcaption></figure>
<figure><img src="XI-2188.png" loading="lazy"><figcaption><span class="s">XI-2188</span><span class="c">render.sh XI-2188</span></figcaption></figure>
<figure><img src="MUON-1704.png" loading="lazy"><figcaption><span class="s">MUON-1704</span><span class="c">render.sh MUON-1704</span></figcaption></figure>
<figure><img src="BARYON-9263.png" loading="lazy"><figcaption><span class="s">BARYON-9263</span><span class="c">render.sh BARYON-9263</span></figcaption></figure>
<figure><img src="HYPERON-8289.png" loading="lazy"><figcaption><span class="s">HYPERON-8289</span><span class="c">render.sh HYPERON-8289</span></figcaption></figure>
<figure><img src="POSITRON-3617.png" loading="lazy"><figcaption><span class="s">POSITRON-3617</span><span class="c">render.sh POSITRON-3617</span></figcaption></figure>
<figure><img src="ANTIPROTON-6835.png" loading="lazy"><figcaption><span class="s">ANTIPROTON-6835</span><span class="c">render.sh ANTIPROTON-6835</span></figcaption></figure>
<figure><img src="PARITY-1530.png" loading="lazy"><figcaption><span class="s">PARITY-1530</span><span class="c">render.sh PARITY-1530</span></figcaption></figure>
<figure><img src="CHARM-8097.png" loading="lazy"><figcaption><span class="s">CHARM-8097</span><span class="c">render.sh CHARM-8097</span></figcaption></figure>
<figure><img src="HADRON-7754.png" loading="lazy"><figcaption><span class="s">HADRON-7754</span><span class="c">render.sh HADRON-7754</span></figcaption></figure>
<figure><img src="LEPTON-5840.png" loading="lazy"><figcaption><span class="s">LEPTON-5840</span><span class="c">render.sh LEPTON-5840</span></figcaption></figure>
<figure><img src="BOSON-3530.png" loading="lazy"><figcaption><span class="s">BOSON-3530</span><span class="c">render.sh BOSON-3530</span></figcaption></figure>
<figure><img src="MUON-5176.png" loading="lazy"><figcaption><span class="s">MUON-5176</span><span class="c">render.sh MUON-5176</span></figcaption></figure>
<figure><img src="NEUTRINO-3556.png" loading="lazy"><figcaption><span class="s">NEUTRINO-3556</span><span class="c">render.sh NEUTRINO-3556</span></figcaption></figure>
<figure><img src="KAON-3434.png" loading="lazy"><figcaption><span class="s">KAON-3434</span><span class="c">render.sh KAON-3434</span></figcaption></figure>
<figure><img src="MESON-9679.png" loading="lazy"><figcaption><span class="s">MESON-9679</span><span class="c">render.sh MESON-9679</span></figcaption></figure>
<figure><img src="CASCADE-1099.png" loading="lazy"><figcaption><span class="s">CASCADE-1099</span><span class="c">render.sh CASCADE-1099</span></figcaption></figure>
</div></body></html>

View File

@@ -0,0 +1,24 @@
BOSON-8669
CHARM-9063
LAMBDA-2648
NEUTRINO-9243
GLUEBALL-7284
RESONANCE-1584
ANTIPROTON-5808
NUCLEON-8802
XI-2188
MUON-1704
BARYON-9263
HYPERON-8289
POSITRON-3617
ANTIPROTON-6835
PARITY-1530
CHARM-8097
HADRON-7754
LEPTON-5840
BOSON-3530
MUON-5176
NEUTRINO-3556
KAON-3434
MESON-9679
CASCADE-1099

585366
output/masters/HYPERON-8289.pdf Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 MiB

381543
output/masters/LAMBDA-2648.pdf Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 MiB

12
package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "bubble-chamber",
"version": "3.0.0",
"description": "Parametric bubble-chamber plate generator — deterministic scene model, photographic + vector renderers.",
"type": "module",
"scripts": {
"build": "node build.mjs",
"shot": "tools/shoot.sh",
"svg": "node tools/render-svg.mjs"
},
"license": "MIT"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

BIN
ruvector.db Normal file

Binary file not shown.

213
src/main.js Normal file
View File

@@ -0,0 +1,213 @@
/* ============================================================
main.js — app wiring.
Builds the control panel from controls.js, reads params,
drives generate→render, and handles exports. The preview is
always the photographic renderer; SVG/PDF are the vector path.
============================================================ */
import { cyrb53 } from './rng.js';
import { generateScene } from './scene/scene.js';
import { renderCanvasPhoto } from './render/canvasPhoto.js';
import { renderSVG } from './render/svgVector.js';
import { buildPDF } from './render/pdf.js';
import { GROUPS, TOGGLES, FIXED, PRESETS } from './ui/controls.js';
import { paramsFromSeed } from './scene/params.js';
const PREVIEW = 1000; // internal preview resolution
const EXPORT_PNG = 7200; // hi-res raster — 24" @ 300 DPI
/* ---------- build the panel ---------- */
const panel = document.getElementById('controls');
const sliderDefs = {};
const toggleDefs = {};
function buildPanel() {
for (const g of GROUPS) {
const grp = el('div', 'group');
grp.appendChild(el('div', 'group-title', g.title));
for (const c of g.controls) {
sliderDefs[c.id] = c;
const row = el('div', 'row');
row.appendChild(el('label', null, c.label));
const val = el('span', 'val');
val.id = c.id + 'Val';
row.appendChild(val);
grp.appendChild(row);
const input = document.createElement('input');
input.type = 'range';
input.id = c.id;
input.min = c.min; input.max = c.max; input.step = c.step; input.value = c.value;
grp.appendChild(input);
}
// toggles belonging to a group title go after Film & Plate / Shock
panel.appendChild(grp);
if (g.title === 'Shock-wave Disk') addToggle(grp, 'shock');
if (g.title === 'Film & Plate') {
addToggle(grp, 'showFiducials');
addToggle(grp, 'showBoundary');
addToggle(grp, 'showHeader');
addToggle(grp, 'invert');
}
}
}
function addToggle(grp, id) {
const def = TOGGLES.find(t => t.id === id);
toggleDefs[id] = def;
const row = el('label', 'checkbox-row');
const cb = document.createElement('input');
cb.type = 'checkbox'; cb.id = id; cb.checked = def.value;
const span = el('span', null, def.label);
row.appendChild(cb); row.appendChild(span);
grp.appendChild(row);
}
function el(tag, cls, text) {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
}
/* ---------- params ---------- */
function readParams() {
const p = { ...FIXED, seed: document.getElementById('seedInput').value || 'DEFAULT' };
for (const [id, def] of Object.entries(sliderDefs)) {
const v = parseFloat(document.getElementById(id).value);
p[id] = def.int ? Math.round(v) : v;
}
for (const id of Object.keys(toggleDefs)) p[id] = document.getElementById(id).checked;
return p;
}
function updateLabels() {
for (const [id, def] of Object.entries(sliderDefs)) {
const v = parseFloat(document.getElementById(id).value);
document.getElementById(id + 'Val').textContent = def.int ? String(Math.round(v)) : v.toFixed(2);
}
}
/* ---------- render loop ---------- */
const canvas = document.getElementById('preview');
canvas.width = canvas.height = PREVIEW;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
let scene = null, params = null, timer = null;
function regen() {
params = readParams();
scene = generateScene(params);
renderCanvasPhoto(ctx, PREVIEW, PREVIEW, scene, params, { preview: true });
document.getElementById('hashDisplay').textContent = scene.hash.toUpperCase();
document.getElementById('plateNum').textContent = scene.plate;
document.getElementById('exposureDate').textContent = scene.exposure;
document.getElementById('labName').textContent = scene.lab;
}
function rerender() {
if (!scene) return regen();
params = readParams();
renderCanvasPhoto(ctx, PREVIEW, PREVIEW, scene, params, { preview: true });
}
function schedule(needsRegen) {
clearTimeout(timer);
timer = setTimeout(() => (needsRegen ? regen() : rerender()), 28);
}
/* ---------- wiring ---------- */
function wire() {
for (const [id, def] of Object.entries(sliderDefs)) {
document.getElementById(id).addEventListener('input', () => {
updateLabels();
schedule(def.mode === 'scene');
});
}
for (const [id, def] of Object.entries(toggleDefs)) {
document.getElementById(id).addEventListener('change', () => schedule(def.mode === 'scene'));
}
document.getElementById('seedInput').addEventListener('change', regen);
document.getElementById('regen').addEventListener('click', regen);
document.getElementById('fromSeed').addEventListener('click', deriveFromSeed);
document.getElementById('randomSeed').addEventListener('click', randomSeed);
// presets
const ps = document.getElementById('presets');
for (const name of Object.keys(PRESETS)) {
const o = document.createElement('option'); o.value = name; o.textContent = name; ps.appendChild(o);
}
ps.addEventListener('change', () => { applyPreset(ps.value); ps.selectedIndex = 0; });
document.getElementById('exportSVG').addEventListener('click', exportSVG);
document.getElementById('exportPDF').addEventListener('click', exportPDF);
document.getElementById('exportPNG').addEventListener('click', exportPNG);
}
const WORDS = ['MUON', 'KAON', 'PION', 'LAMBDA', 'SIGMA', 'XI', 'OMEGA', 'TAU', 'GLUON', 'QUARK', 'HADRON', 'BARYON', 'LEPTON', 'NEUTRINO', 'BOSON'];
function randomSeed() {
const w = WORDS[Math.floor(Math.random() * WORDS.length)];
const n = Math.floor(Math.random() * 9000 + 1000);
document.getElementById('seedInput').value = `${w}-${n}`;
regen();
}
function applyParams(obj) {
for (const [k, v] of Object.entries(obj)) {
if (k === 'seed') { document.getElementById('seedInput').value = v; continue; }
const sl = document.getElementById(k);
if (!sl) continue; // ignore derived-only keys (archetype, shockX…)
if (sl.type === 'checkbox') sl.checked = !!v;
else sl.value = v;
}
updateLabels();
regen();
}
function applyPreset(name) {
if (PRESETS[name]) applyParams(PRESETS[name]);
}
function deriveFromSeed() {
const seed = document.getElementById('seedInput').value || 'ENTROPY-001';
applyParams(paramsFromSeed(seed));
}
/* ---------- exports ---------- */
function showToast(msg) {
const t = document.getElementById('toast');
t.textContent = msg; t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 1800);
}
function download(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function exportSVG() {
if (!scene) regen();
download(new Blob([renderSVG(scene, params, 4800)], { type: 'image/svg+xml' }),
`bubble-chamber-${params.seed}.svg`);
showToast('SVG exported');
}
function exportPDF() {
if (!scene) regen();
download(new Blob([buildPDF(scene, params)], { type: 'application/pdf' }),
`bubble-chamber-${params.seed}.pdf`);
showToast('PDF exported');
}
function exportPNG() {
if (!scene) regen();
showToast('Rendering hi-res…');
setTimeout(() => {
const off = document.createElement('canvas');
off.width = off.height = EXPORT_PNG;
const octx = off.getContext('2d', { willReadFrequently: true });
renderCanvasPhoto(octx, EXPORT_PNG, EXPORT_PNG, scene, params, { preview: false });
off.toBlob(b => { download(b, `bubble-chamber-${params.seed}.png`); showToast('PNG exported'); }, 'image/png');
}, 50);
}
/* ---------- init ---------- */
function init() {
buildPanel();
wire();
updateLabels();
regen(); // exposure date & lab now derive deterministically from the seed
}
init();

411
src/render/canvasPhoto.js Normal file
View File

@@ -0,0 +1,411 @@
/* ============================================================
canvasPhoto.js — the photographic raster renderer.
A multi-pass compositor that turns the pure scene model into
something that reads as a worn bubble-chamber plate:
paper + gas-glow → tonal mottle → ink (soft blooming
bubbles that merge into lines) → halation → shock disk →
plate damage → vignette → film grain.
Geometry is identical to the vector renderer; only the look
differs. Same code path serves the live preview and the
high-resolution print render (effect radii scale with size).
============================================================ */
import { makeRng } from '../rng.js';
import { sampleBubbles, trackInkWeight } from '../scene/bubbles.js';
import { mottleCanvas, grainCanvas } from './noise.js';
const MARGIN = 0.02;
/* ---- palettes ---- */
function palette(inv) {
return inv
? { paperFlat: '#d3ccb8', glowIn: '#e4ddc9', glowOut: '#b3aa92',
ink: [28, 24, 19], inkBlend: 'multiply',
lift: 'screen', vign: [56, 46, 32] }
: { paperFlat: '#0e0d0b', glowIn: '#211e18', glowOut: '#070605',
ink: [233, 228, 214], inkBlend: 'screen',
lift: 'multiply', vign: [0, 0, 0] };
}
/* cached noise canvases (rebuilt only when seed/size change) */
const noiseCache = { key: '', mottleA: null, mottleB: null, grain: null, sprite: null, spriteKey: '' };
function softDotSprite(inkRGB) {
const key = inkRGB.join(',');
if (noiseCache.sprite && noiseCache.spriteKey === key) return noiseCache.sprite;
const S = 64;
const c = document.createElement('canvas');
c.width = c.height = S;
const g = c.getContext('2d');
const grad = g.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2);
const [r, gr, b] = inkRGB;
grad.addColorStop(0.0, `rgba(${r},${gr},${b},1)`);
grad.addColorStop(0.30, `rgba(${r},${gr},${b},0.92)`);
grad.addColorStop(0.62, `rgba(${r},${gr},${b},0.40)`);
grad.addColorStop(1.0, `rgba(${r},${gr},${b},0)`);
g.fillStyle = grad;
g.fillRect(0, 0, S, S);
noiseCache.sprite = c; noiseCache.spriteKey = key;
return c;
}
export function renderCanvasPhoto(ctx, w, h, scene, params, opts = {}) {
const pre = opts.preview !== false;
const inv = params.invert;
const P = palette(inv);
const scale = (w / 2) * (1 - MARGIN);
const cx = w / 2, cy = h / 2;
const tx = (x) => cx + x * scale;
const ty = (y) => cy + y * scale;
const u = w / 1000; // unit scale relative to a 1000px render
const blur = (px) => `blur(${(px * u).toFixed(2)}px)`;
// ---- noise cache key ----
const nKey = `${params.seed}|${w}`;
if (noiseCache.key !== nKey) {
noiseCache.key = nKey;
noiseCache.mottleA = mottleCanvas(params.seed, Math.min(512, w), 4, 4); // broad blotches
noiseCache.mottleB = mottleCanvas(params.seed + '#b', Math.min(512, w), 14, 4); // medium
noiseCache.grain = grainCanvas(params.seed, Math.max(256, Math.round(w / 2.2)), 1.15);
}
/* ---------- Pass 1: paper + gas glow ---------- */
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
ctx.fillStyle = P.paperFlat;
ctx.fillRect(0, 0, w, h);
const glow = ctx.createRadialGradient(w * 0.5, h * 0.42, 0, w * 0.5, h * 0.5, w * 0.72);
glow.addColorStop(0, P.glowIn);
glow.addColorStop(1, P.glowOut);
ctx.fillStyle = glow;
ctx.fillRect(0, 0, w, h);
/* ---------- Pass 2: tonal mottle (uneven development) ---------- */
if (params.mottle > 0) {
ctx.save();
ctx.globalCompositeOperation = inv ? 'multiply' : 'screen';
ctx.globalAlpha = params.mottle * 0.5;
ctx.drawImage(noiseCache.mottleA, 0, 0, w, h);
ctx.globalAlpha = params.mottle * 0.28;
ctx.drawImage(noiseCache.mottleB, 0, 0, w, h);
ctx.restore();
}
/* ---------- Pass 2.5: chamber optics / structural geometry ---------- */
if (scene.instrument) drawInstrument(ctx, scene.instrument, tx, ty, scale, u, P, inv);
/* ---------- Pass 3: ink layer (offscreen, transparent) ---------- */
const ink = document.createElement('canvas');
ink.width = w; ink.height = h;
const ic = ink.getContext('2d');
const [ir, ig, ib] = P.ink;
const sprite = softDotSprite(P.ink);
// NOTE: never set ic.filter here — a filter set before the bubble loop is
// re-applied to every single drawImage stamp (tens of thousands), which is
// catastrophically slow and scales with size. Softness is the sprite's job,
// plus one whole-layer blur at composite time (Pass 5).
// 3a. continuity under-stroke beneath each track
ic.lineCap = 'round'; ic.lineJoin = 'round';
for (const track of scene.tracks) {
if (track.pts.length < 2) continue;
const iw = trackInkWeight(track);
const wgt = track.weight;
const lw = Math.min(2.6, (0.25 + Math.sqrt(iw) * 0.12)) * u * params.size * wgt;
if (lw < 0.2 * u) continue;
ic.strokeStyle = `rgba(${ir},${ig},${ib},${0.14 * wgt})`;
ic.lineWidth = lw;
ic.beginPath();
ic.moveTo(tx(track.pts[0].x), ty(track.pts[0].y));
for (let i = 1; i < track.pts.length; i++) ic.lineTo(tx(track.pts[i].x), ty(track.pts[i].y));
ic.stroke();
}
// 3b. soft blooming bubbles (stamped sprite; overlaps merge into lines)
const bubbleRng = makeRng(params.seed, 'bubbles');
for (const track of scene.tracks) {
const bubs = sampleBubbles(track, params, bubbleRng);
ic.globalAlpha = Math.min(1, 0.45 + track.weight * 0.5);
for (const b of bubs) {
const rr = Math.max(b.r * scale, 0.45);
const d = rr * 2.4; // sprite footprint a touch larger than core
ic.drawImage(sprite, tx(b.x) - d / 2, ty(b.y) - d / 2, d, d);
}
}
ic.globalAlpha = 1;
// 3c. shock disk drawn into the ink layer so bloom catches it
if (scene.shock) drawShock(ic, scene.shock, tx, ty, scale, u, P);
/* ---------- Pass 4: halation / bloom ---------- */
// composite a blurred copy of the ink under the sharp ink for soft spread
if (params.bloom > 0) {
ctx.save();
ctx.globalCompositeOperation = P.inkBlend;
ctx.globalAlpha = Math.min(0.9, params.bloom * 0.7);
ctx.filter = blur(pre ? 2.4 : 3.0);
ctx.drawImage(ink, 0, 0);
ctx.filter = 'none';
ctx.restore();
}
/* ---------- Pass 5: composite sharp ink onto paper (one soft-focus blur) ---------- */
ctx.save();
ctx.globalCompositeOperation = P.inkBlend;
ctx.globalAlpha = 1;
ctx.filter = blur(pre ? 0.5 : 0.7); // single whole-layer blur, not per-stamp
ctx.drawImage(ink, 0, 0);
ctx.filter = 'none';
ctx.restore();
/* ---------- Pass 6: plate damage ---------- */
drawDamage(ctx, scene.artifacts, tx, ty, scale, u, P, inv);
/* ---------- Pass 7: fiducials, boundary & archival header ---------- */
drawFiducialsBoundary(ctx, w, h, params, scale, cx, cy, tx, ty, u, P, inv);
if (params.showHeader) drawHeader(ctx, w, h, scene, params, u, P);
/* ---------- Pass 8: vignette ---------- */
if (params.vign > 0) {
const vg = ctx.createRadialGradient(w / 2, h / 2, w * 0.28, w / 2, h / 2, w * 0.72);
const [vr, vgc, vb] = P.vign;
vg.addColorStop(0, `rgba(${vr},${vgc},${vb},0)`);
vg.addColorStop(1, `rgba(${vr},${vgc},${vb},${params.vign * (inv ? 0.5 : 0.85)})`);
ctx.fillStyle = vg;
ctx.fillRect(0, 0, w, h);
}
/* ---------- Pass 9: film grain ---------- */
if (params.grain > 0) {
ctx.save();
ctx.globalCompositeOperation = 'overlay';
ctx.globalAlpha = Math.min(0.85, params.grain * 0.7);
// tile the grain canvas across at ~1:1.6 so clumps are supra-pixel
const gsz = noiseCache.grain.width;
const tile = gsz * 1.6;
for (let gy = 0; gy < h; gy += tile) {
for (let gx = 0; gx < w; gx += tile) {
ctx.drawImage(noiseCache.grain, gx, gy, tile, tile);
}
}
ctx.restore();
}
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
}
/* ---- shock disk ---- */
function drawShock(c, shock, tx, ty, scale, u, P) {
const [r, g, b] = P.ink;
const px = tx(shock.x), py = ty(shock.y), R = shock.r * scale;
// disk body: dark annulus that keeps a lighter, detailed centre
const core = c.createRadialGradient(px, py, 0, px, py, R);
core.addColorStop(0.0, `rgba(${r},${g},${b},${0.06 * shock.intensity})`);
core.addColorStop(0.35, `rgba(${r},${g},${b},${0.18 * shock.intensity})`);
core.addColorStop(0.72, `rgba(${r},${g},${b},${0.38 * shock.intensity})`);
core.addColorStop(0.94, `rgba(${r},${g},${b},${0.34 * shock.intensity})`);
core.addColorStop(1, `rgba(${r},${g},${b},0)`);
c.fillStyle = core;
c.beginPath(); c.arc(px, py, R, 0, Math.PI * 2); c.fill();
// radial striations (the sunburst)
c.lineCap = 'round';
for (const s of shock.striations) {
const ix = px + Math.cos(s.a) * s.inner * scale;
const iy = py + Math.sin(s.a) * s.inner * scale;
const ox = px + Math.cos(s.a) * s.outer * scale;
const oy = py + Math.sin(s.a) * s.outer * scale;
const mx = (ix + ox) / 2 + Math.cos(s.a + Math.PI / 2) * s.wobble * scale;
const my = (iy + oy) / 2 + Math.sin(s.a + Math.PI / 2) * s.wobble * scale;
c.strokeStyle = `rgba(${r},${g},${b},${s.opacity})`;
c.lineWidth = s.width * u;
c.beginPath();
c.moveTo(ix, iy);
c.quadraticCurveTo(mx, my, ox, oy);
c.stroke();
}
// inner pressure-front rings
for (const ring of shock.rings) {
c.strokeStyle = `rgba(${r},${g},${b},${ring.opacity})`;
c.lineWidth = ring.width * u;
c.beginPath(); c.arc(px, py, ring.rr * scale, 0, Math.PI * 2); c.stroke();
}
// rim as eroded arc segments
if (shock.rimSegs) {
for (const seg of shock.rimSegs) {
c.strokeStyle = `rgba(${r},${g},${b},${seg.opacity})`;
c.lineWidth = seg.width * u;
c.beginPath(); c.arc(px, py, shock.r * scale, seg.a0, seg.a1); c.stroke();
}
}
// staining blotches: dark = grime, light = lifted/washed clean spots
if (shock.stains) {
for (const st of shock.stains) {
const sr = st.r * scale;
const grad = c.createRadialGradient(tx(st.x), ty(st.y), 0, tx(st.x), ty(st.y), sr);
if (st.dark) {
c.globalCompositeOperation = 'source-over';
grad.addColorStop(0, `rgba(${r},${g},${b},${st.opacity})`);
grad.addColorStop(1, `rgba(${r},${g},${b},0)`);
c.fillStyle = grad;
} else {
c.globalCompositeOperation = 'destination-out';
grad.addColorStop(0, `rgba(0,0,0,${st.opacity * 1.4})`);
grad.addColorStop(1, 'rgba(0,0,0,0)');
c.fillStyle = grad;
}
c.beginPath(); c.arc(tx(st.x), ty(st.y), sr, 0, Math.PI * 2); c.fill();
}
c.globalCompositeOperation = 'source-over';
}
// textured core chords
for (const k of shock.core) {
c.strokeStyle = `rgba(${r},${g},${b},${k.opacity})`;
c.lineWidth = k.width * u;
c.beginPath(); c.moveTo(tx(k.x1), ty(k.y1)); c.lineTo(tx(k.x2), ty(k.y2)); c.stroke();
}
// keep a bright, detailed centre by clearing a soft hole in the ink
if (shock.bright) {
c.save();
c.globalCompositeOperation = 'destination-out';
const hole = c.createRadialGradient(px, py, 0, px, py, shock.bright * scale);
hole.addColorStop(0, 'rgba(0,0,0,0.85)');
hole.addColorStop(1, 'rgba(0,0,0,0)');
c.fillStyle = hole;
c.beginPath(); c.arc(px, py, shock.bright * scale, 0, Math.PI * 2); c.fill();
c.restore();
}
}
/* ---- plate damage ---- */
function drawDamage(ctx, A, tx, ty, scale, u, P, inv) {
if (!A) return;
const [r, g, b] = P.ink;
ctx.save();
// water rings (faint, under)
ctx.globalCompositeOperation = inv ? 'multiply' : 'screen';
for (const ring of A.rings) {
ctx.strokeStyle = `rgba(${r},${g},${b},${ring.opacity})`;
ctx.lineWidth = ring.width * u;
ctx.beginPath(); ctx.arc(tx(ring.x), ty(ring.y), ring.r * scale, 0, Math.PI * 2); ctx.stroke();
}
// fingerprints
for (const fp of A.fingerprints) {
ctx.strokeStyle = `rgba(${r},${g},${b},${fp.opacity})`;
ctx.lineWidth = 0.7 * u;
for (const lp of fp.loops) {
ctx.save();
ctx.translate(tx(fp.x), ty(fp.y));
ctx.rotate(lp.rot);
ctx.scale(1, lp.squash);
ctx.beginPath(); ctx.arc(0, 0, lp.rr * scale, 0, Math.PI * 2); ctx.stroke();
ctx.restore();
}
}
// scratches: dark (multiply) and bright (lift)
for (const s of A.scratches) {
ctx.globalCompositeOperation = s.bright ? P.lift : (inv ? 'multiply' : 'screen');
const col = s.bright ? (inv ? '245,242,232' : '0,0,0') : `${r},${g},${b}`;
ctx.strokeStyle = `rgba(${col},${s.opacity})`;
ctx.lineWidth = s.width * u;
ctx.beginPath(); ctx.moveTo(tx(s.x1), ty(s.y1)); ctx.lineTo(tx(s.x2), ty(s.y2)); ctx.stroke();
}
// hairs
ctx.globalCompositeOperation = inv ? 'multiply' : 'screen';
for (const hair of A.hairs) {
ctx.strokeStyle = `rgba(${r},${g},${b},${hair.opacity})`;
ctx.lineWidth = hair.width * u;
ctx.beginPath();
ctx.moveTo(tx(hair.pts[0].x), ty(hair.pts[0].y));
for (let i = 1; i < hair.pts.length; i++) ctx.lineTo(tx(hair.pts[i].x), ty(hair.pts[i].y));
ctx.stroke();
}
// dust specks (over)
for (const sp of A.specks) {
ctx.fillStyle = `rgba(${r},${g},${b},${sp.opacity})`;
ctx.beginPath(); ctx.arc(tx(sp.x), ty(sp.y), Math.max(sp.r * scale, 0.4), 0, Math.PI * 2); ctx.fill();
}
ctx.restore();
}
/* ---- chamber optics / structural geometry ---- */
function drawInstrument(ctx, inst, tx, ty, scale, u, P, inv) {
const [r, g, b] = P.ink;
ctx.save();
ctx.globalCompositeOperation = inv ? 'multiply' : 'screen';
ctx.lineCap = 'round';
ctx.filter = `blur(${(0.4 * u).toFixed(2)}px)`;
for (const l of inst.lines) {
ctx.strokeStyle = `rgba(${r},${g},${b},${l.opacity})`;
ctx.lineWidth = l.width * u;
ctx.beginPath(); ctx.moveTo(tx(l.x1), ty(l.y1)); ctx.lineTo(tx(l.x2), ty(l.y2)); ctx.stroke();
}
for (const a of inst.arcs) {
ctx.strokeStyle = `rgba(${r},${g},${b},${a.opacity})`;
ctx.lineWidth = a.width * u;
ctx.beginPath();
ctx.moveTo(tx(a.pts[0].x), ty(a.pts[0].y));
for (let i = 1; i < a.pts.length; i++) ctx.lineTo(tx(a.pts[i].x), ty(a.pts[i].y));
ctx.stroke();
}
ctx.restore();
}
/* ---- archival header (rendered text, baked into the plate) ---- */
function drawHeader(ctx, w, h, scene, params, u, P) {
const [r, g, b] = P.ink;
ctx.save();
ctx.globalCompositeOperation = params.invert ? 'multiply' : 'screen';
ctx.fillStyle = `rgba(${r},${g},${b},0.62)`;
const pad = 26 * u;
ctx.font = `${11 * u}px 'JetBrains Mono', ui-monospace, monospace`;
ctx.textBaseline = 'top';
ctx.fillText(scene.lab.toUpperCase(), pad, pad);
ctx.font = `${9 * u}px 'JetBrains Mono', ui-monospace, monospace`;
ctx.fillStyle = `rgba(${r},${g},${b},0.5)`;
ctx.fillText(`SEED ${params.seed}`, pad, pad + 16 * u);
ctx.textAlign = 'right';
ctx.textBaseline = 'bottom';
ctx.font = `${10 * u}px 'JetBrains Mono', ui-monospace, monospace`;
ctx.fillStyle = `rgba(${r},${g},${b},0.58)`;
ctx.fillText(`PLATE ${scene.plate}`, w - pad, h - pad - 13 * u);
ctx.fillText(`EXPOSED ${scene.exposure}`, w - pad, h - pad);
ctx.restore();
}
/* ---- fiducials & chamber boundary ---- */
function drawFiducialsBoundary(ctx, w, h, params, scale, cx, cy, tx, ty, u, P, inv) {
const [r, g, b] = P.ink;
if (params.showBoundary) {
ctx.strokeStyle = `rgba(${r},${g},${b},0.4)`;
ctx.lineWidth = 1.4 * u;
ctx.beginPath();
ctx.arc(cx, cy + h * 0.35, w * 0.45, Math.PI * 0.15, Math.PI - Math.PI * 0.15, false);
ctx.stroke();
}
if (params.showFiducials) {
ctx.strokeStyle = `rgba(${r},${g},${b},0.55)`;
ctx.lineWidth = 1 * u;
const fids = [[-0.85, -0.85], [0.85, -0.85], [-0.85, 0.85], [0.85, 0.85], [0, -0.85], [-0.85, 0], [0.85, 0]];
const s = 6 * u;
for (const [fx, fy] of fids) {
const px = tx(fx), py = ty(fy);
ctx.beginPath();
ctx.moveTo(px - s, py); ctx.lineTo(px + s, py);
ctx.moveTo(px, py - s); ctx.lineTo(px, py + s);
ctx.stroke();
}
}
}

93
src/render/noise.js Normal file
View File

@@ -0,0 +1,93 @@
/* ============================================================
noise.js — value noise / fbm helpers for the analog layer.
Used for film grain structure and low-frequency tonal mottle
(uneven gas glow & development stains).
============================================================ */
import { mulberry32, cyrb53 } from '../rng.js';
/* Hash-based value noise on an integer lattice, smoothstep-interpolated. */
function lattice(seedInt) {
const cache = new Map();
return (ix, iy) => {
const key = ix * 73856093 ^ iy * 19349663;
let v = cache.get(key);
if (v === undefined) {
let h = (seedInt ^ Math.imul(ix, 374761393) ^ Math.imul(iy, 668265263)) >>> 0;
h = Math.imul(h ^ (h >>> 13), 1274126177) >>> 0;
v = (h >>> 0) / 4294967296;
cache.set(key, v);
}
return v;
};
}
const smooth = (t) => t * t * (3 - 2 * t);
function valueNoise2D(lat, x, y) {
const x0 = Math.floor(x), y0 = Math.floor(y);
const fx = smooth(x - x0), fy = smooth(y - y0);
const v00 = lat(x0, y0), v10 = lat(x0 + 1, y0);
const v01 = lat(x0, y0 + 1), v11 = lat(x0 + 1, y0 + 1);
const a = v00 + (v10 - v00) * fx;
const b = v01 + (v11 - v01) * fx;
return a + (b - a) * fy;
}
/* Fractal (fbm) value noise, octaves of valueNoise2D. Returns [0,1]. */
export function fbm(lat, x, y, octaves = 4, lacunarity = 2, gain = 0.5) {
let amp = 1, freq = 1, sum = 0, norm = 0;
for (let o = 0; o < octaves; o++) {
sum += amp * valueNoise2D(lat, x * freq, y * freq);
norm += amp;
amp *= gain;
freq *= lacunarity;
}
return sum / norm;
}
/* Build a low-frequency tonal mottle canvas (uneven illumination + stains).
`cells` ≈ how many noise cells across the image (low = broad blotches). */
export function mottleCanvas(seedStr, size, cells = 5, octaves = 4) {
const c = document.createElement('canvas');
c.width = c.height = size;
const cx = c.getContext('2d');
const img = cx.createImageData(size, size);
const d = img.data;
const lat = lattice(parseInt(cyrb53(seedStr + '::mottle').slice(0, 8), 16));
const s = cells / size;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
let n = fbm(lat, x * s, y * s, octaves, 2.1, 0.55);
// bias toward mid, widen contrast a touch
n = Math.min(1, Math.max(0, (n - 0.5) * 1.6 + 0.5));
const v = Math.round(n * 255);
const i = (y * size + x) * 4;
d[i] = d[i + 1] = d[i + 2] = v;
d[i + 3] = 255;
}
}
cx.putImageData(img, 0, 0);
return c;
}
/* Build a film-grain tile. Grain is generated at `grainSize` then meant to be
drawn upscaled, giving clumps larger than one device pixel (real emulsion
grain is not per-pixel). Returns an offscreen canvas of grain in alpha. */
export function grainCanvas(seedStr, grainSize, contrast = 1) {
const c = document.createElement('canvas');
c.width = c.height = grainSize;
const cx = c.getContext('2d');
const img = cx.createImageData(grainSize, grainSize);
const d = img.data;
const rng = mulberry32(parseInt(cyrb53(seedStr + '::grain').slice(0, 8), 16));
for (let i = 0; i < d.length; i += 4) {
// signed grain centred on 0.5, gaussian-ish via two uniforms
let g = (rng() + rng() - 1) * 0.5 + 0.5;
g = Math.min(1, Math.max(0, (g - 0.5) * contrast + 0.5));
const v = Math.round(g * 255);
d[i] = d[i + 1] = d[i + 2] = v;
d[i + 3] = 255;
}
cx.putImageData(img, 0, 0);
return c;
}

197
src/render/pdf.js Normal file
View File

@@ -0,0 +1,197 @@
/* ============================================================
pdf.js — single-page vector PDF writer for the print shop.
Native PDF drawing ops (no embedded raster). Uses:
• DeviceCMYK colour with a controllable rich black, so a
print shop gets proper separations rather than RGB;
• ExtGState soft-mask alpha (/ca,/CA) for true opacity on
strokes and fills;
• a base-14 Helvetica archival header (no font embedding).
Geometry comes from the same scene model as every renderer.
============================================================ */
import { makeRng } from '../rng.js';
import { sampleBubbles, trackInkWeight } from '../scene/bubbles.js';
const MARGIN = 0.02;
export function buildPDF(scene, params, pageSize = 1728) {
const scale = (pageSize / 2) * (1 - MARGIN);
const cx = pageSize / 2, cy = pageSize / 2;
const tx = (x) => (cx + x * scale);
const ty = (y) => (cy - y * scale); // PDF y-up
const u = pageSize / 1000;
const inv = params.invert;
// CMYK colours: warm cream paper, warm rich black ink (or swapped)
const paperCMYK = inv ? '0.04 0.06 0.16 0.00' : '0.30 0.30 0.32 1.00';
const inkCMYK = inv ? '0.30 0.30 0.32 0.98' : '0.03 0.05 0.14 0.00';
// ExtGState alpha registry
const gsSet = new Map();
const gs = (alpha) => {
const k = Math.max(0, Math.min(100, Math.round(alpha * 100)));
if (!gsSet.has(k)) gsSet.set(k, `GS${k}`);
return `/GS${k} gs\n`;
};
let c = '';
c += `${paperCMYK} k\n0 0 ${pageSize} ${pageSize} re f\n`;
// chamber boundary
if (params.showBoundary) {
c += `q\n${gs(0.4)}${inkCMYK} K\n${(2 * u).toFixed(2)} w\n`;
const bcx = cx, bcy = cy - pageSize * 0.35, br = pageSize * 0.45;
const a1 = Math.PI * 0.15, a2 = Math.PI - Math.PI * 0.15, steps = 90;
for (let i = 0; i <= steps; i++) {
const a = Math.PI + a1 + (a2 - a1) * (i / steps);
const px = bcx + Math.cos(a) * br, py = bcy - Math.sin(a) * br;
c += `${px.toFixed(1)} ${py.toFixed(1)} ${i === 0 ? 'm' : 'l'}\n`;
}
c += `S\nQ\n`;
}
// instrument geometry
if (scene.instrument) {
c += `q\n${inkCMYK} K\n1 J\n`;
for (const l of scene.instrument.lines)
c += `${gs(l.opacity)}${(l.width * u).toFixed(2)} w\n${tx(l.x1).toFixed(1)} ${ty(l.y1).toFixed(1)} m ${tx(l.x2).toFixed(1)} ${ty(l.y2).toFixed(1)} l S\n`;
for (const a of scene.instrument.arcs) {
c += `${gs(a.opacity)}${(a.width * u).toFixed(2)} w\n${tx(a.pts[0].x).toFixed(1)} ${ty(a.pts[0].y).toFixed(1)} m\n`;
for (let i = 1; i < a.pts.length; i++) c += `${tx(a.pts[i].x).toFixed(1)} ${ty(a.pts[i].y).toFixed(1)} l\n`;
c += `S\n`;
}
c += `Q\n`;
}
// continuity under-strokes (bucketed by alpha)
c += `q\n${inkCMYK} K\n1 J\n1 j\n`;
for (const t of scene.tracks) {
if (t.pts.length < 2) continue;
const iw = trackInkWeight(t);
const lw = Math.min(2.6, 0.25 + Math.sqrt(iw) * 0.12) * u * params.size * t.weight;
if (lw < 0.2 * u) continue;
c += `${gs(0.14 * t.weight)}${lw.toFixed(2)} w\n`;
c += `${tx(t.pts[0].x).toFixed(2)} ${ty(t.pts[0].y).toFixed(2)} m\n`;
for (let i = 1; i < t.pts.length; i++) c += `${tx(t.pts[i].x).toFixed(2)} ${ty(t.pts[i].y).toFixed(2)} l\n`;
c += `S\n`;
}
c += `Q\n`;
// shock
if (scene.shock) {
const sh = scene.shock;
const px = tx(sh.x), py = ty(sh.y);
c += `q\n${inkCMYK} K\n1 J\n`;
for (const st of sh.striations) {
const ix = px + Math.cos(st.a) * st.inner * scale, iy = py - Math.sin(st.a) * st.inner * scale;
const ox = px + Math.cos(st.a) * st.outer * scale, oy = py - Math.sin(st.a) * st.outer * scale;
c += `${gs(st.opacity)}${(st.width * u).toFixed(2)} w\n${ix.toFixed(1)} ${iy.toFixed(1)} m ${ox.toFixed(1)} ${oy.toFixed(1)} l S\n`;
}
for (const k of sh.core) {
c += `${gs(k.opacity)}${(k.width * u).toFixed(2)} w\n${tx(k.x1).toFixed(1)} ${ty(k.y1).toFixed(1)} m ${tx(k.x2).toFixed(1)} ${ty(k.y2).toFixed(1)} l S\n`;
}
for (const ring of sh.rings) c += `${gs(ring.opacity)}${(ring.width * u).toFixed(2)} w\n` + strokeCircle(px, py, ring.rr * scale);
c += `Q\n`;
}
// bubbles (bucketed by alpha)
const buckets = new Map();
const bRng = makeRng(params.seed, 'bubbles');
for (const t of scene.tracks) {
const key = Math.round(Math.min(1, 0.45 + t.weight * 0.5) * 20) / 20;
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key).push(...sampleBubbles(t, params, bRng));
}
c += `q\n${inkCMYK} k\n`;
for (const [alpha, bubs] of buckets) {
c += gs(alpha);
for (const b of bubs) c += circlePath(tx(b.x), ty(b.y), Math.max(b.r * scale, 0.5)) + 'f\n';
}
c += `Q\n`;
// plate damage
const A = scene.artifacts;
if (A) {
c += `q\n${inkCMYK} K\n1 J\n`;
for (const sc of A.scratches) c += `${gs(sc.opacity)}${(sc.width * u).toFixed(2)} w\n${tx(sc.x1).toFixed(1)} ${ty(sc.y1).toFixed(1)} m ${tx(sc.x2).toFixed(1)} ${ty(sc.y2).toFixed(1)} l S\n`;
for (const hair of A.hairs) {
c += `${gs(hair.opacity)}${(hair.width * u).toFixed(2)} w\n${tx(hair.pts[0].x).toFixed(1)} ${ty(hair.pts[0].y).toFixed(1)} m\n`;
for (let i = 1; i < hair.pts.length; i++) c += `${tx(hair.pts[i].x).toFixed(1)} ${ty(hair.pts[i].y).toFixed(1)} l\n`;
c += `S\n`;
}
for (const ring of A.rings) c += `${gs(ring.opacity)}${(ring.width * u).toFixed(2)} w\n` + strokeCircle(tx(ring.x), ty(ring.y), ring.r * scale);
c += `Q\nq\n${inkCMYK} k\n`;
for (const sp of A.specks) c += gs(sp.opacity) + circlePath(tx(sp.x), ty(sp.y), Math.max(sp.r * scale, 0.5)) + 'f\n';
c += `Q\n`;
}
// fiducials
if (params.showFiducials) {
c += `q\n${gs(0.55)}${inkCMYK} K\n${(1.2 * u).toFixed(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]];
const s = 8 * u;
for (const [fx, fy] of fids) {
const px = tx(fx), py = ty(fy);
c += `${px - s} ${py} m ${px + s} ${py} l S\n${px} ${py - s} m ${px} ${py + s} l S\n`;
}
c += `Q\n`;
}
// archival header (Helvetica)
if (params.showHeader) {
const pad = 26 * u;
c += `q\n${gs(0.6)}${inkCMYK} k\nBT\n/F0 ${(11 * u).toFixed(1)} Tf\n1 0 0 1 ${pad.toFixed(1)} ${(pageSize - pad - 11 * u).toFixed(1)} Tm (${pdfStr(scene.lab.toUpperCase())}) Tj\nET\n`;
c += `BT\n/F0 ${(9 * u).toFixed(1)} Tf\n1 0 0 1 ${pad.toFixed(1)} ${(pageSize - pad - 27 * u).toFixed(1)} Tm (SEED ${pdfStr(params.seed)}) Tj\nET\n`;
const fs = 10 * u;
const rt = (txt, yoff) => {
const wEst = txt.length * fs * 0.5;
c += `BT\n/F0 ${fs.toFixed(1)} Tf\n1 0 0 1 ${(pageSize - pad - wEst).toFixed(1)} ${yoff.toFixed(1)} Tm (${pdfStr(txt)}) Tj\nET\n`;
};
rt(`PLATE ${scene.plate}`, pad + 13 * u);
rt(`EXPOSED ${scene.exposure}`, pad);
c += `Q\n`;
}
// ExtGState dict
let extg = '';
for (const [k, name] of gsSet) extg += `/${name} << /ca ${(k / 100).toFixed(2)} /CA ${(k / 100).toFixed(2)} >> `;
return assemblePDF(c, pageSize, extg);
}
function circlePath(px, py, r) {
const k = 0.5522847498 * r;
return `${(px - r).toFixed(2)} ${py.toFixed(2)} m\n` +
`${(px - r).toFixed(2)} ${(py + k).toFixed(2)} ${(px - k).toFixed(2)} ${(py + r).toFixed(2)} ${px.toFixed(2)} ${(py + r).toFixed(2)} c\n` +
`${(px + k).toFixed(2)} ${(py + r).toFixed(2)} ${(px + r).toFixed(2)} ${(py + k).toFixed(2)} ${(px + r).toFixed(2)} ${py.toFixed(2)} c\n` +
`${(px + r).toFixed(2)} ${(py - k).toFixed(2)} ${(px + k).toFixed(2)} ${(py - r).toFixed(2)} ${px.toFixed(2)} ${(py - r).toFixed(2)} c\n` +
`${(px - k).toFixed(2)} ${(py - r).toFixed(2)} ${(px - r).toFixed(2)} ${(py - k).toFixed(2)} ${(px - r).toFixed(2)} ${py.toFixed(2)} c\n`;
}
function strokeCircle(px, py, r) {
const k = 0.5522847498 * r;
return `${(px - r).toFixed(2)} ${py.toFixed(2)} m ` +
`${(px - r).toFixed(2)} ${(py + k).toFixed(2)} ${(px - k).toFixed(2)} ${(py + r).toFixed(2)} ${px.toFixed(2)} ${(py + r).toFixed(2)} c ` +
`${(px + k).toFixed(2)} ${(py + r).toFixed(2)} ${(px + r).toFixed(2)} ${(py + k).toFixed(2)} ${(px + r).toFixed(2)} ${py.toFixed(2)} c ` +
`${(px + r).toFixed(2)} ${(py - k).toFixed(2)} ${(px + k).toFixed(2)} ${(py - r).toFixed(2)} ${px.toFixed(2)} ${(py - r).toFixed(2)} c ` +
`${(px - k).toFixed(2)} ${(py - r).toFixed(2)} ${(px - r).toFixed(2)} ${(py - k).toFixed(2)} ${(px - r).toFixed(2)} ${py.toFixed(2)} c S\n`;
}
function pdfStr(s) { return String(s).replace(/([()\\])/g, '\\$1'); }
function assemblePDF(content, pageSize, extg) {
const enc = new TextEncoder();
const contentBytes = enc.encode(content);
let body = `%PDF-1.4\n%\xC3\xA0\xC3\xA1\xC3\xA2\xC3\xA3\n`;
const offsets = [];
const addObj = (s) => { offsets.push(body.length); body += s; };
const resources = `<< /ExtGState << ${extg} >> /Font << /F0 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >>`;
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 ${resources} >>\nendobj\n`);
addObj(`4 0 obj\n<< /Length ${contentBytes.length} >>\nstream\n${content}\nendstream\nendobj\n`);
const xref = 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${xref}\n%%EOF\n`;
return new Uint8Array(enc.encode(body));
}

194
src/render/svgVector.js Normal file
View File

@@ -0,0 +1,194 @@
/* ============================================================
svgVector.js — clean vector renderer for print.
Same scene model as the photographic renderer, emitted as
resolution-independent SVG. Soft bubbles are approximated
with a shared radial-gradient fill; tracks get a faint
continuity under-stroke. Grain/mottle are intentionally
omitted (raster-only effects); this is the graphic version.
============================================================ */
import { makeRng, cyrb53 } from '../rng.js';
import { sampleBubbles, trackInkWeight } from '../scene/bubbles.js';
const MARGIN = 0.02;
export function renderSVG(scene, params, sizePx = 4800) {
const w = sizePx, h = sizePx;
const scale = (w / 2) * (1 - MARGIN);
const cx = w / 2, cy = h / 2;
const tx = (x) => (cx + x * scale).toFixed(2);
const ty = (y) => (cy + y * scale).toFixed(2);
const u = w / 1000;
const inv = params.invert;
const paper = inv ? '#cfc8b4' : '#0e0d0b';
const glowIn = inv ? '#e2dbc7' : '#211e18';
const glowOut = inv ? '#b3aa92' : '#070605';
const ink = inv ? '#1c1814' : '#e9e4d6';
const inkRGB = inv ? '28,24,20' : '233,228,214';
let s = `<?xml version="1.0" encoding="UTF-8"?>\n`;
s += `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">\n`;
s += `<metadata>Bubble Chamber · seed=${params.seed} · hash=${cyrb53(params.seed)}</metadata>\n`;
s += `<defs>
<radialGradient id="paper" cx="50%" cy="42%" r="72%">
<stop offset="0%" stop-color="${glowIn}"/><stop offset="100%" stop-color="${glowOut}"/>
</radialGradient>
<radialGradient id="bub" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="${ink}" stop-opacity="1"/>
<stop offset="55%" stop-color="${ink}" stop-opacity="0.92"/>
<stop offset="100%" stop-color="${ink}" stop-opacity="0"/>
</radialGradient>
<radialGradient id="shockcore" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="${ink}" stop-opacity="0.5"/>
<stop offset="60%" stop-color="${ink}" stop-opacity="0.28"/>
<stop offset="100%" stop-color="${ink}" stop-opacity="0"/>
</radialGradient>
<radialGradient id="shockstain" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="${ink}" stop-opacity="0.9"/>
<stop offset="100%" stop-color="${ink}" stop-opacity="0"/>
</radialGradient>
<radialGradient id="shockclean" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="${paper}" stop-opacity="0.9"/>
<stop offset="100%" stop-color="${paper}" stop-opacity="0"/>
</radialGradient>
<radialGradient id="vign" cx="50%" cy="50%" r="72%">
<stop offset="30%" stop-color="${inv ? '#382e1e' : '#000'}" stop-opacity="0"/>
<stop offset="100%" stop-color="${inv ? '#382e1e' : '#000'}" stop-opacity="${(inv ? 0.5 : 0.85) * params.vign}"/>
</radialGradient>
</defs>\n`;
s += `<rect width="${w}" height="${h}" fill="${paper}"/>\n`;
s += `<rect width="${w}" height="${h}" fill="url(#paper)"/>\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, y1 = bcy + Math.sin(Math.PI + a1) * br;
const x2 = bcx + Math.cos(Math.PI + a2) * br, y2 = bcy + Math.sin(Math.PI + a2) * br;
s += `<path d="M ${x1.toFixed(1)} ${y1.toFixed(1)} A ${br} ${br} 0 0 1 ${x2.toFixed(1)} ${y2.toFixed(1)}" fill="none" stroke="${ink}" stroke-opacity="0.4" stroke-width="${2 * u}"/>\n`;
}
// chamber optics / structural geometry
if (scene.instrument) {
const inst = scene.instrument;
s += `<g fill="none" stroke="${ink}" stroke-linecap="round">\n`;
for (const l of inst.lines)
s += `<line x1="${tx(l.x1)}" y1="${ty(l.y1)}" x2="${tx(l.x2)}" y2="${ty(l.y2)}" stroke-opacity="${l.opacity.toFixed(3)}" stroke-width="${(l.width * u).toFixed(2)}"/>`;
for (const a of inst.arcs) {
let d = `M ${tx(a.pts[0].x)} ${ty(a.pts[0].y)}`;
for (let i = 1; i < a.pts.length; i++) d += ` L ${tx(a.pts[i].x)} ${ty(a.pts[i].y)}`;
s += `<path d="${d}" stroke-opacity="${a.opacity.toFixed(3)}" stroke-width="${(a.width * u).toFixed(2)}"/>`;
}
s += `\n</g>\n`;
}
// continuity under-strokes
s += `<g fill="none" stroke="${ink}" stroke-linecap="round" stroke-linejoin="round">\n`;
for (const track of scene.tracks) {
if (track.pts.length < 2) continue;
const iw = trackInkWeight(track);
const lw = Math.min(2.6, 0.25 + Math.sqrt(iw) * 0.12) * u * params.size * track.weight;
if (lw < 0.2 * u) continue;
let d = `M ${tx(track.pts[0].x)} ${ty(track.pts[0].y)}`;
for (let i = 1; i < track.pts.length; i++) d += ` L ${tx(track.pts[i].x)} ${ty(track.pts[i].y)}`;
s += `<path d="${d}" stroke-opacity="${(0.14 * track.weight).toFixed(3)}" stroke-width="${lw.toFixed(2)}"/>`;
}
s += `\n</g>\n`;
// shock disk
if (scene.shock) {
const sh = scene.shock;
const px = +tx(sh.x), py = +ty(sh.y), R = sh.r * scale;
s += `<circle cx="${px}" cy="${py}" r="${R.toFixed(1)}" fill="url(#shockcore)"/>\n`;
s += `<g stroke="${ink}" stroke-linecap="round" fill="none">\n`;
for (const st of sh.striations) {
const ix = px + Math.cos(st.a) * st.inner * scale, iy = py + Math.sin(st.a) * st.inner * scale;
const ox = px + Math.cos(st.a) * st.outer * scale, oy = py + Math.sin(st.a) * st.outer * scale;
s += `<line x1="${ix.toFixed(1)}" y1="${iy.toFixed(1)}" x2="${ox.toFixed(1)}" y2="${oy.toFixed(1)}" stroke-opacity="${st.opacity.toFixed(3)}" stroke-width="${(st.width * u).toFixed(2)}"/>`;
}
for (const ring of sh.rings) {
s += `<circle cx="${px}" cy="${py}" r="${(ring.rr * scale).toFixed(1)}" stroke-opacity="${ring.opacity.toFixed(3)}" stroke-width="${(ring.width * u).toFixed(2)}"/>`;
}
// eroded rim segments
for (const seg of (sh.rimSegs || [])) {
const x0 = px + Math.cos(seg.a0) * R, y0 = py + Math.sin(seg.a0) * R;
const x1a = px + Math.cos(seg.a1) * R, y1a = py + Math.sin(seg.a1) * R;
s += `<path d="M ${x0.toFixed(1)} ${y0.toFixed(1)} A ${R.toFixed(1)} ${R.toFixed(1)} 0 0 1 ${x1a.toFixed(1)} ${y1a.toFixed(1)}" stroke-opacity="${seg.opacity.toFixed(3)}" stroke-width="${(seg.width * u).toFixed(2)}"/>`;
}
for (const k of sh.core) {
s += `<line x1="${tx(k.x1)}" y1="${ty(k.y1)}" x2="${tx(k.x2)}" y2="${ty(k.y2)}" stroke-opacity="${k.opacity.toFixed(3)}" stroke-width="${(k.width * u).toFixed(2)}"/>`;
}
s += `\n</g>\n`;
// staining blotches (dark grime / light washed spots)
for (const st of (sh.stains || [])) {
const sr = (st.r * scale).toFixed(1);
s += `<circle cx="${tx(st.x)}" cy="${ty(st.y)}" r="${sr}" fill="url(#${st.dark ? 'shockstain' : 'shockclean'})" fill-opacity="${st.opacity.toFixed(3)}"/>`;
}
s += `\n`;
}
// bubbles, bucketed by opacity
const buckets = new Map();
const bubbleRng = makeRng(params.seed, 'bubbles');
for (const track of scene.tracks) {
const key = Math.round(Math.min(1, 0.45 + track.weight * 0.5) * 20) / 20;
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key).push(...sampleBubbles(track, params, bubbleRng));
}
for (const [alpha, bubs] of buckets) {
s += `<g fill="url(#bub)" fill-opacity="${alpha}">`;
for (const b of bubs) {
const r = Math.max(b.r * scale * 1.15, 0.5);
s += `<circle cx="${tx(b.x)}" cy="${ty(b.y)}" r="${r.toFixed(2)}"/>`;
}
s += `</g>\n`;
}
// plate damage
const A = scene.artifacts;
if (A) {
s += `<g stroke="${ink}" fill="none" stroke-linecap="round">\n`;
for (const ring of A.rings)
s += `<circle cx="${tx(ring.x)}" cy="${ty(ring.y)}" r="${(ring.r * scale).toFixed(1)}" stroke-opacity="${ring.opacity.toFixed(3)}" stroke-width="${(ring.width * u).toFixed(2)}"/>`;
for (const sc of A.scratches)
s += `<line x1="${tx(sc.x1)}" y1="${ty(sc.y1)}" x2="${tx(sc.x2)}" y2="${ty(sc.y2)}" stroke-opacity="${sc.opacity.toFixed(3)}" stroke-width="${(sc.width * u).toFixed(2)}"/>`;
for (const hair of A.hairs) {
let d = `M ${tx(hair.pts[0].x)} ${ty(hair.pts[0].y)}`;
for (let i = 1; i < hair.pts.length; i++) d += ` L ${tx(hair.pts[i].x)} ${ty(hair.pts[i].y)}`;
s += `<path d="${d}" stroke-opacity="${hair.opacity.toFixed(3)}" stroke-width="${(hair.width * u).toFixed(2)}"/>`;
}
s += `\n</g>\n`;
s += `<g fill="${ink}">`;
for (const sp of A.specks)
s += `<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)}"/>`;
s += `</g>\n`;
}
// fiducials
if (params.showFiducials) {
s += `<g stroke="${ink}" stroke-opacity="0.55" stroke-width="${1.2 * u}">\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]];
const sz = 9 * u;
for (const [fx, fy] of fids) {
const px = +tx(fx), py = +ty(fy);
s += `<line x1="${px - sz}" y1="${py}" x2="${px + sz}" y2="${py}"/><line x1="${px}" y1="${py - sz}" x2="${px}" y2="${py + sz}"/>`;
}
s += `\n</g>\n`;
}
if (params.vign > 0) s += `<rect width="${w}" height="${h}" fill="url(#vign)"/>\n`;
// archival header
if (params.showHeader) {
const pad = 26 * u;
const esc = (t) => String(t).replace(/[<&]/g, c => (c === '<' ? '&lt;' : '&amp;'));
s += `<g fill="${ink}" font-family="'JetBrains Mono', monospace">\n`;
s += `<text x="${pad.toFixed(0)}" y="${(pad + 11 * u).toFixed(0)}" font-size="${(11 * u).toFixed(0)}" fill-opacity="0.62">${esc(scene.lab.toUpperCase())}</text>`;
s += `<text x="${pad.toFixed(0)}" y="${(pad + 27 * u).toFixed(0)}" font-size="${(9 * u).toFixed(0)}" fill-opacity="0.5">SEED ${esc(params.seed)}</text>`;
s += `<text x="${(w - pad).toFixed(0)}" y="${(h - pad - 13 * u).toFixed(0)}" font-size="${(10 * u).toFixed(0)}" text-anchor="end" fill-opacity="0.58">PLATE ${scene.plate}</text>`;
s += `<text x="${(w - pad).toFixed(0)}" y="${(h - pad).toFixed(0)}" font-size="${(10 * u).toFixed(0)}" text-anchor="end" fill-opacity="0.58">EXPOSED ${scene.exposure}</text>`;
s += `\n</g>\n`;
}
s += `</svg>\n`;
return s;
}

70
src/rng.js Normal file
View File

@@ -0,0 +1,70 @@
/* ============================================================
rng.js — deterministic seeding & sampling
Every stochastic decision in the generator draws from a
salted sub-stream so that changing one parameter does not
reshuffle unrelated parts of the scene.
============================================================ */
/* cyrb53 — fast 53-bit string hash. Stable across runs/machines. */
export function cyrb53(str, seed = 0) {
let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed;
for (let i = 0; i < str.length; i++) {
const ch = str.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16).padStart(13, '0');
}
/* mulberry32 — tiny fast PRNG, returns [0,1). */
export function mulberry32(seed) {
let a = seed >>> 0;
return function () {
a = (a + 0x6D2B79F5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/* makeRng — seed string + salt → independent PRNG stream. */
export function makeRng(seedStr, salt = '') {
const h = cyrb53(seedStr + '::' + salt);
return mulberry32(parseInt(h.slice(0, 8), 16));
}
/* ---------- Distributions ---------- */
/* Standard normal via Box-Muller. */
export function gauss(rng) {
const u = 1 - rng(), v = rng();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}
/* Log-normal — heavy-tailed positive sample. */
export function logNormal(rng, mu, sigma) {
return Math.exp(mu + sigma * gauss(rng));
}
/* Uniform in [lo, hi). */
export function range(rng, lo, hi) {
return lo + (hi - lo) * rng();
}
/* Random int in [lo, hi]. */
export function randInt(rng, lo, hi) {
return lo + Math.floor(rng() * (hi - lo + 1));
}
/* Pick one element. */
export function pick(rng, arr) {
return arr[Math.floor(rng() * arr.length)];
}
/* Bernoulli. */
export function chance(rng, p) {
return rng() < p;
}

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];
}

71
src/style.css Normal file
View File

@@ -0,0 +1,71 @@
:root {
--bg: #0a0a0a; --panel: #141414; --panel-2: #1c1c1c; --line: #2a2a2a;
--ink: #e8e4d8; --ink-dim: #8a8578; --ink-mute: #555049;
--accent: #d4a574; --warn: #c87a4a;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
body {
background: var(--bg); color: var(--ink);
font-family: 'JetBrains Mono', monospace; font-size: 12px;
display: grid; grid-template-columns: 330px 1fr; height: 100vh;
}
/* ---------- Sidebar ---------- */
aside { background: var(--panel); border-right: 1px solid var(--line); overflow-y: auto; padding: 20px 18px; }
aside::-webkit-scrollbar { width: 6px; }
aside::-webkit-scrollbar-thumb { background: var(--line); }
.brand { font-family: 'Cormorant Garamond', serif; font-style: italic; font-size: 22px; letter-spacing: 0.02em; margin-bottom: 2px; }
.subtitle { font-size: 10px; text-transform: uppercase; letter-spacing: 0.2em; color: var(--ink-mute); margin-bottom: 22px; }
.group { border-top: 1px solid var(--line); padding: 16px 0 8px; }
.group-title { font-size: 9px; text-transform: uppercase; letter-spacing: 0.25em; color: var(--accent); margin-bottom: 14px; }
.row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; gap: 10px; }
.row label { font-size: 11px; color: var(--ink-dim); flex: 1; }
.row .val { font-size: 11px; color: var(--ink); min-width: 50px; text-align: right; font-variant-numeric: tabular-nums; }
input[type=range] { width: 100%; -webkit-appearance: none; appearance: none; background: transparent; height: 18px; margin-bottom: 6px; }
input[type=range]::-webkit-slider-runnable-track { height: 1px; background: var(--line); }
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; height: 10px; width: 10px; background: var(--ink); border-radius: 0; margin-top: -4px; cursor: ew-resize; transition: background 0.15s; }
input[type=range]:hover::-webkit-slider-thumb { background: var(--accent); }
input[type=range]::-moz-range-track { height: 1px; background: var(--line); }
input[type=range]::-moz-range-thumb { height: 10px; width: 10px; background: var(--ink); border: none; border-radius: 0; cursor: ew-resize; }
.seed-row { display: grid; grid-template-columns: 1fr auto; gap: 6px; align-items: center; }
.seed-input, select.preset {
background: var(--panel-2); border: 1px solid var(--line); color: var(--ink);
padding: 8px 10px; font-family: 'JetBrains Mono', monospace; font-size: 11px; width: 100%; letter-spacing: 0.05em;
}
.seed-input:focus, select.preset:focus { outline: none; border-color: var(--accent); }
select.preset { margin-top: 8px; cursor: pointer; }
button {
background: transparent; border: 1px solid var(--line); color: var(--ink);
padding: 8px 12px; font-family: 'JetBrains Mono', monospace; font-size: 10px;
text-transform: uppercase; letter-spacing: 0.15em; cursor: pointer; transition: all 0.15s;
}
button:hover { border-color: var(--accent); color: var(--accent); }
button.primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
button.primary:hover { background: var(--accent); border-color: var(--accent); color: var(--bg); }
.btn-row { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 8px; }
.btn-row-full { display: grid; gap: 6px; margin-top: 8px; }
.checkbox-row { display: flex; align-items: center; gap: 8px; margin: 4px 0 8px; cursor: pointer; }
.checkbox-row input { accent-color: var(--accent); }
.checkbox-row span { font-size: 11px; color: var(--ink-dim); }
/* ---------- Stage ---------- */
main { position: relative; background: #050505; overflow: hidden; display: flex; align-items: center; justify-content: center; }
.stage-frame { position: relative; background: #0a0a0a; box-shadow: 0 30px 80px rgba(0,0,0,0.6), 0 0 0 1px var(--line); }
#preview { display: block; width: min(88vh, calc(100vw - 400px)); height: min(88vh, calc(100vw - 400px)); image-rendering: -webkit-optimize-contrast; }
.stage-meta { position: absolute; bottom: 16px; left: 18px; font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--ink-mute); }
.stage-meta .hash { color: var(--accent); letter-spacing: 0.1em; text-transform: none; }
.stage-corner { position: absolute; top: 16px; right: 18px; font-family: 'Cormorant Garamond', serif; font-style: italic; font-size: 14px; color: var(--ink-mute); text-align: right; line-height: 1.4; }
.stage-corner .sm { font-family: 'JetBrains Mono', monospace; font-style: normal; font-size: 9px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--ink-mute); margin-top: 2px; }
.toast { position: absolute; bottom: 16px; right: 18px; background: var(--panel); border: 1px solid var(--accent); color: var(--accent); padding: 8px 14px; font-size: 10px; letter-spacing: 0.15em; text-transform: uppercase; opacity: 0; transition: opacity 0.3s; pointer-events: none; }
.toast.show { opacity: 1; }

117
src/ui/controls.js vendored Normal file
View File

@@ -0,0 +1,117 @@
/* ============================================================
controls.js — declarative UI config.
One place defines every control: its range, default, label,
and whether changing it needs a scene regen ('scene') or just
a re-render ('render'). main.js builds the panel from this and
reads params back out of it.
============================================================ */
export const GROUPS = [
{
title: 'Event',
controls: [
{ id: 'primaries', label: 'Primary tracks', min: 3, max: 48, step: 1, value: 14, int: true, mode: 'scene' },
{ id: 'burst', label: 'Burst intensity', min: 0, max: 1, step: 0.01, value: 0.7, mode: 'scene' },
{ id: 'vdecay', label: 'V-decay vertices', min: 0, max: 10, step: 1, value: 3, int: true, mode: 'scene' },
{ id: 'cosmics', label: 'Cosmic / transient tracks', min: 0, max: 16, step: 1, value: 6, int: true, mode: 'scene' },
{ id: 'sweepers', label: 'Sweeping arcs', min: 0, max: 12, step: 1, value: 3, int: true, mode: 'scene' },
],
},
{
title: 'Field & Trajectory',
controls: [
{ id: 'bfield', label: 'Magnetic field |B|', min: 0.2, max: 3, step: 0.01, value: 1.0, mode: 'scene' },
{ id: 'eloss', label: 'Energy-loss rate', min: 0, max: 1.5, step: 0.01, value: 0.55, mode: 'scene' },
{ id: 'pspread', label: 'Momentum spread', min: 0, max: 1, step: 0.01, value: 0.6, mode: 'scene' },
],
},
{
title: 'δ-Rays (curly bits)',
controls: [
{ id: 'deltaRate', label: 'Spawn rate', min: 0, max: 1, step: 0.01, value: 0.6, mode: 'scene' },
{ id: 'deltaTight', label: 'Tightness', min: 0.1, max: 1.5, step: 0.01, value: 0.7, mode: 'scene' },
],
},
{
title: 'Shock-wave Disk',
controls: [
{ id: 'shockIntensity', label: 'Intensity', min: 0, max: 1, step: 0.01, value: 0.7, mode: 'scene' },
{ id: 'shockSize', label: 'Size', min: 0.1, max: 0.6, step: 0.01, value: 0.3, mode: 'scene' },
{ id: 'shockStriations', label: 'Striation density', min: 0, max: 1, step: 0.01, value: 0.5, mode: 'scene' },
{ id: 'shockStain', label: 'Staining / erosion', min: 0, max: 1, step: 0.01, value: 0.35, mode: 'scene' },
{ id: 'shockY', label: 'Vertical position', min: -0.7, max: 0.7, step: 0.01, value: 0.52, mode: 'scene' },
],
},
{
title: 'Chamber Optics',
controls: [
{ id: 'instrument', label: 'Structural geometry', min: 0, max: 1, step: 0.01, value: 0.35, mode: 'scene' },
],
},
{
title: 'Event Layers',
controls: [
{ id: 'bgEvents', label: 'Background events', min: 0, max: 12, step: 1, value: 4, int: true, mode: 'scene' },
{ id: 'bgIntensity', label: 'Background intensity', min: 0, max: 1, step: 0.01, value: 0.4, mode: 'scene' },
],
},
{
title: 'Film & Plate',
controls: [
{ id: 'density', label: 'Bubble density', min: 0.2, max: 2.5, step: 0.01, value: 1.1, mode: 'render' },
{ id: 'size', label: 'Bubble size', min: 0.3, max: 2.5, step: 0.01, value: 1.0, mode: 'render' },
{ id: 'bloom', label: 'Halation / bloom', min: 0, max: 1, step: 0.01, value: 0.5, mode: 'render' },
{ id: 'mottle', label: 'Tonal mottle', min: 0, max: 1, step: 0.01, value: 0.45, mode: 'render' },
{ id: 'grain', label: 'Film grain', min: 0, max: 1, step: 0.01, value: 0.4, mode: 'render' },
{ id: 'vign', label: 'Vignette', min: 0, max: 1, step: 0.01, value: 0.4, mode: 'render' },
{ id: 'artifacts', label: 'Plate damage', min: 0, max: 1, step: 0.01, value: 0.5, mode: 'scene' },
],
},
];
export const TOGGLES = [
{ id: 'shock', label: 'Shock-wave disk', value: true, mode: 'scene' },
{ id: 'showFiducials', label: 'Fiducial marks', value: true, mode: 'render' },
{ id: 'showBoundary', label: 'Chamber boundary', value: true, mode: 'render' },
{ id: 'showHeader', label: 'Archival header', value: true, mode: 'render' },
{ id: 'invert', label: 'Invert · photographic positive', value: true, mode: 'render' },
];
/* Fixed (non-UI) params with sensible defaults. */
export const FIXED = { shockX: 0 };
/* Curated presets. Each overrides a subset of params. */
export const PRESETS = {
'BEBC Archival': {
seed: 'BEBC-1973', primaries: 18, burst: 0.8, vdecay: 4, cosmics: 8, sweepers: 4,
bfield: 1.1, eloss: 0.6, pspread: 0.7, deltaRate: 0.75, deltaTight: 0.8,
shock: true, shockIntensity: 0.85, shockSize: 0.34, shockStriations: 0.7, shockY: 0.55,
instrument: 0.4, bgEvents: 6, bgIntensity: 0.45, density: 1.2, size: 1.0,
bloom: 0.55, mottle: 0.6, grain: 0.5, vign: 0.5, artifacts: 0.7, invert: true,
},
'Clean Study': {
primaries: 10, burst: 0.5, vdecay: 2, cosmics: 2, sweepers: 2,
deltaRate: 0.4, deltaTight: 0.6, shockIntensity: 0, instrument: 0.15,
bgEvents: 1, bgIntensity: 0.25, bloom: 0.3, mottle: 0.2, grain: 0.15,
vign: 0.25, artifacts: 0.15, invert: true,
},
'Dense Chaos': {
primaries: 34, burst: 0.95, vdecay: 7, cosmics: 12, sweepers: 7,
bfield: 1.8, deltaRate: 0.95, deltaTight: 1.0, instrument: 0.5,
shock: true, shockIntensity: 0.7, bgEvents: 9, bgIntensity: 0.5,
density: 1.4, bloom: 0.6, mottle: 0.5, grain: 0.5, artifacts: 0.7, invert: true,
},
'Cosmic Sheet': {
seed: 'COSMIC-RAY', primaries: 6, burst: 0.3, vdecay: 1, cosmics: 14, sweepers: 9,
bfield: 0.7, eloss: 0.4, pspread: 0.85, deltaRate: 0.45, deltaTight: 0.7,
shock: false, shockIntensity: 0, instrument: 0.6,
bgEvents: 3, bgIntensity: 0.4, density: 0.9, bloom: 0.45, mottle: 0.55,
grain: 0.55, vign: 0.45, artifacts: 0.6, invert: true,
},
'Negative Plate': {
seed: 'GLASS-NEG', primaries: 16, burst: 0.75, vdecay: 3, cosmics: 7, sweepers: 4,
deltaRate: 0.7, deltaTight: 0.8, shock: true, shockIntensity: 0.7,
instrument: 0.35, bgEvents: 5, bgIntensity: 0.4, density: 1.1,
bloom: 0.5, mottle: 0.5, grain: 0.45, vign: 0.55, artifacts: 0.55, invert: false,
},
};

27
tools/gallery.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Render a curated set of plates into output/ — tuned toward the CERN reference.
# Usage: tools/gallery.sh [size_px] (default 2000)
set -e
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SIZE="${1:-1600}"
OUT="$ROOT/output"
mkdir -p "$OUT"
# A reference-leaning custom parameter string (busy, multi-scale, big disk, fan lines)
REF="primaries=22&burst=0.85&vdecay=4&cosmics=9&sweepers=5&bfield=1.2&eloss=0.6&pspread=0.78&deltaRate=0.92&deltaTight=0.85&shockIntensity=0.88&shockSize=0.33&shockStriations=0.78&shockY=0.5&instrument=0.5&bgEvents=6&bgIntensity=0.45&density=1.28&size=1.0&bloom=0.55&mottle=0.6&grain=0.5&vign=0.5&artifacts=0.72"
render () { # name query
"$ROOT/tools/shoot.sh" "$OUT/$1.png" "$2&size_px=$SIZE" >/dev/null
echo " output/$1.png"
}
echo "Rendering plates → output/ (${SIZE}px)…"
render "01_bebc-archival-1973" "preset=BEBC%20Archival"
render "02_bebc-gargamelle" "preset=BEBC%20Archival&seed=GARGAMELLE-4471"
render "03_dense-chaos-omega" "preset=Dense%20Chaos&seed=OMEGA-7782"
render "04_cosmic-sheet" "preset=Cosmic%20Sheet"
render "05_ref-match-strange" "$REF&seed=STRANGE-1187"
render "06_ref-match-hyperon" "$REF&seed=HYPERON-0440"
render "07_ref-match-kaon" "$REF&seed=KAON-2291&shockY=0.46&shockSize=0.36"
render "08_negative-plate" "preset=Negative%20Plate&seed=GLASS-NEG-77"
echo "done."

52
tools/inspire.sh Executable file
View File

@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Inspiration randomizer: generate N plates from random seeds, each named by its
# seed, plus an HTML contact sheet to browse them. Because every parameter is
# derived from the seed, picking a favourite just means noting its seed and
# running: tools/render.sh <SEED>
#
# Usage: tools/inspire.sh [count] [thumb_px]
# tools/inspire.sh 32 600
set -e
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
COUNT="${1:-24}"
SIZE="${2:-600}"
OUT="$ROOT/output/inspiration"
mkdir -p "$OUT"
WORDS=(MUON KAON PION LAMBDA SIGMA XI OMEGA TAU GLUON QUARK HADRON BARYON LEPTON \
NEUTRINO BOSON STRANGE CHARM HYPERON ANTIPROTON POSITRON MESON FERMION NUCLEON \
ISOSPIN CASCADE RESONANCE PARITY GLUEBALL PENTAQUARK PHOTON)
SEEDS_FILE="$OUT/seeds.txt"
: > "$SEEDS_FILE"
echo "Generating $COUNT inspiration plates (${SIZE}px) → output/inspiration/"
for ((i = 1; i <= COUNT; i++)); do
W=${WORDS[$RANDOM % ${#WORDS[@]}]}
N=$((RANDOM % 9000 + 1000))
SEED="$W-$N"
"$ROOT/tools/shoot.sh" "$OUT/$SEED.png" "fromseed=1&seed=$SEED&size_px=$SIZE" >/dev/null
echo "$SEED" >> "$SEEDS_FILE"
printf " [%2d/%2d] %s\n" "$i" "$COUNT" "$SEED"
done
# Build the contact sheet
SHEET="$OUT/index.html"
{
echo '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Bubble Chamber — inspiration</title>'
echo '<style>body{margin:0;background:#111;color:#bbb;font:12px/1.4 ui-monospace,Menlo,monospace;padding:24px}'
echo 'h1{font-weight:400;letter-spacing:.2em;text-transform:uppercase;font-size:13px;color:#d4a574}'
echo '.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:18px;margin-top:18px}'
echo 'figure{margin:0;background:#000;border:1px solid #2a2a2a}img{width:100%;display:block}'
echo 'figcaption{padding:6px 8px;display:flex;justify-content:space-between;align-items:center}'
echo '.s{color:#e8e4d8;letter-spacing:.05em}.c{color:#555;user-select:all}</style></head><body>'
echo "<h1>Bubble Chamber · inspiration · $(date +%Y-%m-%d)</h1>"
echo '<p>Click a seed to select it, then run <span class="c">tools/render.sh &lt;SEED&gt;</span> for the print master.</p>'
echo '<div class="grid">'
while IFS= read -r S; do
echo "<figure><img src=\"$S.png\" loading=\"lazy\"><figcaption><span class=\"s\">$S</span><span class=\"c\">render.sh $S</span></figcaption></figure>"
done < "$SEEDS_FILE"
echo '</div></body></html>'
} > "$SHEET"
echo "done. open: output/inspiration/index.html"

40
tools/preview.html Normal file
View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><style>html,body{margin:0;background:#000}canvas{display:block}</style></head>
<body>
<canvas id="c"></canvas>
<script type="module">
import { generateScene } from '../src/scene/scene.js';
import { renderCanvasPhoto } from '../src/render/canvasPhoto.js';
import { GROUPS, TOGGLES, FIXED, PRESETS } from '../src/ui/controls.js';
import { paramsFromSeed } from '../src/scene/params.js';
const q = new URLSearchParams(location.search);
// fromseed=1 : derive the ENTIRE parameter set deterministically from the seed
let params;
if (q.get('fromseed') === '1') {
params = { ...FIXED, ...paramsFromSeed(q.get('seed') || 'ENTROPY-001') };
} else {
params = { ...FIXED, seed: 'ENTROPY-001' };
for (const g of GROUPS) for (const c of g.controls) params[c.id] = c.value;
for (const t of TOGGLES) params[t.id] = t.value;
if (q.get('preset') && PRESETS[q.get('preset')]) Object.assign(params, PRESETS[q.get('preset')]);
}
if (q.get('seed')) params.seed = q.get('seed');
for (const [k, v] of q) {
if (k === 'seed' || k === 'preset' || k === 'size_px') continue;
if (k in params) params[k] = (v === 'true') ? true : (v === 'false') ? false : (isNaN(+v) ? v : +v);
}
const SIZE = +(q.get('size_px') || 1200);
const c = document.getElementById('c');
c.width = c.height = SIZE;
const ctx = c.getContext('2d', { willReadFrequently: true });
const scene = generateScene(params);
renderCanvasPhoto(ctx, SIZE, SIZE, scene, params, { preview: q.get('hq') !== '1' });
document.title = 'done';
window.__done = true;
</script>
</body>
</html>

33
tools/render-pdf.mjs Normal file
View File

@@ -0,0 +1,33 @@
/* Render a scene to a print-ready CMYK PDF from the CLI.
Two modes:
node tools/render-pdf.mjs --seed SEED [outfile] [pagePt] (seed → all params)
node tools/render-pdf.mjs [preset] [seed] [outfile] (legacy preset mode)
*/
import { writeFileSync } from 'node:fs';
import { generateScene } from '../src/scene/scene.js';
import { buildPDF } from '../src/render/pdf.js';
import { paramsFromSeed } from '../src/scene/params.js';
import { GROUPS, TOGGLES, FIXED, PRESETS } from '../src/ui/controls.js';
const argv = process.argv.slice(2);
let params, out, page = 1728;
if (argv[0] === '--seed') {
const seed = argv[1] || 'ENTROPY-001';
params = { ...FIXED, ...paramsFromSeed(seed) };
out = argv[2];
if (argv[3]) page = +argv[3];
} else {
const [preset, seed, o] = argv;
params = { ...FIXED, seed: seed || 'ENTROPY-001' };
for (const g of GROUPS) for (const c of g.controls) params[c.id] = c.value;
for (const t of TOGGLES) params[t.id] = t.value;
if (preset && PRESETS[preset]) Object.assign(params, PRESETS[preset]);
if (seed) params.seed = seed;
out = o;
}
const pdf = buildPDF(generateScene(params), params, page);
const file = out || `/tmp/bc-${params.seed}.pdf`;
writeFileSync(file, pdf);
console.log(`PDF -> ${file} (${(pdf.length / 1024).toFixed(0)} KB)`);

33
tools/render-svg.mjs Normal file
View File

@@ -0,0 +1,33 @@
/* Render a scene to SVG from the CLI (shares the scene module).
Two modes:
node tools/render-svg.mjs --seed SEED [outfile] [sizePx] (seed → all params)
node tools/render-svg.mjs [preset] [seed] [outfile] (legacy preset mode)
*/
import { writeFileSync } from 'node:fs';
import { generateScene } from '../src/scene/scene.js';
import { renderSVG } from '../src/render/svgVector.js';
import { paramsFromSeed } from '../src/scene/params.js';
import { GROUPS, TOGGLES, FIXED, PRESETS } from '../src/ui/controls.js';
const argv = process.argv.slice(2);
let params, out, size = 4800;
if (argv[0] === '--seed') {
const seed = argv[1] || 'ENTROPY-001';
params = { ...FIXED, ...paramsFromSeed(seed) };
out = argv[2];
if (argv[3]) size = +argv[3];
} else {
const [preset, seed, o] = argv;
params = { ...FIXED, seed: seed || 'ENTROPY-001' };
for (const g of GROUPS) for (const c of g.controls) params[c.id] = c.value;
for (const t of TOGGLES) params[t.id] = t.value;
if (preset && PRESETS[preset]) Object.assign(params, PRESETS[preset]);
if (seed) params.seed = seed;
out = o;
}
const svg = renderSVG(generateScene(params), params, size);
const file = out || `/tmp/bc-${params.seed}.svg`;
writeFileSync(file, svg);
console.log(`SVG -> ${file} (${(svg.length / 1024).toFixed(0)} KB)`);

29
tools/render.sh Executable file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Print-master renderer for a chosen seed. Produces, into output/masters/:
# <SEED>.svg vector geometry — INFINITE resolution, the true master
# <SEED>.pdf vector CMYK — print-shop ready, 24" page
# <SEED>_<size>.png photographic — carries grain / bloom / stain
#
# Every parameter derives from the seed, so this is identical to the inspiration
# thumbnail of the same seed, just at full resolution.
#
# Usage: tools/render.sh <SEED> [png_px]
# tools/render.sh STRANGENESS-7
# tools/render.sh STRANGENESS-7 12000
set -e
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SEED="$1"
SIZE="${2:-9000}"
if [ -z "$SEED" ]; then echo "usage: tools/render.sh <SEED> [png_px]"; exit 1; fi
OUT="$ROOT/output/masters"
mkdir -p "$OUT"
echo "Rendering master for seed: $SEED"
echo " • vector geometry (infinite res)…"
node "$ROOT/tools/render-svg.mjs" --seed "$SEED" "$OUT/$SEED.svg" 6000 | sed 's/^/ /'
echo " • vector CMYK PDF (print shop)…"
node "$ROOT/tools/render-pdf.mjs" --seed "$SEED" "$OUT/$SEED.pdf" | sed 's/^/ /'
echo " • photographic raster (${SIZE}px)…"
"$ROOT/tools/shoot.sh" "$OUT/${SEED}_${SIZE}.png" "fromseed=1&seed=$SEED&size_px=$SIZE" | sed 's/^/ /'
echo "done → output/masters/ ($SEED .svg/.pdf/_${SIZE}.png)"

37
tools/shoot.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Render the artwork headlessly for the compare loop.
# Usage: tools/shoot.sh [outfile] [query]
# tools/shoot.sh /tmp/bc.png "preset=BEBC%20Archival&size_px=1200"
# tools/shoot.sh /tmp/bc.png "seed=KAON-0088&shockIntensity=0.9"
set -e
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OUT="${1:-/tmp/bc.png}"
QUERY="${2:-size_px=1200}"
PORT=8731
CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
# start a static server if not already up
if ! curl -s "http://localhost:$PORT/tools/preview.html" >/dev/null 2>&1; then
(cd "$ROOT" && python3 -m http.server "$PORT" >/tmp/bc_server.log 2>&1 &)
sleep 1
fi
# pull size for the window
SIZE=$(echo "$QUERY" | sed -n 's/.*size_px=\([0-9]*\).*/\1/p'); SIZE="${SIZE:-1200}"
# scale the virtual-time budget with resolution; cap with a hard watchdog so a
# slow/hung render can never block forever.
BUDGET=4000; WATCHDOG=45
if [ "$SIZE" -ge 1800 ]; then BUDGET=8000; WATCHDOG=90; fi
"$CHROME" --headless=new --disable-gpu --hide-scrollbars \
--force-device-scale-factor=1 --virtual-time-budget="$BUDGET" \
--run-all-compositor-stages-before-draw \
--screenshot="$OUT" --window-size="$SIZE,$SIZE" \
"http://localhost:$PORT/tools/preview.html?$QUERY" 2>/dev/null &
CPID=$!
( sleep "$WATCHDOG"; kill -9 "$CPID" 2>/dev/null ) & WPID=$!
wait "$CPID" 2>/dev/null
kill "$WPID" 2>/dev/null || true
if [ -f "$OUT" ]; then echo "shot -> $OUT ($QUERY)"; else echo "FAILED -> $OUT ($QUERY)"; exit 1; fi