/* Zustand 的 action 引用稳定;初始化 viewer 与导入回调有意只创建一次。 */ /* eslint-disable react-hooks/exhaustive-deps */ import {useCallback,useEffect,useRef,useState,type ChangeEvent,type DragEvent} from 'react'; import type {ProjectManifest} from '../project/types'; import {filesFromDrop,importBrowserFiles,ProjectImportError} from '../project/importer'; import {ProjectTree} from '../project/ProjectTree'; import {MainThreadPhysicsAdapter,type UrdfBaseMode,type UrdfLoadMode} from '../simulation/PhysicsAdapter'; import {MuJoCoViewer,type InteractionMode} 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={select:'选择',joint:'关节拖动',force:'外力施加'}; export function App(){ const state=useAppStore(); const manifest=useRef(null); const adapter=useRef(new MainThreadPhysicsAdapter()); const viewerHost=useRef(null); const viewer=useRef(null); const [forceScale,setForceScale]=useState(50); const [leftOpen,setLeftOpen]=useState(true);const [rightOpen,setRightOpen]=useState(true);const [urdfMode,setUrdfMode]=useState('mjcf');const urdfModeRef=useRef('mjcf');const [baseMode,setBaseMode]=useState('floating');const baseModeRef=useRef('floating');const [showCollision,setShowCollision]=useState(false); 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);}},[]); 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);}; const changeBaseMode=(value:UrdfBaseMode)=>{setBaseMode(value);baseModeRef.current=value;const entry=state.entries.find(candidate=>candidate.path===state.selectedEntry);if(entry?.format==='urdf'&&urdfModeRef.current==='mjcf')void loadEntry(entry.path,'mjcf');}; const changeFiles=(event:ChangeEvent)=>{void ingest(Array.from(event.target.files??[]));event.target.value='';}; const drop=(event:DragEvent)=>{event.preventDefault();void filesFromDrop(event.dataTransfer.items,event.dataTransfer.files).then(ingest).catch((e)=>state.setDiagnostic(diagnostic('导入',e)));}; const togglePause=()=>{const value=!state.paused;state.setPaused(value);adapter.current.setPaused(value);}; const reset=()=>{adapter.current.setPaused(true);adapter.current.reset();state.setSnapshot(adapter.current.snapshot()??undefined);state.setPaused(true);}; 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);}; 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
e.preventDefault()} onDrop={drop}>

MuJoCo Web 仿真平台

{(Object.keys(modeLabels) as InteractionMode[]).map(value=>)}
{leftOpen&&}
{!state.snapshot&&!state.loading&&
}{state.loading&&
正在加载 MuJoCo 与模型…
}{state.entries.length>1&&!state.selectedEntry&&} {state.diagnostic&&state.setDiagnostic(undefined)}/>}
{rightOpen&&}
时间 {state.snapshot?.time.toFixed(3)??'—'} sFPS {state.fps.toFixed(0)}物理 {state.stepMs.toFixed(2)} ms内存 {state.memoryMb===undefined?'—':`${state.memoryMb.toFixed(1)} MiB`}WASM {state.snapshot?'已加载':'未加载'}{state.overBudget&&主线程超出步进预算,已限制追帧}快捷键:Space 播放/暂停 · R 重置 · 1/2/3 模式
; } function PanelTitle({children}:{children:string}){return

{children}

} function EmptyImport(){return

拖放模型工程到此处

支持 MJCF/XML、URDF、文件夹和 ZIP

} function EntryDialog({entries,onSelect}:{entries:{path:string;label:string}[];onSelect:(path:string)=>void}){return

选择模型入口

工程包含多个可加载模型,请选择一个。

{entries.map(e=>)}
} function DiagnosticCard({value,onClose}:{value:AppDiagnostic;onClose:()=>void}){return

{value.summary}

{value.path&&

路径:{value.path}

}
{value.detail}
} 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 }