Spaces:
Running
Running
Download src/ObjectViewer.tsx from fffiloni/openusd-explorer: direct link, hf CLI and curl.
- Browser
- Download file 8.63 kB
-
https://huggingface.co/spaces/fffiloni/openusd-explorer/resolve/main/src/ObjectViewer.tsx
- Command line
-
hf download hf://spaces/fffiloni/openusd-explorer/src/ObjectViewer.tsx
-
curl -L -o ObjectViewer.tsx https://huggingface.co/spaces/fffiloni/openusd-explorer/resolve/main/src/ObjectViewer.tsx
8.63 kB
| 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></>} | |
| </>; | |
| } | |