File size: 11,445 Bytes
0dfa89f 7539b2e 0dfa89f 7539b2e 0dfa89f 7539b2e 0dfa89f 7539b2e 0dfa89f 7539b2e 0dfa89f 7539b2e 0dfa89f 7539b2e 0dfa89f 7539b2e 0dfa89f 7539b2e 0dfa89f 7539b2e 0dfa89f 7407d55 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 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 | 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 SelectionIndicator from './SelectionIndicator';
import {homeProgress} from './vendor/kinematics.js';
import {downloadCommand} from './download';
import {viewerFrame,selectionInFocus} from './spatial';
import {formatFileSize} from './file-size';
type Props={asset:Asset;moving?:boolean;x:number;y:number;scale:number;objectSize:number;width:number;height:number;inBasket:boolean;onToggleBasket:()=>void;onClose:()=>void};
export default function ObjectViewer({asset,moving=false,x,y,scale,objectSize,width,height,inBasket,onToggleBasket,onClose}:Props){
const host=useRef<HTMLDivElement>(null),video=useRef<HTMLVideoElement>(null),engine=useRef<ReturnType<typeof createEngine>|null>(null);
const movingRef=useRef(moving);movingRef.current=moving;
const values=useRef<Record<string,number>>({}),activeJoint=useRef<string|null>(null);
const [status,setStatus]=useState('Preparing 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),[copyError,setCopyError]=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;
const {dimension,left,top,inView}=viewerFrame(x,y,scale,width,height,objectSize);
const showControls=inView&&selectionInFocus(x,y,width,height);
useEffect(()=>{
if(mode!=='3d'||!asset.model||large)return;
let cancelled=false,start=0;setReady(false);setError('');setJoints([]);setPlaying(false);setHighlighted(true);setStatus('Preparing 3D…');
// Center first: rapidly changing selection can cancel before allocating a renderer.
const begin=()=>{
if(cancelled)return;
if(movingRef.current){start=requestAnimationFrame(begin);return;}
start=requestAnimationFrame(()=>{
if(movingRef.current){begin();return;}
import('./viewer-engine').then(({createEngine})=>{
if(cancelled||!host.current)return;
try{
engine.current=createEngine(host.current,{url:asset.model!,compact:asset.compact},value=>{if(!cancelled)setStatus(value);},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){setReady(false);setPlaying(false);setJoints([]);setError(failure.message);setStatus('');}},(id,value)=>{
if(cancelled)return;values.current[id]=value;if(activeJoint.current===id)setProgress(value);
});
}catch(failure){if(!cancelled){setError(String(failure));setStatus('');}}
}).catch(failure=>{if(!cancelled){setError(String(failure));setStatus('');}});
});
};start=requestAnimationFrame(begin);
return()=>{cancelled=true;cancelAnimationFrame(start);engine.current?.dispose();engine.current=null;};
},[asset.id,mode,retry,large]);
useEffect(()=>{
engine.current?.setVisible(inView);
if(!showControls){engine.current?.play(false);setPlaying(false);video.current?.pause();}
},[inView,showControls,ready,mode]);
const joint=joints[jointIndex],hasMovement=mode==='3d'&&ready&&joints.length>0;
const command=downloadCommand(asset);
const copy=async()=>{try{await navigator.clipboard.writeText(command);setCopied(true);setCopyError(false);}catch{setCopied(false);setCopyError(true);}};
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)=>{if(!joint)return;setPlaying(false);values.current[joint.id]=value;setProgress(value);engine.current?.setJoint(joint.id,value);};
const indicator=error?'error':ready?'ready':large||!asset.model?'idle':'loading';
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,pointerEvents:ready?'auto':'none'}}/>{!ready&&<AssetImage asset={asset}/>}{inView&&<SelectionIndicator state={indicator} label={error?'Selected object · 3D unavailable':ready?'Selected object · 3D ready':large?'Selected object · Load 3D to continue':!asset.model?'Selected object · Image preview':'Selected object · Preparing 3D'}/>}</>}
{mode==='video'&&asset.video&&<video ref={video} className="motion-video" src={asset.video} poster={asset.renderedPreview??undefined} controls autoPlay muted loop playsInline onError={()=>setError('Motion recording unavailable')}/>}
</div>
{showControls&&<section className={`object-dock selection-bar ${hasMovement?'with-motion':''}`} data-ui aria-label="Object controls">
<div className="selection-summary">
<div className="selection-identity"><strong title={asset.name}>{asset.name}</strong><span className="selection-meta"><span className="selection-generator" title={asset.dataset}>{asset.author}</span><span className="selection-size" title={asset.bytes!=null?`Prepared USD preview · ${asset.bytes.toLocaleString('en-US')} bytes reported by source manifest; not the complete source package`:'Prepared USD preview size unavailable'}>USD preview{asset.bytes!=null?` · ${formatFileSize(asset.bytes)}`:''}</span></span></div>
<div className="selection-actions">
<button className={`icon-button ${panel==='info'?'active':''}`} aria-label="Object information" aria-expanded={panel==='info'} aria-controls="selection-details" title="Object information" onClick={()=>setPanel(panel==='info'?null:'info')}><Info size={18}/></button>
<button className={`icon-button ${panel==='download'?'active':''}`} aria-label="Download object" aria-expanded={panel==='download'} aria-controls="selection-details" title="Download object" onClick={()=>setPanel(panel==='download'?null:'download')}><Download size={18}/></button>
<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>
<span className="divider"/><button className="icon-button selection-close" aria-label="Close object" title="Close object" onClick={onClose}><X size={18}/></button>
</div>
</div>
<div className="selection-status" role="status">
{mode==='video'?<span>{error||'Recorded motion · all parts'}</span>:!asset.model?<span>Image preview · 3D unavailable for this object</span>:large?<button className="quiet-button" onClick={()=>setAllowLarge(true)}>Load large 3D preview · {formatFileSize(asset.bytes!)}</button>:error?<><span>3D unavailable · Showing source image</span><button className="icon-button" aria-label="Retry 3D preview" title="Retry 3D preview" onClick={()=>setRetry(n=>n+1)}><RotateCw size={15}/></button></>:ready?<><Check size={12} className="selection-ready-icon"/><span>3D ready · Drag object to rotate</span></>:<><i className="tiny-spinner"/><span>{moving?'Centering object…':status}</span></>}
{asset.video&&<button className="video-toggle" aria-label={mode==='3d'?'Watch recorded motion of all parts':'Return to interactive 3D'} onClick={()=>{setPlaying(false);setReady(false);setError('');setMode(mode==='3d'?'video':'3d');}}>{mode==='3d'?<Film size={14}/>:<Box size={14}/>} {mode==='3d'?'Watch recording':'Interactive 3D'}</button>}
</div>
{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);}}/>}
{panel==='info'&&<div id="selection-details" className="dock-panel selection-details"><h2>{asset.name}</h2><dl>
<div><dt>Creator</dt><dd>{asset.author}</dd></div><div><dt>Dataset</dt><dd>{asset.dataset}</dd></div><div><dt>Source format</dt><dd>OpenUSD</dd></div>
<div><dt>3D preview size</dt><dd>{asset.bytes!=null?formatFileSize(asset.bytes):'Not provided'}</dd></div>
<div><dt>Movement</dt><dd>{ready?joints.length?`${joints.length} controllable movements`:'Static object':asset.moving?`${asset.moving} movements listed in catalogue`:'Static object'}</dd></div>
<div><dt>License</dt><dd>{Array.isArray(asset.license)?asset.license.join(', '):asset.license||'See source asset'}</dd></div>
<div><dt>Source version</dt><dd>{asset.revision.slice(0,8)}</dd></div></dl>
<a href={asset.source} target="_blank" rel="noreferrer">Original OpenUSD asset<ArrowUpRight size={14}/></a>
<p>The preview size comes from the prepared-media manifest. The complete source download includes textures and referenced files and may be larger.</p>
{asset.moving>0&&<details><summary>About movements</summary><p>Choose a movement to highlight its moving part. Play and the slider control that movement and linked parts; other movements keep their positions. The recording shows all parts independently.</p><p>Names and limits come from the source model. Generic names mean no descriptive label was provided. This is a kinematic preview, not physics simulation.</p></details>}
{error&&<details><summary>Preview details</summary><p>{error}</p></details>}</div>}
{panel==='download'&&<div id="selection-details" className="dock-panel selection-details"><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>{copyError&&<p className="selection-copy-error" role="alert">Clipboard unavailable. Open the command below and copy it manually.</p>}<a className="download-action" href={asset.sourceFolder} target="_blank" rel="noreferrer">Browse source files<ArrowUpRight size={15}/></a><details open={copyError||undefined}><summary>Show command</summary><code>{command}</code></details><small>Source version: {asset.revision.slice(0,8)}</small></div>}
</section>}
</>;
}
|