Files
Mujoco_WASM/wasm/web_platform/src/app/App.tsx
T

115 lines
31 KiB
TypeScript

/* Zustand 的 action 引用稳定;初始化 viewer 与导入回调有意只创建一次。 */
/* eslint-disable react-hooks/exhaustive-deps */
import {useCallback,useEffect,useRef,useState,type ChangeEvent,type DragEvent} from 'react';
import {Camera,ChevronLeft,ChevronRight,CircleHelp,Code2,Crosshair,Download,Hand,Maximize,MousePointer2,PanelsTopLeft,Pause,Play,RotateCcw,Settings as SettingsIcon,SunMoon} from 'lucide-react';
import {DEFAULT_IMPORT_LIMITS,type ProjectManifest} from '../project/types';
import {filesFromDrop,importBrowserFiles,normalizeProjectPath,ProjectImportError} from '../project/importer';
import {MainThreadPhysicsAdapter,type UrdfBaseMode,type UrdfEnhancementOptions,type UrdfLoadMode} from '../simulation/PhysicsAdapter';
import type {ActuatorParameters} from '../simulation/SimulationSession';
import type {ControllerCommand,ControllerStatus} from '../controller/types';
import type {RLCommand,RLPolicyStatus} from '../rl/types';
import {MuJoCoViewer,type InteractionMode,type ViewerTheme} from '../viewer/MuJoCoViewer';
import {useAppStore,type AppDiagnostic} from '../stores/useAppStore';
import {WorkbenchHeader} from './components/WorkbenchHeader';
import {ViewerToolDock} from './components/ViewerToolDock';
import {ProjectSidebar,ModelControlsSidebar} from './components/SidebarPanel';
import {WorkspaceOverlays,type ImportProgress} from './components/WorkspaceOverlays';
import {EntrySelectionDialog} from './components/EntrySelectionDialog';
import {ErrorRecoveryPanel} from './components/ErrorRecoveryPanel';
import {StatusBar} from './components/StatusBar';
import {ViewportHUD} from './components/ViewportHUD';
import {ShortcutHelpDialog} from './components/ShortcutHelpDialog';
import {CommandPalette,type WorkbenchCommand} from './components/CommandPalette';
import {NotificationCenter,ToastViewport,type WorkbenchNotification} from './components/NotificationCenter';
import {SettingsDialog} from './components/SettingsDialog';
import {dispatchLayoutWidths,LayoutSettingsDialog,type LayoutPreset} from './components/LayoutSettingsDialog';
import {Button,ConfirmDialog,IconButton} from '../components/ui';
import {DiagnosticsDrawer} from './components/DiagnosticsDrawer';
import {ToolbarOverflowMenu} from './components/ToolbarOverflowMenu';
import {SourceEditorDialog} from './components/SourceEditorDialog';
import {UrdfImportOptionsDialog} from './components/UrdfImportOptionsDialog';
import {downloadBytes,exportedFileName,mergeCachedFiles,readCachedText,upsertCachedMjcf} from '../project/cachedFiles';
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()};}
function initialTheme():ViewerTheme{try{return localStorage.getItem('mujoco-platform-theme')==='light'?'light':'dark';}catch{return'dark';}}
function convertedCachePath(entryPath:string):string{const slash=entryPath.lastIndexOf('/');return `${slash>=0?entryPath.slice(0,slash+1):''}.__converted_mjcf_cache__.xml`;}
function urdfLinkNames(project:ProjectManifest|null,path:string|undefined):string[]{const file=path?project?.files.find(candidate=>candidate.path===path):undefined;if(!file)return[];const document=new DOMParser().parseFromString(new TextDecoder().decode(file.data),'application/xml');return Array.from(document.querySelectorAll('robot > link[name]')).map(link=>link.getAttribute('name')).filter((name):name is string=>Boolean(name));}
export function App(){
const state=useAppStore();
const manifest=useRef<ProjectManifest|null>(null),notificationId=useRef(0),loadInFlight=useRef(false),importInFlight=useRef(false),adapter=useRef(new MainThreadPhysicsAdapter()),root=useRef<HTMLDivElement>(null),viewerHost=useRef<HTMLDivElement>(null),viewer=useRef<MuJoCoViewer|null>(null),urdfEnhancementsRef=useRef<UrdfEnhancementOptions>({addActuators:true,addSensors:true,sensorType:'camera'});
const [forceScale,setForceScale]=useState(50),[leftOpen,setLeftOpen]=useState(true),[rightOpen,setRightOpen]=useState(true),[helpOpen,setHelpOpen]=useState(false),[commandOpen,setCommandOpen]=useState(false),[sourceOpen,setSourceOpen]=useState(false),[generatedMjcf,setGeneratedMjcf]=useState<string>(),[generatedMjcfPath,setGeneratedMjcfPath]=useState<string>(),[pendingUrdfPath,setPendingUrdfPath]=useState<string>(),[pendingUrdfMounts,setPendingUrdfMounts]=useState<string[]>([]),[removeConfirmOpen,setRemoveConfirmOpen]=useState(false),[fullscreen,setFullscreen]=useState(false),[settingsOpen,setSettingsOpen]=useState(false),[layoutOpen,setLayoutOpen]=useState(false),[diagnosticsOpen,setDiagnosticsOpen]=useState(false),[importProgress,setImportProgress]=useState<ImportProgress>(),[notifications,setNotifications]=useState<WorkbenchNotification[]>([]),[toast,setToast]=useState<WorkbenchNotification>(),[selectedControllerPath,setSelectedControllerPath]=useState<string>(),[controllerStatus,setControllerStatus]=useState<ControllerStatus>(),[selectedPolicyPath,setSelectedPolicyPath]=useState<string>(),[policyStatus,setPolicyStatus]=useState<RLPolicyStatus>();
const [urdfMode,setUrdfMode]=useState<UrdfLoadMode>('mjcf'),urdfModeRef=useRef<UrdfLoadMode>('mjcf');
const [baseMode,setBaseMode]=useState<UrdfBaseMode>('floating'),baseModeRef=useRef<UrdfBaseMode>('floating');
const [showCollision,setShowCollision]=useState(false),[showSensorCamera,setShowSensorCamera]=useState(true),[theme,setTheme]=useState<ViewerTheme>(initialTheme),[jointAdvanced,setJointAdvanced]=useState(false),[ignoreJointLimits,setIgnoreJointLimits]=useState(false),[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);setControllerStatus(snapshot.controller);setPolicyStatus(snapshot.rlPolicy);if(snapshot.controller?.error||snapshot.rlPolicy?.error){adapter.current.setPaused(true);state.setPaused(true);}}},onError:error=>state.setDiagnostic(diagnostic(error.message.includes('控制器')?'仿真':'渲染',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]);
useEffect(()=>{viewer.current?.setShowSensorCamera(showSensorCamera);},[showSensorCamera]);
useEffect(()=>{viewer.current?.setTheme(theme);document.documentElement.style.colorScheme=theme;try{localStorage.setItem('mujoco-platform-theme',theme);}catch{/* 当前会话仍可切换 */}},[theme]);
useEffect(()=>{const change=()=>setFullscreen(document.fullscreenElement===root.current);document.addEventListener('fullscreenchange',change);return()=>document.removeEventListener('fullscreenchange',change);},[]);
const loadEntry=useCallback(async(path:string,requestedMode?:UrdfLoadMode)=>{if(!manifest.current||loadInFlight.current)return;loadInFlight.current=true;setIgnoreJointLimits(false);setControllerStatus(undefined);setPolicyStatus(undefined);state.setEntry(path);state.setLoading(true);setImportProgress({label:'初始化 WASM 与编译模型',value:.65});state.setDiagnostic(undefined);setGeneratedMjcf(undefined);setGeneratedMjcfPath(undefined);viewer.current?.attach(null);state.setSnapshot(undefined);state.setSelection(null);try{const snapshot=await adapter.current.load(manifest.current,path,requestedMode??urdfModeRef.current,baseModeRef.current,urdfEnhancementsRef.current);const supportFiles=adapter.current.cachedSupportFiles();if(supportFiles.length&&manifest.current){manifest.current=mergeCachedFiles(manifest.current,supportFiles);state.setProject(manifest.current.name,manifest.current.files.map(file=>({path:file.path,size:file.size})),manifest.current.entries,path);}setImportProgress({label:'创建视口场景',value:.92});adapter.current.setSpeed(useAppStore.getState().speed);state.setSnapshot(snapshot);state.setPaused(true);viewer.current?.attach(adapter.current.session);try{setGeneratedMjcf(new TextDecoder().decode(adapter.current.exportMjcf()));setGeneratedMjcfPath(convertedCachePath(path));}catch(error){console.warn('[MuJoCo] 无法生成源码预览',error);}const notice:WorkbenchNotification={id:++notificationId.current,title:snapshot.warnings.length?'URDF 兼容处理':'模型加载完成',detail:snapshot.warnings.length?snapshot.warnings.join('\n'):path,tone:snapshot.warnings.length?'warning':'success',at:Date.now()};setNotifications(items=>[notice,...items].slice(0,20));setToast(notice);}catch(error){state.setDiagnostic(diagnostic('模型编译',error,path));const notice:WorkbenchNotification={id:++notificationId.current,title:'模型编译失败',detail:error instanceof Error?error.message:String(error),tone:'danger',at:Date.now()};setNotifications(items=>[notice,...items].slice(0,20));setToast(notice);}finally{loadInFlight.current=false;setImportProgress(undefined);state.setLoading(false);}},[]);
const requestLoadEntry=useCallback(async(path:string)=>{const entry=manifest.current?.entries.find(candidate=>candidate.path===path);if(entry?.format==='urdf'&&urdfModeRef.current==='mjcf'){setPendingUrdfMounts(urdfLinkNames(manifest.current,path));setPendingUrdfPath(path);return;}await loadEntry(path);},[loadEntry]);
const confirmUrdfOptions=(options:UrdfEnhancementOptions)=>{const path=pendingUrdfPath;if(!path)return;urdfEnhancementsRef.current=options;setPendingUrdfPath(undefined);setPendingUrdfMounts([]);void loadEntry(path);};
const skipUrdfOptions=()=>confirmUrdfOptions({addActuators:false,addSensors:false,sensorType:'camera'});
const ingest=useCallback(async(files:File[],lockOwned=false)=>{if(importInFlight.current&&!lockOwned)return;importInFlight.current=true;state.setLoading(true);setImportProgress({label:'读取工程文件',value:.12});try{const next=await importBrowserFiles(files);setImportProgress({label:'处理模型资源与入口',value:.38});manifest.current=next;setSelectedControllerPath(next.files.find(file=>/\.py$/i.test(file.path))?.path);setSelectedPolicyPath(next.files.find(file=>/\.onnx$/i.test(file.path))?.path);state.setProject(next.name,next.files.map(({path,size})=>({path,size})),next.entries,next.selectedEntry);if(next.selectedEntry)await requestLoadEntry(next.selectedEntry);}catch(error){state.setDiagnostic(diagnostic(error instanceof ProjectImportError&&/ZIP/.test(error.message)?'ZIP':'导入',error,error instanceof ProjectImportError?error.path:undefined));const notice:WorkbenchNotification={id:++notificationId.current,title:'工程导入失败',detail:error instanceof Error?error.message:String(error),tone:'danger',at:Date.now()};setNotifications(items=>[notice,...items].slice(0,20));setToast(notice);}finally{importInFlight.current=false;setImportProgress(undefined);state.setLoading(false);}},[requestLoadEntry]);
const removeProject=()=>{if(state.projectName)setRemoveConfirmOpen(true);};
const confirmRemoveProject=()=>{viewer.current?.attach(null);adapter.current.dispose();manifest.current=null;setGeneratedMjcf(undefined);setGeneratedMjcfPath(undefined);setPendingUrdfPath(undefined);setPendingUrdfMounts([]);setSelectedControllerPath(undefined);setControllerStatus(undefined);setSelectedPolicyPath(undefined);setPolicyStatus(undefined);state.clearProject();setRemoveConfirmOpen(false);};
const changeUrdfMode=(value:UrdfLoadMode)=>{setUrdfMode(value);urdfModeRef.current=value;const entry=state.entries.find(candidate=>candidate.path===state.selectedEntry);if(entry?.format!=='urdf')return;if(value==='mjcf'){setPendingUrdfMounts(urdfLinkNames(manifest.current,entry.path));setPendingUrdfPath(entry.path);}else 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<HTMLInputElement>)=>{void ingest(Array.from(event.target.files??[]));event.target.value='';};
const drop=(event:DragEvent)=>{event.preventDefault();if(state.loading||importInFlight.current)return;importInFlight.current=true;state.setLoading(true);setImportProgress({label:'读取拖放文件',value:.05});void (async()=>{try{const files=await filesFromDrop(event.dataTransfer.items,event.dataTransfer.files);await ingest(files,true);}catch(error){importInFlight.current=false;setImportProgress(undefined);state.setLoading(false);state.setDiagnostic(diagnostic('导入',error));}})();};
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);
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);};
const setActuator=(id:number,value:number)=>{adapter.current.setActuator(id,value);state.setSnapshot(adapter.current.snapshot()??undefined);};
const setActuatorParameters=(id:number,parameters:ActuatorParameters)=>{if(!adapter.current.setActuatorParameters(id,parameters))return;state.setSnapshot(adapter.current.snapshot()??undefined);try{setGeneratedMjcf(new TextDecoder().decode(adapter.current.exportMjcf()));}catch(error){console.warn('[MuJoCo] 无法刷新驱动器参数源码',error);}};
const setJoint=(id:number,value:number)=>{adapter.current.setJointPosition(id,value);state.setPaused(true);state.setSnapshot(adapter.current.snapshot()??undefined);};
const loadControllerSource=async(source:string,path:string)=>{state.setLoading(true);setImportProgress({label:'初始化 Python 运行时并加载控制器',value:.5});state.setDiagnostic(undefined);try{const status=await adapter.current.loadPythonController(source,path);setControllerStatus(status);state.setSnapshot(adapter.current.snapshot()??undefined);notify('Python 控制器已加载',`${status.name} · ${status.controlHz} Hz`);}catch(error){state.setDiagnostic(diagnostic('仿真',error,path));}finally{setImportProgress(undefined);state.setLoading(false);}};
const loadControllerPath=(path:string)=>{const file=manifest.current?.files.find(candidate=>candidate.path===path);if(!file){state.setDiagnostic(diagnostic('仿真',new Error('工程中找不到控制脚本'),path));return;}setSelectedControllerPath(path);void loadControllerSource(new TextDecoder().decode(file.data),path);};
const importController=(file:File)=>{void (async()=>{try{if(!/\.py$/i.test(file.name))throw new Error('请选择 .py 文件');if(file.size>1024*1024)throw new Error('Python 控制脚本不能超过 1 MiB');const path=normalizeProjectPath(file.name),data=new Uint8Array(await file.arrayBuffer());if(manifest.current){const index=manifest.current.files.findIndex(candidate=>candidate.path===path),files=manifest.current.files.slice(),entry={path,data,size:data.byteLength,source:'file' as const,mimeType:file.type||'text/x-python'};if(index>=0)files[index]=entry;else files.push(entry);manifest.current={...manifest.current,files,totalBytes:files.reduce((total,item)=>total+item.size,0)};state.setProject(manifest.current.name,files.map(({path:filePath,size})=>({path:filePath,size})),manifest.current.entries,manifest.current.selectedEntry);state.setSnapshot(adapter.current.snapshot()??undefined);}setSelectedControllerPath(path);await loadControllerSource(new TextDecoder().decode(data),path);}catch(error){state.setDiagnostic(diagnostic('仿真',error,file.name));}})();};
const toggleController=(enabled:boolean)=>{adapter.current.setControllerEnabled(enabled);const snapshot=adapter.current.snapshot()??undefined;setControllerStatus(snapshot?.controller);setPolicyStatus(snapshot?.rlPolicy);state.setSnapshot(snapshot);};
const sendControllerCommand=(command:ControllerCommand)=>{try{adapter.current.sendControllerCommand(command);const snapshot=adapter.current.snapshot()??undefined;setControllerStatus(snapshot?.controller);state.setSnapshot(snapshot);}catch(error){state.setDiagnostic(diagnostic('仿真',error,selectedControllerPath));}};
const removeController=()=>{adapter.current.removeController();setControllerStatus(undefined);state.setSnapshot(adapter.current.snapshot()??undefined);};
const loadPolicyBytes=async(data:Uint8Array,path:string)=>{state.setLoading(true);setImportProgress({label:'初始化 ONNX Runtime 并加载策略',value:.55});state.setDiagnostic(undefined);try{const status=await adapter.current.loadRLPolicy(data,path);setPolicyStatus(status);state.setSnapshot(adapter.current.snapshot()??undefined);notify('ONNX 策略已加载',`${status.taskName} · ${status.observationSize}${status.actionSize}`);}catch(error){state.setDiagnostic(diagnostic('仿真',error,path));}finally{setImportProgress(undefined);state.setLoading(false);}};
const loadPolicyPath=(path:string)=>{const file=manifest.current?.files.find(candidate=>candidate.path===path);if(!file){state.setDiagnostic(diagnostic('仿真',new Error('工程中找不到 ONNX 策略'),path));return;}setSelectedPolicyPath(path);void loadPolicyBytes(file.data,path);};
const importPolicy=(file:File)=>{void (async()=>{try{if(!/\.onnx$/i.test(file.name))throw new Error('请选择 .onnx 文件');if(file.size>64*1024*1024)throw new Error('ONNX 策略不能超过 64 MiB');const path=normalizeProjectPath(file.name),data=new Uint8Array(await file.arrayBuffer());if(manifest.current){const index=manifest.current.files.findIndex(candidate=>candidate.path===path),files=manifest.current.files.slice(),entry={path,data,size:data.byteLength,source:'file' as const,mimeType:file.type||'application/octet-stream'};if(index>=0)files[index]=entry;else files.push(entry);const totalBytes=files.reduce((total,item)=>total+item.size,0);if(totalBytes>DEFAULT_IMPORT_LIMITS.maxTotalBytes)throw new Error('加入 ONNX 后工程总大小超过 512 MiB');manifest.current={...manifest.current,files,totalBytes};state.setProject(manifest.current.name,files.map(({path:filePath,size})=>({path:filePath,size})),manifest.current.entries,manifest.current.selectedEntry);state.setSnapshot(adapter.current.snapshot()??undefined);}setSelectedPolicyPath(path);await loadPolicyBytes(data,path);}catch(error){state.setDiagnostic(diagnostic('仿真',error,file.name));}})();};
const togglePolicy=(enabled:boolean)=>{adapter.current.setRLPolicyEnabled(enabled);const snapshot=adapter.current.snapshot()??undefined;setPolicyStatus(snapshot?.rlPolicy);setControllerStatus(snapshot?.controller);state.setSnapshot(snapshot);};
const setPolicyCommand=(command:RLCommand)=>{adapter.current.setRLCommand(command);const snapshot=adapter.current.snapshot()??undefined;setPolicyStatus(snapshot?.rlPolicy);state.setSnapshot(snapshot);};
const removePolicy=()=>{adapter.current.removeRLPolicy();setPolicyStatus(undefined);state.setSnapshot(adapter.current.snapshot()??undefined);};
const notify=(title:string,detail:string,tone:WorkbenchNotification['tone']='success')=>{const notice:WorkbenchNotification={id:++notificationId.current,title,detail,tone,at:Date.now()};setNotifications(items=>[notice,...items].slice(0,20));setToast(notice);};
const saveCachedSource=async(path:string,text:string)=>{if(!manifest.current)return;manifest.current=upsertCachedMjcf(manifest.current,path,text);state.setProject(manifest.current.name,manifest.current.files.map(file=>({path:file.path,size:file.size})),manifest.current.entries,path);notify('转换后的 MJCF 已保存到缓存',path);await loadEntry(path);};
const exportUrdf=()=>{if(!manifest.current||selectedFormat!=='urdf'||!state.selectedEntry)return;const text=readCachedText(manifest.current,state.selectedEntry);downloadBytes(new TextEncoder().encode(text),exportedFileName(manifest.current.name,'urdf'));notify('URDF 已导出',state.selectedEntry);};
const exportMjcf=()=>{try{const data=adapter.current.exportMjcf();downloadBytes(data,exportedFileName(manifest.current?.name??'model','xml'));notify('MJCF 已导出','导出内容来自当前已编译模型');}catch(error){state.setDiagnostic(diagnostic('模型编译',error,state.selectedEntry));}};
const toggleFullscreen=()=>{if(document.fullscreenElement)void document.exitFullscreen().catch(()=>{});else if(root.current)void root.current.requestFullscreen().catch(()=>{});};
const applyLayoutPreset=(preset:LayoutPreset)=>{if(preset==='viewport'){setLeftOpen(false);setRightOpen(false);dispatchLayoutWidths(288,288);}else if(preset==='project'){setLeftOpen(true);setRightOpen(false);dispatchLayoutWidths(384,288);}else if(preset==='control'){setLeftOpen(false);setRightOpen(true);dispatchLayoutWidths(288,384);}else{setLeftOpen(true);setRightOpen(true);dispatchLayoutWidths(288,288);}};
useEffect(()=>{const key=(event:KeyboardEvent)=>{if(document.activeElement instanceof HTMLElement&&document.activeElement.closest('[role="dialog"]'))return;if((event.ctrlKey||event.metaKey)&&event.key.toLocaleLowerCase()==='k'){event.preventDefault();setCommandOpen(true);return;}if((event.target as HTMLElement).matches('input,select,button'))return;if(event.code==='Space'){event.preventDefault();togglePause();}if(event.key==='r')reset();if(event.key==='1')mode('select');if(event.key==='2')mode('joint');if(event.key==='3')mode('force');};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);});
const selectedFormat=state.entries.find(entry=>entry.path===state.selectedEntry)?.format;
const commands:WorkbenchCommand[]=[
{id:'play',label:state.paused?'播放仿真':'暂停仿真',group:'仿真',icon:state.paused?<Play className="h-4 w-4"/>:<Pause className="h-4 w-4"/>,shortcut:'Space',disabled:!state.snapshot,run:togglePause},
{id:'reset',label:'重置仿真',group:'仿真',icon:<RotateCcw className="h-4 w-4"/>,shortcut:'R',disabled:!state.snapshot,run:reset},
{id:'select',label:'切换到选择模式',group:'视口',icon:<MousePointer2 className="h-4 w-4"/>,shortcut:'1',run:()=>mode('select')},
{id:'joint',label:'切换到关节拖动',group:'视口',icon:<Hand className="h-4 w-4"/>,shortcut:'2',run:()=>mode('joint')},
{id:'force',label:'切换到外力施加',group:'视口',icon:<Crosshair className="h-4 w-4"/>,shortcut:'3',run:()=>mode('force')},
{id:'camera',label:'复位相机',group:'视口',icon:<Camera className="h-4 w-4"/>,run:()=>viewer.current?.resetCamera()},
{id:'source',label:'查看和修改缓存源代码',group:'工程',icon:<Code2 className="h-4 w-4"/>,disabled:!generatedMjcf,run:()=>setSourceOpen(true)},
{id:'export-urdf',label:'导出 URDF 文件',group:'工程',icon:<Download className="h-4 w-4"/>,disabled:selectedFormat!=='urdf',run:exportUrdf},
{id:'export-mjcf',label:'导出 MJCF 文件',group:'工程',icon:<Download className="h-4 w-4"/>,disabled:!state.snapshot,run:exportMjcf},
{id:'left',label:leftOpen?'隐藏工程面板':'显示工程面板',group:'布局',icon:leftOpen?<ChevronLeft className="h-4 w-4"/>:<ChevronRight className="h-4 w-4"/>,run:()=>setLeftOpen(value=>!value)},
{id:'right',label:rightOpen?'隐藏属性面板':'显示属性面板',group:'布局',icon:rightOpen?<ChevronRight className="h-4 w-4"/>:<ChevronLeft className="h-4 w-4"/>,run:()=>setRightOpen(value=>!value)},
{id:'theme',label:theme==='dark'?'切换到白天主题':'切换到黑夜主题',group:'外观',icon:<SunMoon className="h-4 w-4"/>,run:()=>setTheme(value=>value==='dark'?'light':'dark')},
{id:'fullscreen',label:fullscreen?'退出全屏':'进入全屏',group:'布局',icon:<Maximize className="h-4 w-4"/>,run:toggleFullscreen},
{id:'help',label:'查看快捷键帮助',group:'帮助',icon:<CircleHelp className="h-4 w-4"/>,run:()=>setHelpOpen(true)},
];
return <div ref={root} className={`${theme==='light'?'theme-light':'theme-dark'} flex h-screen min-w-[1024px] flex-col overflow-hidden bg-app text-text-primary`} onDragOver={event=>event.preventDefault()} onDrop={drop}>
<WorkbenchHeader paused={state.paused} ready={Boolean(state.snapshot)} speed={state.speed} theme={theme} loading={state.loading} leftOpen={leftOpen} rightOpen={rightOpen} fullscreen={fullscreen} hasProject={Boolean(generatedMjcf)} onFiles={changeFiles} onFolder={changeFiles} onOpenSource={()=>setSourceOpen(true)} onTogglePause={togglePause} onStep={singleStep} onReset={reset} onSpeed={changeSpeed} onToggleLeft={()=>setLeftOpen(value=>!value)} onToggleRight={()=>setRightOpen(value=>!value)} onToggleTheme={()=>setTheme(value=>value==='dark'?'light':'dark')} onHelp={()=>setHelpOpen(true)} endActions={<><NotificationCenter items={notifications} onDismiss={id=>setNotifications(items=>items.filter(item=>item.id!==id))} onClear={()=>setNotifications([])} onOpenLog={()=>setDiagnosticsOpen(true)}/><span className="hidden items-center gap-0.5 xl:flex"><IconButton tooltip="布局设置" aria-label="布局设置" onClick={()=>setLayoutOpen(true)}><PanelsTopLeft className="h-4 w-4"/></IconButton><IconButton tooltip="工作台设置" aria-label="工作台设置" onClick={()=>setSettingsOpen(true)}><SettingsIcon className="h-4 w-4"/></IconButton></span></>} compactMenu={<ToolbarOverflowMenu fullscreen={fullscreen} onCommands={()=>setCommandOpen(true)} onLayout={()=>setLayoutOpen(true)} onSettings={()=>setSettingsOpen(true)} onFullscreen={toggleFullscreen} onHelp={()=>setHelpOpen(true)} onTheme={()=>setTheme(value=>value==='dark'?'light':'dark')}/>} onCommands={()=>setCommandOpen(true)} onToggleFullscreen={toggleFullscreen} center={<ViewerToolDock mode={state.mode} onModeChange={mode} onResetCamera={()=>viewer.current?.resetCamera()}/>}/>
<div className="flex min-h-0 flex-1"><ProjectSidebar visible={leftOpen} projectName={state.projectName} files={state.files} entries={state.entries} selectedEntry={state.selectedEntry} snapshot={state.snapshot} loading={state.loading} onRemove={removeProject} onSelectEntry={requestLoadEntry} onJointHover={jointId=>viewer.current?.highlightJoint(jointId)}/><main className="relative min-w-0 flex-1"><div ref={viewerHost} className="absolute inset-0"/><ViewportHUD paused={state.paused} mode={state.mode} selection={state.selection} ready={Boolean(state.snapshot)}/><WorkspaceOverlays loading={state.loading} hasSnapshot={Boolean(state.snapshot)} progress={importProgress}/><ToastViewport item={toast} onDismiss={()=>setToast(undefined)}/>{Boolean(state.snapshot?.model.ncam)&&(showSensorCamera?<div aria-label="摄像头画面" className="pointer-events-none absolute bottom-4 left-4 z-20 aspect-video w-[min(320px,32%)] min-w-[120px] overflow-hidden rounded-lg border border-border-strong shadow-2xl"><div className="pointer-events-auto absolute inset-x-0 top-0 flex h-7 items-center justify-between bg-black/65 px-2 text-[10px] font-medium text-white"><span className="flex items-center gap-1"><Camera className="h-3 w-3"/>摄像头</span><button type="button" className="rounded px-1.5 py-0.5 hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60" onClick={()=>setShowSensorCamera(false)}>隐藏画面</button></div></div>:<Button className="absolute bottom-4 left-4 z-20" icon={<Camera className="h-3.5 w-3.5"/>} onClick={()=>setShowSensorCamera(true)}>显示摄像头画面</Button>)}{state.entries.length>1&&!state.selectedEntry&&!pendingUrdfPath&&<EntrySelectionDialog entries={state.entries} onSelect={requestLoadEntry}/>} {state.diagnostic&&<ErrorRecoveryPanel key={state.diagnostic.at} value={state.diagnostic} onClose={()=>state.setDiagnostic(undefined)} onRetry={state.diagnostic.category==='模型编译'&&state.diagnostic.path?()=>void loadEntry(state.diagnostic!.path!):undefined} onOpenProject={()=>{setLeftOpen(true);state.setDiagnostic(undefined);}}/>}</main><ModelControlsSidebar visible={rightOpen} snapshot={state.snapshot} selection={state.selection} selectedFormat={selectedFormat} loading={state.loading} urdfMode={urdfMode} baseMode={baseMode} showCollision={showCollision} ignoreJointLimits={ignoreJointLimits} jointAdvanced={jointAdvanced} angleUnit={angleUnit} forceScale={forceScale} controllerPaths={state.files.filter(file=>/\.py$/i.test(file.path)).map(file=>file.path)} selectedControllerPath={selectedControllerPath} controllerStatus={controllerStatus} policyPaths={state.files.filter(file=>/\.onnx$/i.test(file.path)).map(file=>file.path)} selectedPolicyPath={selectedPolicyPath} policyStatus={policyStatus} onUrdfMode={changeUrdfMode} onBaseMode={changeBaseMode} onShowCollision={setShowCollision} onResetJoints={resetJoints} onToggleJointLimits={toggleJointLimits} onToggleAdvanced={()=>setJointAdvanced(value=>!value)} onToggleAngleUnit={()=>setAngleUnit(value=>value==='rad'?'deg':'rad')} onActuator={setActuator} onActuatorParameters={setActuatorParameters} onJoint={setJoint} onForceScale={setForceScale} onSelectControllerPath={setSelectedControllerPath} onLoadControllerPath={loadControllerPath} onImportController={importController} onToggleController={toggleController} onControllerCommand={sendControllerCommand} onRemoveController={removeController} onSelectPolicyPath={setSelectedPolicyPath} onLoadPolicyPath={loadPolicyPath} onImportPolicy={importPolicy} onTogglePolicy={togglePolicy} onPolicyCommand={setPolicyCommand} onRemovePolicy={removePolicy}/></div>
{pendingUrdfPath&&<UrdfImportOptionsDialog open path={pendingUrdfPath} mountBodies={pendingUrdfMounts} onConfirm={confirmUrdfOptions} onSkip={skipUrdfOptions}/>}{sourceOpen&&generatedMjcf&&generatedMjcfPath&&<SourceEditorDialog open code={generatedMjcf} filePath={generatedMjcfPath} theme={theme} onClose={()=>setSourceOpen(false)} onSave={saveCachedSource}/>}<ShortcutHelpDialog open={helpOpen} onClose={()=>setHelpOpen(false)}/><DiagnosticsDrawer open={diagnosticsOpen} items={notifications} onClose={()=>setDiagnosticsOpen(false)} onClear={()=>setNotifications([])}/><SettingsDialog open={settingsOpen} onClose={()=>setSettingsOpen(false)} theme={theme} angleUnit={angleUnit} showCollision={showCollision} jointAdvanced={jointAdvanced} forceScale={forceScale} onTheme={setTheme} onAngleUnit={setAngleUnit} onShowCollision={setShowCollision} onJointAdvanced={setJointAdvanced} onForceScale={setForceScale}/><LayoutSettingsDialog open={layoutOpen} onClose={()=>setLayoutOpen(false)} leftOpen={leftOpen} rightOpen={rightOpen} onLeftOpen={setLeftOpen} onRightOpen={setRightOpen} onPreset={applyLayoutPreset} onReset={()=>applyLayoutPreset('default')}/><CommandPalette open={commandOpen} onClose={()=>setCommandOpen(false)} commands={commands}/><ConfirmDialog open={removeConfirmOpen} title="移除当前工程" confirmLabel="移除工程" danger onConfirm={confirmRemoveProject} onClose={()=>setRemoveConfirmOpen(false)}><p className="text-sm text-text-secondary">确定从当前会话中移除“<strong className="text-text-primary">{state.projectName}</strong>”吗?</p><p className="mt-2 text-xs text-text-tertiary">该操作不会删除本地文件。</p></ConfirmDialog><StatusBar time={state.snapshot?.time} fps={state.fps} stepMs={state.stepMs} memoryMb={state.memoryMb} loaded={Boolean(state.snapshot)} overBudget={state.overBudget}/>
</div>;
}