Initial
51
.vscode/launch.json
vendored
Normal 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
30
.vscode/tasks.json
vendored
Normal 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
@@ -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
@@ -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 > 1–2 m, 100–150 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
1256
bubble_chamber.v2.html
Normal file
85
build.mjs
Normal 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
@@ -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
@@ -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 (0–12 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.
|
||||||
BIN
output/01_bebc-archival-1973.png
Normal file
|
After Width: | Height: | Size: 6.7 MiB |
BIN
output/02_bebc-gargamelle.png
Normal file
|
After Width: | Height: | Size: 6.7 MiB |
BIN
output/03_dense-chaos-omega.png
Normal file
|
After Width: | Height: | Size: 6.8 MiB |
BIN
output/04_cosmic-sheet.png
Normal file
|
After Width: | Height: | Size: 6.8 MiB |
BIN
output/05_ref-match-strange.png
Normal file
|
After Width: | Height: | Size: 6.7 MiB |
BIN
output/06_ref-match-hyperon.png
Normal file
|
After Width: | Height: | Size: 6.7 MiB |
BIN
output/07_ref-match-kaon.png
Normal file
|
After Width: | Height: | Size: 6.7 MiB |
BIN
output/08_negative-plate.png
Normal file
|
After Width: | Height: | Size: 5.5 MiB |
BIN
output/inspiration/ANTIPROTON-5808.png
Normal file
|
After Width: | Height: | Size: 679 KiB |
BIN
output/inspiration/ANTIPROTON-6835.png
Normal file
|
After Width: | Height: | Size: 579 KiB |
BIN
output/inspiration/BARYON-9263.png
Normal file
|
After Width: | Height: | Size: 685 KiB |
BIN
output/inspiration/BOSON-3530.png
Normal file
|
After Width: | Height: | Size: 520 KiB |
BIN
output/inspiration/BOSON-8669.png
Normal file
|
After Width: | Height: | Size: 733 KiB |
BIN
output/inspiration/CASCADE-1099.png
Normal file
|
After Width: | Height: | Size: 729 KiB |
BIN
output/inspiration/CHARM-8097.png
Normal file
|
After Width: | Height: | Size: 661 KiB |
BIN
output/inspiration/CHARM-9063.png
Normal file
|
After Width: | Height: | Size: 729 KiB |
BIN
output/inspiration/GLUEBALL-7284.png
Normal file
|
After Width: | Height: | Size: 710 KiB |
BIN
output/inspiration/HADRON-7754.png
Normal file
|
After Width: | Height: | Size: 495 KiB |
BIN
output/inspiration/HYPERON-8289.png
Normal file
|
After Width: | Height: | Size: 701 KiB |
BIN
output/inspiration/KAON-3434.png
Normal file
|
After Width: | Height: | Size: 735 KiB |
BIN
output/inspiration/LAMBDA-2648.png
Normal file
|
After Width: | Height: | Size: 701 KiB |
BIN
output/inspiration/LEPTON-5840.png
Normal file
|
After Width: | Height: | Size: 528 KiB |
BIN
output/inspiration/MESON-9679.png
Normal file
|
After Width: | Height: | Size: 668 KiB |
BIN
output/inspiration/MUON-1704.png
Normal file
|
After Width: | Height: | Size: 564 KiB |
BIN
output/inspiration/MUON-5176.png
Normal file
|
After Width: | Height: | Size: 615 KiB |
BIN
output/inspiration/NEUTRINO-3556.png
Normal file
|
After Width: | Height: | Size: 701 KiB |
BIN
output/inspiration/NEUTRINO-9243.png
Normal file
|
After Width: | Height: | Size: 705 KiB |
BIN
output/inspiration/NUCLEON-8802.png
Normal file
|
After Width: | Height: | Size: 664 KiB |
BIN
output/inspiration/PARITY-1530.png
Normal file
|
After Width: | Height: | Size: 709 KiB |
BIN
output/inspiration/POSITRON-3617.png
Normal file
|
After Width: | Height: | Size: 668 KiB |
BIN
output/inspiration/RESONANCE-1584.png
Normal file
|
After Width: | Height: | Size: 690 KiB |
BIN
output/inspiration/XI-2188.png
Normal file
|
After Width: | Height: | Size: 588 KiB |
35
output/inspiration/index.html
Normal 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 <SEED></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>
|
||||||
24
output/inspiration/seeds.txt
Normal 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
60
output/masters/HYPERON-8289.svg
Normal file
|
After Width: | Height: | Size: 4.4 MiB |
BIN
output/masters/HYPERON-8289_4000.png
Normal file
|
After Width: | Height: | Size: 26 MiB |
381543
output/masters/LAMBDA-2648.pdf
Normal file
59
output/masters/LAMBDA-2648.svg
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
output/masters/LAMBDA-2648_12000.png
Normal file
|
After Width: | Height: | Size: 243 MiB |
12
package.json
Normal 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"
|
||||||
|
}
|
||||||
BIN
reference/100-atoms_spotlight_tracks_feat.webp
Normal file
|
After Width: | Height: | Size: 249 KiB |
BIN
ruvector.db
Normal file
213
src/main.js
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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 === '<' ? '<' : '&'));
|
||||||
|
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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,57 @@
|
|||||||
|
/* ============================================================
|
||||||
|
delta.js — δ-rays, the iconic curly bits.
|
||||||
|
A δ-ray is a low-energy electron knocked off an atom by a
|
||||||
|
passing track. In the B field it makes a tight spiral that
|
||||||
|
loses energy and winds inward to a point. We model it
|
||||||
|
directly as a logarithmic spiral (radius decays exponentially
|
||||||
|
with wind angle) because that converges cleanly to a point
|
||||||
|
and reads as the textbook curl — far more reliable than
|
||||||
|
tuning the generic integrator down to sub-percent radii.
|
||||||
|
β decreases toward the centre, so bubbles get denser & fatter
|
||||||
|
as the curl tightens — exactly as in real plates.
|
||||||
|
============================================================ */
|
||||||
|
import { gauss } from '../rng.js';
|
||||||
|
|
||||||
|
/* Build one δ-ray spiral starting at (and tangent to) a parent point. */
|
||||||
|
export function spawnDeltaSpiral(parentPt, params, rng) {
|
||||||
|
const tight = params.deltaTight; // 0.1..1.5 ; higher = smaller curls
|
||||||
|
const dir = rng() < 0.5 ? 1 : -1; // wind direction (charge sign)
|
||||||
|
|
||||||
|
// revolutions: most curls do 1.5–4 turns
|
||||||
|
const turns = 1.2 + rng() * rng() * 3.2;
|
||||||
|
const phiMax = turns * Math.PI * 2;
|
||||||
|
|
||||||
|
// starting radius: a small fraction of the chamber, smaller when tighter
|
||||||
|
const r0 = (0.010 + rng() * 0.045) / (0.6 + tight);
|
||||||
|
|
||||||
|
// exponential decay per radian → converges to centre
|
||||||
|
const decay = (0.07 + rng() * 0.13);
|
||||||
|
|
||||||
|
// launch direction: roughly perpendicular to parent (knock-on kinematics)
|
||||||
|
const launch = parentPt.theta + dir * Math.PI / 2 + gauss(rng) * 0.35;
|
||||||
|
|
||||||
|
// spiral centre placed so the curve starts exactly at the parent point
|
||||||
|
const cx = parentPt.x - Math.cos(launch) * r0;
|
||||||
|
const cy = parentPt.y - Math.sin(launch) * r0;
|
||||||
|
|
||||||
|
const pts = [];
|
||||||
|
// adaptive angular step: finer as radius shrinks to keep it smooth
|
||||||
|
let phi = 0;
|
||||||
|
while (phi <= phiMax) {
|
||||||
|
const r = r0 * Math.exp(-decay * phi);
|
||||||
|
const ang = launch + dir * phi;
|
||||||
|
const x = cx + Math.cos(ang) * r;
|
||||||
|
const y = cy + Math.sin(ang) * r;
|
||||||
|
// β falls as the electron slows toward the centre
|
||||||
|
const frac = phi / phiMax;
|
||||||
|
const beta = 0.85 * Math.exp(-1.4 * frac) + 0.12;
|
||||||
|
// tangent heading (for downstream use)
|
||||||
|
const theta = ang + dir * Math.PI / 2;
|
||||||
|
pts.push({ x, y, beta, theta });
|
||||||
|
if (r < 0.0008) break; // arrived at the point
|
||||||
|
// step in angle, larger when radius is big, smaller when tight
|
||||||
|
const dPhi = Math.min(0.4, 0.012 / Math.max(r, 0.001) + 0.05);
|
||||||
|
phi += dPhi;
|
||||||
|
}
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
72
src/scene/instrument.js
Normal file
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 <SEED></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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||