openusd-explorer / src /viewer-engine.ts
ZIP Space Deployer
Deploy ZIP — 2026-10-26 20:15 UTC
0dfa89f
Raw History Blame Contribute Delete
5.96 kB
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<string,unknown>)=>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<string,number>={};
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<THREE.Object3D>();
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();}
};
}