Spaces:
Running
Running
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></>}
</>;
}
|