Files
Mujoco_WASM/web_platform/src/app/App.tsx
T
chenlin 60d3a6d68c
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
chore(web-platform): release V0.6.1 工程质量优化
2026-08-28 15:38:10 +08:00

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>
);
}