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