Fable Review

This commit is contained in:
2026-07-04 09:47:50 -04:00
parent e7fb2e9a68
commit 4314087557
15 changed files with 1302 additions and 3 deletions

View File

@@ -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 2021** — 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 13001600 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.
- ~150200 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 0109,
templates 14, 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.

92
fable/02-current-state.md Normal file
View File

@@ -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 2425) |
| 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 2425) 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`);
~150200 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 34 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 2021) → 5 iteration series → QFT sketches 0109 → qft-bc →
layering (642 MB, the largest) → liberation → templates 14 → watercolor +
milkweed (untracked).
- `output/masters/`: 7 masters, 5 seeds, all from May 2021. 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 0809 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`.

113
fable/03-feedback.md Normal file
View File

@@ -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: 34 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.

119
fable/04-ideas.md Normal file
View File

@@ -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 0714: 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.

30
fable/README.md Normal file
View File

@@ -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.

View File

@@ -0,0 +1,40 @@
<!DOCTYPE html><html><head><meta charset="utf-8"><title>iterations-watercolor</title>
<style>body{margin:0;background:#0b0b0b;color:#bbb;font:12px/1.5 ui-monospace,Menlo,monospace;padding:26px}
h1{font-weight:400;letter-spacing:.22em;text-transform:uppercase;font-size:13px;color:#c79b6a}
p.note{color:#777;max-width:62em}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:20px;margin-top:18px}
figure{margin:0;background:#000;border:1px solid #262626}img{width:100%;display:block}
figcaption{padding:8px 10px;color:#e8e4d8}
figcaption b{color:#fff;font-weight:600}
span.s{color:#555}</style></head><body>
<h1>Bubble Chamber · iterations-watercolor</h1>
<p class="note">The wet medium — <code>src/render/watercolor.js</code>, 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. BeerLambert pigment optics · wet-edge darkening · cold-press granulation · wet-into-wet
bleed. Ten studies varying colour, composition, craft, density and viewpoint. 1400&nbsp;px previews,
deterministic from seed. <span class="s">render: <code>renderer=watercolor</code> via tools/shoot.sh.</span></p>
<div class=grid>
<figure><img src="01_stillwater-sepia.png"><figcaption><b>Stillwater</b> — a quiet single event, warm walnut-sepia on rag, two-pigment granulation. The disk a translucent bloom; δ-rays as fern curls.<br><span class="s">mono · sepia · low density · HYPERON-8444</span></figcaption></figure>
<figure><img src="02_two-waters-charge.png"><figcaption><b>Two Waters</b> — charge duotone: positive a warm red wash, negative a cool blue, the two pigments mixing where tracks cross. False-colour that <i>means</i> something.<br><span class="s">charge · cream · STRANGE-1187</span></figcaption></figure>
<figure><img src="03_spectral-legend-kind.png"><figcaption><b>Spectral Legend</b> — one pigment per particle type: amber primaries, cyan δ-rays, blue cosmics, green sweepers, magenta V-decays. The event reads as its own legend.<br><span class="s">kind · neutral · dense · KAON-0088</span></figcaption></figure>
<figure><img src="04_sunken-bell.png"><figcaption><b>Sunken Bell</b> — the low-disk semicircle: the shock half-risen from the bottom edge, a struck bell / setting sun, its corona radiating in wet pigment.<br><span class="s">mono · cream · low disk composition · OMEGA-7782</span></figcaption></figure>
<figure><img src="05_velocity-beta.png"><figcaption><b>Velocity</b> — colour by speed (β): fast tracks cool blue, the slowing ones warming through cyan and green. A cool, watery plate on selenium-blue paper.<br><span class="s">beta · cool · LAMBDA-2648</span></figcaption></figure>
<figure><img src="06_lifecycle-bloom.png"><figcaption><b>Birth to Death</b> — 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.<br><span class="s">lifecycle (per-bubble) · cream · HYPERON-8444</span></figcaption></figure>
<figure><img src="07_dryfield-cosmic.png"><figcaption><b>Dry Field</b> — 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.<br><span class="s">mono · selenium · sparse · cosmic-heavy · COSMIC-3140</span></figcaption></figure>
<figure><img src="08_dense-chaos-olive.png"><figcaption><b>Dense Chaos</b> — maximal density, a crowded interaction star with glowing-death pigment on olive paper. Craft pushed to saturation; the plate "ran all night."<br><span class="s">kindrise (per-bubble) · olive · density 1.5 · OMEGA-7782</span></figcaption></figure>
<figure><img src="09_offcenter-magenta.png"><figcaption><b>Off-Centre</b> — 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.<br><span class="s">magentarise · neutral · off-centre composition · NUCLEON-2131</span></figcaption></figure>
<figure><img src="10_wet-psychedelic.png"><figcaption><b>Wet Bloom</b> — 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.<br><span class="s">psychedelic (per-bubble) · cream · wcWetness 1.6 / wcBleed 1.45 · CASCADE-2755</span></figcaption></figure>
</div>
</body></html>

View File

@@ -0,0 +1,34 @@
<!DOCTYPE html><html><head><meta charset="utf-8"><title>milkweed · watercolour</title>
<style>body{margin:0;background:#0b0b0b;color:#bbb;font:12px/1.5 ui-monospace,Menlo,monospace;padding:26px}
h1{font-weight:400;letter-spacing:.22em;text-transform:uppercase;font-size:13px;color:#b98aa0}
h2{font-weight:400;letter-spacing:.16em;text-transform:uppercase;font-size:11px;color:#7da06a;margin-top:30px}
p.note{color:#777;max-width:64em}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:20px;margin-top:16px}
figure{margin:0;background:#000;border:1px solid #262626}img{width:100%;display:block}
figcaption{padding:8px 10px;color:#e8e4d8}figcaption b{color:#fff;font-weight:600}
span.s{color:#555}.ref{border-color:#3a3a3a}</style></head><body>
<h1>Milkweed in bloom · recreated in watercolour</h1>
<p class="note">A common-milkweed (<i>Asclepias syriaca</i>) umbel, painted with the bubble-chamber project's
own wet renderer (<code>src/render/watercolor.js</code>) driven by a new umbel generator
(<code>src/milkweed/umbel.js</code>). The conceit that made it fit: a milkweed umbel is a <i>radial
event</i> — 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 — BeerLambert 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.</p>
<h2>The reference</h2>
<div class=grid>
<figure class=ref><img src="reference-asclepias-syriaca.jpg"><figcaption><b>Reference</b><i>Asclepias syriaca</i> in full bloom: a domed cluster of open mauve star-flowers with pale corona crowns, deep-maroon buds, sage leaves.<br><span class="s">photo · Wikimedia Commons (Common_Milkweed_Bloom)</span></figcaption></figure>
</div>
<h2>The recreations</h2>
<div class=grid>
<figure><img src="01_bloom-monarch.png"><figcaption><b>Milkweed Bloom</b> — the recreation: a domed umbel of pink florets, pale-crowned, buds toward the rim, leaves splayed behind.<br><span class="s">milkweed · cream · MONARCH-3320</span></figcaption></figure>
<figure><img src="02_bloom-asclepias.png"><figcaption><b>Asclepias</b> — a second specimen, same medium, a fuller cluster.<br><span class="s">milkweed · cream · ASCLEPIAS-7781</span></figcaption></figure>
<figure><img src="03_bloom-prairie.png"><figcaption><b>Prairie</b> — a rounder dome; the wet-into-wet bleed unifies the florets into one pink haze.<br><span class="s">milkweed · cream · PRAIRIE-5562</span></figcaption></figure>
<figure><img src="04_dewy-wetbleed.png"><figcaption><b>Dewy</b> — the medium turned wet: high wetness and bleed, soft edges, on cool blue-grey paper. A misted morning.<br><span class="s">milkweed · cool · wcWetness 1.5 / wcBleed 1.4 · SWAMP-1190</span></figcaption></figure>
<figure><img src="05_drybrush-sepia.png"><figcaption><b>Dry-brush</b> — the opposite craft: drier, crisp wet edges, granulation turned up, on warm sepia. Late-summer, going to seed.<br><span class="s">milkweed · sepia · wcGranulate 1.35 / wcEdge 1.2 · AUTUMN-4408</span></figcaption></figure>
</div>
</body></html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

View File

@@ -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.

169
src/milkweed/umbel.js Normal file
View File

@@ -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 JunAug
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 },
};
}

View File

@@ -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),

427
src/render/watercolor.js Normal file
View File

@@ -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 (BeerLambert): 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 (BeerLambert, 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 BeerLambert 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;
// BeerLambert: 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();
}

58
tools/inkwash-export.mjs Normal file
View File

@@ -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`);

34
tools/milkweed.html Normal file
View File

@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><style>html,body{margin:0;background:#000}canvas{display:block}</style></head>
<body>
<canvas id="c"></canvas>
<script type="module">
import { generateUmbel } from '../src/milkweed/umbel.js';
import { renderWatercolor } from '../src/render/watercolor.js';
const q = new URLSearchParams(location.search);
const params = {
seed: q.get('seed') || 'ASCLEPIAS-0001',
palette: 'milkweed',
paperTone: 'cream', toneStrength: 1, paperBright: 1.06, glow: 0.5,
saturation: 1, hueShift: 0, size: 1, density: 1, depth: 0, aging: 0,
showHeader: true,
};
// pass through any query param (numbers/bools), incl. wc* dials and umbel* knobs
for (const [k, v] of q) {
if (k === 'size_px' || k === 'hq') continue;
params[k] = (v === 'true') ? true : (v === 'false') ? false : (isNaN(+v) ? v : +v);
}
const SIZE = +(q.get('size_px') || 1200);
const c = document.getElementById('c');
c.width = c.height = SIZE;
const ctx = c.getContext('2d', { willReadFrequently: true });
const scene = generateUmbel(params);
renderWatercolor(ctx, SIZE, SIZE, scene, params, { preview: q.get('hq') !== '1' });
document.title = 'done';
window.__done = true;
</script>
</body>
</html>

View File

@@ -6,6 +6,7 @@
<script type="module">
import { generateScene } from '../src/scene/scene.js';
import { renderCanvasPhoto } from '../src/render/canvasPhoto.js';
import { renderWatercolor } from '../src/render/watercolor.js';
import { GROUPS, TOGGLES, SELECTS, FIXED, PRESETS } from '../src/ui/controls.js';
import { paramsFromSeed } from '../src/scene/params.js';
@@ -24,8 +25,9 @@ if (q.get('fromseed') === '1') {
}
if (q.get('seed')) params.seed = q.get('seed');
for (const [k, v] of q) {
if (k === 'seed' || k === 'preset' || k === 'size_px') continue;
if (k in params) params[k] = (v === 'true') ? true : (v === 'false') ? false : (isNaN(+v) ? v : +v);
if (k === 'seed' || k === 'preset' || k === 'size_px' || k === 'renderer' || k === 'hq') continue;
// pass through known params plus any wc* watercolour dials (not in the base set)
if (k in params || k.startsWith('wc')) params[k] = (v === 'true') ? true : (v === 'false') ? false : (isNaN(+v) ? v : +v);
}
const SIZE = +(q.get('size_px') || 1200);
@@ -33,7 +35,8 @@ const c = document.getElementById('c');
c.width = c.height = SIZE;
const ctx = c.getContext('2d', { willReadFrequently: true });
const scene = generateScene(params);
renderCanvasPhoto(ctx, SIZE, SIZE, scene, params, { preview: q.get('hq') !== '1' });
const render = q.get('renderer') === 'watercolor' ? renderWatercolor : renderCanvasPhoto;
render(ctx, SIZE, SIZE, scene, params, { preview: q.get('hq') !== '1' });
document.title = 'done';
window.__done = true;
</script>