Compare commits

...

2 Commits

Author SHA1 Message Date
2a4fffbb2a Ready to test print 2026-07-19 13:37:10 -04:00
3f2d25f90e Chose a test print in Proof. Workshopping layering. 2026-07-19 13:35:22 -04:00
81 changed files with 3937 additions and 37 deletions

55
fable/Proof1/README.md Normal file
View File

@@ -0,0 +1,55 @@
# Proof1 — the lit chamber
*First physical translation: screen → acrylic → light. Based on
`REFINED_Backlit7/merge-45 → merge-55`.*
## What this is
A bubble chamber photographed the invisible: a particle passed, boiled a
line of bubbles into being, and was gone — leaving a trail as its only
confession. This project makes new such confessions by hand-parameter, then
stages one as a world. *One Sun Setting*: a single event erupting at the
waterline of a quiet sea, beneath a sun already fading into memory. Silence
and violence in one frame.
## Inspiration
CERN plate film on a light table. Hokusai's small figures under enormous
weather. Twombly's one honest gesture on a still ground. The idea that
*evidence of the invisible rhymes across scales* — the spiral of a δ-ray and
the spiral of a milkweed seed are the same handwriting.
## Boundaries
The archive costume came off, layer by layer, until only the gesture
remained. No scanner's hand. No labels — a legible glyph commands *read me*;
this piece asks only *look*. The palette holds: β-law magenta, hot at birth,
violet only as a last word. The sea stays calm so the event can be loud —
loudness lives in a small mark against vastness, never in clutter. The sun
recedes; it is what the light *was*, not a character. Composition obeys φ:
the golden rectangle, the horizon on the golden division, sun and event vertex
on opposing golden nodes.
## Craft
The seed is the whole fingerprint — an image and its four-metre master are one
truth at two sizes. Bubbles are hollow: pale core, refracting rim, delicate as
mist at nose distance. Where they crowd, they *merge* — one flat union at an
even density, so a dense knot never becomes mud, and, lit, never punches a dark
hole in the glow. Even ink = even light. That is the whole discipline of the
backlit grammar: **depth is transparency.** The deepest plane carries the least
ink and the most light.
## Goals
Take the locked frame apart into planes and give it air. Sun to the back, pure
transmitted colour, a light source rather than a picture. Sea in the middle,
a woven field. The event to the front, erupting toward the viewer. Air gaps
turn faked blur into real parallax; the LED panel behind frosted acrylic turns
faded ink into a sun that actually shines. Prove one hero, then let the typology
grow around it — a wall of catalogued dawns over one vacuum sea.
## Feels
Arrival and dispersal at once. A single instant, embalmed, outliving the
conditions that made it. Awe, not spectacle. The held breath before the wave.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.1 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.1 MiB

View File

@@ -0,0 +1,83 @@
{
"piece": "Proof1 — the lit chamber",
"source": "REFINED_Backlit7 merge (mirror event, uniform-lace)",
"seed": "MESON-5113#03",
"bubbleOpacity": 0.5,
"art": {
"w": 1001,
"h": 1620,
"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": [
{
"id": "sun",
"file": "plane-1-sun.svg",
"z": 0,
"blur": 1.4,
"dim": 0.95,
"white": "none",
"glow": 1
},
{
"id": "sea-far",
"file": "plane-2-sea-far.svg",
"z": 9,
"blur": 0.9,
"dim": 1,
"white": "none",
"glow": 0.7
},
{
"id": "sea-mid",
"file": "plane-3-sea-mid.svg",
"z": 15,
"blur": 0.5,
"dim": 1,
"white": "trace",
"glow": 0.7
},
{
"id": "sea-near",
"file": "plane-4-sea-near.svg",
"z": 18,
"blur": 0.2,
"dim": 1,
"white": "trace",
"glow": 0.7
},
{
"id": "event-halo",
"file": "plane-5-event-halo.svg",
"z": 27,
"blur": 2.4,
"dim": 0.85,
"white": "none",
"glow": 0.9
},
{
"id": "event-core",
"file": "plane-6-event-core.svg",
"z": 30,
"blur": 0,
"dim": 1,
"white": "under",
"glow": 0.5
},
{
"id": "front-glass",
"file": "plane-7-front-glass.svg",
"z": 30,
"blur": 0,
"dim": 1,
"white": "none",
"glow": 0,
"blend": "multiply"
}
]
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 206 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 396 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 396 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 396 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 4.5 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 4.5 MiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1001" height="1620" viewBox="0 0 1001 1620"><image x="0" y="0" width="1001" height="1620" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAxIiBoZWlnaHQ9IjE2MjAiIHZpZXdCb3g9IjAgMCAxMDAxIDE2MjAiPgo8ZGVmcz4KIDxmaWx0ZXIgaWQ9ImR1c3QiIHg9IjAiIHk9IjAiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiPjxmZVR1cmJ1bGVuY2UgdHlwZT0idHVyYnVsZW5jZSIgYmFzZUZyZXF1ZW5jeT0iMC41IiBudW1PY3RhdmVzPSIyIiBzZWVkPSIxMyIgc3RpdGNoVGlsZXM9InN0aXRjaCIgcmVzdWx0PSJuIi8+PGZlQ29sb3JNYXRyaXggaW49Im4iIHR5cGU9Im1hdHJpeCIgdmFsdWVzPSIwIDAgMCAwIDAuMjM1IDAgMCAwIDAgMC4xODggMCAwIDAgMCAwLjEzMyAwIDAgMCAwLjMwMCAtMC4xOCIvPjwvZmlsdGVyPgogPGZpbHRlciBpZD0iZm94IiB4PSIwIiB5PSIwIiB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIj48ZmVUdXJidWxlbmNlIHR5cGU9ImZyYWN0YWxOb2lzZSIgYmFzZUZyZXF1ZW5jeT0iMC4wMDQiIG51bU9jdGF2ZXM9IjMiIHNlZWQ9IjE3IiBzdGl0Y2hUaWxlcz0ic3RpdGNoIiByZXN1bHQ9Im4iLz48ZmVDb2xvck1hdHJpeCBpbj0ibiIgdHlwZT0ibWF0cml4IiB2YWx1ZXM9IjAgMCAwIDAgMC40MiAwIDAgMCAwIDAuMzAgMCAwIDAgMCAwLjE2IDAgMCAwIDAuMTk4IC0wLjEyIi8+PC9maWx0ZXI+CjwvZGVmcz4KPHJlY3Qgd2lkdGg9IjEwMDEiIGhlaWdodD0iMTYyMCIgZmlsdGVyPSJ1cmwoI2ZveCkiLz4KPHJlY3Qgd2lkdGg9IjEwMDEiIGhlaWdodD0iMTYyMCIgZmlsdGVyPSJ1cmwoI2R1c3QpIi8+CjxsaW5lIHgxPSI0MTEiIHkxPSIzNDQiIHgyPSI0MzAiIHkyPSIxMjQ3IiBzdHJva2U9InJnYig2MCw0OCwzNCkiIHN0cm9rZS1vcGFjaXR5PSIwLjA5MCIgc3Ryb2tlLXdpZHRoPSIwLjc5Ii8+PGxpbmUgeDE9IjcwMiIgeTE9IjY4IiB4Mj0iNjg3IiB5Mj0iMTQzNyIgc3Ryb2tlPSJyZ2IoNjAsNDgsMzQpIiBzdHJva2Utb3BhY2l0eT0iMC4wNTEiIHN0cm9rZS13aWR0aD0iMC43NyIvPjxsaW5lIHgxPSI0MjIiIHkxPSI1MTQiIHgyPSI0MzciIHkyPSIxNzU3IiBzdHJva2U9InJnYig2MCw0OCwzNCkiIHN0cm9rZS1vcGFjaXR5PSIwLjE2NSIgc3Ryb2tlLXdpZHRoPSIxLjAzIi8+PGxpbmUgeDE9IjEwIiB5MT0iNTIzIiB4Mj0iMTkiIHkyPSIxODY5IiBzdHJva2U9InJnYig2MCw0OCwzNCkiIHN0cm9rZS1vcGFjaXR5PSIwLjE1MCIgc3Ryb2tlLXdpZHRoPSIwLjUzIi8+PGxpbmUgeDE9IjQzNCIgeTE9IjI4NSIgeDI9IjQyMSIgeTI9IjE0NDIiIHN0cm9rZT0icmdiKDYwLDQ4LDM0KSIgc3Ryb2tlLW9wYWNpdHk9IjAuMTU3IiBzdHJva2Utd2lkdGg9IjAuNzAiLz48bGluZSB4MT0iMTExIiB5MT0iMTgwIiB4Mj0iOTUiIHkyPSIxNTk0IiBzdHJva2U9InJnYig2MCw0OCwzNCkiIHN0cm9rZS1vcGFjaXR5PSIwLjA4NiIgc3Ryb2tlLXdpZHRoPSIwLjkxIi8+PGxpbmUgeDE9IjE0MCIgeTE9IjE2NCIgeDI9IjE1OCIgeTI9Ijc1OCIgc3Ryb2tlPSJyZ2IoNjAsNDgsMzQpIiBzdHJva2Utb3BhY2l0eT0iMC4xMDUiIHN0cm9rZS13aWR0aD0iMS4wNCIvPjxsaW5lIHgxPSIxOTIiIHkxPSIzNjMiIHgyPSIyMDciIHkyPSIxNDg5IiBzdHJva2U9InJnYig2MCw0OCwzNCkiIHN0cm9rZS1vcGFjaXR5PSIwLjA3NSIgc3Ryb2tlLXdpZHRoPSIxLjAzIi8+PC9zdmc+"/><g opacity="0.85"><image x="0" y="0" width="1001" height="1620" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAxIiBoZWlnaHQ9IjE2MjAiIHZpZXdCb3g9IjAgMCAxMDAxIDE2MjAiPgo8ZGVmcz48ZmlsdGVyIGlkPSJnIiB4PSIwIiB5PSIwIiB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIj4KPGZlVHVyYnVsZW5jZSB0eXBlPSJmcmFjdGFsTm9pc2UiIGJhc2VGcmVxdWVuY3k9IjAuOSIgbnVtT2N0YXZlcz0iMiIgc2VlZD0iMTkiIHN0aXRjaFRpbGVzPSJzdGl0Y2giIHJlc3VsdD0ibiIvPgo8ZmVDb2xvck1hdHJpeCBpbj0ibiIgdHlwZT0ibWF0cml4IiB2YWx1ZXM9IjAgMCAwIDAgMC4xNDkgMCAwIDAgMCAwLjEyNSAwIDAgMCAwIDAuMTAyIDAgMCAwIDAuNTIgMCIvPgo8L2ZpbHRlcj48L2RlZnM+PHJlY3Qgd2lkdGg9IjEwMDEiIGhlaWdodD0iMTYyMCIgZmlsdGVyPSJ1cmwoI2cpIi8+PC9zdmc+"/></g></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 MiB

View File

@@ -0,0 +1,81 @@
<!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:500.5px;height:810px;}
.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=[{"id":"sun","file":"plane-1-sun.svg","z":0,"blur":1.4,"dim":0.95,"white":"none","glow":1},{"id":"sea-far","file":"plane-2-sea-far.svg","z":9,"blur":0.9,"dim":1,"white":"none","glow":0.7},{"id":"sea-mid","file":"plane-3-sea-mid.svg","z":15,"blur":0.5,"dim":1,"white":"trace","glow":0.7},{"id":"sea-near","file":"plane-4-sea-near.svg","z":18,"blur":0.2,"dim":1,"white":"trace","glow":0.7},{"id":"event-halo","file":"plane-5-event-halo.svg","z":27,"blur":2.4,"dim":0.85,"white":"none","glow":0.9},{"id":"event-core","file":"plane-6-event-core.svg","z":30,"blur":0,"dim":1,"white":"under","glow":0.5},{"id":"front-glass","file":"plane-7-front-glass.svg","z":30,"blur":0,"dim":1,"white":"none","glow":0,"blend":"multiply"}];
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>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.7 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.9 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.0 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.2 MiB

View File

@@ -0,0 +1,13 @@
<!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 Nº 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"><figure><img src="01_sun-above.svg"><figcaption>sun above · 19:04</figcaption></figure><figure><img src="02_sun-touching.svg"><figcaption>sun touching · 19:26</figcaption></figure><figure><img src="03_sun-half.svg"><figcaption>sun half · 19:47</figcaption></figure><figure><img src="04_sun-drowned.svg"><figcaption>sun drowned · 20:08</figcaption></figure><figure><img src="05_sun-gone.svg"><figcaption>sun gone · 20:31</figcaption></figure></div>
<div class="grid"><figure><img src="01_sun-above.svg"><figcaption>sun above · 19:04</figcaption></figure><figure><img src="02_sun-touching.svg"><figcaption>sun touching · 19:26</figcaption></figure><figure><img src="03_sun-half.svg"><figcaption>sun half · 19:47</figcaption></figure><figure><img src="04_sun-drowned.svg"><figcaption>sun drowned · 20:08</figcaption></figure><figure><img src="05_sun-gone.svg"><figcaption>sun gone · 20:31</figcaption></figure></div>
</body></html>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.6 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.8 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.8 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.7 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.6 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.0 MiB

View File

@@ -0,0 +1,9 @@
<!DOCTYPE html><html><head><meta charset="utf-8"><title>REFINED_Backlit2 · sun-above scale studies</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(3,1fr);gap:12px;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}</style></head><body>
<h1>Sun-above — scale studies (colours &amp; composition held)</h1>
<div class="row"><figure><img src="00_reference.svg"><figcaption>reference — baseline (traces 1.0 · rows 46 · overlap 1.9 · line ×1)</figcaption></figure><figure><img src="01_traces-bold.svg"><figcaption>traces-bold — evidence enlarged (bubble size 1.6, density 0.9)</figcaption></figure><figure><img src="02_traces-fine.svg"><figcaption>traces-fine — evidence miniaturised (size 0.62, density 1.3)</figcaption></figure><figure><img src="03_sea-broad.svg"><figcaption>sea-broad — few broad swells (rows 26)</figcaption></figure><figure><img src="04_sea-fine.svg"><figcaption>sea-fine — fine ripple (rows 78, line ×0.75)</figcaption></figure><figure><img src="05_sea-deep.svg"><figcaption>sea-deep — deep undulation (overlap 3.1)</figcaption></figure><figure><img src="06_sea-etched.svg"><figcaption>sea-etched — heavy wave line (line ×2.3)</figcaption></figure><figure><img src="07_giant-evidence.svg"><figcaption>giant-evidence — large ink over fine calm water (size 1.7 · rows 78 · line ×0.7 · overlap 1.6)</figcaption></figure><figure><img src="08_woodcut.svg"><figcaption>woodcut — small ink in a heavy carved sea (size 0.7 · rows 24 · line ×2.4 · overlap 2.6)</figcaption></figure></div>
</body></html>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.6 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 4.3 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.2 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.1 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.4 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.3 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.9 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.5 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.0 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 MiB

