File size: 6,531 Bytes
0dfa89f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7539b2e
0dfa89f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7539b2e
0dfa89f
 
 
 
 
 
 
 
7539b2e
 
 
 
 
0dfa89f
7539b2e
0dfa89f
 
 
 
7539b2e
0dfa89f
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
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 visible=true;
  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||!visible)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();
    renderer.render(scene,camera);onStatus('');onReady(controllableJoints(model.articulation),model.info);
  }).catch((error:Error)=>{if(!disposed){dispose();onFailure(error);}});
  const contextLost=(event:Event)=>{event.preventDefault();if(!disposed){dispose();onFailure(new Error('The graphics context was lost. Retry the 3D preview.'));}};
  renderer.domElement.addEventListener('webglcontextlost',contextLost);
  const dispose=()=>{if(disposed)return;disposed=true;renderer.domElement.removeEventListener('webglcontextlost',contextLost);controller.abort();cancelAnimationFrame(raf);ro.disconnect();controls.dispose();clearHighlight();model?.dispose();ktx.dispose();studio.dispose();renderer.dispose();renderer.forceContextLoss();renderer.domElement.remove();};
  return {
    setVisible:(value:boolean)=>{if(visible===value||disposed)return;visible=value;cancelAnimationFrame(raf);if(visible)raf=requestAnimationFrame(frame);},
    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
  };
}