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

306 lines
17 KiB
JavaScript
Raw 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';
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/Proof2';
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 };
// TIME-SPLIT into N SHELLS by b.life (arc-length fraction from the vertex).
// Because the β-law drives colour BY proper time (magenta birth → violet
// death), the shells step smoothly in hue: the event ages across depth.
// Later time = nearer. Scatter spreads the dense vertex knot across shells.
const SCATTER = 0.32;
const NP = 7; // particle time-shells p1..p7
const evt = (band) => renderSVG({ ...bare, tracks: scene.tracks, shock: null },
band ? { ...eventBase, bubbleBand: band, bubbleScatter: SCATTER } : 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([i / NP, i === NP - 1 ? 1.001 : (i + 1) / NP]));
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]));
// PROOF2 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: 'Proof2 — the lit chamber (7 time-shells)',
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>Proof2 · 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;}
.pill{display:inline-block;margin:2px 4px 2px 0;padding:1px 6px;border:1px solid #333;border-radius:9px;cursor:pointer;color:#aab;}
.pill.off{opacity:.35;text-decoration:line-through;}
</style></head><body>
<div id="hud">
<h1>Proof2 · 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">
<div id="pills"></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 pills=document.getElementById('pills');
const maxZ=Math.max(...PLANES.map(p=>p.z));
const els={};
legend.innerHTML='<b>planes</b> (front → back)<br>'+[...PLANES].reverse().map(p=>p.id+' · '+p.z+'mm').join('<br>');
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;
const pill=document.createElement('span'); pill.className='pill'; pill.textContent=p.id;
pill.onclick=()=>{d.dataset.off=d.dataset.off?'':'1'; pill.classList.toggle('off'); layout();};
pills.appendChild(pill);
});
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=e.dataset.off?0:(p.dim??1);
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(`PROOF2 — ${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`);