import { useThemePreference } from './hooks/useThemePreference'; import { resolvePolicyDeployment, type PolicyDeployment } from '../rl/deployment'; /* Zustand 的 action 引用稳定;初始化 viewer 与导入回调有意只创建一次。 */ /* eslint-disable react-hooks/exhaustive-deps */ import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, } from 'react'; import { useShallow } from 'zustand/react/shallow'; import { Camera, ChevronLeft, ChevronRight, CircleHelp, Code2, Crosshair, Database, Download, Hand, Maximize, MousePointer2, PanelsTopLeft, Pause, Play, RotateCcw, SlidersHorizontal, 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 { DataRecorderConfig } from '../telemetry/DataRecorder'; import type { ControllerCommand, ControllerStatus } from '../controller/types'; import type { RLCommand, RLPolicyStatus } from '../rl/types'; import type { MuJoCoViewer, InteractionMode } 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 { MapAssetDropIndicator, MapDraftStatusOverlay, MapViewportToolbar, type MapAssetDropTarget, } from './components/MapViewportTools'; import { ModelControlsSidebar } from './components/ModelControlsSidebar'; import { ProjectSidebar, type ProjectResourceTab } from './components/ProjectSidebar'; import type { WorkspaceTool } from './components/WorkspaceToolsPanel'; import type { EditorSelection } from './editorSelection'; import { isTextEditingTarget } from './keyboard'; import { useMapEditorShortcuts } from './hooks/useMapEditorShortcuts'; import { WorkspaceOverlays, type ImportProgress } from './components/WorkspaceOverlays'; import { EntrySelectionDialog } from './components/EntrySelectionDialog'; import { ErrorRecoveryPanel } from './components/ErrorRecoveryPanel'; import { useSidebarLayout } from './hooks/useSidebarLayout'; import { ViewportOverlayLayout } from './components/ViewportOverlayLayout'; import { SimulationControls } from './components/SimulationControls'; import { ViewerDisplayPopover } from './components/ViewerDisplayPopover'; 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 { PanelWidthBudgetContext } from '../components/ui/panelWidthBudget'; 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 { createPlacedMapAsset, DEFAULT_MAP_SELECTION, DEFAULT_PHYSICAL_MAP_CONFIG, PHYSICAL_MAP_PRESET_LABELS, mapLocalPointToWorld, mapSelectionTransform, mapWorldPointToLocal, updatePlacedMapAsset, type MapSelection, type PlacedMapAsset, type PlacedMapSelection, type SystemTerrainPreset, } from '../map/types'; import { discoverMapEntries, resolveProjectMap, visualMapAssets } from '../map/MapLoader'; import { decodeEditableMapDocument, encodeEditableMapDocument } from '../map/editor/editorSchema'; import type { EditableMapDocument, EditableMapObjectType, MapEditorInteractionCallbacks, MapEditorSessionState, MapEditorTransformMode, MapObjectPlacementMode, } from '../map/editor/types'; import { decodeMapLibraryDragPayload, MAP_LIBRARY_DRAG_MIME } from '../map/editor/assetCatalog'; import { compileEditableMapDocument } from '../map/editor/MapDocumentCompiler'; import { materializeEditableMapDrafts } from '../map/editor/EditableMapDraftCommit'; import { findCompiledEditableMapPick } from '../map/editor/compiledMapPick'; import { mapSceneSurfaceHeightAt } from '../map/sceneSurface'; import { importEditableMapDocument } from '../map/editor/MapDocumentImporter'; import { resolveProjectAssetPath } from '../map/mapPaths'; import { clonePlacedMapAssets, mapEditorDraftPreviewInstances, resolveMapSceneLoadAssets, restoreAppliedMapScene, summarizeMapSceneDraft, transformParametricMapAsset, } from '../map/mapSceneDraft'; const SourceEditorDialog = lazy(() => import('./components/SourceEditorDialog').then((module) => ({ default: module.SourceEditorDialog, })), ); const WorkspaceToolsPanel = lazy(() => import('./components/WorkspaceToolsPanel').then((module) => ({ default: module.WorkspaceToolsPanel, })), ); function hasTransferType(dataTransfer: DataTransfer, type: string): boolean { return Array.from(dataTransfer.types).includes(type); } 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 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)); } function omitManifestFiles( manifest: ProjectManifest, omittedPaths: ReadonlySet, ): ProjectManifest { if (!omittedPaths.size) return manifest; const files = manifest.files.filter((file) => !omittedPaths.has(file.path)); return { ...manifest, files, maps: discoverMapEntries(files), totalBytes: files.reduce((total, file) => total + file.size, 0), }; } function manifestEditorDocuments(manifest: ProjectManifest): Map { const documents = new Map(); for (const map of manifest.maps) { if (!map.authoringPath) continue; const file = manifest.files.find((candidate) => candidate.path === map.authoringPath); if (file) documents.set(map.descriptorPath, decodeEditableMapDocument(file.data)); } return documents; } 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), policyLoadInFlight = useRef(false), importInFlight = useRef(false), adapter = useRef(new MainThreadPhysicsAdapter()), root = useRef(null), viewportShell = useRef(null), viewerHost = useRef(null), viewer = useRef(null), viewerReady = useRef | null>(null), dragDepth = useRef(0), editorInteraction = useRef(null), editorDraftsRef = useRef>(new Map()), provisionalMapFilesRef = useRef>(new Map()), pendingEditorObjectId = useRef(undefined), compiledMapBodyInteraction = useRef<(bodyName: string) => boolean>(() => false), mapEditorPreviewInteraction = useRef<(mapAssetId: string, objectId: string) => void>(() => {}), parametricMapInteraction = useRef<{ onSelect(id: string | null): void; onTransform(id: string, position: [number, number], yawDeg: number): void; } | null>(null), pendingMapAsset = useRef<{ type: EditableMapObjectType; position?: [number, number, number]; placementMode: MapObjectPlacementMode; externalSupportTop?: number; } | null>(null), urdfEnhancementsRef = useRef({ addActuators: true, addSensors: true, sensorType: 'camera', }); const { width: workspaceWidth, leftOpen, rightOpen, setLeftOpen, setRightOpen, } = useSidebarLayout(); const sensorCameraFrame = useRef(null); const orientationHost = useRef(null); const [mapCommitState, setMapCommitState] = useState<'idle' | 'submitting' | 'failed'>('idle'); const [forceScale, setForceScale] = useState(50), [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(), [navigationTargetMode, setNavigationTargetMode] = useState(false), [trainingDeployment, setTrainingDeployment] = useState(), [showPerceptionRays, setShowPerceptionRays] = useState(true), [projectMaps, setProjectMaps] = useState([]), [editorDocument, setEditorDocument] = useState(null), [editorDrafts, setEditorDrafts] = useState>(() => new Map()), [committedEditorDocuments, setCommittedEditorDocuments] = useState< Map >(() => new Map()), [projectSidebarTab, setProjectSidebarTab] = useState('assets'), [editorSelection, setEditorSelection] = useState(null), [workspaceTool, setWorkspaceTool] = useState(null), [mapTransformMode, setMapTransformMode] = useState('translate'), [mapSnapping, setMapSnapping] = useState(true), [assetPlacementMode, setAssetPlacementMode] = useState('auto_ground'), [editorSessionStates, setEditorSessionStates] = useState>( () => new Map(), ), [mapAssetDropTarget, setMapAssetDropTarget] = useState(); 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), [placedMapAssets, setPlacedMapAssets] = useState([]), placedMapAssetsRef = useRef([]), [appliedMapAssets, setAppliedMapAssets] = useState([]), appliedMapAssetsRef = useRef([]), [activeMapAssetId, setActiveMapAssetId] = useState(), activeMapAssetIdRef = useRef(undefined), [showVisualMap, setShowVisualMap] = useState(true), [showMapCollision, setShowMapCollision] = useState(false); const [displayOptions, setDisplayOptions] = useState(initialDisplayOptions), [showSensorCamera, setShowSensorCamera] = useState(true), [theme, setTheme] = useThemePreference(), [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 mapSceneDraft = useMemo( () => summarizeMapSceneDraft(placedMapAssets, appliedMapAssets), [placedMapAssets, appliedMapAssets], ); const pendingSceneIds = useMemo(() => { const ids = new Set(mapSceneDraft.changedIds); for (const asset of placedMapAssets) if (asset.selection.kind === 'project' && editorDrafts.has(asset.selection.descriptorPath)) ids.add(asset.id); return [...ids]; }, [mapSceneDraft.changedIds, placedMapAssets, editorDrafts]); const editorOnlyDraftCount = useMemo(() => { const changedIds = new Set(mapSceneDraft.changedIds); const coveredDescriptors = new Set( placedMapAssets.flatMap((asset) => changedIds.has(asset.id) && asset.selection.kind === 'project' ? [asset.selection.descriptorPath] : [], ), ); return [...editorDrafts.keys()] .filter((path) => !coveredDescriptors.has(path)) .reduce( (count, path) => count + Math.max(1, editorSessionStates.get(path)?.changeCount ?? 1), 0, ); }, [mapSceneDraft.changedIds, placedMapAssets, editorDrafts, editorSessionStates]); const sceneDraftChangeCount = mapSceneDraft.changeCount + editorOnlyDraftCount, mapSceneDirty = sceneDraftChangeCount > 0; const activeEditorView = mapSelection.kind === 'project' ? (editorDrafts.get(mapSelection.descriptorPath) ?? editorDocument) : null; const selectedMapObject = editorSelection?.kind === 'map-object' && editorSelection.mapAssetId === activeMapAssetId ? activeEditorView?.objects.find((object) => object.id === editorSelection.objectId) : undefined; const mapEditingActive = Boolean(activeMapAssetId) && (editorSelection?.kind === 'map' || editorSelection?.kind === 'map-object'); const activePlacementMode = selectedMapObject?.placementMode ?? assetPlacementMode; 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: (selection) => { if (selection && compiledMapBodyInteraction.current(selection.bodyName)) { state.setSelection(null); return; } state.setSelection(selection); setEditorSelection(selection ? { kind: 'body', bodyId: selection.bodyId } : null); if (selection) setRightOpen(true); }, onNavigationMode: setNavigationTargetMode, onNavigationTarget: (target) => { adapter.current.setNavigationTarget(target); const snapshot = adapter.current.snapshot(); state.setSnapshot(snapshot ?? undefined); setPolicyStatus(snapshot?.rlPolicy); }, 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); const mapAssetId = activeMapAssetIdRef.current; setEditorSelection( mapAssetId ? id ? { kind: 'map-object', mapAssetId, objectId: id } : { kind: 'map', mapAssetId } : null, ); if (mapAssetId) setRightOpen(true); }, onMapEditorPreviewSelect: (mapAssetId, objectId) => mapEditorPreviewInteraction.current(mapAssetId, objectId), onParametricMapSelect: (id) => parametricMapInteraction.current?.onSelect(id), onParametricMapTransform: (id, position, yawDeg) => parametricMapInteraction.current?.onTransform(id, position, yawDeg), 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.setSensorCameraViewportElement(sensorCameraFrame.current); if (orientationHost.current) next.setOrientationGizmoHost(orientationHost.current); next.setTheme(settings.theme); next.setParametricMapAssets( placedMapAssetsRef.current, summarizeMapSceneDraft(placedMapAssetsRef.current, appliedMapAssetsRef.current) .changedIds, ); 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(() => { viewer.current?.setMapEditorTransformMode(mapTransformMode); }, [mapTransformMode]); useEffect(() => { viewer.current?.setMapEditorSnapping(mapSnapping ? 0.1 : null, mapSnapping ? 5 : null); }, [mapSnapping]); 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(() => { viewer.current?.setParametricMapAssets( trainingDeployment ? [] : placedMapAssets, mapSceneDraft.changedIds, ); }, [placedMapAssets, mapSceneDraft.changedIds, trainingDeployment]); useEffect(() => { viewer.current?.setShowPerceptionRays(showPerceptionRays); }, [showPerceptionRays]); useEffect(() => { viewer.current?.setShowSensorCamera(showSensorCamera); }, [showSensorCamera]); useEffect(() => { viewer.current?.setTheme(theme); }, [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, requestedSceneAssets?: readonly PlacedMapAsset[], requestedDeployment?: PolicyDeployment, requestedPolicy?: { data: Uint8Array; path: string }, ) => { if (!manifest.current || loadInFlight.current) return false; // 普通模型重载始终使用上次成功应用的地图基线。只有场景提交入口可以显式 // 传入草稿实例,防止切换模型/URDF 模式时把一半场景静默提前提交。 const sceneAssets = resolveMapSceneLoadAssets( appliedMapAssetsRef.current, requestedSceneAssets, ); const previousState = useAppStore.getState(); const previousEntry = previousState.selectedEntry; const previousPaused = previousState.paused; 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); state.setPaused(true); let attachedViewer: MuJoCoViewer | null = null; let sessionSwapped = false; 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, mapAssets: sceneAssets, trainingDeployment: requestedDeployment, trainingPolicy: requestedPolicy, 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); attachedViewer = activeViewer; sessionSwapped = true; } catch (error) { adapter.current.rollbackRetired(); activeViewer.attach(adapter.current.session); throw error; } 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); setMapCommitState('idle'); state.setSelection(null); setEditorSelection(null); state.setPaused(true); setImportProgress({ title: '正在准备仿真', label: '加载视觉地图与材质', detail: path, value: 0.97, }); await activeViewer.setVisualMaps([]); let visualMapWarning: string | undefined; try { const assets = manifest.current && !requestedDeployment ? visualMapAssets(manifest.current, placedMapAssetsRef.current) : []; await activeViewer.setVisualMaps(assets); } 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, message: `${snapshot.model.nbody} Bodies · ${snapshot.model.njnt} Joints · ${snapshot.model.ngeom} Geoms`, category: 'compile', tone: snapshot.warnings.length || visualMapWarning ? 'warning' : 'success', at: Date.now(), }; setNotifications((items) => [notice, ...items].slice(0, 20)); setToast(notice); if (requestedSceneAssets !== undefined) { const committedMapAssets = clonePlacedMapAssets(sceneAssets); appliedMapAssetsRef.current = committedMapAssets; setAppliedMapAssets(committedMapAssets); } activeViewer.setParametricMapAssets( requestedDeployment ? [] : placedMapAssetsRef.current, summarizeMapSceneDraft(placedMapAssetsRef.current, appliedMapAssetsRef.current) .changedIds, ); adapter.current.releaseRetired(); setTrainingDeployment(requestedDeployment); sessionSwapped = false; return true; } catch (error) { if (sessionSwapped && attachedViewer) { try { adapter.current.rollbackRetired(); attachedViewer.attach(adapter.current.session); } catch (rollbackError) { console.error('[MuJoCo] 无法恢复上一仿真会话', rollbackError); } } state.setDiagnostic(diagnostic('模型编译', error, path)); const notice: WorkbenchNotification = { id: ++notificationId.current, title: '模型编译失败', detail: error instanceof Error ? error.message : String(error), message: path, category: 'compile', 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); adapter.current.setPaused(previousPaused); state.setPaused(previousPaused); state.setSnapshot(retained ?? undefined); 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); setCommittedEditorDocuments(manifestEditorDocuments(next)); setProjectSidebarTab('assets'); setEditorSelection(null); setEditorDocument(null); editorDraftsRef.current = new Map(); setEditorDrafts(new Map()); provisionalMapFilesRef.current.clear(); viewer.current?.setMapEditorDocument(null); placedMapAssetsRef.current = []; setPlacedMapAssets([]); appliedMapAssetsRef.current = []; setAppliedMapAssets([]); viewer.current?.setParametricMapAssets([], []); activeMapAssetIdRef.current = undefined; setActiveMapAssetId(undefined); 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, ); const importNotice: WorkbenchNotification = { id: ++notificationId.current, title: '工程导入完成', message: `${next.files.length} files · ${next.entries.length} entries · ${next.maps.length} maps`, detail: [ `工程:${next.name}`, `文件:${next.files.length}`, `模型入口:${next.entries.length}`, `地图:${next.maps.length}`, next.selectedEntry ? `默认入口:${next.selectedEntry}` : '默认入口:未选择', ].join('\n'), category: 'import', tone: 'success', at: Date.now(), }; setNotifications((items) => [importNotice, ...items].slice(0, 20)); setToast(importNotice); 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), message: error instanceof ProjectImportError ? error.path : undefined, category: 'import', 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 = () => { setMapCommitState('idle'); void viewer.current?.setVisualMaps([]); 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); setTrainingDeployment(undefined); setPolicyStatus(undefined); setProjectMaps([]); setCommittedEditorDocuments(new Map()); setProjectSidebarTab('assets'); setEditorSelection(null); setEditorDocument(null); editorDraftsRef.current = new Map(); setEditorDrafts(new Map()); setEditorSessionStates(new Map()); provisionalMapFilesRef.current.clear(); viewer.current?.setMapEditorDocument(null); placedMapAssetsRef.current = []; setPlacedMapAssets([]); appliedMapAssetsRef.current = []; setAppliedMapAssets([]); viewer.current?.setParametricMapAssets([], []); activeMapAssetIdRef.current = undefined; setActiveMapAssetId(undefined); 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 editorSurfaceHeight = useCallback( (position: readonly [number, number, number]): number | null => { const selection = mapSelectionRef.current; const worldPosition = selection.kind === 'project' ? mapLocalPointToWorld(position, selection) : ([...position] as [number, number, number]); return mapSceneSurfaceHeightAt( placedMapAssetsRef.current, worldPosition[0], worldPosition[1], ); }, [], ); const previewEditorDocument = useCallback((document: EditableMapDocument | null) => { const selection = mapSelectionRef.current; viewer.current?.setMapEditorDocument( document, selection.kind === 'project' ? mapSelectionTransform(selection) : undefined, ); if (document) { adapter.current.setPaused(true); state.setPaused(true); } }, []); const updateEditorDraft = useCallback( (descriptorPath: string, document: EditableMapDocument, dirty: boolean) => { if (loadInFlight.current) return; const drafts = new Map(editorDraftsRef.current); if (dirty) drafts.set(descriptorPath, structuredClone(document)); else drafts.delete(descriptorPath); editorDraftsRef.current = drafts; setEditorDrafts(drafts); }, [], ); const clearEditorDrafts = useCallback(() => { editorDraftsRef.current = new Map(); setEditorDrafts(new Map()); setEditorSessionStates(new Map()); }, []); 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, pending.externalSupportTop, ); } const objectId = pendingEditorObjectId.current; if (callbacks && objectId) { pendingEditorObjectId.current = undefined; callbacks.onSelect(objectId); } }, []); const selectEditorObject = useCallback((id: string | null) => { viewer.current?.selectMapEditorObject(id); const mapAssetId = activeMapAssetIdRef.current; setEditorSelection( mapAssetId ? id ? { kind: 'map-object', mapAssetId, objectId: id } : { kind: 'map', mapAssetId } : null, ); }, []); const updateEditorSessionState = useCallback( (descriptorPath: string, sessionState: MapEditorSessionState | null) => { setEditorSessionStates((current) => { const next = new Map(current); if (sessionState?.dirty) next.set(descriptorPath, sessionState); else next.delete(descriptorPath); return next; }); }, [], ); const activateMapEditing = useCallback(() => { state.setMode('select'); }, []); const changeMapTransformMode = useCallback((nextMode: MapEditorTransformMode) => { setMapTransformMode(nextMode); state.setMode('select'); }, []); const changeMapPlacementMode = useCallback( (placementMode: MapObjectPlacementMode) => { setAssetPlacementMode(placementMode); if (placementMode === 'locked') setMapTransformMode((currentMode) => (currentMode === 'scale' ? 'translate' : currentMode)); if (editorSelection?.kind === 'map-object') { editorInteraction.current?.onSetPlacementMode(editorSelection.objectId, placementMode); if (placementMode !== 'locked') viewer.current?.flashMapEditorSurfaceAlignment(editorSelection.objectId); } }, [editorSelection], ); const alignSelectedMapObject = useCallback(() => { if (editorSelection?.kind !== 'map-object') return; editorInteraction.current?.onAlignToSurface(editorSelection.objectId); viewer.current?.flashMapEditorSurfaceAlignment(editorSelection.objectId); }, [editorSelection]); const focusSelectedObject = useCallback(() => { const bodyId = editorSelection?.kind === 'body' || editorSelection?.kind === 'joint' ? editorSelection.bodyId : undefined; viewer.current?.focusSelection(bodyId); }, [editorSelection]); const deleteSelectedMapObject = useCallback(() => { if (editorSelection?.kind === 'map-object') editorInteraction.current?.onDelete(editorSelection.objectId); }, [editorSelection]); const readEditorDocument = useCallback((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 sceneEditorDocuments = useMemo( () => new Map([...committedEditorDocuments, ...editorDrafts]), [committedEditorDocuments, editorDrafts], ); const mapEditorDraftPreviews = useMemo( () => mapEditorDraftPreviewInstances( placedMapAssets, pendingSceneIds, sceneEditorDocuments, activeMapAssetId, ), [placedMapAssets, pendingSceneIds, sceneEditorDocuments, activeMapAssetId], ); useEffect(() => { let active = true; const update = (activeViewer: MuJoCoViewer | null) => { if (active) activeViewer?.setMapEditorPreviewInstances(mapEditorDraftPreviews); }; if (viewer.current) update(viewer.current); else void viewerReady.current?.then(update); return () => { active = false; }; }, [mapEditorDraftPreviews]); const selectionName = (selection: PlacedMapSelection): string => selection.kind === 'builtin' ? PHYSICAL_MAP_PRESET_LABELS[selection.config.preset] : (projectMaps.find((map) => map.descriptorPath === selection.descriptorPath)?.name ?? selection.descriptorPath.split('/').at(-2) ?? '工程地图'); const uniqueMapAssetName = ( base: string, assets: readonly PlacedMapAsset[] = placedMapAssetsRef.current, excludedId?: string, ): string => { const names = new Set( assets.filter((asset) => asset.id !== excludedId).map((asset) => asset.name), ); if (!names.has(base)) return base; let index = 2; while (names.has(`${base} ${index}`)) index += 1; return `${base} ${index}`; }; const setMapScene = useCallback( (assets: PlacedMapAsset[], activeId: string | undefined, selection?: MapSelection) => { const active = activeId ? assets.find((asset) => asset.id === activeId) : undefined; const nextSelection = selection ?? active?.selection ?? DEFAULT_MAP_SELECTION; if (activeMapAssetIdRef.current !== active?.id) { viewer.current?.selectParametricMapAsset(null); viewer.current?.selectMapEditorObject(null); editorInteraction.current = null; } placedMapAssetsRef.current = assets; setPlacedMapAssets(assets); activeMapAssetIdRef.current = active?.id; setActiveMapAssetId(active?.id); mapSelectionRef.current = nextSelection; setMapSelection(nextSelection); if (nextSelection.kind === 'builtin') setMapTransformMode((currentMode) => (currentMode === 'scale' ? 'translate' : currentMode)); }, [], ); const focusMapProperties = useCallback(() => { setRightOpen(true); }, []); const previewVisualMapScene = useCallback( (assets: readonly PlacedMapAsset[], reload: boolean) => { const current = manifest.current; const activeViewer = viewer.current; if (!current || !activeViewer) return; try { const visuals = visualMapAssets(current, assets); if (reload) void activeViewer.setVisualMaps(visuals).catch((error) => { console.warn('[MuJoCo] 视觉地图草稿预览失败', error); }); else activeViewer.setVisualMapTransforms(visuals); } catch (error) { console.warn('[MuJoCo] 无法解析视觉地图草稿', error); } }, [], ); const stageMapSelectionDraft = useCallback( (selection: PlacedMapSelection) => { if (useAppStore.getState().loading || loadInFlight.current) return; const activeId = activeMapAssetIdRef.current; const active = activeId ? placedMapAssetsRef.current.find((asset) => asset.id === activeId) : undefined; if (!active || active.selection.kind !== selection.kind) return; if ( active.selection.kind === 'project' && selection.kind === 'project' && active.selection.descriptorPath !== selection.descriptorPath ) return; const assets = updatePlacedMapAsset(placedMapAssetsRef.current, active.id, selection); setMapScene(assets, active.id, selection); if (selection.kind === 'project') { previewVisualMapScene(assets, false); const committed = readEditorDocument(selection); setEditorDocument(committed); previewEditorDocument(editorDraftsRef.current.get(selection.descriptorPath) ?? committed); } adapter.current.setPaused(true); useAppStore.getState().setPaused(true); }, [previewEditorDocument, previewVisualMapScene, readEditorDocument, setMapScene], ); const activateMapAsset = useCallback( (id: string, objectId?: string) => { if (useAppStore.getState().loading || loadInFlight.current) return; const asset = placedMapAssetsRef.current.find((candidate) => candidate.id === id); if (!asset) return; pendingEditorObjectId.current = objectId; viewer.current?.selectParametricMapAsset(null); viewer.current?.selectMapEditorObject(null); editorInteraction.current?.onSelect(null); setMapScene(placedMapAssetsRef.current, id, asset.selection); const committedEditorDocument = readEditorDocument(asset.selection); const previewDocument = asset.selection.kind === 'project' ? (editorDraftsRef.current.get(asset.selection.descriptorPath) ?? committedEditorDocument) : null; setEditorDocument(committedEditorDocument); previewEditorDocument(previewDocument); if (objectId) { viewer.current?.selectMapEditorObject(objectId); if (editorInteraction.current) { editorInteraction.current.onSelect(objectId); pendingEditorObjectId.current = undefined; } } setEditorSelection( objectId ? { kind: 'map-object', mapAssetId: id, objectId } : { kind: 'map', mapAssetId: id }, ); useAppStore.getState().setSelection(null); focusMapProperties(); }, [focusMapProperties, previewEditorDocument, readEditorDocument, setMapScene], ); useEffect(() => { const interaction = (mapAssetId: string, objectId: string) => activateMapAsset(mapAssetId, objectId); mapEditorPreviewInteraction.current = interaction; return () => { if (mapEditorPreviewInteraction.current === interaction) mapEditorPreviewInteraction.current = () => {}; }; }, [activateMapAsset]); const selectCompiledEditableMapObject = useCallback( (bodyName: string): boolean => { const current = manifest.current; if (!current || useAppStore.getState().loading || loadInFlight.current) return false; const target = findCompiledEditableMapPick(bodyName, current, placedMapAssetsRef.current); if (!target) return false; activateMapAsset(target.mapAssetId, target.objectId); adapter.current.setPaused(true); useAppStore.getState().setPaused(true); return true; }, [activateMapAsset], ); useEffect(() => { compiledMapBodyInteraction.current = selectCompiledEditableMapObject; return () => { if (compiledMapBodyInteraction.current === selectCompiledEditableMapObject) compiledMapBodyInteraction.current = () => false; }; }, [selectCompiledEditableMapObject]); const selectParametricMapInViewport = useCallback( (id: string | null) => { if (!id) return; const asset = placedMapAssetsRef.current.find((candidate) => candidate.id === id); if (!asset || asset.selection.kind !== 'builtin' || asset.selection.config.preset === 'none') return; activateMapAsset(id); if (activeMapAssetIdRef.current !== id) return; viewer.current?.selectParametricMapAsset(id); adapter.current.setPaused(true); useAppStore.getState().setPaused(true); useAppStore.getState().setSelection(null); setEditorSelection({ kind: 'map', mapAssetId: id }); setRightOpen(true); }, [activateMapAsset], ); const updateParametricMapTransform = useCallback( (id: string, position: [number, number], yawDeg: number) => { if (useAppStore.getState().loading || loadInFlight.current) return; const next = transformParametricMapAsset(placedMapAssetsRef.current, id, position, yawDeg); if (next === placedMapAssetsRef.current) return; const assets = [...next]; const active = assets.find((asset) => asset.id === id); if (!active) return; setMapScene(assets, id, active.selection); setEditorDocument(null); adapter.current.setPaused(true); useAppStore.getState().setPaused(true); setEditorSelection({ kind: 'map', mapAssetId: id }); setRightOpen(true); }, [setMapScene], ); useEffect(() => { const interaction = { onSelect: selectParametricMapInViewport, onTransform: updateParametricMapTransform, }; parametricMapInteraction.current = interaction; return () => { if (parametricMapInteraction.current === interaction) parametricMapInteraction.current = null; }; }, [selectParametricMapInViewport, updateParametricMapTransform]); const createEditableScene = async ( type: EditableMapObjectType, position?: [number, number, number], placementMode: MapObjectPlacementMode = 'auto_ground', externalSupportTop = 0, ): 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: PlacedMapSelection = { kind: 'project', descriptorPath, positionX: 0, positionY: 0, yawDeg: 0, }; const placed = createPlacedMapAsset(selection, uniqueMapAssetName(definition.name)); pendingMapAsset.current = { type, position, placementMode, externalSupportTop }; manifest.current = candidate; provisionalMapFilesRef.current.set(descriptorPath, [ descriptorPath, physicsPath, authoringPath, ]); setProjectMaps(maps); setCommittedEditorDocuments(manifestEditorDocuments(candidate)); setMapScene([...placedMapAssetsRef.current, placed], placed.id, selection); setEditorSelection({ kind: 'map', mapAssetId: placed.id }); setRightOpen(true); setEditorDocument(document); 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; focusMapProperties(); const worldPosition = position; const externalSupportTop = position && placementMode === 'gravity' ? (mapSceneSurfaceHeightAt(placedMapAssetsRef.current, position[0], position[1]) ?? 0) : 0; const interaction = editorInteraction.current; if (interaction) { let localPosition = worldPosition; const selection = mapSelectionRef.current; if (worldPosition && selection.kind === 'project') localPosition = mapWorldPointToLocal(worldPosition, selection); interaction.onAddAsset(type, localPosition, placementMode, externalSupportTop); return; } await createEditableScene(type, worldPosition, placementMode, externalSupportTop); }; const appendMapSelection = (selection: PlacedMapSelection, requestedName?: string): boolean => { const entry = state.entries.find((candidate) => candidate.path === state.selectedEntry); if (!entry || state.loading || loadInFlight.current) return false; if (entry.format === 'urdf' && urdfModeRef.current === 'native') { state.setDiagnostic( diagnostic('模型编译', new Error('原生 URDF 不能注入地图,请切换为转换模式'), entry.path), ); return false; } const name = uniqueMapAssetName(requestedName ?? selectionName(selection)); const placed = createPlacedMapAsset(selection, name); const assets = [...placedMapAssetsRef.current, placed]; setMapScene(assets, placed.id, selection); setEditorSelection({ kind: 'map', mapAssetId: placed.id }); setRightOpen(true); if (selection.kind === 'project') previewVisualMapScene(assets, true); setEditorDocument(readEditorDocument(selection)); viewer.current?.setMapEditorDocument(null); adapter.current.setPaused(true); useAppStore.getState().setPaused(true); return true; }; const stageMapAssetRemoval = (id: string) => { const previousAssets = placedMapAssetsRef.current; const previousActiveId = activeMapAssetIdRef.current; const nextAssets = previousAssets.filter((asset) => asset.id !== id); if (nextAssets.length === previousAssets.length) return false; const nextActive = previousActiveId === id ? (nextAssets.at(-1) ?? undefined) : nextAssets.find((asset) => asset.id === previousActiveId); setMapScene(nextAssets, nextActive?.id, nextActive?.selection); setEditorSelection(nextActive ? { kind: 'map', mapAssetId: nextActive.id } : null); setEditorDocument(nextActive ? readEditorDocument(nextActive.selection) : null); viewer.current?.setMapEditorDocument(null); return true; }; const removePlacedMapAsset = (id: string) => { if (state.loading || loadInFlight.current) return; const target = placedMapAssetsRef.current.find((asset) => asset.id === id); if (!target || !stageMapAssetRemoval(id)) return; if (target.selection.kind === 'project') previewVisualMapScene(placedMapAssetsRef.current, true); adapter.current.setPaused(true); useAppStore.getState().setPaused(true); }; const performMapSceneCommit = async ( requestedDrafts: ReadonlyMap = editorDraftsRef.current, ): Promise => { const entry = state.entries.find((candidate) => candidate.path === state.selectedEntry); const current = manifest.current; if (!entry || !current || state.loading || loadInFlight.current) return false; if (entry.format === 'urdf' && urdfModeRef.current === 'native') { state.setDiagnostic( diagnostic('模型编译', new Error('原生 URDF 不能注入地图,请切换为转换模式'), entry.path), ); return false; } const submittedDrafts = new Map( [...requestedDrafts].map(([path, document]) => [path, structuredClone(document)]), ); let candidate: ProjectManifest; try { candidate = materializeEditableMapDrafts(current, submittedDrafts); const referencedDescriptors = new Set( placedMapAssetsRef.current.flatMap((asset) => asset.selection.kind === 'project' ? [asset.selection.descriptorPath] : [], ), ); const omittedPaths = new Set(); for (const [descriptorPath, paths] of provisionalMapFilesRef.current) if (!referencedDescriptors.has(descriptorPath)) for (const path of paths) omittedPaths.add(path); candidate = omitManifestFiles(candidate, omittedPaths); if (candidate.totalBytes > DEFAULT_IMPORT_LIMITS.maxTotalBytes) throw new Error('应用场景草稿后工程总大小超过 512 MiB'); } catch (error) { state.setDiagnostic(diagnostic('模型编译', error, entry.path)); return false; } manifest.current = candidate; const loaded = await loadEntry(entry.path, undefined, placedMapAssetsRef.current); if (!loaded) { manifest.current = current; return false; } const loadedManifest = manifest.current ?? candidate; const maps = discoverMapEntries(loadedManifest.files); const committed: ProjectManifest = { ...loadedManifest, maps }; manifest.current = committed; provisionalMapFilesRef.current.clear(); const remainingDrafts = new Map(editorDraftsRef.current); for (const [path, submitted] of submittedDrafts) { const latest = remainingDrafts.get(path); if (latest && JSON.stringify(latest) === JSON.stringify(submitted)) remainingDrafts.delete(path); } editorDraftsRef.current = remainingDrafts; setEditorDrafts(remainingDrafts); setEditorSessionStates((currentStates) => { const nextStates = new Map(currentStates); for (const path of submittedDrafts.keys()) if (!remainingDrafts.has(path)) nextStates.delete(path); return nextStates; }); setProjectMaps(maps); setCommittedEditorDocuments(manifestEditorDocuments(committed)); const committedEditorDocument = readEditorDocument(mapSelectionRef.current); const remainingEditorDraft = mapSelectionRef.current.kind === 'project' ? remainingDrafts.get(mapSelectionRef.current.descriptorPath) : undefined; setEditorDocument(committedEditorDocument); previewEditorDocument(remainingEditorDraft ?? committedEditorDocument); state.setProject( committed.name, committed.files.map((file) => ({ path: file.path, size: file.size })), committed.entries, entry.path, ); state.setSnapshot(adapter.current.snapshot() ?? undefined); const selectedMapAssetId = activeMapAssetIdRef.current; setEditorSelection(selectedMapAssetId ? { kind: 'map', mapAssetId: selectedMapAssetId } : null); return true; }; const commitMapScene = async ( requestedDrafts: ReadonlyMap = editorDraftsRef.current, ): Promise => { if (state.loading || loadInFlight.current) return false; setMapCommitState('submitting'); try { const applied = await performMapSceneCommit(requestedDrafts); setMapCommitState(applied ? 'idle' : 'failed'); return applied; } catch (error) { setMapCommitState('failed'); throw error; } }; const discardMapSceneDraft = () => { if (state.loading || loadInFlight.current) return; setMapCommitState('idle'); editorInteraction.current?.onDiscard(); clearEditorDrafts(); const omittedPaths = new Set(); for (const paths of provisionalMapFilesRef.current.values()) for (const path of paths) omittedPaths.add(path); if (manifest.current && omittedPaths.size) { const restoredManifest = omitManifestFiles(manifest.current, omittedPaths); manifest.current = restoredManifest; provisionalMapFilesRef.current.clear(); setProjectMaps(restoredManifest.maps); setCommittedEditorDocuments(manifestEditorDocuments(restoredManifest)); state.setProject( restoredManifest.name, restoredManifest.files.map((file) => ({ path: file.path, size: file.size })), restoredManifest.entries, state.selectedEntry, ); state.setSnapshot(adapter.current.snapshot() ?? undefined); } const assets = restoreAppliedMapScene(appliedMapAssetsRef.current); const active = assets.find((asset) => asset.id === activeMapAssetIdRef.current) ?? assets.at(-1); setMapScene(assets, active?.id, active?.selection); setEditorSelection(active ? { kind: 'map', mapAssetId: active.id } : null); previewVisualMapScene(assets, true); setEditorDocument(active ? readEditorDocument(active.selection) : null); viewer.current?.setMapEditorDocument(null); }; const applyMapSelection = (value: MapSelection) => { const entry = state.entries.find((candidate) => candidate.path === state.selectedEntry); if (!entry) return; const activeId = activeMapAssetIdRef.current; const active = activeId ? placedMapAssetsRef.current.find((asset) => asset.id === activeId) : undefined; if (value.kind === 'none') { if (!active) return; removePlacedMapAsset(active.id); void commitMapScene(); return; } if (entry.format === 'urdf' && urdfModeRef.current === 'native') { state.setDiagnostic( diagnostic('模型编译', new Error('原生 URDF 不能注入地图,请切换为转换模式'), entry.path), ); return; } if (!active) { if (appendMapSelection(value)) void commitMapScene(); return; } const nextName = uniqueMapAssetName( selectionName(value), placedMapAssetsRef.current, active.id, ); const nextAssets = updatePlacedMapAsset(placedMapAssetsRef.current, active.id, value, nextName); setMapScene(nextAssets, active.id, value); setEditorSelection({ kind: 'map', mapAssetId: active.id }); setEditorDocument(readEditorDocument(value)); viewer.current?.setMapEditorDocument(null); void commitMapScene(); }; const selectTerrainAsset = (preset: SystemTerrainPreset, position?: [number, number, number]) => { const positionX = position ? Math.round(position[0] * 10) / 10 : 0; const positionY = position ? Math.round(position[1] * 10) / 10 : 0; void appendMapSelection( { kind: 'builtin', config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset, positionX, positionY }, }, PHYSICAL_MAP_PRESET_LABELS[preset], ); }; const addProjectMapAsset = (descriptorPath: string, position?: [number, number, number]) => { const map = projectMaps.find((candidate) => candidate.descriptorPath === descriptorPath); if (!map) return; appendMapSelection( { kind: 'project', descriptorPath, positionX: position ? Math.round(position[0] * 10) / 10 : 0, positionY: position ? Math.round(position[1] * 10) / 10 : 0, yawDeg: 0, }, map.name, ); }; const applyEditorDocument = async (document: EditableMapDocument): Promise => { const selection = mapSelectionRef.current; if (selection.kind !== 'project' || !manifest.current || !state.selectedEntry) return false; updateEditorDraft(selection.descriptorPath, document, true); return commitMapScene(new Map(editorDraftsRef.current)); }; 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); setCommittedEditorDocuments(manifestEditorDocuments(committed)); 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); const selectedMapAssetId = activeMapAssetIdRef.current; setEditorSelection( selectedMapAssetId ? { kind: 'map', mapAssetId: selectedMapAssetId } : null, ); 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); setMapAssetDropTarget(undefined); }; const dragEnter = (event: DragEvent) => { if (hasTransferType(event.dataTransfer, MAP_LIBRARY_DRAG_MIME)) { const bounds = viewportShell.current?.getBoundingClientRect(); if (bounds) setMapAssetDropTarget({ left: event.clientX - bounds.left, top: event.clientY - bounds.top, position: viewer.current?.mapPlanePoint(event.clientX, event.clientY) ?? null, }); return; } if (hasTransferType(event.dataTransfer, 'Files')) { dragDepth.current += 1; if (!state.loading) setDragActive(true); } }; const dragLeave = (event: DragEvent) => { if (hasTransferType(event.dataTransfer, MAP_LIBRARY_DRAG_MIME)) { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setMapAssetDropTarget(undefined); return; } if (!hasTransferType(event.dataTransfer, 'Files')) return; dragDepth.current = Math.max(0, dragDepth.current - 1); if (dragDepth.current === 0) setDragActive(false); }; const dragOver = (event: DragEvent) => { event.preventDefault(); if (hasTransferType(event.dataTransfer, MAP_LIBRARY_DRAG_MIME)) { const position = viewer.current?.mapPlanePoint(event.clientX, event.clientY) ?? null; const bounds = viewportShell.current?.getBoundingClientRect(); if (bounds) setMapAssetDropTarget({ left: event.clientX - bounds.left, top: event.clientY - bounds.top, position, }); event.dataTransfer.dropEffect = position ? 'copy' : 'none'; return; } if (hasTransferType(event.dataTransfer, 'Files')) event.dataTransfer.dropEffect = state.loading ? 'none' : 'copy'; }; const drop = (event: DragEvent) => { event.preventDefault(); resetDragState(); const mapAsset = decodeMapLibraryDragPayload(event.dataTransfer.getData(MAP_LIBRARY_DRAG_MIME)); if (mapAsset) { event.stopPropagation(); const position = viewer.current?.mapPlanePoint(event.clientX, event.clientY); if (!position) return; if (mapAsset.kind === 'certified') void addCertifiedMapAsset(mapAsset.type, position, mapAsset.placementMode); else if (mapAsset.kind === 'terrain') selectTerrainAsset(mapAsset.preset, position); else addProjectMapAsset(mapAsset.descriptorPath, position); return; } if (state.loading || importInFlight.current) return; // 必须在 drop 用户手势仍有效时读取句柄;Chromium 随后会清空 DataTransfer。 const filesPromise = filesFromDrop(event.dataTransfer.items, event.dataTransfer.files); importInFlight.current = true; state.setLoading(true); setImportProgress({ title: '正在导入工程', label: '扫描拖放的文件与文件夹', value: 0.02, }); void (async () => { try { const files = await filesPromise; 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, expected?: PolicyDeployment) => { if (policyLoadInFlight.current || loadInFlight.current) throw new Error('模型/策略正在加载,请稍后重试'); viewer.current?.setNavigationTargetMode(false); const previousPaused = useAppStore.getState().paused; const previousSession = adapter.current.session; policyLoadInFlight.current = true; state.setLoading(true); state.setDiagnostic(undefined); adapter.current.setPaused(true); state.setPaused(true); try { const deployment = resolvePolicyDeployment(data, expected); if (deployment?.terrain) { const entry = useAppStore.getState().selectedEntry; if (!entry || !manifest.current) throw new Error('请先导入并加载Go2机器人'); if (!(await loadEntry(entry, 'mjcf', undefined, deployment, { data, path }))) throw new Error( useAppStore.getState().diagnostic?.detail ?? '配套训练地图加载失败,策略未启用', ); } setImportProgress({ title: '正在加载强化学习策略', label: '初始化 ONNX Runtime', detail: path, value: 0.55, }); state.setLoading(true); const session = adapter.current.session; const status = deployment?.terrain ? adapter.current.snapshot()!.rlPolicy! : await adapter.current.loadRLPolicy(data, path, deployment); if (session !== adapter.current.session) throw new Error('模型已切换,策略加载取消'); if (deployment?.terrain) { adapter.current.setPaused(false); state.setPaused(false); setShowSensorCamera(true); viewer.current?.setShowSensorCamera(true); } setPolicyStatus(adapter.current.snapshot()?.rlPolicy ?? status); state.setSnapshot(adapter.current.snapshot() ?? undefined); notify( 'ONNX 策略已加载', `${status.taskName} · ${status.observationSize} → ${status.actionSize}`, ); } catch (error) { if (adapter.current.session === previousSession) { adapter.current.setPaused(previousPaused); state.setPaused(previousPaused); state.setSnapshot(adapter.current.snapshot() ?? undefined); setPolicyStatus(adapter.current.snapshot()?.rlPolicy); } state.setDiagnostic(diagnostic('仿真', error, path)); throw error; } finally { policyLoadInFlight.current = false; 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).catch(() => {}); }; const importPolicy = async (file: File, expected?: PolicyDeployment) => { try { if (!/\.onnx$/i.test(file.name)) throw new Error('请选择 .onnx 文件'); if (file.size > 64 * 1024 * 1024) throw new Error('ONNX 策略不能超过 64 MiB'); const project = manifest.current; const path = normalizeProjectPath(file.name), data = new Uint8Array(await file.arrayBuffer()); if (project !== manifest.current) throw new Error('工程已切换,策略导入取消'); const projectedBytes = (project?.files .filter((item) => item.path !== path) .reduce((total, item) => total + item.size, 0) ?? 0) + data.byteLength; if (projectedBytes > DEFAULT_IMPORT_LIMITS.maxTotalBytes) throw new Error('加入 ONNX 后工程总大小超过 512 MiB'); await loadPolicyBytes(data, path, expected); if (project && manifest.current?.id === project.id) { const files = manifest.current.files.filter((candidate) => candidate.path !== path); files.push({ path, data, size: data.byteLength, source: 'file', mimeType: file.type || 'application/octet-stream', }); 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, size }) => ({ path, size })), manifest.current.entries, useAppStore.getState().selectedEntry, ); state.setSnapshot(adapter.current.snapshot() ?? undefined); } setSelectedPolicyPath(path); } catch (error) { state.setDiagnostic(diagnostic('仿真', error, file.name)); throw error; } }; 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 configureDataRecorder = (patch: Partial) => { try { adapter.current.configureDataRecorder(patch); state.setSnapshot(adapter.current.snapshot() ?? undefined); } catch (error) { state.setDiagnostic(diagnostic('仿真', error, state.selectedEntry)); } }; const startDataRecording = () => { adapter.current.startDataRecording(); state.setSnapshot(adapter.current.snapshot() ?? undefined); }; const stopDataRecording = () => { adapter.current.stopDataRecording(); state.setSnapshot(adapter.current.snapshot() ?? undefined); }; const clearDataRecording = () => { adapter.current.clearDataRecording(); state.setSnapshot(adapter.current.snapshot() ?? undefined); }; const exportDataRecording = (format: 'csv' | 'json') => { try { const stem = (manifest.current?.name ?? 'simulation') .replace(/\.(?:zip|xml|urdf)$/i, '') .replace(/[^\p{L}\p{N}._-]+/gu, '_') || 'simulation'; downloadBytes( adapter.current.exportDataRecording(format), `${stem}-telemetry.${format}`, format === 'csv' ? 'text/csv' : 'application/json', ); notify(`遥测 ${format.toUpperCase()} 已导出`, `${stem}-telemetry.${format}`); } catch (error) { state.setDiagnostic(diagnostic('仿真', error, state.selectedEntry)); } }; 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 showWorkspaceTool = (tool: WorkspaceTool) => { setWorkspaceTool(tool); setRightOpen(true); }; 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); setWorkspaceTool('controls'); dispatchLayoutWidths(288, 384); } else { setLeftOpen(true); setRightOpen(true); dispatchLayoutWidths(288, 288); } }; useMapEditorShortcuts({ enabled: Boolean(state.snapshot), mapEditing: mapEditingActive, dirty: mapSceneDirty, loading: state.loading, hasSelection: Boolean(editorSelection ?? state.selection), canDelete: editorSelection?.kind === 'map-object', canScale: mapSelection.kind === 'project' && selectedMapObject?.placementMode !== 'locked', onTransformMode: changeMapTransformMode, onFocusSelection: focusSelectedObject, onDeleteSelection: deleteSelectedMapObject, onToggleSnapping: () => setMapSnapping((value) => !value), onSave: () => void commitMapScene(), }); useEffect(() => { const key = (event: KeyboardEvent) => { if (event.defaultPrevented) return; if ( document.activeElement instanceof HTMLElement && document.activeElement.closest('[role="dialog"]') ) return; const target = event.target instanceof HTMLElement ? event.target : null; if (isTextEditingTarget(target)) return; if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === 'k') { event.preventDefault(); setCommandOpen(true); return; } if (target?.closest('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: 'workspace-controls', label: '在右侧打开控制台', group: '工具', icon: , disabled: !state.snapshot, run: () => showWorkspaceTool('controls'), }, { id: 'workspace-data', label: '在右侧打开数据录制', group: '工具', icon: , disabled: !state.snapshot, run: () => showWorkspaceTool('data'), }, { 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), }, ]; const [workspaceToolsVisited, setWorkspaceToolsVisited] = useState(false); if (workspaceTool && !workspaceToolsVisited) setWorkspaceToolsVisited(true); const workspaceTools = workspaceToolsVisited || workspaceTool ? ( 正在加载工作区工具… } > /\.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} 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} compileTrainingScene={(coordinates) => { if ( mapSceneDirty || trainingDeployment || useAppStore.getState().loading || loadInFlight.current ) throw new Error('请先应用地图草稿;训练部署/加载中的场景不能同步'); return adapter.current.exportTrainingTerrain(appliedMapAssets, coordinates); }} trainingSceneMaps={appliedMapAssets} trainingSceneDirty={mapSceneDirty || Boolean(trainingDeployment)} onTogglePolicy={togglePolicy} onPolicyCommand={setPolicyCommand} navigationTargetMode={navigationTargetMode} onNavigationTargetMode={(active) => viewer.current?.setNavigationTargetMode(active)} onResetNavigationTarget={() => { viewer.current?.setNavigationTargetMode(false); adapter.current.resetNavigationTarget(); const snapshot = adapter.current.snapshot(); state.setSnapshot(snapshot ?? undefined); setPolicyStatus(snapshot?.rlPolicy); }} onRemovePolicy={removePolicy} onDataRecorderConfigure={configureDataRecorder} onDataRecordingStart={startDataRecording} onDataRecordingStop={stopDataRecording} onDataRecordingClear={clearDataRecording} onDataRecordingExport={exportDataRecording} /> ) : undefined; return (
setSourceOpen(true)} onToggleLeft={() => setLeftOpen((value) => !value)} onToggleRight={() => setRightOpen((value) => !value)} endActions={ <> setNotifications((items) => items.filter((item) => item.id !== id)) } onClear={() => setNotifications([])} onOpenLog={() => setDiagnosticsOpen(true)} /> setLayoutOpen(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)} /> = 1024 ? (workspaceWidth - 480) / 2 : undefined } >
{ state.setSelection(null); setEditorSelection({ kind: 'body', bodyId }); viewer.current?.selectMapEditorObject(null); viewer.current?.selectParametricMapAsset(null); viewer.current?.highlightJoint(null); setRightOpen(true); }} onSelectJoint={(jointId, bodyId) => { state.setSelection(null); setEditorSelection({ kind: 'joint', jointId, bodyId }); viewer.current?.selectMapEditorObject(null); viewer.current?.selectParametricMapAsset(null); viewer.current?.highlightJoint(jointId); setRightOpen(true); }} onJointHover={(jointId) => viewer.current?.highlightJoint( jointId ?? (editorSelection?.kind === 'joint' ? editorSelection.jointId : null), ) } onAddMapAsset={(type, placementMode) => addCertifiedMapAsset(type, undefined, placementMode) } onAddProjectMap={addProjectMapAsset} onSelectTerrain={selectTerrainAsset} onAssetPlacementModeChange={setAssetPlacementMode} onApplyScene={() => void commitMapScene()} onDiscardScene={discardMapSceneDraft} onSelectMap={activateMapAsset} onRemoveMap={removePlacedMapAsset} onSelectMapObject={(mapId, objectId) => activateMapAsset(mapId, objectId)} />
} orientation={
} view={
viewer.current?.resetCamera()} >
} controls={
} draft={ void commitMapScene()} onDiscard={discardMapSceneDraft} /> } notices={
{state.diagnostic && ( state.setDiagnostic(undefined)} onRetry={ state.diagnostic.category === '模型编译' && state.diagnostic.path ? () => void loadEntry(state.diagnostic!.path!) : undefined } onOpenProject={() => { setLeftOpen(true); state.setDiagnostic(undefined); }} /> )} {!state.diagnostic && ( setToast(undefined)} /> )}
} context={ trainingDeployment && (
训练配套物理地图(编辑器地图未更改,重载模型恢复)。 {trainingDeployment.terrain?.approximation && '训练专用离散近似。'} {(policyStatus?.observationSize === 81 || policyStatus?.observationSize === 97) && ( )}
) } camera={ Boolean(state.snapshot?.model.ncam) && (showSensorCamera ? (
{ sensorCameraFrame.current = node; viewer.current?.setSensorCameraViewportElement(node); }} className="sensor-camera-frame overflow-hidden rounded-lg border border-border-strong shadow-xl" >
摄像头
) : ( )) } /> {state.entries.length > 1 && !state.selectedEntry && !pendingUrdfPath && ( )}{' '}
asset.id === activeMapAssetId)?.name } mapSceneDirty={mapSceneDirty} maps={projectMaps} showVisualMap={showVisualMap} showMapCollision={showMapCollision} editorDocument={editorDocument} editorDraftDocument={ mapSelection.kind === 'project' ? editorDrafts.get(mapSelection.descriptorPath) : undefined } workspaceTool={workspaceTool} workspaceTools={workspaceTools} onWorkspaceToolChange={(tool) => { setWorkspaceTool(tool); if (tool) setRightOpen(true); }} onSelectJoint={(jointId, bodyId) => { setEditorSelection({ kind: 'joint', jointId, bodyId }); viewer.current?.highlightJoint(jointId); }} 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} onApplyMap={applyMapSelection} onMapDraft={stageMapSelectionDraft} onEditorPreview={previewEditorDocument} onEditorDraftChange={updateEditorDraft} onEditorApply={applyEditorDocument} onEditorExport={exportSelectedMap} onEditorConvert={convertSelectedMap} onEditorBindInteraction={bindEditorInteraction} onEditorSelect={selectEditorObject} onEditorSessionStateChange={updateEditorSessionState} onEditorSurfaceHeight={editorSurfaceHeight} onMapDisplay={(visual, collision) => { setShowVisualMap(visual); setShowMapCollision(collision); }} />
{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} ”吗?

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

); }