Files
bubblechambersimart/tools/refined-backlit.mjs

382 lines
22 KiB
JavaScript
Raw Normal View History

2026-07-19 13:37:10 -04:00
/* ============================================================
refined-backlit.mjs ONE SUN SETTING: five plates of a single
seed's world (seethe-bold's sea, MESON-5113) across which time
advances. The trigger event is byte-identical in every frame
a single millisecond, embalmed while everything around it
ages: the sun walks its setting diagonal LR and sinks, the
chamber keeps running (background events accumulate), the
grain grows as the light dies, and THE WATER REMEMBERS.
The constitution (2026-07-05/06, from the maker):
· THE WORLD IS SOVEREIGN IN THE INSTANT the sea never reacts
to the event within a frame; submerged evidence is refracted,
tinted, dimmed, and the waves pass in front of it.
· BUT THE WATER REMEMBERS ACROSS EXPOSURES frame by frame,
the wave rows bend around where the evidence has been sitting
(feDisplacementMap driven by the submerged ink, scale growing
with time). Influence arrives only through time.
· THE SKY IS THE MARGIN WHERE THE HAND LIVES and the hand has
a JOB: it tracks the descent. A pencil ghost-ring marks where
the sun was in the PREVIOUS exposure; an altitude reading is
logged each frame; when the sun sinks, the ring stops the
hand loses its subject and starts guessing (h 0·54?).
· β-LAW COLOUR, magenta-centred transparent births, the family
holds hot magenta, violet is only the last word.
· EMBER HEART · EVEN DEMOCRACY · AGED DOCUMENT (foxing, dust,
scratches; grain ramps up as dusk falls).
Usage: node tools/refined-backlit.mjs [size]
fable/REFINED_Backlit/
============================================================ */
import { writeFileSync, mkdirSync } from 'node:fs';
import { carpetSVG } from '../src/qft/carpet.js';
import { generateScene } from '../src/scene/scene.js';
import { renderSVG } from '../src/render/svgVector.js';
import { paramsFromSeed as bcParams } from '../src/scene/params.js';
import { GROUPS, TOGGLES, FIXED } from '../src/ui/controls.js';
import { splitTracksAtWaterline } from '../src/compose/waterline.js';
import { chinagraphText } from '../src/scene/chinagraph.js';
import { makeRng, gauss } from '../src/rng.js';
const SIZE = +(process.argv[2] || 1500);
const HAND = process.argv[3] !== undefined ? +process.argv[3] : 0; // scanner's-hand presence: 0 silent … 1 full (2026-07-16: DISABLED for now — still not feeling it; ledger experiment kept behind the flag)
const OUT = process.argv[4] || 'fable/REFINED_Backlit';
const LEDGER = process.argv[5] === 'ledger'; // EXPERIMENT 2026-07-16: embalmed marks — written once, persist on every later exposure
mkdirSync(OUT, { recursive: true });
const u = SIZE / 1000;
const dataUri = (svg) => 'data:image/svg+xml;base64,' + Buffer.from(svg).toString('base64');
/* ---- the world constants: seethe-bold's own config ---- */
const SEED = 'MESON-5113';
const BASE = 'rgb(227,220,200)';
const HORIZON = 0.37;
const YH = HORIZON * 2 - 1; // -0.26 scene
const FILM = { seed: 41, density: 0.6 };
const GRAPHITE = '#39312a';
/* ---- film / grain / aging (the document's flesh) ---- */
function filmSVG(o = {}) {
const { seed = 7, freq = 0.0016, octaves = 4, tone = [236, 228, 208], density = 0.55 } = o;
const t = tone.map(v => (v / 255).toFixed(3));
return `<svg xmlns="http://www.w3.org/2000/svg" width="${SIZE}" height="${SIZE}" viewBox="0 0 ${SIZE} ${SIZE}">
<defs><filter id="fog" x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="${freq}" numOctaves="${octaves}" seed="${seed}" stitchTiles="stitch" result="n"/>
<feColorMatrix in="n" type="matrix" values="0 0 0 0 ${t[0]} 0 0 0 0 ${t[1]} 0 0 0 0 ${t[2]} 0 0 0 ${density} 0"/>
</filter></defs><rect width="${SIZE}" height="${SIZE}" filter="url(#fog)"/></svg>`;
}
function grainSVG(o = {}) {
const { seed = 19, tone = [38, 32, 26], amount = 0.5 } = o;
const t = tone.map(v => (v / 255).toFixed(3));
return `<svg xmlns="http://www.w3.org/2000/svg" width="${SIZE}" height="${SIZE}" viewBox="0 0 ${SIZE} ${SIZE}">
<defs><filter id="g" x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed="${seed}" stitchTiles="stitch" result="n"/>
<feColorMatrix in="n" type="matrix" values="0 0 0 0 ${t[0]} 0 0 0 0 ${t[1]} 0 0 0 0 ${t[2]} 0 0 0 ${amount} 0"/>
</filter></defs><rect width="${SIZE}" height="${SIZE}" filter="url(#g)"/></svg>`;
}
function agingSVG(o = {}) {
const { seed = 5, scratches = 6, dust = 0.5, foxing = 0.55, tone = [60, 48, 34] } = o;
const t = tone.map(v => (v / 255).toFixed(3));
const rng = makeRng(SEED, 'aging' + seed);
let lines = '';
for (let i = 0; i < scratches; i++) {
const x = rng() * SIZE, y0 = rng() * SIZE * 0.4, len = (0.3 + rng() * 0.6) * SIZE;
const x2 = x + (rng() - 0.5) * 40, y2 = y0 + len;
lines += `<line x1="${x.toFixed(0)}" y1="${y0.toFixed(0)}" x2="${x2.toFixed(0)}" y2="${y2.toFixed(0)}" stroke="rgb(${tone.join(',')})" stroke-opacity="${(0.05 + rng() * 0.12).toFixed(3)}" stroke-width="${(0.4 + rng() * 0.8).toFixed(2)}"/>`;
}
return `<svg xmlns="http://www.w3.org/2000/svg" width="${SIZE}" height="${SIZE}" viewBox="0 0 ${SIZE} ${SIZE}">
<defs>
<filter id="dust" x="0" y="0" width="100%" height="100%"><feTurbulence type="turbulence" baseFrequency="0.5" numOctaves="2" seed="${seed + 5}" stitchTiles="stitch" result="n"/><feColorMatrix in="n" type="matrix" values="0 0 0 0 ${t[0]} 0 0 0 0 ${t[1]} 0 0 0 0 ${t[2]} 0 0 0 ${(dust * 0.5).toFixed(3)} -0.18"/></filter>
<filter id="fox" x="0" y="0" width="100%" height="100%"><feTurbulence type="fractalNoise" baseFrequency="0.004" numOctaves="3" seed="${seed + 9}" stitchTiles="stitch" result="n"/><feColorMatrix in="n" type="matrix" values="0 0 0 0 0.42 0 0 0 0 0.30 0 0 0 0 0.16 0 0 0 ${(foxing * 0.32).toFixed(3)} -0.12"/></filter>
</defs>
<rect width="${SIZE}" height="${SIZE}" filter="url(#fox)"/>
<rect width="${SIZE}" height="${SIZE}" filter="url(#dust)"/>
${lines}</svg>`;
}
function deck(c, warpFn) {
const base = { mode: 'plate', rows: 46, horizon: HORIZON, wFar: 0.58, wNear: 0.7,
overlap: 1.9, mound: 0.4, sat: 0.58, lightNear: 0.33, lightFar: 0.56, blips: c.blips, warpFn };
const lerp = (a, b, t) => a + (b - a) * t;
const strokes = [{ near: 2.6, far: 1.0 }, { near: 1.6, far: 0.6 }, { near: 1.0, far: 0.38 }];
return [0, 1, 2].map(i => {
const t = i / 2;
return carpetSVG(SIZE, { ...base, salt: 'field' + i,
hue: lerp(0.55, 0.47, t), hue2: lerp(0.55, 0.47, t) + 0.035,
chaos: lerp(c.chaos * 0.8, c.chaos, t),
strokeNear: strokes[i].near, strokeFar: strokes[i].far });
});
}
/* ============================================================
IRON FILINGS the field attends to the evidence.
A coarse attraction field is built from the submerged ink
(track points + the sunken sun); every carpet row point is
pulled TOWARD the nearest evidence, like filings onto a
magnet. Two time-scales: a faint instantaneous pull in every
frame (the field can never fully ignore the evidence) plus a
memory ramp that deepens across the exposures.
============================================================ */
function buildAttraction(below, shock, sunSubmerged) {
const S = (v) => SIZE / 2 + v * (SIZE / 2) * 0.98; // scene → px (renderSVG's mapping)
const pts = [];
for (const t of below) for (let i = 0; i < t.pts.length; i += 5)
pts.push({ x: S(t.pts[i].x), y: S(t.pts[i].y), w: 1 });
if (sunSubmerged) pts.push({ x: S(shock.x), y: S(shock.y), w: 12 });
const G = 72, cell = SIZE / G, sig = 0.045 * SIZE;
const fy = new Float32Array(G * G);
for (let gy = 0; gy < G; gy++) for (let gx = 0; gx < G; gx++) {
const x = (gx + 0.5) * cell, y = (gy + 0.5) * cell;
let dy = 0, wsum = 0;
for (const p of pts) {
const dx0 = p.x - x, dy0 = p.y - y;
const k = p.w * Math.exp(-(dx0 * dx0 + dy0 * dy0) / (2 * sig * sig));
dy += k * Math.max(-sig, Math.min(sig, dy0));
wsum += k;
}
fy[gy * G + gx] = wsum > 0.02 ? (dy / (wsum + 1.2)) / sig : 0; // → roughly -0.5..0.5, fades to 0 away from ink
}
return (x, y) => { // bilinear sample, px in → pull fraction out
const fx0 = Math.min(G - 1.001, Math.max(0, x / cell - 0.5));
const fy0 = Math.min(G - 1.001, Math.max(0, y / cell - 0.5));
const ix = Math.floor(fx0), iy = Math.floor(fy0), ax = fx0 - ix, ay = fy0 - iy;
const v00 = fy[iy * G + ix], v10 = fy[iy * G + ix + 1];
const v01 = fy[(iy + 1) * G + ix], v11 = fy[(iy + 1) * G + ix + 1];
return (v00 * (1 - ax) + v10 * ax) * (1 - ay) + (v01 * (1 - ax) + v11 * ax) * ay;
};
}
// reconstruct carpetSVG's own row geometry so the pull is expressed in the
// hook's normalized units (returned value is multiplied by the row's amp)
function warpFor(sample, pullPx) {
const hY = HORIZON * SIZE, bottom = 0.99 * SIZE;
return (t, d) => {
const half = (0.58 + 0.12 * d) * SIZE;
const x = SIZE / 2 - half + t * 2 * half;
const y = hY + (bottom - hY) * Math.pow(d, 1.7);
const localGap = (bottom - hY) * 1.7 * Math.pow(Math.max(d, 0.02), 0.7) / 45;
const amp = Math.max(2 * u, 1.9 * localGap);
const n = sample(x, y) * pullPx / amp;
return Math.max(-0.9, Math.min(0.9, n)); // pinch, don't shatter
};
}
/* ---- the scanner's marks (beyond glyphs) ---- */
function pencilRing(cx, cy, r, rng, ink, w) {
const n = 44, start = rng() * Math.PI * 2, turn = Math.PI * 2 * (1.04 + rng() * 0.1);
const dx = gauss(rng) * r * 0.05, dy = gauss(rng) * r * 0.05;
let d = '';
for (let i = 0; i <= n; i++) {
const a = start + turn * (i / n), rr = r * (1 + gauss(rng) * 0.045);
d += `${i ? 'L' : 'M'} ${(cx + Math.cos(a) * rr + dx * i / n).toFixed(1)} ${(cy + Math.sin(a) * rr + dy * i / n).toFixed(1)} `;
}
return `<path d="${d}" fill="none" stroke="${ink}" stroke-opacity="0.6" stroke-width="${w.toFixed(2)}" stroke-linecap="round"/>`;
}
function pencilArc(cx, cy, r, a0, a1, rng, ink, w) {
const n = 18; let d = '';
for (let i = 0; i <= n; i++) {
const a = a0 + (a1 - a0) * (i / n);
d += `${i ? 'L' : 'M'} ${(cx + Math.cos(a) * r + gauss(rng) * 1.4).toFixed(1)} ${(cy + Math.sin(a) * r + gauss(rng) * 1.4).toFixed(1)} `;
}
return `<path d="${d}" fill="none" stroke="${ink}" stroke-opacity="0.7" stroke-width="${w.toFixed(2)}" stroke-linecap="round"/>`;
}
/* ---- the event ---- */
function bcAssembleParams(over = {}) {
const p = { ...FIXED, ...bcParams(SEED) };
for (const g of GROUPS) for (const c of g.controls) if (!(c.id in p)) p[c.id] = c.value;
for (const t of TOGGLES) if (!(t.id in p)) p[t.id] = t.value;
Object.assign(p, {
invert: true, transparentPaper: true, vign: 0,
showHeader: false, showBoundary: false, showFiducials: false,
annotate: 0, reseau: 0, filmEdge: false, splice: false, artifacts: 0,
palette: 'magbeta', saturation: 1.0,
diskBubbles: false, diskPressure: 0.9, diskSoften: 0.8,
shockIntensity: 0.9, shockStriations: 1.0,
diskHollow: +(process.env.HOLLOW ?? 0.85), // open the sun's heart: centre fades to paper, ember gathers in a ring
depth: 0.3, aging: 0.25,
}, over);
return p;
}
const fx = (sx) => ((sx + 1) / 2), fy = (sy) => ((sy + 1) / 2); // scene → frame fraction
function renderPlate(v, i) {
const p = bcAssembleParams(v.bcOver);
const scene = generateScene(p);
const { above, below } = splitTracksAtWaterline(scene.tracks, YH, { kink: 0.016, compress: 0.95 });
const bare = { ...scene, instrument: null, artifacts: null, media: null };
// RECIPROCITY (asymmetric): the submerged trails pick up a subtle waviness
// along the field's rows — coerced, but far less than they coerce.
const wamp = v.wave;
const belowWavy = below.map(t => ({
...t,
pts: t.pts.map(pt => ({ ...pt, y: pt.y + wamp * Math.sin(pt.x * 34 + pt.y * 18) })),
}));
const trackSVG = (tracks) => renderSVG({ ...bare, tracks, shock: null }, { ...p, emit: ['bubble'] }, SIZE);
const diskSVG = () => renderSVG({ ...bare, tracks: [], shock: scene.shock }, { ...p, emit: ['disk'] }, SIZE);
const aboveImg = dataUri(trackSVG(above));
const belowImg = dataUri(trackSVG(belowWavy));
const diskImg = dataUri(diskSVG());
// IRON FILINGS: every wave row is pulled toward the submerged evidence.
const sunSubmerged = scene.shock && scene.shock.y > YH - scene.shock.r * 0.4;
const sample = buildAttraction(belowWavy, scene.shock, sunSubmerged);
const sheets = deck(v.sea, warpFor(sample, v.pull * u)).map(dataUri);
const film = dataUri(filmSVG(FILM));
const grain = dataUri(grainSVG({ amount: v.grain }));
const aging = dataUri(agingSVG({ seed: 5 + i, scratches: 6, dust: 0.5, foxing: 0.55 }));
const hpx = (HORIZON * SIZE).toFixed(1);
const m = v.muffle;
const IMG = (href, attrs = '') => `<image x="0" y="0" width="${SIZE}" height="${SIZE}" href="${href}" ${attrs}/>`;
/* ---- the hand: tracking the descent ---- */
const hRng = makeRng(SEED, 'hand:' + v.name);
const px = (f) => f * SIZE, py = (f) => f * SIZE;
let hand = '';
if (HAND > 0 && LEDGER) {
/* EMBALMED MARKS one physical plate, five exposures. Each mark is
written ONCE, at its own exposure (rng seeded per mark, not per
plate), so its strokes are byte-identical on every later plate.
The ledger accumulates in the corner; the rings march down the
diagonal and stop where the water takes their subject. */
const rngFor = (tag) => makeRng(SEED, 'mark:' + tag);
const LX = 0.735; // ledger lives top-right: the north-west sky belongs to the sun's history
hand += chinagraphText('Nº 217', { x: px(LX), y: py(0.06), h: 20 * u, rng: rngFor('label'), ink: GRAPHITE, width: 2.6 * u });
for (let k = 0; k <= i; k++) {
const line = PLATES[k].time + ' h ' + alt(k) + (k === PLATES.length - 1 ? '?' : '');
hand += chinagraphText(line, { x: px(LX), y: py(0.102 + k * 0.031), h: 15 * u, rng: rngFor('line' + k), ink: GRAPHITE, width: 2.0 * u });
if (k < i && SUNPATH[k][1] < YH - 0.02)
hand += `<g clip-path="url(#skyc)">${pencilRing(px(fx(SUNPATH[k][0])), py(fy(SUNPATH[k][1])), 0.05 * SIZE, rngFor('ring' + k), GRAPHITE, 2.2 * u)}</g>`;
}
if (i >= 2) { // the θ measurement, taken at the third exposure, persists
const a = PLATES[2].arc;
hand += pencilArc(px(a.at[0]), py(a.at[1]), a.r * SIZE, a.a0, a.a1, rngFor('arc'), GRAPHITE, 2.2 * u);
hand += chinagraphText(a.text, { x: px(a.textAt[0]), y: py(a.textAt[1]), h: 17 * u, rng: rngFor('arctext'), ink: GRAPHITE, width: 2.2 * u });
}
} else if (HAND > 0) {
if (v.label) hand += chinagraphText(v.label, { x: px(0.065), y: py(0.085), h: 26 * u, rng: hRng, ink: GRAPHITE, width: 3.0 * u });
hand += chinagraphText(v.time, { x: px(0.065), y: py(0.125), h: 21 * u, rng: hRng, ink: GRAPHITE, width: 2.6 * u });
// ghost-ring where the sun was LAST exposure (only while that spot is in the sky)
if (v.ghost) {
const [gx, gy, gr] = v.ghost;
hand += `<g clip-path="url(#skyc)">${pencilRing(px(gx), py(gy), gr * SIZE, hRng, GRAPHITE, 2.4 * u)}</g>`;
}
// altitude reading, logged beside the sun's vertical
if (v.alt) hand += chinagraphText(v.alt.text, { x: px(v.alt.at[0]), y: py(v.alt.at[1]), h: 19 * u, rng: hRng, ink: GRAPHITE, width: 2.4 * u });
// plate 3: the measured angle lives ON the vertex, arc + reading
if (v.arc) {
const [ax, ay] = v.arc.at;
hand += pencilArc(px(ax), py(ay), v.arc.r * SIZE, v.arc.a0, v.arc.a1, hRng, GRAPHITE, 2.2 * u);
hand += chinagraphText(v.arc.text, { x: px(v.arc.textAt[0]), y: py(v.arc.textAt[1]), h: 19 * u, rng: hRng, ink: GRAPHITE, width: 2.4 * u });
}
}
if (HAND > 0 && HAND < 1) hand = `<g opacity="${HAND}">${hand}</g>`; // the hand recedes into the wax
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${SIZE}" height="${SIZE}" viewBox="0 0 ${SIZE} ${SIZE}">
<defs>
<filter id="b3" x="-8%" y="-8%" width="116%" height="116%"><feGaussianBlur stdDeviation="${(2.6 * u).toFixed(2)}"/></filter>
<filter id="b2" x="-6%" y="-6%" width="112%" height="112%"><feGaussianBlur stdDeviation="${(1.1 * u).toFixed(2)}"/></filter>
<filter id="subT" x="-6%" y="-6%" width="112%" height="112%">
<feColorMatrix type="matrix" values="0.78 0 0 0 0.010 0 0.94 0 0 0.022 0 0 0.96 0 0.022 0 0 0 0.85 0"/>
<feGaussianBlur stdDeviation="${(1.0 * u).toFixed(2)}"/>
</filter>
<filter id="subD" x="-10%" y="-10%" width="120%" height="120%">
<feColorMatrix type="matrix" values="0.80 0 0 0 0.012 0 0.93 0 0 0.024 0 0 0.95 0 0.024 0 0 0 ${(1 - 0.55 * m).toFixed(2)} 0"/>
<feGaussianBlur stdDeviation="${((0.6 + 3.2 * m) * u).toFixed(2)}"/>
</filter>
<clipPath id="skyc"><rect x="0" y="0" width="${SIZE}" height="${hpx}"/></clipPath>
<clipPath id="seac"><rect x="0" y="${hpx}" width="${SIZE}" height="${(SIZE - HORIZON * SIZE).toFixed(1)}"/></clipPath>
</defs>
<rect width="${SIZE}" height="${SIZE}" fill="${BASE}"/>
${IMG(film, 'opacity="0.6"')}
<g clip-path="url(#skyc)">${IMG(diskImg)}</g>
${IMG(sheets[0], 'filter="url(#b3)" opacity="0.5"')}
<g clip-path="url(#seac)">${IMG(diskImg, 'filter="url(#subD)"')}</g>
${IMG(belowImg, 'filter="url(#subT)"')}
<rect x="0" y="${hpx}" width="${SIZE}" height="${SIZE}" fill="rgb(96,138,128)" opacity="0.07"/>
${IMG(sheets[1], `filter="url(#b2)" opacity="0.72"`)}
${IMG(sheets[2], 'opacity="0.95"')}
${IMG(aboveImg)}
${hand}
${IMG(aging, 'opacity="0.5" style="mix-blend-mode:multiply"')}
${IMG(grain, `opacity="0.45" style="mix-blend-mode:multiply"`)}
</svg>`;
writeFileSync(`${OUT}/${v.name}.svg`, svg);
console.log(` ${v.name} sun(${v.bcOver.shockX},${v.bcOver.shockY}) bg=${v.bcOver.bgEvents} pull=${v.pull}`);
}
/* ============================================================
ONE SUN SETTING five exposures of plate 217.
The trigger is identical in every frame (embalmed); the sun
walks its setting diagonal; the chamber keeps running; the
grain grows; the water remembers.
============================================================ */
const EVENT = { eventX: -0.14, eventY: -0.37 };
const TRACES = { sweepers: 5, primaries: 17, eloss: 0.34, deltaRate: 0.8 };
const SUNPATH = [[-0.55, -0.72], [-0.37, -0.53], [0.02, YH], [0.22, 0.15], [0.4, 0.27]];
const alt = (i) => {
const h = YH - SUNPATH[i][1];
const s = Math.abs(h).toFixed(2).replace('0.', '0·');
return (h > 0.005 ? '+' : h < -0.005 ? '-' : '') + s;
};
const PLATES = [
{ name: '01_sun-above', time: '19:04', label: 'Nº 217',
muffle: 0, pull: 7, wave: 0.003, grain: 0.42,
sea: { chaos: 0.8, blips: 1.4 },
alt: { text: 'h ' + alt(0), at: [fx(SUNPATH[0][0]) + 0.1, fy(SUNPATH[0][1]) - 0.02] },
bcOver: { ...EVENT, ...TRACES, bgEvents: 2, aging: 0.2, shockX: SUNPATH[0][0], shockY: SUNPATH[0][1], shockSize: 0.17, shockIntensity: 0.8 } },
{ name: '02_sun-touching', time: '19:26',
muffle: 0.18, pull: 13, wave: 0.0045, grain: 0.46,
sea: { chaos: 0.84, blips: 1.2 },
ghost: [fx(SUNPATH[0][0]), fy(SUNPATH[0][1]), 0.055],
alt: { text: 'h ' + alt(1), at: [fx(SUNPATH[1][0]) + 0.11, fy(SUNPATH[1][1]) - 0.02] },
bcOver: { ...EVENT, ...TRACES, bgEvents: 4, aging: 0.3, shockX: SUNPATH[1][0], shockY: SUNPATH[1][1], shockSize: 0.19, shockIntensity: 0.85 } },
{ name: '03_sun-half', time: '19:47',
muffle: 0.4, pull: 20, wave: 0.006, grain: 0.5,
sea: { chaos: 0.88, blips: 1.0 },
ghost: [fx(SUNPATH[1][0]), fy(SUNPATH[1][1]), 0.06],
alt: { text: 'h 0·00', at: [fx(SUNPATH[2][0]) + 0.12, fy(YH) - 0.035] },
arc: { at: [fx(EVENT.eventX), fy(EVENT.eventY)], r: 0.055, a0: 2.4, a1: 3.4, text: 'θ 38°',
textAt: [fx(EVENT.eventX) - 0.185, fy(EVENT.eventY) + 0.045] },
bcOver: { ...EVENT, ...TRACES, bgEvents: 6, aging: 0.45, shockX: SUNPATH[2][0], shockY: SUNPATH[2][1], shockSize: 0.21 } },
{ name: '04_sun-drowned', time: '20:08', label: 'Nº 217',
muffle: 0.62, pull: 28, wave: 0.008, grain: 0.55,
sea: { chaos: 0.92, blips: 0.85 },
// no ghost-ring: the previous position is at the waterline — the hand
// cannot ring what the water has taken; only the reading continues
alt: { text: 'h ' + alt(3), at: [fx(SUNPATH[3][0]) + 0.1, fy(YH) - 0.03] },
bcOver: { ...EVENT, ...TRACES, bgEvents: 8, aging: 0.6, shockX: SUNPATH[3][0], shockY: SUNPATH[3][1], shockSize: 0.24 } },
{ name: '05_sun-gone', time: '20:31',
muffle: 0.85, pull: 36, wave: 0.01, grain: 0.6,
sea: { chaos: 0.96, blips: 0.7 },
alt: { text: 'h ' + alt(4) + '?', at: [fx(SUNPATH[4][0]) + 0.06, fy(YH) - 0.03] },
bcOver: { ...EVENT, ...TRACES, bgEvents: 10, aging: 0.75, shockX: SUNPATH[4][0], shockY: SUNPATH[4][1], shockSize: 0.27, shockStriations: 1.2 } },
];
console.log(`ONE SUN SETTING — ${PLATES.length} plates → ${OUT}/`);
PLATES.forEach(renderPlate);
const cap = (v) => v.name.replace(/^\d+_/, '').replace(/-/g, ' ');
writeFileSync(`${OUT}/index.html`, `<!DOCTYPE html><html><head><meta charset="utf-8"><title>REFINED_Backlit · one sun setting</title>
<style>body{margin:0;background:#0b0b0b;color:#bbb;font:12px/1.5 ui-monospace,monospace;padding:26px}
h1{font-weight:400;letter-spacing:.22em;text-transform:uppercase;font-size:13px;color:#d9b48a}
.row{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;margin-top:16px}
figure{margin:0;background:#fff;overflow:hidden}img{width:100%;display:block}
figcaption{padding:6px 8px;color:#e8e4d8;background:#111;font-size:11px}
.grid{display:grid;grid-template-columns:repeat(2,1fr);gap:14px;margin-top:22px}
.notes{color:#999;background:#161210;padding:12px 16px;border-left:3px solid #d9b48a;margin:14px 0;max-width:960px}</style></head><body>
<h1>One sun setting plate 217, five exposures</h1>
<div class="notes">The trigger event is identical in every frame a single millisecond, embalmed while everything around it ages: the sun walks its setting diagonal and sinks, the chamber keeps running (history accumulates), the grain grows as the light dies, and the water remembers: the wave rows slowly bend around the submerged evidence. The sea never reacts in the instant; influence arrives only through time. The hand tracks the descent ghost-rings where the sun was, altitude readings, and when the sun is gone, a guess.</div>
<div class="row">${PLATES.map(v => `<figure><img src="${v.name}.svg"><figcaption>${cap(v)} · ${v.time}</figcaption></figure>`).join('')}</div>
<div class="grid">${PLATES.map(v => `<figure><img src="${v.name}.svg"><figcaption>${cap(v)} · ${v.time}</figcaption></figure>`).join('')}</div>
</body></html>`);
console.log(`gallery -> ${OUT}/index.html`);