Files
bubblechambersimart/tools/proof1-render.mjs
2026-07-19 13:37:10 -04:00

267 lines
14 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/Proof1';
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 tracksImg = renderSVG({ ...bare, tracks: scene.tracks, shock: null },
{ ...p, emit: ['bubble'], originX: VERTEX.x, originY: VERTEX.y, sceneZoom: TRACE_ZOOM }, W);
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>`);
// DOF blur is tuned per MARK TYPE: thin wave-lines dissolve under blur far
// faster than dense bubbles, so line-art planes get very little (parallax
// offset carries their depth). Bubbles/glow tolerate more.
const PLANES = [
{ id: 'sun', file: 'plane-1-sun.svg', z: 0, blur: 1.4, dim: 0.95, white: 'none', glow: 1.0, svg: planeSVG(IMG(diskImg)) },
{ id: 'sea-far', file: 'plane-2-sea-far.svg', z: 9, blur: 0.9, dim: 1.0, white: 'none', glow: 0.7, svg: planeSVG(IMG(sheets[0])) },
{ id: 'sea-mid', file: 'plane-3-sea-mid.svg', z: 15, blur: 0.5, dim: 1.0, white: 'trace', glow: 0.7, svg: planeSVG(IMG(sheets[1])) },
{ id: 'sea-near', file: 'plane-4-sea-near.svg', z: 18, blur: 0.2, dim: 1.0, white: 'trace', glow: 0.7, svg: planeSVG(IMG(sheets[2])) },
{ id: 'event-halo', file: 'plane-5-event-halo.svg', z: 27, blur: 2.4, dim: 0.85, white: 'none', glow: 0.9, svg: planeSVG(IMG(tracksImg)) },
{ id: 'event-core', file: 'plane-6-event-core.svg', z: 30, blur: 0.0, dim: 1.0, white: 'under', glow: 0.5, svg: planeSVG(IMG(tracksImg)) },
{ id: 'front-glass',file: 'plane-7-front-glass.svg',z: 30, 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: 'Proof1 — the lit chamber',
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>Proof1 · lit chamber — backlit parallax</title>
<style>
:root{--gap:6;--bright:1;}
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:2200px;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:-14%;transform:translateZ(-120px);
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>Proof1 · 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="18" step="0.5" value="6">
<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=6, bright=1, mx=0, my=0;
function layout(){
for(const p of PLANES){
const e=els[p.id];
const z=(p.z-maxZ)*gap; // front plane at 0, others recede
const blur=(p.blur||0)*(gap/6);
const off=e.dataset.off?' opacity:0;':'';
e.style.transform='translateZ('+z+'px)';
e.style.filter='blur('+blur.toFixed(2)+'px)';
e.style.opacity=e.dataset.off?0:(p.dim??1);
e.style.mixBlendMode=p.blend||'normal';
}
document.getElementById('panel').style.filter='blur(2px) brightness('+bright+')';
}
function parallax(){
stack.style.transform='rotateX('+(my*7)+'deg) rotateY('+(mx*9)+'deg)';
}
addEventListener('mousemove',e=>{
mx=(e.clientX/innerWidth-0.5)*2; my=-(e.clientY/innerHeight-0.5)*2; parallax();
});
const gapI=document.getElementById('gap'), brI=document.getElementById('bright');
gapI.oninput=()=>{gap=+gapI.value; document.getElementById('gv').textContent=' '+gap; layout();};
brI.oninput=()=>{bright=+brI.value; document.getElementById('bv').textContent=' '+bright.toFixed(2); layout();};
document.getElementById('gv').textContent=' '+gap;
document.getElementById('bv').textContent=' '+bright.toFixed(2);
layout(); parallax();
</script></body></html>`);
console.log(`PROOF1 — ${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`);