Files
Mujoco_WASM/web_platform/src/app/App.tsx
T
chenlin cb3fb47561
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (pull_request) Has been cancelled
web-platform-ci / Playwright E2E (pull_request) Has been cancelled
feat(web-platform): release V0.9.4 前端设计优化
2026-09-09 13:15:46 +08:00

2911 lines
114 KiB
TypeScript

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<ViewerDisplayOptions> | 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<string>,
): 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<string, EditableMapDocument> {
const documents = new Map<string, EditableMapDocument>();
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<ProjectManifest | null>(null),
notificationId = useRef(0),
loadInFlight = useRef(false),
policyLoadInFlight = useRef(false),
importInFlight = useRef(false),
adapter = useRef(new MainThreadPhysicsAdapter()),
root = useRef<HTMLDivElement>(null),
viewportShell = useRef<HTMLElement>(null),
viewerHost = useRef<HTMLDivElement>(null),
viewer = useRef<MuJoCoViewer | null>(null),
viewerReady = useRef<Promise<MuJoCoViewer | null> | null>(null),
dragDepth = useRef(0),
editorInteraction = useRef<MapEditorInteractionCallbacks | null>(null),
editorDraftsRef = useRef<Map<string, EditableMapDocument>>(new Map()),
provisionalMapFilesRef = useRef<Map<string, string[]>>(new Map()),
pendingEditorObjectId = useRef<string | undefined>(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<UrdfEnhancementOptions>({
addActuators: true,
addSensors: true,
sensorType: 'camera',
});
const {
width: workspaceWidth,
leftOpen,
rightOpen,
setLeftOpen,
setRightOpen,
} = useSidebarLayout();
const sensorCameraFrame = useRef<HTMLDivElement>(null);
const orientationHost = useRef<HTMLDivElement>(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<string>(),
[generatedMjcfPath, setGeneratedMjcfPath] = useState<string>(),
[pendingUrdfPath, setPendingUrdfPath] = useState<string>(),
[pendingUrdfMounts, setPendingUrdfMounts] = useState<string[]>([]),
[removeConfirmOpen, setRemoveConfirmOpen] = useState(false),
[fullscreen, setFullscreen] = useState(false),
[settingsOpen, setSettingsOpen] = useState(false),
[layoutOpen, setLayoutOpen] = useState(false),
[diagnosticsOpen, setDiagnosticsOpen] = useState(false),
[dragActive, setDragActive] = useState(false),
[importProgress, setImportProgress] = useState<ImportProgress>(),
[notifications, setNotifications] = useState<WorkbenchNotification[]>([]),
[toast, setToast] = useState<WorkbenchNotification>(),
[selectedControllerPath, setSelectedControllerPath] = useState<string>(),
[controllerStatus, setControllerStatus] = useState<ControllerStatus>(),
[selectedPolicyPath, setSelectedPolicyPath] = useState<string>(),
[policyStatus, setPolicyStatus] = useState<RLPolicyStatus>(),
[navigationTargetMode, setNavigationTargetMode] = useState(false),
[trainingDeployment, setTrainingDeployment] = useState<PolicyDeployment>(),
[showPerceptionRays, setShowPerceptionRays] = useState(true),
[projectMaps, setProjectMaps] = useState<MapEntry[]>([]),
[editorDocument, setEditorDocument] = useState<EditableMapDocument | null>(null),
[editorDrafts, setEditorDrafts] = useState<Map<string, EditableMapDocument>>(() => new Map()),
[committedEditorDocuments, setCommittedEditorDocuments] = useState<
Map<string, EditableMapDocument>
>(() => new Map()),
[projectSidebarTab, setProjectSidebarTab] = useState<ProjectResourceTab>('assets'),
[editorSelection, setEditorSelection] = useState<EditorSelection | null>(null),
[workspaceTool, setWorkspaceTool] = useState<WorkspaceTool | null>(null),
[mapTransformMode, setMapTransformMode] = useState<MapEditorTransformMode>('translate'),
[mapSnapping, setMapSnapping] = useState(true),
[assetPlacementMode, setAssetPlacementMode] = useState<MapObjectPlacementMode>('auto_ground'),
[editorSessionStates, setEditorSessionStates] = useState<Map<string, MapEditorSessionState>>(
() => new Map(),
),
[mapAssetDropTarget, setMapAssetDropTarget] = useState<MapAssetDropTarget>();
const [urdfMode, setUrdfMode] = useState<UrdfLoadMode>('mjcf'),
urdfModeRef = useRef<UrdfLoadMode>('mjcf');
const [baseMode, setBaseMode] = useState<UrdfBaseMode>('floating'),
baseModeRef = useRef<UrdfBaseMode>('floating');
const [mapSelection, setMapSelection] = useState<MapSelection>(DEFAULT_MAP_SELECTION),
mapSelectionRef = useRef<MapSelection>(DEFAULT_MAP_SELECTION),
[placedMapAssets, setPlacedMapAssets] = useState<PlacedMapAsset[]>([]),
placedMapAssetsRef = useRef<PlacedMapAsset[]>([]),
[appliedMapAssets, setAppliedMapAssets] = useState<PlacedMapAsset[]>([]),
appliedMapAssetsRef = useRef<PlacedMapAsset[]>([]),
[activeMapAssetId, setActiveMapAssetId] = useState<string>(),
activeMapAssetIdRef = useRef<string | undefined>(undefined),
[showVisualMap, setShowVisualMap] = useState(true),
[showMapCollision, setShowMapCollision] = useState(false);
const [displayOptions, setDisplayOptions] = useState<ViewerDisplayOptions>(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<boolean> => {
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<string, EditableMapDocument> = editorDraftsRef.current,
): Promise<boolean> => {
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<string>();
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<string, EditableMapDocument> = editorDraftsRef.current,
): Promise<boolean> => {
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<string>();
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<boolean> => {
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<boolean> => {
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<HTMLInputElement>) => {
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<DataRecorderConfig>) => {
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 ? <Play className="h-4 w-4" /> : <Pause className="h-4 w-4" />,
shortcut: 'Space',
disabled: !state.snapshot,
run: togglePause,
},
{
id: 'reset',
label: '重置仿真',
group: '仿真',
icon: <RotateCcw className="h-4 w-4" />,
shortcut: 'R',
disabled: !state.snapshot,
run: reset,
},
{
id: 'select',
label: '切换到选择模式',
group: '视口',
icon: <MousePointer2 className="h-4 w-4" />,
shortcut: '1',
run: () => mode('select'),
},
{
id: 'joint',
label: '切换到关节拖动',
group: '视口',
icon: <Hand className="h-4 w-4" />,
shortcut: '2',
run: () => mode('joint'),
},
{
id: 'force',
label: '切换到外力施加',
group: '视口',
icon: <Crosshair className="h-4 w-4" />,
shortcut: '3',
run: () => mode('force'),
},
{
id: 'camera',
label: '复位相机',
group: '视口',
icon: <Camera className="h-4 w-4" />,
run: () => viewer.current?.resetCamera(),
},
{
id: 'source',
label: '查看和修改缓存源代码',
group: '工程',
icon: <Code2 className="h-4 w-4" />,
disabled: !generatedMjcf,
run: () => setSourceOpen(true),
},
{
id: 'export-urdf',
label: '导出 URDF 文件',
group: '工程',
icon: <Download className="h-4 w-4" />,
disabled: selectedFormat !== 'urdf',
run: exportUrdf,
},
{
id: 'export-mjcf',
label: '导出 MJCF 文件',
group: '工程',
icon: <Download className="h-4 w-4" />,
disabled: !state.snapshot,
run: exportMjcf,
},
{
id: 'workspace-controls',
label: '在右侧打开控制台',
group: '工具',
icon: <SlidersHorizontal className="h-4 w-4" />,
disabled: !state.snapshot,
run: () => showWorkspaceTool('controls'),
},
{
id: 'workspace-data',
label: '在右侧打开数据录制',
group: '工具',
icon: <Database className="h-4 w-4" />,
disabled: !state.snapshot,
run: () => showWorkspaceTool('data'),
},
{
id: 'left',
label: leftOpen ? '隐藏工程面板' : '显示工程面板',
group: '布局',
icon: leftOpen ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />,
run: () => setLeftOpen((value) => !value),
},
{
id: 'right',
label: rightOpen ? '隐藏右侧面板' : '显示右侧面板',
group: '布局',
icon: rightOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />,
run: () => setRightOpen((value) => !value),
},
{
id: 'theme',
label: theme === 'dark' ? '切换到白天主题' : '切换到黑夜主题',
group: '外观',
icon: <SunMoon className="h-4 w-4" />,
run: () => setTheme((value) => (value === 'dark' ? 'light' : 'dark')),
},
{
id: 'fullscreen',
label: fullscreen ? '退出全屏' : '进入全屏',
group: '布局',
icon: <Maximize className="h-4 w-4" />,
run: toggleFullscreen,
},
{
id: 'help',
label: '查看快捷键帮助',
group: '帮助',
icon: <CircleHelp className="h-4 w-4" />,
run: () => setHelpOpen(true),
},
];
const [workspaceToolsVisited, setWorkspaceToolsVisited] = useState(false);
if (workspaceTool && !workspaceToolsVisited) setWorkspaceToolsVisited(true);
const workspaceTools =
workspaceToolsVisited || workspaceTool ? (
<Suspense
fallback={
<div
role="status"
className="grid min-h-0 flex-1 place-items-center p-4 text-sm text-text-tertiary"
>
正在加载工作区工具…
</div>
}
>
<WorkspaceToolsPanel
active={workspaceTool ?? 'controls'}
snapshot={state.snapshot}
loading={state.loading}
ignoreJointLimits={ignoreJointLimits}
jointAdvanced={jointAdvanced}
angleUnit={angleUnit}
forceScale={forceScale}
controllerPaths={state.files
.filter((file) => /\.py$/i.test(file.path))
.map((file) => file.path)}
selectedControllerPath={selectedControllerPath}
controllerStatus={controllerStatus}
policyPaths={state.files
.filter((file) => /\.onnx$/i.test(file.path))
.map((file) => file.path)}
selectedPolicyPath={selectedPolicyPath}
policyStatus={policyStatus}
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}
/>
</Suspense>
) : undefined;
return (
<div
ref={root}
className={`${theme === 'light' ? 'theme-light' : 'theme-dark'} cyber-workspace flex h-screen min-w-0 flex-col overflow-hidden bg-app text-text-primary`}
onDragEnter={dragEnter}
onDragLeave={dragLeave}
onDragOver={dragOver}
onDragEnd={resetDragState}
onDrop={drop}
>
<WorkbenchHeader
loading={state.loading}
leftOpen={leftOpen}
rightOpen={rightOpen}
hasProject={Boolean(generatedMjcf)}
onFiles={changeFiles}
onFolder={changeFiles}
onOpenSource={() => setSourceOpen(true)}
onToggleLeft={() => setLeftOpen((value) => !value)}
onToggleRight={() => setRightOpen((value) => !value)}
endActions={
<>
<NotificationCenter
items={notifications}
onDismiss={(id) =>
setNotifications((items) => items.filter((item) => item.id !== id))
}
onClear={() => setNotifications([])}
onOpenLog={() => setDiagnosticsOpen(true)}
/>
<IconButton
tooltip="布局设置"
aria-label="布局设置"
onClick={() => setLayoutOpen(true)}
>
<PanelsTopLeft className="h-4 w-4" />
</IconButton>
</>
}
compactMenu={
<ToolbarOverflowMenu
fullscreen={fullscreen}
onCommands={() => setCommandOpen(true)}
onLayout={() => setLayoutOpen(true)}
onSettings={() => setSettingsOpen(true)}
onFullscreen={toggleFullscreen}
onHelp={() => setHelpOpen(true)}
onTheme={() => setTheme((value) => (value === 'dark' ? 'light' : 'dark'))}
/>
}
onCommands={() => setCommandOpen(true)}
/>
<PanelWidthBudgetContext
value={
leftOpen && rightOpen && workspaceWidth >= 1024 ? (workspaceWidth - 480) / 2 : undefined
}
>
<div
className="workbench-body relative flex min-h-0 flex-1"
data-both-sidebars={leftOpen && rightOpen}
>
<ProjectSidebar
visible={leftOpen}
projectName={state.projectName}
files={state.files}
entries={state.entries}
selectedEntry={state.selectedEntry}
snapshot={state.snapshot}
selection={editorSelection}
loading={state.loading}
nativeUrdf={selectedFormat === 'urdf' && urdfMode === 'native'}
mapSelection={mapSelection}
maps={projectMaps}
placedMaps={placedMapAssets}
pendingSceneChangeCount={sceneDraftChangeCount}
pendingSceneIds={pendingSceneIds}
editorDocuments={sceneEditorDocuments}
assetPlacementMode={assetPlacementMode}
activeTab={projectSidebarTab}
onActiveTabChange={setProjectSidebarTab}
onRemove={removeProject}
onSelectEntry={requestLoadEntry}
onSelectBody={(bodyId) => {
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)}
/>
<main ref={viewportShell} className="viewport-shell relative min-w-0 flex-1">
<div ref={viewerHost} className="absolute inset-0" />
<ViewportOverlayLayout
status={<ViewportHUD />}
orientation={<div ref={orientationHost} />}
view={
<div
aria-label="视图工具"
className="flex items-center gap-1 rounded-lg border border-border bg-panel/90 p-1"
>
<ViewerDisplayPopover value={displayOptions} onChange={setDisplayOptions} />
<IconButton
tooltip="相机复位"
aria-label="相机复位"
onClick={() => viewer.current?.resetCamera()}
>
<RotateCcw className="h-3.5 w-3.5" />
</IconButton>
</div>
}
controls={
<div className="viewport-control-surface">
<SimulationControls
paused={state.paused}
ready={Boolean(state.snapshot)}
speed={state.speed}
loading={state.loading}
onTogglePause={togglePause}
onStep={singleStep}
onReset={reset}
onSpeed={changeSpeed}
/>
<ViewerToolDock mode={state.mode} onModeChange={mode} />
<MapViewportToolbar
visible={mapEditingActive && Boolean(state.snapshot)}
interactionActive={state.mode === 'select'}
mode={mapTransformMode}
snapping={mapSnapping}
placementMode={activePlacementMode}
hasSelectedObject={Boolean(selectedMapObject)}
allowScale={
mapSelection.kind === 'project' &&
selectedMapObject?.placementMode !== 'locked'
}
loading={state.loading}
onActivate={activateMapEditing}
onModeChange={changeMapTransformMode}
onSnappingChange={setMapSnapping}
onPlacementModeChange={changeMapPlacementMode}
onAlignToSurface={alignSelectedMapObject}
/>
</div>
}
draft={
<MapDraftStatusOverlay
visible={Boolean(state.snapshot)}
changeCount={sceneDraftChangeCount}
loading={mapCommitState === 'submitting'}
error={
mapCommitState === 'failed'
? (state.diagnostic?.summary ?? '请检查诊断后重试;草稿已保留')
: undefined
}
onCommit={() => void commitMapScene()}
onDiscard={discardMapSceneDraft}
/>
}
notices={
<div className="flex max-h-[32vh] flex-col gap-2 overflow-auto">
{state.diagnostic && (
<ErrorRecoveryPanel
key={state.diagnostic.at}
value={state.diagnostic}
onClose={() => state.setDiagnostic(undefined)}
onRetry={
state.diagnostic.category === '模型编译' && state.diagnostic.path
? () => void loadEntry(state.diagnostic!.path!)
: undefined
}
onOpenProject={() => {
setLeftOpen(true);
state.setDiagnostic(undefined);
}}
/>
)}
{!state.diagnostic && (
<ToastViewport item={toast} onDismiss={() => setToast(undefined)} />
)}
</div>
}
context={
trainingDeployment && (
<div className="rounded border border-warning-border bg-panel p-2 text-xs">
训练配套物理地图(编辑器地图未更改,重载模型恢复)。
{trainingDeployment.terrain?.approximation && '训练专用离散近似。'}
{(policyStatus?.observationSize === 81 ||
policyStatus?.observationSize === 97) && (
<label>
<input
type="checkbox"
checked={showPerceptionRays}
onChange={(e) => setShowPerceptionRays(e.target.checked)}
/>
显示避障射线
</label>
)}
</div>
)
}
camera={
Boolean(state.snapshot?.model.ncam) &&
(showSensorCamera ? (
<div
aria-label="摄像头画面"
ref={(node) => {
sensorCameraFrame.current = node;
viewer.current?.setSensorCameraViewportElement(node);
}}
className="sensor-camera-frame overflow-hidden rounded-lg border border-border-strong shadow-xl"
>
<div className="pointer-events-auto absolute inset-x-0 top-0 flex h-7 items-center justify-between bg-black/65 px-2 text-xs font-medium text-white">
<span className="flex items-center gap-1">
<Camera className="h-3 w-3" />
摄像头
</span>
<button
type="button"
className="rounded px-1.5 py-0.5 hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
onClick={() => setShowSensorCamera(false)}
>
隐藏画面
</button>
</div>
</div>
) : (
<Button
icon={<Camera className="h-3.5 w-3.5" />}
onClick={() => setShowSensorCamera(true)}
>
显示摄像头画面
</Button>
))
}
/>
<MapAssetDropIndicator target={mapAssetDropTarget} />
<WorkspaceOverlays
loading={state.loading}
hasSnapshot={Boolean(state.snapshot)}
dragActive={dragActive}
progress={importProgress}
/>
{state.entries.length > 1 && !state.selectedEntry && !pendingUrdfPath && (
<EntrySelectionDialog entries={state.entries} onSelect={requestLoadEntry} />
)}{' '}
</main>
<ModelControlsSidebar
visible={rightOpen}
snapshot={state.snapshot}
selection={editorSelection}
viewerSelection={state.selection}
selectedFormat={selectedFormat}
loading={state.loading}
urdfMode={urdfMode}
baseMode={baseMode}
showCollision={showCollision}
ignoreJointLimits={ignoreJointLimits}
jointAdvanced={jointAdvanced}
angleUnit={angleUnit}
mapSelection={mapSelection}
activeMapAssetId={activeMapAssetId}
activeMapAssetName={
placedMapAssets.find((asset) => 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);
}}
/>
</div>
</PanelWidthBudgetContext>
{pendingUrdfPath && (
<UrdfImportOptionsDialog
open
path={pendingUrdfPath}
mountBodies={pendingUrdfMounts}
onConfirm={confirmUrdfOptions}
onSkip={skipUrdfOptions}
/>
)}
{sourceOpen && generatedMjcf && generatedMjcfPath && (
<Suspense
fallback={
<div
role="status"
className="fixed inset-0 z-[390] grid place-items-center bg-app/60 text-sm text-text-secondary backdrop-blur-sm"
>
正在加载源码编辑器…
</div>
}
>
<SourceEditorDialog
open
code={generatedMjcf}
filePath={generatedMjcfPath}
theme={theme}
onClose={() => setSourceOpen(false)}
onSave={saveCachedSource}
/>
</Suspense>
)}
<ShortcutHelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
<DiagnosticsDrawer
open={diagnosticsOpen}
items={notifications}
onClose={() => setDiagnosticsOpen(false)}
onClear={() => setNotifications([])}
/>
<SettingsDialog
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
theme={theme}
angleUnit={angleUnit}
showCollision={showCollision}
jointAdvanced={jointAdvanced}
forceScale={forceScale}
onTheme={setTheme}
onAngleUnit={setAngleUnit}
onShowCollision={setShowCollision}
onJointAdvanced={setJointAdvanced}
onForceScale={setForceScale}
/>
<LayoutSettingsDialog
open={layoutOpen}
onClose={() => setLayoutOpen(false)}
leftOpen={leftOpen}
rightOpen={rightOpen}
onLeftOpen={setLeftOpen}
onRightOpen={setRightOpen}
onPreset={applyLayoutPreset}
onReset={() => applyLayoutPreset('default')}
/>
<CommandPalette
open={commandOpen}
onClose={() => setCommandOpen(false)}
commands={commands}
/>
<ConfirmDialog
open={removeConfirmOpen}
title="移除当前工程"
confirmLabel="移除工程"
danger
onConfirm={confirmRemoveProject}
onClose={() => setRemoveConfirmOpen(false)}
>
<p className="text-sm text-text-secondary">
确定从当前会话中移除“<strong className="text-text-primary">{state.projectName}</strong>
”吗?
</p>
<p className="mt-2 text-xs text-text-tertiary">该操作不会删除本地文件。</p>
</ConfirmDialog>
</div>
);
}