import * as THREE from 'three'; import {OrbitControls} from 'three/addons/controls/OrbitControls.js'; import {KTX2Loader} from 'three/addons/loaders/KTX2Loader.js'; import {loadWebUsd,setWebUsdReader} from './vendor/web-usd.js'; import {bodyMatrices,controllableJoints,homeProgress} from './vendor/kinematics.js'; import {createStudio,objectBounds,STUDIO_DIRECTION} from './studio'; import {affectedBodies,selectedPose,type MotionJoint} from './motion'; const base=import.meta.env.BASE_URL; setWebUsdReader({workerUrl:new URL(base+'vendor/usd-reader/esm/worker.js',location.href).href,wasmUrl:new URL(base+'vendor/usd-reader/wasm/usd_wasm_bg.wasm',location.href).href}); export type ModelAsset={url:string;compact?:number[];name?:string}; export type Joint=MotionJoint; export function createEngine(host:HTMLElement,asset:ModelAsset,onStatus:(s:string)=>void,onReady:(j:Joint[],info:Record)=>void,onFailure:(e:Error)=>void,onMotion?:(id:string,progress:number)=>void){ const controller=new AbortController(); let disposed=false,model:any=null,raf=0,playing=false,start=0,lastMotion=0,activeJoint:string|null=null,highlightEnabled=true; let values:Record={}; const highlights:{mesh:THREE.Mesh;original:THREE.Material|THREE.Material[];copies:THREE.Material[]}[]=[]; const clearHighlight=()=>{for(const item of highlights){item.mesh.material=item.original;item.copies.forEach(material=>material.dispose());}highlights.length=0;}; const highlight=()=>{ clearHighlight();if(!model||!activeJoint||!highlightEnabled)return; const bodies=affectedBodies(model.articulation,activeJoint,values),moving=new Set(); for(const id of bodies)model.bodies.get(id)?.traverse((object:THREE.Object3D)=>moving.add(object)); if(!moving.size)return; model.scene.traverse((object:THREE.Object3D)=>{ if(!(object instanceof THREE.Mesh))return; const original=object.material,materials=Array.isArray(original)?original:[original]; const copies=materials.map(material=>{ const copy=material.clone() as THREE.MeshStandardMaterial; if(moving.has(object)){if(copy.emissive){copy.emissive.set('#9573e8');copy.emissiveIntensity=.3;}} else{copy.transparent=true;copy.opacity=Math.min(copy.opacity,.14);copy.depthWrite=false;} return copy; }); object.material=Array.isArray(original)?copies:copies[0];highlights.push({mesh:object,original,copies}); }); }; const renderer=new THREE.WebGLRenderer({alpha:true,antialias:true,powerPreference:'high-performance'}); renderer.setPixelRatio(Math.min(devicePixelRatio,1.75)); host.appendChild(renderer.domElement);renderer.domElement.setAttribute('aria-label','Interactive 3D object'); const studio=createStudio(renderer),scene=studio.scene,camera=new THREE.PerspectiveCamera(35,1,0.001,10000); const controls=new OrbitControls(camera,renderer.domElement);controls.enableDamping=true;controls.enablePan=false;controls.enableZoom=false; const ktx=new KTX2Loader().setTranscoderPath(base+'vendor/basis/').setWorkerLimit(2);ktx.detectSupport(renderer); const update=()=>{if(!model?.articulation)return;for(const [id,matrix] of Object.entries(bodyMatrices(model.articulation,values))){const body=model.bodies.get(id);if(body){body.matrixAutoUpdate=false;body.matrix.set(...matrix as number[]);body.matrixWorldNeedsUpdate=true;}}}; const resize=()=>{const {width,height}=host.getBoundingClientRect();renderer.setSize(Math.max(width,1),Math.max(height,1),false);camera.aspect=Math.max(width,1)/Math.max(height,1);camera.updateProjectionMatrix();}; const ro=new ResizeObserver(resize);ro.observe(host);resize(); const frame=(now:number)=>{if(disposed)return;if(playing&&model&&activeJoint){const progress=(1-Math.cos((now-start)/1600))/2;values=selectedPose(values,activeJoint,progress);update();if(now-lastMotion>65){onMotion?.(activeJoint,progress);lastMotion=now;}}controls.update();renderer.render(scene,camera);raf=requestAnimationFrame(frame);};raf=requestAnimationFrame(frame); onStatus('Loading 3D…'); loadWebUsd(asset.url,{ktx2:ktx,signal:controller.signal,physics:true,colliders:false,compact:asset.compact,onStage:(stage:string)=>{if(!disposed)onStatus(stage==='geometry'?'Loading materials…':'Loading 3D…');}}).then((loaded:any)=>{ if(disposed){loaded.dispose();return;}model=loaded;scene.add(model.scene); for(const j of controllableJoints(model.articulation))values[j.id]=homeProgress(j);update(); activeJoint=controllableJoints(model.articulation)[0]?.id??null;highlight(); const {center,radius}=objectBounds(model.scene),distance=radius/Math.sin(THREE.MathUtils.degToRad(camera.fov/2))*1.08; camera.near=Math.max(radius/1000,0.00001);camera.far=radius*100;camera.updateProjectionMatrix(); camera.position.copy(center).add(new THREE.Vector3(...STUDIO_DIRECTION).normalize().multiplyScalar(distance));controls.target.copy(center);controls.update(); onStatus('');onReady(controllableJoints(model.articulation),model.info); }).catch((error:Error)=>{if(!disposed)onFailure(error);}); return { setJoint:(id:string,value:number)=>{playing=false;values=selectedPose(values,id,value);update();}, selectJoint:(id:string)=>{playing=false;activeJoint=id;highlight();}, setHighlight:(enabled:boolean)=>{highlightEnabled=enabled;highlight();}, play:(enabled:boolean,id?:string)=>{if(!enabled&&activeJoint)onMotion?.(activeJoint,values[activeJoint]??0);if(id&&id!==activeJoint){activeJoint=id;highlight();}playing=enabled&&!!activeJoint;const position=activeJoint?(values[activeJoint]??0):0;start=performance.now()-Math.acos(1-2*Math.min(1,Math.max(0,position)))*1600;}, dispose:()=>{disposed=true;controller.abort();cancelAnimationFrame(raf);ro.disconnect();controls.dispose();clearHighlight();model?.dispose();ktx.dispose();studio.dispose();renderer.dispose();renderer.forceContextLoss();renderer.domElement.remove();} }; }