Compare commits
2 Commits
milkweed-w
...
6dcb10c68b
| Author | SHA1 | Date | |
|---|---|---|---|
| 6dcb10c68b | |||
| 4314087557 |
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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
99
fable/stack/SPEC.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# The Chamber as a Volume — acrylic stack spec & depth design
|
||||
|
||||
A backlit, six-plane physical piece for the eufyMake E1: three acrylic sheets,
|
||||
printed front **and** back, spaced with air gaps over an LED panel. This is
|
||||
roadmap Phase 3 made literal — the scene model's per-track `z` and `age` stop
|
||||
being opacity tricks and become actual millimetres.
|
||||
|
||||
## Physical stack (proposed — buy list at bottom)
|
||||
|
||||
```
|
||||
viewer
|
||||
─ C-front the hand: grease pencil, réseau, fiducials, header, scratches
|
||||
═ sheet C 3 mm clear cast acrylic
|
||||
─ C-back near scatter: δ-ray curls, close cosmics
|
||||
~6 mm air gap
|
||||
─ B-front THE EVENT: sharp beaded track cores
|
||||
═ sheet B 3 mm
|
||||
─ B-back halation: the same tracks, blurred — real glow-behind-the-line
|
||||
~6 mm air gap
|
||||
─ A-front history: older background events, sinking
|
||||
═ sheet A 3 mm
|
||||
─ A-back the cause: pressure disk (CMYK-only → pure glow) + chamber optics
|
||||
~12 mm gap
|
||||
▤ diffuser frosted acrylic 2–3 mm (kills LED hotspots)
|
||||
▦ LED panel A3 slim light pad
|
||||
```
|
||||
|
||||
Plane depths (A-back = 0): 0 / 3 / 9 / 12 / 18 / 21 mm — two depth scales:
|
||||
3 mm *within* a sheet (fine parallax: halation, moiré) and 9 mm *between*
|
||||
sheets (coarse parallax: cause → event → hand).
|
||||
|
||||
**The depth story is causal, not just spatial**: deepest plane is the cause
|
||||
(the pressure wave, pure transmitted light), then history, then the halation,
|
||||
then the event itself, then the debris flying toward you, and finally — on the
|
||||
front glass, where scanners actually wrote — the human hand measuring it all.
|
||||
Parallax reads as *interpretation floating above evidence*.
|
||||
|
||||
## Sheet & print geometry
|
||||
|
||||
- Sheet: 330 × 420 mm (E1 full bed, A3+), portrait. 3 mm clear cast acrylic,
|
||||
gloss both faces.
|
||||
- Art square: 300 × 300 mm, centred horizontally (15 mm margins), top edge at
|
||||
y = 20 mm. Zone below (y = 320–420) carries film furniture / data box /
|
||||
caption on C-front only; other sheets stay clear there (light spills through
|
||||
— the plate floats in a lit void).
|
||||
- Corner mounting holes 5 mm ∅ at 8 mm insets, all sheets + diffuser, for M4
|
||||
nylon standoffs (3 mm + 6 mm + 6 mm spacer stacking, or threaded rod with
|
||||
nuts as spacers).
|
||||
- Registration: every plane file carries corner crosses at the same four
|
||||
coordinates just outside the art square; back-face files are **mirrored** at
|
||||
export so they read correctly through the sheet.
|
||||
|
||||
## White-ink logic (backlit grammar)
|
||||
|
||||
| zone | channels | lit result |
|
||||
|---|---|---|
|
||||
| clear acrylic | none | pure light |
|
||||
| glow elements (disk, halation, deep tracks) | CMYK only | stained-glass — light pours through colour |
|
||||
| focal ink (event cores, grease pencil, furniture) | CMYK + white underlay | holds density against the panel — reads as ink |
|
||||
| blocking (none in v1) | white + black | opaque |
|
||||
|
||||
Rule of thumb: **depth = transparency**. The deeper the plane, the less white
|
||||
ink; the front glass is the most "solid" ink. The disk gets *zero* white — lit,
|
||||
it becomes a light source (the laser-interference reference lives here, later).
|
||||
|
||||
## Pipeline
|
||||
|
||||
```
|
||||
plan (fable/stack/plans/*.mjs)
|
||||
→ node tools/stack-render.mjs <plan> per-plane SVGs + manifest.json
|
||||
→ fable/stack/simulator.html?c=<id> parallax + backlight art-direction
|
||||
→ [next] tools/stack-export.mjs print masters: mirrored backs,
|
||||
white-ink masks, A3 layout @ true scale
|
||||
```
|
||||
|
||||
`src/render/stack.js` splits one generated scene into per-plane sub-scenes
|
||||
(selectors: track kind / z band / age band; disk, optics, damage, media,
|
||||
fiducials, header as flags) and renders each through the untouched `renderSVG`
|
||||
with `transparentPaper`. Bloom/core pairs use the *same* track selector so
|
||||
bubble positions are identical on both faces (same filtered list → same salted
|
||||
draws) — keep `density`/`size` equal within a pair.
|
||||
|
||||
Per-plane renders set `depth`/`aging` low: the physical stack now *is* the
|
||||
depth model; double-encoding it as opacity would fight the medium.
|
||||
|
||||
## Calibration coupon (print before the hero)
|
||||
|
||||
One A5 sheet, double-sided: white-ink step wedge (0/25/50/75/100%, single +
|
||||
double pass) · the same δ-ray at three white levels · CMYK-only vs CMYK+white
|
||||
swatch pair · front/back registration cross · 6 pt / 8 pt mono type both faces.
|
||||
Judge **lit on the panel**, not in hand.
|
||||
|
||||
## Buy list (if starting from zero)
|
||||
|
||||
- 3× cast clear acrylic 330 × 420 × 3 mm (+1 spare), protective film both sides
|
||||
- 1× frosted/opal acrylic 330 × 420 × 2–3 mm (diffuser)
|
||||
- A3 slim LED light panel (dimmable if possible)
|
||||
- M4 nylon standoff kit (assorted 3/6/12 mm) or 4× M4 threaded rod + nuts
|
||||
- IPA + lint-free cloths; adhesion promoter if the E1 profile recommends it
|
||||
BIN
fable/stack/candidates/01-event-volume/composite-lit.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
60
fable/stack/candidates/01-event-volume/manifest.json
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"id": "01-event-volume",
|
||||
"title": "The Event, Suspended",
|
||||
"seed": "LAMBDA-2648",
|
||||
"geometry": {
|
||||
"thickness": 3,
|
||||
"gap": 6
|
||||
},
|
||||
"planes": [
|
||||
{
|
||||
"file": "plane-00-A-back.svg",
|
||||
"sheet": "A",
|
||||
"face": "back",
|
||||
"zmm": 0,
|
||||
"label": "the cause — disk body + chamber optics"
|
||||
},
|
||||
{
|
||||
"file": "plane-01-A-back.svg",
|
||||
"sheet": "A",
|
||||
"face": "back",
|
||||
"zmm": 0,
|
||||
"label": "the cause — disk granular texture"
|
||||
},
|
||||
{
|
||||
"file": "plane-02-A-front.svg",
|
||||
"sheet": "A",
|
||||
"face": "front",
|
||||
"zmm": 3,
|
||||
"label": "history — older events sink"
|
||||
},
|
||||
{
|
||||
"file": "plane-03-B-back.svg",
|
||||
"sheet": "B",
|
||||
"face": "back",
|
||||
"zmm": 9,
|
||||
"label": "halation — the trigger blooms behind itself"
|
||||
},
|
||||
{
|
||||
"file": "plane-04-B-front.svg",
|
||||
"sheet": "B",
|
||||
"face": "front",
|
||||
"zmm": 12,
|
||||
"label": "the event — sharp beaded cores"
|
||||
},
|
||||
{
|
||||
"file": "plane-05-C-back.svg",
|
||||
"sheet": "C",
|
||||
"face": "back",
|
||||
"zmm": 18,
|
||||
"label": "near scatter — δ-curls lift toward the eye"
|
||||
},
|
||||
{
|
||||
"file": "plane-06-C-front.svg",
|
||||
"sheet": "C",
|
||||
"face": "front",
|
||||
"zmm": 21,
|
||||
"label": "the hand — grease pencil on the glass"
|
||||
}
|
||||
]
|
||||
}
|
||||
38
fable/stack/candidates/01-event-volume/plane-00-A-back.svg
Normal file
|
After Width: | Height: | Size: 142 KiB |
42
fable/stack/candidates/01-event-volume/plane-01-A-back.svg
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
157
fable/stack/candidates/01-event-volume/plane-02-A-front.svg
Normal file
|
After Width: | Height: | Size: 641 KiB |
106
fable/stack/candidates/01-event-volume/plane-03-B-back.svg
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
105
fable/stack/candidates/01-event-volume/plane-04-B-front.svg
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
207
fable/stack/candidates/01-event-volume/plane-05-C-back.svg
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
41
fable/stack/candidates/01-event-volume/plane-06-C-front.svg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
fable/stack/candidates/02-palimpsest/composite-lit.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
67
fable/stack/candidates/02-palimpsest/manifest.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"id": "02-palimpsest",
|
||||
"title": "Palimpsest",
|
||||
"seed": "KAON-0088",
|
||||
"geometry": {
|
||||
"thickness": 3,
|
||||
"gap": 6
|
||||
},
|
||||
"planes": [
|
||||
{
|
||||
"file": "plane-00-A-back.svg",
|
||||
"sheet": "A",
|
||||
"face": "back",
|
||||
"zmm": 0,
|
||||
"label": "deep time — oldest exposures + the chamber"
|
||||
},
|
||||
{
|
||||
"file": "plane-01-A-front.svg",
|
||||
"sheet": "A",
|
||||
"face": "front",
|
||||
"zmm": 3,
|
||||
"label": "earlier cycle"
|
||||
},
|
||||
{
|
||||
"file": "plane-02-B-back.svg",
|
||||
"sheet": "B",
|
||||
"face": "back",
|
||||
"zmm": 9,
|
||||
"label": "moments ago"
|
||||
},
|
||||
{
|
||||
"file": "plane-03-B-back.svg",
|
||||
"sheet": "B",
|
||||
"face": "back",
|
||||
"zmm": 9,
|
||||
"label": "the pressure wave — body + texture"
|
||||
},
|
||||
{
|
||||
"file": "plane-04-B-back.svg",
|
||||
"sheet": "B",
|
||||
"face": "back",
|
||||
"zmm": 9,
|
||||
"label": "pressure-wave grain"
|
||||
},
|
||||
{
|
||||
"file": "plane-05-B-front.svg",
|
||||
"sheet": "B",
|
||||
"face": "front",
|
||||
"zmm": 12,
|
||||
"label": "NOW — the trigger, all kinds, sharp"
|
||||
},
|
||||
{
|
||||
"file": "plane-06-C-back.svg",
|
||||
"sheet": "C",
|
||||
"face": "back",
|
||||
"zmm": 18,
|
||||
"label": "still in flight — δ-debris of now"
|
||||
},
|
||||
{
|
||||
"file": "plane-07-C-front.svg",
|
||||
"sheet": "C",
|
||||
"face": "front",
|
||||
"zmm": 21,
|
||||
"label": "after — the hand, the furniture, the wear"
|
||||
}
|
||||
]
|
||||
}
|
||||
49
fable/stack/candidates/02-palimpsest/plane-00-A-back.svg
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
50
fable/stack/candidates/02-palimpsest/plane-01-A-front.svg
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
38
fable/stack/candidates/02-palimpsest/plane-02-B-back.svg
Normal file
|
After Width: | Height: | Size: 474 KiB |
34
fable/stack/candidates/02-palimpsest/plane-03-B-back.svg
Normal file
|
After Width: | Height: | Size: 146 KiB |
42
fable/stack/candidates/02-palimpsest/plane-04-B-back.svg
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
40
fable/stack/candidates/02-palimpsest/plane-05-B-front.svg
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
36
fable/stack/candidates/02-palimpsest/plane-06-C-back.svg
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
41
fable/stack/candidates/02-palimpsest/plane-07-C-front.svg
Normal file
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="1400" height="1400" viewBox="0 0 1400 1400">
|
||||
<metadata>Bubble Chamber · seed=KAON-0088 · hash=1e2f9c933ac58 · palette=mono</metadata>
|
||||
<defs>
|
||||
<filter id="soften" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="1.40"/></filter>
|
||||
<radialGradient id="paper" cx="50%" cy="42%" r="72%">
|
||||
<stop offset="0%" stop-color="#e2dbc7"/><stop offset="100%" stop-color="#b3aa92"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="bub-28_24_20" cx="50%" cy="50%" r="50%"><stop offset="0%" stop-color="#1c1814" stop-opacity="0.949"/><stop offset="48%" stop-color="#1c1814" stop-opacity="0.798"/><stop offset="100%" stop-color="#1c1814" stop-opacity="0.000"/></radialGradient>
|
||||
<radialGradient id="shockcore" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#16120f" stop-opacity="0.68"/>
|
||||
<stop offset="28%" stop-color="#16120f" stop-opacity="0.45"/>
|
||||
<stop offset="62%" stop-color="#1c1814" stop-opacity="0.28"/>
|
||||
<stop offset="100%" stop-color="#1c1814" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="shockstain" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#1c1814" stop-opacity="0.9"/>
|
||||
<stop offset="100%" stop-color="#1c1814" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="shockclean" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#cfc8b4" stop-opacity="0.9"/>
|
||||
<stop offset="100%" stop-color="#cfc8b4" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="vign" cx="50%" cy="50%" r="72%">
|
||||
<stop offset="30%" stop-color="#382e20" stop-opacity="0"/>
|
||||
<stop offset="100%" stop-color="#382e20" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<g id="damage" inkscape:groupmode="layer" inkscape:label="Plate damage" style="display:inline">
|
||||
<g stroke="#1c1814" fill="none" stroke-linecap="round"><circle cx="956.49" cy="1118.27" r="62.0" stroke-opacity="0.101" stroke-width="0.92"/><line x1="-165.08" y1="217.16" x2="759.53" y2="948.27" stroke-opacity="0.103" stroke-width="0.95"/><line x1="782.39" y1="691.43" x2="1321.41" y2="1717.56" stroke-opacity="0.123" stroke-width="0.73"/><line x1="1005.04" y1="814.99" x2="1239.92" y2="1410.61" stroke-opacity="0.221" stroke-width="0.43"/><line x1="1118.24" y1="1430.05" x2="968.95" y2="148.41" stroke-opacity="0.187" stroke-width="1.02"/><line x1="1454.37" y1="1651.97" x2="790.12" y2="913.80" stroke-opacity="0.115" stroke-width="0.66"/><path d="M 444.99 581.16 L 449.31 555.71 L 448.81 529.90 L 440.73 505.39 L 434.74 480.28 L 427.48 455.50 L 429.69 429.79 L 433.86 404.31 L 441.00 379.50 L 448.82 354.90 L 461.74 332.55 L 473.24 309.44 L 482.63 285.40 L 499.68 266.01 L 510.32 242.49 L 533.56 231.25 L 553.94 215.40 L 567.43 193.40 L 587.38 177.02 L 609.36 190.56 L 624.96 211.13 L 630.32 236.38 L 629.93 262.19 L 623.65 287.23 L 601.97 301.24 L 577.42 309.20 L 552.27 315.04 L 531.88 330.87 L 513.93 349.42" stroke-opacity="0.249" stroke-width="0.91"/><path d="M 242.23 622.29 L 233.10 602.86 L 226.14 582.55 L 230.53 561.54 L 239.13 541.86 L 251.87 524.58 L 268.17 510.62 L 286.60 499.61 L 304.38 487.57 L 323.24 477.31 L 340.81 464.98 L 362.20 463.17 L 382.47 456.11 L 402.11 447.45 L 407.85 426.76 L 415.83 406.83 L 434.65 396.52 L 456.12 396.32 L 477.31 392.85 L 494.31 405.95 L 514.34 413.69 L 535.57 416.86 L 555.33 425.25 L 575.21 433.35 L 595.70 439.77 L 616.79 443.78 L 637.13 450.64 L 646.53 469.94 L 660.80 485.97 L 672.51 503.97 L 685.32 521.19" stroke-opacity="0.225" stroke-width="1.05"/></g><g fill="#1c1814"><circle cx="467.63" cy="542.33" r="1.67" fill-opacity="0.310"/><circle cx="479.15" cy="544.76" r="0.88" fill-opacity="0.331"/><circle cx="486.58" cy="532.89" r="2.01" fill-opacity="0.234"/><circle cx="508.05" cy="536.46" r="0.66" fill-opacity="0.276"/><circle cx="463.94" cy="543.84" r="0.73" fill-opacity="0.300"/><circle cx="462.97" cy="557.88" r="0.94" fill-opacity="0.180"/><circle cx="568.21" cy="1197.08" r="1.48" fill-opacity="0.193"/><circle cx="424.16" cy="368.66" r="1.02" fill-opacity="0.383"/><circle cx="390.41" cy="326.62" r="1.78" fill-opacity="0.215"/><circle cx="460.65" cy="321.21" r="1.87" fill-opacity="0.156"/><circle cx="415.35" cy="339.99" r="1.80" fill-opacity="0.409"/><circle cx="481.74" cy="122.14" r="1.72" fill-opacity="0.250"/><circle cx="439.98" cy="124.51" r="0.81" fill-opacity="0.219"/><circle cx="447.47" cy="116.60" r="2.12" fill-opacity="0.403"/><circle cx="456.70" cy="94.46" r="1.44" fill-opacity="0.327"/><circle cx="437.56" cy="62.94" r="0.81" fill-opacity="0.365"/><circle cx="831.20" cy="888.38" r="1.27" fill-opacity="0.416"/><circle cx="874.34" cy="932.63" r="0.50" fill-opacity="0.307"/><circle cx="874.33" cy="932.27" r="1.70" fill-opacity="0.240"/><circle cx="873.22" cy="957.85" r="0.70" fill-opacity="0.203"/><circle cx="896.09" cy="931.92" r="1.38" fill-opacity="0.192"/><circle cx="892.40" cy="902.63" r="2.21" fill-opacity="0.230"/><circle cx="252.16" cy="868.61" r="1.79" fill-opacity="0.373"/><circle cx="265.02" cy="827.58" r="2.24" fill-opacity="0.399"/><circle cx="307.77" cy="870.71" r="1.17" fill-opacity="0.258"/><circle cx="1091.63" cy="1262.63" r="0.52" fill-opacity="0.306"/><circle cx="1136.10" cy="1271.88" r="1.46" fill-opacity="0.282"/><circle cx="1102.95" cy="1268.03" r="0.93" fill-opacity="0.363"/><circle cx="917.63" cy="303.65" r="1.85" fill-opacity="0.301"/><circle cx="921.65" cy="241.66" r="1.18" fill-opacity="0.192"/><circle cx="912.80" cy="326.36" r="0.54" fill-opacity="0.312"/><circle cx="908.94" cy="264.09" r="0.78" fill-opacity="0.440"/><circle cx="1154.45" cy="698.26" r="2.08" fill-opacity="0.429"/><circle cx="1183.48" cy="680.93" r="0.86" fill-opacity="0.436"/><circle cx="1210.26" cy="687.58" r="0.67" fill-opacity="0.418"/><circle cx="1235.14" cy="721.34" r="0.68" fill-opacity="0.152"/><circle cx="895.28" cy="85.22" r="0.75" fill-opacity="0.242"/><circle cx="916.78" cy="110.01" r="1.74" fill-opacity="0.385"/><circle cx="865.80" cy="120.73" r="1.15" fill-opacity="0.229"/><circle cx="916.25" cy="90.18" r="2.02" fill-opacity="0.359"/><circle cx="901.22" cy="70.57" r="1.27" fill-opacity="0.170"/><circle cx="928.37" cy="103.64" r="1.82" fill-opacity="0.235"/><circle cx="862.76" cy="104.59" r="0.50" fill-opacity="0.167"/></g>
|
||||
</g>
|
||||
<g id="fiducials" inkscape:groupmode="layer" inkscape:label="Fiducials" style="display:inline">
|
||||
<g stroke="#1c1814" stroke-opacity="0.55" stroke-width="1.68"><line x1="104.30000000000001" y1="116.9" x2="129.5" y2="116.9"/><line x1="116.9" y1="104.30000000000001" x2="116.9" y2="129.5"/><line x1="1270.5" y1="116.9" x2="1295.6999999999998" y2="116.9"/><line x1="1283.1" y1="104.30000000000001" x2="1283.1" y2="129.5"/><line x1="104.30000000000001" y1="1283.1" x2="129.5" y2="1283.1"/><line x1="116.9" y1="1270.5" x2="116.9" y2="1295.6999999999998"/><line x1="1270.5" y1="1283.1" x2="1295.6999999999998" y2="1283.1"/><line x1="1283.1" y1="1270.5" x2="1283.1" y2="1295.6999999999998"/><line x1="687.4" y1="116.9" x2="712.6" y2="116.9"/><line x1="700" y1="104.30000000000001" x2="700" y2="129.5"/><line x1="104.30000000000001" y1="700" x2="129.5" y2="700"/><line x1="116.9" y1="687.4" x2="116.9" y2="712.6"/><line x1="1270.5" y1="700" x2="1295.6999999999998" y2="700"/><line x1="1283.1" y1="687.4" x2="1283.1" y2="712.6"/></g>
|
||||
</g>
|
||||
<g id="header" inkscape:groupmode="layer" inkscape:label="Archival header" style="display:inline">
|
||||
<g fill="#1c1814" font-family="'JetBrains Mono', monospace"><text x="36" y="52" font-size="15" fill-opacity="0.62">GARGAMELLE · CERN</text><text x="36" y="74" font-size="13" fill-opacity="0.5">SEED KAON-0088</text><text x="1364" y="1345" font-size="14" text-anchor="end" fill-opacity="0.58">PLATE 163</text><text x="1364" y="1364" font-size="14" text-anchor="end" fill-opacity="0.58">EXPOSED 1971.06.13</text></g>
|
||||
</g>
|
||||
<g id="media" inkscape:groupmode="layer" inkscape:label="Media & hand" style="display:inline">
|
||||
<rect x="38.0" y="38.0" width="1324.0" height="1324.0" fill="none" stroke="#1c1814" stroke-opacity="0.5" stroke-width="2.8"/><rect x="14.7" y="65.5" width="19.2" height="34.3" rx="5.6" fill="#faf8f0" fill-opacity="0.7" stroke="#1c1814" stroke-opacity="0.4" stroke-width="1.4"/><rect x="1366.1" y="65.5" width="19.2" height="34.3" rx="5.6" fill="#faf8f0" fill-opacity="0.7" stroke="#1c1814" stroke-opacity="0.4" stroke-width="1.4"/><rect x="14.7" y="219.8" width="19.2" height="34.3" rx="5.6" fill="#faf8f0" fill-opacity="0.7" stroke="#1c1814" stroke-opacity="0.4" stroke-width="1.4"/><rect x="1366.1" y="219.8" width="19.2" height="34.3" rx="5.6" fill="#faf8f0" fill-opacity="0.7" stroke="#1c1814" stroke-opacity="0.4" stroke-width="1.4"/><rect x="14.7" y="374.2" width="19.2" height="34.3" rx="5.6" fill="#faf8f0" fill-opacity="0.7" stroke="#1c1814" stroke-opacity="0.4" stroke-width="1.4"/><rect x="1366.1" y="374.2" width="19.2" height="34.3" rx="5.6" fill="#faf8f0" fill-opacity="0.7" stroke="#1c1814" stroke-opacity="0.4" stroke-width="1.4"/><rect x="14.7" y="528.5" width="19.2" height="34.3" rx="5.6" fill="#faf8f0" fill-opacity="0.7" stroke="#1c1814" stroke-opacity="0.4" stroke-width="1.4"/><rect x="1366.1" y="528.5" width="19.2" height="34.3" rx="5.6" fill="#faf8f0" fill-opacity="0.7" stroke="#1c1814" stroke-opacity="0.4" stroke-width="1.4"/><rect x="14.7" y="682.9" width="19.2" height="34.3" rx="5.6" fill="#faf8f0" fill-opacity="0.7" stroke="#1c1814" stroke-opacity="0.4" stroke-width="1.4"/><rect x="1366.1" y="682.9" width="19.2" height="34.3" rx="5.6" fill="#faf8f0" fill-opacity="0.7" stroke="#1c1814" stroke-opacity="0.4" stroke-width="1.4"/><rect x="14.7" y="837.2" width="19.2" height="34.3" rx="5.6" fill="#faf8f0" fill-opacity="0.7" stroke="#1c1814" stroke-opacity="0.4" stroke-width="1.4"/><rect x="1366.1" y="837.2" width="19.2" height="34.3" rx="5.6" fill="#faf8f0" fill-opacity="0.7" stroke="#1c1814" stroke-opacity="0.4" stroke-width="1.4"/><rect x="14.7" y="991.6" width="19.2" height="34.3" rx="5.6" fill="#faf8f0" fill-opacity="0.7" stroke="#1c1814" stroke-opacity="0.4" stroke-width="1.4"/><rect x="1366.1" y="991.6" width="19.2" height="34.3" rx="5.6" fill="#faf8f0" fill-opacity="0.7" stroke="#1c1814" stroke-opacity="0.4" stroke-width="1.4"/><rect x="14.7" y="1145.9" width="19.2" height="34.3" rx="5.6" fill="#faf8f0" fill-opacity="0.7" stroke="#1c1814" stroke-opacity="0.4" stroke-width="1.4"/><rect x="1366.1" y="1145.9" width="19.2" height="34.3" rx="5.6" fill="#faf8f0" fill-opacity="0.7" stroke="#1c1814" stroke-opacity="0.4" stroke-width="1.4"/><rect x="14.7" y="1300.3" width="19.2" height="34.3" rx="5.6" fill="#faf8f0" fill-opacity="0.7" stroke="#1c1814" stroke-opacity="0.4" stroke-width="1.4"/><rect x="1366.1" y="1300.3" width="19.2" height="34.3" rx="5.6" fill="#faf8f0" fill-opacity="0.7" stroke="#1c1814" stroke-opacity="0.4" stroke-width="1.4"/><g fill="#1c1814" font-family="'JetBrains Mono', monospace"><text x="58.6" y="700.0" font-size="10" fill-opacity="0.45" text-anchor="middle" transform="rotate(-90 58.6 700.0)">KODAK SAFETY FILM</text><text x="82.6" y="1248.8" font-size="13" fill-opacity="0.5">ROLL 627</text><text x="82.6" y="1265.6" font-size="13" fill-opacity="0.5">FRAME 163</text><text x="82.6" y="1282.4" font-size="13" fill-opacity="0.5">1971.06.13</text></g><g stroke="#9c1e1e" fill="#9c1e1e" stroke-linecap="round" stroke-linejoin="round"><path d="M 1142.9 886.3 L 1140.6 890.2 L 1136.5 891.9 L 1133.9 896.9 L 1130.0 900.7 L 1125.0 901.9 L 1120.0 898.3 L 1115.0 900.6 L 1111.0 896.4 L 1106.5 895.4 L 1101.5 894.9 L 1097.6 892.0 L 1094.0 888.7 L 1091.2 884.8 L 1089.5 880.4 L 1088.1 875.9 L 1089.5 870.9 L 1086.8 867.0 L 1085.3 862.5 L 1087.2 858.2 L 1082.1 852.1 L 1088.7 849.3 L 1088.1 843.6 L 1088.7 837.7 L 1092.5 834.0 L 1099.5 834.8 L 1104.2 833.8 L 1106.8 828.3 L 1110.8 823.4 L 1116.5 827.6 L 1121.2 827.1 L 1126.2 826.2 L 1131.6 825.5 L 1133.6 833.3 L 1137.2 835.6 L 1140.9 837.9 L 1145.8 839.3 L 1150.6 841.7 L 1151.3 846.9 L 1154.8 850.7 L 1155.0 855.7 L 1157.0 860.4 L 1154.1 865.2 L 1152.5 869.6 L 1151.5 874.0 L 1152.6 879.7 L 1144.9 880.4 L 1144.8 886.0 L 1143.6 891.7 L 1134.9 887.5 L 1134.5 896.2 L 1128.8 894.5 L 1124.9 898.1" fill="none" stroke-opacity="0.85" stroke-width="1.68"/><path d="M 875.2 624.7 L 899.7 640.5 L 933.8 663.3 L 965.0 691.7 L 995.0 710.9 L 1037.6 729.5 L 1071.6 748.7 L 1097.6 777.1 L 1133.8 799.8 L 1166.6 822.1" fill="none" stroke-opacity="0.85" stroke-width="1.40"/><path d="M 1165.1 822.7 L 1145.0 818.6 M 1165.1 822.7 L 1154.1 805.4" fill="none" stroke-opacity="0.85" stroke-width="1.40"/><text x="1157.9" y="826.3" font-size="31" font-family="'Bradley Hand','Segoe Script','Comic Sans MS',cursive" fill-opacity="0.9" transform="rotate(-5.6 1157.9 826.3)">Nº 163</text></g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
BIN
fable/stack/candidates/03-interference/composite-lit.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
67
fable/stack/candidates/03-interference/manifest.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"id": "03-interference",
|
||||
"title": "The Measurement Interferes",
|
||||
"seed": "NEUTRINO-1973",
|
||||
"geometry": {
|
||||
"thickness": 3,
|
||||
"gap": 6
|
||||
},
|
||||
"planes": [
|
||||
{
|
||||
"file": "plane-00-A-back.svg",
|
||||
"sheet": "A",
|
||||
"face": "back",
|
||||
"zmm": 0,
|
||||
"label": "the spectral disk — interference at the source"
|
||||
},
|
||||
{
|
||||
"file": "plane-01-A-back.svg",
|
||||
"sheet": "A",
|
||||
"face": "back",
|
||||
"zmm": 0,
|
||||
"label": "the disk — granular spray"
|
||||
},
|
||||
{
|
||||
"file": "plane-02-A-front.svg",
|
||||
"sheet": "A",
|
||||
"face": "front",
|
||||
"zmm": 3,
|
||||
"label": "the sea — sweepers, cosmics, history"
|
||||
},
|
||||
{
|
||||
"file": "plane-03-B-back.svg",
|
||||
"sheet": "B",
|
||||
"face": "back",
|
||||
"zmm": 9,
|
||||
"label": "deep réseau — scaled +1.5%, moirés against the front grid"
|
||||
},
|
||||
{
|
||||
"file": "plane-04-B-back.svg",
|
||||
"sheet": "B",
|
||||
"face": "back",
|
||||
"zmm": 9,
|
||||
"label": "halation — the trigger blooms"
|
||||
},
|
||||
{
|
||||
"file": "plane-05-B-front.svg",
|
||||
"sheet": "B",
|
||||
"face": "front",
|
||||
"zmm": 12,
|
||||
"label": "the event — sharp"
|
||||
},
|
||||
{
|
||||
"file": "plane-06-C-back.svg",
|
||||
"sheet": "C",
|
||||
"face": "back",
|
||||
"zmm": 18,
|
||||
"label": "δ-curls in flight"
|
||||
},
|
||||
{
|
||||
"file": "plane-07-C-front.svg",
|
||||
"sheet": "C",
|
||||
"face": "front",
|
||||
"zmm": 21,
|
||||
"label": "front réseau + the hand — measurement on glass"
|
||||
}
|
||||
]
|
||||
}
|
||||
38
fable/stack/candidates/03-interference/plane-00-A-back.svg
Normal file
|
After Width: | Height: | Size: 147 KiB |
42
fable/stack/candidates/03-interference/plane-01-A-back.svg
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
44
fable/stack/candidates/03-interference/plane-02-A-front.svg
Normal file
|
After Width: | Height: | Size: 531 KiB |
33
fable/stack/candidates/03-interference/plane-03-B-back.svg
Normal file
|
After Width: | Height: | Size: 12 KiB |
42
fable/stack/candidates/03-interference/plane-04-B-back.svg
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
41
fable/stack/candidates/03-interference/plane-05-B-front.svg
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
36
fable/stack/candidates/03-interference/plane-06-C-back.svg
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
41
fable/stack/candidates/03-interference/plane-07-C-front.svg
Normal file
|
After Width: | Height: | Size: 23 KiB |
48
fable/stack/plans/01-event-volume.mjs
Normal file
@@ -0,0 +1,48 @@
|
||||
/* Candidate 01 — THE EVENT, SUSPENDED
|
||||
The purest depth thesis: causality back-to-front. The pressure
|
||||
disk glows deepest (CMYK-only when printed — it becomes a light
|
||||
source), history sinks behind the trigger, the trigger blooms on
|
||||
B-back and cuts sharp on B-front (real halation across 3 mm of
|
||||
acrylic), the δ-curls lift off toward the eye, and the scanner's
|
||||
grease pencil floats on the front glass where hands actually wrote.
|
||||
Palette: near-mono traces + one earned ember accent on the disk
|
||||
(magentarise with saturation crushed; disk hue is immune). */
|
||||
export default {
|
||||
id: '01-event-volume',
|
||||
title: 'The Event, Suspended',
|
||||
seed: 'LAMBDA-2648',
|
||||
size: 1400,
|
||||
geometry: { thickness: 3, gap: 6 },
|
||||
params: {
|
||||
palette: 'magentarise', saturation: 0.14,
|
||||
diskHue: 0.055, diskSat: 0.88, // ember — the one colour
|
||||
primaries: 18, burst: 0.8, vdecay: 4,
|
||||
bgEvents: 7, bgIntensity: 0.5, cosmics: 8, sweepers: 5,
|
||||
deltaRate: 0.9, deltaTight: 0.9,
|
||||
shock: true, diskBubbles: true, diskSoften: 1.2,
|
||||
shockIntensity: 0.9, diskPressure: 0.55,
|
||||
shockSize: 0.34, shockY: 0.52,
|
||||
annotate: 0.7, annoWidth: 0.5, reseau: 0.4, filmEdge: true,
|
||||
artifacts: 0.5, instrument: 0.45,
|
||||
},
|
||||
planes: [
|
||||
{ sheet: 'A', face: 'back', label: 'the cause — disk body + chamber optics',
|
||||
disk: true, boundary: true, optics: true,
|
||||
params: { diskBubbles: false, diskPressure: 0.85, diskSoften: 1.6 } },
|
||||
{ sheet: 'A', face: 'back', label: 'the cause — disk granular texture',
|
||||
disk: true,
|
||||
params: { diskBubbles: true }, fx: { opacity: 0.9 } },
|
||||
{ sheet: 'A', face: 'front', label: 'history — older events sink',
|
||||
tracks: { age: [0.25, 1] },
|
||||
fx: { blur: 1.6, opacity: 0.85 } },
|
||||
{ sheet: 'B', face: 'back', label: 'halation — the trigger blooms behind itself',
|
||||
tracks: { age: [0, 0.25], kinds: ['primary', 'vdecay'] },
|
||||
fx: { blur: 7, opacity: 0.5 } },
|
||||
{ sheet: 'B', face: 'front', label: 'the event — sharp beaded cores',
|
||||
tracks: { age: [0, 0.25], kinds: ['primary', 'vdecay'] } },
|
||||
{ sheet: 'C', face: 'back', label: 'near scatter — δ-curls lift toward the eye',
|
||||
tracks: { age: [0, 0.25], kinds: ['delta', 'cosmic', 'sweep'] } },
|
||||
{ sheet: 'C', face: 'front', label: 'the hand — grease pencil on the glass',
|
||||
media: true, fiducials: true, header: true, damage: true },
|
||||
],
|
||||
};
|
||||
47
fable/stack/plans/02-palimpsest.mjs
Normal file
@@ -0,0 +1,47 @@
|
||||
/* Candidate 02 — PALIMPSEST
|
||||
Depth as pure time, full mono: the chamber ran all night and the
|
||||
stack is the exposure record. Oldest events deepest; each plane
|
||||
forward is a later moment; the current trigger sits on B-front;
|
||||
its δ-debris is still in flight on C-back; the hand arrives last,
|
||||
on the front glass — the only "now" that outlived the event.
|
||||
No colour anywhere: transmitted light does all the work. */
|
||||
export default {
|
||||
id: '02-palimpsest',
|
||||
title: 'Palimpsest',
|
||||
seed: 'KAON-0088',
|
||||
size: 1400,
|
||||
geometry: { thickness: 3, gap: 6 },
|
||||
params: {
|
||||
palette: 'mono',
|
||||
primaries: 16, burst: 0.75, vdecay: 3,
|
||||
bgEvents: 11, bgIntensity: 0.75, cosmics: 10, sweepers: 6,
|
||||
deltaRate: 0.85,
|
||||
shock: true, diskBubbles: true, diskSoften: 1.0,
|
||||
shockIntensity: 0.85, diskPressure: 0.4,
|
||||
shockSize: 0.28, shockY: 0.55,
|
||||
annotate: 0.5, annoWidth: 0.5, filmEdge: true,
|
||||
artifacts: 0.6, instrument: 0.5,
|
||||
},
|
||||
planes: [
|
||||
{ sheet: 'A', face: 'back', label: 'deep time — oldest exposures + the chamber',
|
||||
tracks: { age: [0.6, 1] }, boundary: true, optics: true,
|
||||
fx: { blur: 1.4, opacity: 0.9 } },
|
||||
{ sheet: 'A', face: 'front', label: 'earlier cycle',
|
||||
tracks: { age: [0.35, 0.6] },
|
||||
fx: { blur: 0.8, opacity: 0.95 } },
|
||||
{ sheet: 'B', face: 'back', label: 'moments ago',
|
||||
tracks: { age: [0.12, 0.35] } },
|
||||
{ sheet: 'B', face: 'back', label: 'the pressure wave — body + texture',
|
||||
disk: true,
|
||||
params: { diskBubbles: false, diskPressure: 0.7 } },
|
||||
{ sheet: 'B', face: 'back', label: 'pressure-wave grain',
|
||||
disk: true,
|
||||
params: { diskBubbles: true }, fx: { opacity: 0.85 } },
|
||||
{ sheet: 'B', face: 'front', label: 'NOW — the trigger, all kinds, sharp',
|
||||
tracks: { age: [0, 0.12], kinds: ['primary', 'vdecay'] } },
|
||||
{ sheet: 'C', face: 'back', label: 'still in flight — δ-debris of now',
|
||||
tracks: { age: [0, 0.12], kinds: ['delta'] } },
|
||||
{ sheet: 'C', face: 'front', label: 'after — the hand, the furniture, the wear',
|
||||
media: true, fiducials: true, header: true, damage: true },
|
||||
],
|
||||
};
|
||||
51
fable/stack/plans/03-interference.mjs
Normal file
@@ -0,0 +1,51 @@
|
||||
/* Candidate 03 — THE MEASUREMENT INTERFERES
|
||||
The instrument becomes phenomenon: the réseau measuring grid is
|
||||
printed TWICE — once deep (B-back, scaled 1.5%), once on the
|
||||
front glass — so the act of measurement moirés against itself as
|
||||
the viewer moves. The disk goes deepest with a full spectral
|
||||
sunburst (iridescent striations, CMYK-only glow when printed):
|
||||
the interference-pattern reference, arrived at from inside the
|
||||
project's own vocabulary. Mono traces; the spectrum is earned by
|
||||
the physics feature that IS interference. */
|
||||
export default {
|
||||
id: '03-interference',
|
||||
title: 'The Measurement Interferes',
|
||||
seed: 'NEUTRINO-1973',
|
||||
size: 1400,
|
||||
geometry: { thickness: 3, gap: 6 },
|
||||
params: {
|
||||
palette: 'mono',
|
||||
primaries: 14, burst: 0.7, vdecay: 3,
|
||||
bgEvents: 5, bgIntensity: 0.45, cosmics: 7, sweepers: 6,
|
||||
deltaRate: 0.8,
|
||||
shock: true, diskBubbles: false, diskSoften: 0.6,
|
||||
shockIntensity: 0.95, diskPressure: 0.5,
|
||||
diskSpectrum: 0.85, shockStriations: 1.0,
|
||||
shockSize: 0.38, shockY: 0.5, shockX: 0,
|
||||
annotate: 0.7, annoWidth: 0.5, reseau: 0.85, filmEdge: true,
|
||||
artifacts: 0.45, instrument: 0.4,
|
||||
},
|
||||
planes: [
|
||||
{ sheet: 'A', face: 'back', label: 'the spectral disk — interference at the source',
|
||||
disk: true, boundary: true, optics: true,
|
||||
params: { diskSoften: 1.2, diskPressure: 0.8 } },
|
||||
{ sheet: 'A', face: 'back', label: 'the disk — granular spray',
|
||||
disk: true,
|
||||
params: { diskBubbles: true }, fx: { opacity: 0.8 } },
|
||||
{ sheet: 'A', face: 'front', label: 'the sea — sweepers, cosmics, history',
|
||||
tracks: { age: [0.2, 1] },
|
||||
fx: { blur: 1.4, opacity: 0.85 } },
|
||||
{ sheet: 'B', face: 'back', label: 'deep réseau — scaled +1.5%, moirés against the front grid',
|
||||
media: { reseau: true },
|
||||
fx: { scale: 1.015 } },
|
||||
{ sheet: 'B', face: 'back', label: 'halation — the trigger blooms',
|
||||
tracks: { age: [0, 0.2], kinds: ['primary', 'vdecay'] },
|
||||
fx: { blur: 5, opacity: 0.55 } },
|
||||
{ sheet: 'B', face: 'front', label: 'the event — sharp',
|
||||
tracks: { age: [0, 0.2], kinds: ['primary', 'vdecay'] } },
|
||||
{ sheet: 'C', face: 'back', label: 'δ-curls in flight',
|
||||
tracks: { age: [0, 0.2], kinds: ['delta'] } },
|
||||
{ sheet: 'C', face: 'front', label: 'front réseau + the hand — measurement on glass',
|
||||
media: true, fiducials: true, header: true, damage: true },
|
||||
],
|
||||
};
|
||||
129
fable/stack/simulator.html
Normal file
@@ -0,0 +1,129 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>stack simulator</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; margin: 0; }
|
||||
body { background:#0c0b09; color:#b8b0a0; font:12px/1.5 'JetBrains Mono',ui-monospace,monospace;
|
||||
min-height:100vh; display:flex; }
|
||||
#panel { width:250px; padding:16px; border-right:1px solid #26221c; flex-shrink:0; }
|
||||
#panel h1 { font-size:13px; color:#e0d8c8; font-weight:600; margin-bottom:2px; }
|
||||
#panel .sub { color:#6d6558; margin-bottom:14px; }
|
||||
#panel label { display:block; margin:10px 0 2px; color:#8d8574; }
|
||||
#panel input[type=range] { width:100%; accent-color:#c9803a; }
|
||||
.plane-row { display:flex; gap:6px; align-items:baseline; margin:3px 0; cursor:pointer; }
|
||||
.plane-row .z { color:#5d5548; width:44px; flex-shrink:0; }
|
||||
.plane-row.off { opacity:0.35; }
|
||||
button { background:#1c1914; color:#c9b98c; border:1px solid #3a342a; padding:5px 10px;
|
||||
font:inherit; cursor:pointer; margin:2px 2px 0 0; }
|
||||
button.on { background:#c9803a; color:#14100a; border-color:#c9803a; }
|
||||
#stagewrap { flex:1; display:flex; align-items:center; justify-content:center;
|
||||
perspective:1400px; overflow:hidden; }
|
||||
#stage { position:relative; transform-style:preserve-3d; }
|
||||
#stage img { position:absolute; left:0; top:0; width:100%; height:100%; user-select:none;
|
||||
-webkit-user-drag:none; }
|
||||
/* the light table — painted on the WRAP, not the stage: with preserve-3d a
|
||||
stage background sits at z=0 and would occlude planes behind it */
|
||||
#stagewrap.lit #light { background:radial-gradient(circle at 50% 46%, #fffdf4 0%, #f4ecd8 55%, #d8cdb2 100%); }
|
||||
#stagewrap.unlit #light{ background:#b9b2a2; }
|
||||
#stagewrap.unlit #stage img { filter:brightness(0.94) contrast(0.96); }
|
||||
#light { position:absolute; inset:0; transform:translateZ(-400px) scale(1.6); }
|
||||
#hint { position:fixed; right:14px; bottom:10px; color:#4d4639; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="panel">
|
||||
<h1 id="title">—</h1>
|
||||
<div class="sub" id="seed">—</div>
|
||||
<div>
|
||||
<button id="bl" class="on">backlit</button>
|
||||
<button id="spin">auto-orbit</button>
|
||||
</div>
|
||||
<label>depth exaggeration <span id="zv"></span></label>
|
||||
<input type="range" id="zscale" min="0" max="14" step="0.5" value="5">
|
||||
<label>tilt range <span id="tv"></span></label>
|
||||
<input type="range" id="tilt" min="0" max="24" step="1" value="10">
|
||||
<label style="margin-top:14px">planes (click to toggle)</label>
|
||||
<div id="planes"></div>
|
||||
</div>
|
||||
<div id="stagewrap" class="lit"><div id="stage"><div id="light"></div></div></div>
|
||||
<div id="hint">move mouse to parallax · ?c=<candidate-id></div>
|
||||
<script>
|
||||
const q = new URLSearchParams(location.search);
|
||||
const cid = q.get('c') || '01-event-volume';
|
||||
const stage = document.getElementById('stage');
|
||||
const SZ = Math.min(innerHeight, innerWidth - 250) - 40;
|
||||
stage.style.width = stage.style.height = SZ + 'px';
|
||||
|
||||
let planes = [], rx = 0, ry = 0, zs = 5, spin = false, t0 = performance.now();
|
||||
|
||||
fetch(`candidates/${cid}/manifest.json`).then(r => r.json()).then(m => {
|
||||
document.getElementById('title').textContent = m.title;
|
||||
document.getElementById('seed').textContent = `seed ${m.seed} · ${m.planes.length} planes`;
|
||||
const list = document.getElementById('planes');
|
||||
m.planes.forEach((p, i) => {
|
||||
const img = document.createElement('img');
|
||||
img.src = `candidates/${cid}/${p.file}`;
|
||||
stage.appendChild(img);
|
||||
const row = document.createElement('div');
|
||||
row.className = 'plane-row';
|
||||
row.innerHTML = `<span class="z">${p.zmm}mm</span><span>${p.sheet}-${p.face} · ${p.label}</span>`;
|
||||
row.onclick = () => { img.style.display = img.style.display === 'none' ? '' : 'none';
|
||||
row.classList.toggle('off'); };
|
||||
list.appendChild(row);
|
||||
planes.push({ img, zmm: p.zmm });
|
||||
});
|
||||
layout();
|
||||
});
|
||||
|
||||
function layout() {
|
||||
const mid = planes.length ? planes.reduce((s, p) => s + p.zmm, 0) / planes.length : 0;
|
||||
for (const p of planes)
|
||||
p.img.style.transform = `translateZ(${(p.zmm - mid) * zs}px)`;
|
||||
stage.style.transform = `rotateX(${rx}deg) rotateY(${ry}deg)`;
|
||||
}
|
||||
|
||||
const zEl = document.getElementById('zscale'), tEl = document.getElementById('tilt');
|
||||
const zv = document.getElementById('zv'), tv = document.getElementById('tv');
|
||||
function syncLabels(){ zv.textContent = `×${zEl.value}`; tv.textContent = `±${tEl.value}°`; }
|
||||
syncLabels();
|
||||
zEl.oninput = () => { zs = +zEl.value; syncLabels(); layout(); };
|
||||
tEl.oninput = syncLabels;
|
||||
|
||||
const staticMode = q.get('static') !== null;
|
||||
addEventListener('mousemove', e => {
|
||||
if (spin || staticMode) return;
|
||||
const T = +tEl.value;
|
||||
ry = ((e.clientX / innerWidth) - 0.5) * 2 * T;
|
||||
rx = -((e.clientY / innerHeight) - 0.5) * 2 * T;
|
||||
layout();
|
||||
});
|
||||
document.getElementById('bl').onclick = function () {
|
||||
const wrap = document.getElementById('stagewrap');
|
||||
const lit = wrap.classList.toggle('lit');
|
||||
wrap.classList.toggle('unlit', !lit);
|
||||
this.classList.toggle('on', lit);
|
||||
};
|
||||
document.getElementById('spin').onclick = function () {
|
||||
spin = !spin; this.classList.toggle('on', spin);
|
||||
if (spin) requestAnimationFrame(orbit);
|
||||
};
|
||||
function orbit(t) {
|
||||
if (!spin) return;
|
||||
const T = +tEl.value;
|
||||
ry = Math.sin((t - t0) / 1400) * T;
|
||||
rx = Math.cos((t - t0) / 2300) * T * 0.4;
|
||||
layout();
|
||||
requestAnimationFrame(orbit);
|
||||
}
|
||||
// static pose for headless screenshots: ?static=ry,rx
|
||||
if (staticMode) {
|
||||
const [a, b] = (q.get('static') || '').split(',').map(Number);
|
||||
ry = isFinite(a) ? a : 8; rx = isFinite(b) ? b : -4;
|
||||
setTimeout(layout, 300);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
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
@@ -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
|
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
@@ -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),
|
||||
|
||||
112
src/render/stack.js
Normal file
@@ -0,0 +1,112 @@
|
||||
/* ============================================================
|
||||
stack.js — split one scene across the printable planes of a
|
||||
physical acrylic stack (front + back of several sheets, air
|
||||
gaps, backlit). The scene model's per-track z and age stop
|
||||
being opacity tricks and become actual millimetres.
|
||||
|
||||
A PLAN describes the piece:
|
||||
{ id, seed, size, geometry:{thickness,gap}, params:{...},
|
||||
planes: [ { sheet:'A', face:'back', label,
|
||||
tracks: false | true | {kinds, z:[lo,hi], age:[lo,hi]},
|
||||
disk, boundary, optics, damage, // flags
|
||||
media: false | true | {reseau,grease,film,splice},
|
||||
fiducials, header, // flags
|
||||
params: {...per-plane overrides},
|
||||
fx: { blur, opacity, scale } }, ... ] }
|
||||
|
||||
The scene is generated ONCE from the merged params; each plane
|
||||
renders a filtered copy through the untouched renderSVG with
|
||||
transparentPaper (the backlight is the paper). Bloom/core face
|
||||
pairs must use IDENTICAL track selectors and equal density/size
|
||||
so their salted bubble draws match — same filtered list, same
|
||||
positions; only fx differ.
|
||||
|
||||
Per-plane depth/aging default LOW: the physical stack now
|
||||
carries depth; double-encoding it as opacity fights the medium.
|
||||
============================================================ */
|
||||
import { generateScene } from '../scene/scene.js';
|
||||
import { paramsFromSeed } from '../scene/params.js';
|
||||
import { renderSVG } from './svgVector.js';
|
||||
|
||||
const inRange = (v, r) => !r || (v >= r[0] && v <= r[1]);
|
||||
|
||||
function filterTracks(tracks, sel) {
|
||||
if (!sel) return [];
|
||||
if (sel === true) return tracks;
|
||||
return tracks.filter(t =>
|
||||
(!sel.kinds || sel.kinds.includes(t.kind)) &&
|
||||
inRange(t.z ?? 0, sel.z) &&
|
||||
inRange(t.age ?? 0, sel.age));
|
||||
}
|
||||
|
||||
function filterMedia(media, sel) {
|
||||
if (!media || !sel) return null;
|
||||
if (sel === true) return media;
|
||||
return {
|
||||
reseau: sel.reseau ? media.reseau : null,
|
||||
grease: sel.grease ? media.grease : [],
|
||||
film: sel.film ? media.film : null,
|
||||
splice: sel.splice ? media.splice : null,
|
||||
};
|
||||
}
|
||||
|
||||
/* wrap a rendered plane in optional blur / opacity / centre-scale
|
||||
(blur is in u = px per 1000; scale is for deliberate moiré offsets) */
|
||||
function applyFx(svg, fx, size) {
|
||||
const { blur = 0, opacity = 1, scale = 1 } = fx || {};
|
||||
if (!blur && opacity === 1 && scale === 1) return svg;
|
||||
const u = size / 1000, c = size / 2;
|
||||
const filter = blur
|
||||
? `<filter id="planefx" x="-15%" y="-15%" width="130%" height="130%"><feGaussianBlur stdDeviation="${(blur * u).toFixed(2)}"/></filter>`
|
||||
: '';
|
||||
const g = `<defs>${filter}</defs><g${blur ? ' filter="url(#planefx)"' : ''}`
|
||||
+ `${opacity !== 1 ? ` opacity="${opacity}"` : ''}`
|
||||
+ `${scale !== 1 ? ` transform="translate(${c} ${c}) scale(${scale}) translate(${-c} ${-c})"` : ''}>`;
|
||||
return svg.replace(/(<svg[^>]*>)/, `$1\n${g}`).replace(/<\/svg>\s*$/, '</g></svg>\n');
|
||||
}
|
||||
|
||||
export function renderStack(plan) {
|
||||
const base = { ...paramsFromSeed(plan.seed), ...(plan.params || {}), invert: true };
|
||||
const scene = generateScene(base);
|
||||
const size = plan.size || 1400;
|
||||
|
||||
const planes = plan.planes.map((plane) => {
|
||||
const emit = [];
|
||||
if (plane.disk) emit.push('disk');
|
||||
if (plane.tracks || plane.optics || plane.damage || plane.boundary) emit.push('bubble');
|
||||
if (plane.media || plane.fiducials || plane.header) emit.push('fiduciaries');
|
||||
|
||||
const ps = {
|
||||
...base,
|
||||
transparentPaper: true, vign: 0,
|
||||
showBoundary: !!plane.boundary,
|
||||
showFiducials: !!plane.fiducials,
|
||||
showHeader: !!plane.header,
|
||||
depth: 0.15, aging: 0.2, // the stack is the depth model now
|
||||
emit,
|
||||
...(plane.params || {}),
|
||||
};
|
||||
const sc = {
|
||||
...scene,
|
||||
tracks: filterTracks(scene.tracks, plane.tracks ?? false),
|
||||
shock: plane.disk ? scene.shock : null,
|
||||
instrument: plane.optics ? scene.instrument : null,
|
||||
artifacts: plane.damage ? scene.artifacts : null,
|
||||
media: filterMedia(scene.media, plane.media),
|
||||
};
|
||||
const svg = applyFx(renderSVG(sc, ps, size), plane.fx, size);
|
||||
return { sheet: plane.sheet, face: plane.face, label: plane.label || '', svg };
|
||||
});
|
||||
|
||||
return { plan, scene, planes };
|
||||
}
|
||||
|
||||
/* physical z (mm) of each plane from stack geometry; sheet order = first
|
||||
appearance in the plan (back of the stack first) */
|
||||
export function planeDepths(plan) {
|
||||
const t = plan.geometry?.thickness ?? 3;
|
||||
const g = plan.geometry?.gap ?? 6;
|
||||
const sheets = [...new Set(plan.planes.map(p => p.sheet))];
|
||||
return plan.planes.map(p =>
|
||||
sheets.indexOf(p.sheet) * (t + g) + (p.face === 'front' ? t : 0));
|
||||
}
|
||||
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
@@ -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
@@ -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>
|
||||
|
||||
33
tools/stack-render.mjs
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
/* stack-render.mjs — render an acrylic-stack plan to per-plane SVGs.
|
||||
usage: node tools/stack-render.mjs <plan.mjs> [outDir]
|
||||
Writes plane-NN-<sheet>-<face>.svg + manifest.json for the simulator
|
||||
(fable/stack/simulator.html?c=<id>). */
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { renderStack, planeDepths } from '../src/render/stack.js';
|
||||
|
||||
const [planPath, outArg] = process.argv.slice(2);
|
||||
if (!planPath) { console.error('usage: node tools/stack-render.mjs <plan.mjs> [outDir]'); process.exit(1); }
|
||||
|
||||
const plan = (await import(pathToFileURL(resolve(planPath)).href)).default;
|
||||
const outDir = resolve(outArg || `fable/stack/candidates/${plan.id}`);
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const { planes } = renderStack(plan);
|
||||
const zmm = planeDepths(plan);
|
||||
|
||||
const manifest = {
|
||||
id: plan.id, title: plan.title || plan.id, seed: plan.seed,
|
||||
geometry: { thickness: plan.geometry?.thickness ?? 3, gap: plan.geometry?.gap ?? 6 },
|
||||
planes: [],
|
||||
};
|
||||
planes.forEach((p, i) => {
|
||||
const file = `plane-${String(i).padStart(2, '0')}-${p.sheet}-${p.face}.svg`;
|
||||
writeFileSync(`${outDir}/${file}`, p.svg);
|
||||
manifest.planes.push({ file, sheet: p.sheet, face: p.face, zmm: zmm[i], label: p.label });
|
||||
console.log(` ${file} z=${zmm[i]}mm ${p.label}`);
|
||||
});
|
||||
writeFileSync(`${outDir}/manifest.json`, JSON.stringify(manifest, null, 2));
|
||||
console.log(`→ ${outDir} (${planes.length} planes)`);
|
||||