View File

@@ -0,0 +1,9 @@
<!DOCTYPE html><html><head><meta charset="utf-8"><title>REFINED_Backlit3 · bold large-trace studies</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(3,1fr);gap:12px;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}</style></head><body>
<h1>Sun-above — bold, large traces on top · waves varied · sun faded back</h1>
<div class="row"><figure><img src="00_bold-base.svg"><figcaption>bold-base — large ink · sea baseline (rows 40 · overlap 2.2 · line ×1.35)</figcaption></figure><figure><img src="01_broad-swell.svg"><figcaption>broad-swell — few broad swells (rows 24 · overlap 2.6)</figcaption></figure><figure><img src="02_fine-ripple.svg"><figcaption>fine-ripple — dense fine ripple (rows 82 · line ×0.9)</figcaption></figure><figure><img src="03_deep-heave.svg"><figcaption>deep-heave — deep undulation (overlap 3.4)</figcaption></figure><figure><img src="04_etched-sea.svg"><figcaption>etched-sea — heavy carved wave line (line ×2.6)</figcaption></figure><figure><img src="05_glassy-calm.svg"><figcaption>glassy-calm — shallow glassy water (overlap 1.3 · rows 52)</figcaption></figure><figure><img src="06_turbulent.svg"><figcaption>turbulent — turbulent, blip-rich sea (chaos 1.0 · blips 2.2 · overlap 2.8)</figcaption></figure><figure><img src="07_woodcut.svg"><figcaption>woodcut — heavy carved swell (rows 26 · line ×2.8 · overlap 3.0)</figcaption></figure><figure><img src="08_giant-ink-calm.svg"><figcaption>giant-ink-calm — giant evidence over quiet fine water (size 1.9 · rows 78 · line ×0.8 · overlap 1.5)</figcaption></figure><figure><img src="09_max-bold.svg"><figcaption>max-bold — everything loud (size 1.8 · rows 32 · line ×2.4 · overlap 3.0 · chaos 0.95)</figcaption></figure></div>
</body></html>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.9 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.3 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.9 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.6 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 MiB

View File

@@ -0,0 +1,9 @@
<!DOCTYPE html><html><head><meta charset="utf-8"><title>REFINED_Backlit4 · golden</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(2,1fr);gap:14px;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}</style></head><body>
<h1>One sun setting — golden (1001×1620, φ)</h1>
<div class="row"><figure><img src="00_golden.svg"><figcaption>00_golden</figcaption></figure><figure><img src="01_golden.svg"><figcaption>01_golden</figcaption></figure><figure><img src="02_golden.svg"><figcaption>02_golden</figcaption></figure><figure><img src="03_golden-mirror.svg"><figcaption>03_golden-mirror</figcaption></figure><figure><img src="04_golden-mirror.svg"><figcaption>04_golden-mirror</figcaption></figure><figure><img src="05_golden-mirror.svg"><figcaption>05_golden-mirror</figcaption></figure></div>
</body></html>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.9 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.3 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.0 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.6 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 MiB

View File

@@ -0,0 +1,9 @@
<!DOCTYPE html><html><head><meta charset="utf-8"><title>REFINED_Backlit5 · golden · delicate bubbles</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(2,1fr);gap:14px;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}</style></head><body>
<h1>One sun setting — golden (1001×1620, φ)</h1>
<div class="row"><figure><img src="00_golden.svg"><figcaption>00_golden</figcaption></figure><figure><img src="01_golden.svg"><figcaption>01_golden</figcaption></figure><figure><img src="02_golden.svg"><figcaption>02_golden</figcaption></figure><figure><img src="03_golden-mirror.svg"><figcaption>03_golden-mirror</figcaption></figure><figure><img src="04_golden-mirror.svg"><figcaption>04_golden-mirror</figcaption></figure><figure><img src="05_golden-mirror.svg"><figcaption>05_golden-mirror</figcaption></figure></div>
</body></html>

View File

@@ -0,0 +1,9 @@
<!DOCTYPE html><html><head><meta charset="utf-8"><title>REFINED_Backlit6 · bubble-opacity sweep</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(3,1fr);gap:14px;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}</style></head><body>
<h1>One sun setting — bubble-opacity sweep 20→80% (1001×1620, φ)</h1>
<div class="row"><figure><img src="op-20.svg"><figcaption>op-20</figcaption></figure><figure><img src="op-32.svg"><figcaption>op-32</figcaption></figure><figure><img src="op-44.svg"><figcaption>op-44</figcaption></figure><figure><img src="op-56.svg"><figcaption>op-56</figcaption></figure><figure><img src="op-68.svg"><figcaption>op-68</figcaption></figure><figure><img src="op-80.svg"><figcaption>op-80</figcaption></figure></div>
</body></html>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 MiB

View File

@@ -0,0 +1,9 @@
<!DOCTYPE html><html><head><meta charset="utf-8"><title>REFINED_Backlit6 · bubble-opacity sweep</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(3,1fr);gap:14px;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}</style></head><body>
<h1>One sun setting — merge · uniform-lace target sweep (1854×3000, φ)</h1>
<div class="row"><figure><img src="merge-35.svg"><figcaption>merge-35</figcaption></figure><figure><img src="merge-45.svg"><figcaption>merge-45</figcaption></figure><figure><img src="merge-55.svg"><figcaption>merge-55</figcaption></figure><figure><img src="merge-65.svg"><figcaption>merge-65</figcaption></figure><figure><img src="merge-75.svg"><figcaption>merge-75</figcaption></figure><figure><img src="merge-85.svg"><figcaption>merge-85</figcaption></figure></div>
</body></html>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.3 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.3 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.3 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.3 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.3 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.3 MiB

67
src/compose/waterline.js Normal file
View File

@@ -0,0 +1,67 @@
/* ============================================================
waterline.js — the world is sovereign, and it doesn't notice.
Splits track polylines at a horizon line yH (scene coords,
[-1,1]) into ABOVE and BELOW sets so a compositor can treat
the submerged evidence differently: the sea never reacts to
the event; everything that crosses the surface is altered BY
the water instead —
· a refraction KINK at the crossing (horizontal offset
driven by the entry slope),
· a slight vertical COMPRESSION of the submerged world
toward the surface (optical squash),
· (tint / blur / dimming are the compositor's job).
β, weight, kind, z, age are preserved across the split so
physics-law palettes keep their colour continuity.
============================================================ */
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
/* one track → runs of consecutive points on one side, with exact
crossing points interpolated onto BOTH adjacent runs */
function runsOf(track, yH) {
const runs = [];
let cur = null;
const pts = track.pts;
const side = (p) => (p.y < yH ? 'above' : 'below');
for (let i = 0; i < pts.length; i++) {
const p = pts[i];
if (!cur) { cur = { side: side(p), pts: [p], entrySlope: 0 }; continue; }
if (side(p) === cur.side) { cur.pts.push(p); continue; }
// crossing: interpolate the surface point
const q = pts[i - 1];
const f = (yH - q.y) / (p.y - q.y);
const cross = {
x: q.x + (p.x - q.x) * f, y: yH,
beta: (q.beta ?? 0.5) + ((p.beta ?? 0.5) - (q.beta ?? 0.5)) * f,
theta: q.theta ?? 0,
};
cur.pts.push(cross);
runs.push(cur);
const slope = (p.x - q.x) / Math.max(1e-6, Math.abs(p.y - q.y));
cur = { side: side(p), pts: [{ ...cross }], entrySlope: clamp(slope, -1.4, 1.4) };
}
if (cur) runs.push(cur);
return runs;
}
/* split a track list at the waterline.
opts: kink (horizontal refraction offset per unit entry slope),
compress (vertical squash of the submerged world, 1 = none) */
export function splitTracksAtWaterline(tracks, yH, opts = {}) {
const kink = opts.kink ?? 0.016;
const comp = opts.compress ?? 0.95;
const above = [], below = [];
for (const t of tracks) {
if (!t.pts || t.pts.length < 2) continue;
for (const run of runsOf(t, yH)) {
if (run.pts.length < 2) continue;
if (run.side === 'above') { above.push({ ...t, pts: run.pts }); continue; }
const dx = run.entrySlope * kink;
below.push({
...t,
pts: run.pts.map(p => ({ ...p, x: p.x + dx, y: yH + (p.y - yH) * comp })),
});
}
}
return { above, below };
}

View File

