Files
bubblechambersimart/tools/proof3-render.mjs
2026-07-19 21:28:14 -04:00

350 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
proof1-render.mjs — PROOF 1: the lit chamber, split into planes.
Takes the locked golden merge composition (REFINED_Backlit7,
the mirror event, uniform-lace merge) and emits it as SEPARATE
transparent planes for a backlit acrylic stack, plus a manifest
and a parallax/backlight simulator to art-direct the depth
BEFORE committing to print.
Plane map (deepest → nearest), causal depth cause→world→evidence:
1 sun faded disk · pure transmitted colour · zero white
2 sea-far sheet[0]
3 sea-mid sheet[1]
4 sea-near sheet[2]
5 event-halo merged bubbles, blurred by the air gap (glow)
6 event-core merged bubbles, sharp · white underlay
+ front-glass grain + aging (the surface, multiply)
Golden frame (φ) is preserved and FLOATED in an A3 sheet later;
this stage renders the art square only.
Usage: node tools/proof1-render.mjs [width]
→ fable/Proof1/
============================================================ */
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 { sampleBubbles, depthFactors } from '../src/scene/bubbles.js';
import { makeRng } from '../src/rng.js';
const PHI = (1 + Math.sqrt(5)) / 2;
const IP = 1 / PHI, IP2 = 1 / (PHI * PHI);
const W = +(process.argv[2] || 1001);
const H = Math.round(W * PHI); // golden portrait
const OUT = 'fable/Proof3';
mkdirSync(OUT, { recursive: true });
const u = Math.min(W, H) / 1000;
const dataUri = (svg) => 'data:image/svg+xml;base64,' + Buffer.from(svg).toString('base64');
/* ---- golden armature (identical to REFINED_Backlit7) ---- */
const SEED = 'MESON-5113';
const SEED_EVENT = `${SEED}#03`; // the chosen mirror event
const BUBOP = +(process.env.BUBOP ?? 0.5); // mid of the 45→55 band
const HORIZON_F = IP2;
const HY = HORIZON_F * H, SKY_H = HY;
const S_SCALE = (Math.min(W, H) / 2) * (1 - 0.02);
const XL = IP2 * W, XR = IP * W;
const SUN = { x: XR, y: IP * SKY_H }; // mirror: sun on the RIGHT line
const SUN_R = IP2 * SKY_H;
const VERTEX = { x: XL, y: HY }; // mirror: vertex on the LEFT line
const TRACE_ZOOM = 2.1;
/* ---- textures ---- */
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="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
<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="${W}" height="${H}" filter="url(#g)"/></svg>`;
}
function agingSVG(o = {}) {
const { seed = 5, scratches = 8, dust = 0.6, foxing = 0.62, tone = [60, 48, 34] } = o;
const t = tone.map(v => (v / 255).toFixed(3));
let lines = '';
// deterministic scratch scatter without Math.random (seeded LCG)
let s = seed * 2654435761 >>> 0;
const rnd = () => ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296);
for (let i = 0; i < scratches; i++) {
const x = rnd() * W, y0 = rnd() * H * 0.4, len = (0.3 + rnd() * 0.6) * H;
const x2 = x + (rnd() - 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 + rnd() * 0.12).toFixed(3)}" stroke-width="${(0.4 + rnd() * 0.8).toFixed(2)}"/>`;
}
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
<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="${W}" height="${H}" filter="url(#fox)"/>
<rect width="${W}" height="${H}" filter="url(#dust)"/>
${lines}</svg>`;
}
/* ---- golden sea deck (equal-line, φ waves) ---- */
function deck() {
const base = {
mode: 'plate', width: W, height: H, horizon: HORIZON_F,
wFar: 0.5, wNear: 0.5 * PHI, overlap: PHI,
rows: 48, mound: 0.4, sat: 0.58, lightNear: 0.33, lightFar: 0.56,
chaos: 0.82, blips: 1.5,
};
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(H, { ...base, salt: 'mfield' + i,
hue: lerp(0.55, 0.47, t), hue2: lerp(0.55, 0.47, t) + 0.035,
chaos: lerp(0.82 * 0.8, 0.82, t),
strokeNear: strokes[i].near * 1.35, strokeFar: strokes[i].far * 1.35 });
});
}
/* ---- event params (merge, delicate, golden-placed) ---- */
function bcAssembleParams() {
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: 1.4,
shockStriations: 1.0, diskHollow: 1.0,
depth: 0.3, aging: 0.28,
eventX: 0, eventY: 0, shockX: 0, shockY: 0,
sweepers: 5, primaries: 17, eloss: 0.34, deltaRate: 0.8,
bgEvents: 2,
shockSize: SUN_R / S_SCALE, shockIntensity: 0.8,
size: 1.6,
bubbleHollow: 1, bubbleWeight: 0.7, bubbleSoft: 0.7, bubbleMerge: 1,
bubbleOpacity: BUBOP,
canvasW: W, canvasH: H,
seed: SEED_EVENT,
});
return p;
}
/* ---- render the source images ---- */
const p = bcAssembleParams();
const scene = generateScene(p);
const bare = { ...scene, instrument: null, artifacts: null, media: null };
const eventBase = { ...p, emit: ['bubble'], originX: VERTEX.x, originY: VERTEX.y, sceneZoom: TRACE_ZOOM };
const SCATTER = 0.32;
const NP = 7; // particle time-shells p1..p7
// EQUAL-COUNT bands: sample the exact bubble stream renderSVG will use (same
// seed/track order/params) to get the life distribution, then split it at
// count-quantiles so every shell carries ~the same number of bubbles (dense
// late betas no longer overweight the front shells).
function equalCountEdges(n) {
const rng = makeRng(p.seed, 'bubbles');
const lives = [];
for (const track of scene.tracks) {
if (track.pts.length < 2) continue;
const df = depthFactors(track, p);
for (const b of sampleBubbles({ ...track, sizeScale: df.sizeScale }, p, rng)) lives.push(b.life);
}
lives.sort((a, b) => a - b);
const edges = [0];
for (let i = 1; i < n; i++) edges.push(lives[Math.floor(i / n * lives.length)]);
edges.push(1.001);
return edges;
}
const EDGES = equalCountEdges(NP);
// UN-MUD: as a shell ages toward the FRONT (t→1), draw the bubble CENTRE down
// (core→0, transparent) and CRISP the rim (soft→thin, peak→bright) so dense
// violet deaths read as a lace of popping rings, not filled mud. Back/birth
// shells stay soft and full.
const lerp = (a, b, t) => a + (b - a) * t;
const shellParams = (i) => {
const t = NP > 1 ? i / (NP - 1) : 0;
return {
bubbleCore: lerp(0.72, 0.05, t), // centre empties toward the front
bubbleSoft: lerp(0.75, 0.14, t), // rim thins/crisps toward the front
bubbleMergePeak: lerp(0.86, 0.99, t), // rim brightens/pops toward the front
bubbleScatter: SCATTER,
};
};
const evt = (band, extra = {}) => renderSVG({ ...bare, tracks: scene.tracks, shock: null },
band ? { ...eventBase, bubbleBand: band, ...extra } : eventBase, W);
const tracksAll = evt(null); // glow = whole event, blurred deep
const P = []; // P[i] = shell i (p(i+1)), birth→death
for (let i = 0; i < NP; i++) P.push(evt([EDGES[i], EDGES[i + 1]], shellParams(i)));
const diskImg = renderSVG({ ...bare, tracks: [], shock: scene.shock },
{ ...p, emit: ['disk'], originX: SUN.x, originY: SUN.y }, W);
const sheets = deck();
const grain = grainSVG({ amount: 0.52 });
const aging = agingSVG({ seed: 8 });
/* ---- write each plane as a transparent SVG (art square only) ---- */
const planeSVG = (inner) =>
`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${inner}</svg>`;
const IMG = (svg) => `<image x="0" y="0" width="${W}" height="${H}" href="${dataUri(svg)}"/>`;
// grain+aging combined onto one front-glass texture (multiply handled in sim)
const frontGlass = planeSVG(
`${IMG(aging)}<g opacity="0.85">${IMG(grain)}</g>`);
// SEA on THREE individual plates (own internal parallax), FULL opacity so the
// rearmost (sea-far) stays visible — real air gaps carry depth, not opacity
// dimming. Thin wave-lines get minimal blur (they dissolve under it).
//
// ORDER (deep → near): sun · event-GLOW (submerged afterimage) · SEA ×3 ·
// event BIRTH/LIFE/DEATH · glass. The sea is a VEIL between the event's ghost
// (its light, held under the water) and its body (the fresh bubbles above it).
// sun +50%: a 0.7-opacity second pass lifts the faint hollow sun. sea 30% (0.7).
const sunPlane = planeSVG(`${IMG(diskImg)}<g opacity="0.7">${IMG(diskImg)}</g>`);
const pPlane = (i) => planeSVG(IMG(P[i]));
// PROOF3 layer order (deep → near), thin stacked sheets:
// sun · glow · wave1 · p1 · wave2 · p2 · wave3 · p3 · p4 · p5 · p6 · p7 · grain
// The sea threads through the EARLY (birth) shells; the later (death) shells
// stack in front of the water. Each particle shell is one step older in time.
const zBubBlur = [0.8, 0.7, 0.55, 0.45, 0.35, 0.2, 0.0]; // p1..p7: deeper = softer
const pWhite = ['none', 'none', 'trace', 'trace', 'trace', 'under', 'under'];
const PLANES = [
{ id: 'sun', file: 'plane-01-sun.svg', z: 0, blur: 1.4, dim: 1.0, white: 'none', glow: 1.0, svg: sunPlane },
{ id: 'glow', file: 'plane-02-glow.svg', z: 5, blur: 2.6, dim: 0.85, white: 'none', glow: 0.9, svg: planeSVG(IMG(tracksAll)) },
{ id: 'wave1', file: 'plane-03-wave1.svg', z: 10, blur: 0.9, dim: 0.7, white: 'none', glow: 0.8, svg: planeSVG(IMG(sheets[0])) },
{ id: 'p1', file: 'plane-04-p1.svg', z: 14, blur: zBubBlur[0], dim: 1.0, white: pWhite[0], glow: 0.8, svg: pPlane(0) },
{ id: 'wave2', file: 'plane-05-wave2.svg', z: 18, blur: 0.5, dim: 0.7, white: 'trace', glow: 0.7, svg: planeSVG(IMG(sheets[1])) },
{ id: 'p2', file: 'plane-06-p2.svg', z: 22, blur: zBubBlur[1], dim: 1.0, white: pWhite[1], glow: 0.7, svg: pPlane(1) },
{ id: 'wave3', file: 'plane-07-wave3.svg', z: 26, blur: 0.2, dim: 0.7, white: 'trace', glow: 0.7, svg: planeSVG(IMG(sheets[2])) },
{ id: 'p3', file: 'plane-08-p3.svg', z: 30, blur: zBubBlur[2], dim: 1.0, white: pWhite[2], glow: 0.6, svg: pPlane(2) },
{ id: 'p4', file: 'plane-09-p4.svg', z: 34, blur: zBubBlur[3], dim: 1.0, white: pWhite[3], glow: 0.5, svg: pPlane(3) },
{ id: 'p5', file: 'plane-10-p5.svg', z: 38, blur: zBubBlur[4], dim: 1.0, white: pWhite[4], glow: 0.4, svg: pPlane(4) },
{ id: 'p6', file: 'plane-11-p6.svg', z: 42, blur: zBubBlur[5], dim: 1.0, white: pWhite[5], glow: 0.35, svg: pPlane(5) },
{ id: 'p7', file: 'plane-12-p7.svg', z: 46, blur: zBubBlur[6], dim: 1.0, white: pWhite[6], glow: 0.3, svg: pPlane(6) },
{ id: 'film-grain', file: 'plane-13-film-grain.svg', z: 46, blur: 0.0, dim: 1.0, white: 'none', glow: 0.0, blend: 'multiply', svg: frontGlass },
];
for (const pl of PLANES) writeFileSync(`${OUT}/${pl.file}`, pl.svg);
const manifest = {
piece: 'Proof3 — the lit chamber (equal-count shells, ring-pop)',
source: 'REFINED_Backlit7 merge (mirror event, uniform-lace)',
seed: SEED_EVENT, bubbleOpacity: BUBOP,
art: { w: W, h: H, ratio: 'phi (golden portrait)' },
sheet: { w_mm: 330, h_mm: 420, note: 'A3 — golden art square floated, clear margins = lit void' },
gaps_mm: 'plane z is nominal; two scales: ~3mm within a sheet, ~9mm between',
planes: PLANES.map(({ svg, ...rest }) => rest),
};
writeFileSync(`${OUT}/manifest.json`, JSON.stringify(manifest, null, 2));
/* ---- the backlit parallax simulator ---- */
const planesJSON = JSON.stringify(PLANES.map(({ svg, ...r }) => r));
writeFileSync(`${OUT}/simulator.html`, `<!DOCTYPE html><html><head><meta charset="utf-8">
<title>Proof3 · lit chamber — backlit parallax</title>
<style>
html,body{margin:0;height:100%;background:#0a0a0b;overflow:hidden;
font:12px/1.5 ui-monospace,monospace;color:#9aa;}
#wrap{position:fixed;inset:0;display:grid;place-items:center;}
.scene{perspective:1400px;perspective-origin:50% 45%;}
.stack{position:relative;transform-style:preserve-3d;transition:transform .06s linear;
width:${W / 2}px;height:${H / 2}px;}
.panel{position:absolute;inset:-70%;
background:radial-gradient(120% 90% at 50% 42%,#fff6ec, #ffe7c8 42%, #f0c48c 78%, #caa06a 100%);
filter:blur(2px);}
.plane{position:absolute;inset:0;width:100%;height:100%;background-size:cover;
background-repeat:no-repeat;will-change:transform,filter;}
#hud{position:fixed;left:14px;top:12px;z-index:9;background:#141416cc;padding:12px 14px;
border-left:3px solid #d9b48a;max-width:300px;}
#hud h1{font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:#d9b48a;font-weight:400;margin:0 0 8px;}
#hud label{display:block;margin:7px 0 2px;color:#aab;}
#hud input[type=range]{width:100%;}
#legend{position:fixed;right:14px;top:12px;z-index:9;background:#141416cc;padding:10px 12px;color:#889;}
#legend b{color:#d9b48a;font-weight:400;}
#layers{margin-top:10px;max-height:44vh;overflow:auto;padding-right:4px;}
.lay{display:flex;align-items:center;gap:7px;margin:1px 0;}
.lay span{width:82px;color:#aab;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.lay input[type=range]{flex:1;}
.lay b{width:26px;text-align:right;color:#778;font-size:10px;}
</style></head><body>
<div id="hud">
<h1>Proof3 · lit chamber</h1>
<div>move mouse = parallax · drag sliders</div>
<label>air gap ×<span id="gv"></span></label>
<input id="gap" type="range" min="1" max="28" step="0.5" value="12">
<label>parallax ×<span id="pv"></span></label>
<input id="par" type="range" min="0" max="220" step="5" value="110">
<label>backlight<span id="bv"></span></label>
<input id="bright" type="range" min="0.3" max="1.8" step="0.05" value="1">
<label>layer opacity (front → back)</label>
<div id="layers"></div>
</div>
<div id="legend"></div>
<div id="wrap"><div class="scene"><div class="stack" id="stack">
<div class="panel" id="panel"></div>
</div></div></div>
<script>
const PLANES=${planesJSON};
const stack=document.getElementById('stack');
const legend=document.getElementById('legend');
const layers=document.getElementById('layers');
const maxZ=Math.max(...PLANES.map(p=>p.z));
const els={}, op={}; // op[id] = per-layer opacity override (a slider each)
legend.innerHTML='<b>planes</b> (front → back)<br>'+[...PLANES].reverse().map(p=>p.id+' · '+p.z+'mm').join('<br>');
// build planes back→front for correct DOM paint order, sliders front→back for reading
PLANES.forEach(p=>{
const d=document.createElement('div');
d.className='plane'; d.style.backgroundImage='url('+p.file+')';
d.dataset.id=p.id; stack.appendChild(d); els[p.id]=d;
op[p.id]=(p.dim??1);
});
[...PLANES].reverse().forEach(p=>{
const row=document.createElement('div'); row.className='lay';
const lab=document.createElement('span'); lab.textContent=p.id;
const sl=document.createElement('input'); sl.type='range'; sl.min=0; sl.max=1; sl.step=0.05; sl.value=op[p.id];
const val=document.createElement('b'); val.textContent=op[p.id].toFixed(2);
sl.oninput=()=>{op[p.id]=+sl.value; val.textContent=op[p.id].toFixed(2); update();};
row.appendChild(lab); row.appendChild(sl); row.appendChild(val); layers.appendChild(row);
});
let gap=12, par=110, bright=1, mx=0, my=0;
const panel=document.getElementById('panel');
// One transform per plane = translateZ (real depth) + a depth-scaled screen
// shift (EXAGGERATED parallax) + the stack's 3D tilt. Deeper planes shift more,
// so the event's birth→death timeline slides apart as you move.
function update(){
stack.style.transform='rotateX('+(my*11).toFixed(2)+'deg) rotateY('+(mx*15).toFixed(2)+'deg)';
for(const p of PLANES){
const e=els[p.id];
const z=(p.z-maxZ)*gap; // front plane at 0, others recede
const depth=(maxZ-p.z)/(maxZ||1); // 0 = front, 1 = deepest
const ox=(-mx*depth*par).toFixed(1), oy=(my*depth*par).toFixed(1);
e.style.transform='translate3d('+ox+'px,'+oy+'px,'+z.toFixed(1)+'px)';
e.style.filter='blur('+((p.blur||0)*(gap/8)).toFixed(2)+'px)';
e.style.opacity=op[p.id];
e.style.mixBlendMode=p.blend||'normal';
}
// keep the light panel ALWAYS behind the deepest plane, scaled up so it still
// fills the backdrop as it recedes (else deep planes pop behind it at high gap).
const pz=(0-maxZ)*gap-90;
panel.style.transform='translateZ('+pz.toFixed(0)+'px) scale('+(1+(-pz)/1400).toFixed(2)+')';
panel.style.filter='blur(2px) brightness('+bright+')';
}
addEventListener('mousemove',e=>{
mx=(e.clientX/innerWidth-0.5)*2; my=-(e.clientY/innerHeight-0.5)*2; update();
});
const gapI=document.getElementById('gap'), parI=document.getElementById('par'), brI=document.getElementById('bright');
gapI.oninput=()=>{gap=+gapI.value; document.getElementById('gv').textContent=' '+gap; update();};
parI.oninput=()=>{par=+parI.value; document.getElementById('pv').textContent=' '+par; update();};
brI.oninput=()=>{bright=+brI.value; document.getElementById('bv').textContent=' '+bright.toFixed(2); update();};
document.getElementById('gv').textContent=' '+gap;
document.getElementById('pv').textContent=' '+par;
document.getElementById('bv').textContent=' '+bright.toFixed(2);
update();
</script></body></html>`);
console.log(`PROOF3 — ${W}×${H} (φ) — ${PLANES.length} planes → ${OUT}/`);
for (const pl of PLANES) console.log(` ${pl.file.padEnd(26)} z=${pl.z}mm blur=${pl.blur} white=${pl.white}`);
console.log(`manifest -> ${OUT}/manifest.json`);
console.log(`simulator -> ${OUT}/simulator.html`);