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
+ +
+ diff --git a/output/milkweed/index.html b/output/milkweed/index.html new file mode 100644 index 0000000..9cb0b4e --- /dev/null +++ b/output/milkweed/index.html @@ -0,0 +1,34 @@ +milkweed · watercolour + +

Milkweed in bloom · recreated in watercolour

+

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

+
+
ReferenceAsclepias 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 @@