@@ -43,20 +43,36 @@ export function carpetSVG(size, opts = {}) {
blips: 1.0, // density of the spiralling wave-packet excitations
hue: 0.52, hue2: 0.55, sat: 0.55, lightNear: 0.34, lightFar: 0.62,
strokeNear: 1.7, strokeFar: 0.5,
// optional field injections (all default OFF → output unchanged):
// warpFn(t, d) → extra normalized vertical displacement (×amp) — an
// external field (e.g. wind) shaping the rows
// phaseFn(t, r) → phase shift added to every base mode (shimmer)
// modesFn(rng, o) → replace the base mode set entirely (e.g. a CMB
// power-spectrum-shaped spectrum)
warpFn: null, phaseFn: null, modesFn: null,
}, opts);
const W = size, H = size, u = size / 1000;
const W = o.width ?? size, H = o.height ?? size, u = size / 1000; // width/height default to a square of `size`
const paper = resolveSubstrate(o.substrate).paper.flat;
const rng = makeRng(o.seed, o.salt);
// ---- base sea: low-frequency field modes (phase drifts slowly per row) ----
const M = Math.round(4 + o.chaos * 8);
const modes = [];
for (let m = 0; m < M; m++) {
let modes = [];
if (o.modesFn) {
modes = o.modesFn(rng, o);
} else for (let m = 0; m < M; m++) {
const f = range(rng, 0.4, 3.0 + o.chaos * 5); // low q: long swells dominate
modes.push({ f, a: 1 / (1 + f * 1.0), phi: range(rng, 0, Math.PI * 2), drift: range(rng, -1, 1) * (0.06 + f * 0.02) });
}
const norm = modes.reduce((s, m) => s + m.a, 0) || 1;
// coherentRows: express the standing-wave field in a SHARED row coordinate
// (this reference count) instead of the layer's own `rows`. Two carpets with
// the same seed/salt/chaos/blips but DIFFERENT rows then phase-lock — the
// crests and spiralling packets land at the same depth, so a stack of sheets
// with different line counts reads as one sea sampled at several resolutions.
const REF = o.coherentRows ?? o.rows;
// ---- coherent excitations: soft wave-packets that drift + rotate phase
// across rows, so they SPIRAL through the depth of the stack ----
const nExc = Math.round((2 + o.chaos * 5) * o.blips);
@@ -64,25 +80,30 @@ export function carpetSVG(size, opts = {}) {
for (let e = 0; e < nExc; e++) {
exc.push({
x0: range(rng, 0.12, 0.88),
row0: range(rng, 0, o.rows - 1),
span: range(rng, 0.12, 0.3) * o.rows, // rows over which it lives
row0: range(rng, 0, REF - 1),
span: range(rng, 0.12, 0.3) * REF, // reference rows over which it lives
w: range(rng, 0.05, 0.11), // packet width (soft edge)
k: range(rng, 3.5, 7), // oscillations within the packet
amp: range(rng, 0.30, 0.7),
drift: range(rng, -0.018, 0.018), // lateral drift per row
drift: range(rng, -0.018, 0.018), // lateral drift per ref-row
phase: range(rng, 0, Math.PI * 2),
phaseAdv: range(rng, -0.45, 0.45), // phase rotation per row → spiral
phaseAdv: range(rng, -0.45, 0.45), // phase rotation per ref-row → spiral
sign: rng() < 0.5 ? -1 : 1,
});
}
// r is this layer's integer row; rr is the shared reference-row coordinate at
// the same depth, so the field is a function of depth, not of line count.
const value = (t, r) => {
const d = o.rows > 1 ? r / (o.rows - 1) : 0;
const rr = d * (REF - 1);
let s = 0;
for (const m of modes) s += m.a * Math.sin(2 * Math.PI * m.f * t + m.phi + r * m.drift);
const dphi = o.phaseFn ? o.phaseFn(t, r) : 0;
for (const m of modes) s += m.a * Math.sin(2 * Math.PI * m.f * t + m.phi + rr * m.drift + dphi);
s /= norm;
let blip = 0;
for (const e of exc) {
const dr = r - e.row0;
const dr = rr - e.row0;
const env = Math.exp(-Math.pow(dr / (e.span * 0.5), 2));
if (env < 0.02) continue;
const cx = e.x0 + e.drift * dr;
@@ -112,7 +133,8 @@ export function carpetSVG(size, opts = {}) {
for (let i = 0; i <= npts; i++) {
const t = i / npts;
const e = env(t);
pts.push({ x: cx - half + t * 2 * half, y: baseY[r] - amp * e * value(t, r) - amp * 0.55 * (e - eMin) * d });
const warp = o.warpFn ? o.warpFn(t, d) * amp : 0;
pts.push({ x: cx - half + t * 2 * half, y: baseY[r] - amp * e * value(t, r) - amp * 0.55 * (e - eMin) * d + warp });
}
const path = smoothPath(pts);
const hue = o.hue + (o.hue2 - o.hue) * (1 - d);

View File

@@ -112,6 +112,25 @@ export function bubbleStops(soft) {
const midA = Math.max(0.15, 0.9 - 0.32 * s);
return [[0, coreA], [midOff, midA], [1, 0]];
}
// Hollow "hybrid" bubble: a faint paper-tinted veil in the core + a brighter,
// SATURATED rim that softens outward — a refracting bubble rather than a filled
// disc. Each stop carries [offset, alpha, paperMix]: paperMix 1 = fully toward
// paper (pale core), 0 = full family colour (jewel rim). `weight` (0..1) scales
// the rim's peak alpha for delicacy; `soft` widens the rim inward.
export function bubbleHollowStops(weight = 0.7, soft = 0.3, peakOverride = null) {
const s = clamp(soft, 0, 1.6);
const peak = peakOverride ?? (0.35 + 0.45 * clamp(weight, 0, 1)); // ~0.7 at weight 0.78
const rimOff = clamp(0.80 - 0.14 * s, 0.6, 0.86); // rim sits ~0.70.8 of radius
return [
[0.00, peak * 0.36, 0.74], // pale veil at the very centre
[Math.max(0.22, rimOff - 0.5), peak * 0.26, 0.5], // dip — the transparent belly
[rimOff - 0.16, peak * 0.72, 0.12], // rim rising, colour saturating
[rimOff, peak, 0.0], // bright saturated rim (peak)
[Math.min(0.94, rimOff + 0.12), peak * 0.55, 0.04],
[1.00, 0.0, 0.0], // feather to nothing
];
}
export const bubbleFoot = (soft) => 2.4 + clamp(soft, 0, 1.6) * 1.5; // sprite footprint grows with softness
/* ---------- the colour mappings ---------- */
@@ -246,6 +265,52 @@ export const PALETTES = {
feature: (e) => featureHue(e.inv, e.diskHue ?? 0.06, e.diskSat ?? 0.82), // disk + furniture
},
// magentarise's type-family with the β law living INSIDE each member: kind
// chooses the hue (hot magenta primaries, pink δ-rays, cooler purples for
// cosmics and V's — the family structure that made seethe-bold's chord), and
// within each track β drives transparent birth → the family hue at full
// strength → a dive to dark violet only at the very end (dying is slowing).
magbeta: {
id: 'magbeta', label: 'Magenta family · β within',
bubbleInk: (b, e) => {
const bb = Math.round(clamp(b.beta ?? 0.5, 0, 1) * 20) / 20;
const slow = 1 - bb;
const kindH = (((MAG_HUE[b.track.kind] ?? 0.9) + (e.traceHue ?? 0)) % 1 + 1) % 1;
const dive = Math.pow(slow, 3.2); // hold the family hue; violet is the last word
const h = kindH - (kindH - 0.755) * dive;
const s = 0.62 + 0.28 * slow;
const l = e.inv ? 0.56 - 0.22 * slow : 0.32 + 0.30 * slow;
const c = hslToRgb(h, s, l);
const toPaper = Math.pow(bb, 1.4) * 0.7; // fast → dissolves into the substrate
return mix(c, e.basePaper.flat, toPaper);
},
feature: (e) => featureHue(e.inv, e.diskHue ?? 0.06, e.diskSat ?? 0.82),
},
// β-law: the strict physics legend. Opacity is already physics (bubble density
// ∝ 1/β² — fast particles barely nucleate), and ink now follows the same law:
// born fast = barely tints the PAPER (mixing toward the substrate, not white —
// transparent births), slowing = saturating through the magenta family, at
// rest = dense violet. "Death earns the violet" emerges without a narrative
// rule, because dying IS slowing. Feature/disk defaults to the ember heart.
betalaw: {
id: 'betalaw', label: 'β law (fast → faint · rest → violet)',
bubbleInk: (b, e) => {
const bb = Math.round(clamp(b.beta ?? 0.5, 0, 1) * 20) / 20; // quantise: bounded gradients
const slow = 1 - bb;
// quadratic hue: the family HOLDS hot magenta through most of a life and
// dives to violet only at the very end — the violet is the last word, not
// the whole sentence.
const h = 0.95 - 0.19 * slow * slow;
const s = 0.60 + 0.30 * slow;
const l = e.inv ? 0.58 - 0.24 * slow : 0.30 + 0.32 * slow;
const c = hslToRgb(h, s, l);
const toPaper = Math.pow(bb, 1.3) * 0.72; // fast → dissolves into the substrate
return mix(c, e.basePaper.flat, toPaper);
},
feature: (e) => featureHue(e.inv, e.diskHue ?? 0.02, e.diskSat ?? 0.88),
},
// a complete "chemistry": overrides paper + ink together (deep Prussian-blue
// ground, pale lines) — the blueprint/cyanotype look.
cyanotype: {

View File

@@ -13,7 +13,7 @@
============================================================ */
import { makeRng, cyrb53 } from '../rng.js';
import { sampleBubbles, trackInkWeight, depthFactors } from '../scene/bubbles.js';
import { resolvePalette, paperTone, rgbHex, rgbKey, bubbleStops, hslToRgb, mix } from './palette.js';
import { resolvePalette, paperTone, rgbHex, rgbKey, bubbleStops, bubbleHollowStops, hslToRgb, mix } from './palette.js';
const MARGIN = 0.02;
@@ -37,12 +37,18 @@ function rgb2hsl([r, g, b]) {
}
export function renderSVG(scene, params, sizePx = 4800) {
const w = sizePx, h = sizePx;
const scale = (w / 2) * (1 - MARGIN);
const cx = w / 2, cy = h / 2;
// Non-square canvas support (defaults preserve exact square behaviour):
// canvasW/canvasH → explicit page size · originX/originY → scene origin (px)
// scale stays isotropic (min dimension) so the event never distorts; the
// scene origin can be placed anywhere on the page (e.g. a golden intersection).
const w = params.canvasW ?? sizePx, h = params.canvasH ?? sizePx;
// sceneZoom > 1 magnifies the whole event about its origin (bubbles AND spray
// extent grow together); content may spill past the page edge. Default 1 = unchanged.
const scale = (Math.min(w, h) / 2) * (1 - MARGIN) * (params.sceneZoom ?? 1);
const cx = params.originX ?? w / 2, cy = params.originY ?? h / 2;
const tx = (x) => (cx + x * scale).toFixed(2);
const ty = (y) => (cy + y * scale).toFixed(2);
const u = w / 1000;
const u = Math.min(w, h) / 1000;
const inv = params.invert;
// base ink (mono), toned paper (independent of ink palette), then resolve feel
@@ -60,7 +66,10 @@ export function renderSVG(scene, params, sizePx = 4800) {
// so the centre reads as compressed colour rather than a hole.
const press = Math.max(0, Math.min(1, params.diskPressure ?? 0));
const [fh, fs, fl] = rgb2hsl(pal.feature());
const coreInk = rgbHex(hslToRgb(fh, Math.min(1, fs * (1 + 0.25 * press)), fl * (1 - 0.6 * press)));
// diskHollow winds the compression back: the core ink relaxes toward the
// feature hue as the centre opens (a grey plug would just become a grey ring)
const hol0 = Math.max(0, Math.min(1, params.diskHollow ?? 0));
const coreInk = rgbHex(hslToRgb(fh, Math.min(1, fs * (1 + 0.25 * press)), fl * (1 - 0.6 * press * (1 - 0.75 * hol0))));
const featKey = rgbKey(pal.feature());
const colorMap = new Map([[featKey, pal.feature()]]); // distinct bubble colours → gradients
@@ -155,6 +164,12 @@ export function renderSVG(scene, params, sizePx = 4800) {
const buckets = new Map(); // layerId -> Map(colorKey|alpha -> {key, alpha, arr})
const ensure = (id) => { if (!under.has(id)) { under.set(id, ''); buckets.set(id, new Map()); } };
const bop = params.bubbleOpacity ?? 1; // master bubble opacity (a single slider)
const hollowOn = (params.bubbleHollow ?? 0) > 0;
// bubbleMerge: crowded bubbles fuse into ONE flat union at a uniform density
// (group opacity isolates → overlaps don't accumulate). Dense knots become
// even lace, not mud — even ink density = even backlit transmission.
const merge = !!params.bubbleMerge;
const bubbleRng = makeRng(params.seed, 'bubbles'); // scene.tracks order → deterministic
for (const track of scene.tracks) {
if (track.pts.length < 2) continue;
@@ -162,16 +177,18 @@ export function renderSVG(scene, params, sizePx = 4800) {
ensure(id);
const df = depthFactors(track, params);
const repCol = pal.ink(track); // representative ink (under-stroke)
// continuity under-stroke
// continuity under-stroke. When bubbles are hollow, thin this solid core
// hard so the delicate rings carry the trail instead of a filled spine.
const iw = trackInkWeight(track);
const lw = Math.min(2.6, 0.25 + Math.sqrt(iw) * 0.12) * u * params.size * track.weight;
if (lw >= 0.2 * u) {
const lw = Math.min(2.6, 0.25 + Math.sqrt(iw) * 0.12) * u * params.size * track.weight * (hollowOn ? 0.6 : 1);
if (lw >= 0.2 * u && !merge) { // merge carries continuity via the union itself
let d = `M ${tx(track.pts[0].x)} ${ty(track.pts[0].y)}`;
for (let i = 1; i < track.pts.length; i++) d += ` L ${tx(track.pts[i].x)} ${ty(track.pts[i].y)}`;
under.set(id, under.get(id) + `<path d="${d}" stroke="${rgbHex(repCol)}" stroke-opacity="${(0.14 * df.tone).toFixed(3)}" stroke-width="${lw.toFixed(2)}"/>`);
const uop = 0.14 * df.tone * bop * (hollowOn ? 0.4 : 1);
under.set(id, under.get(id) + `<path d="${d}" stroke="${rgbHex(repCol)}" stroke-opacity="${uop.toFixed(3)}" stroke-width="${lw.toFixed(2)}"/>`);
}
// bubbles — opacity from depth/age tone, colour per-bubble from palette
const alpha = Math.round(Math.min(1, 0.36 + df.tone * 0.58) * 20) / 20;
// bubbles — merge: uniform bucket (density capped by the group). else: opacity from depth/age tone × master.
const alpha = merge ? 1 : Math.round(Math.min(1, (0.36 + df.tone * 0.58) * bop) * 100) / 100;
const m = buckets.get(id);
for (const b of sampleBubbles({ ...track, sizeScale: df.sizeScale }, params, bubbleRng)) {
const bcol = pal.bubbleInk(track, b.life, b.beta), bkc = rgbKey(bcol);
@@ -187,7 +204,11 @@ export function renderSVG(scene, params, sizePx = 4800) {
const us = under.get(L.id);
if (us) content += `<g fill="none" stroke-linecap="round" stroke-linejoin="round">${us}</g>\n`;
for (const { key, alpha, arr } of buckets.get(L.id).values()) {
if (arr.length) content += `<g fill="url(#bub-${key})" fill-opacity="${alpha}">${arr.join('')}</g>\n`;
if (!arr.length) continue;
// merge: one isolated group at the uniform target → overlaps stay flat (no mud).
content += merge
? `<g fill="url(#bub-${key})" opacity="${bop.toFixed(2)}" style="isolation:isolate">${arr.join('')}</g>\n`
: `<g fill="url(#bub-${key})" fill-opacity="${alpha}">${arr.join('')}</g>\n`;
}
return layer(L.id, L.label, content);
}).join('');
@@ -309,7 +330,7 @@ export function renderSVG(scene, params, sizePx = 4800) {
let s = `<?xml version="1.0" encoding="UTF-8"?>\n`;
s += `<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">\n`;
s += `<metadata>Bubble Chamber · seed=${params.seed} · hash=${cyrb53(params.seed)} · palette=${params.palette || 'mono'}</metadata>\n`;
s += defs({ paperC, ink, coreInk, press, baseVign: pal.vign(), params, u, colorMap });
s += defs({ paperC, paperFlat: paperRGB.flat, ink, coreInk, press, baseVign: pal.vign(), params, u, colorMap });
// emit: optional array of layer-GROUPS to include, so the plate can be rendered
// as decoupled sub-layers — 'background' | 'disk' | 'bubble' | 'fiduciaries'.
const emit = params.emit;
@@ -327,20 +348,53 @@ export function renderSVG(scene, params, sizePx = 4800) {
return s;
}
function defs({ paperC, ink, coreInk, press = 0, baseVign, params, u, colorMap }) {
// diskHollow: 0 = pressure plug (dense core), 1 = the centre dissolves to the
// paper — density gathers into an ember RING and the heart is left open (for
// backlit media the unprinted centre passes lamp light: the sun glows).
function shockcoreStops({ coreInk, ink, press, hollow }) {
const hol = Math.max(0, Math.min(1, hollow ?? 0));
const core = coreInk || ink;
const a0 = 0.5 + press * 0.45, a1 = 0.32 + press * 0.32;
if (hol <= 0) return `<stop offset="0%" stop-color="${core}" stop-opacity="${a0.toFixed(2)}"/>
<stop offset="28%" stop-color="${core}" stop-opacity="${a1.toFixed(2)}"/>
<stop offset="62%" stop-color="${ink}" stop-opacity="0.28"/>
<stop offset="100%" stop-color="${ink}" stop-opacity="0"/>`;
return `<stop offset="0%" stop-color="${core}" stop-opacity="${(a0 * (1 - hol)).toFixed(2)}"/>
<stop offset="${Math.round(16 + 8 * hol)}%" stop-color="${core}" stop-opacity="${(a0 * (1 - 0.72 * hol)).toFixed(2)}"/>
<stop offset="${Math.round(28 + 12 * hol)}%" stop-color="${core}" stop-opacity="${(a1 * (1 - 0.45 * hol)).toFixed(2)}"/>
<stop offset="62%" stop-color="${ink}" stop-opacity="0.28"/>
<stop offset="100%" stop-color="${ink}" stop-opacity="0"/>`;
}
function defs({ paperC, paperFlat, ink, coreInk, press = 0, baseVign, params, u, colorMap }) {
const soften = params.diskSoften > 0
? `<filter id="soften" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="${(params.diskSoften * u).toFixed(2)}"/></filter>`
: '';
// one soft-bubble gradient per distinct ink colour used (edge profile shared
// with the raster sprite via bubbleStops)
const stops = bubbleStops(params.bubbleSoft ?? 0.3);
// one bubble gradient per distinct ink colour. Default: soft filled disc
// (edge profile shared with the raster sprite via bubbleStops). Opt-in
// bubbleHollow>0: a hollow refracting bubble — pale core, saturated rim.
const hollow = params.bubbleHollow ?? 0;
let bubGrads = '';
if (hollow > 0) {
// merge wants a near-opaque rim so union coverage is flat within the group
const hy = bubbleHollowStops(params.bubbleWeight ?? 0.7, params.bubbleSoft ?? 0.3, params.bubbleMerge ? 0.97 : null);
for (const [key, col] of colorMap) {
let st = '';
for (const [off, a, pm] of hy) {
const c = rgbHex(mix(col, paperFlat, pm * hollow)); // rim = family colour, core → paper
st += `<stop offset="${(off * 100).toFixed(0)}%" stop-color="${c}" stop-opacity="${a.toFixed(3)}"/>`;
}
bubGrads += `<radialGradient id="bub-${key}" cx="50%" cy="50%" r="50%">${st}</radialGradient>`;
}
} else {
const stops = bubbleStops(params.bubbleSoft ?? 0.3);
for (const [key, col] of colorMap) {
const c = rgbHex(col);
let st = '';
for (const [off, a] of stops) st += `<stop offset="${(off * 100).toFixed(0)}%" stop-color="${c}" stop-opacity="${a.toFixed(3)}"/>`;
bubGrads += `<radialGradient id="bub-${key}" cx="50%" cy="50%" r="50%">${st}</radialGradient>`;
}
}
const vignHex = rgbHex(baseVign);
return `<defs>
${soften}
@@ -349,10 +403,7 @@ function defs({ paperC, ink, coreInk, press = 0, baseVign, params, u, colorMap }
</radialGradient>
${bubGrads}
<radialGradient id="shockcore" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="${coreInk || ink}" stop-opacity="${(0.5 + press * 0.45).toFixed(2)}"/>
<stop offset="28%" stop-color="${coreInk || ink}" stop-opacity="${(0.32 + press * 0.32).toFixed(2)}"/>
<stop offset="62%" stop-color="${ink}" stop-opacity="0.28"/>
<stop offset="100%" stop-color="${ink}" stop-opacity="0"/>
${shockcoreStops({ coreInk, ink, press, hollow: params.diskHollow })}
</radialGradient>
<radialGradient id="shockstain" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="${ink}" stop-opacity="0.9"/>

105
src/scene/chinagraph.js Normal file
View File

@@ -0,0 +1,105 @@
/* ============================================================
chinagraph.js — the fictional scanner's hand, done right.
Letterforms built as STROKES (no fonts anywhere): each glyph
is a set of polylines in a unit box, rendered with seeded
wobble, per-character baseline drift and rotation, and a
waxy double-pass — the soft fat grease pencil of a 195585
scanning table. Seeded per plate: one plate's hand is
consistent with itself, no two plates' hands identical.
Charset is the scanner's working set: digits, Nº, θ, °, ·,
colon, dash, question — measurement, not prose.
============================================================ */
import { gauss } from '../rng.js';
/* glyphs in a unit box, y down; each = array of polylines */
const ellipse = (cx, cy, rx, ry, n = 11, a0 = -0.4) => {
const pts = [];
for (let i = 0; i <= n; i++) {
const a = a0 + (i / n) * Math.PI * 2 * 1.03; // slight overshoot — doesn't close cleanly
pts.push([cx + Math.cos(a) * rx, cy + Math.sin(a) * ry]);
}
return pts;
};
const GLYPHS = {
'0': [ellipse(0.48, 0.5, 0.3, 0.44)],
'1': [[[0.3, 0.26], [0.55, 0.08], [0.55, 0.95]]],
'2': [[[0.16, 0.3], [0.2, 0.12], [0.5, 0.05], [0.76, 0.16], [0.77, 0.36], [0.15, 0.95], [0.85, 0.93]]],
'3': [[[0.16, 0.13], [0.6, 0.06], [0.8, 0.25], [0.53, 0.46]], [[0.53, 0.46], [0.84, 0.68], [0.6, 0.93], [0.15, 0.87]]],
'4': [[[0.64, 0.08], [0.13, 0.66], [0.87, 0.66]], [[0.64, 0.3], [0.64, 0.95]]],
'5': [[[0.8, 0.08], [0.22, 0.09], [0.17, 0.46], [0.55, 0.4], [0.82, 0.6], [0.6, 0.92], [0.17, 0.87]]],
'6': [[[0.7, 0.07], [0.32, 0.34], [0.17, 0.64], [0.3, 0.92], [0.64, 0.9], [0.75, 0.66], [0.52, 0.5], [0.22, 0.62]]],
'7': [[[0.15, 0.1], [0.85, 0.09], [0.42, 0.95]]],
'8': [[[0.5, 0.08], [0.25, 0.2], [0.31, 0.42], [0.5, 0.5], [0.72, 0.63], [0.66, 0.88], [0.4, 0.92], [0.27, 0.79], [0.35, 0.56], [0.5, 0.5], [0.71, 0.36], [0.67, 0.15], [0.5, 0.08]]],
'9': [[[0.74, 0.38], [0.5, 0.5], [0.27, 0.44], [0.22, 0.2], [0.46, 0.07], [0.7, 0.13], [0.75, 0.4], [0.6, 0.7], [0.35, 0.94]]],
'N': [[[0.1, 0.95], [0.1, 0.08]], [[0.1, 0.1], [0.8, 0.93]], [[0.8, 0.95], [0.8, 0.08]]],
'º': [ellipse(0.42, 0.24, 0.17, 0.17, 8), [[0.14, 0.6], [0.72, 0.58]]],
'°': [ellipse(0.45, 0.18, 0.14, 0.14, 8)],
'θ': [ellipse(0.48, 0.5, 0.28, 0.44), [[0.22, 0.5], [0.75, 0.5]]],
':': [[[0.48, 0.3], [0.51, 0.33]], [[0.48, 0.72], [0.51, 0.75]]],
'·': [[[0.48, 0.5], [0.52, 0.53]]],
'-': [[[0.15, 0.52], [0.8, 0.5]]],
'+': [[[0.16, 0.5], [0.8, 0.49]], [[0.48, 0.18], [0.49, 0.82]]],
'h': [[[0.16, 0.08], [0.16, 0.95]], [[0.16, 0.5], [0.4, 0.36], [0.66, 0.42], [0.7, 0.95]]],
'?': [[[0.2, 0.24], [0.32, 0.08], [0.62, 0.06], [0.78, 0.22], [0.68, 0.44], [0.48, 0.55], [0.48, 0.7]], [[0.47, 0.88], [0.5, 0.91]]],
' ': [],
};
/* render a string as chinagraph strokes.
o: { x, y px origin (baseline-left)
h char height px
rng seeded stream (one per plate → consistent hand)
ink css colour
width base stroke width px
slant italic shear (default slight) }
Returns an SVG fragment (a <g>). */
export function chinagraphText(str, o) {
const { x, y, h, rng, ink } = o;
const w0 = o.width ?? h * 0.11;
const slant = o.slant ?? 0.07;
const cw = h * 0.72;
let cx = x;
let out = '';
for (const ch of String(str)) {
const glyph = GLYPHS[ch] ?? GLYPHS['·'];
const rot = gauss(rng) * 0.035;
const dy = gauss(rng) * 0.045 * h;
const cos = Math.cos(rot), sin = Math.sin(rot);
for (const stroke of glyph) {
if (!stroke.length) continue;
// two passes: the waxy body + a lighter ghost slightly offset (pencil drag)
for (let pass = 0; pass < 2; pass++) {
const off = pass * 0.05 * h, op = pass ? 0.3 : 0.82;
const wv = w0 * (0.8 + rng() * 0.45);
let d = '';
stroke.forEach((pt, i) => {
let gx = (pt[0] - slant * (1 - pt[1])) * cw * 1.18;
let gy = (pt[1] - 0.9) * h;
const rx = gx * cos - gy * sin, ry = gx * sin + gy * cos;
const jx = gauss(rng) * 0.013 * h, jy = gauss(rng) * 0.013 * h;
d += `${i ? 'L' : 'M'} ${(cx + rx + jx + off).toFixed(1)} ${(y + dy + ry + jy + off * 0.4).toFixed(1)} `;
});
out += `<path d="${d}" fill="none" stroke="${ink}" stroke-opacity="${op}" stroke-width="${wv.toFixed(2)}" stroke-linecap="round" stroke-linejoin="round"/>`;
}
}
cx += cw * (ch === '·' || ch === ':' ? 0.55 : ch === ' ' ? 0.6 : 1);
}
return `<g>${out}</g>`;
}
/* a wobbly grease arrow (shaft + open head), same hand */
export function chinagraphArrow(x1, y1, x2, y2, o) {
const { rng, ink } = o;
const w = o.width ?? 3;
const seg = 8;
let d = '';
for (let i = 0; i <= seg; i++) {
const t = i / seg;
const px = x1 + (x2 - x1) * t + gauss(rng) * 2.2;
const py = y1 + (y2 - y1) * t + gauss(rng) * 2.2;
d += `${i ? 'L' : 'M'} ${px.toFixed(1)} ${py.toFixed(1)} `;
}
const ang = Math.atan2(y2 - y1, x2 - x1), hl = Math.hypot(x2 - x1, y2 - y1) * 0.14;
const head = (da) => `M ${x2.toFixed(1)} ${y2.toFixed(1)} L ${(x2 - Math.cos(ang + da) * hl + gauss(rng) * 1.5).toFixed(1)} ${(y2 - Math.sin(ang + da) * hl + gauss(rng) * 1.5).toFixed(1)} `;
return `<g fill="none" stroke="${ink}" stroke-linecap="round" stroke-opacity="0.8" stroke-width="${w}">`
+ `<path d="${d}"/><path d="${head(0.42)}${head(-0.42)}"/></g>`;
}

266
tools/proof1-render.mjs Normal file
View File

@@ -0,0 +1,266 @@
/* ============================================================
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`);

381
tools/refined-backlit.mjs Normal file
View File

@@ -0,0 +1,381 @@
/* ============================================================
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 L→R 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 Nº 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 Nº 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`);

255
tools/refined-backlit2.mjs Normal file
View File

@@ -0,0 +1,255 @@
/* ============================================================
refined-backlit2.mjs — SUN-ABOVE SCALE STUDIES.
One frame (plate Nº 217, exposure 19:04, MESON-5113), colours
and composition held constant; each render plays with the
RELATIVE SCALE of the marks:
· traces — bubble size / density of the particle ink
· waves — row count (few = broad swells, many = fine ripple)
· depth — overlap: wave amplitude relative to row gap
· line — stroke weight of the wave rows
No hand. Same sun (hollow heart), same sea hue, same aging.
Usage: node tools/refined-backlit2.mjs [size]
→ fable/REFINED_Backlit2/
============================================================ */
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 { makeRng } from '../src/rng.js';
const SIZE = +(process.argv[2] || 1500);
const OUT = 'fable/REFINED_Backlit2';
mkdirSync(OUT, { recursive: true });
const u = SIZE / 1000;
const dataUri = (svg) => 'data:image/svg+xml;base64,' + Buffer.from(svg).toString('base64');
/* ---- the world constants: identical to One Sun Setting ---- */
const SEED = 'MESON-5113';
const BASE = 'rgb(227,220,200)';
const HORIZON = 0.37;
const YH = HORIZON * 2 - 1;
const FILM = { seed: 41, density: 0.6 };
/* ---- film / grain / aging (unchanged from refined-backlit) ---- */
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>`;
}
/* ---- the sea deck, scale-parameterised ----
rows: wave count · overlap: amplitude vs row gap (depth) ·
strokeMul: line weight multiplier on the per-sheet stroke table */
function deck(sea, warpFn) {
const rows = sea.rows ?? 46, overlap = sea.overlap ?? 1.9, sm = sea.strokeMul ?? 1;
const base = { mode: 'plate', rows, horizon: HORIZON, wFar: 0.58, wNear: 0.7,
overlap, mound: 0.4, sat: 0.58, lightNear: 0.33, lightFar: 0.56, blips: 1.4, 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(0.8 * 0.8, 0.8, t),
strokeNear: strokes[i].near * sm, strokeFar: strokes[i].far * sm });
});
}
/* ---- iron filings (identical logic, geometry follows the sea's scale) ---- */
function buildAttraction(below, shock, sunSubmerged) {
const S = (v) => SIZE / 2 + v * (SIZE / 2) * 0.98;
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;
}
return (x, y) => {
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;
};
}
function warpFor(sample, pullPx, sea) {
const rows = sea.rows ?? 46, overlap = sea.overlap ?? 1.9;
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) / (rows - 1);
const amp = Math.max(2 * u, overlap * localGap);
const n = sample(x, y) * pullPx / amp;
return Math.max(-0.9, Math.min(0.9, n));
};
}
/* ---- the event: sun-above config, colours fixed ---- */
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,
shockStriations: 1.0, diskHollow: 0.85,
depth: 0.3, aging: 0.2,
eventX: -0.14, eventY: -0.37,
sweepers: 5, primaries: 17, eloss: 0.34, deltaRate: 0.8,
bgEvents: 2, shockX: -0.55, shockY: -0.72, shockSize: 0.17, shockIntensity: 0.8,
}, over);
return p;
}
function renderVariant(v) {
const p = bcAssembleParams(v.traces || {});
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 };
const sea = v.sea || {};
const wamp = 0.003;
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());
const sample = buildAttraction(belowWavy, scene.shock, false);
const sheets = deck(sea, warpFor(sample, 7 * u, sea)).map(dataUri);
const film = dataUri(filmSVG(FILM));
const grain = dataUri(grainSVG({ amount: 0.42 }));
const aging = dataUri(agingSVG({ seed: 5, scratches: 6, dust: 0.5, foxing: 0.55 }));
const hpx = (HORIZON * SIZE).toFixed(1);
const IMG = (href, attrs = '') => `<image x="0" y="0" width="${SIZE}" height="${SIZE}" href="${href}" ${attrs}/>`;
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.00 0"/>
<feGaussianBlur stdDeviation="${(0.6 * 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)}
${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} ${v.note}`);
}
/* ============================================================
THE STUDIES — single axes first, then two deliberate chords.
============================================================ */
const VARIANTS = [
{ name: '00_reference', note: 'baseline (traces 1.0 · rows 46 · overlap 1.9 · line ×1)',
traces: {}, sea: {} },
{ name: '01_traces-bold', note: 'evidence enlarged (bubble size 1.6, density 0.9)',
traces: { size: 1.6, density: 0.9 } },
{ name: '02_traces-fine', note: 'evidence miniaturised (size 0.62, density 1.3)',
traces: { size: 0.62, density: 1.3 } },
{ name: '03_sea-broad', note: 'few broad swells (rows 26)',
sea: { rows: 26 } },
{ name: '04_sea-fine', note: 'fine ripple (rows 78, line ×0.75)',
sea: { rows: 78, strokeMul: 0.75 } },
{ name: '05_sea-deep', note: 'deep undulation (overlap 3.1)',
sea: { overlap: 3.1 } },
{ name: '06_sea-etched', note: 'heavy wave line (line ×2.3)',
sea: { strokeMul: 2.3 } },
{ name: '07_giant-evidence', note: 'large ink over fine calm water (size 1.7 · rows 78 · line ×0.7 · overlap 1.6)',
traces: { size: 1.7, density: 0.85 }, sea: { rows: 78, strokeMul: 0.7, overlap: 1.6 } },
{ name: '08_woodcut', note: 'small ink in a heavy carved sea (size 0.7 · rows 24 · line ×2.4 · overlap 2.6)',
traces: { size: 0.7, density: 1.3 }, sea: { rows: 24, strokeMul: 2.4, overlap: 2.6 } },
];
console.log(`SUN-ABOVE SCALE STUDIES — ${VARIANTS.length} renders → ${OUT}/`);
VARIANTS.forEach(renderVariant);
writeFileSync(`${OUT}/index.html`, `<!DOCTYPE html><html><head><meta charset="utf-8"><title>REFINED_Backlit2 · sun-above scale studies</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(3,1fr);gap:12px;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}</style></head><body>
<h1>Sun-above — scale studies (colours &amp; composition held)</h1>
<div class="row">${VARIANTS.map(v => `<figure><img src="${v.name}.svg"><figcaption>${v.name.replace(/^\d+_/, '')}${v.note}</figcaption></figure>`).join('')}</div>
</body></html>`);
console.log(`gallery -> ${OUT}/index.html`);

260
tools/refined-backlit3.mjs Normal file
View File

@@ -0,0 +1,260 @@
/* ============================================================
refined-backlit3.mjs — SUN-ABOVE, BOLDER · LARGE TRACES.
One frame (plate Nº 217, exposure 19:04, MESON-5113). This
generation commits to LARGER bubble traces throughout and a
generally BOLDER read; each render plays the waves differently
(count / depth / line weight / chaos). The pressure disc is
FADED INTO THE BACK — its heart fully hollowed and the whole
sun dimmed + pushed behind the sea, a ghost the tracks cross.
Film grain + distressing (foxing / dust / scratches) stay ON.
Usage: node tools/refined-backlit3.mjs [size]
→ fable/REFINED_Backlit3/
============================================================ */
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 { makeRng } from '../src/rng.js';
const SIZE = +(process.argv[2] || 1500);
const OUT = 'fable/REFINED_Backlit3';
mkdirSync(OUT, { recursive: true });
const u = SIZE / 1000;
const dataUri = (svg) => 'data:image/svg+xml;base64,' + Buffer.from(svg).toString('base64');
/* ---- the world constants: identical to One Sun Setting ---- */
const SEED = 'MESON-5113';
const BASE = 'rgb(227,220,200)';
const HORIZON = 0.37;
const YH = HORIZON * 2 - 1;
const FILM = { seed: 41, density: 0.6 };
/* ---- film / grain / aging (unchanged from refined-backlit) ---- */
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>`;
}
/* ---- the sea deck, scale-parameterised ----
rows: wave count · overlap: amplitude vs row gap (depth) ·
strokeMul: line weight multiplier on the per-sheet stroke table */
function deck(sea, warpFn) {
const rows = sea.rows ?? 40, overlap = sea.overlap ?? 2.2, sm = sea.strokeMul ?? 1.35; // v3: bolder default line
const chaosMax = sea.chaos ?? 0.8, blips = sea.blips ?? 1.4;
const base = { mode: 'plate', rows, horizon: HORIZON, wFar: 0.58, wNear: 0.7,
overlap, mound: 0.4, sat: 0.58, lightNear: 0.33, lightFar: 0.56, 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(chaosMax * 0.8, chaosMax, t),
strokeNear: strokes[i].near * sm, strokeFar: strokes[i].far * sm });
});
}
/* ---- iron filings (identical logic, geometry follows the sea's scale) ---- */
function buildAttraction(below, shock, sunSubmerged) {
const S = (v) => SIZE / 2 + v * (SIZE / 2) * 0.98;
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;
}
return (x, y) => {
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;
};
}
function warpFor(sample, pullPx, sea) {
const rows = sea.rows ?? 40, overlap = sea.overlap ?? 2.2;
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) / (rows - 1);
const amp = Math.max(2 * u, overlap * localGap);
const n = sample(x, y) * pullPx / amp;
return Math.max(-0.9, Math.min(0.9, n));
};
}
/* ---- the event: sun-above config, colours fixed ---- */
// seed drives ONLY the stochastic realization; the MESON-5113 parameter
// fingerprint (event position, counts, physics, sun) is held constant, so a
// new seed re-rolls the bubble layout without changing the piece's character.
function bcAssembleParams(over = {}, seed = SEED) {
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, // softer → the disc reads as a haze behind, not an object
shockStriations: 1.0, diskHollow: 1.0, // heart fully open: the sun is a ring of light, not a plug
depth: 0.3, aging: 0.28, // distressing up a touch (bolder document)
eventX: -0.14, eventY: -0.37,
sweepers: 5, primaries: 17, eloss: 0.34, deltaRate: 0.8,
bgEvents: 2, shockX: -0.55, shockY: -0.72, shockSize: 0.17, shockIntensity: 0.8,
}, over, { seed });
return p;
}
/* ---- the SUN and the WAVE-FIELD are fixed across the whole set (base seed),
so the sea is byte-identical per iteration and only the ink is re-rolled ---- */
const baseP = bcAssembleParams();
const baseScene = generateScene(baseP);
const baseBare = { ...baseScene, instrument: null, artifacts: null, media: null };
const diskImg = dataUri(renderSVG({ ...baseBare, tracks: [], shock: baseScene.shock }, { ...baseP, emit: ['disk'] }, SIZE));
const baseBelow = baseScene.tracks
.map(t => ({ ...t, pts: t.pts.filter(pt => pt.y > YH) }))
.filter(t => t.pts.length);
const fieldSample = buildAttraction(baseBelow, baseScene.shock, false); // waves attend to the base event only
function renderVariant(v, i) {
// unique bubble-trace seed per iteration; waves + sun stay put
const p = bcAssembleParams(v.traces || {}, `${SEED}#${String(i).padStart(2, '0')}`);
const scene = generateScene(p);
const bare = { ...scene, instrument: null, artifacts: null, media: null };
const sea = v.sea || {};
// REVERTED 2026-07-17: no waterline split, no refraction — the traces are ONE
// object drawn cleanly ON TOP of the sea; the water neither fronts nor tints them.
const tracksImg = dataUri(renderSVG({ ...bare, tracks: scene.tracks, shock: null }, { ...p, emit: ['bubble'] }, SIZE));
const sheets = deck(sea, warpFor(fieldSample, 7 * u, sea)).map(dataUri);
const film = dataUri(filmSVG(FILM));
const grain = dataUri(grainSVG({ amount: 0.52 })); // bolder film grain
const aging = dataUri(agingSVG({ seed: 5, scratches: 8, dust: 0.6, foxing: 0.62 })); // heavier distressing
const hpx = (HORIZON * SIZE).toFixed(1);
const IMG = (href, attrs = '') => `<image x="0" y="0" width="${SIZE}" height="${SIZE}" href="${href}" ${attrs}/>`;
const diskFade = v.diskFade ?? 0.5; // the sun receded into the back: a dim haze the evidence crosses
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="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.00 0"/>
<feGaussianBlur stdDeviation="${(0.6 * u).toFixed(2)}"/>
</filter>
<filter id="diskback" x="-12%" y="-12%" width="124%" height="124%"><feGaussianBlur stdDeviation="${(2.2 * 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, `filter="url(#diskback)" opacity="${diskFade.toFixed(2)}"`)}</g>
${IMG(sheets[0], 'filter="url(#b3)" opacity="0.5"')}
<g clip-path="url(#seac)">${IMG(diskImg, `filter="url(#subD)" opacity="${(0.7 + 0.3 * diskFade).toFixed(2)}"`)}</g>
<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(tracksImg)}
${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} ${v.note}`);
}
/* ============================================================
THE STUDIES — single axes first, then two deliberate chords.
============================================================ */
// Every variant carries LARGE bubble traces (evidence on top of the sea) and a
// generally bolder read; the sun is faded into the back. Each render moves the
// WAVES somewhere different — count, depth, line weight, chaos.
const VARIANTS = [
{ name: '00_bold-base', note: 'large ink · sea baseline (rows 40 · overlap 2.2 · line ×1.35)',
traces: { size: 1.5, density: 1.0 }, sea: {}, diskFade: 0.5 },
{ name: '01_broad-swell', note: 'few broad swells (rows 24 · overlap 2.6)',
traces: { size: 1.55, density: 1.0 }, sea: { rows: 24, overlap: 2.6 }, diskFade: 0.5 },
{ name: '02_fine-ripple', note: 'dense fine ripple (rows 82 · line ×0.9)',
traces: { size: 1.5, density: 1.0 }, sea: { rows: 82, strokeMul: 0.9 }, diskFade: 0.5 },
{ name: '03_deep-heave', note: 'deep undulation (overlap 3.4)',
traces: { size: 1.55, density: 1.0 }, sea: { overlap: 3.4 }, diskFade: 0.45 },
{ name: '04_etched-sea', note: 'heavy carved wave line (line ×2.6)',
traces: { size: 1.5, density: 1.05 }, sea: { strokeMul: 2.6 }, diskFade: 0.5 },
{ name: '05_glassy-calm', note: 'shallow glassy water (overlap 1.3 · rows 52)',
traces: { size: 1.6, density: 0.95 }, sea: { overlap: 1.3, rows: 52 }, diskFade: 0.55 },
{ name: '06_turbulent', note: 'turbulent, blip-rich sea (chaos 1.0 · blips 2.2 · overlap 2.8)',
traces: { size: 1.5, density: 1.05 }, sea: { chaos: 1.0, blips: 2.2, overlap: 2.8 }, diskFade: 0.45 },
{ name: '07_woodcut', note: 'heavy carved swell (rows 26 · line ×2.8 · overlap 3.0)',
traces: { size: 1.55, density: 1.0 }, sea: { rows: 26, strokeMul: 2.8, overlap: 3.0 }, diskFade: 0.5 },
{ name: '08_giant-ink-calm', note: 'giant evidence over quiet fine water (size 1.9 · rows 78 · line ×0.8 · overlap 1.5)',
traces: { size: 1.9, density: 0.9 }, sea: { rows: 78, strokeMul: 0.8, overlap: 1.5 }, diskFade: 0.55 },
{ name: '09_max-bold', note: 'everything loud (size 1.8 · rows 32 · line ×2.4 · overlap 3.0 · chaos 0.95)',
traces: { size: 1.8, density: 1.1 }, sea: { rows: 32, strokeMul: 2.4, overlap: 3.0, chaos: 0.95, blips: 1.8 }, diskFade: 0.4 },
];
console.log(`SUN-ABOVE · BOLD/LARGE-TRACE STUDIES — ${VARIANTS.length} renders → ${OUT}/`);
VARIANTS.forEach(renderVariant);
writeFileSync(`${OUT}/index.html`, `<!DOCTYPE html><html><head><meta charset="utf-8"><title>REFINED_Backlit3 · bold large-trace studies</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(3,1fr);gap:12px;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}</style></head><body>
<h1>Sun-above — bold, large traces on top · waves varied · sun faded back</h1>
<div class="row">${VARIANTS.map(v => `<figure><img src="${v.name}.svg"><figcaption>${v.name.replace(/^\d+_/, '')}${v.note}</figcaption></figure>`).join('')}</div>
</body></html>`);
console.log(`gallery -> ${OUT}/index.html`);

212
tools/refined-backlit4.mjs Normal file
View File

@@ -0,0 +1,212 @@
/* ============================================================
refined-backlit4.mjs — ONE SUN SETTING, GOLDEN.
The whole composition is built on φ (1.6180…):
· CANVAS a golden rectangle, W = φ·H (landscape)
· HORIZON the waterline splits the height 1/φ : 1 → y = W·0
(sky = 0.382 H · sea = 0.618 H, ratio 1/φ)
· DISK the sun's centre sits on the LEFT golden line
(0.382 W) at the golden division of the sky; its
radius is 0.382 of the sky band
· TRACES the event vertex sits on the RIGHT golden line
(0.618 W) where it meets the horizon
· WAVES amplitude:gap = φ · near:far width = φ · the three
stacked sheets stepped in opacity by 1/φ
Each iteration re-rolls only the bubble-trace seed; the golden
armature is identical. Grain + distressing stay on.
Usage: node tools/refined-backlit4.mjs [width]
→ fable/REFINED_Backlit4/
============================================================ */
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 { makeRng } from '../src/rng.js';
const PHI = (1 + Math.sqrt(5)) / 2; // 1.6180339887…
const IP = 1 / PHI; // 0.6180… (long section)
const IP2 = 1 / (PHI * PHI); // 0.3820… (short section)
const W = +(process.argv[2] || 1001);
const H = Math.round(W * PHI); // golden rectangle, PORTRAIT (H = φ·W)
const OUT = 'fable/REFINED_Backlit4';
mkdirSync(OUT, { recursive: true });
const u = Math.min(W, H) / 1000; // stroke unit keyed to the short side (width)
const dataUri = (svg) => 'data:image/svg+xml;base64,' + Buffer.from(svg).toString('base64');
/* ---- golden armature (px) ---- */
const SEED = 'MESON-5113';
const BASE = 'rgb(227,220,200)';
const HORIZON_F = IP2; // waterline at 0.382 H → sky:sea = 1/φ
const HY = HORIZON_F * H;
const SKY_H = HY; // sky band height
const S_SCALE = (Math.min(W, H) / 2) * (1 - 0.02); // renderSVG's isotropic scene→px scale
const XL = IP2 * W; // left golden line 0.382 W
const XR = IP * W; // right golden line 0.618 W
const SUN = { x: XL, y: IP * SKY_H }; // sun centre: left line, 0.618 down the sky
const SUN_R = IP2 * SKY_H; // sun radius = 0.382 of the sky band
const VERTEX = { x: XR, y: HY }; // event vertex: right line ∩ horizon
/* ---- film / grain / aging, now golden-canvas sized ---- */
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="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
<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="${W}" height="${H}" 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="${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));
const rng = makeRng(SEED, 'aging' + seed);
let lines = '';
for (let i = 0; i < scratches; i++) {
const x = rng() * W, y0 = rng() * H * 0.4, len = (0.3 + rng() * 0.6) * H;
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="${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>`;
}
/* ---- the golden sea: three sheets, EQUAL line count, φ-ratioed waves ---- */
function deck(mirror) {
const base = {
mode: 'plate', width: W, height: H, horizon: HORIZON_F,
wFar: 0.5, wNear: 0.5 * PHI, // near:far width = φ
overlap: PHI, // wave amplitude : row gap = φ
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: (mirror ? 'mfield' : 'field') + 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 });
});
}
/* ---- the event: golden-placed disk + traces ---- */
function bcAssembleParams(seed, 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: 1.4,
shockStriations: 1.0, diskHollow: 1.0,
depth: 0.3, aging: 0.28,
// event + shock both at scene origin; each is PLACED on the page by origin
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, // radius = 0.382 of the sky band
shockIntensity: 0.8,
size: 1.6, // larger bubbles
canvasW: W, canvasH: H,
}, over, { seed });
return p;
}
const TRACE_ZOOM = 2.1; // the evidence grows past the frame
function renderVariant(v, i) {
const sun = v.mirror ? { x: XR, y: SUN.y } : SUN;
const vtx = v.mirror ? { x: XL, y: HY } : VERTEX;
const p = bcAssembleParams(`${SEED}#${String(i).padStart(2, '0')}`);
const scene = generateScene(p);
const bare = { ...scene, instrument: null, artifacts: null, media: null };
const tracksImg = dataUri(renderSVG(
{ ...bare, tracks: scene.tracks, shock: null },
{ ...p, emit: ['bubble'], originX: vtx.x, originY: vtx.y, sceneZoom: TRACE_ZOOM }, W));
const diskImg = dataUri(renderSVG(
{ ...bare, tracks: [], shock: scene.shock },
{ ...p, emit: ['disk'], originX: sun.x, originY: sun.y }, W));
const sheets = deck(v.mirror).map(dataUri);
const film = dataUri(filmSVG({ seed: 41, density: 0.6 }));
const grain = dataUri(grainSVG({ amount: 0.52 }));
const aging = dataUri(agingSVG({ seed: 5 + i }));
const hpx = HY.toFixed(1);
const IMG = (href, attrs = '') => `<image x="0" y="0" width="${W}" height="${H}" href="${href}" ${attrs}/>`;
// three sea planes stepped in opacity by 1/φ (front → back)
const OP = [0.95 * IP2, 0.95 * IP, 0.95];
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
<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="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.00 0"/>
<feGaussianBlur stdDeviation="${(0.6 * u).toFixed(2)}"/>
</filter>
<filter id="diskback" x="-12%" y="-12%" width="124%" height="124%"><feGaussianBlur stdDeviation="${(2.2 * u).toFixed(2)}"/></filter>
<clipPath id="skyc"><rect x="0" y="0" width="${W}" height="${hpx}"/></clipPath>
<clipPath id="seac"><rect x="0" y="${hpx}" width="${W}" height="${(H - HY).toFixed(1)}"/></clipPath>
</defs>
<rect width="${W}" height="${H}" fill="${BASE}"/>
${IMG(film, 'opacity="0.6"')}
<g clip-path="url(#skyc)">${IMG(diskImg, 'filter="url(#diskback)" opacity="0.5"')}</g>
${IMG(sheets[0], `filter="url(#b3)" opacity="${OP[0].toFixed(2)}"`)}
<g clip-path="url(#seac)">${IMG(diskImg, 'filter="url(#subD)" opacity="0.85"')}</g>
<rect x="0" y="${hpx}" width="${W}" height="${(H - HY).toFixed(1)}" fill="rgb(96,138,128)" opacity="0.07"/>
${IMG(sheets[1], `filter="url(#b2)" opacity="${OP[1].toFixed(2)}"`)}
${IMG(sheets[2], `opacity="${OP[2].toFixed(2)}"`)}
${IMG(tracksImg)}
${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(${sun.x.toFixed(0)},${sun.y.toFixed(0)}) vtx(${vtx.x.toFixed(0)},${vtx.y.toFixed(0)}) ${v.mirror ? 'mirrored' : ''}`);
}
const VARIANTS = [
{ name: '00_golden', mirror: false },
{ name: '01_golden', mirror: false },
{ name: '02_golden', mirror: false },
{ name: '03_golden-mirror', mirror: true },
{ name: '04_golden-mirror', mirror: true },
{ name: '05_golden-mirror', mirror: true },
];
console.log(`ONE SUN SETTING · GOLDEN — ${W}×${H} (φ) — ${VARIANTS.length} renders → ${OUT}/`);
VARIANTS.forEach(renderVariant);
writeFileSync(`${OUT}/index.html`, `<!DOCTYPE html><html><head><meta charset="utf-8"><title>REFINED_Backlit4 · golden</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(2,1fr);gap:14px;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}</style></head><body>
<h1>One sun setting — golden (${W}×${H}, φ)</h1>
<div class="row">${VARIANTS.map(v => `<figure><img src="${v.name}.svg"><figcaption>${v.name}</figcaption></figure>`).join('')}</div>
</body></html>`);
console.log(`gallery -> ${OUT}/index.html`);

213
tools/refined-backlit5.mjs Normal file
View File

@@ -0,0 +1,213 @@
/* ============================================================
refined-backlit4.mjs — ONE SUN SETTING, GOLDEN.
The whole composition is built on φ (1.6180…):
· CANVAS a golden rectangle, W = φ·H (landscape)
· HORIZON the waterline splits the height 1/φ : 1 → y = W·0
(sky = 0.382 H · sea = 0.618 H, ratio 1/φ)
· DISK the sun's centre sits on the LEFT golden line
(0.382 W) at the golden division of the sky; its
radius is 0.382 of the sky band
· TRACES the event vertex sits on the RIGHT golden line
(0.618 W) where it meets the horizon
· WAVES amplitude:gap = φ · near:far width = φ · the three
stacked sheets stepped in opacity by 1/φ
Each iteration re-rolls only the bubble-trace seed; the golden
armature is identical. Grain + distressing stay on.
Usage: node tools/refined-backlit4.mjs [width]
→ fable/REFINED_Backlit5/
============================================================ */
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 { makeRng } from '../src/rng.js';
const PHI = (1 + Math.sqrt(5)) / 2; // 1.6180339887…
const IP = 1 / PHI; // 0.6180… (long section)
const IP2 = 1 / (PHI * PHI); // 0.3820… (short section)
const W = +(process.argv[2] || 1001);
const H = Math.round(W * PHI); // golden rectangle, PORTRAIT (H = φ·W)
const OUT = 'fable/REFINED_Backlit5';
mkdirSync(OUT, { recursive: true });
const u = Math.min(W, H) / 1000; // stroke unit keyed to the short side (width)
const dataUri = (svg) => 'data:image/svg+xml;base64,' + Buffer.from(svg).toString('base64');
/* ---- golden armature (px) ---- */
const SEED = 'MESON-5113';
const BASE = 'rgb(227,220,200)';
const HORIZON_F = IP2; // waterline at 0.382 H → sky:sea = 1/φ
const HY = HORIZON_F * H;
const SKY_H = HY; // sky band height
const S_SCALE = (Math.min(W, H) / 2) * (1 - 0.02); // renderSVG's isotropic scene→px scale
const XL = IP2 * W; // left golden line 0.382 W
const XR = IP * W; // right golden line 0.618 W
const SUN = { x: XL, y: IP * SKY_H }; // sun centre: left line, 0.618 down the sky
const SUN_R = IP2 * SKY_H; // sun radius = 0.382 of the sky band
const VERTEX = { x: XR, y: HY }; // event vertex: right line ∩ horizon
/* ---- film / grain / aging, now golden-canvas sized ---- */
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="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
<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="${W}" height="${H}" 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="${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));
const rng = makeRng(SEED, 'aging' + seed);
let lines = '';
for (let i = 0; i < scratches; i++) {
const x = rng() * W, y0 = rng() * H * 0.4, len = (0.3 + rng() * 0.6) * H;
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="${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>`;
}
/* ---- the golden sea: three sheets, EQUAL line count, φ-ratioed waves ---- */
function deck(mirror) {
const base = {
mode: 'plate', width: W, height: H, horizon: HORIZON_F,
wFar: 0.5, wNear: 0.5 * PHI, // near:far width = φ
overlap: PHI, // wave amplitude : row gap = φ
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: (mirror ? 'mfield' : 'field') + 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 });
});
}
/* ---- the event: golden-placed disk + traces ---- */
function bcAssembleParams(seed, 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: 1.4,
shockStriations: 1.0, diskHollow: 1.0,
depth: 0.3, aging: 0.28,
// event + shock both at scene origin; each is PLACED on the page by origin
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, // radius = 0.382 of the sky band
shockIntensity: 0.8,
size: 1.6, // larger bubbles
bubbleHollow: 1, bubbleWeight: 0.7, // delicate hollow bubbles: pale core, saturated rim
canvasW: W, canvasH: H,
}, over, { seed });
return p;
}
const TRACE_ZOOM = 2.1; // the evidence grows past the frame
function renderVariant(v, i) {
const sun = v.mirror ? { x: XR, y: SUN.y } : SUN;
const vtx = v.mirror ? { x: XL, y: HY } : VERTEX;
const p = bcAssembleParams(`${SEED}#${String(i).padStart(2, '0')}`);
const scene = generateScene(p);
const bare = { ...scene, instrument: null, artifacts: null, media: null };
const tracksImg = dataUri(renderSVG(
{ ...bare, tracks: scene.tracks, shock: null },
{ ...p, emit: ['bubble'], originX: vtx.x, originY: vtx.y, sceneZoom: TRACE_ZOOM }, W));
const diskImg = dataUri(renderSVG(
{ ...bare, tracks: [], shock: scene.shock },
{ ...p, emit: ['disk'], originX: sun.x, originY: sun.y }, W));
const sheets = deck(v.mirror).map(dataUri);
const film = dataUri(filmSVG({ seed: 41, density: 0.6 }));
const grain = dataUri(grainSVG({ amount: 0.52 }));
const aging = dataUri(agingSVG({ seed: 5 + i }));
const hpx = HY.toFixed(1);
const IMG = (href, attrs = '') => `<image x="0" y="0" width="${W}" height="${H}" href="${href}" ${attrs}/>`;
// three sea planes stepped in opacity by 1/φ (front → back)
const OP = [0.95 * IP2, 0.95 * IP, 0.95];
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
<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="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.00 0"/>
<feGaussianBlur stdDeviation="${(0.6 * u).toFixed(2)}"/>
</filter>
<filter id="diskback" x="-12%" y="-12%" width="124%" height="124%"><feGaussianBlur stdDeviation="${(2.2 * u).toFixed(2)}"/></filter>
<clipPath id="skyc"><rect x="0" y="0" width="${W}" height="${hpx}"/></clipPath>
<clipPath id="seac"><rect x="0" y="${hpx}" width="${W}" height="${(H - HY).toFixed(1)}"/></clipPath>
</defs>
<rect width="${W}" height="${H}" fill="${BASE}"/>
${IMG(film, 'opacity="0.6"')}
<g clip-path="url(#skyc)">${IMG(diskImg, 'filter="url(#diskback)" opacity="0.5"')}</g>
${IMG(sheets[0], `filter="url(#b3)" opacity="${OP[0].toFixed(2)}"`)}
<g clip-path="url(#seac)">${IMG(diskImg, 'filter="url(#subD)" opacity="0.85"')}</g>
<rect x="0" y="${hpx}" width="${W}" height="${(H - HY).toFixed(1)}" fill="rgb(96,138,128)" opacity="0.07"/>
${IMG(sheets[1], `filter="url(#b2)" opacity="${OP[1].toFixed(2)}"`)}
${IMG(sheets[2], `opacity="${OP[2].toFixed(2)}"`)}
${IMG(tracksImg)}
${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(${sun.x.toFixed(0)},${sun.y.toFixed(0)}) vtx(${vtx.x.toFixed(0)},${vtx.y.toFixed(0)}) ${v.mirror ? 'mirrored' : ''}`);
}
const VARIANTS = [
{ name: '00_golden', mirror: false },
{ name: '01_golden', mirror: false },
{ name: '02_golden', mirror: false },
{ name: '03_golden-mirror', mirror: true },
{ name: '04_golden-mirror', mirror: true },
{ name: '05_golden-mirror', mirror: true },
];
console.log(`ONE SUN SETTING · GOLDEN · DELICATE BUBBLES — ${W}×${H} (φ) — ${VARIANTS.length} renders → ${OUT}/`);
VARIANTS.forEach(renderVariant);
writeFileSync(`${OUT}/index.html`, `<!DOCTYPE html><html><head><meta charset="utf-8"><title>REFINED_Backlit5 · golden · delicate bubbles</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(2,1fr);gap:14px;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}</style></head><body>
<h1>One sun setting — golden (${W}×${H}, φ)</h1>
<div class="row">${VARIANTS.map(v => `<figure><img src="${v.name}.svg"><figcaption>${v.name}</figcaption></figure>`).join('')}</div>
</body></html>`);
console.log(`gallery -> ${OUT}/index.html`);

223
tools/refined-backlit6.mjs Normal file
View File

@@ -0,0 +1,223 @@
/* ============================================================
refined-backlit4.mjs — ONE SUN SETTING, GOLDEN.
The whole composition is built on φ (1.6180…):
· CANVAS a golden rectangle, W = φ·H (landscape)
· HORIZON the waterline splits the height 1/φ : 1 → y = W·0
(sky = 0.382 H · sea = 0.618 H, ratio 1/φ)
· DISK the sun's centre sits on the LEFT golden line
(0.382 W) at the golden division of the sky; its
radius is 0.382 of the sky band
· TRACES the event vertex sits on the RIGHT golden line
(0.618 W) where it meets the horizon
· WAVES amplitude:gap = φ · near:far width = φ · the three
stacked sheets stepped in opacity by 1/φ
Each iteration re-rolls only the bubble-trace seed; the golden
armature is identical. Grain + distressing stay on.
Usage: node tools/refined-backlit4.mjs [width]
→ fable/REFINED_Backlit6/
============================================================ */
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 { makeRng } from '../src/rng.js';
const PHI = (1 + Math.sqrt(5)) / 2; // 1.6180339887…
const IP = 1 / PHI; // 0.6180… (long section)
const IP2 = 1 / (PHI * PHI); // 0.3820… (short section)
const W = +(process.argv[2] || 1001);
const H = Math.round(W * PHI); // golden rectangle, PORTRAIT (H = φ·W)
const OUT = 'fable/REFINED_Backlit6';
mkdirSync(OUT, { recursive: true });
const u = Math.min(W, H) / 1000; // stroke unit keyed to the short side (width)
const dataUri = (svg) => 'data:image/svg+xml;base64,' + Buffer.from(svg).toString('base64');
/* ---- golden armature (px) ---- */
const SEED = 'MESON-5113';
const BASE = 'rgb(227,220,200)';
const HORIZON_F = IP2; // waterline at 0.382 H → sky:sea = 1/φ
const HY = HORIZON_F * H;
const SKY_H = HY; // sky band height
const S_SCALE = (Math.min(W, H) / 2) * (1 - 0.02); // renderSVG's isotropic scene→px scale
const XL = IP2 * W; // left golden line 0.382 W
const XR = IP * W; // right golden line 0.618 W
const SUN = { x: XL, y: IP * SKY_H }; // sun centre: left line, 0.618 down the sky
const SUN_R = IP2 * SKY_H; // sun radius = 0.382 of the sky band
const VERTEX = { x: XR, y: HY }; // event vertex: right line ∩ horizon
/* ---- film / grain / aging, now golden-canvas sized ---- */
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="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
<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="${W}" height="${H}" 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="${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));
const rng = makeRng(SEED, 'aging' + seed);
let lines = '';
for (let i = 0; i < scratches; i++) {
const x = rng() * W, y0 = rng() * H * 0.4, len = (0.3 + rng() * 0.6) * H;
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="${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>`;
}
/* ---- the golden sea: three sheets, EQUAL line count, φ-ratioed waves ---- */
function deck(mirror) {
const base = {
mode: 'plate', width: W, height: H, horizon: HORIZON_F,
wFar: 0.5, wNear: 0.5 * PHI, // near:far width = φ
overlap: PHI, // wave amplitude : row gap = φ
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: (mirror ? 'mfield' : 'field') + 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 });
});
}
/* ---- the event: golden-placed disk + traces ---- */
function bcAssembleParams(seed, 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: 1.4,
shockStriations: 1.0, diskHollow: 1.0,
depth: 0.3, aging: 0.28,
// event + shock both at scene origin; each is PLACED on the page by origin
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, // radius = 0.382 of the sky band
shockIntensity: 0.8,
size: 1.6, // larger bubbles
bubbleHollow: 1, bubbleWeight: 0.7, // delicate hollow bubbles: pale core, saturated rim
bubbleSoft: 0.7, // softer, wider rings (was 0.3)
bubbleOpacity: +(process.env.BUBOP ?? 0.9), // master bubble opacity slider — start at 90%
canvasW: W, canvasH: H,
}, over, { seed });
return p;
}
const TRACE_ZOOM = 2.1; // the evidence grows past the frame
function renderVariant(v, i) {
const sun = v.mirror ? { x: XR, y: SUN.y } : SUN;
const vtx = v.mirror ? { x: XL, y: HY } : VERTEX;
const p = bcAssembleParams(v.seed ?? `${SEED}#${String(i).padStart(2, '0')}`,
v.bubOp != null ? { bubbleOpacity: v.bubOp } : {});
const scene = generateScene(p);
const bare = { ...scene, instrument: null, artifacts: null, media: null };
const tracksImg = dataUri(renderSVG(
{ ...bare, tracks: scene.tracks, shock: null },
{ ...p, emit: ['bubble'], originX: vtx.x, originY: vtx.y, sceneZoom: TRACE_ZOOM }, W));
const diskImg = dataUri(renderSVG(
{ ...bare, tracks: [], shock: scene.shock },
{ ...p, emit: ['disk'], originX: sun.x, originY: sun.y }, W));
const sheets = deck(v.mirror).map(dataUri);
const film = dataUri(filmSVG({ seed: 41, density: 0.6 }));
const grain = dataUri(grainSVG({ amount: 0.52 }));
const aging = dataUri(agingSVG({ seed: v.agingSeed ?? (5 + i) }));
const hpx = HY.toFixed(1);
const IMG = (href, attrs = '') => `<image x="0" y="0" width="${W}" height="${H}" href="${href}" ${attrs}/>`;
// three sea planes stepped in opacity by 1/φ (front → back)
const OP = [0.95 * IP2, 0.95 * IP, 0.95];
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
<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="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.00 0"/>
<feGaussianBlur stdDeviation="${(0.6 * u).toFixed(2)}"/>
</filter>
<filter id="diskback" x="-12%" y="-12%" width="124%" height="124%"><feGaussianBlur stdDeviation="${(2.2 * u).toFixed(2)}"/></filter>
<clipPath id="skyc"><rect x="0" y="0" width="${W}" height="${hpx}"/></clipPath>
<clipPath id="seac"><rect x="0" y="${hpx}" width="${W}" height="${(H - HY).toFixed(1)}"/></clipPath>
</defs>
<rect width="${W}" height="${H}" fill="${BASE}"/>
${IMG(film, 'opacity="0.6"')}
<g clip-path="url(#skyc)">${IMG(diskImg, 'filter="url(#diskback)" opacity="0.5"')}</g>
${IMG(sheets[0], `filter="url(#b3)" opacity="${OP[0].toFixed(2)}"`)}
<g clip-path="url(#seac)">${IMG(diskImg, 'filter="url(#subD)" opacity="0.85"')}</g>
<rect x="0" y="${hpx}" width="${W}" height="${(H - HY).toFixed(1)}" fill="rgb(96,138,128)" opacity="0.07"/>
${IMG(sheets[1], `filter="url(#b2)" opacity="${OP[1].toFixed(2)}"`)}
${IMG(sheets[2], `opacity="${OP[2].toFixed(2)}"`)}
${IMG(tracksImg)}
${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} bubbleOpacity=${(v.bubOp ?? 0.9).toFixed(2)} ${v.mirror ? 'mirrored' : ''}`);
}
// OPACITY SWEEP — one composition (the liked mirror event, fixed seed), the
// bubble-opacity slider stepped 0.50 → 0.95. Only the ink density changes.
const SWEEP_SEED = `${SEED}#03`;
const VARIANTS = [0.20, 0.32, 0.44, 0.56, 0.68, 0.80].map(op => ({
name: `op-${Math.round(op * 100)}`, mirror: true, seed: SWEEP_SEED, bubOp: op, agingSeed: 8,
}));
const _UNUSED = [
{ name: '00_golden', mirror: false },
{ name: '01_golden', mirror: false },
{ name: '02_golden', mirror: false },
{ name: '03_golden-mirror', mirror: true },
{ name: '04_golden-mirror', mirror: true },
{ name: '05_golden-mirror', mirror: true },
];
console.log(`ONE SUN SETTING · GOLDEN · OPACITY SWEEP — ${W}×${H} (φ) — ${VARIANTS.length} renders → ${OUT}/`);
VARIANTS.forEach(renderVariant);
writeFileSync(`${OUT}/index.html`, `<!DOCTYPE html><html><head><meta charset="utf-8"><title>REFINED_Backlit6 · bubble-opacity sweep</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(3,1fr);gap:14px;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}</style></head><body>
<h1>One sun setting — bubble-opacity sweep 20→80% (${W}×${H}, φ)</h1>
<div class="row">${VARIANTS.map(v => `<figure><img src="${v.name}.svg"><figcaption>${v.name}</figcaption></figure>`).join('')}</div>
</body></html>`);
console.log(`gallery -> ${OUT}/index.html`);

226
tools/refined-backlit7.mjs Normal file
View File

@@ -0,0 +1,226 @@
/* ============================================================
refined-backlit4.mjs — ONE SUN SETTING, GOLDEN.
The whole composition is built on φ (1.6180…):
· CANVAS a golden rectangle, W = φ·H (landscape)
· HORIZON the waterline splits the height 1/φ : 1 → y = W·0
(sky = 0.382 H · sea = 0.618 H, ratio 1/φ)
· DISK the sun's centre sits on the LEFT golden line
(0.382 W) at the golden division of the sky; its
radius is 0.382 of the sky band
· TRACES the event vertex sits on the RIGHT golden line
(0.618 W) where it meets the horizon
· WAVES amplitude:gap = φ · near:far width = φ · the three
stacked sheets stepped in opacity by 1/φ
Each iteration re-rolls only the bubble-trace seed; the golden
armature is identical. Grain + distressing stay on.
Usage: node tools/refined-backlit4.mjs [width]
→ fable/REFINED_Backlit7/
============================================================ */
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 { makeRng } from '../src/rng.js';
const PHI = (1 + Math.sqrt(5)) / 2; // 1.6180339887…
const IP = 1 / PHI; // 0.6180… (long section)
const IP2 = 1 / (PHI * PHI); // 0.3820… (short section)
const W = +(process.argv[2] || 1001);
const H = Math.round(W * PHI); // golden rectangle, PORTRAIT (H = φ·W)
const OUT = 'fable/REFINED_Backlit7';
mkdirSync(OUT, { recursive: true });
const u = Math.min(W, H) / 1000; // stroke unit keyed to the short side (width)
const dataUri = (svg) => 'data:image/svg+xml;base64,' + Buffer.from(svg).toString('base64');
/* ---- golden armature (px) ---- */
const SEED = 'MESON-5113';
const BASE = 'rgb(227,220,200)';
const HORIZON_F = IP2; // waterline at 0.382 H → sky:sea = 1/φ
const HY = HORIZON_F * H;
const SKY_H = HY; // sky band height
const S_SCALE = (Math.min(W, H) / 2) * (1 - 0.02); // renderSVG's isotropic scene→px scale
const XL = IP2 * W; // left golden line 0.382 W
const XR = IP * W; // right golden line 0.618 W
const SUN = { x: XL, y: IP * SKY_H }; // sun centre: left line, 0.618 down the sky
const SUN_R = IP2 * SKY_H; // sun radius = 0.382 of the sky band
const VERTEX = { x: XR, y: HY }; // event vertex: right line ∩ horizon
/* ---- film / grain / aging, now golden-canvas sized ---- */
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="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
<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="${W}" height="${H}" 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="${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));
const rng = makeRng(SEED, 'aging' + seed);
let lines = '';
for (let i = 0; i < scratches; i++) {
const x = rng() * W, y0 = rng() * H * 0.4, len = (0.3 + rng() * 0.6) * H;
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="${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>`;
}
/* ---- the golden sea: three sheets, EQUAL line count, φ-ratioed waves ---- */
function deck(mirror) {
const base = {
mode: 'plate', width: W, height: H, horizon: HORIZON_F,
wFar: 0.5, wNear: 0.5 * PHI, // near:far width = φ
overlap: PHI, // wave amplitude : row gap = φ
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: (mirror ? 'mfield' : 'field') + 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 });
});
}
/* ---- the event: golden-placed disk + traces ---- */
function bcAssembleParams(seed, 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: 1.4,
shockStriations: 1.0, diskHollow: 1.0,
depth: 0.3, aging: 0.28,
// event + shock both at scene origin; each is PLACED on the page by origin
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, // radius = 0.382 of the sky band
shockIntensity: 0.8,
size: 1.6, // larger bubbles
bubbleHollow: 1, bubbleWeight: 0.7, // delicate hollow bubbles: pale core, saturated rim
bubbleSoft: 0.7, // softer, wider rings (was 0.3)
bubbleMerge: 1, // crowded bubbles fuse to flat union (uniform lace, backlit-even)
bubbleOpacity: +(process.env.BUBOP ?? 0.9), // = uniform target density / backlit brightness
canvasW: W, canvasH: H,
}, over, { seed });
return p;
}
const TRACE_ZOOM = 2.1; // the evidence grows past the frame
function renderVariant(v, i) {
const sun = v.mirror ? { x: XR, y: SUN.y } : SUN;
const vtx = v.mirror ? { x: XL, y: HY } : VERTEX;
const p = bcAssembleParams(v.seed ?? `${SEED}#${String(i).padStart(2, '0')}`,
v.bubOp != null ? { bubbleOpacity: v.bubOp } : {});
const scene = generateScene(p);
const bare = { ...scene, instrument: null, artifacts: null, media: null };
const tracksImg = dataUri(renderSVG(
{ ...bare, tracks: scene.tracks, shock: null },
{ ...p, emit: ['bubble'], originX: vtx.x, originY: vtx.y, sceneZoom: TRACE_ZOOM }, W));
const diskImg = dataUri(renderSVG(
{ ...bare, tracks: [], shock: scene.shock },
{ ...p, emit: ['disk'], originX: sun.x, originY: sun.y }, W));
const sheets = deck(v.mirror).map(dataUri);
const film = dataUri(filmSVG({ seed: 41, density: 0.6 }));
const grain = dataUri(grainSVG({ amount: 0.52 }));
const aging = dataUri(agingSVG({ seed: v.agingSeed ?? (5 + i) }));
const hpx = HY.toFixed(1);
const IMG = (href, attrs = '') => `<image x="0" y="0" width="${W}" height="${H}" href="${href}" ${attrs}/>`;
// three sea planes stepped in opacity by 1/φ (front → back)
const OP = [0.95 * IP2, 0.95 * IP, 0.95];
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
<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="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.00 0"/>
<feGaussianBlur stdDeviation="${(0.6 * u).toFixed(2)}"/>
</filter>
<filter id="diskback" x="-12%" y="-12%" width="124%" height="124%"><feGaussianBlur stdDeviation="${(2.2 * u).toFixed(2)}"/></filter>
<clipPath id="skyc"><rect x="0" y="0" width="${W}" height="${hpx}"/></clipPath>
<clipPath id="seac"><rect x="0" y="${hpx}" width="${W}" height="${(H - HY).toFixed(1)}"/></clipPath>
</defs>
<rect width="${W}" height="${H}" fill="${BASE}"/>
${IMG(film, 'opacity="0.6"')}
<g clip-path="url(#skyc)">${IMG(diskImg, 'filter="url(#diskback)" opacity="0.5"')}</g>
${IMG(sheets[0], `filter="url(#b3)" opacity="${OP[0].toFixed(2)}"`)}
<g clip-path="url(#seac)">${IMG(diskImg, 'filter="url(#subD)" opacity="0.85"')}</g>
<rect x="0" y="${hpx}" width="${W}" height="${(H - HY).toFixed(1)}" fill="rgb(96,138,128)" opacity="0.07"/>
${IMG(sheets[1], `filter="url(#b2)" opacity="${OP[1].toFixed(2)}"`)}
${IMG(sheets[2], `opacity="${OP[2].toFixed(2)}"`)}
${IMG(tracksImg)}
${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} bubbleOpacity=${(v.bubOp ?? 0.9).toFixed(2)} ${v.mirror ? 'mirrored' : ''}`);
}
// MERGE · UNIFORM LACE — one composition (the liked mirror event, fixed seed), the
// bubble-opacity slider stepped 0.50 → 0.95. Only the ink density changes.
const SWEEP_SEED = `${SEED}#03`;
// MERGE — uniform-lace target sweep. bubOp is now the CAPPED union density
// (dense knot = same density as a lone bubble), i.e. the backlit brightness.
const VARIANTS = [0.35, 0.45, 0.55, 0.65, 0.75, 0.85].map(op => ({
name: `merge-${Math.round(op * 100)}`, mirror: true, seed: SWEEP_SEED, bubOp: op, agingSeed: 8,
}));
const _UNUSED = [
{ name: '00_golden', mirror: false },
{ name: '01_golden', mirror: false },
{ name: '02_golden', mirror: false },
{ name: '03_golden-mirror', mirror: true },
{ name: '04_golden-mirror', mirror: true },
{ name: '05_golden-mirror', mirror: true },
];
console.log(`ONE SUN SETTING · GOLDEN · MERGE · UNIFORM LACE — ${W}×${H} (φ) — ${VARIANTS.length} renders → ${OUT}/`);
VARIANTS.forEach(renderVariant);
writeFileSync(`${OUT}/index.html`, `<!DOCTYPE html><html><head><meta charset="utf-8"><title>REFINED_Backlit6 · bubble-opacity sweep</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(3,1fr);gap:14px;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}</style></head><body>
<h1>One sun setting — merge · uniform-lace target sweep (${W}×${H}, φ)</h1>
<div class="row">${VARIANTS.map(v => `<figure><img src="${v.name}.svg"><figcaption>${v.name}</figcaption></figure>`).join('')}</div>
</body></html>`);
console.log(`gallery -> ${OUT}/index.html`);