feat(web-platform): release V0.1

This commit is contained in:
2026-08-20 16:36:19 +08:00
parent 1eb05132f1
commit e4849e1645
11 changed files with 160 additions and 61 deletions
+13 -9
View File
@@ -4,18 +4,20 @@ import {useCallback,useEffect,useRef,useState,type ChangeEvent,type DragEvent} f
import type {ProjectManifest} from '../project/types';
import {filesFromDrop,importBrowserFiles,ProjectImportError} from '../project/importer';
import {ProjectTree} from '../project/ProjectTree';
import {ModelStructureTree} from '../project/ModelStructureTree';
import {MainThreadPhysicsAdapter,type UrdfBaseMode,type UrdfLoadMode} from '../simulation/PhysicsAdapter';
import {MuJoCoViewer,type InteractionMode} from '../viewer/MuJoCoViewer';
import {MuJoCoViewer,type InteractionMode,type ViewerTheme} from '../viewer/MuJoCoViewer';
import {useAppStore,type AppDiagnostic} from '../stores/useAppStore';
function diagnostic(category:AppDiagnostic['category'],error:unknown,path?:string):AppDiagnostic{const detail=error instanceof Error?error.message:String(error);return {category,summary:`${category}失败`,detail,path,at:Date.now()};}
const modeLabels:Record<InteractionMode,string>={select:'选择',joint:'关节拖动',force:'外力施加'};
function initialTheme():ViewerTheme{try{return localStorage.getItem('mujoco-platform-theme')==='light'?'light':'dark';}catch{return'dark';}}
export function App(){
const state=useAppStore(); const manifest=useRef<ProjectManifest|null>(null); const adapter=useRef(new MainThreadPhysicsAdapter()); const viewerHost=useRef<HTMLDivElement>(null); const viewer=useRef<MuJoCoViewer|null>(null); const [forceScale,setForceScale]=useState(50); const [leftOpen,setLeftOpen]=useState(true);const [rightOpen,setRightOpen]=useState(true);const [urdfMode,setUrdfMode]=useState<UrdfLoadMode>('mjcf');const urdfModeRef=useRef<UrdfLoadMode>('mjcf');const [baseMode,setBaseMode]=useState<UrdfBaseMode>('floating');const baseModeRef=useRef<UrdfBaseMode>('floating');const [showCollision,setShowCollision]=useState(false);
const state=useAppStore(); const manifest=useRef<ProjectManifest|null>(null); const adapter=useRef(new MainThreadPhysicsAdapter()); const viewerHost=useRef<HTMLDivElement>(null); const viewer=useRef<MuJoCoViewer|null>(null); const [forceScale,setForceScale]=useState(50); const [leftOpen,setLeftOpen]=useState(true);const [rightOpen,setRightOpen]=useState(true);const [urdfMode,setUrdfMode]=useState<UrdfLoadMode>('mjcf');const urdfModeRef=useRef<UrdfLoadMode>('mjcf');const [baseMode,setBaseMode]=useState<UrdfBaseMode>('floating');const baseModeRef=useRef<UrdfBaseMode>('floating');const [showCollision,setShowCollision]=useState(false);const [theme,setTheme]=useState<ViewerTheme>(initialTheme);const [jointAdvanced,setJointAdvanced]=useState(false);const [ignoreJointLimits,setIgnoreJointLimits]=useState(false);const [angleUnit,setAngleUnit]=useState<'rad'|'deg'>('rad');
useEffect(()=>{if(!viewerHost.current)return;viewer.current=new MuJoCoViewer(viewerHost.current,{onSelection:state.setSelection,onFrame:(frame,fps,snapshot)=>{const memory=(performance as Performance&{memory?:{usedJSHeapSize:number}}).memory?.usedJSHeapSize;state.setMetrics(fps,frame.stepMs,memory===undefined?undefined:memory/1048576,frame.overBudget);if(snapshot)state.setSnapshot(snapshot);},onError:(error)=>state.setDiagnostic(diagnostic('渲染',error))});return()=>{viewer.current?.dispose();viewer.current=null;adapter.current.dispose();};},[]);
useEffect(()=>{viewer.current?.setMode(state.mode);},[state.mode]); useEffect(()=>{if(viewer.current)viewer.current.forceScale=forceScale;},[forceScale]);useEffect(()=>{viewer.current?.setShowCollision(showCollision);},[showCollision]);
const loadEntry=useCallback(async(path:string,requestedMode?:UrdfLoadMode)=>{if(!manifest.current)return;state.setEntry(path);state.setLoading(true);state.setDiagnostic(undefined);viewer.current?.attach(null);try{const snapshot=await adapter.current.load(manifest.current,path,requestedMode??urdfModeRef.current,baseModeRef.current);state.setSnapshot(snapshot);state.setPaused(true);viewer.current?.attach(adapter.current.session);}catch(error){state.setDiagnostic(diagnostic('模型编译',error,path));}finally{state.setLoading(false);}},[]);
useEffect(()=>{viewer.current?.setMode(state.mode);},[state.mode]); useEffect(()=>{if(viewer.current)viewer.current.forceScale=forceScale;},[forceScale]);useEffect(()=>{viewer.current?.setShowCollision(showCollision);},[showCollision]);useEffect(()=>{viewer.current?.setTheme(theme);document.documentElement.style.colorScheme=theme;try{localStorage.setItem('mujoco-platform-theme',theme);}catch{/* 浏览器禁用存储时仍可在当前会话切换 */}},[theme]);
const loadEntry=useCallback(async(path:string,requestedMode?:UrdfLoadMode)=>{if(!manifest.current)return;setIgnoreJointLimits(false);state.setEntry(path);state.setLoading(true);state.setDiagnostic(undefined);viewer.current?.attach(null);try{const snapshot=await adapter.current.load(manifest.current,path,requestedMode??urdfModeRef.current,baseModeRef.current);state.setSnapshot(snapshot);state.setPaused(true);viewer.current?.attach(adapter.current.session);}catch(error){state.setDiagnostic(diagnostic('模型编译',error,path));}finally{state.setLoading(false);}},[]);
const ingest=useCallback(async(files:File[])=>{state.setLoading(true);try{const next=await importBrowserFiles(files);manifest.current=next;state.setProject(next.name,next.files.map(({path,size})=>({path,size})),next.entries,next.selectedEntry);if(next.selectedEntry)await loadEntry(next.selectedEntry);}catch(error){state.setDiagnostic(diagnostic(error instanceof ProjectImportError&&/ZIP/.test(error.message)?'ZIP':'导入',error,error instanceof ProjectImportError?error.path:undefined));}finally{state.setLoading(false);}},[loadEntry]);
const removeProject=()=>{if(!state.projectName||!window.confirm(`确定从当前会话中移除“${state.projectName}”吗?\n不会删除本地文件。`))return;viewer.current?.attach(null);adapter.current.dispose();manifest.current=null;state.clearProject();};
const changeUrdfMode=(value:UrdfLoadMode)=>{setUrdfMode(value);urdfModeRef.current=value;const entry=state.entries.find(candidate=>candidate.path===state.selectedEntry);if(entry?.format==='urdf')void loadEntry(entry.path,value);};
@@ -27,12 +29,14 @@ export function App(){
const singleStep=()=>{adapter.current.singleStep();state.setSnapshot(adapter.current.snapshot()??undefined);};
const changeSpeed=(value:number)=>{state.setSpeed(value);adapter.current.setSpeed(value);};
const mode=(value:InteractionMode)=>{state.setMode(value);};
const resetJoints=()=>{adapter.current.resetJoints();state.setPaused(true);state.setSnapshot(adapter.current.snapshot()??undefined);};
const toggleJointLimits=()=>{const next=!ignoreJointLimits;adapter.current.setIgnoreJointLimits(next);setIgnoreJointLimits(next);state.setSnapshot(adapter.current.snapshot()??undefined);};
useEffect(()=>{const key=(e:KeyboardEvent)=>{if((e.target as HTMLElement).matches('input,select,button'))return;if(e.code==='Space'){e.preventDefault();togglePause();}if(e.key==='r')reset();if(e.key==='1')mode('select');if(e.key==='2')mode('joint');if(e.key==='3')mode('force');};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);});
return <div className="flex h-screen min-w-[1024px] flex-col overflow-hidden bg-slate-950 text-slate-100" onDragOver={e=>e.preventDefault()} onDrop={drop}>
<header className="flex h-14 shrink-0 items-center gap-3 border-b border-slate-700 bg-slate-900 px-4"><h1 className="mr-3 text-base font-semibold">MuJoCo Web 仿真平台</h1><label className="btn cursor-pointer">打开文件/ZIP<input className="sr-only" type="file" multiple accept=".xml,.urdf,.zip,.obj,.stl,.dae,.msh,.png,.jpg,.jpeg,.bmp,.tga,.hdr" onChange={changeFiles}/></label><label className="btn cursor-pointer">打开文件夹<input className="sr-only" type="file" multiple {...({webkitdirectory:'',directory:''} as object)} onChange={changeFiles}/></label><span className="h-6 border-l border-slate-700"/><button className="btn" onClick={togglePause} disabled={!state.snapshot}>{state.paused?'▶ 播放':'⏸ 暂停'}</button><button className="btn" onClick={singleStep} disabled={!state.snapshot||!state.paused}>单步</button><button className="btn" onClick={reset} disabled={!state.snapshot}>重置</button><select aria-label="仿真速度" value={state.speed} onChange={e=>changeSpeed(Number(e.target.value))} className="field w-24"><option value={.25}>0.25×</option><option value={.5}>0.5×</option><option value={1}>1×</option><option value={2}>2×</option><option value={4}>4×</option></select><span className="h-6 border-l border-slate-700"/>{(Object.keys(modeLabels) as InteractionMode[]).map(value=><button key={value} className={`btn ${state.mode===value?'border-green-500 bg-green-900/50':''}`} onClick={()=>mode(value)}>{modeLabels[value]}</button>)}<button className="btn ml-auto" onClick={()=>viewer.current?.resetCamera()}>相机复位</button><button className="icon-btn" aria-label="切换工程面板" onClick={()=>setLeftOpen(v=>!v)}>☰</button><button className="icon-btn" aria-label="切换属性面板" onClick={()=>setRightOpen(v=>!v)}>⚙</button></header>
<div className="flex min-h-0 flex-1">{leftOpen&&<aside className="panel w-72 min-w-56 max-w-[40vw] shrink-0 resize-x overflow-auto border-r"><PanelTitle>工程资源</PanelTitle>{state.projectName?<><div className="flex items-center gap-2 px-3 py-2"><div className="min-w-0 flex-1 truncate text-sm font-medium text-green-300" title={state.projectName}>{state.projectName}</div><button type="button" className="btn shrink-0 border-red-900 px-2 text-red-300 hover:border-red-700 hover:bg-red-950" onClick={removeProject} disabled={state.loading} aria-label="移除当前工程" title="从当前会话中移除,不会删除本地文件">移除</button></div><div className="px-3 pb-2 text-xs text-slate-400">{state.files.length} 个文件</div><div className="px-2 pb-3"><ProjectTree files={state.files} entries={state.entries} selectedEntry={state.selectedEntry}/></div></>:<EmptyImport/>}</aside>}
return <div className={`${theme==='light'?'theme-light':'theme-dark'} flex h-screen min-w-[1024px] flex-col overflow-hidden bg-slate-950 text-slate-100`} onDragOver={e=>e.preventDefault()} onDrop={drop}>
<header className="flex h-14 shrink-0 items-center gap-3 border-b border-slate-700 bg-slate-900 px-4"><h1 className="mr-3 text-base font-semibold">MuJoCo Web 仿真平台</h1><label className="btn cursor-pointer">打开文件/ZIP<input className="sr-only" type="file" multiple accept=".xml,.urdf,.zip,.obj,.stl,.dae,.msh,.png,.jpg,.jpeg,.bmp,.tga,.hdr" onChange={changeFiles}/></label><label className="btn cursor-pointer">打开文件夹<input className="sr-only" type="file" multiple {...({webkitdirectory:'',directory:''} as object)} onChange={changeFiles}/></label><span className="h-6 border-l border-slate-700"/><button className="btn" onClick={togglePause} disabled={!state.snapshot}>{state.paused?'▶ 播放':'⏸ 暂停'}</button><button className="btn" onClick={singleStep} disabled={!state.snapshot||!state.paused}>单步</button><button className="btn" onClick={reset} disabled={!state.snapshot}>重置</button><select aria-label="仿真速度" value={state.speed} onChange={e=>changeSpeed(Number(e.target.value))} className="field w-24"><option value={.25}>0.25×</option><option value={.5}>0.5×</option><option value={1}>1×</option><option value={2}>2×</option><option value={4}>4×</option></select><span className="h-6 border-l border-slate-700"/>{(Object.keys(modeLabels) as InteractionMode[]).map(value=><button key={value} className={`btn ${state.mode===value?'border-green-500 bg-green-900/50':''}`} onClick={()=>mode(value)}>{modeLabels[value]}</button>)}<button className="btn ml-auto" onClick={()=>viewer.current?.resetCamera()}>相机复位</button><button className="icon-btn" aria-label="切换工程面板" onClick={()=>setLeftOpen(v=>!v)}>☰</button><button className="icon-btn" aria-label="切换属性面板" onClick={()=>setRightOpen(v=>!v)}>⚙</button><button className="icon-btn" aria-label={theme==='dark'?'切换到白天主题':'切换到黑夜主题'} title={theme==='dark'?'切换到白天主题':'切换到黑夜主题'} onClick={()=>setTheme(value=>value==='dark'?'light':'dark')}><span aria-hidden="true">{theme==='dark'?'☀':'☾'}</span></button></header>
<div className="flex min-h-0 flex-1">{leftOpen&&<aside className="panel w-72 min-w-56 max-w-[40vw] shrink-0 resize-x overflow-auto border-r"><PanelTitle>工程资源</PanelTitle>{state.projectName?<><div className="flex items-center gap-2 px-3 py-2"><div className="min-w-0 flex-1 truncate text-sm font-medium text-green-300" title={state.projectName}>{state.projectName}</div><button type="button" className="btn shrink-0 border-red-900 px-2 text-red-300 hover:border-red-700 hover:bg-red-950" onClick={removeProject} disabled={state.loading} aria-label="移除当前工程" title="从当前会话中移除,不会删除本地文件">移除</button></div><div className="px-3 pb-2 text-xs text-slate-400">{state.files.length} 个文件</div><div className="px-2 pb-3"><ProjectTree files={state.files} entries={state.entries} selectedEntry={state.selectedEntry}/></div>{state.snapshot&&<section className="border-t border-slate-700 px-2 pb-4"><h3 className="px-1 py-2 text-xs font-semibold uppercase tracking-wide text-slate-300">模型结构</h3><ModelStructureTree bodies={state.snapshot.bodies} joints={state.snapshot.joints} onJointHover={jointId=>viewer.current?.highlightJoint(jointId)}/></section>}</>:<EmptyImport/>}</aside>}
<main className="relative min-w-0 flex-1"><div ref={viewerHost} className="absolute inset-0"/>{!state.snapshot&&!state.loading&&<div className="pointer-events-none absolute inset-0 grid place-items-center"><EmptyImport/></div>}{state.loading&&<div role="status" className="absolute inset-0 grid place-items-center bg-slate-950/70"><div className="rounded bg-slate-800 px-5 py-3">正在加载 MuJoCo 与模型…</div></div>}{state.entries.length>1&&!state.selectedEntry&&<EntryDialog entries={state.entries} onSelect={loadEntry}/>} {state.diagnostic&&<DiagnosticCard value={state.diagnostic} onClose={()=>state.setDiagnostic(undefined)}/>}</main>
{rightOpen&&<aside className="panel w-80 min-w-64 max-w-[40vw] shrink-0 resize-x overflow-auto border-l"><PanelTitle>模型与控制</PanelTitle>{state.snapshot?<><section className="section"><h3>模型信息</h3><dl className="grid grid-cols-2 gap-1 text-xs"><dt>Body</dt><dd>{state.snapshot.model.nbody}</dd><dt>Joint</dt><dd>{state.snapshot.model.njnt}</dd><dt>Geom</dt><dd>{state.snapshot.model.ngeom}</dd><dt>Actuator</dt><dd>{state.snapshot.model.nu}</dd><dt>qpos / qvel</dt><dd>{state.snapshot.model.nq} / {state.snapshot.model.nv}</dd></dl></section>{state.entries.find(entry=>entry.path===state.selectedEntry)?.format==='urdf'&&<section className="section"><h3>URDF 处理方式</h3><select aria-label="URDF 处理方式" className="field w-full" value={urdfMode} disabled={state.loading} onChange={event=>changeUrdfMode(event.target.value as UrdfLoadMode)}><option value="mjcf">转换为 MJCF(推荐)</option><option value="native">MuJoCo 原生 URDF</option></select><label className="mt-3 block text-xs text-slate-300"><span className="mb-1 block">基座类型</span><select aria-label="URDF 基座类型" className="field w-full" value={baseMode} disabled={state.loading||urdfMode==='native'} onChange={event=>changeBaseMode(event.target.value as UrdfBaseMode)}><option value="floating">浮动基座(Free Joint)</option><option value="fixed">固定基座(连接世界)</option></select></label><p className="hint mt-2">MJCF 模式会保留 visual mesh、添加物理地面,并将模型最低点对齐到 z=0。浮动基座可受重力和外力运动;固定基座保持与世界固连。</p><label className="mt-3 flex items-center gap-2 text-xs"><input type="checkbox" className="accent-green-500" checked={showCollision} onChange={event=>setShowCollision(event.target.checked)}/>显示碰撞几何</label></section>}{state.snapshot.warnings.length>0&&<section className="section border-amber-700/60 bg-amber-950/20"><h3 className="text-amber-300">URDF 兼容处理</h3><ul className="list-disc space-y-1 pl-4 text-xs text-amber-200">{state.snapshot.warnings.map(message=><li key={message}>{message}</li>)}</ul></section>}<section className="section"><h3>当前选择</h3>{state.selection?<div className="text-xs"><p>{state.selection.bodyName}</p><p className="text-slate-400">body {state.selection.bodyId} · geom {state.selection.geomId} · type {state.selection.geomType}</p><p className="text-slate-400">位置 {state.selection.position.map(v=>v.toFixed(3)).join(', ')}</p></div>:<p className="hint">在视口中单击物体</p>}</section><section className="section"><h3>Actuator</h3>{state.snapshot.actuators.length?state.snapshot.actuators.map(a=><ControlSlider key={a.id} label={a.name} value={a.value} min={a.min} max={a.max} onChange={v=>{adapter.current.setActuator(a.id,v);state.setSnapshot(adapter.current.snapshot()??undefined);}}/>):<p className="hint">模型没有 actuator</p>}</section><section className="section"><h3>关节</h3>{state.snapshot.joints.map(j=><ControlSlider key={j.id} label={`${j.name}${j.editable?'':'(只读)'}`} value={j.value} min={j.min} max={j.max} disabled={!j.editable} onChange={v=>{adapter.current.setJointPosition(j.id,v);state.setPaused(true);state.setSnapshot(adapter.current.snapshot()??undefined);}}/>)}</section><section className="section"><h3>外力强度</h3><ControlSlider label={`${forceScale.toFixed(0)} N/屏幕单位`} value={forceScale} min={5} max={200} onChange={setForceScale}/><p className="hint">选择“外力施加”,在动态物体上按住拖动,松开即清零。</p></section></>:<p className="p-4 text-sm text-slate-400">导入模型后显示属性</p>}</aside>}</div>
{rightOpen&&<aside className="panel w-80 min-w-64 max-w-[40vw] shrink-0 resize-x overflow-auto border-l"><PanelTitle>模型与控制</PanelTitle>{state.snapshot?<><section className="section"><h3>模型信息</h3><dl className="grid grid-cols-2 gap-1 text-xs"><dt>Body</dt><dd>{state.snapshot.model.nbody}</dd><dt>Joint</dt><dd>{state.snapshot.model.njnt}</dd><dt>Geom</dt><dd>{state.snapshot.model.ngeom}</dd><dt>Actuator</dt><dd>{state.snapshot.model.nu}</dd><dt>qpos / qvel</dt><dd>{state.snapshot.model.nq} / {state.snapshot.model.nv}</dd></dl></section>{state.entries.find(entry=>entry.path===state.selectedEntry)?.format==='urdf'&&<section className="section"><h3>URDF 处理方式</h3><select aria-label="URDF 处理方式" className="field w-full" value={urdfMode} disabled={state.loading} onChange={event=>changeUrdfMode(event.target.value as UrdfLoadMode)}><option value="mjcf">转换为 MJCF(推荐)</option><option value="native">MuJoCo 原生 URDF</option></select><label className="mt-3 block text-xs text-slate-300"><span className="mb-1 block">基座类型</span><select aria-label="URDF 基座类型" className="field w-full" value={baseMode} disabled={state.loading||urdfMode==='native'} onChange={event=>changeBaseMode(event.target.value as UrdfBaseMode)}><option value="floating">浮动基座(Free Joint)</option><option value="fixed">固定基座(连接世界)</option></select></label><p className="hint mt-2">MJCF 模式会保留 visual mesh、添加物理地面,并将模型最低点对齐到 z=0。浮动基座可受重力和外力运动;固定基座保持与世界固连。</p><label className="mt-3 flex items-center gap-2 text-xs"><input type="checkbox" className="accent-green-500" checked={showCollision} onChange={event=>setShowCollision(event.target.checked)}/>显示碰撞几何</label></section>}{state.snapshot.warnings.length>0&&<section className="section border-amber-700/60 bg-amber-950/20"><h3 className="text-amber-300">URDF 兼容处理</h3><ul className="list-disc space-y-1 pl-4 text-xs text-amber-200">{state.snapshot.warnings.map(message=><li key={message}>{message}</li>)}</ul></section>}<section className="section"><h3>当前选择</h3>{state.selection?<div className="text-xs"><p>{state.selection.bodyName}</p><p className="text-slate-400">body {state.selection.bodyId} · geom {state.selection.geomId} · type {state.selection.geomType}</p><p className="text-slate-400">位置 {state.selection.position.map(v=>v.toFixed(3)).join(', ')}</p></div>:<p className="hint">在视口中单击物体</p>}</section><section className="section"><h3>Actuator</h3>{state.snapshot.actuators.length?state.snapshot.actuators.map(a=><ControlSlider key={a.id} label={a.name} value={a.value} min={a.min} max={a.max} onChange={v=>{adapter.current.setActuator(a.id,v);state.setSnapshot(adapter.current.snapshot()??undefined);}}/>):<p className="hint">模型没有 actuator</p>}</section><section className="section"><h3>关节</h3><div className="mb-4 grid grid-cols-2 gap-2"><button className="btn" onClick={resetJoints}>重置关节</button><button className={`btn ${ignoreJointLimits?'border-amber-500 bg-amber-950/50':''}`} aria-pressed={ignoreJointLimits} onClick={toggleJointLimits}>忽略关节限位</button><button className={`btn ${jointAdvanced?'border-green-500 bg-green-900/50':''}`} aria-pressed={jointAdvanced} onClick={()=>setJointAdvanced(value=>!value)}>高级</button><button className={`btn ${angleUnit==='deg'?'border-green-500 bg-green-900/50':''}`} aria-pressed={angleUnit==='deg'} onClick={()=>setAngleUnit(value=>value==='rad'?'deg':'rad')}>{angleUnit==='rad'?'rad 弧度制':'° 角度制'}</button></div>{state.snapshot.joints.map(j=>{const scale=j.type===3&&angleUnit==='deg'?180/Math.PI:1,unit=j.type===3?(angleUnit==='deg'?'°':' rad'):j.type===2?' m':'';return <ControlSlider key={j.id} label={`${j.name}${j.editable?'':'(只读)'}`} value={j.value*scale} min={j.min*scale} max={j.max*scale} unit={unit} advanced={jointAdvanced} limited={j.limited} limitsIgnored={j.limitsIgnored} limitMin={j.limitMin*scale} limitMax={j.limitMax*scale} disabled={!j.editable} onChange={v=>{adapter.current.setJointPosition(j.id,v/scale);state.setPaused(true);state.setSnapshot(adapter.current.snapshot()??undefined);}}/>;})}</section><section className="section"><h3>外力强度</h3><ControlSlider label={`${forceScale.toFixed(0)} N/屏幕单位`} value={forceScale} min={5} max={200} onChange={setForceScale}/><p className="hint">选择“外力施加”,在动态物体上按住拖动,松开即清零。</p></section></>:<p className="p-4 text-sm text-slate-400">导入模型后显示属性</p>}</aside>}</div>
<footer className="flex h-7 shrink-0 items-center gap-5 border-t border-slate-700 bg-slate-900 px-3 text-xs text-slate-400"><span>时间 {state.snapshot?.time.toFixed(3)??'—'} s</span><span>FPS {state.fps.toFixed(0)}</span><span>物理 {state.stepMs.toFixed(2)} ms</span><span>内存 {state.memoryMb===undefined?'—':`${state.memoryMb.toFixed(1)} MiB`}</span><span>WASM {state.snapshot?'已加载':'未加载'}</span>{state.overBudget&&<span className="text-amber-400">主线程超出步进预算,已限制追帧</span>}<span className="ml-auto">快捷键:Space 播放/暂停 · R 重置 · 1/2/3 模式</span></footer>
</div>;
}
@@ -40,4 +44,4 @@ function PanelTitle({children}:{children:string}){return <h2 className="sticky t
function EmptyImport(){return <div className="rounded-lg border border-dashed border-slate-600 bg-slate-900/80 p-6 text-center"><p className="font-medium">拖放模型工程到此处</p><p className="mt-1 text-xs text-slate-400">支持 MJCF/XML、URDF、文件夹和 ZIP</p></div>}
function EntryDialog({entries,onSelect}:{entries:{path:string;label:string}[];onSelect:(path:string)=>void}){return <div role="dialog" aria-modal="true" aria-label="选择模型入口" className="absolute inset-0 grid place-items-center bg-slate-950/80"><div className="w-[32rem] rounded border border-slate-600 bg-slate-900 p-5"><h2 className="font-semibold">选择模型入口</h2><p className="mt-1 text-sm text-slate-400">工程包含多个可加载模型,请选择一个。</p><div className="mt-4 space-y-2">{entries.map(e=><button className="btn block w-full overflow-hidden text-ellipsis text-left" key={e.path} onClick={()=>onSelect(e.path)}>{e.label}</button>)}</div></div></div>}
function DiagnosticCard({value,onClose}:{value:AppDiagnostic;onClose:()=>void}){return <section role="alert" className="absolute bottom-4 left-4 right-4 max-h-48 overflow-auto rounded border border-red-700 bg-red-950/95 p-3 shadow-xl"><button aria-label="关闭错误" className="float-right" onClick={onClose}>×</button><h2 className="font-semibold text-red-200">{value.summary}</h2>{value.path&&<p className="mt-1 text-xs text-red-300">路径:{value.path}</p>}<pre className="mt-2 whitespace-pre-wrap text-xs text-red-100">{value.detail}</pre></section>}
function ControlSlider({label,value,min,max,onChange,disabled=false}:{label:string;value:number;min:number;max:number;onChange:(v:number)=>void;disabled?:boolean}){const sane=Number.isFinite(value)?value:0;return <label className="mb-3 block text-xs"><span className="mb-1 flex justify-between"><span className="truncate pr-2">{label}</span><output>{sane.toFixed(3)}</output></span><input className="w-full accent-green-500" type="range" disabled={disabled} value={Math.min(max,Math.max(min,sane))} min={min} max={max} step={(max-min)/500||.001} onChange={e=>onChange(Number(e.target.value))}/></label>}
function ControlSlider({label,value,min,max,onChange,disabled=false,unit='',advanced=false,limited=false,limitsIgnored=false,limitMin=0,limitMax=0}:{label:string;value:number;min:number;max:number;onChange:(v:number)=>void;disabled?:boolean;unit?:string;advanced?:boolean;limited?:boolean;limitsIgnored?:boolean;limitMin?:number;limitMax?:number}){const sane=Number.isFinite(value)?value:0,format=(number:number)=>`${number.toFixed(3)}${unit}`;return <label className="mb-3 block text-xs"><span className="mb-1 flex justify-between"><span className="truncate pr-2">{label}</span><output>{format(sane)}</output></span><input className="w-full accent-green-500" type="range" disabled={disabled} value={Math.min(max,Math.max(min,sane))} min={min} max={max} step={(max-min)/500||.001} onChange={e=>onChange(Number(e.target.value))}/>{advanced&&<span className="mt-1 flex justify-between text-[10px] text-slate-500"><span>下限 {limited?format(limitMin):'无限制'}</span>{limitsIgnored&&limited&&<span className="text-amber-400">已忽略</span>}<span>上限 {limited?format(limitMax):'无限制'}</span></span>}</label>}