diff --git a/fable/01-intent-vs-execution.md b/fable/01-intent-vs-execution.md
new file mode 100644
index 0000000..d021bc3
--- /dev/null
+++ b/fable/01-intent-vs-execution.md
@@ -0,0 +1,154 @@
+# Intent vs. execution
+
+The project states its intent unusually well, in four layers:
+
+- **originalprompt.md** (May 19): "One goal is large format print. Vector
+ graphics are ideal. …seed/hash concept for reproducibility. This is an art
+ project."
+- **DESIGN.md**: one renderer-agnostic scene model, many renderers; "get the β
+ statistics right and the image reads as authentic."
+- **PRINT.md**: a ~2.5 × 4 m wall piece, assembled from registered SVG geometry
+ + raster texture layers.
+- **roadmap.md**: the feelings — awe, stillness, melancholy, scale-vertigo, the
+ numinous — and a guiding test: *"does it deepen the sense that we are looking
+ at a real trace of something unseen — and does it reward standing closer?"*
+
+Below, each declared intent against what the repo actually shows.
+
+---
+
+## 1. "Large-format print" — the founding goal is the least-executed one
+
+The print pipeline is real and good: `tools/render.sh`, a hand-rolled CMYK PDF
+writer with OCG layers (`src/render/pdf.js`, 295 lines, genuinely impressive),
+Inkscape-convention layered SVG, resolution math in PRINT.md.
+
+The execution record: `output/masters/` holds 7 masters across 5 seeds, **all
+dated May 20–21** — the first 48 hours. No master exists from any of the eight
+creative phases since. No `_9000.png` photographic master is on disk. The tiled
+renderer PRINT.md names as the escape hatch for the 4 m dimension
+(`tools/render-tiled.mjs` — "ask and I'll build it") was never built. The dev
+entry `index.html` still says "Print target: 24″ × 24″", contradicting the
+2.5 × 4 m ambition in PRINT.md — neither has been updated because neither has
+been tested against a physical object.
+
+Every phase since May 21 stopped at 1300–1600 px exploration renders. The
+roadmap's guiding test — "does it reward standing closer?" — is a question about
+a physical print viewed by a body, and it has never once been asked of a
+physical print. **The project optimizes a feeling it has not yet measured.**
+
+## 2. "One scene model, many renderers" — the seam holds; the look leaks
+
+DESIGN.md's architectural claim is verifiably true in the strict sense: all 11
+files in `src/scene/` are pure data, zero canvas/DOM references. Bubble
+positions come from one shared salted stream and land identically in raster,
+SVG, and PDF. The discipline held for six weeks of feature growth. That is rare
+and worth saying plainly.
+
+But the *look* was never given the same seam. Style decisions live triplicated
+as freehand copies inside the three renderers, and they have already drifted:
+
+- Bubble radius: `rr*foot(soft)` in canvas, `b.r*scale*1.15` in SVG,
+ `b.r*scale` (no 1.15) in PDF — three renderers, three bubble sizes.
+- Fiducial arm length: 6u canvas, 9u SVG, 8u PDF.
+- ~150–200 lines of formulas (under-stroke width, bubble alpha, disk-stroke
+ alpha, iridescent spectrum mix) exist in verbatim triplicate.
+- PDF quietly lags: no vignette, no media layer (grease pencil / réseau / film
+ furniture), no shock stains, no fingerprints. Fingerprints are canvas-only.
+ Whole parameters (`boundaryY/R`, `diskPressure`, `emit`, `transparentPaper`)
+ exist only in the SVG renderer.
+
+`bubbleStops()` in palette.js — one edge profile consumed by both the canvas
+sprite gradient and the SVG radial gradient — is the model the rest of the look
+logic should have followed. The architecture document describes the seam that
+exists; it does not mention the seam that's missing.
+
+## 3. "Determinism is a feature. The seed as a true name" — true at the altar, loose in the pews
+
+The salt discipline (`makeRng(seed, 'subsystem')`) is followed at feature
+granularity: changing cosmic count cannot move the shock disk; `eventX/eventY`
+overrides still burn their rng draws so downstream is stable. Someone thought
+about this carefully.
+
+But within modules the promise breaks: dialing `shockStriations` reshuffles the
+rim segments, rings, stains, *and* core cracks; the `artifacts` slider re-rolls
+every scratch and dust speck instead of fading them; the density slider —
+nominally a render-mode param — reshuffles every bubble position on every
+track. And the theology weakens with distance from the original generator:
+PRINT.md admits manual slider tweaks are *not* part of the seed fingerprint;
+`src/qft/params.js` claims "seed → complete fingerprint" while the camera,
+ripples, vortices, and wavepackets have **no seeded path at all** — a bare QFT
+seed always yields the default isometric lattice. The true name names less and
+less of the plate the further you get from `src/scene/`.
+
+## 4. The confirmed bug that is secretly the thesis
+
+`renderQFTSVG` silently drops ripple grids. The scene builder creates
+`id:'ripple'` grids; the renderer emits only `cubic`, `schlegel`, and `e8`
+layers (`src/qft/renderer.js:178-181`). Verified at runtime: a scene declaring
+ripples renders an SVG with no ripple geometry. This has been true since the
+single "Added QFT" commit.
+
+Consequence: sketch08's entire premise ("RIPPLES — concentric expanding
+wavefronts"), sketch09's `03_vortex-with-spinning-ripples`,
+`15_perturbation-cascade`, `17_spectral-vortex-storm` — **106 hand-authored
+variation configs, and every one that invoked ripples shipped a plate whose
+label describes a feature absent from the image.** Two subsequent sketch series
+were art-directed on top of it. Nobody noticed, because the plates were judged
+by eye against feelings, not against their own scene models.
+
+Note what happened here: a project whose subject is *evidence of the invisible*,
+which forges archival captions ("PLATE 095 · EXPOSED 1972"), produced a
+catalogue of plates whose captions confidently describe invisible content. The
+fiction became true by accident. There are two valid responses — fix it, or
+recognize it (see [04-ideas.md](04-ideas.md) §3, the neutrino argument) — but
+the current state, where the archive lies and the author doesn't know, is the
+only wrong one.
+
+## 5. "The series is an argument" — the argument exists; it's the repo itself
+
+Roadmap principle 5 wants Becher-typology grids, a fabricated catalogue, an
+archive. Phase 4 is marked "in progress" and the media layer (grease pencil,
+sprockets, réseau) shipped beautifully — the BEBC-1973 output plate genuinely
+reads as a worn archival photograph; I looked.
+
+Meanwhile, without noticing, the project *built an actual archive*: 19 output
+directories, 675 tracked files, ten gallery `index.html`s, sketch series 01–09,
+templates 1–4, a wall-mock (`tools/archive-wall.html`) frozen at the May 29
+curation. It has the true properties of real archives: numbered series with
+missing entries (perspective1/2 scripts deleted, their outputs orphaned),
+instruments that no longer match their plates (`compositions/hero.mjs` uses
+radial-grid keys the code no longer reads), mislabeled holdings (§4), and an
+expedition that never reported back (the `milkweed-wind-field` branch). The
+fabricated-archive artwork the roadmap plans is a *simulation* of what the repo
+has *actually become*. That inversion is the most interesting unexploited
+material in the project.
+
+## 6. Physics honesty — the one place intent and execution fully agree
+
+The code is candid about its fakery in a way most generative-art code isn't:
+δ-rays are analytic log spirals because integrating to sub-percent radii "is
+unreliable" (the header says so); the shock disk announces "Not physics — a
+chamber/window pressure artifact"; every particle in this universe has mass 1;
+V-decays conserve neither momentum nor anything else. The load-bearing claim —
+get the 1/β² bubble statistics right and the eye accepts everything — is
+correct, and the outputs prove it. One comment lies (`delta.js:53` claims the
+angular step gets finer toward the center; the formula coarsens it), which is
+the kind of thing that only matters because the rest of the codebase has earned
+the habit of being believed.
+
+## 7. The drift nobody wrote down
+
+The documents describe a project: *one generator, deepened along a staged plan.*
+The git history records a different project: *a widening fleet of instruments* —
+bubble chamber → QFT lattices → carpet/perspective primitives → composition
+schema → art-historical templates → element liberation → watercolor → milkweed —
+where each phase spawns a fork-and-mutate tool family and a fresh output
+directory, and commits are creative sessions ("vast ocean", "Element
+Liberation"), not code changes. Neither mode is wrong. But the roadmap still
+narrates mode one while the hands do mode two, which is why Phase 3 (the
+translucent stacked print — the *stated* strongest fusion) has had no work at
+all while two unplanned renderers and two unplanned subject-matters shipped.
+The truest sentence in the repo is in the memory index, not the roadmap:
+"typology MATRIX of standalone singles." The roadmap should be made to say what
+the project has decided by doing.
diff --git a/fable/02-current-state.md b/fable/02-current-state.md
new file mode 100644
index 0000000..f4588e1
--- /dev/null
+++ b/fable/02-current-state.md
@@ -0,0 +1,92 @@
+# Current state — factual snapshot (2026-07-04)
+
+## Codebase
+
+| Area | Size | State |
+|---|---|---|
+| Core (`src/rng` + `scene/` + `render/` + `ui/` + `main` + build) | ~3,220 lines | Committed, disciplined, small |
+| QFT (`src/qft/`) | 9 files, ~1,250 lines | Committed (one commit, May 29) |
+| Compose (`src/compose/`) | 4 files, ~680 lines | Committed |
+| Watercolor (`src/render/watercolor.js`) | 427 lines | **Uncommitted** (Jun 24–25) |
+| Milkweed-botanical (`src/milkweed/umbel.js`) | 169 lines | **Uncommitted** (Jun 25) |
+| Milkweed-windfield (`src/field/wind.js`, `src/scene/umbel.js`, `src/scene/drift.js`, GROUP_BUILDERS registry) | ~225 lines + compose rework | **Committed on unmerged branch** `milkweed-wind-field` (63066ee, Jun 17, also pushed) |
+| Tools | 37 files, ~4,750 lines | Committed except 3 newest |
+
+Git: 12 commits on `main`, May 20 → Jun 10. Last commit **24 days ago**. The
+uncommitted watercolor + milkweed work is a 2-day session (Jun 24–25) followed
+by 9 days of silence. An entire fourth renderer exists only in the working tree.
+
+## The two milkweeds (important — memory/docs conflate them)
+
+1. **Branch** `milkweed-wind-field`: the "hidden realities" system — one
+ divergence-free wind field (closed-form time integral, elegantly done), three
+ detectors (dunes/water/seeds), umbel-as-vertex, drift-as-advection, plus the
+ GROUP_BUILDERS registry refactor of the composition engine. Committed Jun 17,
+ never merged.
+2. **Working tree on main** (`src/milkweed/umbel.js`): a *different* milkweed —
+ a botanical Asclepias still-life scene built for the watercolor renderer.
+ Does not import the wind field.
+
+Both export `generateUmbel` with **incompatible signatures**. Merging the branch
+as-is collides. This is an unmade decision materialized as two files.
+
+## Renderers
+
+4 renderers for bubble-chamber scenes (photo raster / SVG / PDF / watercolor);
+QFT has only its own SVG renderer — no photographic or watercolor QFT exists.
+Feature parity: PDF lags (no media layer, vignette, stains, fingerprints); SVG
+has private params (`boundary*`, `diskPressure`, `emit`, `transparentPaper`);
+~150–200 lines of look logic triplicated and drifting (three bubble sizes).
+Watercolor integrates through the proper seam (same signature, shared
+`sampleBubbles`/`depthFactors`, palette registry extended not bypassed), with
+known rough edges: dead `preview` flag, scratch-canvas cache keyed by width
+only, heavy per-pigment blur cost at print resolution, no UI entry point yet —
+reachable only via `tools/preview.html?renderer=watercolor`.
+
+## Parameter surface
+
+≈68 total: 53 in the UI (43 sliders / 2 selects / 8 toggles) + ~15 hidden or
+SVG-only. Flat namespace is starting to encode per-palette sub-schemas by label
+convention ("(magentarise)" ×3). Every new param is a 3–4 place edit.
+
+## Tools & outputs
+
+- 8 durable pipeline tools; ~25 one-off sketch scripts (fork-and-mutate: the
+ same `merge()` helper verbatim in 5 files, `F()` color helper redefined in 12
+ of 17 QFT tools, the contact-sheet HTML inlined 17 times). Zero shared
+ tool-level utility module.
+- `output/`: 2.4 GB, 19 directories, 675 tracked files. Chronology: inspiration
+ → masters (May 20–21) → 5 iteration series → QFT sketches 01–09 → qft-bc →
+ layering (642 MB, the largest) → liberation → templates 1–4 → watercolor +
+ milkweed (untracked).
+- `output/masters/`: 7 masters, 5 seeds, all from May 20–21. Nothing since.
+
+## Stale / dead weight
+
+- `bubble_chamber.html` — the headline "portable single file" deliverable —
+ built May 31, now 3.5 weeks behind `src/` (missing the milkweed palette, the
+ Jun 17 svgVector changes, watercolor). Nothing detects staleness; the built
+ file stamps *that* it was built, not from what.
+- `bubble_chamber.v2.html` — superseded snapshot, dead.
+- `tools/archive-wall.html` — hardcodes the May 29 curation; predates
+ templates/liberation/watercolor.
+- `compositions/hero.mjs` — the only file in `compositions/`; uses radial-grid
+ keys (`vp`, `rays`, `depthLines`) that `gridSheets` now silently ignores.
+- `tools/inkwash-export.mjs` — self-declared superseded by the native
+ watercolor renderer ("kept as a record").
+- `composer.html` — works, but one phase behind (untouched during watercolor).
+- `package.json` — 3 npm scripts, all from May 20; none of the ~25 later tools
+ registered.
+- `tools/render-qft-svg.mjs` references a `render-milkweed-svg.mjs` that
+ doesn't exist.
+
+## Known bugs
+
+1. **Ripple grids silently dropped** by `renderQFTSVG` (`src/qft/renderer.js:178-181`)
+ — every "ripple" variation across sketches 08–09 shipped without its subject.
+2. `drawDamage`/`drawInstrument` last param named `inv` but called with `light`
+ (`src/render/canvasPhoto.js:110,208`) — works today, trap for the next edit.
+3. `perspectiveGridSVG` creates an rng it never uses — the compose layer's
+ 'pA'/'pB' salts do nothing for radial grids.
+4. Dead pre-palette-era fields in `canvasPhoto.js` `palette()`; `delta.js:53`
+ comment inverts what the formula does; `watercolor.js:374` `0.05 * 8`.
diff --git a/fable/03-feedback.md b/fable/03-feedback.md
new file mode 100644
index 0000000..a89e170
--- /dev/null
+++ b/fable/03-feedback.md
@@ -0,0 +1,113 @@
+# Feedback
+
+Ordered by how much I think each item matters. Praise is included only where
+it's load-bearing for a recommendation.
+
+## 1. You are avoiding the print, and the repo shows it
+
+Eight creative phases in six weeks, and the master pipeline — the thing the
+project exists for — was last fed on day two. This has the signature of a
+familiar creative pattern: exploring is reversible and always rewarding;
+printing is a commitment that risks discovering the piece doesn't work at
+scale. So the exploration space keeps widening (new generator, new renderer,
+new subject) because widening is the move that never has to survive contact
+with a wall.
+
+The honest counter-argument is that the vocabulary wasn't ready. It's ready.
+The BEBC plates read as archival photographs; the watercolor stillwater study
+holds up; PRINT.md already contains the assembly recipe. Every week of further
+exploration is now producing information about the *screen* version of a *wall*
+piece. One A1/A2 test print of one strong seed would generate more course
+correction than any three future sketch series — it will tell you whether the
+grain scales, whether the δ-ray curls reward approach, whether "standing
+closer" works, and it will almost certainly reorder the roadmap.
+
+## 2. Decisions are being deferred into the working tree
+
+Three currently open:
+
+- The wind-field milkweed (branch, Jun 17) vs. the botanical milkweed (working
+ tree, Jun 25) — two incompatible `generateUmbel`s. Which milkweed is *the*
+ milkweed, or are they two subjects (a field study and a specimen plate)?
+ Either answer is fine; not answering is what costs.
+- The watercolor renderer: 427 good lines, uncommitted for 10 days. Ten more
+ days of this and merging the branch becomes an archaeology project.
+- The GROUP_BUILDERS registry on the branch is a strictly better composition
+ engine than what's on main, independent of milkweed. It could be
+ cherry-picked today.
+
+A useful rule given how you work (commits = sessions): **end every session with
+a commit, even a WIP one.** Your commits are already narrative; let them be
+frequent-narrative.
+
+## 3. Nothing verifies that a plate contains what its scene model says
+
+The ripple bug survived three sketch series because the only QA is your eye,
+and your eye judges *feeling*, not *inventory*. Determinism is the project's
+religion, but determinism without ground truth just means you reproducibly get
+the wrong image. Cheap fixes that fit the project's culture:
+
+- A layer-census check: render a scene, assert every `id` in the scene model
+ produced a nonempty layer/group in the output. ~30 lines, would have caught
+ the ripple drop on day one.
+- Golden-seed snapshots: 3–4 canonical seeds rendered per renderer, diffed on
+ commit (you already have headless Chrome plumbing in `shoot.sh`).
+- Stamp the build: embed the source git hash + date in `bubble_chamber.html`
+ so staleness is visible instead of silent.
+
+## 4. The look needs its own seam
+
+The scene/renderer split is genuinely well-kept — which makes it strange that
+the *style* never got the same treatment. Three renderers currently disagree
+about bubble size, and each new visual feature is hand-ported (or, for PDF,
+quietly not). `bubbleStops()` already demonstrates the fix: one shared module
+of style formulas (stroke widths, alphas, radii, spectrum mixes) consumed by
+all renderers. This is a half-day of consolidation that removes the largest
+source of drift in the codebase. Do it before renderer #5, because there will
+be a renderer #5.
+
+## 5. Sketch tools: keep the data, share the harness
+
+The 17 QFT scripts are a legitimate serial sketchbook — the variation configs
+*are* the artwork's process record, and hardcoded OUT dirs are honest about
+their one-shot nature. But the 12-line render loop, `F()`, `paper()`, and the
+gallery HTML are copy-pasted across all of them, with signature drift. One
+40-line `renderSketch(outDir, baseSeed, variations)` harness turns every future
+sketch into a pure data file. Same for the deep-`merge()` in 5 compose tools.
+Don't retrofit the old ones; they're finished documents. Change the template
+for the next one.
+
+## 6. Small structural debts worth an hour each
+
+- `carpet.js`/`perspgrid.js` are compose-system primitives parked in
+ `src/qft/` — they share nothing with the QFT pipeline.
+- `src/compose/composition.js` imports parameter defaults from
+ `src/ui/controls.js` — the headless engine depends on the UI layer. Move
+ defaults down; let the UI consume them.
+- The `MILK` pigment table just colonized core `palette.js` from the milkweed
+ satellite. One entry is fine; it's the precedent to watch — palettes should
+ be injectable from the satellite that owns them.
+- Delete or archive: `bubble_chamber.v2.html`, `tools/inkwash-export.mjs` (it
+ says goodbye in its own header), `tools/find-semicircle.mjs`. Fix or delete
+ `compositions/hero.mjs` (currently half-ignored by the code) and
+ `tools/archive-wall.html` (frozen curation), or explicitly rename them as
+ dated snapshots.
+- `params.js` QFT: either seed the camera/perturbation params or drop the
+ "seed → complete fingerprint" claim from the header. The claim is currently
+ the documentation equivalent of the ripple bug.
+
+## 7. What's working — keep doing these on purpose
+
+- **Literate headers.** Nearly every file explains *why the fake was chosen
+ over the real*. This is the best code-as-notebook practice I've seen in a
+ personal art repo, and it's why the intent/execution comparison in this
+ review was even possible.
+- **The β thesis.** "Get the statistics right and the eye believes" was the
+ founding bet and it paid; the outputs are convincing at thumbnail and at
+ 1500 px. Protect that thesis as you add renderers — watercolor already
+ correctly routes 1/β² into stroke weight.
+- **Deterministic fiction.** Plate numbers and exposure dates derived from the
+ seed hash — the forgery itself is reproducible. This idea is bigger than its
+ current use (see ideas §2).
+- **Commits as sessions** produce a legible creative history. The fix from §2
+ isn't to change the style, just the frequency.
diff --git a/fable/04-ideas.md b/fable/04-ideas.md
new file mode 100644
index 0000000..238e5c4
--- /dev/null
+++ b/fable/04-ideas.md
@@ -0,0 +1,119 @@
+# Ideas to explore
+
+Ordered roughly by information-per-effort. §1 is the forcing function; the rest
+are perpendicular moves the roadmap doesn't already contain (I've skipped
+things roadmap.md lists — stereo pairs, sonification, animation — except where
+there's a twist).
+
+## 1. The calibration print — and make the calibration itself the artwork
+
+Not "eventually print the piece": print a *test instrument*. One strong seed
+(LAMBDA-2648 already has three disk-variant masters) rendered as an A1 sheet
+that deliberately contains its own diagnostics — a strip of the plate at 1:1
+wall scale, a δ-ray magnified 10×, grain swatches at three DPIs, the archival
+header, a ruler. Real labs print calibration targets; yours would be a
+fictional lab's calibration target, which means it's simultaneously the
+cheapest possible print test *and* a legitimate piece in the archive fiction.
+You cannot art-direct "does it reward standing closer" from a screen, and this
+turns the scary commitment into a small in-universe document.
+
+## 2. Generate the catalogue raisonné from the repo itself
+
+The project plans a *fabricated* archive (Phase 4) while sitting on a *real*
+one: 675 outputs, 19 directories, sketch series with gaps, orphaned plates
+whose generating instruments were deleted, a wall-mock frozen at an old
+curation. Perpendicular move: a tool that walks `output/` + `git log` and
+typesets the actual history as an archival catalogue — every plate with its
+seed, commit hash as accession number, session name ("vast ocean") as
+expedition title, *including* the losses ("plates 07–14: instrument lost,
+holdings retained"). The deterministic-fiction machinery (seed → plate number →
+exposure date) already exists; point it at the truth. This is the Becher-grid
+"series is an argument" piece, except the argument is real, and it costs a
+script rather than a new subsystem.
+
+## 3. The neutrino plate — the bug is pointing at something
+
+Before fixing the ripple bug, notice what it made: plates whose captions
+describe content that isn't visible. That is *literally the most famous bubble
+chamber result*. Gargamelle's 1973 neutral-current discovery — the actual lab
+your plates cite — was an image where the evidence was an *absence*: a neutrino
+enters, nothing appears, an electron suddenly moves. A deliberate series of
+"invisible content" plates — captions and grease-pencil annotations
+confidently marking events the plate doesn't show, a measured arrow pointing
+at blank emulsion, `EVENT: ν̄ CANDIDATE` over undisturbed paper — is the
+purest possible execution of the roadmap's founding line: *evidence of the
+invisible*. The most devotional plate in the archive should be the one where
+faith is required. (Then fix the renderer, and let the ripples exist on
+purpose.)
+
+## 4. Fill the matrix — subjects × mediums is the actual typology
+
+Your own memory note already names the thesis: "typology MATRIX of standalone
+singles." Make the matrix literal. You have subjects {bubble chamber, QFT
+lattice, milkweed/wind} and mediums {photographic, clean vector, CMYK plate,
+watercolor} — and only ~half the cells exist. No photographic QFT (a *lattice*
+shot on worn film, with grain and grease pencil, would be extraordinary and is
+mostly plumbing — QFT only ever got an SVG renderer). No watercolor QFT. No
+photographic milkweed (a botanical subject shot as if it were a physics event —
+herbarium-as-detector). The empty cells are cheaper than any new subject
+because both axes already exist; and a wall hung *as the matrix* — same seed
+down each column — is the standalone-singles argument stated in one object.
+
+## 5. One hidden field, many instruments — say the quiet thesis out loud
+
+The wind-field branch stumbled onto the project's real unifying idea: **one
+invisible cause, several detectors that each transduce it differently** (dunes
+remember, water reports instantaneously, seeds integrate along their path).
+Notice the bubble chamber is already this — the B-field and beam are the
+hidden cause; chamber, film, and scanner are the detector chain. The QFT plates
+are this — the vacuum as field, lattices as instruments. Reframed, the project
+isn't "a bubble chamber generator plus spinoffs"; it's *portraits of invisible
+fields, each painted by the instruments that can feel them*. Concretely: merge
+the branch's field/detector separation into the architecture (`src/field/` as
+a first-class layer any subject can sample), and let one seed drive the same
+hidden field through a chamber plate, a dune plate, and a water plate — a
+triptych where the viewer slowly realizes all three images are the *same
+event*. That's the scale-vertigo feeling the roadmap chases, produced
+structurally instead of stylistically.
+
+## 6. The failure plates
+
+Real bubble chamber archives are mostly failures: empty triggers, double
+exposures, boiling chamber, light leaks, frames where the beam didn't arrive.
+Your generator only makes successes. A `misfire` archetype — blank plates with
+full film furniture, an over-exposed white-out with one surviving corner, a
+splice through the event — does three things: deepens the archive fiction
+(archives without failures read as fake), gives the typology grids their
+necessary rest-notes (24 perfect plates read as wallpaper; 21 + 3 failures read
+as history), and it's nearly free — the media/damage layers already exist and
+just need permission to win.
+
+## 7. Verification as darkroom craft, not engineering chore
+
+Framing matters in a solo art repo: don't build "CI", build *the darkroom
+contact sheet*. One command that renders the golden seeds through all four
+renderers onto a single dated sheet, with the layer census (§feedback 3)
+printed in the margin like exposure notes. Run it when you commit. It catches
+the next ripple bug, documents renderer drift as a visible artifact, and the
+sheets themselves accumulate into a record of the project's eye changing over
+time — which, given idea §2, eventually becomes material.
+
+## 8. The seed as edition certificate
+
+"The seed as a true name" is currently a private convenience. Push it to the
+viewer-facing surface: the frame label carries the seed, the git hash of the
+renderer, and the sentence "this image can be re-derived, at any size,
+forever." An edition where what's sold/kept is effectively *the name plus the
+instrument version* is both honest about how the work is made and — because
+PRINT.md's fingerprint claim currently has holes (manual tweaks, unseeded QFT
+params) — it forces the discipline to become real: if the seed is the
+certificate, everything must actually live in the seed. Theology becomes
+engineering requirement.
+
+---
+
+**One anti-idea, for balance:** no fifth subject and no fifth renderer until
+something hangs on a wall. The pattern analysis in 01/03 says the next new
+instrument is the most likely next move and the least informative one
+available. The wind field (§5) is exempt because it consolidates instruments;
+everything else can wait a print.
diff --git a/fable/README.md b/fable/README.md
new file mode 100644
index 0000000..187ac74
--- /dev/null
+++ b/fable/README.md
@@ -0,0 +1,30 @@
+# fable/ — an outside reading of the bubble chamber project
+
+Written 2026-07-04 by Claude (Fable 5), after a full review: every doc, the core
+`src/` tree, the QFT and compose subsystems, the milkweed/watercolor working set,
+all 37 tools, the 2.4 GB output archive, the git history on `main`, and the
+unmerged `milkweed-wind-field` branch. Outputs were viewed, not just listed.
+
+Four documents:
+
+1. **[01-intent-vs-execution.md](01-intent-vs-execution.md)** — what the project
+ says it is vs. what the repo shows it does. The interesting gaps, including
+ one confirmed rendering bug that is also, accidentally, the most on-theme
+ thing in the archive.
+2. **[02-current-state.md](02-current-state.md)** — a factual snapshot: what
+ exists, what's committed, what's stale, what's stranded on a branch.
+3. **[03-feedback.md](03-feedback.md)** — critique. The habits that are working,
+ the ones that are quietly costing you, and the decisions currently deferred
+ into the working tree.
+4. **[04-ideas.md](04-ideas.md)** — directions to explore, weighted toward the
+ perpendicular ones the roadmap doesn't already contain.
+
+The one-paragraph version: **the images earn their ambition; the stated purpose
+of the project is the only part of it that has never been exercised.** This is a
+large-format print project with no evidence of a print — the master pipeline was
+built in the first 48 hours and never fed again, while eight subsequent creative
+phases produced 2.2 GB of screen-resolution sketches. The instinct that keeps
+generating new instruments (QFT, compositions, milkweed, watercolor) is the
+project's engine and its avoidance mechanism at the same time. The single
+highest-information act available right now costs less than any feature on the
+roadmap: pick one seed and send it to a printer.
diff --git a/output/iterations-watercolor/index.html b/output/iterations-watercolor/index.html
new file mode 100644
index 0000000..4a8781e
--- /dev/null
+++ b/output/iterations-watercolor/index.html
@@ -0,0 +1,40 @@
+
iterations-watercolor
+
+
Bubble Chamber · iterations-watercolor
+
The wet medium — src/render/watercolor.js, the 4th renderer. The trace as
+ink-in-water: a bubble chamber is superheated liquid hydrogen, so the track is a wake of boiling
+bubbles in a fluid; watercolour is pigment migrating through water on paper — the same physics
+restated. Beer–Lambert pigment optics · wet-edge darkening · cold-press granulation · wet-into-wet
+bleed. Ten studies varying colour, composition, craft, density and viewpoint. 1400 px previews,
+deterministic from seed. render: renderer=watercolor via tools/shoot.sh.
+
+
+Stillwater — a quiet single event, warm walnut-sepia on rag, two-pigment granulation. The disk a translucent bloom; δ-rays as fern curls. mono · sepia · low density · HYPERON-8444
+
+Two Waters — charge duotone: positive a warm red wash, negative a cool blue, the two pigments mixing where tracks cross. False-colour that means something. charge · cream · STRANGE-1187
+
+Spectral Legend — one pigment per particle type: amber primaries, cyan δ-rays, blue cosmics, green sweepers, magenta V-decays. The event reads as its own legend. kind · neutral · dense · KAON-0088
+
+Sunken Bell — the low-disk semicircle: the shock half-risen from the bottom edge, a struck bell / setting sun, its corona radiating in wet pigment. mono · cream · low disk composition · OMEGA-7782
+
+Velocity — colour by speed (β): fast tracks cool blue, the slowing ones warming through cyan and green. A cool, watery plate on selenium-blue paper. beta · cool · LAMBDA-2648
+
+Birth to Death — per-bubble lifecycle: each wash shifts hue along the trail, blue at the vertex through green to a deep-red death where the pigment pools — colour migrating as wet pigment does. lifecycle (per-bubble) · cream · HYPERON-8444
+
+Dry Field — a wide, quiet sheet crossed by long faint cosmics and sweepers, the focal bloom small and low. Negative space as subject; airy dry-brush lines. mono · selenium · sparse · cosmic-heavy · COSMIC-3140
+
+Dense Chaos — maximal density, a crowded interaction star with glowing-death pigment on olive paper. Craft pushed to saturation; the plate "ran all night." kindrise (per-bubble) · olive · density 1.5 · OMEGA-7782
+
+Off-Centre — the vertex pushed to a third, a purple/magenta trace family against a single burnt-orange disk-bloom low and to the side. Asymmetric, contrapuntal colour. magentarise · neutral · off-centre composition · NUCLEON-2131
+
+Wet Bloom — the wettest craft: high wetness and bleed, soft edges, hue cycling along each wake. Pigment dissolving into damp paper — the dreaming end of the medium. psychedelic (per-bubble) · cream · wcWetness 1.6 / wcBleed 1.45 · CASCADE-2755
+
+
A common-milkweed (Asclepias syriaca) umbel, painted with the bubble-chamber project's
+own wet renderer (src/render/watercolor.js) driven by a new umbel generator
+(src/milkweed/umbel.js). The conceit that made it fit: a milkweed umbel is a radial
+event — a hidden central node, pedicels radiating like tracks, each blooming into a five-pointed
+star at its tip. The bubble chamber's vertex and δ-ray spray, restated in botany; life wearing the
+shape of the particle event. Same pigment physics as the plates — Beer–Lambert subtractive optics,
+the wet edge, cold-press granulation, wet-into-wet bleed — only the geometry and the palette change
+(sap green, quinacridone rose, a pale corona crown that barely tints the paper). Deterministic from
+seed; herbarium caption from the shared archive-identity system.
+
+
The reference
+
+Reference — Asclepias syriaca in full bloom: a domed cluster of open mauve star-flowers with pale corona crowns, deep-maroon buds, sage leaves. photo · Wikimedia Commons (Common_Milkweed_Bloom)
+
+
+
The recreations
+
+Milkweed Bloom — the recreation: a domed umbel of pink florets, pale-crowned, buds toward the rim, leaves splayed behind. milkweed · cream · MONARCH-3320
+Asclepias — a second specimen, same medium, a fuller cluster. milkweed · cream · ASCLEPIAS-7781
+Prairie — a rounder dome; the wet-into-wet bleed unifies the florets into one pink haze. milkweed · cream · PRAIRIE-5562
+Dewy — the medium turned wet: high wetness and bleed, soft edges, on cool blue-grey paper. A misted morning. milkweed · cool · wcWetness 1.5 / wcBleed 1.4 · SWAMP-1190
+Dry-brush — the opposite craft: drier, crisp wet edges, granulation turned up, on warm sepia. Late-summer, going to seed. milkweed · sepia · wcGranulate 1.35 / wcEdge 1.2 · AUTUMN-4408
+
+
diff --git a/output/milkweed/reference-asclepias-syriaca.jpg b/output/milkweed/reference-asclepias-syriaca.jpg
new file mode 100644
index 0000000..0bda9dc
Binary files /dev/null and b/output/milkweed/reference-asclepias-syriaca.jpg differ
diff --git a/roadmap.md b/roadmap.md
index b3fe99d..d42e834 100644
--- a/roadmap.md
+++ b/roadmap.md
@@ -91,6 +91,12 @@ default). Each regime is a mapping from physics → colour via palette hooks
the chamber as deep-sky object.
- **Analog photographic:** cyanotype, sepia/albumen, silver-gelatin, blueprint —
alternate *plate chemistries* rather than hues.
+- **Watercolour / inkwash (the wet medium):** ✅ `src/render/watercolor.js` — a 4th
+ renderer that treats the trace as ink-in-water (the chamber *is* a fluid). Beer–
+ Lambert pigment optics, wet-edge darkening, paper-tooth granulation, wet-into-wet
+ bleed; palettes route as multi-pigment washes (mono two-pigment sepia+payne's-grey,
+ charge red/blue duotone, kind = full physics legend). `renderer=watercolor` in the
+ preview harness. Next: a renderer SELECT in the app UI; print masters on a go-ahead.
- **Psychedelic (mind set free):** hue cycling *along* each track (rainbow wakes),
hue-by-depth, complementary bloom/halation, solarisation, oil-on-water
iridescence on the shock disk. Energy, synaesthesia, the numinous turned up.
diff --git a/src/milkweed/umbel.js b/src/milkweed/umbel.js
new file mode 100644
index 0000000..4bdcc42
--- /dev/null
+++ b/src/milkweed/umbel.js
@@ -0,0 +1,169 @@
+/* ============================================================
+ umbel.js — a common-milkweed (Asclepias syriaca) inflorescence
+ as a scene for the watercolour renderer.
+
+ Why it belongs (the taxonomy / scale-rhyme):
+ a milkweed umbel is a *radial event*. A hidden central node;
+ pedicels radiating outward like tracks; each ending not in a
+ bubble but in a five-pointed star-flower — the reflexed corolla
+ and its pale corona crown. It is the bubble chamber's vertex and
+ δ-ray spray restated in botany: life wearing the same shape as
+ the particle event. "Evidence of the invisible" → here the
+ invisible is growth, the slow algorithm of a living thing.
+
+ generateUmbel(params) returns the same scene shape the watercolour
+ renderer consumes, but expressed in its area-wash / stroke / dab
+ primitives instead of particle tracks. Deterministic from seed.
+ Parts carry a `kind` routed by the `milkweed` palette to a pigment:
+ leaf/leafdk/ground → greens behind
+ stem/pedicel/midrib → stalk & veins
+ corolla/corollad → the dusty mauve reflexed petals
+ corona/center → the pale crown & dark gynostegium
+ bud/budtip → unopened maroon buds
+ ============================================================ */
+import { makeRng, gauss, cyrb53 } from '../rng.js';
+
+const TAU = Math.PI * 2;
+
+/* a lance/ovate leaf outline from base→tip, widest near the middle, with a gentle
+ bend. Returns a closed polygon (frame coords). */
+function leafPoly(bx, by, tx, ty, halfW, bend = 0) {
+ const dx = tx - bx, dy = ty - by, L = Math.hypot(dx, dy) || 1;
+ const ux = dx / L, uy = dy / L; // spine
+ const nx = -uy, ny = ux; // normal
+ const left = [], right = [];
+ const STEPS = 14;
+ for (let i = 0; i <= STEPS; i++) {
+ const t = i / STEPS;
+ const w = halfW * Math.pow(Math.sin(Math.PI * t), 0.8);
+ const b = bend * Math.sin(Math.PI * t); // sideways bow of the spine
+ const sx = bx + dx * t + nx * b, sy = by + dy * t + ny * b;
+ left.push([sx + nx * w, sy + ny * w]);
+ right.push([sx - nx * w, sy - ny * w]);
+ }
+ const pts = [...left, ...right.reverse()].map(([x, y]) => ({ x, y }));
+ return pts;
+}
+
+/* sample a quadratic bezier into a short polyline (for curved pedicels/petals). */
+function quad(p0, c, p1, n = 6) {
+ const out = [];
+ for (let i = 0; i <= n; i++) {
+ const t = i / n, mt = 1 - t;
+ out.push({
+ x: mt * mt * p0.x + 2 * mt * t * c.x + t * t * p1.x,
+ y: mt * mt * p0.y + 2 * mt * t * c.y + t * t * p1.y,
+ });
+ }
+ return out;
+}
+
+export function generateUmbel(params) {
+ const rng = makeRng(params.seed, 'umbel');
+ const washes = [], strokes = [], dabs = [];
+
+ const cx = params.umbelX ?? 0.0;
+ const cy = params.umbelY ?? -0.1; // cluster centre (slightly high)
+ const R = params.umbelR ?? 0.4; // umbel radius — the hero
+ const squash = params.umbelSquash ?? 0.98; // gentle vertical droop
+ const nodeX = cx + (params.nodeDX ?? 0.0);
+ const nodeY = cy + (params.nodeDY ?? 0.52); // pedicels attach below the globe
+ const nFlor = Math.round(params.florets ?? 80);
+ const baseR = params.flowerR ?? 0.092;
+
+ /* ---- foliage behind: a few big soft leaves splaying out from below the cluster,
+ kept mostly clear of the umbel so the flowers read in front. Faint. ---- */
+ const leafSpec = [ // [angle, length, halfWidth, foreground?] milkweed opposite-leaf feel
+ [2.55, 1.05, 0.13, false], [0.62, 1.15, 0.14, false], // upper-left & upper-right, behind
+ [2.05, 1.25, 0.15, true], [1.05, 1.05, 0.13, true], // lower flanks, nearer
+ [1.55, 0.7, 0.10, false], // one straight down behind
+ ];
+ const nLeaf = Math.min(leafSpec.length, Math.round(params.leaves ?? 4));
+ for (let i = 0; i < nLeaf; i++) {
+ const [a0, len0, hw0, fore] = leafSpec[i];
+ const ang = a0 + gauss(rng) * 0.12;
+ const len = len0 * (0.9 + rng() * 0.2);
+ // start the blade just outside the umbel so it doesn't smother the flowers
+ const bx = cx + Math.cos(ang) * R * 0.72 + gauss(rng) * 0.04;
+ const by = cy + Math.sin(ang) * R * 0.72 * squash + 0.06;
+ const tx = bx + Math.cos(ang) * len, ty = by + Math.sin(ang) * len;
+ const hw = hw0 + rng() * 0.03, bend = gauss(rng) * 0.14;
+ washes.push({ kind: fore ? 'leaf' : 'leafdk', pts: leafPoly(bx, by, tx, ty, hw, bend), alpha: fore ? 0.24 : 0.16 });
+ strokes.push({ kind: 'midrib', pts: quad({ x: bx, y: by }, { x: (bx + tx) / 2 + bend, y: (by + ty) / 2 }, { x: tx, y: ty }, 6), width: 0.004, alpha: fore ? 0.2 : 0.12 });
+ }
+
+ // main stalk, rising to the node from the bottom
+ strokes.push({ kind: 'stem', pts: [{ x: cx + 0.015, y: 1.15 }, { x: cx + 0.005, y: nodeY + 0.28 }, { x: nodeX, y: nodeY }], width: 0.028, alpha: 0.5 });
+
+ /* ---- the florets on a drooping dome ---- */
+ const rot0 = rng() * TAU;
+ for (let i = 0; i < nFlor; i++) {
+ // fill a disk, slightly denser toward the centre; depth (near→far) from radius
+ // so the cluster reads as a rounded dome: centre flowers near/large/open, the
+ // rim far/small/budding. This fills the middle (a hemisphere projection didn't).
+ const rr = R * Math.pow(rng(), 0.62);
+ const az = rng() * TAU;
+ const fx = cx + Math.cos(az) * rr + gauss(rng) * 0.01;
+ const fy = cy + Math.sin(az) * rr * squash + 0.03 + gauss(rng) * 0.01; // +droop
+ const radial = rr / R; // 0 centre … 1 rim
+ const depth = 1 - radial * radial + gauss(rng) * 0.12; // centre nearest
+ const dA = 0.4 + 0.6 * Math.max(0, Math.min(1, depth)); // front brighter
+ const size = 0.6 + 0.55 * Math.max(0, depth); // front flowers larger
+ const fr = baseR * size;
+
+ // pedicel from node, gently bowing out to the floret
+ const mid = { x: (nodeX + fx) / 2 + gauss(rng) * 0.04, y: (nodeY + fy) / 2 + gauss(rng) * 0.03 };
+ strokes.push({ kind: 'pedicel', pts: quad({ x: nodeX, y: nodeY }, mid, { x: fx, y: fy }, 6), width: 0.0045, alpha: 0.26 * dA });
+
+ // back/upper florets tend to be unopened buds; front/lower are open stars
+ const budProb = 0.05 + 0.22 * radial; // a minority, mostly toward the rim
+ if (rng() < budProb) {
+ // bud: a maroon teardrop (body + tip) with a pale highlight — kept modest so
+ // the open flowers, not the buds, carry the cluster.
+ const ba = -Math.PI * 0.5 + gauss(rng) * 0.6; // points roughly up/out
+ const bl = fr * 0.62;
+ const tipx = fx + Math.cos(ba) * bl, tipy = fy + Math.sin(ba) * bl;
+ dabs.push({ kind: 'bud', x: fx, y: fy, r: fr * 0.32, alpha: 0.42 * dA });
+ dabs.push({ kind: 'bud', x: (fx + tipx) / 2, y: (fy + tipy) / 2, r: fr * 0.22, alpha: 0.36 * dA });
+ dabs.push({ kind: 'budtip', x: tipx, y: tipy, r: fr * 0.14, alpha: 0.32 * dA });
+ } else {
+ // open flower: 5 reflexed petals (a star) + pale corona crown + dark centre
+ const rot = rot0 + i * 2.39996 + gauss(rng) * 0.3; // golden-angle spin
+ for (let k = 0; k < 5; k++) {
+ const a = rot + k * (TAU / 5);
+ const il = fr * 0.3, ol = fr * (1.32 + rng() * 0.22); // long rays splaying out
+ const p0 = { x: fx + Math.cos(a) * il, y: fy + Math.sin(a) * il };
+ const p1 = { x: fx + Math.cos(a) * ol, y: fy + Math.sin(a) * ol };
+ // a slight reflex curve (petal bends back)
+ const c = { x: (p0.x + p1.x) / 2 + Math.cos(a + Math.PI / 2) * fr * 0.16,
+ y: (p0.y + p1.y) / 2 + Math.sin(a + Math.PI / 2) * fr * 0.16 };
+ const shaded = (k % 2 === 0) && depth < 0.7;
+ strokes.push({ kind: shaded ? 'corollad' : 'corolla', pts: quad(p0, c, p1, 5), width: 0.02 * size, alpha: 0.95 * dA });
+ }
+ // corona: a soft PALE centre (the crown reads light, the flower pink around it),
+ // 5 little hoods, and only a faint dark eye — not a bullseye.
+ dabs.push({ kind: 'corona', x: fx, y: fy, r: fr * 0.34, alpha: 0.4 * dA });
+ for (let k = 0; k < 5; k++) {
+ const a = rot + k * (TAU / 5) + Math.PI / 5;
+ dabs.push({ kind: 'corona', x: fx + Math.cos(a) * fr * 0.22, y: fy + Math.sin(a) * fr * 0.22, r: fr * 0.12, alpha: 0.46 * dA });
+ }
+ if (rng() < 0.6) dabs.push({ kind: 'center', x: fx, y: fy, r: fr * 0.09, alpha: 0.32 * dA });
+ }
+ }
+
+ // deterministic herbarium metadata (reuses the archive identity system)
+ 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 = 1901 + (ds % 96);
+ const month = 6 + (ds >> 4) % 3; // milkweed blooms Jun–Aug
+ const day = 1 + ((ds >> 8) % 28);
+ const exposure = `${year}.${String(month).padStart(2, '0')}.${String(day).padStart(2, '0')}`;
+
+ return {
+ tracks: [], washes, strokes, dabs,
+ shock: null, artifacts: { rings: [] },
+ lab: 'ASCLEPIAS SYRIACA · COMMON MILKWEED',
+ plate, exposure, vertex: { x: cx, y: cy },
+ };
+}
diff --git a/src/render/palette.js b/src/render/palette.js
index 945c7fd..97f56ec 100644
--- a/src/render/palette.js
+++ b/src/render/palette.js
@@ -147,10 +147,30 @@ function lifeColor(life, inv) {
return hslToRgb(h, 0.72, (inv ? 0.44 : 0.62) - 0.07 * t);
}
+// botanical pigments for the milkweed umbel (a taxonomy subject sharing the plate
+// language). Keyed by the part `kind` the milkweed generator stamps. Mid-dark
+// subtractive pigments — real watercolours: sap green, quinacridone rose/magenta,
+// a dusty mauve corolla, a pale crown that barely tints the paper.
+const MILK = {
+ leaf: [110, 140, 86], leafdk: [80, 108, 64],
+ stem: [98, 124, 66], pedicel: [128, 156, 88],
+ midrib: [150, 100, 84],
+ corolla: [168, 104, 134], corollad: [126, 70, 100],
+ corona: [228, 202, 210], center: [112, 76, 86],
+ bud: [134, 70, 92], budtip: [180, 130, 142],
+ ground: [120, 140, 118],
+};
+
/* ---------- the registry: each entry is a "feel" ---------- */
export const PALETTES = {
mono: { id: 'mono', label: 'Monochrome' }, // all defaults → B&W
+ // botanical: one pigment per flower part — the milkweed umbel as a wet plate.
+ milkweed: {
+ id: 'milkweed', label: 'Milkweed (botanical)',
+ ink: (t, e) => MILK[t.kind] || e.baseInk,
+ },
+
charge: {
id: 'charge', label: 'Charge duotone',
ink: (t, e) => chargeColor(t.q ?? 1, e.inv),
diff --git a/src/render/watercolor.js b/src/render/watercolor.js
new file mode 100644
index 0000000..51dc142
--- /dev/null
+++ b/src/render/watercolor.js
@@ -0,0 +1,427 @@
+/* ============================================================
+ watercolor.js — the wet renderer. One scene model → pigment.
+
+ The conceit (why this belongs in the project):
+ a bubble chamber is superheated liquid hydrogen, and the track
+ is a wake of boiling bubbles *in a fluid*. Watercolour is pigment
+ migrating through water on paper. To render the trace as ink in
+ water is not a style laid over the physics — it is the same
+ physics restated in a wetter medium. The slow end of a track
+ (the particle losing momentum, ionising harder: density ∝ 1/β²)
+ is exactly where the wash pools darkest. The δ-ray's logarithmic
+ curl becomes a curl of pigment; the shock disk blooms like a
+ drop released in still water. "Evidence of the invisible," in
+ the most literal wet sense.
+
+ The craft — the watercolour "tells" reproduced here:
+ 1. Subtractive pigment optics (Beer–Lambert): paper·e^(−A).
+ Density saturates softly, never to flat black — the glow of
+ the paper survives under every wash.
+ 2. The wet edge / edge-darkening: as a wash dries, water carries
+ pigment to the receding perimeter, leaving a darker rim. The
+ single most recognisable watercolour signature. (rim = the
+ sharp pigment minus its own diffusion.)
+ 3. Granulation: pigment settles into the tooth of cold-press
+ paper, mottling the density and separating in the valleys.
+ 4. Wet-into-wet bleed: a soft diffused halo beyond each stroke.
+ 5. Pigment load tracks speed: heavy pooled pigment at the
+ slow/dense end of range, thin dry-brush where the particle is
+ fast — this falls straight out of the bubble density model.
+ 6. The paper itself: warm rag, cold-press tooth, a back-light
+ bloom, a deckle edge and faint cockle. Unpainted paper is
+ never dead white.
+
+ Geometry is identical to the photographic + vector renderers; only
+ the medium differs. Deterministic from params.seed.
+ ============================================================ */
+import { makeRng } from '../rng.js';
+import { sampleBubbles, trackInkWeight, depthFactors } from '../scene/bubbles.js';
+import { mottleCanvas } from './noise.js';
+import { resolvePalette, paperTone } from './palette.js';
+
+const MARGIN = 0.02;
+
+/* deep pigments for the monochrome default — a sympathetic-magic palette of
+ real watercolours: a warm sepia bound with a cool payne's-grey shadow, the
+ pairing landscape painters reach for when they want "almost black" that still
+ breathes. Used as baseInk so the mono feel reads as ink-in-water, not toner. */
+const PIGMENT_SEPIA = [54, 33, 20]; // warm walnut-sepia (the body of the wash)
+const PIGMENT_SHADOW = [20, 31, 54]; // cool payne's-grey (granulates into valleys)
+
+// fast particles ionise less → thinner, paler tracks; let them recede so the slow,
+// blooming focal event holds the eye (atmosphere via physics, not arbitrary dimming).
+const KIND_LOAD = { primary: 1, delta: 1, vdecay: 1, cosmic: 0.55, sweep: 0.6 };
+
+/* a scratch canvas pool keyed by size, so we are not allocating 1200² buffers
+ on every blur read. */
+const scratch = { w: 0, a: null, b: null };
+function scratchPair(w, h) {
+ if (scratch.w !== w) {
+ scratch.w = w;
+ scratch.a = document.createElement('canvas'); scratch.a.width = w; scratch.a.height = h;
+ scratch.b = document.createElement('canvas'); scratch.b.width = w; scratch.b.height = h;
+ }
+ return [scratch.a.getContext('2d', { willReadFrequently: true }),
+ scratch.b.getContext('2d', { willReadFrequently: true })];
+}
+
+/* read one channel of a canvas into a Float32 field in [0,1]. */
+function readField(ctx, w, h, chan = 0) {
+ const d = ctx.getImageData(0, 0, w, h).data;
+ const f = new Float32Array(w * h);
+ for (let i = 0, p = chan; i < f.length; i++, p += 4) f[i] = d[p] / 255;
+ return f;
+}
+
+/* paper tooth field: cold-press grain (fine) modulated by a broad cockle/mottle,
+ in [0,1] where 1 = a raised fibre (catches less pigment) and 0 = a valley
+ (pools / granulates). Built from the project's own fbm mottle so it stays
+ deterministic and consistent with the other renderers' analog layer. */
+function toothField(seed, w, h) {
+ const fine = mottleCanvas(seed + '::tooth', 512, 150, 5); // paper grain
+ const broad = mottleCanvas(seed + '::cockle', 512, 6, 4); // cockle / undulation
+ const [ca] = scratchPair(w, h);
+ ca.clearRect(0, 0, w, h);
+ ca.drawImage(fine, 0, 0, w, h);
+ const fineF = readField(ca, w, h);
+ ca.clearRect(0, 0, w, h);
+ ca.drawImage(broad, 0, 0, w, h);
+ const broadF = readField(ca, w, h);
+ const out = new Float32Array(w * h);
+ for (let i = 0; i < out.length; i++) {
+ // centre both around 0, combine, recentre — fine dominates, broad tilts it
+ const t = 0.5 + (fineF[i] - 0.5) * 0.85 + (broadF[i] - 0.5) * 0.5;
+ out[i] = Math.max(0, Math.min(1, t));
+ }
+ return out;
+}
+
+/* blur a Float32 field by rendering it through the GPU canvas blur and reading
+ it back — far faster than a JS convolution at print sizes. */
+function blurField(field, w, h, radiusPx) {
+ const [ca, cb] = scratchPair(w, h);
+ const img = ca.createImageData(w, h);
+ const d = img.data;
+ for (let i = 0, p = 0; i < field.length; i++, p += 4) {
+ const v = Math.max(0, Math.min(255, field[i] * 255));
+ d[p] = d[p + 1] = d[p + 2] = v; d[p + 3] = 255;
+ }
+ ca.putImageData(img, 0, 0);
+ cb.clearRect(0, 0, w, h);
+ cb.filter = `blur(${radiusPx.toFixed(2)}px)`;
+ cb.drawImage(ca.canvas, 0, 0);
+ cb.filter = 'none';
+ return readField(cb, w, h);
+}
+
+export function renderWatercolor(ctx, w, h, scene, params, opts = {}) {
+ const pre = opts.preview !== false;
+ const u = w / 1000; // unit scale relative to 1000px
+ 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;
+
+ // ---- the medium dials (all optional; sensible wet defaults) ----
+ const D = {
+ pigment: params.wcPigment ?? 1.0, // overall optical density
+ edge: params.wcEdge ?? 1.0, // wet-edge / edge-darkening strength
+ bleed: params.wcBleed ?? 1.0, // wet-into-wet halo
+ granulate: params.wcGranulate ?? 1.0, // how much the paper tooth bites
+ tooth: params.wcTooth ?? 1.0, // visible paper texture in the wash
+ wetness: params.wcWetness ?? 1.0, // diffusion radius scale
+ };
+
+ // ---- colour: a light rag-paper ground, pigments dark-on-light ----
+ // Force the light-ground (positive) reading regardless of the global invert —
+ // watercolour lives on paper. Palettes still map physics → pigment hue.
+ const pt = paperTone({ ...params, paperBright: (params.paperBright ?? 1) * 1.06 }, true);
+ const pal = resolvePalette(params.palette, {
+ inv: false, sat: params.saturation ?? 1, hue: (params.hueShift ?? 0) * 360,
+ cycles: params.hueCycles ?? 3,
+ traceHue: params.traceHue ?? 0, diskHue: params.diskHue ?? 0.06, diskSat: params.diskSat ?? 0.82,
+ baseInk: PIGMENT_SEPIA,
+ basePaper: { flat: pt.flat, glowIn: pt.glowIn, glowOut: pt.glowOut },
+ baseVign: pt.vign,
+ });
+ const paper = pal.hasPaper ? pal.paper() : { flat: pt.flat, glowIn: pt.glowIn, glowOut: pt.glowOut };
+
+ // ---- paper tooth (shared by every wash) ----
+ const tooth = toothField(params.seed, w, h);
+
+ // global absorbance accumulators (Beer–Lambert, per channel)
+ const N = w * h;
+ const Ar = new Float32Array(N), Ag = new Float32Array(N), Ab = new Float32Array(N);
+ const bubbleRng = makeRng(params.seed, 'bubbles');
+
+ /* lay one pigment down as a wash: `paint(mc)` deposits white "wet mass" onto a
+ scratch context; we then derive the wet structure (edge-darkening, bleed,
+ granulation) and pull the pigment's colour through Beer–Lambert into the
+ global absorbance. `col` is the pigment; `granPartner` (optional) is a second
+ pigment that separates into the tooth valleys — real granulating-watercolour
+ behaviour, and the source of the wash's living, two-tone shimmer. */
+ function layWash(paint, col, granPartner = null, granAmt = 0) {
+ const [mc] = scratchPair(w, h);
+ mc.clearRect(0, 0, w, h);
+ mc.globalCompositeOperation = 'lighter'; // pigment mass accumulates
+ mc.lineCap = 'round'; mc.lineJoin = 'round';
+ paint(mc);
+ mc.globalAlpha = 1;
+ mc.globalCompositeOperation = 'source-over';
+
+ const M = readField(mc, w, h);
+ const sharp = blurField(M, w, h, 1.15 * u); // soften beads into wash
+ const diffuse = blurField(M, w, h, (4.5 * D.wetness) * u); // the drying-front spread
+ const wide = blurField(M, w, h, (14 * D.wetness) * u); // the wet-into-wet halo
+
+ const cr = 1 - col[0] / 255, cg = 1 - col[1] / 255, cb = 1 - col[2] / 255;
+ let gr = cr, gg = cg, gb = cb;
+ if (granPartner) { gr = 1 - granPartner[0] / 255; gg = 1 - granPartner[1] / 255; gb = 1 - granPartner[2] / 255; }
+ const k = 2.7 * D.pigment;
+ for (let i = 0; i < N; i++) {
+ const s = sharp[i];
+ if (s < 0.003 && wide[i] < 0.003) continue;
+ // wet edge: water receding to the perimeter concentrates pigment in a rim.
+ const rim = Math.max(0, s - diffuse[i]);
+ // wet-into-wet halo: pigment carried out beyond the stroke into damp paper.
+ const halo = Math.max(0, wide[i] - s);
+ let p = s + rim * (2.2 * D.edge) + halo * (0.85 * D.bleed);
+ // granulation: pigment settles in the tooth valleys (low tooth), thins on
+ // the raised fibres — a textured, mottled density rather than a flat wash.
+ const tv = tooth[i] - 0.5; // +raised fibre … −valley
+ const g = 1 - D.granulate * (0.6 * tv + 0.12);
+ p *= Math.max(0.18, g);
+ const a = p * k;
+ // the granulating partner deposits preferentially in the valleys
+ const frac = granPartner ? granAmt * Math.max(0, 0.5 - tv) : 0;
+ const aMain = a * (1 - frac), aGran = a * frac;
+ Ar[i] += aMain * cr + aGran * gr;
+ Ag[i] += aMain * cg + aGran * gg;
+ Ab[i] += aMain * cb + aGran * gb;
+ }
+ }
+
+ // soft round pigment deposit (radial alpha) — no per-stamp filter (slow); the
+ // diffusion blur in layWash carries the wet spread.
+ function blob(mc, X, Y, R, a) {
+ const grd = mc.createRadialGradient(X, Y, 0, X, Y, R);
+ grd.addColorStop(0, `rgba(255,255,255,${a})`);
+ grd.addColorStop(0.5, `rgba(255,255,255,${a * 0.5})`);
+ grd.addColorStop(1, 'rgba(255,255,255,0)');
+ mc.fillStyle = grd;
+ mc.beginPath(); mc.arc(X, Y, R, 0, Math.PI * 2); mc.fill();
+ }
+
+ // ---- collect pigment deposits into colour buckets ----
+ // Each bucket is one pigment with its own drying front (its own wet edge). For a
+ // per-track palette a track contributes its whole self to one bucket; for a
+ // per-bubble palette (lifecycle, kindrise, psychedelic…) each *bubble* lands in
+ // the bucket of its own colour, so the wash shifts hue along the trail the way wet
+ // pigment really migrates and separates. mono additionally carries a granulating
+ // partner (cool payne's-grey settling out of the warm sepia).
+ const isMono = (params.palette ?? 'mono') === 'mono';
+ const perBubble = pal.perBubble;
+ const QUANT = 28; // colour quantisation → bounded buckets
+ const qkey = (c) => `${Math.round(c[0] / QUANT)}_${Math.round(c[1] / QUANT)}_${Math.round(c[2] / QUANT)}`;
+ const deps = new Map(); // key → { col, fills, strokes, blobs }
+ const bucketFor = (col) => {
+ const key = qkey(col);
+ let d = deps.get(key);
+ if (!d) { d = { col, fills: [], strokes: [], blobs: [] }; deps.set(key, d); }
+ return d;
+ };
+
+ for (const track of scene.tracks || []) {
+ if (track.pts.length < 2) continue;
+ const df = depthFactors(track, params);
+ const load = KIND_LOAD[track.kind] ?? 1;
+ const repCol = pal.ink(track);
+ // 1) continuity wash — a soft varying-width body of water, heavier where the
+ // particle ionises hardest (1/β² via trackInkWeight) — to the track's pigment.
+ const iw = trackInkWeight(track);
+ const lw = Math.min(5.5, 0.7 + Math.sqrt(iw) * 0.6) * u * params.size * track.weight;
+ if (lw >= 0.25 * u) bucketFor(repCol).strokes.push({ pts: track.pts, lw, alpha: 0.27 * df.tone * load });
+ // 2) pigment grain — discrete bubbles (shared nucleation model); pooling at the
+ // slow/dense end falls out of the 1/β² density for free. Per-bubble colour
+ // when the palette varies along the trail.
+ const bubs = sampleBubbles({ ...track, sizeScale: df.sizeScale }, params, bubbleRng);
+ const a = Math.min(1, 0.5 + df.tone * 0.5) * load;
+ for (const b of bubs) {
+ const col = perBubble ? pal.bubbleInk(track, b.life, b.beta) : repCol;
+ bucketFor(col).blobs.push({ x: tx(b.x), y: ty(b.y), r: Math.max(b.r * scale, 0.5) * 2.6, a: a * 0.36 });
+ }
+ }
+
+ // ---- explicit geometry primitives (used by non-particle subjects, e.g. the
+ // milkweed umbel): area washes (leaves/ground), strokes (pedicels, petals,
+ // midribs — no bubble grain), and dabs (corona crowns, bud bodies, centres).
+ // Each carries its own pigment `kind`, routed through the same palette. All
+ // optional → particle scenes set none of these and behave unchanged. ----
+ const inkKind = (kind) => pal.ink({ kind, pts: [{ beta: 0.5 }], q: 1 });
+ for (const wsh of (scene.washes || [])) {
+ if (!wsh.pts || wsh.pts.length < 3) continue;
+ bucketFor(inkKind(wsh.kind)).fills.push({ pts: wsh.pts, alpha: wsh.alpha ?? 0.45 });
+ }
+ for (const st of (scene.strokes || [])) {
+ if (!st.pts || st.pts.length < 2) continue;
+ bucketFor(inkKind(st.kind)).strokes.push({ pts: st.pts, lw: (st.width ?? 0.008) * scale, alpha: st.alpha ?? 0.3 });
+ }
+ for (const db of (scene.dabs || [])) {
+ bucketFor(inkKind(db.kind)).blobs.push({ x: tx(db.x), y: ty(db.y), r: (db.r ?? 0.01) * scale, a: db.alpha ?? 0.5 });
+ }
+
+ // ---- lay each pigment bucket as its own wash ----
+ for (const { col, fills, strokes, blobs } of deps.values()) {
+ layWash((mc) => {
+ // area washes first (they sit beneath), then strokes, then grain/dabs
+ for (const f of fills) {
+ mc.globalAlpha = f.alpha;
+ mc.beginPath(); mc.moveTo(tx(f.pts[0].x), ty(f.pts[0].y));
+ for (let i = 1; i < f.pts.length; i++) mc.lineTo(tx(f.pts[i].x), ty(f.pts[i].y));
+ mc.closePath(); mc.fillStyle = 'rgba(255,255,255,1)'; mc.fill();
+ }
+ for (const s of strokes) {
+ mc.globalAlpha = s.alpha; mc.lineWidth = s.lw;
+ mc.beginPath(); mc.moveTo(tx(s.pts[0].x), ty(s.pts[0].y));
+ for (let i = 1; i < s.pts.length; i++) mc.lineTo(tx(s.pts[i].x), ty(s.pts[i].y));
+ mc.stroke();
+ }
+ // larger, softer, lower-alpha deposits coalesce into a textured wash, not beads
+ for (const b of blobs) blob(mc, b.x, b.y, b.r, b.a);
+ }, col, isMono ? PIGMENT_SHADOW : null, isMono ? 0.5 : 0);
+ }
+
+ // ---- the shock disk: a drop of pigment released into still water ----
+ if (scene.shock && (params.diskOff !== true)) {
+ const sh = scene.shock;
+ const feat = pal.feature();
+ const SX = tx(sh.x), SY = ty(sh.y), R = sh.r * scale;
+ layWash((mc) => {
+ // the bloom: a dense mid-radius ring (the drop's wet front) fading both ways
+ mc.globalAlpha = 1;
+ // a translucent bloom: faint, luminous centre (the drop's clearing eye)
+ // rising to a soft wet front near the rim, then feathering out. Kept light
+ // on purpose so paper-glow, striations and granulation read *through* it.
+ const ring = mc.createRadialGradient(SX, SY, 0, SX, SY, R);
+ const I = sh.intensity;
+ ring.addColorStop(0.0, `rgba(255,255,255,${0.03 * I})`);
+ ring.addColorStop(0.5, `rgba(255,255,255,${0.07 * I})`);
+ ring.addColorStop(0.86, `rgba(255,255,255,${0.17 * I})`);
+ ring.addColorStop(1.0, 'rgba(255,255,255,0)');
+ mc.fillStyle = ring;
+ mc.beginPath(); mc.arc(SX, SY, R, 0, Math.PI * 2); mc.fill();
+ // striations as fine pigment threads radiating from the centre — now the
+ // dominant disk gesture (the sunburst of a struck bell / a drop's corona).
+ mc.globalCompositeOperation = 'lighter';
+ mc.lineCap = 'round';
+ for (const s of (sh.striations || [])) {
+ const ix = SX + Math.cos(s.a) * s.inner * scale, iy = SY + Math.sin(s.a) * s.inner * scale;
+ const ox = SX + Math.cos(s.a) * s.outer * scale, oy = SY + Math.sin(s.a) * s.outer * scale;
+ mc.globalAlpha = s.opacity * 0.38;
+ mc.lineWidth = (s.width || 1) * u;
+ mc.beginPath(); mc.moveTo(ix, iy); mc.lineTo(ox, oy); mc.stroke();
+ }
+ // disk-bubble strokes (rings/rim/core) if present, as grain
+ if (sh.bubbleStrokes) {
+ const dRng = makeRng(params.seed, 'diskbubbles');
+ for (const stroke of sh.bubbleStrokes) {
+ const bubs = sampleBubbles(stroke, params, dRng);
+ for (const b of bubs) blob(mc, tx(b.x), ty(b.y), Math.max(b.r * scale, 0.5) * 1.9, 0.24);
+ }
+ }
+ }, feat, isMono ? PIGMENT_SHADOW : null, isMono ? 0.4 : 0);
+ }
+
+ // ---- paper ground with back-light bloom + tooth, then pull pigment through it ----
+ const img = ctx.createImageData(w, h);
+ const D8 = img.data;
+ const pf = paper.flat, pin = paper.glowIn, pout = paper.glowOut;
+ const cxg = w * 0.5, cyg = h * 0.46, rg = w * 0.72;
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ const i = y * w + x;
+ // radial back-light: paper glows from within (a lit chamber behind the wash)
+ const dx = (x - cxg) / rg, dy = (y - cyg) / rg;
+ const rr = Math.min(1, Math.sqrt(dx * dx + dy * dy));
+ const gl = 1 - rr * rr;
+ let pr = pout[0] + (pin[0] - pout[0]) * gl;
+ let pg = pout[1] + (pin[1] - pout[1]) * gl;
+ let pb = pout[2] + (pin[2] - pout[2]) * gl;
+ pr = pf[0] + (pr - pf[0]) * 0.55; pg = pf[1] + (pg - pf[1]) * 0.55; pb = pf[2] + (pb - pf[2]) * 0.55;
+ // paper tooth visible even on bare paper (very subtle), stronger in the wash
+ const th = (tooth[i] - 0.5);
+ const toothShade = 1 - D.tooth * 0.05 * (0.5 - th) * 2;
+ pr *= toothShade; pg *= toothShade; pb *= toothShade;
+ // Beer–Lambert: paper · e^(−A)
+ const p4 = i * 4;
+ D8[p4] = Math.max(0, Math.min(255, pr * Math.exp(-Ar[i])));
+ D8[p4 + 1] = Math.max(0, Math.min(255, pg * Math.exp(-Ag[i])));
+ D8[p4 + 2] = Math.max(0, Math.min(255, pb * Math.exp(-Ab[i])));
+ D8[p4 + 3] = 255;
+ }
+ }
+ ctx.putImageData(img, 0, 0);
+
+ // ---- tidelines: the scene's own water rings read as pale pigment high-water
+ // marks — a quiet rhyme between "plate damage" and the wet medium. ----
+ if (scene.artifacts && scene.artifacts.rings && D.bleed > 0) {
+ ctx.save();
+ ctx.globalCompositeOperation = 'multiply';
+ for (const ring of scene.artifacts.rings) {
+ const rr = ring.r * scale;
+ const grd = ctx.createRadialGradient(tx(ring.x), ty(ring.y), rr * 0.86, tx(ring.x), ty(ring.y), rr);
+ grd.addColorStop(0, 'rgba(120,96,70,0)');
+ grd.addColorStop(0.7, `rgba(120,96,70,${0.05 * ring.opacity * 8})`);
+ grd.addColorStop(1, 'rgba(120,96,70,0)');
+ ctx.strokeStyle = grd;
+ ctx.lineWidth = Math.max(1, rr * 0.06);
+ ctx.beginPath(); ctx.arc(tx(ring.x), ty(ring.y), rr * 0.93, 0, Math.PI * 2); ctx.stroke();
+ }
+ ctx.restore();
+ }
+
+ // ---- deckle edge: the torn, soft border of hand-made paper ----
+ drawDeckle(ctx, w, h, params, u);
+
+ // ---- the hand: a pencilled seed/lab caption, the studied archive note ----
+ if (params.showHeader) drawCaption(ctx, w, h, scene, params, u);
+}
+
+/* an irregular soft paper border — bright paper feathering to a faint torn line. */
+function drawDeckle(ctx, w, h, params, u) {
+ const rng = makeRng(params.seed, 'deckle');
+ const inset = 14 * u;
+ ctx.save();
+ // a soft inner vignette of *light* (paper curling up at the edges into the light)
+ const vg = ctx.createRadialGradient(w / 2, h / 2, w * 0.40, w / 2, h / 2, w * 0.72);
+ vg.addColorStop(0, 'rgba(40,30,18,0)');
+ vg.addColorStop(1, 'rgba(40,30,18,0.10)');
+ ctx.fillStyle = vg; ctx.fillRect(0, 0, w, h);
+ // torn deckle line
+ ctx.globalCompositeOperation = 'multiply';
+ ctx.strokeStyle = 'rgba(60,48,32,0.16)';
+ ctx.lineWidth = 1.2 * u;
+ ctx.beginPath();
+ const edge = (along, base) => base + (rng() - 0.5) * 6 * u;
+ ctx.moveTo(edge(0, inset), inset);
+ for (let x = 0; x <= w; x += 24 * u) ctx.lineTo(x, edge(x, inset));
+ for (let y = 0; y <= h; y += 24 * u) ctx.lineTo(edge(y, w - inset), y);
+ for (let x = w; x >= 0; x -= 24 * u) ctx.lineTo(x, edge(x, h - inset));
+ for (let y = h; y >= 0; y -= 24 * u) ctx.lineTo(edge(y, inset), y);
+ ctx.closePath(); ctx.stroke();
+ ctx.restore();
+}
+
+/* a faint graphite caption — the human who studied the plate, kept in. */
+function drawCaption(ctx, w, h, scene, params, u) {
+ ctx.save();
+ ctx.globalCompositeOperation = 'multiply';
+ ctx.fillStyle = 'rgba(58,52,44,0.55)';
+ ctx.font = `${11 * u}px 'JetBrains Mono', ui-monospace, monospace`;
+ ctx.textBaseline = 'bottom';
+ ctx.fillText(`${scene.lab}`, 30 * u, h - 30 * u - 15 * u);
+ ctx.fillStyle = 'rgba(58,52,44,0.42)';
+ ctx.font = `${9 * u}px 'JetBrains Mono', ui-monospace, monospace`;
+ ctx.fillText(`seed ${params.seed} · plate ${scene.plate} · ${scene.exposure}`, 30 * u, h - 30 * u);
+ ctx.restore();
+}
diff --git a/tools/inkwash-export.mjs b/tools/inkwash-export.mjs
new file mode 100644
index 0000000..09c0fe8
--- /dev/null
+++ b/tools/inkwash-export.mjs
@@ -0,0 +1,58 @@
+/* ============================================================
+ inkwash-export.mjs — bubble-chamber geometry → inkwash strokes.
+ Exploratory bridge: take a generated scene's tracks and emit a
+ strokes.js that the forked inkwash play.html replays through its
+ own brush engine + fluid sim → a watercolor/ink rendering of a
+ particle event. Each track becomes one pen stroke; β drives the
+ pen pressure (slow/dense end of range = heavier ink).
+
+ NOTE (2026-06-24): the wet look now lives NATIVELY in the project as
+ `src/render/watercolor.js` (renderer=watercolor), on the same scene model —
+ no external fork needed. This bridge is kept as a record / for driving the
+ external play.html, but the live watercolour path is the renderer.
+
+ Usage: node tools/inkwash-export.mjs [seed] [out.js] [maxTracks] [deltaFrac]
+ ============================================================ */
+import { writeFileSync } from 'node:fs';
+import { generateScene } from '../src/scene/scene.js';
+import { paramsFromSeed } from '../src/scene/params.js';
+
+const SEED = process.argv[2] || 'KAON-0088';
+const OUT = process.argv[3] || '/tmp/inkwash/strokes.js';
+const MAXTRACKS = +(process.argv[4] || 26);
+const DELTAFRAC = +(process.argv[5] ?? 0.08); // fraction of δ-ray curls to keep
+const FIT = 0.62; // event half-extent in uv (×0.5)
+
+const p = paramsFromSeed(SEED);
+const scene = generateScene(p);
+
+// frame coords (−1.2..1.2, y down) → inkwash uv (0..1, y up)
+const toUv = (x, y) => [0.5 + x * 0.5 * FIT, 0.5 - y * 0.5 * FIT];
+
+// the heavy structural tracks first; δ-rays are tiny curls — keep only a few
+const heavy = scene.tracks.filter(t => t.kind !== 'delta');
+const deltas = scene.tracks.filter(t => t.kind === 'delta');
+const keepDeltas = deltas.filter((_, i) => i % Math.max(1, Math.round(1 / Math.max(DELTAFRAC, 1e-3))) === 0);
+// longest/most-energetic first so the cap keeps the spine of the event
+const ranked = heavy.sort((a, b) => b.pts.length - a.pts.length).slice(0, MAXTRACKS);
+const chosen = [...ranked, ...keepDeltas];
+
+const strokes = [];
+for (const tr of chosen) {
+ const pts = tr.pts.map(pt => {
+ const [u, v] = toUv(pt.x, pt.y);
+ // β low (slow, end of range) ⇒ denser ionisation ⇒ heavier ink
+ const pr = Math.min(0.95, Math.max(0.18, 0.30 + 0.65 * (1 - (pt.beta ?? 0.6))));
+ return [+u.toFixed(4), +v.toFixed(4), +pr.toFixed(3)];
+ }).filter(([u, v]) => u > -0.2 && u < 1.2 && v > -0.2 && v < 1.2);
+ if (pts.length >= 2) strokes.push({ mode: 'pen', pts });
+}
+
+const opts = { stepUv: 0.008, gapFrames: 6, settleFrames: 120, fixEnd: true };
+const totalPts = strokes.reduce((s, k) => s + k.pts.length, 0);
+writeFileSync(OUT,
+ `/* generated from bubble-chamber seed ${SEED} — ${strokes.length} strokes, ${totalPts} pts */\n` +
+ `window.INKWASH_OPTS = ${JSON.stringify(opts)};\n` +
+ `window.INKWASH_STROKES = ${JSON.stringify(strokes)};\n`);
+console.log(`inkwash-export: seed ${SEED} → ${OUT}`);
+console.log(` ${chosen.length} tracks (${ranked.length} structural + ${keepDeltas.length} δ) → ${strokes.length} strokes, ${totalPts} pts`);
diff --git a/tools/milkweed.html b/tools/milkweed.html
new file mode 100644
index 0000000..76ddad2
--- /dev/null
+++ b/tools/milkweed.html
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
diff --git a/tools/preview.html b/tools/preview.html
index 4656608..1a4e099 100644
--- a/tools/preview.html
+++ b/tools/preview.html
@@ -6,6 +6,7 @@