File size: 8,627 Bytes
0dfa89f
 
 
 
 
 
 
 
7407d55
0dfa89f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7407d55
0dfa89f
 
 
 
 
 
 
 
 
 
 
 
 
 
7407d55
0dfa89f
 
 
7407d55
0dfa89f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7407d55
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
72
73
74
75
76
77
78
import {useEffect,useRef,useState} from 'react';
import {X,Info,Download,Copy,Check,ArrowUpRight,Film,Box,RotateCw,ShoppingBag} from 'lucide-react';
import type {Asset} from './types';
import type {createEngine,Joint} from './viewer-engine';
import AssetImage from './AssetImage';
import MotionControls from './MotionControls';
import {homeProgress} from './vendor/kinematics.js';
import {downloadCommand} from './download';
import {viewerFrame} from './spatial';

type Props={asset:Asset;x:number;y:number;scale:number;width:number;height:number;inBasket:boolean;onToggleBasket:()=>void;onClose:()=>void};
export default function ObjectViewer({asset,x,y,scale,width,height,inBasket,onToggleBasket,onClose}:Props){
  const host=useRef<HTMLDivElement>(null),engine=useRef<ReturnType<typeof createEngine>|null>(null);
  const values=useRef<Record<string,number>>({}),activeJoint=useRef<string|null>(null);
  const [status,setStatus]=useState('Loading 3D…'),[error,setError]=useState(''),[ready,setReady]=useState(false);
  const [joints,setJoints]=useState<Joint[]>([]),[jointIndex,setJointIndex]=useState(0),[progress,setProgress]=useState(0),[playing,setPlaying]=useState(false),[highlighted,setHighlighted]=useState(true);
  const [panel,setPanel]=useState<'info'|'download'|null>(null),[copied,setCopied]=useState(false),[mode,setMode]=useState<'3d'|'video'>('3d'),[retry,setRetry]=useState(0),[allowLarge,setAllowLarge]=useState(false);
  const large=!!asset.bytes&&asset.bytes>50*1024*1024&&!allowLarge;
  useEffect(()=>{
    if(mode!=='3d'||!asset.model||large)return;
    let cancelled=false;setReady(false);setError('');setJoints([]);setPlaying(false);setHighlighted(true);setStatus('Loading 3D…');
    import('./viewer-engine').then(({createEngine})=>{
      if(cancelled||!host.current)return;
      try{
        engine.current=createEngine(host.current,{url:asset.model!,compact:asset.compact},setStatus,items=>{
          if(cancelled)return;
          setJoints(items);setJointIndex(0);activeJoint.current=items[0]?.id??null;
          values.current=Object.fromEntries(items.map(j=>[j.id,homeProgress(j)]));
          setProgress(items[0]?homeProgress(items[0]):0);setReady(true);
        },failure=>{if(!cancelled){setError(failure.message);setStatus('');}},(id,value)=>{
          if(cancelled)return;values.current[id]=value;if(activeJoint.current===id)setProgress(value);
        });
      }catch(failure){setError(String(failure));setStatus('');}
    }).catch(failure=>{if(!cancelled){setError(String(failure));setStatus('');}});
    return()=>{cancelled=true;engine.current?.dispose();engine.current=null;};
  },[asset.id,mode,retry,large]);
  const joint=joints[jointIndex],hasMovement=mode==='3d'&&joints.length>0;
  const {dimension,left,top,inView}=viewerFrame(x,y,scale,width,height);
  const dockWidth=Math.min(440,width-30),dockHeight=hasMovement?158:52;
  const dockX=Math.max(15,Math.min(width-dockWidth-15,x-dockWidth/2));
  const dockY=Math.max(width<700?126:95,Math.min(height-dockHeight-62,y+dimension/2+12));
  const command=downloadCommand(asset);
  const copy=async()=>{try{await navigator.clipboard.writeText(command);setCopied(true);}catch{setCopied(false);}};
  const chooseMovement=(index:number)=>{
    engine.current?.play(false);setPlaying(false);setJointIndex(index);
    const next=joints[index];activeJoint.current=next.id;engine.current?.selectJoint(next.id);
    setProgress(values.current[next.id]??homeProgress(next));
  };
  const setPosition=(value:number)=>{
    setPlaying(false);values.current[joint.id]=value;setProgress(value);engine.current?.setJoint(joint.id,value);
  };
  return <>
    <div className="object-stage" data-ui inert={!inView} style={{left,top,width:dimension,height:dimension}}>
      {mode==='3d'&&<><div ref={host} className="viewer-host" style={{opacity:ready?1:0}}/>{!ready&&<AssetImage asset={asset}/>}<div className="model-status" role="status">{large?<button className="quiet-button" onClick={()=>setAllowLarge(true)}>Load large 3D model · {Math.round(asset.bytes!/1048576)} MB</button>:error?<span>3D preview unavailable <button className="icon-button" aria-label="Retry 3D preview" onClick={()=>setRetry(n=>n+1)}><RotateCw size={13}/></button></span>:status&&<span><i className="tiny-spinner"/>{status}</span>}</div></>}
      {mode==='video'&&asset.video&&<video className="motion-video" src={asset.video} poster={asset.renderedPreview??undefined} controls autoPlay muted loop playsInline onError={()=>setError('Motion preview unavailable')}/>}
    </div>
    {inView&&<><div className="selection-label" data-ui style={{left:Math.max(15,Math.min(width-260,x-130)),top:Math.max(width<700?119:84,y-dimension/2-61)}}><div><strong>{asset.name}</strong><span>{asset.author}</span></div><button className="icon-button" aria-label="Close object" onClick={onClose}><X size={15}/></button></div>
    <section className={`object-dock ${hasMovement?'with-motion':''}`} data-ui aria-label="Object controls" style={{width:dockWidth,left:dockX,top:dockY}}>
      {hasMovement&&<MotionControls joints={joints} index={jointIndex} progress={progress} playing={playing} highlighted={highlighted}
        onSelect={chooseMovement} onProgress={setPosition}
        onPlay={()=>{engine.current?.play(!playing,joint.id);setPlaying(!playing);}}
        onReset={()=>setPosition(homeProgress(joint))}
        onHighlight={()=>{engine.current?.setHighlight(!highlighted);setHighlighted(!highlighted);}}/>}
      <div className="dock-actions">
        <span className="dock-caption">{mode==='video'?'Recorded motion · all parts':ready?'Drag object to rotate':error?'Source preview':large?'Preview':'Opening object…'}</span>
        <span className="dock-spacer"/>
        {asset.video&&<button className={`icon-button ${mode==='video'?'active':''}`} aria-label={mode==='3d'?'Watch recorded motion of all parts':'Return to interactive 3D'} title={mode==='3d'?'Recorded motion · all parts':'Interactive 3D'} onClick={()=>{setPlaying(false);setError('');setMode(mode==='3d'?'video':'3d');}}>{mode==='3d'?<Film size={17}/>:<Box size={17}/>}</button>}
        <button className={`icon-button ${panel==='info'?'active':''}`} aria-label="Object information" title="Object information" onClick={()=>setPanel(panel==='info'?null:'info')}><Info size={17}/></button>
        <button className={`icon-button ${panel==='download'?'active':''}`} aria-label="Download object" title="Download object" onClick={()=>setPanel(panel==='download'?null:'download')}><Download size={17}/></button>
        <span className="divider"/>
        <button className={`icon-button add-to-basket ${inBasket?'active':''}`} aria-label={inBasket?'Remove from basket':'Add to basket'} title={inBasket?'Remove from basket':'Add to basket'} aria-pressed={inBasket} onClick={onToggleBasket}><ShoppingBag size={18}/>{inBasket&&<Check className="bag-check" size={10}/>}</button>
      </div>
      {panel==='info'&&<div className="popover dock-panel"><h2>{asset.name}</h2><dl><div><dt>Creator</dt><dd>{asset.author}</dd></div><div><dt>Movement</dt><dd>{asset.moving?`${asset.moving} controllable movements`:'Static object'}</dd></div><div><dt>License</dt><dd>{Array.isArray(asset.license)?asset.license.join(', '):asset.license||'See source asset'}</dd></div></dl><a href={asset.source} target="_blank" rel="noreferrer">Original OpenUSD asset<ArrowUpRight size={14}/></a><p>Choose a movement to highlight its moving part. Play and the slider control only that movement, including any linked parts. Other movements keep their position.</p><p>Names and limits come from the source model. Generic names indicate that the source has no descriptive label. This is a movement preview; physics simulation requires a compatible simulator.</p>{error&&<details><summary>Preview details</summary><p>{error}</p></details>}</div>}
      {panel==='download'&&<div className="popover dock-panel"><h2>Take this object with you.</h2><p>Download the complete source folder, including textures and referenced files.</p><button className="download-action" onClick={onToggleBasket}><ShoppingBag size={16}/>{inBasket?'Remove from basket':'Add to basket'}</button><button className="download-action" onClick={copy}>{copied?<Check size={16}/>:<Copy size={16}/>} {copied?'Command copied':'Copy download command'}</button><a className="download-action" href={asset.sourceFolder} target="_blank" rel="noreferrer">Browse source files<ArrowUpRight size={15}/></a><details><summary>Show command</summary><code>{command}</code></details><small>Source version: {asset.revision.slice(0,8)}</small></div>}
    </section></>}
  </>;
}