Fable Review
This commit is contained in:
154
fable/01-intent-vs-execution.md
Normal file
154
fable/01-intent-vs-execution.md
Normal 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 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.
|
||||
92
fable/02-current-state.md
Normal file
92
fable/02-current-state.md
Normal 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 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`.
|
||||
113
fable/03-feedback.md
Normal file
113
fable/03-feedback.md
Normal 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: 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.
|
||||
119
fable/04-ideas.md
Normal file
119
fable/04-ideas.md
Normal 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 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.
|
||||
30
fable/README.md
Normal file
30
fable/README.md
Normal 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.
|
||||
40
output/iterations-watercolor/index.html
Normal file
40
output/iterations-watercolor/index.html
Normal 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. 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. <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>
|
||||
34
output/milkweed/index.html
Normal file
34
output/milkweed/index.html
Normal 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 — 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.</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>
|
||||
BIN
output/milkweed/reference-asclepias-syriaca.jpg
Normal file
BIN
output/milkweed/reference-asclepias-syriaca.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
@@ -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
169
src/milkweed/umbel.js
Normal 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 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 },
|
||||
};
|
||||
}
|
||||
@@ -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
427
src/render/watercolor.js
Normal 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 (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();
|
||||
}
|
||||
58
tools/inkwash-export.mjs
Normal file
58
tools/inkwash-export.mjs
Normal 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
34
tools/milkweed.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user