1858 lines
68 KiB
TypeScript
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>
|
|
);
|
|
}
|