Fable Review

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

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

@@ -0,0 +1,169 @@
/* ============================================================
umbel.js — a common-milkweed (Asclepias syriaca) inflorescence
as a scene for the watercolour renderer.
Why it belongs (the taxonomy / scale-rhyme):
a milkweed umbel is a *radial event*. A hidden central node;
pedicels radiating outward like tracks; each ending not in a
bubble but in a five-pointed star-flower — the reflexed corolla
and its pale corona crown. It is the bubble chamber's vertex and
δ-ray spray restated in botany: life wearing the same shape as
the particle event. "Evidence of the invisible" → here the
invisible is growth, the slow algorithm of a living thing.
generateUmbel(params) returns the same scene shape the watercolour
renderer consumes, but expressed in its area-wash / stroke / dab
primitives instead of particle tracks. Deterministic from seed.
Parts carry a `kind` routed by the `milkweed` palette to a pigment:
leaf/leafdk/ground → greens behind
stem/pedicel/midrib → stalk & veins
corolla/corollad → the dusty mauve reflexed petals
corona/center → the pale crown & dark gynostegium
bud/budtip → unopened maroon buds
============================================================ */
import { makeRng, gauss, cyrb53 } from '../rng.js';
const TAU = Math.PI * 2;
/* a lance/ovate leaf outline from base→tip, widest near the middle, with a gentle
bend. Returns a closed polygon (frame coords). */
function leafPoly(bx, by, tx, ty, halfW, bend = 0) {
const dx = tx - bx, dy = ty - by, L = Math.hypot(dx, dy) || 1;
const ux = dx / L, uy = dy / L; // spine
const nx = -uy, ny = ux; // normal
const left = [], right = [];
const STEPS = 14;
for (let i = 0; i <= STEPS; i++) {
const t = i / STEPS;
const w = halfW * Math.pow(Math.sin(Math.PI * t), 0.8);
const b = bend * Math.sin(Math.PI * t); // sideways bow of the spine
const sx = bx + dx * t + nx * b, sy = by + dy * t + ny * b;
left.push([sx + nx * w, sy + ny * w]);
right.push([sx - nx * w, sy - ny * w]);
}
const pts = [...left, ...right.reverse()].map(([x, y]) => ({ x, y }));
return pts;
}
/* sample a quadratic bezier into a short polyline (for curved pedicels/petals). */
function quad(p0, c, p1, n = 6) {
const out = [];
for (let i = 0; i <= n; i++) {
const t = i / n, mt = 1 - t;
out.push({
x: mt * mt * p0.x + 2 * mt * t * c.x + t * t * p1.x,
y: mt * mt * p0.y + 2 * mt * t * c.y + t * t * p1.y,
});
}
return out;
}
export function generateUmbel(params) {
const rng = makeRng(params.seed, 'umbel');
const washes = [], strokes = [], dabs = [];
const cx = params.umbelX ?? 0.0;
const cy = params.umbelY ?? -0.1; // cluster centre (slightly high)
const R = params.umbelR ?? 0.4; // umbel radius — the hero
const squash = params.umbelSquash ?? 0.98; // gentle vertical droop
const nodeX = cx + (params.nodeDX ?? 0.0);
const nodeY = cy + (params.nodeDY ?? 0.52); // pedicels attach below the globe
const nFlor = Math.round(params.florets ?? 80);
const baseR = params.flowerR ?? 0.092;
/* ---- foliage behind: a few big soft leaves splaying out from below the cluster,
kept mostly clear of the umbel so the flowers read in front. Faint. ---- */
const leafSpec = [ // [angle, length, halfWidth, foreground?] milkweed opposite-leaf feel
[2.55, 1.05, 0.13, false], [0.62, 1.15, 0.14, false], // upper-left & upper-right, behind
[2.05, 1.25, 0.15, true], [1.05, 1.05, 0.13, true], // lower flanks, nearer
[1.55, 0.7, 0.10, false], // one straight down behind
];
const nLeaf = Math.min(leafSpec.length, Math.round(params.leaves ?? 4));
for (let i = 0; i < nLeaf; i++) {
const [a0, len0, hw0, fore] = leafSpec[i];
const ang = a0 + gauss(rng) * 0.12;
const len = len0 * (0.9 + rng() * 0.2);
// start the blade just outside the umbel so it doesn't smother the flowers
const bx = cx + Math.cos(ang) * R * 0.72 + gauss(rng) * 0.04;
const by = cy + Math.sin(ang) * R * 0.72 * squash + 0.06;
const tx = bx + Math.cos(ang) * len, ty = by + Math.sin(ang) * len;
const hw = hw0 + rng() * 0.03, bend = gauss(rng) * 0.14;
washes.push({ kind: fore ? 'leaf' : 'leafdk', pts: leafPoly(bx, by, tx, ty, hw, bend), alpha: fore ? 0.24 : 0.16 });
strokes.push({ kind: 'midrib', pts: quad({ x: bx, y: by }, { x: (bx + tx) / 2 + bend, y: (by + ty) / 2 }, { x: tx, y: ty }, 6), width: 0.004, alpha: fore ? 0.2 : 0.12 });
}
// main stalk, rising to the node from the bottom
strokes.push({ kind: 'stem', pts: [{ x: cx + 0.015, y: 1.15 }, { x: cx + 0.005, y: nodeY + 0.28 }, { x: nodeX, y: nodeY }], width: 0.028, alpha: 0.5 });
/* ---- the florets on a drooping dome ---- */
const rot0 = rng() * TAU;
for (let i = 0; i < nFlor; i++) {
// fill a disk, slightly denser toward the centre; depth (near→far) from radius
// so the cluster reads as a rounded dome: centre flowers near/large/open, the
// rim far/small/budding. This fills the middle (a hemisphere projection didn't).
const rr = R * Math.pow(rng(), 0.62);
const az = rng() * TAU;
const fx = cx + Math.cos(az) * rr + gauss(rng) * 0.01;
const fy = cy + Math.sin(az) * rr * squash + 0.03 + gauss(rng) * 0.01; // +droop
const radial = rr / R; // 0 centre … 1 rim
const depth = 1 - radial * radial + gauss(rng) * 0.12; // centre nearest
const dA = 0.4 + 0.6 * Math.max(0, Math.min(1, depth)); // front brighter
const size = 0.6 + 0.55 * Math.max(0, depth); // front flowers larger
const fr = baseR * size;
// pedicel from node, gently bowing out to the floret
const mid = { x: (nodeX + fx) / 2 + gauss(rng) * 0.04, y: (nodeY + fy) / 2 + gauss(rng) * 0.03 };
strokes.push({ kind: 'pedicel', pts: quad({ x: nodeX, y: nodeY }, mid, { x: fx, y: fy }, 6), width: 0.0045, alpha: 0.26 * dA });
// back/upper florets tend to be unopened buds; front/lower are open stars
const budProb = 0.05 + 0.22 * radial; // a minority, mostly toward the rim
if (rng() < budProb) {
// bud: a maroon teardrop (body + tip) with a pale highlight — kept modest so
// the open flowers, not the buds, carry the cluster.
const ba = -Math.PI * 0.5 + gauss(rng) * 0.6; // points roughly up/out
const bl = fr * 0.62;
const tipx = fx + Math.cos(ba) * bl, tipy = fy + Math.sin(ba) * bl;
dabs.push({ kind: 'bud', x: fx, y: fy, r: fr * 0.32, alpha: 0.42 * dA });
dabs.push({ kind: 'bud', x: (fx + tipx) / 2, y: (fy + tipy) / 2, r: fr * 0.22, alpha: 0.36 * dA });
dabs.push({ kind: 'budtip', x: tipx, y: tipy, r: fr * 0.14, alpha: 0.32 * dA });
} else {
// open flower: 5 reflexed petals (a star) + pale corona crown + dark centre
const rot = rot0 + i * 2.39996 + gauss(rng) * 0.3; // golden-angle spin
for (let k = 0; k < 5; k++) {
const a = rot + k * (TAU / 5);
const il = fr * 0.3, ol = fr * (1.32 + rng() * 0.22); // long rays splaying out
const p0 = { x: fx + Math.cos(a) * il, y: fy + Math.sin(a) * il };
const p1 = { x: fx + Math.cos(a) * ol, y: fy + Math.sin(a) * ol };
// a slight reflex curve (petal bends back)
const c = { x: (p0.x + p1.x) / 2 + Math.cos(a + Math.PI / 2) * fr * 0.16,
y: (p0.y + p1.y) / 2 + Math.sin(a + Math.PI / 2) * fr * 0.16 };
const shaded = (k % 2 === 0) && depth < 0.7;
strokes.push({ kind: shaded ? 'corollad' : 'corolla', pts: quad(p0, c, p1, 5), width: 0.02 * size, alpha: 0.95 * dA });
}
// corona: a soft PALE centre (the crown reads light, the flower pink around it),
// 5 little hoods, and only a faint dark eye — not a bullseye.
dabs.push({ kind: 'corona', x: fx, y: fy, r: fr * 0.34, alpha: 0.4 * dA });
for (let k = 0; k < 5; k++) {
const a = rot + k * (TAU / 5) + Math.PI / 5;
dabs.push({ kind: 'corona', x: fx + Math.cos(a) * fr * 0.22, y: fy + Math.sin(a) * fr * 0.22, r: fr * 0.12, alpha: 0.46 * dA });
}
if (rng() < 0.6) dabs.push({ kind: 'center', x: fx, y: fy, r: fr * 0.09, alpha: 0.32 * dA });
}
}
// deterministic herbarium metadata (reuses the archive identity system)
const hash = cyrb53(params.seed);
const ds = parseInt(hash.slice(0, 8), 16);
const plate = (parseInt(hash.slice(-3), 16) % 999).toString().padStart(3, '0');
const year = 1901 + (ds % 96);
const month = 6 + (ds >> 4) % 3; // milkweed blooms JunAug
const day = 1 + ((ds >> 8) % 28);
const exposure = `${year}.${String(month).padStart(2, '0')}.${String(day).padStart(2, '0')}`;
return {
tracks: [], washes, strokes, dabs,
shock: null, artifacts: { rings: [] },
lab: 'ASCLEPIAS SYRIACA · COMMON MILKWEED',
plate, exposure, vertex: { x: cx, y: cy },
};
}

