Code4Scene / assets /scene-cards.js
KoeYe's picture
Claude Opus 5.5
Industrial Harbor card on the same live three.js viewer as the other cases
e998ba1
Raw History Blame Contribute Delete
24 kB
import * as THREE from "three";
import {AssetQueue,fetchSceneBytes} from "./scene-assets.js?v=20261002a";
import {loadProgressive} from "./progressive-gltf.js?v=20261002a";
import {Sky} from "three/addons/objects/Sky.js";
"use strict";
// The 3D engine: one shared WebGL renderer, one scene per loaded submission,
// and a Viewer per canvas that keeps the same interface the prototype's
// procedural viewer had (rotationX / rotationY / zoom / paused / group /
// draw(dt) / destroy()), so the page code above it did not have to change.
//
// Existing public Code4Scene geometry with progressively loaded card textures.
(function () {
const activeViewers = new Set();
window.activeViewers = activeViewers;
let lastFrame = performance.now();
/* ------------------------------------------------------------ renderer */
let gl = null, ENV = null, contextLost = false, restoreTimer = null;
function renderer() {
if (gl) return gl;
gl = new THREE.WebGLRenderer({ antialias: true, alpha: true, premultipliedAlpha: true, powerPreference: "high-performance" });
gl.setPixelRatio(1);
gl.domElement.addEventListener('webglcontextlost',event=>{
event.preventDefault();contextLost=true;
activeViewers.forEach(v=>{if(v.visible)v.status('Restoring 3D graphics…','restoring');});
clearTimeout(restoreTimer);restoreTimer=setTimeout(()=>activeViewers.forEach(v=>{if(v.visible)v.fail(new Error('3D graphics were interrupted.'),'graphics');}),10000);
});
gl.domElement.addEventListener('webglcontextrestored',()=>{
clearTimeout(restoreTimer);contextLost=false;
try{makeEnvironment();for(const e of assets.entries.values())if(e.asset)e.asset.scene.environment=ENV;activeViewers.forEach(v=>{v.dirty=true;v.hasDrawn=false;});}
catch(error){activeViewers.forEach(v=>v.fail(error,'graphics'));}
});
gl.outputColorSpace = THREE.SRGBColorSpace;
gl.toneMapping = THREE.ACESFilmicToneMapping; gl.toneMappingExposure = 1.0;
gl.shadowMap.enabled = true;
gl.shadowMap.type = THREE.PCFSoftShadowMap;
gl.setClearColor(0x000000, 0);
makeEnvironment();
return gl;
}
function makeEnvironment(){
ENV?.dispose();
const sky=new Sky();sky.scale.setScalar(1000);const u=sky.material.uniforms;u.turbidity.value=4;u.rayleigh.value=1.4;u.mieCoefficient.value=.004;u.mieDirectionalG.value=.8;u.sunPosition.value.setFromSphericalCoords(1,THREE.MathUtils.degToRad(52),THREE.MathUtils.degToRad(215));const skyScene=new THREE.Scene();skyScene.add(sky);const pmrem=new THREE.PMREMGenerator(gl);ENV=pmrem.fromScene(skyScene,0,.1,2000).texture;pmrem.dispose();sky.geometry.dispose();sky.material.dispose();
}
function recoverRenderer(){
clearTimeout(restoreTimer);contextLost=false;
if(gl){gl.dispose();gl=null;}ENV?.dispose();ENV=null;renderer();
for(const e of assets.entries.values())if(e.asset)e.asset.scene.environment=ENV;
activeViewers.forEach(v=>{v.dirty=true;v.hasDrawn=false;v.failed=null;});
}
// The benchmark's own light rig (city_bench.building_light/1), the numbers
// the board's renders use: sun in the south-east at 50 degrees, a pale sky,
// and a muted environment. One rig for the page, moved into whichever scene
// is being drawn.
const RIG = (window.BB_DATA && window.BB_DATA.rig) || {};
const RIG_GROUP = new THREE.Group();
const SUN = new THREE.DirectionalLight(0xffffff, RIG.sun_intensity ?? 3.2);
SUN.color.setRGB(...(RIG.sun_colour || [1, 0.97, 0.92]));
SUN.castShadow = true;
SUN.shadow.mapSize.set(1024, 1024);
SUN.shadow.bias = -0.0006;
SUN.shadow.normalBias = 0.02;
const SKY = new THREE.HemisphereLight(0xbcd4f0, 0x6b6157, RIG.sky_intensity ?? 0.45);
SKY.color.setRGB(...(RIG.sky_colour || [0.7, 0.8, 0.94]));
SKY.groundColor.setRGB(...(RIG.ground_colour || [0.36, 0.34, 0.31]));
// A ground that shows only the shadow that lands on it, so the page's own
// background reads through everywhere else.
const GROUND = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.ShadowMaterial({ opacity: 0.22, transparent: true }));
GROUND.rotation.x = -Math.PI / 2;
GROUND.receiveShadow = true;
RIG_GROUP.add(SUN, SUN.target, SKY);
function rigFor(asset) {
const reach = Math.max(RIG.reach_m || 400, asset.radius * 1.6);
const az = THREE.MathUtils.degToRad(RIG.sun_azimuth_deg ?? 135), el = THREE.MathUtils.degToRad(RIG.sun_elevation_deg ?? 50);
SUN.position.set(Math.cos(el) * Math.sin(az), Math.sin(el), -Math.cos(el) * Math.cos(az)).multiplyScalar(reach).add(asset.centre);
SUN.target.position.copy(asset.centre);
const s = SUN.shadow.camera, r = asset.radius * 1.15;
s.left = -r; s.right = r; s.top = r; s.bottom = -r; s.near = 1; s.far = reach * 3;
s.updateProjectionMatrix();
GROUND.position.set(asset.centre.x, asset.floor, asset.centre.z);
GROUND.scale.set(asset.radius * 8, asset.radius * 8, 1);
asset.scene.add(RIG_GROUP);
}
function disposeAsset(asset){
if(!asset)return;asset.materials?.dispose();asset.scene.remove(RIG_GROUP);const geometries=new Set(),materials=new Set(),textures=new Set();
asset.scene.traverse(n=>{if(n.geometry)geometries.add(n.geometry);for(const m of [].concat(n.material||[])){materials.add(m);for(const v of Object.values(m))if(v?.isTexture)textures.add(v);}});
geometries.forEach(g=>g.dispose());textures.forEach(t=>{t.dispose();t.source?.data?.close?.();});materials.forEach(m=>m.dispose());
}
function assetProfile(root){
const buffers=new Set(),images=new Set();let geometry=0,textures=0,maxTexture=0;
root.traverse(n=>{if(n.geometry){for(const a of [...Object.values(n.geometry.attributes),n.geometry.index].filter(Boolean)){const buffer=(a.array||a.data?.array)?.buffer;if(buffer&&!buffers.has(buffer)){buffers.add(buffer);geometry+=buffer.byteLength;}}}for(const m of [].concat(n.material||[]))for(const t of Object.values(m))if(t?.isTexture){const image=t.source?.data;if(image&&!images.has(image)){images.add(image);textures+=(image.width||0)*(image.height||0)*4*1.34;maxTexture=Math.max(maxTexture,image.width||0,image.height||0);}}});
return {geometry,textures,maxTexture,bytes:geometry+textures};
}
const assets=new AssetQueue({
maxConcurrent:2,maxIdle:1,maxBytes:128*1024*1024,dispose:disposeAsset,
onChange:s=>{document.documentElement.dataset.sceneJobs=String(s.running);document.documentElement.dataset.scenePeakJobs=String(s.peak);document.documentElement.dataset.sceneCached=String(s.cached);document.documentElement.dataset.sceneBytes=String(Math.round(s.bytes));},
load:async(key,signal,progress)=>{
const gltf=await loadProgressive(key,{signal,onProgress:progress});
if(signal.aborted){disposeAsset({scene:gltf.scene,materials:gltf.materials});signal.throwIfAborted();}
const root = gltf.scene;
root.traverse(node => { if (node.isMesh) { node.castShadow = true; node.receiveShadow = true; } });
const box = new THREE.Box3(), pts=[];
root.traverse(o=>{if(o.isMesh){const b=new THREE.Box3().setFromObject(o);if(!b.isEmpty())pts.push(b);for(const m of [].concat(o.material||[]))if(m.specularIntensity!==undefined)m.specularIntensity=Math.min(m.specularIntensity,.5);}});
const q=(arr,t)=>arr.slice().sort((a,b)=>a-b)[Math.floor(t*(arr.length-1))];
if(pts.length>8){const cx=pts.map(b=>(b.min.x+b.max.x)/2),cy=pts.map(b=>(b.min.y+b.max.y)/2),cz=pts.map(b=>(b.min.z+b.max.z)/2);box.set(new THREE.Vector3(q(cx,.03),q(cy,.03),q(cz,.03)),new THREE.Vector3(q(cx,.97),q(cy,.97),q(cz,.97)));}else box.setFromObject(root);
const centre = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const corners = [];
for (const x of [box.min.x, box.max.x]) for (const y of [box.min.y, box.max.y]) for (const z of [box.min.z, box.max.z]) corners.push(new THREE.Vector3(x, y, z));
const scene = new THREE.Scene();
scene.environment = ENV || (renderer(), ENV);
scene.environmentIntensity = RIG.environment_intensity ?? 0.8;
scene.background = new THREE.Color(0xe6edf0); scene.add(root);
const task=window.SITE.cases.find(c=>c.track==='i2s'&&(c.input_glb===key||c.gt_glb===key||c.agents.some(a=>a.glb===key)));
if(task){root.updateMatrixWorld(true);root.traverse(o=>{const duplicate=(task.duplicates||[]).includes(o.name);if(!duplicate&&!(task.targets||[]).includes(o.name))return;const b=new THREE.Box3().setFromObject(o);if(b.isEmpty())return;const h=new THREE.Box3Helper(b.expandByScalar(.08),duplicate?0xff6b5e:0x5ee08a);h.material.depthTest=false;h.material.transparent=true;h.renderOrder=999;scene.add(h);});}
return {key,root,scene,materials:gltf.materials,box,centre,corners,radius:Math.max(size.length()/2,1),floor:box.min.y,...assetProfile(root)};
}
});
/* -------------------------------------------------------------- viewer */
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const reduced = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;
class Viewer {
constructor(canvas, options = {}) {
this.canvas = canvas; this.element=canvas.closest(".live-scene");this.visible=false;this.dirty=true;this.handle=null;this.key=null;this.generation=0;
this.ctx = canvas.getContext("2d", { alpha: true });
this.rotationY = options.rotationY ?? -0.65;
this.rotationX = options.rotationX ?? 0.35;
this.zoom = options.zoom ?? 1;
this.centerX = options.centerX ?? 0.5;
this.speed = options.speed ?? 0.22;
this.group = options.group || null;
this.refCam = options.refCam; this.paused = !!options.refCam; this.dragging = false; this.lastPointer = null;
this.destroyed = false; this.hasDrawn = false; this.failed = null;
this.initialRotation = [this.rotationX,this.rotationY];
this.camera = new THREE.PerspectiveCamera(36, 1, 0.1, 5000);
this.entry = null; this.asset = null;
this.bind();
activeViewers.add(this);
if (options.assetKey) this.key=options.assetKey;
}
status(text,state){
this.element.dataset.statePhase=state;this.canvas.dataset.rendered='false';
let box=this.element.querySelector('.scene-loading');if(!box){box=document.createElement('div');box.className='scene-loading';box.setAttribute('role','status');this.element.append(box);}
if(box.textContent!==text)box.textContent=text;
}
fail(error,kind='asset'){
if(!this.visible)return;this.failed=error;this.failureKind=kind;this.status(kind==='graphics'?'3D graphics were interrupted.':'Could not load this scene.','error');
const box=this.element.querySelector('.scene-loading');box.title=error?.message||'';
const retry=document.createElement('button');retry.className='scene-retry';retry.textContent='Retry scene';retry.onclick=()=>{this.failed=null;if(this.failureKind==='graphics'){try{recoverRenderer();this.status('Preparing 3D scene…','decoding');}catch(e){this.fail(e,'graphics');}}else this.load(this.key);};box.append(retry);
}
load(key){
this.key=key;this.generation++;this.handle?.release();this.handle=null;this.asset=null;this.failed=null;this.hasDrawn=false;this.dirty=true;
if(!this.visible)return;
const generation=this.generation;this.status('Waiting for a download slot…','queued');
this.handle=assets.retain(key,entry=>{
if(this.destroyed||!this.visible||generation!==this.generation)return;
if(entry.state==='ready'){this.asset=entry.asset;this.element.dataset.sceneMb=(entry.bytes/1048576).toFixed(1);this.element.dataset.sceneGeometryMb=(entry.asset.geometry/1048576).toFixed(1);this.element.dataset.sceneTextureMb=(entry.asset.textures/1048576).toFixed(1);this.element.dataset.sceneMaxTexture=String(entry.asset.maxTexture);this.hasDrawn=false;this.dirty=true;this.status('Preparing 3D scene…','rendering');}
else if(entry.state==='error')this.fail(entry.error);
else if(entry.state==='downloading'){const text=entry.total?Math.min(100,Math.round(entry.received/entry.total*100))+'%':(entry.received/1048576).toFixed(1)+' MB';this.status('Downloading scene · '+text,'downloading');}
else if(entry.state==='decoding')this.status('Preparing scene geometry…','decoding');
else if(entry.state==='retrying')this.status('Connection interrupted · retrying…','retrying');
},()=>Math.abs(this.canvas.getBoundingClientRect().top));
}
setVisible(visible){
if(this.visible===visible)return;this.visible=visible;
if(visible){this.load(this.key);}
else{this.generation++;this.handle?.release();this.handle=null;this.asset=null;this.element.dataset.statePhase='paused';}
}
bind() {
this.onDown = e => { this.paused = true; this.dragging = true; this.lastPointer = [e.clientX, e.clientY]; this.canvas.setPointerCapture?.(e.pointerId); };
this.onMove = e => { if (!this.dragging) return; this.dirty=true; const dx = e.clientX - this.lastPointer[0], dy = e.clientY - this.lastPointer[1]; this.rotationY += dx * .012; this.rotationX = clamp(this.rotationX + dy * .008, -.05, 1.3); this.lastPointer = [e.clientX, e.clientY]; if (this.group) { this.group.rotationX = this.rotationX; this.group.rotationY = this.rotationY; } };
this.onUp = () => { this.dragging = false; this.lastPointer = null; };
this.onWheel = e => { e.preventDefault();this.dirty=true; this.zoom = clamp(this.zoom - e.deltaY * .0007, .6, 2.2); if (this.group) this.group.zoom = this.zoom; };
this.onKey = e => { const keys = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "+", "=", "-"]; if (!keys.includes(e.key)) return; e.preventDefault(); this.dirty=true; this.paused = true; if (e.key === "ArrowLeft") this.rotationY -= .12; if (e.key === "ArrowRight") this.rotationY += .12; if (e.key === "ArrowUp") this.rotationX = clamp(this.rotationX + .1, -.05, 1.3); if (e.key === "ArrowDown") this.rotationX = clamp(this.rotationX - .1, -.05, 1.3); if (e.key === "+" || e.key === "=") this.zoom = clamp(this.zoom + .08, .6, 2.2); if (e.key === "-") this.zoom = clamp(this.zoom - .08, .6, 2.2); if (this.group) Object.assign(this.group, { rotationX: this.rotationX, rotationY: this.rotationY, zoom: this.zoom, paused: true }); };
this.canvas.addEventListener("wheel", this.onWheel, { passive: false }); this.canvas.addEventListener("keydown", this.onKey);
this.canvas.addEventListener("pointerdown", this.onDown); this.canvas.addEventListener("pointermove", this.onMove); this.canvas.addEventListener("pointerup", this.onUp); this.canvas.addEventListener("pointercancel", this.onUp);
}
resize() {
const rect = this.canvas.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 1.5, 960/Math.max(1,rect.width),600/Math.max(1,rect.height));
const w = Math.max(1, Math.round(rect.width * dpr)), h = Math.max(1, Math.round(rect.height * dpr));
if (this.canvas.width !== w || this.canvas.height !== h) { this.canvas.width = w; this.canvas.height = h;this.dirty=true; }
this.w = rect.width; this.h = rect.height; this.dpr = dpr;
}
// Orbit the model's centre. The distance is fitted to the bounding box
// seen from this direction, not to the sphere, so a wide flat building
// fills its card the way a tower does.
aim() {
const a = this.asset, cam = this.camera;
if(this.refCam){
const rc=this.refCam,target=new THREE.Vector3(...rc.target),offset=new THREE.Vector3(...rc.pos).sub(target);
const spherical=new THREE.Spherical().setFromVector3(offset);
spherical.theta += this.rotationY-this.initialRotation[1]; spherical.phi -= this.rotationX-this.initialRotation[0]; spherical.makeSafe(); spherical.radius /= this.zoom;
cam.aspect=this.w/this.h;cam.fov=2*Math.atan(Math.tan(rc.hfov*Math.PI/360)/cam.aspect)*180/Math.PI;
cam.near=.05;cam.far=Math.max(2000,a.radius*24);cam.position.copy(target).add(new THREE.Vector3().setFromSpherical(spherical));cam.lookAt(target);cam.updateProjectionMatrix();return;
}
const az = this.rotationY, el = this.rotationX;
const dir = new THREE.Vector3(Math.cos(el) * Math.sin(az), Math.sin(el), Math.cos(el) * Math.cos(az)).normalize();
const aspect = this.w / this.h;
cam.aspect = aspect; cam.fov = 34;
const vfov = THREE.MathUtils.degToRad(cam.fov), tanV = Math.tan(vfov / 2), tanH = tanV * aspect;
const probe = new THREE.Object3D();
probe.position.copy(a.centre).add(dir); probe.up.set(0, 1, 0); probe.lookAt(a.centre); probe.updateMatrixWorld();
const right = new THREE.Vector3().setFromMatrixColumn(probe.matrixWorld, 0);
const up = new THREE.Vector3().setFromMatrixColumn(probe.matrixWorld, 1);
let dist = 0;
for (const c of a.corners) { const q = c.clone().sub(a.centre); const z = q.dot(dir); dist = Math.max(dist, z + Math.abs(q.dot(right)) / tanH, z + Math.abs(q.dot(up)) / tanV); }
dist = Math.max(dist * 1.08, a.radius * 0.2) / this.zoom;
const target = a.centre.clone().addScaledVector(right, -(this.centerX - 0.5) * 2 * dist * tanH);
cam.near = Math.max(0.1, dist / 200); cam.far = Math.max(2000, dist * 20 + a.radius * 4);
cam.position.copy(target).addScaledVector(dir, dist);
cam.up.set(0, 1, 0); cam.lookAt(target); cam.updateProjectionMatrix();
}
draw(dt) {
if (this.destroyed || !this.visible || !this.canvas.isConnected || contextLost || this.failed) return;
const rect = this.canvas.getBoundingClientRect();
if (rect.width < 2 || rect.height < 2 || document.hidden) return;
if (rect.bottom < 0 || rect.top > window.innerHeight) return;
this.resize();
if(this.asset&&this.hasDrawn){const asset=this.asset;asset.materials?.start(state=>{
for(const v of activeViewers)if(v.asset===asset){v.dirty=true;v.textureStatus();}
if(state.loaded===state.total||state.loaded%32===0){const e=assets.entries.get(asset.key);if(e){Object.assign(asset,assetProfile(asset.root));e.bytes=asset.bytes;assets.changed();for(const v of activeViewers)if(v.asset===asset){v.element.dataset.sceneMb=(asset.bytes/1048576).toFixed(1);v.element.dataset.sceneTextureMb=(asset.textures/1048576).toFixed(1);v.element.dataset.sceneMaxTexture=String(asset.maxTexture);}}}
});}
if(this.hasDrawn&&!this.dirty&&(this.paused||reduced()))return;
if (this.group) { this.rotationX = this.group.rotationX; this.rotationY = this.group.rotationY; this.zoom = this.group.zoom; }
if (!this.group && !this.paused && !this.dragging && !reduced()) this.rotationY += this.speed * dt;
const ctx = this.ctx, W = this.canvas.width, H = this.canvas.height;
if (!this.asset) {
if (this.hasDrawn) return;
ctx.clearRect(0, 0, W, H);
if (this.failed) { ctx.fillStyle = "#93673f"; ctx.font = `${Math.max(9, Math.round(10 * this.dpr))}px Arial`; ctx.textAlign = "center"; ctx.fillText("mesh unavailable", W / 2, H / 2); this.hasDrawn = true; }
return;
}
const g = renderer();
if(g.domElement.width!==W||g.domElement.height!==H)g.setSize(W,H,false);
this.aim();
rigFor(this.asset);
g.render(this.asset.scene, this.camera);
ctx.clearRect(0, 0, W, H);
ctx.drawImage(g.domElement, 0, 0, W, H, 0, 0, W, H);
this.hasDrawn = true; this.dirty=false;this.element.dataset.statePhase="ready"; this.canvas.dataset.rendered="true"; this.canvas.parentElement.querySelector(".scene-loading")?.remove();this.textureStatus();
}
textureStatus(){
if(!this.asset?.materials)return;const s=this.asset.materials.status;this.element.dataset.texturesLoaded=String(s.loaded);this.element.dataset.texturesTotal=String(s.total);
let badge=this.element.querySelector('.scene-texture-status');
if(s.loaded===s.total){badge?.remove();return;}
if(!badge){badge=document.createElement('div');badge.className='scene-texture-status';badge.setAttribute('role','status');this.element.append(badge);}
const signature=s.loaded+'/'+s.total+'/'+s.failed;if(badge.dataset.signature===signature)return;badge.dataset.signature=signature;badge.replaceChildren(document.createTextNode('Textures '+s.loaded+' / '+s.total));
if(s.failed){const retry=document.createElement('button');retry.textContent='Retry textures';retry.onclick=()=>{this.asset?.materials.retry();this.dirty=true;};badge.append(retry);}
}
destroy() {
this.destroyed = true; activeViewers.delete(this);
this.handle?.release(); this.handle=null;this.asset=null;
this.canvas.removeEventListener("pointerdown", this.onDown); this.canvas.removeEventListener("pointermove", this.onMove); this.canvas.removeEventListener("pointerup", this.onUp); this.canvas.removeEventListener("pointercancel", this.onUp); this.canvas.removeEventListener("wheel", this.onWheel); this.canvas.removeEventListener("keydown", this.onKey);
}
}
window.GLBViewer = Viewer;
window.destroyViewersWithin = root => { activeViewers.forEach(v => { if (root.contains(v.canvas)) v.destroy(); }); };
window.BB_ENGINE = { renderer };
/* ------------------------------------------------------------- the clock */
function loop(now) {
requestAnimationFrame(loop);
if (now - lastFrame < 32) return;
const dt = Math.min(.05, (now - lastFrame) / 1000); lastFrame = now;
const groups = new Set([...activeViewers].filter(v => v.group && !v.destroyed).map(v => v.group));
if (!document.hidden && !reduced()) groups.forEach(g => { if (!g.paused && ![...activeViewers].some(v => v.group === g && v.dragging)) g.rotationY += dt * .14; });
activeViewers.forEach(v => { try { v.draw(dt); } catch (err) { v.fail(err,'graphics');console.error('Code4Scene: viewer draw failed',err); } });
}
requestAnimationFrame(loop);
})();
// Visibility owns the asset subscription; hidden task panels and offscreen cards release resources.
const viewers=new Map();
function syncMotion(viewer,button){button.textContent=viewer.paused?'▶':'Ⅱ';button.setAttribute('aria-label',viewer.paused?'Resume scene rotation':'Pause scene rotation');}
function mount(el){
// a showcase card names its model itself (data-glb): it has no case, score or case-explorer entry in data/site.js
const c=el.dataset.glb?{agents:[{key:el.dataset.agent,glb:el.dataset.glb}]}:window.SITE.cases.find(c=>c.id===el.dataset.case),a=c.agents.find(a=>a.key===el.dataset.agent);
const url=state=>state==='input'?c.input_glb:state==='gt'?c.gt_glb:a.glb;
const viewer=new window.GLBViewer(el.querySelector('canvas'),{assetKey:url(el.dataset.state||'repair'),refCam:c.ref_cam,rotationX:.55,rotationY:-.62,speed:.1});viewers.set(el,viewer);
const pause=el.querySelector('.scene-motion');syncMotion(viewer,pause);pause.addEventListener('click',()=>{viewer.paused=!viewer.paused;viewer.dirty=true;syncMotion(viewer,pause);});
el.querySelector('canvas').addEventListener('pointerdown',()=>syncMotion(viewer,pause));
el.parentElement.querySelectorAll(':scope > .scene-versions button').forEach(b=>b.addEventListener('click',()=>{
const state=b.dataset.state;el.dataset.state=state;el.parentElement.querySelectorAll(':scope > .scene-versions button').forEach(x=>{x.classList.toggle('on',x===b);x.setAttribute('aria-pressed',String(x===b));});
el.querySelector('.scene-hint').textContent=b.textContent+' · Drag to orbit';viewer.load(url(state));
}));return viewer;
}
const observer=new IntersectionObserver(entries=>entries.forEach(({target,isIntersecting})=>{const viewer=viewers.get(target)||mount(target);viewer.setVisible(isIntersecting&&!target.closest('[hidden]'));}),{rootMargin:'40px',threshold:0});
document.querySelectorAll('.live-scene').forEach(el=>observer.observe(el));
document.addEventListener('taskchange',()=>{for(const[el,v]of viewers){if(el.closest('[hidden]'))v.setVisible(false);else{const r=el.getBoundingClientRect();v.setVisible(r.width>0&&r.bottom>0&&r.top<innerHeight+40);}}});