/* Zustand 的 action 引用稳定;初始化 viewer 与导入回调有意只创建一次。 */ /* eslint-disable react-hooks/exhaustive-deps */ import { lazy, Suspense, 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 { DEFAULT_VIEWER_DISPLAY_OPTIONS, type ViewerDisplayOptions, } from '../viewer/displayOptions'; 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 { UrdfImportOptionsDialog } from './components/UrdfImportOptionsDialog'; import { downloadBytes, exportedFileName, mergeCachedFiles, readCachedText, upsertCachedMjcf, } from '../project/cachedFiles'; const SourceEditorDialog = lazy(() => import('./components/SourceEditorDialog').then((module) => ({ default: module.SourceEditorDialog, })), ); 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 initialSidebarVisibility(): { left: boolean; right: boolean } { const width = typeof window === 'undefined' ? 1280 : window.innerWidth; if (width < 900) return { left: false, right: false }; try { const stored = JSON.parse(localStorage.getItem('mujoco-platform-layout') ?? 'null') as { left?: unknown; right?: unknown; } | null; if (stored && typeof stored.left === 'boolean' && typeof stored.right === 'boolean') return { left: stored.left, right: stored.right }; } catch { /* 使用响应式默认布局 */ } return width >= 1280 ? { left: true, right: true } : { left: false, right: true }; } function initialDisplayOptions(): ViewerDisplayOptions { try { const stored = JSON.parse( localStorage.getItem('mujoco-platform-display') ?? 'null', ) as Partial | null; if (!stored) return { ...DEFAULT_VIEWER_DISPLAY_OPTIONS }; const next = { ...DEFAULT_VIEWER_DISPLAY_OPTIONS }; for (const key of Object.keys(next) as (keyof ViewerDisplayOptions)[]) if (typeof stored[key] === 'boolean') next[key] = stored[key]; return next; } catch { return { ...DEFAULT_VIEWER_DISPLAY_OPTIONS }; } } 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(null), notificationId = useRef(0), loadInFlight = useRef(false), importInFlight = useRef(false), adapter = useRef(new MainThreadPhysicsAdapter()), root = useRef(null), viewerHost = useRef(null), viewer = useRef(null), urdfEnhancementsRef = useRef({ addActuators: true, addSensors: true, sensorType: 'camera', }); const [forceScale, setForceScale] = useState(50), [leftOpen, setLeftOpen] = useState(() => initialSidebarVisibility().left), [rightOpen, setRightOpen] = useState(() => initialSidebarVisibility().right), [helpOpen, setHelpOpen] = useState(false), [commandOpen, setCommandOpen] = useState(false), [sourceOpen, setSourceOpen] = useState(false), [generatedMjcf, setGeneratedMjcf] = useState(), [generatedMjcfPath, setGeneratedMjcfPath] = useState(), [pendingUrdfPath, setPendingUrdfPath] = useState(), [pendingUrdfMounts, setPendingUrdfMounts] = useState([]), [removeConfirmOpen, setRemoveConfirmOpen] = useState(false), [fullscreen, setFullscreen] = useState(false), [settingsOpen, setSettingsOpen] = useState(false), [layoutOpen, setLayoutOpen] = useState(false), [diagnosticsOpen, setDiagnosticsOpen] = useState(false), [importProgress, setImportProgress] = useState(), [notifications, setNotifications] = useState([]), [toast, setToast] = useState(), [selectedControllerPath, setSelectedControllerPath] = useState(), [controllerStatus, setControllerStatus] = useState(), [selectedPolicyPath, setSelectedPolicyPath] = useState(), [policyStatus, setPolicyStatus] = useState(); const [urdfMode, setUrdfMode] = useState('mjcf'), urdfModeRef = useRef('mjcf'); const [baseMode, setBaseMode] = useState('floating'), baseModeRef = useRef('floating'); const [displayOptions, setDisplayOptions] = useState(initialDisplayOptions), [showSensorCamera, setShowSensorCamera] = useState(true), [theme, setTheme] = useState(initialTheme), [jointAdvanced, setJointAdvanced] = useState(false), [ignoreJointLimits, setIgnoreJointLimits] = useState(false), [angleUnit, setAngleUnit] = useState<'rad' | 'deg'>('rad'); const showCollision = displayOptions.showCollision, setShowCollision = (value: boolean) => setDisplayOptions((options) => ({ ...options, showCollision: value })); 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?.setDisplayOptions(displayOptions); try { localStorage.setItem('mujoco-platform-display', JSON.stringify(displayOptions)); } catch { /* 当前会话仍可修改 */ } }, [displayOptions]); useEffect(() => { if (window.innerWidth < 900) return; try { localStorage.setItem( 'mujoco-platform-layout', JSON.stringify({ left: leftOpen, right: rightOpen }), ); } catch { /* 当前会话仍可修改 */ } }, [leftOpen, rightOpen]); 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: 0.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: 0.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 ? `模型已加载 · ${snapshot.warnings.length} 项兼容调整` : '模型加载完成', 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: 0.12 }); try { const next = await importBrowserFiles(files); setImportProgress({ label: '处理模型资源与入口', value: 0.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) => { 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: 0.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: 0.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: 0.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 ? : , shortcut: 'Space', disabled: !state.snapshot, run: togglePause, }, { id: 'reset', label: '重置仿真', group: '仿真', icon: , shortcut: 'R', disabled: !state.snapshot, run: reset, }, { id: 'select', label: '切换到选择模式', group: '视口', icon: , shortcut: '1', run: () => mode('select'), }, { id: 'joint', label: '切换到关节拖动', group: '视口', icon: , shortcut: '2', run: () => mode('joint'), }, { id: 'force', label: '切换到外力施加', group: '视口', icon: , shortcut: '3', run: () => mode('force'), }, { id: 'camera', label: '复位相机', group: '视口', icon: , run: () => viewer.current?.resetCamera(), }, { id: 'source', label: '查看和修改缓存源代码', group: '工程', icon: , disabled: !generatedMjcf, run: () => setSourceOpen(true), }, { id: 'export-urdf', label: '导出 URDF 文件', group: '工程', icon: , disabled: selectedFormat !== 'urdf', run: exportUrdf, }, { id: 'export-mjcf', label: '导出 MJCF 文件', group: '工程', icon: , disabled: !state.snapshot, run: exportMjcf, }, { id: 'left', label: leftOpen ? '隐藏工程面板' : '显示工程面板', group: '布局', icon: leftOpen ? : , run: () => setLeftOpen((value) => !value), }, { id: 'right', label: rightOpen ? '隐藏属性面板' : '显示属性面板', group: '布局', icon: rightOpen ? : , run: () => setRightOpen((value) => !value), }, { id: 'theme', label: theme === 'dark' ? '切换到白天主题' : '切换到黑夜主题', group: '外观', icon: , run: () => setTheme((value) => (value === 'dark' ? 'light' : 'dark')), }, { id: 'fullscreen', label: fullscreen ? '退出全屏' : '进入全屏', group: '布局', icon: , run: toggleFullscreen, }, { id: 'help', label: '查看快捷键帮助', group: '帮助', icon: , run: () => setHelpOpen(true), }, ]; return (
event.preventDefault()} onDrop={drop} > 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={ <> setNotifications((items) => items.filter((item) => item.id !== id)) } onClear={() => setNotifications([])} onOpenLog={() => setDiagnosticsOpen(true)} /> setLayoutOpen(true)} > setSettingsOpen(true)} > } compactMenu={ 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={ viewer.current?.resetCamera()} /> } />
viewer.current?.highlightJoint(jointId)} />
setToast(undefined)} /> {Boolean(state.snapshot?.model.ncam) && (showSensorCamera ? (
摄像头
) : ( ))} {state.entries.length > 1 && !state.selectedEntry && !pendingUrdfPath && ( )}{' '} {state.diagnostic && ( state.setDiagnostic(undefined)} onRetry={ state.diagnostic.category === '模型编译' && state.diagnostic.path ? () => void loadEntry(state.diagnostic!.path!) : undefined } onOpenProject={() => { setLeftOpen(true); state.setDiagnostic(undefined); }} /> )}
/\.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} />
{pendingUrdfPath && ( )} {sourceOpen && generatedMjcf && generatedMjcfPath && ( 正在加载源码编辑器…
} > setSourceOpen(false)} onSave={saveCachedSource} /> )} setHelpOpen(false)} /> setDiagnosticsOpen(false)} onClear={() => setNotifications([])} /> setSettingsOpen(false)} theme={theme} angleUnit={angleUnit} showCollision={showCollision} jointAdvanced={jointAdvanced} forceScale={forceScale} onTheme={setTheme} onAngleUnit={setAngleUnit} onShowCollision={setShowCollision} onJointAdvanced={setJointAdvanced} onForceScale={setForceScale} /> setLayoutOpen(false)} leftOpen={leftOpen} rightOpen={rightOpen} onLeftOpen={setLeftOpen} onRightOpen={setRightOpen} onPreset={applyLayoutPreset} onReset={() => applyLayoutPreset('default')} /> setCommandOpen(false)} commands={commands} /> setRemoveConfirmOpen(false)} >

确定从当前会话中移除“{state.projectName} ”吗?

该操作不会删除本地文件。

); }