/* Zustand 的 action 引用稳定;初始化 viewer 与导入回调有意只创建一次。 */ /* eslint-disable react-hooks/exhaustive-deps */ import { lazy, Suspense, useCallback, useEffect, useRef, useState, type ChangeEvent, type DragEvent, } from 'react'; import { useShallow } from 'zustand/react/shallow'; 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 MapEntry, 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 type { MuJoCoViewer, InteractionMode, 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 { StoreStatusBar } 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'; import { DEFAULT_MAP_SELECTION, DEFAULT_PHYSICAL_MAP_CONFIG, type MapSelection, type SystemTerrainPreset, } from '../map/types'; import { discoverMapEntries, resolveProjectMap, visualMapAsset } from '../map/MapLoader'; import { decodeMapDefinition } from '../map/mapSchema'; import { decodeEditableMapDocument, encodeEditableMapDocument } from '../map/editor/editorSchema'; import type { EditableMapDocument, EditableMapObjectType, MapEditorInteractionCallbacks, MapEditorTransformMode, MapObjectPlacementMode, } from '../map/editor/types'; import { isMapObjectPlacementMode } from '../map/editor/types'; import { isEditableMapObjectType, MAP_ASSET_DRAG_MIME, MAP_ASSET_PLACEMENT_MIME, } from '../map/editor/assetCatalog'; import { compileEditableMapDocument } from '../map/editor/MapDocumentCompiler'; import { importEditableMapDocument } from '../map/editor/MapDocumentImporter'; import { resolveProjectAssetPath } from '../map/mapPaths'; 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( useShallow((value) => ({ projectName: value.projectName, files: value.files, entries: value.entries, selectedEntry: value.selectedEntry, loading: value.loading, diagnostic: value.diagnostic, snapshot: value.snapshot, selection: value.selection, paused: value.paused, speed: value.speed, mode: value.mode, clearProject: value.clearProject, setProject: value.setProject, setEntry: value.setEntry, setLoading: value.setLoading, setDiagnostic: value.setDiagnostic, setSnapshot: value.setSnapshot, setSelection: value.setSelection, setPaused: value.setPaused, setSpeed: value.setSpeed, setMode: value.setMode, setMetrics: value.setMetrics, })), ); 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), viewerReady = useRef | null>(null), dragDepth = useRef(0), editorInteraction = useRef(null), pendingMapAsset = useRef<{ type: EditableMapObjectType; position?: [number, number, number]; placementMode: MapObjectPlacementMode; } | null>(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), [dragActive, setDragActive] = useState(false), [importProgress, setImportProgress] = useState(), [notifications, setNotifications] = useState([]), [toast, setToast] = useState(), [selectedControllerPath, setSelectedControllerPath] = useState(), [controllerStatus, setControllerStatus] = useState(), [selectedPolicyPath, setSelectedPolicyPath] = useState(), [policyStatus, setPolicyStatus] = useState(), [projectMaps, setProjectMaps] = useState([]), [editorDocument, setEditorDocument] = useState(null), [projectSidebarTab, setProjectSidebarTab] = useState<'project' | 'structure' | 'assets'>( 'project', ); const [urdfMode, setUrdfMode] = useState('mjcf'), urdfModeRef = useRef('mjcf'); const [baseMode, setBaseMode] = useState('floating'), baseModeRef = useRef('floating'); const [mapSelection, setMapSelection] = useState(DEFAULT_MAP_SELECTION), mapSelectionRef = useRef(DEFAULT_MAP_SELECTION), [showVisualMap, setShowVisualMap] = useState(true), [showMapCollision, setShowMapCollision] = useState(false); 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 })); const viewerSettings = useRef({ mode: state.mode, forceScale, displayOptions, showVisualMap, showMapCollision, showSensorCamera, theme, }); useEffect(() => { viewerSettings.current = { mode: state.mode, forceScale, displayOptions, showVisualMap, showMapCollision, showSensorCamera, theme, }; }, [ state.mode, forceScale, displayOptions, showVisualMap, showMapCollision, showSensorCamera, theme, ]); useEffect(() => { const host = viewerHost.current; if (!host) return; let active = true; const ready = import('../viewer/MuJoCoViewer') .then(({ MuJoCoViewer: Viewer }) => { if (!active) return null; const next = new Viewer(host, { 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) => { console.error('[MuJoCo] 视口运行失败', error); state.setDiagnostic( diagnostic(error.message.includes('控制器') ? '仿真' : '渲染', error), ); }, onMapEditorSelect: (id) => editorInteraction.current?.onSelect(id), onMapEditorTransform: (id, position, quaternion, scale) => editorInteraction.current?.onTransform({ id, position, quaternion, scale }), }); if (!active) { next.dispose(); return null; } viewer.current = next; const settings = viewerSettings.current; next.setMode(settings.mode); next.forceScale = settings.forceScale; next.setDisplayOptions(settings.displayOptions); next.setMapDisplay(settings.showVisualMap, settings.showMapCollision); next.setShowSensorCamera(settings.showSensorCamera); next.setTheme(settings.theme); return next; }) .catch((error) => { if (active) { console.error('[MuJoCo] 三维视口初始化失败', error); state.setDiagnostic(diagnostic('渲染', error)); } return null; }); viewerReady.current = ready; return () => { active = false; if (viewerReady.current === ready) viewerReady.current = null; viewer.current?.dispose(); viewer.current = null; const retiredAdapter = adapter.current; retiredAdapter.dispose(); if (adapter.current === retiredAdapter) adapter.current = new MainThreadPhysicsAdapter(); }; }, []); 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(() => { viewer.current?.setMapDisplay(showVisualMap, showMapCollision); }, [showVisualMap, showMapCollision]); 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 false; const previousEntry = useAppStore.getState().selectedEntry; loadInFlight.current = true; setIgnoreJointLimits(false); setControllerStatus(undefined); setPolicyStatus(undefined); state.setEntry(path); state.setLoading(true); setImportProgress({ title: '正在准备仿真', label: '初始化三维视口', detail: path, value: 0.4, }); state.setDiagnostic(undefined); setGeneratedMjcf(undefined); setGeneratedMjcfPath(undefined); adapter.current.setPaused(true); try { const activeViewer = viewer.current ?? (await viewerReady.current); if (!activeViewer) throw new Error('三维视口尚未就绪,请重试'); const snapshot = await adapter.current.load(manifest.current, path, { urdfMode: requestedMode ?? urdfModeRef.current, baseMode: baseModeRef.current, enhancements: urdfEnhancementsRef.current, map: mapSelectionRef.current, onProgress: ({ value, label }) => setImportProgress({ title: '正在准备仿真', label, detail: path, value: 0.4 + value * 0.53, }), }); const supportFiles = adapter.current.cachedSupportFiles(); setImportProgress({ title: '正在准备仿真', label: '创建三维场景', detail: path, value: 0.94, }); adapter.current.setSpeed(useAppStore.getState().speed); try { activeViewer.attach(adapter.current.session); } catch (error) { adapter.current.rollbackRetired(); activeViewer.attach(adapter.current.session); throw error; } adapter.current.releaseRetired(); 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, ); } state.setSnapshot(snapshot); state.setSelection(null); state.setPaused(true); setImportProgress({ title: '正在准备仿真', label: '加载视觉地图与材质', detail: path, value: 0.97, }); await activeViewer.setVisualMap(null); let visualMapWarning: string | undefined; try { const asset = manifest.current ? visualMapAsset(manifest.current, mapSelectionRef.current) : null; await activeViewer.setVisualMap(asset); } catch (error) { visualMapWarning = `视觉地图加载失败:${error instanceof Error ? error.message : String(error)}`; console.warn('[MuJoCo] 视觉地图加载失败', error); } 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 || visualMapWarning ? `模型已加载 · ${snapshot.warnings.length + (visualMapWarning ? 1 : 0)} 项兼容调整` : '模型加载完成', detail: [...snapshot.warnings, ...(visualMapWarning ? [visualMapWarning] : [])].join('\n') || path, tone: snapshot.warnings.length || visualMapWarning ? 'warning' : 'success', at: Date.now(), }; setNotifications((items) => [notice, ...items].slice(0, 20)); setToast(notice); return true; } 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); const retained = adapter.current.snapshot(); if (retained && previousEntry) state.setEntry(previousEntry); setControllerStatus(retained?.controller); setPolicyStatus(retained?.rlPolicy); return false; } 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({ title: '正在导入工程', label: '检查文件清单', detail: files.length === 1 ? files[0].name : `${files.length} 个文件`, value: 0.04, }); try { const next = await importBrowserFiles( files, DEFAULT_IMPORT_LIMITS, ({ phase, completed, total, path }) => { const ratio = total ? completed / total : 0; const label = phase === 'reading' ? '读取工程文件' : phase === 'extracting' ? '在后台解压工程包' : '索引模型与地图入口'; const value = phase === 'reading' ? 0.06 + ratio * 0.2 : phase === 'extracting' ? 0.28 + ratio * 0.07 : 0.37; setImportProgress({ title: '正在导入工程', label, detail: path, value }); }, ); setImportProgress({ title: '正在导入工程', label: '处理模型资源与入口', detail: `${next.files.length} 个文件`, value: 0.39, }); manifest.current = next; setProjectMaps(next.maps); setProjectSidebarTab('project'); setEditorDocument(null); viewer.current?.setMapEditorDocument(null); mapSelectionRef.current = DEFAULT_MAP_SELECTION; setMapSelection(DEFAULT_MAP_SELECTION); 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 = () => { void viewer.current?.setVisualMap(null); viewer.current?.attach(null); adapter.current.dispose(); adapter.current = new MainThreadPhysicsAdapter(); manifest.current = null; setGeneratedMjcf(undefined); setGeneratedMjcfPath(undefined); setPendingUrdfPath(undefined); setPendingUrdfMounts([]); setSelectedControllerPath(undefined); setControllerStatus(undefined); setSelectedPolicyPath(undefined); setPolicyStatus(undefined); setProjectMaps([]); setProjectSidebarTab('project'); setEditorDocument(null); viewer.current?.setMapEditorDocument(null); mapSelectionRef.current = DEFAULT_MAP_SELECTION; setMapSelection(DEFAULT_MAP_SELECTION); 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 previewEditorDocument = useCallback((document: EditableMapDocument | null) => { viewer.current?.setMapEditorDocument(document); if (document) { adapter.current.setPaused(true); state.setPaused(true); } }, []); const bindEditorInteraction = useCallback((callbacks: MapEditorInteractionCallbacks | null) => { editorInteraction.current = callbacks; const pending = pendingMapAsset.current; if (callbacks && pending) { pendingMapAsset.current = null; callbacks.onAddAsset(pending.type, pending.position, pending.placementMode); } }, []); const selectEditorObject = useCallback((id: string | null) => { viewer.current?.selectMapEditorObject(id); }, []); const setEditorTransformMode = useCallback((mode: MapEditorTransformMode) => { viewer.current?.setMapEditorTransformMode(mode); }, []); const setEditorSnapping = useCallback( (translation: number | null, rotationDegrees: number | null) => { viewer.current?.setMapEditorSnapping(translation, rotationDegrees); }, [], ); const readEditorDocument = (selection: MapSelection): EditableMapDocument | null => { if (selection.kind !== 'project' || !manifest.current) return null; const resolved = resolveProjectMap(manifest.current, selection.descriptorPath); if (!resolved.authoringPath) return null; const file = manifest.current.files.find( (candidate) => candidate.path === resolved.authoringPath, ); return file ? decodeEditableMapDocument(file.data) : null; }; const createEditableScene = async ( type: EditableMapObjectType, position?: [number, number, number], placementMode: MapObjectPlacementMode = 'auto_ground', ): Promise => { const current = manifest.current; const entryPath = state.selectedEntry; const entry = state.entries.find((candidate) => candidate.path === entryPath); if (!current || !entryPath || !entry || loadInFlight.current) return false; if (entry.format === 'urdf' && urdfModeRef.current === 'native') { state.setDiagnostic( diagnostic('模型编译', new Error('原生 URDF 不能创建 MJCF 场景,请切换为转换模式')), ); return false; } if (current.files.length + 3 > DEFAULT_IMPORT_LIMITS.maxFiles) { state.setDiagnostic( diagnostic('文件系统', new Error('工程文件数量已达到上限,无法创建场景')), ); return false; } let index = 1; while ( current.maps.some((map) => map.id === `scene_${index}`) || current.files.some((file) => file.path.startsWith(`maps/scene_${index}/`)) ) index += 1; const mapId = `scene_${index}`; const directory = `maps/${mapId}`; const descriptorPath = `${directory}/map.json`; const physicsPath = `${directory}/physics/world.xml`; const authoringPath = `${directory}/authoring/map.scene.json`; const document: EditableMapDocument = { schemaVersion: 1, mapId, revision: 0, objects: [], spawnPoints: [], }; const definition = { schemaVersion: 2 as const, id: mapId, name: `场景 ${index}`, coordinateSystem: { units: 'm' as const, up: 'Z' as const, forward: '+X' as const }, physics: { source: 'physics/world.xml' }, authoring: { source: 'authoring/map.scene.json' }, spawnPoints: [], }; const descriptorData = new TextEncoder().encode(`${JSON.stringify(definition, null, 2)}\n`); const physicsData = compileEditableMapDocument(document); const authoringData = encodeEditableMapDocument(document); const source = current.files.find((file) => file.path === entryPath)?.source ?? 'file'; const files = [ ...current.files, { path: descriptorPath, data: descriptorData, size: descriptorData.byteLength, source, mimeType: 'application/json', }, { path: physicsPath, data: physicsData, size: physicsData.byteLength, source, mimeType: 'application/xml', }, { path: authoringPath, data: authoringData, size: authoringData.byteLength, source, mimeType: 'application/json', }, ]; const totalBytes = files.reduce((total, file) => total + file.size, 0); if (totalBytes > DEFAULT_IMPORT_LIMITS.maxTotalBytes) { state.setDiagnostic(diagnostic('文件系统', new Error('创建场景后工程总大小超过 512 MiB'))); return false; } try { const maps = discoverMapEntries(files); const candidate: ProjectManifest = { ...current, files, maps, totalBytes }; const selection: MapSelection = { kind: 'project', descriptorPath }; pendingMapAsset.current = { type, position, placementMode }; manifest.current = candidate; mapSelectionRef.current = selection; setProjectMaps(maps); setEditorDocument(document); setMapSelection(selection); previewEditorDocument(document); state.setProject( candidate.name, candidate.files.map((file) => ({ path: file.path, size: file.size })), candidate.entries, entryPath, ); state.setSnapshot(adapter.current.snapshot() ?? undefined); return true; } catch (error) { pendingMapAsset.current = null; state.setDiagnostic(diagnostic('文件系统', error, descriptorPath)); return false; } }; const addCertifiedMapAsset = async ( type: EditableMapObjectType, position?: [number, number, number], placementMode: MapObjectPlacementMode = 'auto_ground', ) => { if (state.loading || loadInFlight.current) return; const interaction = editorInteraction.current; if (interaction) { interaction.onAddAsset(type, position, placementMode); return; } await createEditableScene(type, position, placementMode); }; const applyMapSelection = (value: MapSelection) => { const previous = mapSelectionRef.current; const entry = state.entries.find((candidate) => candidate.path === state.selectedEntry); if (!entry) return; if (entry.format === 'urdf' && urdfModeRef.current === 'native' && value.kind !== 'none') { state.setDiagnostic( diagnostic('模型编译', new Error('原生 URDF 不能注入地图,请切换为转换模式'), entry.path), ); return; } mapSelectionRef.current = value; setMapSelection(value); void loadEntry(entry.path).then((loaded) => { if (loaded) { setEditorDocument(readEditorDocument(value)); viewer.current?.setMapEditorDocument(null); return; } mapSelectionRef.current = previous; setMapSelection(previous); }); }; const selectTerrainAsset = (preset: SystemTerrainPreset) => { const current = mapSelectionRef.current; applyMapSelection({ kind: 'builtin', config: { ...(current.kind === 'builtin' ? current.config : DEFAULT_PHYSICAL_MAP_CONFIG), preset, }, }); }; const applyEditorDocument = async (document: EditableMapDocument): Promise => { const selection = mapSelectionRef.current; const current = manifest.current; const entryPath = state.selectedEntry; if (selection.kind !== 'project' || !current || !entryPath) return false; const resolved = resolveProjectMap(current, selection.descriptorPath); if (!resolved.authoringPath || !resolved.physicsPath) { state.setDiagnostic( diagnostic( '模型编译', new Error('可编辑地图必须同时声明 authoring.source 和 physics.source'), ), ); return false; } try { const authoringData = encodeEditableMapDocument(document); const physicsData = compileEditableMapDocument(document); const definition = decodeMapDefinition( current.files.find((file) => file.path === resolved.descriptorPath)!.data, ); definition.spawnPoints = document.spawnPoints; const descriptorData = new TextEncoder().encode(`${JSON.stringify(definition, null, 2)}\n`); const replacements = new Map([ [resolved.authoringPath, authoringData], [resolved.physicsPath, physicsData], [resolved.descriptorPath, descriptorData], ]); const files = current.files.map((file) => { const data = replacements.get(file.path); return data ? { ...file, data, size: data.byteLength } : file; }); const candidate: ProjectManifest = { ...current, files, maps: discoverMapEntries(files), totalBytes: files.reduce((total, file) => total + file.size, 0), }; manifest.current = candidate; const loaded = await loadEntry(entryPath); if (!loaded) { manifest.current = current; return false; } const loadedManifest = manifest.current ?? candidate; const maps = discoverMapEntries(loadedManifest.files); const committed = { ...loadedManifest, maps }; manifest.current = committed; setProjectMaps(maps); setEditorDocument(document); viewer.current?.setMapEditorDocument(null); state.setProject( committed.name, committed.files.map((file) => ({ path: file.path, size: file.size })), committed.entries, entryPath, ); state.setSnapshot(adapter.current.snapshot() ?? undefined); return true; } catch (error) { manifest.current = current; state.setDiagnostic(diagnostic('模型编译', error, resolved.authoringPath)); return false; } }; const convertSelectedMap = async (): Promise => { const selection = mapSelectionRef.current; const current = manifest.current; const entryPath = state.selectedEntry; if (selection.kind !== 'project' || !current || !entryPath) return false; let diagnosticPath = selection.descriptorPath; try { const resolved = resolveProjectMap(current, selection.descriptorPath); if (resolved.authoringPath) { setEditorDocument(readEditorDocument(selection)); return true; } if (!resolved.physicsPath) throw new Error('只有包含 physics.source 的静态 MJCF 地图可以转换'); diagnosticPath = resolved.physicsPath; const physicsFile = current.files.find((file) => file.path === resolved.physicsPath); const descriptorFile = current.files.find((file) => file.path === resolved.descriptorPath); if (!physicsFile || !descriptorFile) throw new Error('地图物理层或描述文件不存在'); const document = importEditableMapDocument(physicsFile.data, resolved.definition); const authoringReference = 'authoring/map.scene.json'; const authoringPath = resolveProjectAssetPath(resolved.descriptorPath, authoringReference); if (current.files.some((file) => file.path === authoringPath)) throw new Error(`目标创作层已存在但未被地图引用:${authoringPath}`); if (current.files.length >= DEFAULT_IMPORT_LIMITS.maxFiles) throw new Error('工程文件数量已达到上限,无法创建创作层'); const definition = { ...resolved.definition, schemaVersion: 2 as const, authoring: { source: authoringReference }, spawnPoints: document.spawnPoints, }; const descriptorData = new TextEncoder().encode(`${JSON.stringify(definition, null, 2)}\n`); const physicsData = compileEditableMapDocument(document); const authoringData = encodeEditableMapDocument(document); const files = current.files.map((file) => { if (file.path === resolved.descriptorPath) return { ...file, data: descriptorData, size: descriptorData.byteLength }; if (file.path === resolved.physicsPath) return { ...file, data: physicsData, size: physicsData.byteLength }; return file; }); files.push({ path: authoringPath, data: authoringData, size: authoringData.byteLength, source: descriptorFile.source, mimeType: 'application/json', }); const totalBytes = files.reduce((total, file) => total + file.size, 0); if (totalBytes > DEFAULT_IMPORT_LIMITS.maxTotalBytes) throw new Error('创建创作层后工程总大小超过 512 MiB'); const candidate: ProjectManifest = { ...current, files, maps: discoverMapEntries(files), totalBytes, }; manifest.current = candidate; const loaded = await loadEntry(entryPath); if (!loaded) { manifest.current = current; return false; } const loadedManifest = manifest.current ?? candidate; const maps = discoverMapEntries(loadedManifest.files); const committed = { ...loadedManifest, maps }; manifest.current = committed; setProjectMaps(maps); setEditorDocument(document); state.setProject( committed.name, committed.files.map((file) => ({ path: file.path, size: file.size })), committed.entries, entryPath, ); state.setSnapshot(adapter.current.snapshot() ?? undefined); notify('已创建可编辑地图副本', authoringPath); return true; } catch (error) { manifest.current = current; state.setDiagnostic(diagnostic('模型编译', error, diagnosticPath)); return false; } }; const exportSelectedMap = async () => { const selection = mapSelectionRef.current; if (selection.kind !== 'project' || !manifest.current) return; try { const { exportMapPackage } = await import('../map/editor/MapPackageExporter'); const entry = projectMaps.find((map) => map.descriptorPath === selection.descriptorPath); downloadBytes( exportMapPackage(manifest.current, selection.descriptorPath), `${entry?.id ?? 'map'}-map.zip`, 'application/zip', ); } catch (error) { state.setDiagnostic(diagnostic('文件系统', error, selection.descriptorPath)); } }; const changeFiles = (event: ChangeEvent) => { void ingest(Array.from(event.target.files ?? [])); event.target.value = ''; }; const resetDragState = () => { dragDepth.current = 0; setDragActive(false); }; const dragEnter = (event: DragEvent) => { if ( event.dataTransfer.types.includes('Files') && !event.dataTransfer.types.includes(MAP_ASSET_DRAG_MIME) ) { dragDepth.current += 1; if (!state.loading) setDragActive(true); } }; const dragLeave = (event: DragEvent) => { if (!event.dataTransfer.types.includes('Files')) return; dragDepth.current = Math.max(0, dragDepth.current - 1); if (dragDepth.current === 0) setDragActive(false); }; const dragOver = (event: DragEvent) => { event.preventDefault(); if (event.dataTransfer.types.includes(MAP_ASSET_DRAG_MIME)) { event.dataTransfer.dropEffect = viewer.current?.mapPlanePoint(event.clientX, event.clientY) ? 'copy' : 'none'; return; } if (event.dataTransfer.types.includes('Files')) event.dataTransfer.dropEffect = state.loading ? 'none' : 'copy'; }; const drop = (event: DragEvent) => { event.preventDefault(); resetDragState(); const assetType = event.dataTransfer.getData(MAP_ASSET_DRAG_MIME), requestedPlacement = event.dataTransfer.getData(MAP_ASSET_PLACEMENT_MIME), placementMode = isMapObjectPlacementMode(requestedPlacement) ? requestedPlacement : 'auto_ground'; if (isEditableMapObjectType(assetType)) { event.stopPropagation(); const position = viewer.current?.mapPlanePoint(event.clientX, event.clientY); if (position) void addCertifiedMapAsset(assetType, position, placementMode); return; } if (state.loading || importInFlight.current) return; importInFlight.current = true; state.setLoading(true); setImportProgress({ title: '正在导入工程', label: '扫描拖放的文件与文件夹', value: 0.02, }); 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({ title: '正在加载控制器', label: '初始化 Python 运行时', detail: path, 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({ title: '正在加载强化学习策略', label: '初始化 ONNX Runtime', detail: path, 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 (
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)} onAddMapAsset={(type, placementMode) => addCertifiedMapAsset(type, undefined, placementMode) } onSelectTerrain={selectTerrainAsset} onSelectMapObject={(id) => { editorInteraction.current?.onSelect(id); selectEditorObject(id); }} />
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} mapSelection={mapSelection} maps={projectMaps} showVisualMap={showVisualMap} showMapCollision={showMapCollision} editorDocument={editorDocument} 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} onApplyMap={applyMapSelection} onEditorPreview={previewEditorDocument} onEditorApply={applyEditorDocument} onEditorExport={exportSelectedMap} onEditorConvert={convertSelectedMap} onEditorBindInteraction={bindEditorInteraction} onEditorSelect={selectEditorObject} onEditorTransformMode={setEditorTransformMode} onEditorSnapping={setEditorSnapping} onMapDisplay={(visual, collision) => { setShowVisualMap(visual); setShowMapCollision(collision); }} onMapTabOpen={() => { setLeftOpen(true); setProjectSidebarTab('assets'); }} />
{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} ”吗?

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

); }