Files
Mujoco_WASM/web_platform/src/app/App.tsx
T
chenlin 3b4ced6437
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
feat(web-platform): release V0.7 地图模块
2026-09-01 14:23:49 +08:00

1858 lines
68 KiB
TypeScript

/* Zustand 的 action 引用稳定;初始化 viewer 与导入回调有意只创建一次。 */
/* eslint-disable react-hooks/exhaustive-deps */
import {
lazy,
Suspense,
useCallback,
useEffect,
useRef,
useState,
type ChangeEvent,
type DragEvent,
} from 'react';
import { useShallow } from 'zustand/react/shallow';
import {
Camera,
ChevronLeft,
ChevronRight,
CircleHelp,
Code2,
Crosshair,
Download,
Hand,
Maximize,
MousePointer2,
PanelsTopLeft,
Pause,
Play,
RotateCcw,
Settings as SettingsIcon,
SunMoon,
} from 'lucide-react';
import { DEFAULT_IMPORT_LIMITS, type MapEntry, type ProjectManifest } from '../project/types';
import {
filesFromDrop,
importBrowserFiles,
normalizeProjectPath,
ProjectImportError,
} from '../project/importer';
import {
MainThreadPhysicsAdapter,
type UrdfBaseMode,
type UrdfEnhancementOptions,
type UrdfLoadMode,
} from '../simulation/PhysicsAdapter';
import type { ActuatorParameters } from '../simulation/SimulationSession';
import type { ControllerCommand, ControllerStatus } from '../controller/types';
import type { RLCommand, RLPolicyStatus } from '../rl/types';
import type { MuJoCoViewer, InteractionMode, ViewerTheme } from '../viewer/MuJoCoViewer';
import {
DEFAULT_VIEWER_DISPLAY_OPTIONS,
type ViewerDisplayOptions,
} from '../viewer/displayOptions';
import { useAppStore, type AppDiagnostic } from '../stores/useAppStore';
import { WorkbenchHeader } from './components/WorkbenchHeader';
import { ViewerToolDock } from './components/ViewerToolDock';
import { ProjectSidebar, ModelControlsSidebar } from './components/SidebarPanel';
import { WorkspaceOverlays, type ImportProgress } from './components/WorkspaceOverlays';
import { EntrySelectionDialog } from './components/EntrySelectionDialog';
import { ErrorRecoveryPanel } from './components/ErrorRecoveryPanel';
import { StoreStatusBar } from './components/StatusBar';
import { ViewportHUD } from './components/ViewportHUD';
import { ShortcutHelpDialog } from './components/ShortcutHelpDialog';
import { CommandPalette, type WorkbenchCommand } from './components/CommandPalette';
import {
NotificationCenter,
ToastViewport,
type WorkbenchNotification,
} from './components/NotificationCenter';
import { SettingsDialog } from './components/SettingsDialog';
import {
dispatchLayoutWidths,
LayoutSettingsDialog,
type LayoutPreset,
} from './components/LayoutSettingsDialog';
import { Button, ConfirmDialog, IconButton } from '../components/ui';
import { DiagnosticsDrawer } from './components/DiagnosticsDrawer';
import { ToolbarOverflowMenu } from './components/ToolbarOverflowMenu';
import { UrdfImportOptionsDialog } from './components/UrdfImportOptionsDialog';
import {
downloadBytes,
exportedFileName,
mergeCachedFiles,
readCachedText,
upsertCachedMjcf,
} from '../project/cachedFiles';
import {
DEFAULT_MAP_SELECTION,
DEFAULT_PHYSICAL_MAP_CONFIG,
type MapSelection,
type SystemTerrainPreset,
} from '../map/types';
import { discoverMapEntries, resolveProjectMap, visualMapAsset } from '../map/MapLoader';
import { decodeMapDefinition } from '../map/mapSchema';
import { decodeEditableMapDocument, encodeEditableMapDocument } from '../map/editor/editorSchema';
import type {
EditableMapDocument,
EditableMapObjectType,
MapEditorInteractionCallbacks,
MapEditorTransformMode,
MapObjectPlacementMode,
} from '../map/editor/types';
import { isMapObjectPlacementMode } from '../map/editor/types';
import {
isEditableMapObjectType,
MAP_ASSET_DRAG_MIME,
MAP_ASSET_PLACEMENT_MIME,
} from '../map/editor/assetCatalog';
import { compileEditableMapDocument } from '../map/editor/MapDocumentCompiler';
import { importEditableMapDocument } from '../map/editor/MapDocumentImporter';
import { resolveProjectAssetPath } from '../map/mapPaths';
const SourceEditorDialog = lazy(() =>
import('./components/SourceEditorDialog').then((module) => ({
default: module.SourceEditorDialog,
})),
);
function diagnostic(
category: AppDiagnostic['category'],
error: unknown,
path?: string,
): AppDiagnostic {
const detail = error instanceof Error ? error.message : String(error);
return { category, summary: `${category}失败`, detail, path, at: Date.now() };
}
function initialTheme(): ViewerTheme {
try {
return localStorage.getItem('mujoco-platform-theme') === 'light' ? 'light' : 'dark';
} catch {
return 'dark';
}
}
function initialSidebarVisibility(): { left: boolean; right: boolean } {
const width = typeof window === 'undefined' ? 1280 : window.innerWidth;
if (width < 900) return { left: false, right: false };
try {
const stored = JSON.parse(localStorage.getItem('mujoco-platform-layout') ?? 'null') as {
left?: unknown;
right?: unknown;
} | null;
if (stored && typeof stored.left === 'boolean' && typeof stored.right === 'boolean')
return { left: stored.left, right: stored.right };
} catch {
/* 使用响应式默认布局 */
}
return width >= 1280 ? { left: true, right: true } : { left: false, right: true };
}
function initialDisplayOptions(): ViewerDisplayOptions {
try {
const stored = JSON.parse(
localStorage.getItem('mujoco-platform-display') ?? 'null',
) as Partial<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));
}
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),
importInFlight = useRef(false),
adapter = useRef(new MainThreadPhysicsAdapter()),
root = useRef<HTMLDivElement>(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),
pendingMapAsset = useRef<{
type: EditableMapObjectType;
position?: [number, number, number];
placementMode: MapObjectPlacementMode;
} | null>(null),
urdfEnhancementsRef = useRef<UrdfEnhancementOptions>({
addActuators: true,
addSensors: true,
sensorType: 'camera',
});
const [forceScale, setForceScale] = useState(50),
[leftOpen, setLeftOpen] = useState(() => initialSidebarVisibility().left),
[rightOpen, setRightOpen] = useState(() => initialSidebarVisibility().right),
[helpOpen, setHelpOpen] = useState(false),
[commandOpen, setCommandOpen] = useState(false),
[sourceOpen, setSourceOpen] = useState(false),
[generatedMjcf, setGeneratedMjcf] = useState<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>(),
[projectMaps, setProjectMaps] = useState<MapEntry[]>([]),
[editorDocument, setEditorDocument] = useState<EditableMapDocument | null>(null),
[projectSidebarTab, setProjectSidebarTab] = useState<'project' | 'structure' | 'assets'>(
'project',
);
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),
[showVisualMap, setShowVisualMap] = useState(true),
[showMapCollision, setShowMapCollision] = useState(false);
const [displayOptions, setDisplayOptions] = useState<ViewerDisplayOptions>(initialDisplayOptions),
[showSensorCamera, setShowSensorCamera] = useState(true),
[theme, setTheme] = useState<ViewerTheme>(initialTheme),
[jointAdvanced, setJointAdvanced] = useState(false),
[ignoreJointLimits, setIgnoreJointLimits] = useState(false),
[angleUnit, setAngleUnit] = useState<'rad' | 'deg'>('rad');
const showCollision = displayOptions.showCollision,
setShowCollision = (value: boolean) =>
setDisplayOptions((options) => ({ ...options, showCollision: value }));
const viewerSettings = useRef({
mode: state.mode,
forceScale,
displayOptions,
showVisualMap,
showMapCollision,
showSensorCamera,
theme,
});
useEffect(() => {
viewerSettings.current = {
mode: state.mode,
forceScale,
displayOptions,
showVisualMap,
showMapCollision,
showSensorCamera,
theme,
};
}, [
state.mode,
forceScale,
displayOptions,
showVisualMap,
showMapCollision,
showSensorCamera,
theme,
]);
useEffect(() => {
const host = viewerHost.current;
if (!host) return;
let active = true;
const ready = import('../viewer/MuJoCoViewer')
.then(({ MuJoCoViewer: Viewer }) => {
if (!active) return null;
const next = new Viewer(host, {
onSelection: state.setSelection,
onFrame: (frame, fps, snapshot) => {
const memory = (performance as Performance & { memory?: { usedJSHeapSize: number } })
.memory?.usedJSHeapSize;
state.setMetrics(
fps,
frame.stepMs,
memory === undefined ? undefined : memory / 1048576,
frame.overBudget,
);
if (snapshot) {
state.setSnapshot(snapshot);
setControllerStatus(snapshot.controller);
setPolicyStatus(snapshot.rlPolicy);
if (snapshot.controller?.error || snapshot.rlPolicy?.error) {
adapter.current.setPaused(true);
state.setPaused(true);
}
}
},
onError: (error) => {
console.error('[MuJoCo] 视口运行失败', error);
state.setDiagnostic(
diagnostic(error.message.includes('控制器') ? '仿真' : '渲染', error),
);
},
onMapEditorSelect: (id) => editorInteraction.current?.onSelect(id),
onMapEditorTransform: (id, position, quaternion, scale) =>
editorInteraction.current?.onTransform({ id, position, quaternion, scale }),
});
if (!active) {
next.dispose();
return null;
}
viewer.current = next;
const settings = viewerSettings.current;
next.setMode(settings.mode);
next.forceScale = settings.forceScale;
next.setDisplayOptions(settings.displayOptions);
next.setMapDisplay(settings.showVisualMap, settings.showMapCollision);
next.setShowSensorCamera(settings.showSensorCamera);
next.setTheme(settings.theme);
return next;
})
.catch((error) => {
if (active) {
console.error('[MuJoCo] 三维视口初始化失败', error);
state.setDiagnostic(diagnostic('渲染', error));
}
return null;
});
viewerReady.current = ready;
return () => {
active = false;
if (viewerReady.current === ready) viewerReady.current = null;
viewer.current?.dispose();
viewer.current = null;
const retiredAdapter = adapter.current;
retiredAdapter.dispose();
if (adapter.current === retiredAdapter) adapter.current = new MainThreadPhysicsAdapter();
};
}, []);
useEffect(() => {
viewer.current?.setMode(state.mode);
}, [state.mode]);
useEffect(() => {
if (viewer.current) viewer.current.forceScale = forceScale;
}, [forceScale]);
useEffect(() => {
viewer.current?.setDisplayOptions(displayOptions);
try {
localStorage.setItem('mujoco-platform-display', JSON.stringify(displayOptions));
} catch {
/* 当前会话仍可修改 */
}
}, [displayOptions]);
useEffect(() => {
viewer.current?.setMapDisplay(showVisualMap, showMapCollision);
}, [showVisualMap, showMapCollision]);
useEffect(() => {
if (window.innerWidth < 900) return;
try {
localStorage.setItem(
'mujoco-platform-layout',
JSON.stringify({ left: leftOpen, right: rightOpen }),
);
} catch {
/* 当前会话仍可修改 */
}
}, [leftOpen, rightOpen]);
useEffect(() => {
viewer.current?.setShowSensorCamera(showSensorCamera);
}, [showSensorCamera]);
useEffect(() => {
viewer.current?.setTheme(theme);
document.documentElement.style.colorScheme = theme;
try {
localStorage.setItem('mujoco-platform-theme', theme);
} catch {
/* 当前会话仍可切换 */
}
}, [theme]);
useEffect(() => {
const change = () => setFullscreen(document.fullscreenElement === root.current);
document.addEventListener('fullscreenchange', change);
return () => document.removeEventListener('fullscreenchange', change);
}, []);
const loadEntry = useCallback(async (path: string, requestedMode?: UrdfLoadMode) => {
if (!manifest.current || loadInFlight.current) return false;
const previousEntry = useAppStore.getState().selectedEntry;
loadInFlight.current = true;
setIgnoreJointLimits(false);
setControllerStatus(undefined);
setPolicyStatus(undefined);
state.setEntry(path);
state.setLoading(true);
setImportProgress({
title: '正在准备仿真',
label: '初始化三维视口',
detail: path,
value: 0.4,
});
state.setDiagnostic(undefined);
setGeneratedMjcf(undefined);
setGeneratedMjcfPath(undefined);
adapter.current.setPaused(true);
try {
const activeViewer = viewer.current ?? (await viewerReady.current);
if (!activeViewer) throw new Error('三维视口尚未就绪,请重试');
const snapshot = await adapter.current.load(manifest.current, path, {
urdfMode: requestedMode ?? urdfModeRef.current,
baseMode: baseModeRef.current,
enhancements: urdfEnhancementsRef.current,
map: mapSelectionRef.current,
onProgress: ({ value, label }) =>
setImportProgress({
title: '正在准备仿真',
label,
detail: path,
value: 0.4 + value * 0.53,
}),
});
const supportFiles = adapter.current.cachedSupportFiles();
setImportProgress({
title: '正在准备仿真',
label: '创建三维场景',
detail: path,
value: 0.94,
});
adapter.current.setSpeed(useAppStore.getState().speed);
try {
activeViewer.attach(adapter.current.session);
} catch (error) {
adapter.current.rollbackRetired();
activeViewer.attach(adapter.current.session);
throw error;
}
adapter.current.releaseRetired();
if (supportFiles.length && manifest.current) {
manifest.current = mergeCachedFiles(manifest.current, supportFiles);
state.setProject(
manifest.current.name,
manifest.current.files.map((file) => ({ path: file.path, size: file.size })),
manifest.current.entries,
path,
);
}
state.setSnapshot(snapshot);
state.setSelection(null);
state.setPaused(true);
setImportProgress({
title: '正在准备仿真',
label: '加载视觉地图与材质',
detail: path,
value: 0.97,
});
await activeViewer.setVisualMap(null);
let visualMapWarning: string | undefined;
try {
const asset = manifest.current
? visualMapAsset(manifest.current, mapSelectionRef.current)
: null;
await activeViewer.setVisualMap(asset);
} catch (error) {
visualMapWarning = `视觉地图加载失败:${error instanceof Error ? error.message : String(error)}`;
console.warn('[MuJoCo] 视觉地图加载失败', error);
}
try {
setGeneratedMjcf(new TextDecoder().decode(adapter.current.exportMjcf()));
setGeneratedMjcfPath(convertedCachePath(path));
} catch (error) {
console.warn('[MuJoCo] 无法生成源码预览', error);
}
const notice: WorkbenchNotification = {
id: ++notificationId.current,
title:
snapshot.warnings.length || visualMapWarning
? `模型已加载 · ${snapshot.warnings.length + (visualMapWarning ? 1 : 0)} 项兼容调整`
: '模型加载完成',
detail:
[...snapshot.warnings, ...(visualMapWarning ? [visualMapWarning] : [])].join('\n') ||
path,
tone: snapshot.warnings.length || visualMapWarning ? 'warning' : 'success',
at: Date.now(),
};
setNotifications((items) => [notice, ...items].slice(0, 20));
setToast(notice);
return true;
} catch (error) {
state.setDiagnostic(diagnostic('模型编译', error, path));
const notice: WorkbenchNotification = {
id: ++notificationId.current,
title: '模型编译失败',
detail: error instanceof Error ? error.message : String(error),
tone: 'danger',
at: Date.now(),
};
setNotifications((items) => [notice, ...items].slice(0, 20));
setToast(notice);
const retained = adapter.current.snapshot();
if (retained && previousEntry) state.setEntry(previousEntry);
setControllerStatus(retained?.controller);
setPolicyStatus(retained?.rlPolicy);
return false;
} finally {
loadInFlight.current = false;
setImportProgress(undefined);
state.setLoading(false);
}
}, []);
const requestLoadEntry = useCallback(
async (path: string) => {
const entry = manifest.current?.entries.find((candidate) => candidate.path === path);
if (entry?.format === 'urdf' && urdfModeRef.current === 'mjcf') {
setPendingUrdfMounts(urdfLinkNames(manifest.current, path));
setPendingUrdfPath(path);
return;
}
await loadEntry(path);
},
[loadEntry],
);
const confirmUrdfOptions = (options: UrdfEnhancementOptions) => {
const path = pendingUrdfPath;
if (!path) return;
urdfEnhancementsRef.current = options;
setPendingUrdfPath(undefined);
setPendingUrdfMounts([]);
void loadEntry(path);
};
const skipUrdfOptions = () =>
confirmUrdfOptions({ addActuators: false, addSensors: false, sensorType: 'camera' });
const ingest = useCallback(
async (files: File[], lockOwned = false) => {
if (importInFlight.current && !lockOwned) return;
importInFlight.current = true;
state.setLoading(true);
setImportProgress({
title: '正在导入工程',
label: '检查文件清单',
detail: files.length === 1 ? files[0].name : `${files.length} 个文件`,
value: 0.04,
});
try {
const next = await importBrowserFiles(
files,
DEFAULT_IMPORT_LIMITS,
({ phase, completed, total, path }) => {
const ratio = total ? completed / total : 0;
const label =
phase === 'reading'
? '读取工程文件'
: phase === 'extracting'
? '在后台解压工程包'
: '索引模型与地图入口';
const value =
phase === 'reading'
? 0.06 + ratio * 0.2
: phase === 'extracting'
? 0.28 + ratio * 0.07
: 0.37;
setImportProgress({ title: '正在导入工程', label, detail: path, value });
},
);
setImportProgress({
title: '正在导入工程',
label: '处理模型资源与入口',
detail: `${next.files.length} 个文件`,
value: 0.39,
});
manifest.current = next;
setProjectMaps(next.maps);
setProjectSidebarTab('project');
setEditorDocument(null);
viewer.current?.setMapEditorDocument(null);
mapSelectionRef.current = DEFAULT_MAP_SELECTION;
setMapSelection(DEFAULT_MAP_SELECTION);
setSelectedControllerPath(next.files.find((file) => /\.py$/i.test(file.path))?.path);
setSelectedPolicyPath(next.files.find((file) => /\.onnx$/i.test(file.path))?.path);
state.setProject(
next.name,
next.files.map(({ path, size }) => ({ path, size })),
next.entries,
next.selectedEntry,
);
if (next.selectedEntry) await requestLoadEntry(next.selectedEntry);
} catch (error) {
state.setDiagnostic(
diagnostic(
error instanceof ProjectImportError && /ZIP/.test(error.message) ? 'ZIP' : '导入',
error,
error instanceof ProjectImportError ? error.path : undefined,
),
);
const notice: WorkbenchNotification = {
id: ++notificationId.current,
title: '工程导入失败',
detail: error instanceof Error ? error.message : String(error),
tone: 'danger',
at: Date.now(),
};
setNotifications((items) => [notice, ...items].slice(0, 20));
setToast(notice);
} finally {
importInFlight.current = false;
setImportProgress(undefined);
state.setLoading(false);
}
},
[requestLoadEntry],
);
const removeProject = () => {
if (state.projectName) setRemoveConfirmOpen(true);
};
const confirmRemoveProject = () => {
void viewer.current?.setVisualMap(null);
viewer.current?.attach(null);
adapter.current.dispose();
adapter.current = new MainThreadPhysicsAdapter();
manifest.current = null;
setGeneratedMjcf(undefined);
setGeneratedMjcfPath(undefined);
setPendingUrdfPath(undefined);
setPendingUrdfMounts([]);
setSelectedControllerPath(undefined);
setControllerStatus(undefined);
setSelectedPolicyPath(undefined);
setPolicyStatus(undefined);
setProjectMaps([]);
setProjectSidebarTab('project');
setEditorDocument(null);
viewer.current?.setMapEditorDocument(null);
mapSelectionRef.current = DEFAULT_MAP_SELECTION;
setMapSelection(DEFAULT_MAP_SELECTION);
state.clearProject();
setRemoveConfirmOpen(false);
};
const changeUrdfMode = (value: UrdfLoadMode) => {
setUrdfMode(value);
urdfModeRef.current = value;
const entry = state.entries.find((candidate) => candidate.path === state.selectedEntry);
if (entry?.format !== 'urdf') return;
if (value === 'mjcf') {
setPendingUrdfMounts(urdfLinkNames(manifest.current, entry.path));
setPendingUrdfPath(entry.path);
} else void loadEntry(entry.path, value);
};
const changeBaseMode = (value: UrdfBaseMode) => {
setBaseMode(value);
baseModeRef.current = value;
const entry = state.entries.find((candidate) => candidate.path === state.selectedEntry);
if (entry?.format === 'urdf' && urdfModeRef.current === 'mjcf')
void loadEntry(entry.path, 'mjcf');
};
const previewEditorDocument = useCallback((document: EditableMapDocument | null) => {
viewer.current?.setMapEditorDocument(document);
if (document) {
adapter.current.setPaused(true);
state.setPaused(true);
}
}, []);
const bindEditorInteraction = useCallback((callbacks: MapEditorInteractionCallbacks | null) => {
editorInteraction.current = callbacks;
const pending = pendingMapAsset.current;
if (callbacks && pending) {
pendingMapAsset.current = null;
callbacks.onAddAsset(pending.type, pending.position, pending.placementMode);
}
}, []);
const selectEditorObject = useCallback((id: string | null) => {
viewer.current?.selectMapEditorObject(id);
}, []);
const setEditorTransformMode = useCallback((mode: MapEditorTransformMode) => {
viewer.current?.setMapEditorTransformMode(mode);
}, []);
const setEditorSnapping = useCallback(
(translation: number | null, rotationDegrees: number | null) => {
viewer.current?.setMapEditorSnapping(translation, rotationDegrees);
},
[],
);
const readEditorDocument = (selection: MapSelection): EditableMapDocument | null => {
if (selection.kind !== 'project' || !manifest.current) return null;
const resolved = resolveProjectMap(manifest.current, selection.descriptorPath);
if (!resolved.authoringPath) return null;
const file = manifest.current.files.find(
(candidate) => candidate.path === resolved.authoringPath,
);
return file ? decodeEditableMapDocument(file.data) : null;
};
const createEditableScene = async (
type: EditableMapObjectType,
position?: [number, number, number],
placementMode: MapObjectPlacementMode = 'auto_ground',
): Promise<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: MapSelection = { kind: 'project', descriptorPath };
pendingMapAsset.current = { type, position, placementMode };
manifest.current = candidate;
mapSelectionRef.current = selection;
setProjectMaps(maps);
setEditorDocument(document);
setMapSelection(selection);
previewEditorDocument(document);
state.setProject(
candidate.name,
candidate.files.map((file) => ({ path: file.path, size: file.size })),
candidate.entries,
entryPath,
);
state.setSnapshot(adapter.current.snapshot() ?? undefined);
return true;
} catch (error) {
pendingMapAsset.current = null;
state.setDiagnostic(diagnostic('文件系统', error, descriptorPath));
return false;
}
};
const addCertifiedMapAsset = async (
type: EditableMapObjectType,
position?: [number, number, number],
placementMode: MapObjectPlacementMode = 'auto_ground',
) => {
if (state.loading || loadInFlight.current) return;
const interaction = editorInteraction.current;
if (interaction) {
interaction.onAddAsset(type, position, placementMode);
return;
}
await createEditableScene(type, position, placementMode);
};
const applyMapSelection = (value: MapSelection) => {
const previous = mapSelectionRef.current;
const entry = state.entries.find((candidate) => candidate.path === state.selectedEntry);
if (!entry) return;
if (entry.format === 'urdf' && urdfModeRef.current === 'native' && value.kind !== 'none') {
state.setDiagnostic(
diagnostic('模型编译', new Error('原生 URDF 不能注入地图,请切换为转换模式'), entry.path),
);
return;
}
mapSelectionRef.current = value;
setMapSelection(value);
void loadEntry(entry.path).then((loaded) => {
if (loaded) {
setEditorDocument(readEditorDocument(value));
viewer.current?.setMapEditorDocument(null);
return;
}
mapSelectionRef.current = previous;
setMapSelection(previous);
});
};
const selectTerrainAsset = (preset: SystemTerrainPreset) => {
const current = mapSelectionRef.current;
applyMapSelection({
kind: 'builtin',
config: {
...(current.kind === 'builtin' ? current.config : DEFAULT_PHYSICAL_MAP_CONFIG),
preset,
},
});
};
const applyEditorDocument = async (document: EditableMapDocument): Promise<boolean> => {
const selection = mapSelectionRef.current;
const current = manifest.current;
const entryPath = state.selectedEntry;
if (selection.kind !== 'project' || !current || !entryPath) return false;
const resolved = resolveProjectMap(current, selection.descriptorPath);
if (!resolved.authoringPath || !resolved.physicsPath) {
state.setDiagnostic(
diagnostic(
'模型编译',
new Error('可编辑地图必须同时声明 authoring.source 和 physics.source'),
),
);
return false;
}
try {
const authoringData = encodeEditableMapDocument(document);
const physicsData = compileEditableMapDocument(document);
const definition = decodeMapDefinition(
current.files.find((file) => file.path === resolved.descriptorPath)!.data,
);
definition.spawnPoints = document.spawnPoints;
const descriptorData = new TextEncoder().encode(`${JSON.stringify(definition, null, 2)}\n`);
const replacements = new Map<string, Uint8Array>([
[resolved.authoringPath, authoringData],
[resolved.physicsPath, physicsData],
[resolved.descriptorPath, descriptorData],
]);
const files = current.files.map((file) => {
const data = replacements.get(file.path);
return data ? { ...file, data, size: data.byteLength } : file;
});
const candidate: ProjectManifest = {
...current,
files,
maps: discoverMapEntries(files),
totalBytes: files.reduce((total, file) => total + file.size, 0),
};
manifest.current = candidate;
const loaded = await loadEntry(entryPath);
if (!loaded) {
manifest.current = current;
return false;
}
const loadedManifest = manifest.current ?? candidate;
const maps = discoverMapEntries(loadedManifest.files);
const committed = { ...loadedManifest, maps };
manifest.current = committed;
setProjectMaps(maps);
setEditorDocument(document);
viewer.current?.setMapEditorDocument(null);
state.setProject(
committed.name,
committed.files.map((file) => ({ path: file.path, size: file.size })),
committed.entries,
entryPath,
);
state.setSnapshot(adapter.current.snapshot() ?? undefined);
return true;
} catch (error) {
manifest.current = current;
state.setDiagnostic(diagnostic('模型编译', error, resolved.authoringPath));
return false;
}
};
const convertSelectedMap = async (): Promise<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);
setEditorDocument(document);
state.setProject(
committed.name,
committed.files.map((file) => ({ path: file.path, size: file.size })),
committed.entries,
entryPath,
);
state.setSnapshot(adapter.current.snapshot() ?? undefined);
notify('已创建可编辑地图副本', authoringPath);
return true;
} catch (error) {
manifest.current = current;
state.setDiagnostic(diagnostic('模型编译', error, diagnosticPath));
return false;
}
};
const exportSelectedMap = async () => {
const selection = mapSelectionRef.current;
if (selection.kind !== 'project' || !manifest.current) return;
try {
const { exportMapPackage } = await import('../map/editor/MapPackageExporter');
const entry = projectMaps.find((map) => map.descriptorPath === selection.descriptorPath);
downloadBytes(
exportMapPackage(manifest.current, selection.descriptorPath),
`${entry?.id ?? 'map'}-map.zip`,
'application/zip',
);
} catch (error) {
state.setDiagnostic(diagnostic('文件系统', error, selection.descriptorPath));
}
};
const changeFiles = (event: ChangeEvent<HTMLInputElement>) => {
void ingest(Array.from(event.target.files ?? []));
event.target.value = '';
};
const resetDragState = () => {
dragDepth.current = 0;
setDragActive(false);
};
const dragEnter = (event: DragEvent) => {
if (
event.dataTransfer.types.includes('Files') &&
!event.dataTransfer.types.includes(MAP_ASSET_DRAG_MIME)
) {
dragDepth.current += 1;
if (!state.loading) setDragActive(true);
}
};
const dragLeave = (event: DragEvent) => {
if (!event.dataTransfer.types.includes('Files')) return;
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setDragActive(false);
};
const dragOver = (event: DragEvent) => {
event.preventDefault();
if (event.dataTransfer.types.includes(MAP_ASSET_DRAG_MIME)) {
event.dataTransfer.dropEffect = viewer.current?.mapPlanePoint(event.clientX, event.clientY)
? 'copy'
: 'none';
return;
}
if (event.dataTransfer.types.includes('Files'))
event.dataTransfer.dropEffect = state.loading ? 'none' : 'copy';
};
const drop = (event: DragEvent) => {
event.preventDefault();
resetDragState();
const assetType = event.dataTransfer.getData(MAP_ASSET_DRAG_MIME),
requestedPlacement = event.dataTransfer.getData(MAP_ASSET_PLACEMENT_MIME),
placementMode = isMapObjectPlacementMode(requestedPlacement)
? requestedPlacement
: 'auto_ground';
if (isEditableMapObjectType(assetType)) {
event.stopPropagation();
const position = viewer.current?.mapPlanePoint(event.clientX, event.clientY);
if (position) void addCertifiedMapAsset(assetType, position, placementMode);
return;
}
if (state.loading || importInFlight.current) return;
importInFlight.current = true;
state.setLoading(true);
setImportProgress({
title: '正在导入工程',
label: '扫描拖放的文件与文件夹',
value: 0.02,
});
void (async () => {
try {
const files = await filesFromDrop(event.dataTransfer.items, event.dataTransfer.files);
await ingest(files, true);
} catch (error) {
importInFlight.current = false;
setImportProgress(undefined);
state.setLoading(false);
state.setDiagnostic(diagnostic('导入', error));
}
})();
};
const togglePause = () => {
const value = !state.paused;
state.setPaused(value);
adapter.current.setPaused(value);
};
const reset = () => {
adapter.current.setPaused(true);
adapter.current.reset();
state.setSnapshot(adapter.current.snapshot() ?? undefined);
state.setPaused(true);
};
const singleStep = () => {
adapter.current.singleStep();
state.setSnapshot(adapter.current.snapshot() ?? undefined);
};
const changeSpeed = (value: number) => {
state.setSpeed(value);
adapter.current.setSpeed(value);
};
const mode = (value: InteractionMode) => state.setMode(value);
const resetJoints = () => {
adapter.current.resetJoints();
state.setPaused(true);
state.setSnapshot(adapter.current.snapshot() ?? undefined);
};
const toggleJointLimits = () => {
const next = !ignoreJointLimits;
adapter.current.setIgnoreJointLimits(next);
setIgnoreJointLimits(next);
state.setSnapshot(adapter.current.snapshot() ?? undefined);
};
const setActuator = (id: number, value: number) => {
adapter.current.setActuator(id, value);
state.setSnapshot(adapter.current.snapshot() ?? undefined);
};
const setActuatorParameters = (id: number, parameters: ActuatorParameters) => {
if (!adapter.current.setActuatorParameters(id, parameters)) return;
state.setSnapshot(adapter.current.snapshot() ?? undefined);
try {
setGeneratedMjcf(new TextDecoder().decode(adapter.current.exportMjcf()));
} catch (error) {
console.warn('[MuJoCo] 无法刷新驱动器参数源码', error);
}
};
const setJoint = (id: number, value: number) => {
adapter.current.setJointPosition(id, value);
state.setPaused(true);
state.setSnapshot(adapter.current.snapshot() ?? undefined);
};
const loadControllerSource = async (source: string, path: string) => {
state.setLoading(true);
setImportProgress({
title: '正在加载控制器',
label: '初始化 Python 运行时',
detail: path,
value: 0.5,
});
state.setDiagnostic(undefined);
try {
const status = await adapter.current.loadPythonController(source, path);
setControllerStatus(status);
state.setSnapshot(adapter.current.snapshot() ?? undefined);
notify('Python 控制器已加载', `${status.name} · ${status.controlHz} Hz`);
} catch (error) {
state.setDiagnostic(diagnostic('仿真', error, path));
} finally {
setImportProgress(undefined);
state.setLoading(false);
}
};
const loadControllerPath = (path: string) => {
const file = manifest.current?.files.find((candidate) => candidate.path === path);
if (!file) {
state.setDiagnostic(diagnostic('仿真', new Error('工程中找不到控制脚本'), path));
return;
}
setSelectedControllerPath(path);
void loadControllerSource(new TextDecoder().decode(file.data), path);
};
const importController = (file: File) => {
void (async () => {
try {
if (!/\.py$/i.test(file.name)) throw new Error('请选择 .py 文件');
if (file.size > 1024 * 1024) throw new Error('Python 控制脚本不能超过 1 MiB');
const path = normalizeProjectPath(file.name),
data = new Uint8Array(await file.arrayBuffer());
if (manifest.current) {
const index = manifest.current.files.findIndex((candidate) => candidate.path === path),
files = manifest.current.files.slice(),
entry = {
path,
data,
size: data.byteLength,
source: 'file' as const,
mimeType: file.type || 'text/x-python',
};
if (index >= 0) files[index] = entry;
else files.push(entry);
manifest.current = {
...manifest.current,
files,
totalBytes: files.reduce((total, item) => total + item.size, 0),
};
state.setProject(
manifest.current.name,
files.map(({ path: filePath, size }) => ({ path: filePath, size })),
manifest.current.entries,
manifest.current.selectedEntry,
);
state.setSnapshot(adapter.current.snapshot() ?? undefined);
}
setSelectedControllerPath(path);
await loadControllerSource(new TextDecoder().decode(data), path);
} catch (error) {
state.setDiagnostic(diagnostic('仿真', error, file.name));
}
})();
};
const toggleController = (enabled: boolean) => {
adapter.current.setControllerEnabled(enabled);
const snapshot = adapter.current.snapshot() ?? undefined;
setControllerStatus(snapshot?.controller);
setPolicyStatus(snapshot?.rlPolicy);
state.setSnapshot(snapshot);
};
const sendControllerCommand = (command: ControllerCommand) => {
try {
adapter.current.sendControllerCommand(command);
const snapshot = adapter.current.snapshot() ?? undefined;
setControllerStatus(snapshot?.controller);
state.setSnapshot(snapshot);
} catch (error) {
state.setDiagnostic(diagnostic('仿真', error, selectedControllerPath));
}
};
const removeController = () => {
adapter.current.removeController();
setControllerStatus(undefined);
state.setSnapshot(adapter.current.snapshot() ?? undefined);
};
const loadPolicyBytes = async (data: Uint8Array, path: string) => {
state.setLoading(true);
setImportProgress({
title: '正在加载强化学习策略',
label: '初始化 ONNX Runtime',
detail: path,
value: 0.55,
});
state.setDiagnostic(undefined);
try {
const status = await adapter.current.loadRLPolicy(data, path);
setPolicyStatus(status);
state.setSnapshot(adapter.current.snapshot() ?? undefined);
notify(
'ONNX 策略已加载',
`${status.taskName} · ${status.observationSize}${status.actionSize}`,
);
} catch (error) {
state.setDiagnostic(diagnostic('仿真', error, path));
} finally {
setImportProgress(undefined);
state.setLoading(false);
}
};
const loadPolicyPath = (path: string) => {
const file = manifest.current?.files.find((candidate) => candidate.path === path);
if (!file) {
state.setDiagnostic(diagnostic('仿真', new Error('工程中找不到 ONNX 策略'), path));
return;
}
setSelectedPolicyPath(path);
void loadPolicyBytes(file.data, path);
};
const importPolicy = (file: File) => {
void (async () => {
try {
if (!/\.onnx$/i.test(file.name)) throw new Error('请选择 .onnx 文件');
if (file.size > 64 * 1024 * 1024) throw new Error('ONNX 策略不能超过 64 MiB');
const path = normalizeProjectPath(file.name),
data = new Uint8Array(await file.arrayBuffer());
if (manifest.current) {
const index = manifest.current.files.findIndex((candidate) => candidate.path === path),
files = manifest.current.files.slice(),
entry = {
path,
data,
size: data.byteLength,
source: 'file' as const,
mimeType: file.type || 'application/octet-stream',
};
if (index >= 0) files[index] = entry;
else files.push(entry);
const totalBytes = files.reduce((total, item) => total + item.size, 0);
if (totalBytes > DEFAULT_IMPORT_LIMITS.maxTotalBytes)
throw new Error('加入 ONNX 后工程总大小超过 512 MiB');
manifest.current = { ...manifest.current, files, totalBytes };
state.setProject(
manifest.current.name,
files.map(({ path: filePath, size }) => ({ path: filePath, size })),
manifest.current.entries,
manifest.current.selectedEntry,
);
state.setSnapshot(adapter.current.snapshot() ?? undefined);
}
setSelectedPolicyPath(path);
await loadPolicyBytes(data, path);
} catch (error) {
state.setDiagnostic(diagnostic('仿真', error, file.name));
}
})();
};
const togglePolicy = (enabled: boolean) => {
adapter.current.setRLPolicyEnabled(enabled);
const snapshot = adapter.current.snapshot() ?? undefined;
setPolicyStatus(snapshot?.rlPolicy);
setControllerStatus(snapshot?.controller);
state.setSnapshot(snapshot);
};
const setPolicyCommand = (command: RLCommand) => {
adapter.current.setRLCommand(command);
const snapshot = adapter.current.snapshot() ?? undefined;
setPolicyStatus(snapshot?.rlPolicy);
state.setSnapshot(snapshot);
};
const removePolicy = () => {
adapter.current.removeRLPolicy();
setPolicyStatus(undefined);
state.setSnapshot(adapter.current.snapshot() ?? undefined);
};
const notify = (
title: string,
detail: string,
tone: WorkbenchNotification['tone'] = 'success',
) => {
const notice: WorkbenchNotification = {
id: ++notificationId.current,
title,
detail,
tone,
at: Date.now(),
};
setNotifications((items) => [notice, ...items].slice(0, 20));
setToast(notice);
};
const saveCachedSource = async (path: string, text: string) => {
if (!manifest.current) return;
manifest.current = upsertCachedMjcf(manifest.current, path, text);
state.setProject(
manifest.current.name,
manifest.current.files.map((file) => ({ path: file.path, size: file.size })),
manifest.current.entries,
path,
);
notify('转换后的 MJCF 已保存到缓存', path);
await loadEntry(path);
};
const exportUrdf = () => {
if (!manifest.current || selectedFormat !== 'urdf' || !state.selectedEntry) return;
const text = readCachedText(manifest.current, state.selectedEntry);
downloadBytes(new TextEncoder().encode(text), exportedFileName(manifest.current.name, 'urdf'));
notify('URDF 已导出', state.selectedEntry);
};
const exportMjcf = () => {
try {
const data = adapter.current.exportMjcf();
downloadBytes(data, exportedFileName(manifest.current?.name ?? 'model', 'xml'));
notify('MJCF 已导出', '导出内容来自当前已编译模型');
} catch (error) {
state.setDiagnostic(diagnostic('模型编译', error, state.selectedEntry));
}
};
const toggleFullscreen = () => {
if (document.fullscreenElement) void document.exitFullscreen().catch(() => {});
else if (root.current) void root.current.requestFullscreen().catch(() => {});
};
const applyLayoutPreset = (preset: LayoutPreset) => {
if (preset === 'viewport') {
setLeftOpen(false);
setRightOpen(false);
dispatchLayoutWidths(288, 288);
} else if (preset === 'project') {
setLeftOpen(true);
setRightOpen(false);
dispatchLayoutWidths(384, 288);
} else if (preset === 'control') {
setLeftOpen(false);
setRightOpen(true);
dispatchLayoutWidths(288, 384);
} else {
setLeftOpen(true);
setRightOpen(true);
dispatchLayoutWidths(288, 288);
}
};
useEffect(() => {
const key = (event: KeyboardEvent) => {
if (
document.activeElement instanceof HTMLElement &&
document.activeElement.closest('[role="dialog"]')
)
return;
if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === 'k') {
event.preventDefault();
setCommandOpen(true);
return;
}
if ((event.target as HTMLElement).matches('input,select,button')) return;
if (event.code === 'Space') {
event.preventDefault();
togglePause();
}
if (event.key === 'r') reset();
if (event.key === '1') mode('select');
if (event.key === '2') mode('joint');
if (event.key === '3') mode('force');
};
window.addEventListener('keydown', key);
return () => window.removeEventListener('keydown', key);
});
const selectedFormat = state.entries.find((entry) => entry.path === state.selectedEntry)?.format;
const commands: WorkbenchCommand[] = [
{
id: 'play',
label: state.paused ? '播放仿真' : '暂停仿真',
group: '仿真',
icon: state.paused ? <Play className="h-4 w-4" /> : <Pause className="h-4 w-4" />,
shortcut: 'Space',
disabled: !state.snapshot,
run: togglePause,
},
{
id: 'reset',
label: '重置仿真',
group: '仿真',
icon: <RotateCcw className="h-4 w-4" />,
shortcut: 'R',
disabled: !state.snapshot,
run: reset,
},
{
id: 'select',
label: '切换到选择模式',
group: '视口',
icon: <MousePointer2 className="h-4 w-4" />,
shortcut: '1',
run: () => mode('select'),
},
{
id: 'joint',
label: '切换到关节拖动',
group: '视口',
icon: <Hand className="h-4 w-4" />,
shortcut: '2',
run: () => mode('joint'),
},
{
id: 'force',
label: '切换到外力施加',
group: '视口',
icon: <Crosshair className="h-4 w-4" />,
shortcut: '3',
run: () => mode('force'),
},
{
id: 'camera',
label: '复位相机',
group: '视口',
icon: <Camera className="h-4 w-4" />,
run: () => viewer.current?.resetCamera(),
},
{
id: 'source',
label: '查看和修改缓存源代码',
group: '工程',
icon: <Code2 className="h-4 w-4" />,
disabled: !generatedMjcf,
run: () => setSourceOpen(true),
},
{
id: 'export-urdf',
label: '导出 URDF 文件',
group: '工程',
icon: <Download className="h-4 w-4" />,
disabled: selectedFormat !== 'urdf',
run: exportUrdf,
},
{
id: 'export-mjcf',
label: '导出 MJCF 文件',
group: '工程',
icon: <Download className="h-4 w-4" />,
disabled: !state.snapshot,
run: exportMjcf,
},
{
id: 'left',
label: leftOpen ? '隐藏工程面板' : '显示工程面板',
group: '布局',
icon: leftOpen ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />,
run: () => setLeftOpen((value) => !value),
},
{
id: 'right',
label: rightOpen ? '隐藏属性面板' : '显示属性面板',
group: '布局',
icon: rightOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />,
run: () => setRightOpen((value) => !value),
},
{
id: 'theme',
label: theme === 'dark' ? '切换到白天主题' : '切换到黑夜主题',
group: '外观',
icon: <SunMoon className="h-4 w-4" />,
run: () => setTheme((value) => (value === 'dark' ? 'light' : 'dark')),
},
{
id: 'fullscreen',
label: fullscreen ? '退出全屏' : '进入全屏',
group: '布局',
icon: <Maximize className="h-4 w-4" />,
run: toggleFullscreen,
},
{
id: 'help',
label: '查看快捷键帮助',
group: '帮助',
icon: <CircleHelp className="h-4 w-4" />,
run: () => setHelpOpen(true),
},
];
return (
<div
ref={root}
className={`${theme === 'light' ? 'theme-light' : 'theme-dark'} flex h-screen min-w-0 flex-col overflow-hidden bg-app text-text-primary`}
onDragEnter={dragEnter}
onDragLeave={dragLeave}
onDragOver={dragOver}
onDragEnd={resetDragState}
onDrop={drop}
>
<WorkbenchHeader
paused={state.paused}
ready={Boolean(state.snapshot)}
speed={state.speed}
theme={theme}
loading={state.loading}
leftOpen={leftOpen}
rightOpen={rightOpen}
fullscreen={fullscreen}
hasProject={Boolean(generatedMjcf)}
onFiles={changeFiles}
onFolder={changeFiles}
onOpenSource={() => setSourceOpen(true)}
onTogglePause={togglePause}
onStep={singleStep}
onReset={reset}
onSpeed={changeSpeed}
onToggleLeft={() => setLeftOpen((value) => !value)}
onToggleRight={() => setRightOpen((value) => !value)}
onToggleTheme={() => setTheme((value) => (value === 'dark' ? 'light' : 'dark'))}
onHelp={() => setHelpOpen(true)}
endActions={
<>
<NotificationCenter
items={notifications}
onDismiss={(id) =>
setNotifications((items) => items.filter((item) => item.id !== id))
}
onClear={() => setNotifications([])}
onOpenLog={() => setDiagnosticsOpen(true)}
/>
<span className="hidden items-center gap-0.5 xl:flex">
<IconButton
tooltip="布局设置"
aria-label="布局设置"
onClick={() => setLayoutOpen(true)}
>
<PanelsTopLeft className="h-4 w-4" />
</IconButton>
<IconButton
tooltip="工作台设置"
aria-label="工作台设置"
onClick={() => setSettingsOpen(true)}
>
<SettingsIcon className="h-4 w-4" />
</IconButton>
</span>
</>
}
compactMenu={
<ToolbarOverflowMenu
fullscreen={fullscreen}
onCommands={() => setCommandOpen(true)}
onLayout={() => setLayoutOpen(true)}
onSettings={() => setSettingsOpen(true)}
onFullscreen={toggleFullscreen}
onHelp={() => setHelpOpen(true)}
onTheme={() => setTheme((value) => (value === 'dark' ? 'light' : 'dark'))}
/>
}
onCommands={() => setCommandOpen(true)}
onToggleFullscreen={toggleFullscreen}
center={
<ViewerToolDock
mode={state.mode}
display={displayOptions}
onModeChange={mode}
onDisplayChange={setDisplayOptions}
onResetCamera={() => viewer.current?.resetCamera()}
/>
}
/>
<div className="relative flex min-h-0 flex-1">
<ProjectSidebar
visible={leftOpen}
projectName={state.projectName}
files={state.files}
entries={state.entries}
selectedEntry={state.selectedEntry}
snapshot={state.snapshot}
loading={state.loading}
nativeUrdf={selectedFormat === 'urdf' && urdfMode === 'native'}
mapSelection={mapSelection}
editorDocument={editorDocument}
activeTab={projectSidebarTab}
onActiveTabChange={setProjectSidebarTab}
onRemove={removeProject}
onSelectEntry={requestLoadEntry}
onJointHover={(jointId) => viewer.current?.highlightJoint(jointId)}
onAddMapAsset={(type, placementMode) =>
addCertifiedMapAsset(type, undefined, placementMode)
}
onSelectTerrain={selectTerrainAsset}
onSelectMapObject={(id) => {
editorInteraction.current?.onSelect(id);
selectEditorObject(id);
}}
/>
<main className="viewport-shell relative min-w-0 flex-1">
<div ref={viewerHost} className="absolute inset-0" />
<ViewportHUD
paused={state.paused}
mode={state.mode}
selection={state.selection}
ready={Boolean(state.snapshot)}
/>
<WorkspaceOverlays
loading={state.loading}
hasSnapshot={Boolean(state.snapshot)}
dragActive={dragActive}
progress={importProgress}
/>
<ToastViewport item={toast} onDismiss={() => setToast(undefined)} />
{Boolean(state.snapshot?.model.ncam) &&
(showSensorCamera ? (
<div
aria-label="摄像头画面"
className="pointer-events-none absolute bottom-4 left-4 z-20 aspect-[4/3] w-[min(320px,32%)] min-w-[120px] overflow-hidden rounded-lg border border-border-strong shadow-2xl"
>
<div className="pointer-events-auto absolute inset-x-0 top-0 flex h-7 items-center justify-between bg-black/65 px-2 text-[10px] font-medium text-white">
<span className="flex items-center gap-1">
<Camera className="h-3 w-3" />
摄像头
</span>
<button
type="button"
className="rounded px-1.5 py-0.5 hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
onClick={() => setShowSensorCamera(false)}
>
隐藏画面
</button>
</div>
</div>
) : (
<Button
className="absolute bottom-4 left-4 z-20"
icon={<Camera className="h-3.5 w-3.5" />}
onClick={() => setShowSensorCamera(true)}
>
显示摄像头画面
</Button>
))}
{state.entries.length > 1 && !state.selectedEntry && !pendingUrdfPath && (
<EntrySelectionDialog entries={state.entries} onSelect={requestLoadEntry} />
)}{' '}
{state.diagnostic && (
<ErrorRecoveryPanel
key={state.diagnostic.at}
value={state.diagnostic}
onClose={() => state.setDiagnostic(undefined)}
onRetry={
state.diagnostic.category === '模型编译' && state.diagnostic.path
? () => void loadEntry(state.diagnostic!.path!)
: undefined
}
onOpenProject={() => {
setLeftOpen(true);
state.setDiagnostic(undefined);
}}
/>
)}
</main>
<ModelControlsSidebar
visible={rightOpen}
snapshot={state.snapshot}
selection={state.selection}
selectedFormat={selectedFormat}
loading={state.loading}
urdfMode={urdfMode}
baseMode={baseMode}
showCollision={showCollision}
ignoreJointLimits={ignoreJointLimits}
jointAdvanced={jointAdvanced}
angleUnit={angleUnit}
forceScale={forceScale}
controllerPaths={state.files
.filter((file) => /\.py$/i.test(file.path))
.map((file) => file.path)}
selectedControllerPath={selectedControllerPath}
controllerStatus={controllerStatus}
policyPaths={state.files
.filter((file) => /\.onnx$/i.test(file.path))
.map((file) => file.path)}
selectedPolicyPath={selectedPolicyPath}
policyStatus={policyStatus}
mapSelection={mapSelection}
maps={projectMaps}
showVisualMap={showVisualMap}
showMapCollision={showMapCollision}
editorDocument={editorDocument}
onUrdfMode={changeUrdfMode}
onBaseMode={changeBaseMode}
onShowCollision={setShowCollision}
onResetJoints={resetJoints}
onToggleJointLimits={toggleJointLimits}
onToggleAdvanced={() => setJointAdvanced((value) => !value)}
onToggleAngleUnit={() => setAngleUnit((value) => (value === 'rad' ? 'deg' : 'rad'))}
onActuator={setActuator}
onActuatorParameters={setActuatorParameters}
onJoint={setJoint}
onForceScale={setForceScale}
onSelectControllerPath={setSelectedControllerPath}
onLoadControllerPath={loadControllerPath}
onImportController={importController}
onToggleController={toggleController}
onControllerCommand={sendControllerCommand}
onRemoveController={removeController}
onSelectPolicyPath={setSelectedPolicyPath}
onLoadPolicyPath={loadPolicyPath}
onImportPolicy={importPolicy}
onTogglePolicy={togglePolicy}
onPolicyCommand={setPolicyCommand}
onRemovePolicy={removePolicy}
onApplyMap={applyMapSelection}
onEditorPreview={previewEditorDocument}
onEditorApply={applyEditorDocument}
onEditorExport={exportSelectedMap}
onEditorConvert={convertSelectedMap}
onEditorBindInteraction={bindEditorInteraction}
onEditorSelect={selectEditorObject}
onEditorTransformMode={setEditorTransformMode}
onEditorSnapping={setEditorSnapping}
onMapDisplay={(visual, collision) => {
setShowVisualMap(visual);
setShowMapCollision(collision);
}}
onMapTabOpen={() => {
setLeftOpen(true);
setProjectSidebarTab('assets');
}}
/>
</div>
{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>
<StoreStatusBar />
</div>
);
}