VisualLearningIdea / cat-sun-scene.html
kwabs22
Claude Opus 5.5
Add v1: English -> 3D translation Space with MCP tools
77b3711
Raw History Blame Contribute Delete
27 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The cat sat and the sun shone — serial reading + scene</title>
<style>
:root{--bg:#0e1116;--panel:#151a22e6;--border:#2a3242;--text:#e8ecf4;--muted:#93a0b4;--accent:#ffc857;--accent2:#7ee0a3;--warn:#f0b429}
*{box-sizing:border-box;margin:0}
body{background:var(--bg);color:var(--text);font:14px/1.55 -apple-system,"Segoe UI",Roboto,sans-serif;overflow:hidden}
#scene{position:fixed;inset:0}
#panel{position:fixed;top:16px;left:16px;width:352px;max-height:calc(100vh - 32px);overflow-y:auto;background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:14px;backdrop-filter:blur(8px);z-index:10}
h1{font-size:15px;margin-bottom:6px}
.sub{color:var(--muted);font-size:12px;margin-bottom:10px}
.tabs{display:flex;gap:6px;margin-bottom:10px}
.tab{flex:1;text-align:center;padding:6px 0;border:1px solid var(--border);border-radius:8px;font-size:12.5px;cursor:pointer;color:var(--muted)}
.tab.active{color:var(--accent);border-color:var(--accent);background:#ffc85714}
.view{display:none}
.view.active{display:block}
.cap{min-height:48px;font-size:12.5px;background:#ffc85710;border:1px solid var(--border);border-radius:8px;padding:8px 10px;margin-bottom:10px}
.cap b{color:var(--accent)}
.btnrow{display:flex;gap:8px;margin-bottom:10px}
button{flex:1;background:var(--panel);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:7px 0;font-size:12.5px;cursor:pointer}
button:hover{border-color:var(--accent)}
button.primary{background:#ffc85722;border-color:var(--accent)}
button:disabled{opacity:.4;cursor:default}
.section{margin-bottom:10px}
.label{font-size:11px;letter-spacing:1px;text-transform:uppercase;color:var(--accent2);margin-bottom:6px}
.ledger{display:flex;flex-direction:column;gap:4px}
.item{font-size:12px;padding:4px 8px;border-radius:6px;border:1px solid transparent;opacity:.38;transition:all .25s}
.item b{color:var(--accent)}
.item .alt{color:var(--muted);font-size:11px}
.item.done{opacity:1;border-color:var(--border)}
.item.active{opacity:1;border-color:var(--accent);background:#ffc85714}
.unres{border-left:3px solid var(--warn);padding-left:10px}
.unres div{color:var(--muted);font-size:12px;padding:2px 0}
.ctrl{margin:8px 0}
.ctrl label{display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:3px}
input[type=range]{width:100%;accent-color:var(--accent)}
select{background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:4px 8px;font-size:12px;width:100%}
.note{font-size:11px;color:var(--muted);border-top:1px solid var(--border);padding-top:8px;margin-top:4px}
#hud{position:fixed;bottom:12px;right:16px;color:var(--muted);font-size:11px;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:6px 10px}
/* ---- serial reading: full-bleed linear timeline, starts right of sidebar ---- */
#reading{position:fixed;inset:0;display:none;overflow-x:auto;overflow-y:auto;padding:24px 40px 40px 396px}
#reading.active{display:block}
.railhead{margin-bottom:16px;color:var(--muted);font-size:12px}
.railhead b{color:var(--accent2)}
.rail{display:flex;align-items:flex-start;min-width:max-content}
.tokwrap{display:flex;flex-direction:column;align-items:center;position:relative;padding:0 16px}
.tokwrap:not(:last-child)::after{content:'';position:absolute;top:26px;left:calc(50% + 30px);width:calc(100% - 60px);height:2px;background:var(--border)}
.tokwrap.committed:not(:last-child)::after{background:var(--accent2)}
.tok{width:52px;height:52px;border-radius:50%;border:2px solid var(--border);display:flex;align-items:center;justify-content:center;font-size:12.5px;color:var(--muted);background:var(--panel);position:relative;z-index:1;transition:all .3s}
.tok.committed{border-color:var(--accent2);color:var(--text)}
.tok.active{border-color:var(--accent);color:var(--accent);background:#ffc85722;box-shadow:0 0 18px #ffc85744}
.tok.q::before{content:'?';position:absolute;top:-8px;right:-8px;background:var(--warn);color:#000;font-size:10px;font-weight:700;width:16px;height:16px;border-radius:50%;display:flex;align-items:center;justify-content:center}
.model{width:150px;height:112px;margin-top:10px;border:1px solid var(--border);border-radius:10px;background:#10131a;object-fit:cover;display:block}
.model.qbox{display:flex;align-items:center;justify-content:center;color:var(--warn);font-size:40px;font-weight:700}
.word{margin-top:8px;font-size:16px;color:var(--muted)}
.tokwrap.committed .word{color:var(--text)}
.tokwrap.active .word{color:var(--accent)}
.map{margin-top:4px;font-size:11.5px;color:var(--accent2);text-align:center;max-width:150px}
.commits{margin-top:8px;display:flex;flex-direction:column;gap:5px;max-width:150px}
.chip{font-size:10.5px;border:1px solid var(--border);border-radius:6px;padding:3px 7px;color:var(--muted);text-align:center;opacity:.35;transition:all .3s}
.chip.on{opacity:1;border-color:var(--accent2);color:var(--text)}
.chip.rel{border-color:#7eb8e0}
.chip.q{border-color:var(--warn);color:var(--warn)}
</style>
</head>
<body>
<div id="scene"></div>
<div id="reading">
<div class="railhead"><b>SERIAL READING</b> — linear, no camera. Each word shows the model it commits (snapshotted from the same geometry the Scene tab uses). The sidebar no longer overlaps: the timeline starts to its right.</div>
<div class="rail" id="rail"></div>
</div>
<div id="panel">
<h1>“The cat sat and the sun shone”</h1>
<div class="sub">two independent steppers — serial reading (default) and scene</div>
<div class="tabs">
<div class="tab" id="tabScene">🌐 Scene</div>
<div class="tab active" id="tabSerial">📏 Serial reading</div>
</div>
<!-- SERIAL VIEW -->
<div class="view active" id="viewSerial">
<div class="cap" id="capSerial">Press <b>▶ step</b> — each word commits its models and items in reading order.</div>
<div class="btnrow"><button id="rPrev">◀</button><button id="rNext" class="primary">▶ step</button><button id="rAuto">⏩ auto</button></div>
<div class="note">Each word card in the timeline shows the <b>model snapshot</b> its commitment produces. Determiners (“the”) commit nothing — they stay <b style="color:var(--warn)">?</b>. “sat” is the compression star: one word, three commits.</div>
</div>
<!-- SCENE VIEW -->
<div class="view" id="viewScene">
<div class="cap" id="capScene">Scene stepper is independent — press <b>▶ step</b> to build the configuration item by item.</div>
<div class="btnrow"><button id="sPrev">◀</button><button id="sNext" class="primary">▶ step</button><button id="sAuto">⏩ auto</button></div>
<div class="section">
<div class="label">Ledger</div>
<div class="ledger" id="ledger">
<div class="item" data-i="1"><b>1 · ground_plane</b> — grass (implied by “sat”)</div>
<div class="item" data-i="2"><b>2 · form: cat</b> — orange tabby, stylized</div>
<div class="item" data-i="3"><b>3 · pose: sat</b> — haunches down, tail curled</div>
<div class="item" data-i="4"><b>4 · position</b> — left of center (frame decision)</div>
<div class="item" data-i="5"><b>5 · orient</b> — facing the sun (derived from 7)</div>
<div class="item" data-i="6"><b>6 · light_source: sun</b> — disc + warm light</div>
<div class="item" data-i="7"><b>7 · under-light-of</b> — coupling: shadow computed</div>
<div class="item" data-i="8"><b>8 · supported-by</b> — contact: haunches + paws</div>
</div>
</div>
<div class="section">
<div class="label">Unresolved</div>
<div class="unres">
<div>· “the (cat)” — <b style="color:var(--warn)">?</b> defaulted: tabby</div>
<div>· “the (sun)” — <b style="color:var(--warn)">?</b> defaulted: late afternoon</div>
<div>· ground material? — defaulted: grass</div>
<div>· aware of the sun? — defaulted: yes</div>
</div>
</div>
<div class="section">
<div class="label">Refinement (post-serial)</div>
<div class="ctrl"><label>Sun elevation <span id="elevV">35°</span></label><input type="range" id="elev" min="5" max="85" value="35"></div>
<div class="ctrl"><label>Sun azimuth <span id="azV">-40°</span></label><input type="range" id="az" min="-180" max="180" value="-40"></div>
<div class="ctrl"><label>Cat orientation</label>
<select id="facing">
<option value="toward">facing the sun</option>
<option value="away">back to the sun</option>
<option value="profile">profile to the sun</option>
</select>
</div>
</div>
</div>
</div>
<div id="hud">drag = orbit · scroll = zoom · ◀ ▶ keys step the active tab</div>
<script type="importmap">
{"imports":{
"three":"https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/":"https://unpkg.com/three@0.160.0/examples/jsm/"
}}
</script>
<script type="module">
import * as THREE from 'three';
import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
/* ================= reusable model builders (shared by scene + snapshots) ================= */
function matFur(){ return new THREE.MeshStandardMaterial({color:0xd9822b, roughness:0.9}); }
function matDark(){ return new THREE.MeshStandardMaterial({color:0xb5641c, roughness:0.9}); }
function shadowify(g){ g.traverse(o=>{ if(o.isMesh) o.castShadow = true; }); return g; }
function buildGround(){
const g = new THREE.Mesh(new THREE.CircleGeometry(40, 64),
new THREE.MeshStandardMaterial({color:0x4d7c3a, roughness:1}));
g.rotation.x = -Math.PI/2; g.receiveShadow = true; return g;
}
function buildGroundSmall(){
const g = new THREE.Mesh(new THREE.CircleGeometry(3.2, 48),
new THREE.MeshStandardMaterial({color:0x4d7c3a, roughness:1}));
g.rotation.x = -Math.PI/2; g.receiveShadow = true; return g;
}
function buildForm(){
const fur = matFur(), furDark = matDark();
const g = new THREE.Group();
const body = new THREE.Mesh(new THREE.SphereGeometry(0.75, 20, 16), fur);
body.scale.set(1, 1.25, 0.9); body.position.y = 0.85; g.add(body);
const chest = new THREE.Mesh(new THREE.SphereGeometry(0.5, 18, 14), fur);
chest.scale.set(0.9, 1.05, 0.7); chest.position.set(0, 0.95, 0.25); g.add(chest);
const head = new THREE.Mesh(new THREE.SphereGeometry(0.42, 20, 16), fur);
head.scale.set(1, 0.92, 0.95); head.position.set(0, 1.95, 0.18); g.add(head);
[-0.28, 0.28].forEach(x=>{
const ear = new THREE.Mesh(new THREE.ConeGeometry(0.13, 0.3, 4), furDark);
ear.position.set(x, 2.32, 0.15); g.add(ear);
});
[-0.16, 0.16].forEach(x=>{
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.055, 10, 10),
new THREE.MeshStandardMaterial({color:0x222a18, emissive:0x0a1400}));
eye.position.set(x, 2.0, 0.52); g.add(eye);
});
for(let i=0;i<3;i++){
const s = new THREE.Mesh(new THREE.TorusGeometry(0.72, 0.035, 6, 24, Math.PI*0.7), furDark);
s.position.set(0, 0.75+i*0.32, 0); s.rotation.x = Math.PI/2; s.rotation.z = Math.PI*0.15; g.add(s);
}
return shadowify(g);
}
function buildPose(){
const fur = matFur(), furDark = matDark();
const g = new THREE.Group();
[-0.62, 0.62].forEach(x=>{
const h = new THREE.Mesh(new THREE.SphereGeometry(0.34, 16, 12), fur);
h.scale.set(0.8, 1.1, 1.1); h.position.set(x, 0.5, -0.15); g.add(h);
});
[-0.28, 0.28].forEach(x=>{
const paw = new THREE.Mesh(new THREE.CapsuleGeometry(0.13, 0.3, 6, 10), fur);
paw.position.set(x, 0.16, 0.45); g.add(paw);
});
const curve = new THREE.CatmullRomCurve3([
new THREE.Vector3(0.15,0.55,-0.55), new THREE.Vector3(0.75,0.18,-0.25),
new THREE.Vector3(0.85,0.1,0.35), new THREE.Vector3(0.35,0.08,0.6)]);
g.add(new THREE.Mesh(new THREE.TubeGeometry(curve, 32, 0.09, 8, false), furDark));
return shadowify(g);
}
function buildSupport(){
const m = new THREE.MeshBasicMaterial({color:0x7ee0a3, transparent:true, opacity:0.75});
const g = new THREE.Group();
[[-0.28,0.45,0.14,0.2],[0.28,0.45,0.14,0.2],[-0.62,-0.15,0.2,0.27],[0.62,-0.15,0.2,0.27]].forEach(([x,z,r1,r2])=>{
const ring = new THREE.Mesh(new THREE.RingGeometry(r1, r2, 24), m);
ring.rotation.x = -Math.PI/2; ring.position.set(x, 0.02, z); g.add(ring);
});
return g;
}
function buildSunDisc(){
return new THREE.Mesh(new THREE.SphereGeometry(1.1, 24, 24), new THREE.MeshBasicMaterial({color:0xffd66b}));
}
/* ================= token spec (output format of parse_sentence_to_items) ================= */
const TOKENS = [
{word:'the', map:'determiner → ?', unresolved:true, model:'q', commits:[],
cap:'<b>“the”</b> — commits nothing; opens a <b style="color:var(--warn)">?</b>: WHICH cat? Reading skipped it; the ledger must not.'},
{word:'cat', map:'cat = model (form)', unresolved:false, model:'cat',
commits:['form: tabby model','+position default (frame)','unresolved: pose pending'],
cap:'<b>“cat”</b> — a model enters. Form defaulted: tabby; placed left of center (frame decision riding along). No pose yet — floating form.'},
{word:'sat', map:'sat → pose + implied surface + support', unresolved:false, model:'sat',
commits:['ground_plane (implied)','pose: sitting','supported-by: contact'],
cap:'<b>“sat”</b> — one word, THREE commits: surface exists (implied), pose becomes geometry, contact established. Serial reading exposes this compression.'},
{word:'and', map:'connector — serial join', unresolved:false, model:'and',
commits:[], cap:'<b>“and”</b> — no geometry. Its job is serial: binding two clauses into one conversion, promising a second subject.'},
{word:'the', map:'determiner → ?', unresolved:true, model:'q', commits:[],
cap:'<b>“the”</b> again — another <b style="color:var(--warn)">?</b>. WHICH sun? A second unresolved decision, symmetric with the first.'},
{word:'sun', map:'sun = model (light source)', unresolved:false, model:'sun',
commits:['light_source: disc + warm light'],
cap:'<b>“sun”</b> — the second model enters: a light source. Light exists — but is not yet <i>related to</i> the cat.'},
{word:'shone', map:'shone → relation: acting-on-scene', unresolved:false, model:'shone',
commits:['under-light-of: coupling ON','orient default: facing sun'],
cap:'<b>“shone”</b> — a verb of relation: the sun ACTS on the scene. Shadow now computed from sun↔cat geometry; cat faces the light. <b>Configuration complete.</b>'}
];
/* ================= snapshot renderer: static model images per word ================= */
const snapR = new THREE.WebGLRenderer({antialias:true, preserveDrawingBuffer:true});
snapR.setSize(300, 224);
snapR.shadowMap.enabled = true;
snapR.shadowMap.type = THREE.PCFSoftShadowMap;
function snapshot(build, camPos, lookAt){
const s = new THREE.Scene();
s.background = new THREE.Color(0x10131a);
const cam = new THREE.PerspectiveCamera(40, 300/224, 0.1, 200);
cam.position.set(...camPos); cam.lookAt(...lookAt);
s.add(new THREE.AmbientLight(0xffffff, 0.6));
const dl = new THREE.DirectionalLight(0xffffff, 2.2);
dl.position.set(4, 6, 5); dl.castShadow = true;
dl.shadow.mapSize.set(512, 512);
Object.assign(dl.shadow.camera, {left:-6, right:6, top:6, bottom:-6, far:40});
s.add(dl);
build(s);
snapR.render(s, cam);
return snapR.domElement.toDataURL();
}
function addShadowedLight(s, pos, color, intensity, target){
const dl = new THREE.DirectionalLight(color, intensity);
dl.position.set(...pos); dl.castShadow = true;
dl.shadow.mapSize.set(512, 512);
Object.assign(dl.shadow.camera, {left:-6, right:6, top:6, bottom:-6, far:40});
if(target) dl.target.position.set(...target);
s.add(dl); s.add(dl.target);
}
const SNAPSHOTS = {
ground: snapshot(s=>s.add(buildGroundSmall()), [3,4,5], [0,0,0]),
cat: snapshot(s=>s.add(buildForm()), [2.2,2.2,3.4], [0,1.1,0]),
sat: snapshot(s=>{
const d = buildGroundSmall(); s.add(d);
const c = new THREE.Group(); c.add(buildForm()); c.add(buildPose()); c.add(buildSupport()); s.add(c);
}, [2.6,2.4,3.8], [0,0.8,0]),
and: snapshot(s=>{
const a = new THREE.Mesh(new THREE.SphereGeometry(0.4,16,12), matFur()); a.position.x = -1.1;
const b = new THREE.Mesh(new THREE.SphereGeometry(0.4,16,12), new THREE.MeshBasicMaterial({color:0xffd66b})); b.position.x = 1.1;
const link = new THREE.Mesh(new THREE.CylinderGeometry(0.06,0.06,2.2,8), new THREE.MeshStandardMaterial({color:0x93a0b4}));
link.rotation.z = Math.PI/2;
s.add(a, b, link);
}, [0,0.6,5], [0,0,0]),
sun: snapshot(s=>s.add(buildSunDisc()), [0,0,4], [0,0,0]),
shone: snapshot(s=>{
s.add(buildGroundSmall());
const c = new THREE.Group(); c.add(buildForm()); c.add(buildPose()); c.add(buildSupport()); s.add(c);
const sun = buildSunDisc(); sun.position.set(4, 2.2, -3); sun.scale.setScalar(0.8); s.add(sun);
addShadowedLight(s, [4,2.2,-3], 0xffe3a3, 2.5, [0,0.6,0]);
}, [3.2,2.4,4.6], [0,0.7,0])
};
/* ================= tabs ================= */
const tabScene = document.getElementById('tabScene'), tabSerial = document.getElementById('tabSerial');
const viewScene = document.getElementById('viewScene'), viewSerial = document.getElementById('viewSerial');
function showTab(which){
tabScene.classList.toggle('active', which==='scene');
tabSerial.classList.toggle('active', which==='serial');
viewScene.classList.toggle('active', which==='scene');
viewSerial.classList.toggle('active', which==='serial');
document.getElementById('scene').style.visibility = which==='scene' ? 'visible' : 'hidden';
document.getElementById('reading').classList.toggle('active', which==='serial');
document.getElementById('hud').style.visibility = which==='scene' ? 'visible' : 'hidden';
}
tabScene.onclick = ()=>showTab('scene');
tabSerial.onclick = ()=>showTab('serial');
/* ================= build serial rail with models ================= */
const rail = document.getElementById('rail');
const tokEls = [];
TOKENS.forEach((t,i)=>{
const wrap = document.createElement('div'); wrap.className='tokwrap';
const tok = document.createElement('div'); tok.className='tok'+(t.unresolved?' q':''); tok.textContent=i+1;
const model = document.createElement('div');
if(t.model === 'q'){ model.className='model qbox'; model.textContent='?'; }
else { model.className='model'; const img = document.createElement('img'); img.src = SNAPSHOTS[t.model]; img.style.width='100%'; img.style.height='100%'; img.style.objectFit='cover'; img.style.borderRadius='9px'; model.appendChild(img); }
const word = document.createElement('div'); word.className='word'; word.textContent=t.word;
const map = document.createElement('div'); map.className='map'; map.textContent=t.map;
const commits = document.createElement('div'); commits.className='commits';
t.commits.forEach(c=>{
const chip = document.createElement('div');
chip.className='chip'+(c.startsWith('under')||c.startsWith('supported')?' rel':'');
chip.textContent=c; commits.appendChild(chip);
});
wrap.append(tok, model, word, map, commits);
rail.appendChild(wrap);
tokEls.push({wrap, tok, chips:[...commits.children]});
});
/* ================= main scene ================= */
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x2a3346);
scene.fog = new THREE.Fog(0x2a3346, 40, 120);
const camera = new THREE.PerspectiveCamera(50, innerWidth/innerHeight, 0.1, 300);
camera.position.set(6, 3.4, 8);
const renderer = new THREE.WebGLRenderer({antialias:true});
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.getElementById('scene').appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0.8, 0);
controls.enableDamping = true;
const ground = buildGround(); ground.visible = false; scene.add(ground);
const cat = new THREE.Group();
const formG = buildForm(), poseG = buildPose(), supG = buildSupport();
formG.visible = false; poseG.visible = false; supG.visible = false;
cat.add(formG, poseG, supG);
cat.visible = false;
scene.add(cat);
const sunDisc = buildSunDisc(); sunDisc.scale.setScalar(2); sunDisc.visible = false; scene.add(sunDisc);
const sunLight = new THREE.DirectionalLight(0xffe3a3, 0);
sunLight.castShadow = false;
sunLight.shadow.mapSize.set(2048, 2048);
Object.assign(sunLight.shadow.camera, {left:-12, right:12, top:12, bottom:-12, far:80});
scene.add(sunLight);
const ambient = new THREE.AmbientLight(0x8098c0, 0.85); scene.add(ambient);
/* ================= SERIAL stepper (independent) ================= */
let serialStep = 0; // 0..7 tokens committed
function applySerial(){
tokEls.forEach((el,i)=>{
el.tok.classList.toggle('committed', i < serialStep);
el.tok.classList.toggle('active', i === serialStep-1);
el.wrap.classList.toggle('committed', i < serialStep);
el.wrap.classList.toggle('active', i === serialStep-1);
el.chips.forEach(c=>c.classList.toggle('on', i < serialStep));
});
document.getElementById('capSerial').innerHTML = serialStep === 0
? 'Press <b>▶ step</b> — each word commits its models and items in reading order.'
: TOKENS[serialStep-1].cap;
document.getElementById('rPrev').disabled = serialStep === 0;
document.getElementById('rNext').disabled = serialStep === 7;
}
function gotoSerial(n){ serialStep = Math.max(0, Math.min(7, n)); applySerial(); }
/* ================= SCENE stepper (independent) ================= */
let sceneStep = 0; // 0..8 items
const catHome = new THREE.Vector3(0,0,0), catPlaced = new THREE.Vector3(-0.6,0,0);
const targetPos = catHome.clone();
let targetRotY = 0;
const SCENE_CAPS = {
0:'Scene stepper is independent — press <b>▶ step</b> to build the configuration item by item.',
1:'<b>1 · ground_plane</b> — “sat” forces a surface. Defaulted: grass.',
2:'<b>2 · form: cat</b> — a body exists. No pose yet: floating form.',
3:'<b>3 · pose: sat</b> — haunches drop, tail curls. “Sat” became geometry.',
4:'<b>4 · position</b> — frame decision: left of center.',
5:'<b>5 · orient</b> — facing the sun (default derived from item 7).',
6:'<b>6 · light_source: sun</b> — light enters, sky turns. Not yet coupled.',
7:'<b>7 · under-light-of</b> — coupling ON: shadow computed from relations.',
8:'<b>8 · supported-by</b> — contact marked. Configuration complete.'
};
function applyScene(){
ground.visible = sceneStep >= 1;
cat.visible = sceneStep >= 2;
formG.visible = sceneStep >= 2;
poseG.visible = sceneStep >= 3;
targetPos.copy(sceneStep >= 4 ? catPlaced : catHome);
const sunOn = sceneStep >= 6;
sunDisc.visible = sunOn; sunLight.intensity = sunOn ? 3.0 : 0;
sunLight.castShadow = sceneStep >= 7;
scene.background.set(sunOn ? 0x87b5e0 : 0x2a3346);
scene.fog.color.set(sunOn ? 0x87b5e0 : 0x2a3346);
ambient.intensity = sunOn ? 0.55 : 0.85;
supG.visible = sceneStep >= 8;
document.querySelectorAll('#ledger .item[data-i]').forEach(el=>{
const i = +el.dataset.i;
el.classList.toggle('done', i <= sceneStep);
el.classList.toggle('active', i === sceneStep);
});
document.getElementById('capScene').innerHTML = SCENE_CAPS[sceneStep];
document.getElementById('sPrev').disabled = sceneStep === 0;
document.getElementById('sNext').disabled = sceneStep === 8;
}
function gotoScene(n){ sceneStep = Math.max(0, Math.min(8, n)); applyScene(); applyConfiguration(); }
/* ================= wiring ================= */
document.getElementById('rNext').onclick = ()=>gotoSerial(serialStep+1);
document.getElementById('rPrev').onclick = ()=>gotoSerial(serialStep-1);
document.getElementById('sNext').onclick = ()=>gotoScene(sceneStep+1);
document.getElementById('sPrev').onclick = ()=>gotoScene(sceneStep-1);
let autoS = null, autoR = null;
document.getElementById('rAuto').onclick = ()=>{
if(autoR){ clearInterval(autoR); autoR=null; document.getElementById('rAuto').textContent='⏩ auto'; return; }
if(serialStep >= 7) gotoSerial(0);
document.getElementById('rAuto').textContent='⏸ stop';
autoR = setInterval(()=>{ if(serialStep >= 7){ clearInterval(autoR); autoR=null; document.getElementById('rAuto').textContent='⏩ auto'; return; } gotoSerial(serialStep+1); }, 1700);
};
document.getElementById('sAuto').onclick = ()=>{
if(autoS){ clearInterval(autoS); autoS=null; document.getElementById('sAuto').textContent='⏩ auto'; return; }
if(sceneStep >= 8) gotoScene(0);
document.getElementById('sAuto').textContent='⏸ stop';
autoS = setInterval(()=>{ if(sceneStep >= 8){ clearInterval(autoS); autoS=null; document.getElementById('sAuto').textContent='⏩ auto'; return; } gotoScene(sceneStep+1); }, 1400);
};
addEventListener('keydown', e=>{
const active = tabSerial.classList.contains('active') ? 'serial' : 'scene';
if(e.key==='ArrowRight') active==='serial' ? gotoSerial(serialStep+1) : gotoScene(sceneStep+1);
if(e.key==='ArrowLeft') active==='serial' ? gotoSerial(serialStep-1) : gotoScene(sceneStep-1);
});
/* refinement controls */
const DIST = 45;
function applyConfiguration(){
const elev = THREE.MathUtils.degToRad(+document.getElementById('elev').value);
const az = THREE.MathUtils.degToRad(+document.getElementById('az').value);
const dir = new THREE.Vector3(Math.cos(elev)*Math.sin(az), Math.sin(elev), Math.cos(elev)*Math.cos(az));
sunDisc.position.copy(dir).multiplyScalar(DIST);
sunLight.position.copy(dir).multiplyScalar(20);
sunLight.target.position.set(targetPos.x, 0.5, targetPos.z);
scene.add(sunLight.target);
document.getElementById('elevV').textContent = document.getElementById('elev').value + '°';
document.getElementById('azV').textContent = document.getElementById('az').value + '°';
}
const FACINGS = {toward:180, away:0, profile:90};
function applyFacing(){
const v = document.getElementById('facing').value;
targetRotY = THREE.MathUtils.degToRad(FACINGS[v] - document.getElementById('az').value);
}
['elev','az'].forEach(id=>document.getElementById(id).addEventListener('input', applyConfiguration));
document.getElementById('facing').addEventListener('change', applyFacing);
applyConfiguration(); applyFacing(); applySerial(); applyScene();
addEventListener('resize', ()=>{
camera.aspect = innerWidth/innerHeight; camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
(function animate(){
requestAnimationFrame(animate);
cat.position.lerp(targetPos, 0.08);
cat.rotation.y += (targetRotY - cat.rotation.y) * 0.08;
controls.update();
renderer.render(scene, camera);
})();
</script>
</body>
</html>