Spaces:
Running
Running
Download assets/scene-cards.js from SimWorld-AI/Code4Scene: direct link, hf CLI and curl.
- Browser
- Download file 24 kB
-
https://huggingface.co/spaces/SimWorld-AI/Code4Scene/resolve/main/assets/scene-cards.js
- Command line
-
hf download hf://spaces/SimWorld-AI/Code4Scene/assets/scene-cards.js
-
curl -L -o scene-cards.js https://huggingface.co/spaces/SimWorld-AI/Code4Scene/resolve/main/assets/scene-cards.js
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"; | |
| ; | |
| // 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);}}}); | |