View File

@@ -147,10 +147,30 @@ function lifeColor(life, inv) {
return hslToRgb(h, 0.72, (inv ? 0.44 : 0.62) - 0.07 * t);
}
// botanical pigments for the milkweed umbel (a taxonomy subject sharing the plate
// language). Keyed by the part `kind` the milkweed generator stamps. Mid-dark
// subtractive pigments — real watercolours: sap green, quinacridone rose/magenta,
// a dusty mauve corolla, a pale crown that barely tints the paper.
const MILK = {
leaf: [110, 140, 86], leafdk: [80, 108, 64],
stem: [98, 124, 66], pedicel: [128, 156, 88],
midrib: [150, 100, 84],
corolla: [168, 104, 134], corollad: [126, 70, 100],
corona: [228, 202, 210], center: [112, 76, 86],
bud: [134, 70, 92], budtip: [180, 130, 142],
ground: [120, 140, 118],
};
/* ---------- the registry: each entry is a "feel" ---------- */
export const PALETTES = {
mono: { id: 'mono', label: 'Monochrome' }, // all defaults → B&W
// botanical: one pigment per flower part — the milkweed umbel as a wet plate.
milkweed: {
id: 'milkweed', label: 'Milkweed (botanical)',
ink: (t, e) => MILK[t.kind] || e.baseInk,
},
charge: {
id: 'charge', label: 'Charge duotone',
ink: (t, e) => chargeColor(t.q ?? 1, e.inv),

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

@@ -0,0 +1,427 @@
/* ============================================================
watercolor.js — the wet renderer. One scene model → pigment.
The conceit (why this belongs in the project):
a bubble chamber is superheated liquid hydrogen, and the track
is a wake of boiling bubbles *in a fluid*. Watercolour is pigment
migrating through water on paper. To render the trace as ink in
water is not a style laid over the physics — it is the same
physics restated in a wetter medium. The slow end of a track
(the particle losing momentum, ionising harder: density ∝ 1/β²)
is exactly where the wash pools darkest. The δ-ray's logarithmic
curl becomes a curl of pigment; the shock disk blooms like a
drop released in still water. "Evidence of the invisible," in
the most literal wet sense.
The craft — the watercolour "tells" reproduced here:
1. Subtractive pigment optics (BeerLambert): paper·e^(A).
Density saturates softly, never to flat black — the glow of
the paper survives under every wash.
2. The wet edge / edge-darkening: as a wash dries, water carries
pigment to the receding perimeter, leaving a darker rim. The
single most recognisable watercolour signature. (rim = the
sharp pigment minus its own diffusion.)
3. Granulation: pigment settles into the tooth of cold-press
paper, mottling the density and separating in the valleys.
4. Wet-into-wet bleed: a soft diffused halo beyond each stroke.
5. Pigment load tracks speed: heavy pooled pigment at the
slow/dense end of range, thin dry-brush where the particle is
fast — this falls straight out of the bubble density model.
6. The paper itself: warm rag, cold-press tooth, a back-light
bloom, a deckle edge and faint cockle. Unpainted paper is
never dead white.
Geometry is identical to the photographic + vector renderers; only
the medium differs. Deterministic from params.seed.
============================================================ */
import { makeRng } from '../rng.js';
import { sampleBubbles, trackInkWeight, depthFactors } from '../scene/bubbles.js';
import { mottleCanvas } from './noise.js';
import { resolvePalette, paperTone } from './palette.js';
const MARGIN = 0.02;
/* deep pigments for the monochrome default — a sympathetic-magic palette of
real watercolours: a warm sepia bound with a cool payne's-grey shadow, the
pairing landscape painters reach for when they want "almost black" that still
breathes. Used as baseInk so the mono feel reads as ink-in-water, not toner. */
const PIGMENT_SEPIA = [54, 33, 20]; // warm walnut-sepia (the body of the wash)
const PIGMENT_SHADOW = [20, 31, 54]; // cool payne's-grey (granulates into valleys)
// fast particles ionise less → thinner, paler tracks; let them recede so the slow,
// blooming focal event holds the eye (atmosphere via physics, not arbitrary dimming).
const KIND_LOAD = { primary: 1, delta: 1, vdecay: 1, cosmic: 0.55, sweep: 0.6 };
/* a scratch canvas pool keyed by size, so we are not allocating 1200² buffers
on every blur read. */
const scratch = { w: 0, a: null, b: null };
function scratchPair(w, h) {
if (scratch.w !== w) {
scratch.w = w;
scratch.a = document.createElement('canvas'); scratch.a.width = w; scratch.a.height = h;
scratch.b = document.createElement('canvas'); scratch.b.width = w; scratch.b.height = h;
}
return [scratch.a.getContext('2d', { willReadFrequently: true }),
scratch.b.getContext('2d', { willReadFrequently: true })];
}
/* read one channel of a canvas into a Float32 field in [0,1]. */
function readField(ctx, w, h, chan = 0) {
const d = ctx.getImageData(0, 0, w, h).data;
const f = new Float32Array(w * h);
for (let i = 0, p = chan; i < f.length; i++, p += 4) f[i] = d[p] / 255;
return f;
}
/* paper tooth field: cold-press grain (fine) modulated by a broad cockle/mottle,
in [0,1] where 1 = a raised fibre (catches less pigment) and 0 = a valley
(pools / granulates). Built from the project's own fbm mottle so it stays
deterministic and consistent with the other renderers' analog layer. */
function toothField(seed, w, h) {
const fine = mottleCanvas(seed + '::tooth', 512, 150, 5); // paper grain
const broad = mottleCanvas(seed + '::cockle', 512, 6, 4); // cockle / undulation
const [ca] = scratchPair(w, h);
ca.clearRect(0, 0, w, h);
ca.drawImage(fine, 0, 0, w, h);
const fineF = readField(ca, w, h);
ca.clearRect(0, 0, w, h);
ca.drawImage(broad, 0, 0, w, h);
const broadF = readField(ca, w, h);
const out = new Float32Array(w * h);
for (let i = 0; i < out.length; i++) {
// centre both around 0, combine, recentre — fine dominates, broad tilts it
const t = 0.5 + (fineF[i] - 0.5) * 0.85 + (broadF[i] - 0.5) * 0.5;
out[i] = Math.max(0, Math.min(1, t));
}
return out;
}
/* blur a Float32 field by rendering it through the GPU canvas blur and reading
it back — far faster than a JS convolution at print sizes. */
function blurField(field, w, h, radiusPx) {
const [ca, cb] = scratchPair(w, h);
const img = ca.createImageData(w, h);
const d = img.data;
for (let i = 0, p = 0; i < field.length; i++, p += 4) {
const v = Math.max(0, Math.min(255, field[i] * 255));
d[p] = d[p + 1] = d[p + 2] = v; d[p + 3] = 255;
}
ca.putImageData(img, 0, 0);
cb.clearRect(0, 0, w, h);
cb.filter = `blur(${radiusPx.toFixed(2)}px)`;
cb.drawImage(ca.canvas, 0, 0);
cb.filter = 'none';
return readField(cb, w, h);
}
export function renderWatercolor(ctx, w, h, scene, params, opts = {}) {
const pre = opts.preview !== false;
const u = w / 1000; // unit scale relative to 1000px
const scale = (w / 2) * (1 - MARGIN);
const cx = w / 2, cy = h / 2;
const tx = (x) => cx + x * scale;
const ty = (y) => cy + y * scale;
// ---- the medium dials (all optional; sensible wet defaults) ----
const D = {
pigment: params.wcPigment ?? 1.0, // overall optical density
edge: params.wcEdge ?? 1.0, // wet-edge / edge-darkening strength
bleed: params.wcBleed ?? 1.0, // wet-into-wet halo
granulate: params.wcGranulate ?? 1.0, // how much the paper tooth bites
tooth: params.wcTooth ?? 1.0, // visible paper texture in the wash
wetness: params.wcWetness ?? 1.0, // diffusion radius scale
};
// ---- colour: a light rag-paper ground, pigments dark-on-light ----
// Force the light-ground (positive) reading regardless of the global invert —
// watercolour lives on paper. Palettes still map physics → pigment hue.
const pt = paperTone({ ...params, paperBright: (params.paperBright ?? 1) * 1.06 }, true);
const pal = resolvePalette(params.palette, {
inv: false, sat: params.saturation ?? 1, hue: (params.hueShift ?? 0) * 360,
cycles: params.hueCycles ?? 3,
traceHue: params.traceHue ?? 0, diskHue: params.diskHue ?? 0.06, diskSat: params.diskSat ?? 0.82,
baseInk: PIGMENT_SEPIA,
basePaper: { flat: pt.flat, glowIn: pt.glowIn, glowOut: pt.glowOut },
baseVign: pt.vign,
});
const paper = pal.hasPaper ? pal.paper() : { flat: pt.flat, glowIn: pt.glowIn, glowOut: pt.glowOut };
// ---- paper tooth (shared by every wash) ----
const tooth = toothField(params.seed, w, h);
// global absorbance accumulators (BeerLambert, per channel)
const N = w * h;
const Ar = new Float32Array(N), Ag = new Float32Array(N), Ab = new Float32Array(N);
const bubbleRng = makeRng(params.seed, 'bubbles');
/* lay one pigment down as a wash: `paint(mc)` deposits white "wet mass" onto a
scratch context; we then derive the wet structure (edge-darkening, bleed,
granulation) and pull the pigment's colour through BeerLambert into the
global absorbance. `col` is the pigment; `granPartner` (optional) is a second
pigment that separates into the tooth valleys — real granulating-watercolour
behaviour, and the source of the wash's living, two-tone shimmer. */
function layWash(paint, col, granPartner = null, granAmt = 0) {
const [mc] = scratchPair(w, h);
mc.clearRect(0, 0, w, h);
mc.globalCompositeOperation = 'lighter'; // pigment mass accumulates
mc.lineCap = 'round'; mc.lineJoin = 'round';
paint(mc);
mc.globalAlpha = 1;
mc.globalCompositeOperation = 'source-over';
const M = readField(mc, w, h);
const sharp = blurField(M, w, h, 1.15 * u); // soften beads into wash
const diffuse = blurField(M, w, h, (4.5 * D.wetness) * u); // the drying-front spread
const wide = blurField(M, w, h, (14 * D.wetness) * u); // the wet-into-wet halo
const cr = 1 - col[0] / 255, cg = 1 - col[1] / 255, cb = 1 - col[2] / 255;
let gr = cr, gg = cg, gb = cb;
if (granPartner) { gr = 1 - granPartner[0] / 255; gg = 1 - granPartner[1] / 255; gb = 1 - granPartner[2] / 255; }
const k = 2.7 * D.pigment;
for (let i = 0; i < N; i++) {
const s = sharp[i];
if (s < 0.003 && wide[i] < 0.003) continue;
// wet edge: water receding to the perimeter concentrates pigment in a rim.
const rim = Math.max(0, s - diffuse[i]);
// wet-into-wet halo: pigment carried out beyond the stroke into damp paper.
const halo = Math.max(0, wide[i] - s);
let p = s + rim * (2.2 * D.edge) + halo * (0.85 * D.bleed);
// granulation: pigment settles in the tooth valleys (low tooth), thins on
// the raised fibres — a textured, mottled density rather than a flat wash.
const tv = tooth[i] - 0.5; // +raised fibre … valley
const g = 1 - D.granulate * (0.6 * tv + 0.12);
p *= Math.max(0.18, g);
const a = p * k;
// the granulating partner deposits preferentially in the valleys
const frac = granPartner ? granAmt * Math.max(0, 0.5 - tv) : 0;
const aMain = a * (1 - frac), aGran = a * frac;
Ar[i] += aMain * cr + aGran * gr;
Ag[i] += aMain * cg + aGran * gg;
Ab[i] += aMain * cb + aGran * gb;
}
}
// soft round pigment deposit (radial alpha) — no per-stamp filter (slow); the
// diffusion blur in layWash carries the wet spread.
function blob(mc, X, Y, R, a) {
const grd = mc.createRadialGradient(X, Y, 0, X, Y, R);
grd.addColorStop(0, `rgba(255,255,255,${a})`);
grd.addColorStop(0.5, `rgba(255,255,255,${a * 0.5})`);
grd.addColorStop(1, 'rgba(255,255,255,0)');
mc.fillStyle = grd;
mc.beginPath(); mc.arc(X, Y, R, 0, Math.PI * 2); mc.fill();
}
// ---- collect pigment deposits into colour buckets ----
// Each bucket is one pigment with its own drying front (its own wet edge). For a
// per-track palette a track contributes its whole self to one bucket; for a
// per-bubble palette (lifecycle, kindrise, psychedelic…) each *bubble* lands in
// the bucket of its own colour, so the wash shifts hue along the trail the way wet
// pigment really migrates and separates. mono additionally carries a granulating
// partner (cool payne's-grey settling out of the warm sepia).
const isMono = (params.palette ?? 'mono') === 'mono';
const perBubble = pal.perBubble;
const QUANT = 28; // colour quantisation → bounded buckets
const qkey = (c) => `${Math.round(c[0] / QUANT)}_${Math.round(c[1] / QUANT)}_${Math.round(c[2] / QUANT)}`;
const deps = new Map(); // key → { col, fills, strokes, blobs }
const bucketFor = (col) => {
const key = qkey(col);
let d = deps.get(key);
if (!d) { d = { col, fills: [], strokes: [], blobs: [] }; deps.set(key, d); }
return d;
};
for (const track of scene.tracks || []) {
if (track.pts.length < 2) continue;
const df = depthFactors(track, params);
const load = KIND_LOAD[track.kind] ?? 1;
const repCol = pal.ink(track);
// 1) continuity wash — a soft varying-width body of water, heavier where the
// particle ionises hardest (1/β² via trackInkWeight) — to the track's pigment.
const iw = trackInkWeight(track);
const lw = Math.min(5.5, 0.7 + Math.sqrt(iw) * 0.6) * u * params.size * track.weight;
if (lw >= 0.25 * u) bucketFor(repCol).strokes.push({ pts: track.pts, lw, alpha: 0.27 * df.tone * load });
// 2) pigment grain — discrete bubbles (shared nucleation model); pooling at the
// slow/dense end falls out of the 1/β² density for free. Per-bubble colour
// when the palette varies along the trail.
const bubs = sampleBubbles({ ...track, sizeScale: df.sizeScale }, params, bubbleRng);
const a = Math.min(1, 0.5 + df.tone * 0.5) * load;
for (const b of bubs) {
const col = perBubble ? pal.bubbleInk(track, b.life, b.beta) : repCol;
bucketFor(col).blobs.push({ x: tx(b.x), y: ty(b.y), r: Math.max(b.r * scale, 0.5) * 2.6, a: a * 0.36 });
}
}
// ---- explicit geometry primitives (used by non-particle subjects, e.g. the
// milkweed umbel): area washes (leaves/ground), strokes (pedicels, petals,
// midribs — no bubble grain), and dabs (corona crowns, bud bodies, centres).
// Each carries its own pigment `kind`, routed through the same palette. All
// optional → particle scenes set none of these and behave unchanged. ----
const inkKind = (kind) => pal.ink({ kind, pts: [{ beta: 0.5 }], q: 1 });
for (const wsh of (scene.washes || [])) {
if (!wsh.pts || wsh.pts.length < 3) continue;
bucketFor(inkKind(wsh.kind)).fills.push({ pts: wsh.pts, alpha: wsh.alpha ?? 0.45 });
}
for (const st of (scene.strokes || [])) {
if (!st.pts || st.pts.length < 2) continue;
bucketFor(inkKind(st.kind)).strokes.push({ pts: st.pts, lw: (st.width ?? 0.008) * scale, alpha: st.alpha ?? 0.3 });
}
for (const db of (scene.dabs || [])) {
bucketFor(inkKind(db.kind)).blobs.push({ x: tx(db.x), y: ty(db.y), r: (db.r ?? 0.01) * scale, a: db.alpha ?? 0.5 });
}
// ---- lay each pigment bucket as its own wash ----
for (const { col, fills, strokes, blobs } of deps.values()) {
layWash((mc) => {
// area washes first (they sit beneath), then strokes, then grain/dabs
for (const f of fills) {
mc.globalAlpha = f.alpha;
mc.beginPath(); mc.moveTo(tx(f.pts[0].x), ty(f.pts[0].y));
for (let i = 1; i < f.pts.length; i++) mc.lineTo(tx(f.pts[i].x), ty(f.pts[i].y));
mc.closePath(); mc.fillStyle = 'rgba(255,255,255,1)'; mc.fill();
}
for (const s of strokes) {
mc.globalAlpha = s.alpha; mc.lineWidth = s.lw;
mc.beginPath(); mc.moveTo(tx(s.pts[0].x), ty(s.pts[0].y));
for (let i = 1; i < s.pts.length; i++) mc.lineTo(tx(s.pts[i].x), ty(s.pts[i].y));
mc.stroke();
}
// larger, softer, lower-alpha deposits coalesce into a textured wash, not beads
for (const b of blobs) blob(mc, b.x, b.y, b.r, b.a);
}, col, isMono ? PIGMENT_SHADOW : null, isMono ? 0.5 : 0);
}
// ---- the shock disk: a drop of pigment released into still water ----
if (scene.shock && (params.diskOff !== true)) {
const sh = scene.shock;
const feat = pal.feature();
const SX = tx(sh.x), SY = ty(sh.y), R = sh.r * scale;
layWash((mc) => {
// the bloom: a dense mid-radius ring (the drop's wet front) fading both ways
mc.globalAlpha = 1;
// a translucent bloom: faint, luminous centre (the drop's clearing eye)
// rising to a soft wet front near the rim, then feathering out. Kept light
// on purpose so paper-glow, striations and granulation read *through* it.
const ring = mc.createRadialGradient(SX, SY, 0, SX, SY, R);
const I = sh.intensity;
ring.addColorStop(0.0, `rgba(255,255,255,${0.03 * I})`);
ring.addColorStop(0.5, `rgba(255,255,255,${0.07 * I})`);
ring.addColorStop(0.86, `rgba(255,255,255,${0.17 * I})`);
ring.addColorStop(1.0, 'rgba(255,255,255,0)');
mc.fillStyle = ring;
mc.beginPath(); mc.arc(SX, SY, R, 0, Math.PI * 2); mc.fill();
// striations as fine pigment threads radiating from the centre — now the
// dominant disk gesture (the sunburst of a struck bell / a drop's corona).
mc.globalCompositeOperation = 'lighter';
mc.lineCap = 'round';
for (const s of (sh.striations || [])) {
const ix = SX + Math.cos(s.a) * s.inner * scale, iy = SY + Math.sin(s.a) * s.inner * scale;
const ox = SX + Math.cos(s.a) * s.outer * scale, oy = SY + Math.sin(s.a) * s.outer * scale;
mc.globalAlpha = s.opacity * 0.38;
mc.lineWidth = (s.width || 1) * u;
mc.beginPath(); mc.moveTo(ix, iy); mc.lineTo(ox, oy); mc.stroke();
}
// disk-bubble strokes (rings/rim/core) if present, as grain
if (sh.bubbleStrokes) {
const dRng = makeRng(params.seed, 'diskbubbles');
for (const stroke of sh.bubbleStrokes) {
const bubs = sampleBubbles(stroke, params, dRng);
for (const b of bubs) blob(mc, tx(b.x), ty(b.y), Math.max(b.r * scale, 0.5) * 1.9, 0.24);
}
}
}, feat, isMono ? PIGMENT_SHADOW : null, isMono ? 0.4 : 0);
}
// ---- paper ground with back-light bloom + tooth, then pull pigment through it ----
const img = ctx.createImageData(w, h);
const D8 = img.data;
const pf = paper.flat, pin = paper.glowIn, pout = paper.glowOut;
const cxg = w * 0.5, cyg = h * 0.46, rg = w * 0.72;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = y * w + x;
// radial back-light: paper glows from within (a lit chamber behind the wash)
const dx = (x - cxg) / rg, dy = (y - cyg) / rg;
const rr = Math.min(1, Math.sqrt(dx * dx + dy * dy));
const gl = 1 - rr * rr;
let pr = pout[0] + (pin[0] - pout[0]) * gl;
let pg = pout[1] + (pin[1] - pout[1]) * gl;
let pb = pout[2] + (pin[2] - pout[2]) * gl;
pr = pf[0] + (pr - pf[0]) * 0.55; pg = pf[1] + (pg - pf[1]) * 0.55; pb = pf[2] + (pb - pf[2]) * 0.55;
// paper tooth visible even on bare paper (very subtle), stronger in the wash
const th = (tooth[i] - 0.5);
const toothShade = 1 - D.tooth * 0.05 * (0.5 - th) * 2;
pr *= toothShade; pg *= toothShade; pb *= toothShade;
// BeerLambert: paper · e^(A)
const p4 = i * 4;
D8[p4] = Math.max(0, Math.min(255, pr * Math.exp(-Ar[i])));
D8[p4 + 1] = Math.max(0, Math.min(255, pg * Math.exp(-Ag[i])));
D8[p4 + 2] = Math.max(0, Math.min(255, pb * Math.exp(-Ab[i])));
D8[p4 + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
// ---- tidelines: the scene's own water rings read as pale pigment high-water
// marks — a quiet rhyme between "plate damage" and the wet medium. ----
if (scene.artifacts && scene.artifacts.rings && D.bleed > 0) {
ctx.save();
ctx.globalCompositeOperation = 'multiply';
for (const ring of scene.artifacts.rings) {
const rr = ring.r * scale;
const grd = ctx.createRadialGradient(tx(ring.x), ty(ring.y), rr * 0.86, tx(ring.x), ty(ring.y), rr);
grd.addColorStop(0, 'rgba(120,96,70,0)');
grd.addColorStop(0.7, `rgba(120,96,70,${0.05 * ring.opacity * 8})`);
grd.addColorStop(1, 'rgba(120,96,70,0)');
ctx.strokeStyle = grd;
ctx.lineWidth = Math.max(1, rr * 0.06);
ctx.beginPath(); ctx.arc(tx(ring.x), ty(ring.y), rr * 0.93, 0, Math.PI * 2); ctx.stroke();
}
ctx.restore();
}
// ---- deckle edge: the torn, soft border of hand-made paper ----
drawDeckle(ctx, w, h, params, u);
// ---- the hand: a pencilled seed/lab caption, the studied archive note ----
if (params.showHeader) drawCaption(ctx, w, h, scene, params, u);
}
/* an irregular soft paper border — bright paper feathering to a faint torn line. */
function drawDeckle(ctx, w, h, params, u) {
const rng = makeRng(params.seed, 'deckle');
const inset = 14 * u;
ctx.save();
// a soft inner vignette of *light* (paper curling up at the edges into the light)
const vg = ctx.createRadialGradient(w / 2, h / 2, w * 0.40, w / 2, h / 2, w * 0.72);
vg.addColorStop(0, 'rgba(40,30,18,0)');
vg.addColorStop(1, 'rgba(40,30,18,0.10)');
ctx.fillStyle = vg; ctx.fillRect(0, 0, w, h);
// torn deckle line
ctx.globalCompositeOperation = 'multiply';
ctx.strokeStyle = 'rgba(60,48,32,0.16)';
ctx.lineWidth = 1.2 * u;
ctx.beginPath();
const edge = (along, base) => base + (rng() - 0.5) * 6 * u;
ctx.moveTo(edge(0, inset), inset);
for (let x = 0; x <= w; x += 24 * u) ctx.lineTo(x, edge(x, inset));
for (let y = 0; y <= h; y += 24 * u) ctx.lineTo(edge(y, w - inset), y);
for (let x = w; x >= 0; x -= 24 * u) ctx.lineTo(x, edge(x, h - inset));
for (let y = h; y >= 0; y -= 24 * u) ctx.lineTo(edge(y, inset), y);
ctx.closePath(); ctx.stroke();
ctx.restore();
}
/* a faint graphite caption — the human who studied the plate, kept in. */
function drawCaption(ctx, w, h, scene, params, u) {
ctx.save();
ctx.globalCompositeOperation = 'multiply';
ctx.fillStyle = 'rgba(58,52,44,0.55)';
ctx.font = `${11 * u}px 'JetBrains Mono', ui-monospace, monospace`;
ctx.textBaseline = 'bottom';
ctx.fillText(`${scene.lab}`, 30 * u, h - 30 * u - 15 * u);
ctx.fillStyle = 'rgba(58,52,44,0.42)';
ctx.font = `${9 * u}px 'JetBrains Mono', ui-monospace, monospace`;
ctx.fillText(`seed ${params.seed} · plate ${scene.plate} · ${scene.exposure}`, 30 * u, h - 30 * u);
ctx.restore();
}