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