1186 lines
43 KiB
TypeScript
1186 lines
43 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 {
|
|
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 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 { MuJoCoViewer, type InteractionMode, type 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 { StatusBar } 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';
|
|
|
|
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();
|
|
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),
|
|
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),
|
|
[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>();
|
|
const [urdfMode, setUrdfMode] = useState<UrdfLoadMode>('mjcf'),
|
|
urdfModeRef = useRef<UrdfLoadMode>('mjcf');
|
|
const [baseMode, setBaseMode] = useState<UrdfBaseMode>('floating'),
|
|
baseModeRef = useRef<UrdfBaseMode>('floating');
|
|
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 }));
|
|
useEffect(() => {
|
|
if (!viewerHost.current) return;
|
|
viewer.current = new MuJoCoViewer(viewerHost.current, {
|
|
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) =>
|
|
state.setDiagnostic(diagnostic(error.message.includes('控制器') ? '仿真' : '渲染', error)),
|
|
});
|
|
return () => {
|
|
viewer.current?.dispose();
|
|
viewer.current = null;
|
|
adapter.current.dispose();
|
|
};
|
|
}, []);
|
|
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(() => {
|
|
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;
|
|
loadInFlight.current = true;
|
|
setIgnoreJointLimits(false);
|
|
setControllerStatus(undefined);
|
|
setPolicyStatus(undefined);
|
|
state.setEntry(path);
|
|
state.setLoading(true);
|
|
setImportProgress({ label: '初始化 WASM 与编译模型', value: 0.65 });
|
|
state.setDiagnostic(undefined);
|
|
setGeneratedMjcf(undefined);
|
|
setGeneratedMjcfPath(undefined);
|
|
viewer.current?.attach(null);
|
|
state.setSnapshot(undefined);
|
|
state.setSelection(null);
|
|
try {
|
|
const snapshot = await adapter.current.load(
|
|
manifest.current,
|
|
path,
|
|
requestedMode ?? urdfModeRef.current,
|
|
baseModeRef.current,
|
|
urdfEnhancementsRef.current,
|
|
);
|
|
const supportFiles = adapter.current.cachedSupportFiles();
|
|
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,
|
|
);
|
|
}
|
|
setImportProgress({ label: '创建视口场景', value: 0.92 });
|
|
adapter.current.setSpeed(useAppStore.getState().speed);
|
|
state.setSnapshot(snapshot);
|
|
state.setPaused(true);
|
|
viewer.current?.attach(adapter.current.session);
|
|
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
|
|
? `模型已加载 · ${snapshot.warnings.length} 项兼容调整`
|
|
: '模型加载完成',
|
|
detail: snapshot.warnings.length ? snapshot.warnings.join('\n') : path,
|
|
tone: snapshot.warnings.length ? 'warning' : 'success',
|
|
at: Date.now(),
|
|
};
|
|
setNotifications((items) => [notice, ...items].slice(0, 20));
|
|
setToast(notice);
|
|
} 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);
|
|
} 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({ label: '读取工程文件', value: 0.12 });
|
|
try {
|
|
const next = await importBrowserFiles(files);
|
|
setImportProgress({ label: '处理模型资源与入口', value: 0.38 });
|
|
manifest.current = next;
|
|
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 = () => {
|
|
viewer.current?.attach(null);
|
|
adapter.current.dispose();
|
|
manifest.current = null;
|
|
setGeneratedMjcf(undefined);
|
|
setGeneratedMjcfPath(undefined);
|
|
setPendingUrdfPath(undefined);
|
|
setPendingUrdfMounts([]);
|
|
setSelectedControllerPath(undefined);
|
|
setControllerStatus(undefined);
|
|
setSelectedPolicyPath(undefined);
|
|
setPolicyStatus(undefined);
|
|
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 changeFiles = (event: ChangeEvent<HTMLInputElement>) => {
|
|
void ingest(Array.from(event.target.files ?? []));
|
|
event.target.value = '';
|
|
};
|
|
const drop = (event: DragEvent) => {
|
|
event.preventDefault();
|
|
if (state.loading || importInFlight.current) return;
|
|
importInFlight.current = true;
|
|
state.setLoading(true);
|
|
setImportProgress({ label: '读取拖放文件', value: 0.05 });
|
|
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({ label: '初始化 Python 运行时并加载控制器', 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({ label: '初始化 ONNX Runtime 并加载策略', 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`}
|
|
onDragOver={(event) => event.preventDefault()}
|
|
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}
|
|
onRemove={removeProject}
|
|
onSelectEntry={requestLoadEntry}
|
|
onJointHover={(jointId) => viewer.current?.highlightJoint(jointId)}
|
|
/>
|
|
<main className="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)}
|
|
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}
|
|
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}
|
|
/>
|
|
</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>
|
|
<StatusBar
|
|
time={state.snapshot?.time}
|
|
fps={state.fps}
|
|
stepMs={state.stepMs}
|
|
memoryMb={state.memoryMb}
|
|
loaded={Boolean(state.snapshot)}
|
|
overBudget={state.overBudget}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|