/** Standard Three.js backend. UI and catalogue never depend on THREE internals. */ export async function createThreeRenderer({canvas,gltf,binary,bytes,config}) { let timer; const modules=Promise.all([import(config.threeModule),import(config.threeLoader)]); const [T,{GLTFLoader}]=await Promise.race([modules,new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('Three.js CDN timeout')),config.cdnTimeoutMs||6500);})]).finally(()=>clearTimeout(timer)); const loader=new GLTFLoader(); const documentModel=await loader.parseAsync(bytes.buffer.slice(bytes.byteOffset,bytes.byteOffset+bytes.byteLength),''); const scene=new T.Scene();scene.add(documentModel.scene); const renderer=new T.WebGLRenderer({canvas,antialias:true,alpha:false,preserveDrawingBuffer:true,powerPreference:'high-performance'}); renderer.outputColorSpace=T.SRGBColorSpace; renderer.toneMapping=T.ACESFilmicToneMapping;renderer.toneMappingExposure=1.05; renderer.shadowMap.enabled=true;renderer.shadowMap.type=T.PCFSoftShadowMap;renderer.shadowMap.autoUpdate=false; const ambient=new T.AmbientLight(0xffffff,.7); const skyLight=new T.HemisphereLight(0xf5f8ff,0x67604e,1.7); const sun=new T.DirectionalLight(0xffffff,3.0); const fill=new T.HemisphereLight(0xfff5df,0x77716a,0); scene.add(ambient,skyLight,sun,sun.target,fill); sun.castShadow=true;sun.shadow.mapSize.set(2048,2048);sun.shadow.bias=-.00008;sun.shadow.normalBias=.025; const persp=new T.PerspectiveCamera(),ortho=new T.OrthographicCamera(); const textures=await Promise.all((gltf.textures||[]).map((_,i)=>documentModel.parser.getDependency('texture',i))); const images=[]; for (let i=0;i{ if(!obj.isMesh)return; const association=associations.get(obj)||{}; let parent=obj,nodeIndex=association.nodes; while(nodeIndex===undefined&&parent){const a=associations.get(parent);if(a?.nodes!==undefined)nodeIndex=a.nodes;parent=parent.parent;} if(nodeIndex===undefined)return; const node=gltf.nodes[nodeIndex],meshSpec=gltf.meshes[node.mesh]; const primitiveIndex=association.primitives||0; const primitive=meshSpec.primitives[primitiveIndex]||meshSpec.primitives[0]; const materialIndex=primitive.material; if(materialIndex===undefined)return; const spec=gltf.materials[materialIndex],name=node.name||meshSpec.name||obj.name; // Separate copies prevent a material variant from changing unrelated objects. let material=matCopies.get(materialIndex); if(!material){material=obj.material.clone();material.side=T.DoubleSide;matCopies.set(materialIndex,material);} obj.material=material; const box=new T.Box3().setFromObject(obj); const pbr=spec.pbrMetallicRoughness||{}; const desc={object:obj,std:node.extras||{},transparent:spec.alphaMode==='BLEND',material:materialIndex,name,nodeIndex, count:obj.geometry.index?.count||obj.geometry.attributes.position.count,context:/^(90_|91_|28_|23_)/.test(name), legacySite:!!node.extras?.legacySite,siteLayer:node.extras?.siteLayer||null,castShadow:node.extras?.castShadow!==false, bounds:{min:box.min.toArray(),max:box.max.toArray()},glass:(spec.name||'').startsWith('glass'), interiorLayer:node.extras?.interiorLayer||null,interiorMaterial:!!spec.extras?.interiorMaterial, interiorStyle:spec.extras?.interiorStyle||0,pbr}; obj.castShadow=desc.castShadow&&!desc.glass&&!desc.transparent;obj.receiveShadow=!desc.transparent; if(desc.transparent){material.transparent=true;material.depthWrite=false;} entries.push(desc); }); const stamps=new Map(),textureCopies=new Map(); function localTexture(index,role,materialIndex,scale) { const key=materialIndex+':'+role+':'+index+':'+scale.join(','); if(!textureCopies.has(key)){ const tx=textures[index]?.clone();if(!tx)return null; tx.colorSpace=role==='map'?T.SRGBColorSpace:T.NoColorSpace; tx.repeat.set(...scale);tx.needsUpdate=true;textureCopies.set(key,tx); } return textureCopies.get(key); } function syncMaterials(){ for(const [index,mat] of matCopies){ const spec=gltf.materials[index],p=spec.pbrMetallicRoughness||{}; const stamp=JSON.stringify([p,spec.normalTexture,spec.emissiveFactor,spec.name]);if(stamps.get(index)===stamp)continue;stamps.set(index,stamp); const rgba=p.baseColorFactor||[.7,.7,.7,1];mat.color.setRGB(...rgba.slice(0,3)); // Legacy exterior factors were sRGB; tagged PBR / interior factors are already linear. if(!spec.extras?.interiorMaterial&&!spec.extras?.linearColor&&!spec.extras?.hardieRole)mat.color.convertSRGBToLinear(); mat.opacity=rgba[3]??1;mat.roughness=p.roughnessFactor??.8;mat.metalness=p.metallicFactor??0; const tex=p.baseColorTexture; mat.map=tex?localTexture(tex.index,'map',index,tex.extensions?.KHR_texture_transform?.scale||[1,1]):null; mat.normalMap=spec.normalTexture?localTexture(spec.normalTexture.index,'normalMap',index,[1,1]):null; if(mat.normalScale&&spec.normalTexture)mat.normalScale.setScalar(spec.normalTexture.scale??1); if(spec.extras?.interiorStyle===1){mat.emissive.copy(mat.color);mat.emissiveIntensity=.75;} mat.needsUpdate=true; } } const temp=new T.Vector3();let oldVisible='',oldInterior=''; function shadowFit(frame){ const low=[Infinity,Infinity,Infinity],high=[-Infinity,-Infinity,-Infinity]; for(const m of entries){if(!m.object.visible||m.glass)continue;for(let a=0;a<3;a++){low[a]=Math.min(low[a],m.bounds.min[a]);high[a]=Math.max(high[a],m.bounds.max[a]);}} if(!Number.isFinite(low[0]))return; if(frame.interior.enabled){low.splice(0,3,-9,-1,-6);high.splice(0,3,11,6,6);} const center=low.map((v,i)=>(v+high[i])/2); sun.target.position.fromArray(center);sun.position.fromArray(center).addScaledVector(temp.fromArray(frame.solar.light),120); sun.target.updateMatrixWorld();sun.updateMatrixWorld(); const c=sun.shadow.camera;c.near=.1;c.far=320;c.position.copy(sun.position);c.up.set(0,1,0);c.lookAt(sun.target.position);c.updateMatrixWorld(); let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity; for(let a=0;a<8;a++){temp.set(a&1?high[0]:low[0],a&2?high[1]:low[1],a&4?high[2]:low[2]).applyMatrix4(c.matrixWorldInverse);minX=Math.min(minX,temp.x);maxX=Math.max(maxX,temp.x);minY=Math.min(minY,temp.y);maxY=Math.max(maxY,temp.y);} c.left=minX-2;c.right=maxX+2;c.bottom=minY-2;c.top=maxY+2;c.updateProjectionMatrix();renderer.shadowMap.needsUpdate=true; } return {backend:'three',label:'Three.js r'+T.REVISION,meshes:entries,images, resize(w,h,dpr){renderer.setPixelRatio(dpr);renderer.setSize(w,h,false);}, draw(frame){ syncMaterials(); let key='';for(const m of entries){m.object.visible=!!frame.visible(m);key+=m.object.visible?'1':'0';} if(frame.solar.shadowDirty||key!==oldVisible||oldInterior!==frame.interior.mode){shadowFit(frame);oldVisible=key;oldInterior=frame.interior.mode;} sun.intensity=Math.max(0,frame.solar.direct)*3.0;sun.color.fromArray(frame.solar.sunColor);sun.castShadow=frame.solar.shadows; const day=frame.solar.dayFactor;skyLight.intensity=.18+1.5*day;ambient.intensity=.12+.45*day; fill.intensity=frame.interior.enabled&&frame.interior.fill?1.3:0; scene.background=new T.Color().setRGB(...frame.solar.sky).convertSRGBToLinear(); const camera=frame.orthographic?ortho:persp; camera.position.fromArray(frame.eye);camera.up.set(0,1,0);camera.lookAt(temp.fromArray(frame.lookTarget));camera.updateMatrixWorld(); camera.projectionMatrix.fromArray(frame.vp).multiply(camera.matrixWorld);camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert(); renderer.render(scene,camera); }, dispose(){renderer.dispose();images.forEach(i=>i?.src&&URL.revokeObjectURL(i.src));textureCopies.forEach(t=>t.dispose());} }; }