refactor: release v1.0.5 安全精简与网站发布
web-platform-ci / Standalone decision service (no cloud credentials) (push) Waiting to run
web-platform-ci / TypeScript, lint, unit, build (push) Waiting to run
web-platform-ci / Playwright E2E (push) Waiting to run
lekiwi-compatibility / cpu-compatibility (push) Waiting to run

This commit is contained in:
2026-09-29 09:55:08 +08:00
parent 7ebe9092ba
commit 26bb5634bd
64 changed files with 9048 additions and 3688 deletions
+3
View File
@@ -84,6 +84,8 @@ test('URL 自动下载、配置 profile、加载真实 WASM;刷新可重入且
await expect(page.getByRole('button', { name: '关节拖动', exact: true })).toBeDisabled();
expect(downloads).toHaveLength(1);
await verifyBuiltinCameras(page, info);
const simulationTime = page.getByLabel('视口状态').getByText(/^时间 /);
await expect(simulationTime).toHaveText('时间 0.000 s');
const selector = page.getByRole('combobox', { name: '摄像头视角' });
const frame = page.getByLabel('摄像头画面', { exact: true });
await expect(selector.locator('option')).toHaveCount(2);
@@ -104,6 +106,7 @@ test('URL 自动下载、配置 profile、加载真实 WASM;刷新可重入且
await expect(frame).toHaveAttribute('data-camera-body', 'Camera-Model-v3-1');
await expect(play).toBeEnabled();
expect(downloads).toHaveLength(1); // Switching/hiding never reloads the robot.
await expect(simulationTime).toHaveText('时间 0.000 s'); // 相机选择/隐藏不推进物理。
await page.reload();
await expect(play).toBeEnabled({ timeout: 90000 });
await expect(page.getByRole('dialog', { name: '配置 URDF 仿真组件' })).toHaveCount(0);
@@ -0,0 +1,232 @@
import { StrictMode, useEffect, type ReactNode } from 'react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
import type { MuJoCoViewer } from '../viewer/MuJoCoViewer';
import type { SimulationSnapshot } from '../simulation/SimulationSession';
import { DEFAULT_VIEWER_DISPLAY_OPTIONS } from '../viewer/displayOptions';
import { useAppStore } from '../stores/useAppStore';
import { App } from './App';
type Callbacks = ConstructorParameters<typeof MuJoCoViewer>[1];
const probe = vi.hoisted(() => ({
viewers: [] as {
callbacks: Callbacks;
display: ReturnType<typeof vi.fn>;
dispose: ReturnType<typeof vi.fn>;
}[],
setPaused: vi.fn(),
reset: vi.fn(),
adapterDispose: vi.fn(),
agentMount: vi.fn(),
agentUnmount: vi.fn(),
}));
vi.mock('../simulation/PhysicsAdapter', () => ({
MainThreadPhysicsAdapter: class {
session = null;
snapshot = () => null;
dispose = probe.adapterDispose;
setPaused = probe.setPaused;
reset = probe.reset;
externalControlStatus = () => undefined;
stopExternalControl = vi.fn();
},
}));
// 自动 mock 避开 Vitest 手动 factory 对并发 import 的 callstack 限制。
vi.mock('../viewer/MuJoCoViewer');
class ViewerDouble {
constructor(_host: HTMLElement, callbacks: Callbacks) {
probe.viewers.push({ callbacks, display: this.setDisplayOptions, dispose: this.dispose });
}
dispose = vi.fn();
setDisplayOptions = vi.fn();
setMode = vi.fn();
setMapDisplay = vi.fn();
setShowSensorCamera = vi.fn();
setSensorCameraViewportElement = vi.fn();
setOrientationGizmoHost = vi.fn();
setTheme = vi.fn();
setParametricMapAssets = vi.fn();
setMapEditorTransformMode = vi.fn();
setMapEditorSnapping = vi.fn();
setShowPerceptionRays = vi.fn();
setMapEditorPreviewInstances = vi.fn();
}
vi.mock('./components/WorkspaceToolsPanel', () => ({
WorkspaceToolsPanel: ({ agentTask }: { agentTask: ReactNode }) => <>{agentTask}</>,
}));
vi.mock('../mobile/agent/AgentTaskPanel', () => ({
AgentTaskPanel: function ProbeAgent() {
useEffect(() => {
probe.agentMount();
return () => {
probe.agentUnmount();
};
}, []);
return <div data-testid="agent-mount-probe" />;
},
}));
const snapshot: SimulationSnapshot = {
time: 0,
qpos: [],
qvel: [],
ctrl: [],
actuators: [],
joints: [],
bodies: [],
warnings: [],
model: { nbody: 0, njnt: 0, ngeom: 0, ncam: 0, nactuator: 0, nu: 0, nq: 0, nv: 0 },
telemetry: {
recording: false,
limitReached: false,
sampleCount: 0,
segmentCount: 0,
config: { bodyId: 0, sampleRateHz: 10, maxSamples: 100 },
body: { id: 0, name: 'world' },
summary: {
duration: 0,
distanceHorizontal: 0,
maxHorizontalSpeed: 0,
maxAbsRoll: 0,
maxAbsPitch: 0,
},
},
};
async function ready() {
await waitFor(() => expect(probe.viewers).toHaveLength(1));
}
beforeEach(async () => {
const { MuJoCoViewer: Viewer } = await import('../viewer/MuJoCoViewer');
vi.mocked(Viewer).mockImplementation(ViewerDouble as unknown as typeof MuJoCoViewer);
vi.clearAllMocks();
probe.viewers.length = 0;
localStorage.clear();
useAppStore.setState(useAppStore.getInitialState(), true);
window.history.replaceState({}, '', '/');
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.reject(new Error('此表征测试禁止网络访问'))),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('App 原实现的编排边界', () => {
it('动态初始化完成前卸载不会创建迟到 Viewer', async () => {
const { unmount } = render(<App />);
unmount();
await vi.dynamicImportSettled();
expect(probe.viewers).toHaveLength(0);
expect(probe.adapterDispose).toHaveBeenCalledOnce();
});
it('Viewer 构造失败保留原渲染诊断,资源在卸载时清理', async () => {
const { MuJoCoViewer: Viewer } = await import('../viewer/MuJoCoViewer');
vi.mocked(Viewer).mockImplementationOnce(
class {
constructor() {
throw new Error('模拟初始化失败');
}
} as unknown as typeof MuJoCoViewer,
);
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
const { unmount } = render(<App />);
await waitFor(() =>
expect(useAppStore.getState().diagnostic).toMatchObject({
category: '渲染',
summary: '渲染失败',
detail: '模拟初始化失败',
}),
);
expect(probe.viewers).toHaveLength(0);
unmount();
expect(probe.adapterDispose).toHaveBeenCalledOnce();
} finally {
error.mockRestore();
}
});
it('StrictMode 只保留一个有效 Viewer,卸载释放并取消键盘监听,无自动网络请求', async () => {
const { unmount } = render(
<StrictMode>
<App />
</StrictMode>,
);
await ready();
expect(probe.viewers[0].dispose).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
unmount();
expect(probe.viewers[0].dispose).toHaveBeenCalledOnce();
expect(probe.adapterDispose).toHaveBeenCalledTimes(2);
fireEvent.keyDown(window, { key: 'r' });
expect(probe.reset).not.toHaveBeenCalled();
});
it('全局 Space 使用最新暂停状态;r 大小写及 repeat 行为不被顺手修改', async () => {
render(<App />);
await ready();
fireEvent.keyDown(window, { code: 'Space', key: ' ' });
fireEvent.keyDown(window, { code: 'Space', key: ' ' });
expect(probe.setPaused.mock.calls.slice(-2)).toEqual([[false], [true]]);
fireEvent.keyDown(window, { key: 'R' });
expect(probe.reset).not.toHaveBeenCalled();
fireEvent.keyDown(window, { key: 'r', repeat: true });
expect(probe.reset).toHaveBeenCalledOnce();
});
it('输入、按钮和已经 preventDefault 的事件不触发全局重置', async () => {
const { container } = render(<App />);
await ready();
const input = document.createElement('input');
container.append(input);
fireEvent.keyDown(input, { key: 'r' });
fireEvent.keyDown(screen.getAllByRole('button')[0], { key: 'r' });
const event = new KeyboardEvent('keydown', { key: 'r', bubbles: true, cancelable: true });
event.preventDefault();
fireEvent(window, event);
expect(probe.reset).not.toHaveBeenCalled();
});
it('按钮上的 Ctrl+K 可打开命令面板,模态输入焦点抑制全局快捷键', async () => {
render(<App />);
await ready();
fireEvent.keyDown(screen.getAllByRole('button')[0], { key: 'K', ctrlKey: true });
expect(screen.getByRole('dialog')).toBeInTheDocument();
const input = screen.getByRole('dialog').querySelector('input')!;
input.focus();
fireEvent.keyDown(window, { key: 'r' });
expect(probe.reset).not.toHaveBeenCalled();
});
it.each([
['{损坏 JSON', DEFAULT_VIEWER_DISPLAY_OPTIONS],
[
JSON.stringify({ showCollision: true, showInertia: '错误类型', unknown: true }),
{ ...DEFAULT_VIEWER_DISPLAY_OPTIONS, showCollision: true },
],
])('损坏/混合类型的显示偏好安全回退:%s', async (stored, expected) => {
localStorage.setItem('mujoco-platform-display', stored);
render(<App />);
await ready();
expect(probe.viewers[0].display).toHaveBeenLastCalledWith(expected);
expect(JSON.parse(localStorage.getItem('mujoco-platform-display')!)).toEqual(expected);
});
it('工具首次访问才挂载,切换检查器及首个 snapshot 不重挂载 agent', async () => {
const { unmount } = render(<App />);
await ready();
expect(probe.agentMount).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('tab', { name: '控制台' }));
await screen.findByTestId('agent-mount-probe');
expect(probe.agentMount).toHaveBeenCalledOnce();
fireEvent.click(screen.getByRole('tab', { name: '检查器' }));
expect(probe.agentUnmount).not.toHaveBeenCalled();
act(() => useAppStore.getState().setSnapshot(snapshot));
expect(probe.agentMount).toHaveBeenCalledOnce();
expect(probe.agentUnmount).not.toHaveBeenCalled();
unmount();
expect(probe.agentUnmount).toHaveBeenCalledOnce();
});
});
File diff suppressed because it is too large Load Diff
+746 -2801
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,59 @@
import { lazy, Suspense, type ComponentProps } from 'react';
import { ConfirmDialog } from '../../components/ui';
import { WorkbenchCommandPalette } from '../workbenchCommands';
import { DiagnosticsDrawer } from './DiagnosticsDrawer';
import { LayoutSettingsDialog } from './LayoutSettingsDialog';
import { SettingsDialog } from './SettingsDialog';
import { ShortcutHelpDialog } from './ShortcutHelpDialog';
import { UrdfImportOptionsDialog } from './UrdfImportOptionsDialog';
const SourceEditorDialog = lazy(() =>
import('./SourceEditorDialog').then((module) => ({ default: module.SourceEditorDialog })),
);
/** 只组合根级对话框;视口内的入口选择对话框保持原挂载位置。 */
export function WorkbenchDialogs({
urdf,
source,
help,
diagnostics,
settings,
layout,
commands,
remove,
}: {
urdf?: ComponentProps<typeof UrdfImportOptionsDialog>;
source?: ComponentProps<typeof SourceEditorDialog>;
help: ComponentProps<typeof ShortcutHelpDialog>;
diagnostics: ComponentProps<typeof DiagnosticsDrawer>;
settings: ComponentProps<typeof SettingsDialog>;
layout: ComponentProps<typeof LayoutSettingsDialog>;
commands: ComponentProps<typeof WorkbenchCommandPalette>;
remove: ComponentProps<typeof ConfirmDialog>;
}) {
return (
<>
{urdf && <UrdfImportOptionsDialog key={urdf.path} {...urdf} />}
{source && (
<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 {...source} />
</Suspense>
)}
<ShortcutHelpDialog {...help} />
<DiagnosticsDrawer {...diagnostics} />
<SettingsDialog {...settings} />
<LayoutSettingsDialog {...layout} />
<WorkbenchCommandPalette {...commands} />
<ConfirmDialog {...remove} />
</>
);
}
@@ -0,0 +1,51 @@
import { lazy, Suspense, type ComponentProps } from 'react';
import { AgentTaskPanel, type AgentWorkbenchBridge } from '../../mobile/agent/AgentTaskPanel';
import { ExternalControlPanel } from '../../robot/ExternalControlPanel';
const WorkspaceToolsPanel = lazy(() =>
import('./WorkspaceToolsPanel').then((module) => ({ default: module.WorkspaceToolsPanel })),
);
type Props = Omit<ComponentProps<typeof WorkspaceToolsPanel>, 'agentTask' | 'externalControl'> & {
agentBridge: AgentWorkbenchBridge;
externalControlBridge: ComponentProps<typeof ExternalControlPanel>['bridge'];
externalControlKey?: string;
onApplyRobotProfile: ComponentProps<typeof ExternalControlPanel>['onApplyProfile'];
};
/** 组件类型固定;访问历史由组合根持有,隐藏工具不卸载,重场景面板仍 lazy。 */
export function WorkbenchTools({
agentBridge,
externalControlBridge,
externalControlKey,
onApplyRobotProfile,
...props
}: Props) {
return (
<Suspense
fallback={
<div
role="status"
className="grid min-h-0 flex-1 place-items-center p-4 text-sm text-text-tertiary"
>
正在加载工作区工具…
</div>
}
>
<WorkspaceToolsPanel
{...props}
agentTask={<AgentTaskPanel loading={props.loading} bridge={agentBridge} />}
externalControl={
props.snapshot && (
<ExternalControlPanel
key={externalControlKey}
snapshot={props.snapshot}
loading={props.loading}
bridge={externalControlBridge}
onApplyProfile={onApplyRobotProfile}
/>
)
}
/>
</Suspense>
);
}
@@ -0,0 +1,54 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { AppDiagnostic } from '../../stores/useAppStore';
import { WorkspaceNotices } from './WorkspaceNotices';
const diagnostic: AppDiagnostic = {
category: '模型编译',
summary: '编译失败',
detail: '缺少文件',
path: 'robot.xml',
at: 1,
};
const actions = () => ({
onCloseError: vi.fn(),
onRetry: vi.fn(),
onOpenProject: vi.fn(),
onDismissToast: vi.fn(),
});
describe('工作台错误与通知优先级', () => {
it('只有携带路径的模型编译错误开放重试入口', () => {
const callbacks = actions();
const { rerender } = render(<WorkspaceNotices diagnostic={diagnostic} {...callbacks} />);
fireEvent.click(screen.getByRole('button', { name: '重试当前入口' }));
expect(callbacks.onRetry).toHaveBeenCalledWith('robot.xml');
rerender(<WorkspaceNotices diagnostic={{ ...diagnostic, category: '导入' }} {...callbacks} />);
expect(screen.queryByRole('button', { name: '重试当前入口' })).not.toBeInTheDocument();
rerender(<WorkspaceNotices diagnostic={{ ...diagnostic, path: undefined }} {...callbacks} />);
expect(screen.queryByRole('button', { name: '重试当前入口' })).not.toBeInTheDocument();
});
it('错误遮挡 Toast,按原 at key 更换错误后重置展开状态', () => {
const callbacks = actions();
const toast = { id: 1, title: '任务已完成', detail: '详情', tone: 'success' as const, at: 0 };
const { rerender } = render(
<WorkspaceNotices diagnostic={diagnostic} toast={toast} {...callbacks} />,
);
expect(screen.queryByText('任务已完成')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '技术详情' }));
expect(screen.getByRole('button', { name: '技术详情' })).toHaveAttribute(
'aria-expanded',
'true',
);
rerender(
<WorkspaceNotices diagnostic={{ ...diagnostic, at: 2 }} toast={toast} {...callbacks} />,
);
expect(screen.getByRole('button', { name: '技术详情' })).toHaveAttribute(
'aria-expanded',
'false',
);
rerender(<WorkspaceNotices toast={toast} {...callbacks} />);
expect(screen.getByText('任务已完成')).toBeInTheDocument();
});
});
@@ -0,0 +1,38 @@
import type { AppDiagnostic } from '../../stores/useAppStore';
import { ErrorRecoveryPanel } from './ErrorRecoveryPanel';
import { ToastViewport, type WorkbenchNotification } from './NotificationCenter';
export function WorkspaceNotices({
diagnostic,
toast,
onCloseError,
onRetry,
onOpenProject,
onDismissToast,
}: {
diagnostic?: AppDiagnostic;
toast?: WorkbenchNotification;
onCloseError(): void;
onRetry(path: string): void;
onOpenProject(): void;
onDismissToast(): void;
}) {
return (
<div className="flex max-h-[32vh] flex-col gap-2 overflow-auto">
{diagnostic && (
<ErrorRecoveryPanel
key={diagnostic.at}
value={diagnostic}
onClose={onCloseError}
onRetry={
diagnostic.category === '模型编译' && diagnostic.path
? () => onRetry(diagnostic.path!)
: undefined
}
onOpenProject={onOpenProject}
/>
)}
{!diagnostic && <ToastViewport item={toast} onDismiss={onDismissToast} />}
</div>
);
}
-6
View File
@@ -3,9 +3,3 @@ export type EditorSelection =
| { kind: 'joint'; jointId: number; bodyId: number }
| { kind: 'map'; mapAssetId: string }
| { kind: 'map-object'; mapAssetId: string; objectId: string };
export function isMapEditorSelection(
selection: EditorSelection | null,
): selection is Extract<EditorSelection, { kind: 'map' | 'map-object' }> {
return selection?.kind === 'map' || selection?.kind === 'map-object';
}
+110
View File
@@ -0,0 +1,110 @@
import { useEffect, type RefObject } from 'react';
import { BUILTIN_ROBOTS, downloadBuiltinRobot } from '../../project/builtinRobots';
import { PICK_PLACE_ROBOT, type PickPlaceTaskConfig } from '../../mobile/agent/PickPlaceScene';
import type { SimulationSession } from '../../simulation/SimulationSession';
import type { AgentWorkbenchBridge } from '../../mobile/agent/AgentTaskPanel';
import type { PlacedMapAsset } from '../../map/types';
import type { useModelLoading } from './useModelLoading';
import type { useProjectImport } from './useProjectImport';
type Resources = Pick<
Parameters<typeof useModelLoading>[0],
'manifestRef' | 'adapterRef' | 'loadInFlightRef' | 'robotProfilesRef' | 'agentSceneSessionRef'
>;
export function useAgentScene({
manifestRef,
adapterRef,
loadInFlightRef,
robotProfilesRef,
agentSceneSessionRef,
importInFlightRef,
ingest,
loadEntry,
publishExternalSnapshot,
}: Resources & {
importInFlightRef: RefObject<boolean>;
ingest: ReturnType<typeof useProjectImport>['ingest'];
loadEntry: ReturnType<typeof useModelLoading>['loadEntry'];
publishExternalSnapshot(): void;
}) {
const prepareAgentScene = async (
config: PickPlaceTaskConfig,
signal?: AbortSignal,
): Promise<SimulationSession> => {
if (loadInFlightRef.current || importInFlightRef.current)
throw new Error('模型正在加载,请稍后重试');
signal?.throwIfAborted();
const hasRobot = manifestRef.current?.entries.some(
(e) =>
e.format === 'urdf' &&
(robotProfilesRef.current.get(e.path) === 'lekiwi-v1' ||
/(^|\/)URDF\/LeKiwi\.urdf$/.test(e.path)),
);
if (!hasRobot && config.preset === 'language-v2') {
if (
manifestRef.current &&
!window.confirm('载入预设将替换当前工程。请先保存未保存内容,是否继续?')
)
throw new Error('已取消替换工程');
const builtin = BUILTIN_ROBOTS[0];
const bounded = signal
? AbortSignal.any([signal, AbortSignal.timeout(120000)])
: AbortSignal.timeout(120000);
const file = await downloadBuiltinRobot(builtin, bounded);
bounded.throwIfAborted();
await ingest([file], false, builtin, bounded);
bounded.throwIfAborted();
}
const entry = manifestRef.current?.entries.find(
(e) =>
e.format === 'urdf' &&
(robotProfilesRef.current.get(e.path) === 'lekiwi-v1' ||
/(^|\/)URDF\/LeKiwi\.urdf$/.test(e.path)),
);
if (!entry) throw new Error('请先导入 A / lekiwi-v1 ZIP;不会使用其他机器人替代');
if (
!(await loadEntry(
entry.path,
'mjcf',
undefined,
undefined,
undefined,
PICK_PLACE_ROBOT,
config,
signal,
))
)
throw new Error('A 任务场景加载失败,请查看编译诊断');
return adapterRef.current.session!;
};
const agentBridge: AgentWorkbenchBridge = {
loadScene: prepareAgentScene,
getSession: () => adapterRef.current.session ?? undefined,
isTaskScene: () =>
Boolean(
agentSceneSessionRef.current && agentSceneSessionRef.current === adapterRef.current.session,
),
publish: publishExternalSnapshot,
};
return { prepareAgentScene, agentBridge };
}
/** 单独在组合根原位置调用,保持 Viewer/地图 effect 的先后次序。 */
export function useAgentSceneInvalidation({
adapterRef,
placedMapAssets,
publishExternalSnapshot,
}: {
adapterRef: Resources['adapterRef'];
placedMapAssets: PlacedMapAsset[];
publishExternalSnapshot(): void;
}) {
useEffect(() => {
const session = adapterRef.current.session;
if (session?.snapshot().controlOwner === 'agent') {
session.stopAgent('地图草稿编辑使旧计划失效');
session.setPaused(true);
publishExternalSnapshot();
}
}, [adapterRef, placedMapAssets, publishExternalSnapshot]);
}
@@ -0,0 +1,356 @@
import { DEFAULT_IMPORT_LIMITS, type ProjectManifest } from '../../project/types';
import { manifestEditorDocuments } from '../../project/workspaceManifest';
import { downloadBytes } from '../../project/cachedFiles';
import { discoverMapEntries, resolveProjectMap } from '../../map/MapLoader';
import {
createPlacedMapAsset,
mapWorldPointToLocal,
type PlacedMapSelection,
} from '../../map/types';
import { mapSceneSurfaceHeightAt } from '../../map/sceneSurface';
import { resolveProjectAssetPath } from '../../map/mapPaths';
import { compileEditableMapDocument } from '../../map/editor/MapDocumentCompiler';
import { encodeEditableMapDocument } from '../../map/editor/editorSchema';
import { importEditableMapDocument } from '../../map/editor/MapDocumentImporter';
import type {
EditableMapDocument,
EditableMapObjectType,
MapObjectPlacementMode,
} from '../../map/editor/types';
import type { useAppStore } from '../../stores/useAppStore';
import { diagnostic, type useWorkbenchFeedback } from './useWorkbenchFeedback';
import type { useMapEditor } from './useMapEditor';
import type { useMapScene } from './useMapScene';
import type { useMapWorkspaceState } from './useMapWorkspaceState';
import type { useModelLoading } from './useModelLoading';
type Resources = Pick<
Parameters<typeof useModelLoading>[0],
'manifestRef' | 'adapterRef' | 'loadInFlightRef' | 'urdfModeRef'
>;
type EditorPort = Pick<
Parameters<typeof useMapEditor>[0],
'pendingMapAssetRef' | 'editorInteractionRef'
>;
type MapStatePort = Pick<
ReturnType<typeof useMapWorkspaceState>,
| 'projectMaps'
| 'setProjectMaps'
| 'setCommittedEditorDocuments'
| 'activeMapAssetIdRef'
| 'placedMapAssetsRef'
| 'mapSelectionRef'
| 'editorDraftsRef'
| 'setEditorSelection'
| 'setEditorDocument'
| 'provisionalMapFilesRef'
>;
/** 创作文件的创建、转换与导出;原编译/文件上限/失败恢复次序保持不变。 */
export function useMapAuthoring({
state,
manifestRef,
adapterRef,
loadInFlightRef,
urdfModeRef,
pendingMapAssetRef,
editorInteractionRef,
projectMaps,
setProjectMaps,
setCommittedEditorDocuments,
activeMapAssetIdRef,
placedMapAssetsRef,
mapSelectionRef,
editorDraftsRef,
setEditorSelection,
setEditorDocument,
provisionalMapFilesRef,
setRightOpen,
notify,
loadEntry,
updateEditorDraft,
scene: {
previewEditorDocument,
readEditorDocument,
setMapScene,
uniqueMapAssetName,
focusMapProperties,
commitMapScene,
},
}: Resources &
EditorPort &
MapStatePort & {
state: Pick<
ReturnType<typeof useAppStore.getState>,
'entries' | 'selectedEntry' | 'loading' | 'setDiagnostic' | 'setProject' | 'setSnapshot'
>;
setRightOpen(value: boolean): void;
notify: ReturnType<typeof useWorkbenchFeedback>['notify'];
loadEntry: ReturnType<typeof useModelLoading>['loadEntry'];
updateEditorDraft: ReturnType<typeof useMapEditor>['updateEditorDraft'];
scene: Pick<
ReturnType<typeof useMapScene>,
| 'previewEditorDocument'
| 'readEditorDocument'
| 'setMapScene'
| 'uniqueMapAssetName'
| 'focusMapProperties'
| 'commitMapScene'
>;
}) {
const createEditableScene = async (
type: EditableMapObjectType,
position?: [number, number, number],
placementMode: MapObjectPlacementMode = 'auto_ground',
externalSupportTop = 0,
): Promise<boolean> => {
const current = manifestRef.current;
const entryPath = state.selectedEntry;
const entry = state.entries.find((candidate) => candidate.path === entryPath);
if (!current || !entryPath || !entry || loadInFlightRef.current) return false;
if (entry.format === 'urdf' && urdfModeRef.current === 'native') {
state.setDiagnostic(
diagnostic('模型编译', new Error('原生 URDF 不能创建 MJCF 场景,请切换为转换模式')),
);
return false;
}
if (current.files.length + 3 > DEFAULT_IMPORT_LIMITS.maxFiles) {
state.setDiagnostic(
diagnostic('文件系统', new Error('工程文件数量已达到上限,无法创建场景')),
);
return false;
}
let index = 1;
while (
current.maps.some((map) => map.id === `scene_${index}`) ||
current.files.some((file) => file.path.startsWith(`maps/scene_${index}/`))
)
index += 1;
const mapId = `scene_${index}`;
const directory = `maps/${mapId}`;
const descriptorPath = `${directory}/map.json`;
const physicsPath = `${directory}/physics/world.xml`;
const authoringPath = `${directory}/authoring/map.scene.json`;
const document: EditableMapDocument = {
schemaVersion: 1,
mapId,
revision: 0,
objects: [],
spawnPoints: [],
};
const definition = {
schemaVersion: 2 as const,
id: mapId,
name: `场景 ${index}`,
coordinateSystem: { units: 'm' as const, up: 'Z' as const, forward: '+X' as const },
physics: { source: 'physics/world.xml' },
authoring: { source: 'authoring/map.scene.json' },
spawnPoints: [],
};
const descriptorData = new TextEncoder().encode(`${JSON.stringify(definition, null, 2)}\n`);
const physicsData = compileEditableMapDocument(document);
const authoringData = encodeEditableMapDocument(document);
const source = current.files.find((file) => file.path === entryPath)?.source ?? 'file';
const files = [
...current.files,
{
path: descriptorPath,
data: descriptorData,
size: descriptorData.byteLength,
source,
mimeType: 'application/json',
},
{
path: physicsPath,
data: physicsData,
size: physicsData.byteLength,
source,
mimeType: 'application/xml',
},
{
path: authoringPath,
data: authoringData,
size: authoringData.byteLength,
source,
mimeType: 'application/json',
},
];
const totalBytes = files.reduce((total, file) => total + file.size, 0);
if (totalBytes > DEFAULT_IMPORT_LIMITS.maxTotalBytes) {
state.setDiagnostic(diagnostic('文件系统', new Error('创建场景后工程总大小超过 512 MiB')));
return false;
}
try {
const maps = discoverMapEntries(files);
const candidate: ProjectManifest = { ...current, files, maps, totalBytes };
const selection: PlacedMapSelection = {
kind: 'project',
descriptorPath,
positionX: 0,
positionY: 0,
yawDeg: 0,
};
const placed = createPlacedMapAsset(selection, uniqueMapAssetName(definition.name));
pendingMapAssetRef.current = { type, position, placementMode, externalSupportTop };
manifestRef.current = candidate;
provisionalMapFilesRef.current.set(descriptorPath, [
descriptorPath,
physicsPath,
authoringPath,
]);
setProjectMaps(maps);
setCommittedEditorDocuments(manifestEditorDocuments(candidate));
setMapScene([...placedMapAssetsRef.current, placed], placed.id, selection);
setEditorSelection({ kind: 'map', mapAssetId: placed.id });
setRightOpen(true);
setEditorDocument(document);
previewEditorDocument(document);
state.setProject(
candidate.name,
candidate.files.map((file) => ({ path: file.path, size: file.size })),
candidate.entries,
entryPath,
);
state.setSnapshot(adapterRef.current.snapshot() ?? undefined);
return true;
} catch (error) {
pendingMapAssetRef.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 || loadInFlightRef.current) return;
focusMapProperties();
const worldPosition = position;
const externalSupportTop =
position && placementMode === 'gravity'
? (mapSceneSurfaceHeightAt(placedMapAssetsRef.current, position[0], position[1]) ?? 0)
: 0;
const interaction = editorInteractionRef.current;
if (interaction) {
let localPosition = worldPosition;
const selection = mapSelectionRef.current;
if (worldPosition && selection.kind === 'project')
localPosition = mapWorldPointToLocal(worldPosition, selection);
interaction.onAddAsset(type, localPosition, placementMode, externalSupportTop);
return;
}
await createEditableScene(type, worldPosition, placementMode, externalSupportTop);
};
const applyEditorDocument = async (document: EditableMapDocument): Promise<boolean> => {
const selection = mapSelectionRef.current;
if (selection.kind !== 'project' || !manifestRef.current || !state.selectedEntry) return false;
updateEditorDraft(selection.descriptorPath, document, true);
return commitMapScene(new Map(editorDraftsRef.current));
};
const convertSelectedMap = async (): Promise<boolean> => {
const selection = mapSelectionRef.current;
const current = manifestRef.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,
};
manifestRef.current = candidate;
const loaded = await loadEntry(entryPath);
if (!loaded) {
manifestRef.current = current;
return false;
}
const loadedManifest = manifestRef.current ?? candidate;
const maps = discoverMapEntries(loadedManifest.files);
const committed = { ...loadedManifest, maps };
manifestRef.current = committed;
setProjectMaps(maps);
setCommittedEditorDocuments(manifestEditorDocuments(committed));
setEditorDocument(document);
state.setProject(
committed.name,
committed.files.map((file) => ({ path: file.path, size: file.size })),
committed.entries,
entryPath,
);
state.setSnapshot(adapterRef.current.snapshot() ?? undefined);
const selectedMapAssetId = activeMapAssetIdRef.current;
setEditorSelection(
selectedMapAssetId ? { kind: 'map', mapAssetId: selectedMapAssetId } : null,
);
notify('已创建可编辑地图副本', authoringPath);
return true;
} catch (error) {
manifestRef.current = current;
state.setDiagnostic(diagnostic('模型编译', error, diagnosticPath));
return false;
}
};
const exportSelectedMap = async () => {
const selection = mapSelectionRef.current;
if (selection.kind !== 'project' || !manifestRef.current) return;
try {
const { exportMapPackage } = await import('../../map/editor/MapPackageExporter');
const entry = projectMaps.find((map) => map.descriptorPath === selection.descriptorPath);
downloadBytes(
exportMapPackage(manifestRef.current, selection.descriptorPath),
`${entry?.id ?? 'map'}-map.zip`,
'application/zip',
);
} catch (error) {
state.setDiagnostic(diagnostic('文件系统', error, selection.descriptorPath));
}
};
return { addCertifiedMapAsset, applyEditorDocument, convertSelectedMap, exportSelectedMap };
}
+365
View File
@@ -0,0 +1,365 @@
import { useCallback, useEffect, type RefObject } from 'react';
import type { MuJoCoViewer } from '../../viewer/MuJoCoViewer';
import { mapLocalPointToWorld } from '../../map/types';
import { mapSceneSurfaceHeightAt } from '../../map/sceneSurface';
import { findCompiledEditableMapPick } from '../../map/editor/compiledMapPick';
import { transformParametricMapAsset } from '../../map/mapSceneDraft';
import type {
EditableMapDocument,
EditableMapObjectType,
MapEditorInteractionCallbacks,
MapEditorSessionState,
MapEditorTransformMode,
MapObjectPlacementMode,
} from '../../map/editor/types';
import { useAppStore } from '../../stores/useAppStore';
import type { useMapScene } from './useMapScene';
import type { useMapWorkspaceState } from './useMapWorkspaceState';
import type { useModelLoading } from './useModelLoading';
export interface PendingMapAsset {
type: EditableMapObjectType;
position?: [number, number, number];
placementMode: MapObjectPlacementMode;
externalSupportTop?: number;
}
type Resources = Pick<
Parameters<typeof useModelLoading>[0],
'manifestRef' | 'adapterRef' | 'viewerRef' | 'viewerReadyRef' | 'loadInFlightRef'
>;
type MapStatePort = Pick<
ReturnType<typeof useMapWorkspaceState>,
| 'mapSelectionRef'
| 'placedMapAssetsRef'
| 'editorDraftsRef'
| 'setEditorDrafts'
| 'setEditorSessionStates'
| 'activeMapAssetIdRef'
| 'setEditorSelection'
| 'editorSelection'
| 'setAssetPlacementMode'
| 'setMapTransformMode'
| 'mapEditorDraftPreviews'
| 'setEditorDocument'
>;
/** 地图编辑交互与视口绑定;创作文件事务独立,不拥有第二份场景状态。 */
export function useMapEditor({
setMode,
manifestRef,
adapterRef,
viewerRef,
viewerReadyRef,
loadInFlightRef,
editorInteractionRef,
pendingMapAssetRef,
pendingEditorObjectIdRef,
compiledMapBodyInteractionRef,
mapEditorPreviewInteractionRef,
parametricMapInteractionRef,
mapSelectionRef,
placedMapAssetsRef,
editorDraftsRef,
setEditorDrafts,
setEditorSessionStates,
activeMapAssetIdRef,
setEditorSelection,
editorSelection,
setAssetPlacementMode,
setMapTransformMode,
mapEditorDraftPreviews,
setEditorDocument,
setRightOpen,
scene: { previewEditorDocument, readEditorDocument, setMapScene, focusMapProperties },
}: Resources &
MapStatePort & {
setMode: ReturnType<typeof useAppStore.getState>['setMode'];
editorInteractionRef: RefObject<MapEditorInteractionCallbacks | null>;
pendingMapAssetRef: RefObject<PendingMapAsset | null>;
pendingEditorObjectIdRef: RefObject<string | undefined>;
compiledMapBodyInteractionRef: RefObject<(bodyName: string) => boolean>;
mapEditorPreviewInteractionRef: RefObject<(mapAssetId: string, objectId: string) => void>;
parametricMapInteractionRef: RefObject<{
onSelect(id: string | null): void;
onTransform(id: string, position: [number, number], yawDeg: number): void;
} | null>;
setRightOpen(value: boolean): void;
scene: Pick<
ReturnType<typeof useMapScene>,
'previewEditorDocument' | 'readEditorDocument' | 'setMapScene' | 'focusMapProperties'
>;
}) {
const editorSurfaceHeight = useCallback(
(position: readonly [number, number, number]): number | null => {
const selection = mapSelectionRef.current;
const worldPosition =
selection.kind === 'project'
? mapLocalPointToWorld(position, selection)
: ([...position] as [number, number, number]);
return mapSceneSurfaceHeightAt(
placedMapAssetsRef.current,
worldPosition[0],
worldPosition[1],
);
},
[mapSelectionRef, placedMapAssetsRef],
);
const updateEditorDraft = useCallback(
(descriptorPath: string, document: EditableMapDocument, dirty: boolean) => {
if (loadInFlightRef.current) return;
const drafts = new Map(editorDraftsRef.current);
if (dirty) drafts.set(descriptorPath, structuredClone(document));
else drafts.delete(descriptorPath);
editorDraftsRef.current = drafts;
setEditorDrafts(drafts);
},
[loadInFlightRef, editorDraftsRef, setEditorDrafts],
);
const bindEditorInteraction = useCallback(
(callbacks: MapEditorInteractionCallbacks | null) => {
editorInteractionRef.current = callbacks;
const pending = pendingMapAssetRef.current;
if (callbacks && pending) {
pendingMapAssetRef.current = null;
callbacks.onAddAsset(
pending.type,
pending.position,
pending.placementMode,
pending.externalSupportTop,
);
}
const objectId = pendingEditorObjectIdRef.current;
if (callbacks && objectId) {
pendingEditorObjectIdRef.current = undefined;
callbacks.onSelect(objectId);
}
},
[editorInteractionRef, pendingMapAssetRef, pendingEditorObjectIdRef],
);
const selectEditorObject = useCallback(
(id: string | null) => {
viewerRef.current?.selectMapEditorObject(id);
const mapAssetId = activeMapAssetIdRef.current;
setEditorSelection(
mapAssetId
? id
? { kind: 'map-object', mapAssetId, objectId: id }
: { kind: 'map', mapAssetId }
: null,
);
},
[viewerRef, activeMapAssetIdRef, setEditorSelection],
);
const updateEditorSessionState = useCallback(
(descriptorPath: string, sessionState: MapEditorSessionState | null) => {
setEditorSessionStates((current) => {
const next = new Map(current);
if (sessionState?.dirty) next.set(descriptorPath, sessionState);
else next.delete(descriptorPath);
return next;
});
},
[setEditorSessionStates],
);
const activateMapEditing = useCallback(() => {
setMode('select');
}, [setMode]);
const changeMapTransformMode = useCallback(
(nextMode: MapEditorTransformMode) => {
setMapTransformMode(nextMode);
setMode('select');
},
[setMapTransformMode, setMode],
);
const changeMapPlacementMode = useCallback(
(placementMode: MapObjectPlacementMode) => {
setAssetPlacementMode(placementMode);
if (placementMode === 'locked')
setMapTransformMode((currentMode) => (currentMode === 'scale' ? 'translate' : currentMode));
if (editorSelection?.kind === 'map-object') {
editorInteractionRef.current?.onSetPlacementMode(editorSelection.objectId, placementMode);
if (placementMode !== 'locked')
viewerRef.current?.flashMapEditorSurfaceAlignment(editorSelection.objectId);
}
},
[setAssetPlacementMode, setMapTransformMode, editorSelection, editorInteractionRef, viewerRef],
);
const alignSelectedMapObject = useCallback(() => {
if (editorSelection?.kind !== 'map-object') return;
editorInteractionRef.current?.onAlignToSurface(editorSelection.objectId);
viewerRef.current?.flashMapEditorSurfaceAlignment(editorSelection.objectId);
}, [editorSelection, editorInteractionRef, viewerRef]);
const focusSelectedObject = useCallback(() => {
const bodyId =
editorSelection?.kind === 'body' || editorSelection?.kind === 'joint'
? editorSelection.bodyId
: undefined;
viewerRef.current?.focusSelection(bodyId);
}, [editorSelection, viewerRef]);
const deleteSelectedMapObject = useCallback(() => {
if (editorSelection?.kind === 'map-object')
editorInteractionRef.current?.onDelete(editorSelection.objectId);
}, [editorSelection, editorInteractionRef]);
useEffect(() => {
let active = true;
const update = (activeViewer: MuJoCoViewer | null) => {
if (active) activeViewer?.setMapEditorPreviewInstances(mapEditorDraftPreviews);
};
if (viewerRef.current) update(viewerRef.current);
else void viewerReadyRef.current?.then(update);
return () => {
active = false;
};
}, [mapEditorDraftPreviews, viewerRef, viewerReadyRef]);
const activateMapAsset = useCallback(
(id: string, objectId?: string) => {
if (useAppStore.getState().loading || loadInFlightRef.current) return;
const asset = placedMapAssetsRef.current.find((candidate) => candidate.id === id);
if (!asset) return;
pendingEditorObjectIdRef.current = objectId;
viewerRef.current?.selectParametricMapAsset(null);
viewerRef.current?.selectMapEditorObject(null);
editorInteractionRef.current?.onSelect(null);
setMapScene(placedMapAssetsRef.current, id, asset.selection);
const committedEditorDocument = readEditorDocument(asset.selection);
const previewDocument =
asset.selection.kind === 'project'
? (editorDraftsRef.current.get(asset.selection.descriptorPath) ?? committedEditorDocument)
: null;
setEditorDocument(committedEditorDocument);
previewEditorDocument(previewDocument);
if (objectId) {
viewerRef.current?.selectMapEditorObject(objectId);
if (editorInteractionRef.current) {
editorInteractionRef.current.onSelect(objectId);
pendingEditorObjectIdRef.current = undefined;
}
}
setEditorSelection(
objectId
? { kind: 'map-object', mapAssetId: id, objectId }
: { kind: 'map', mapAssetId: id },
);
useAppStore.getState().setSelection(null);
focusMapProperties();
},
[
loadInFlightRef,
placedMapAssetsRef,
pendingEditorObjectIdRef,
viewerRef,
editorInteractionRef,
setMapScene,
readEditorDocument,
editorDraftsRef,
setEditorDocument,
previewEditorDocument,
setEditorSelection,
focusMapProperties,
],
);
useEffect(() => {
const interaction = (mapAssetId: string, objectId: string) =>
activateMapAsset(mapAssetId, objectId);
mapEditorPreviewInteractionRef.current = interaction;
return () => {
if (mapEditorPreviewInteractionRef.current === interaction)
mapEditorPreviewInteractionRef.current = () => {};
};
}, [activateMapAsset, mapEditorPreviewInteractionRef]);
const selectCompiledEditableMapObject = useCallback(
(bodyName: string): boolean => {
const current = manifestRef.current;
if (!current || useAppStore.getState().loading || loadInFlightRef.current) return false;
const target = findCompiledEditableMapPick(bodyName, current, placedMapAssetsRef.current);
if (!target) return false;
activateMapAsset(target.mapAssetId, target.objectId);
adapterRef.current.setPaused(true);
useAppStore.getState().setPaused(true);
return true;
},
[manifestRef, loadInFlightRef, placedMapAssetsRef, activateMapAsset, adapterRef],
);
useEffect(() => {
compiledMapBodyInteractionRef.current = selectCompiledEditableMapObject;
return () => {
if (compiledMapBodyInteractionRef.current === selectCompiledEditableMapObject)
compiledMapBodyInteractionRef.current = () => false;
};
}, [selectCompiledEditableMapObject, compiledMapBodyInteractionRef]);
const selectParametricMapInViewport = useCallback(
(id: string | null) => {
if (!id) return;
const asset = placedMapAssetsRef.current.find((candidate) => candidate.id === id);
if (!asset || asset.selection.kind !== 'builtin' || asset.selection.config.preset === 'none')
return;
activateMapAsset(id);
if (activeMapAssetIdRef.current !== id) return;
viewerRef.current?.selectParametricMapAsset(id);
adapterRef.current.setPaused(true);
useAppStore.getState().setPaused(true);
useAppStore.getState().setSelection(null);
setEditorSelection({ kind: 'map', mapAssetId: id });
setRightOpen(true);
},
[
placedMapAssetsRef,
activateMapAsset,
activeMapAssetIdRef,
viewerRef,
adapterRef,
setEditorSelection,
setRightOpen,
],
);
const updateParametricMapTransform = useCallback(
(id: string, position: [number, number], yawDeg: number) => {
if (useAppStore.getState().loading || loadInFlightRef.current) return;
const next = transformParametricMapAsset(placedMapAssetsRef.current, id, position, yawDeg);
if (next === placedMapAssetsRef.current) return;
const assets = [...next];
const active = assets.find((asset) => asset.id === id);
if (!active) return;
setMapScene(assets, id, active.selection);
setEditorDocument(null);
adapterRef.current.setPaused(true);
useAppStore.getState().setPaused(true);
setEditorSelection({ kind: 'map', mapAssetId: id });
setRightOpen(true);
},
[
loadInFlightRef,
placedMapAssetsRef,
setMapScene,
setEditorDocument,
adapterRef,
setEditorSelection,
setRightOpen,
],
);
useEffect(() => {
const interaction = {
onSelect: selectParametricMapInViewport,
onTransform: updateParametricMapTransform,
};
parametricMapInteractionRef.current = interaction;
return () => {
if (parametricMapInteractionRef.current === interaction)
parametricMapInteractionRef.current = null;
};
}, [selectParametricMapInViewport, updateParametricMapTransform, parametricMapInteractionRef]);
return {
editorSurfaceHeight,
updateEditorDraft,
bindEditorInteraction,
selectEditorObject,
updateEditorSessionState,
activateMapEditing,
changeMapTransformMode,
changeMapPlacementMode,
alignSelectedMapObject,
focusSelectedObject,
deleteSelectedMapObject,
activateMapAsset,
};
}
+491
View File
@@ -0,0 +1,491 @@
import { useCallback, type RefObject } from 'react';
import { DEFAULT_IMPORT_LIMITS, type ProjectManifest } from '../../project/types';
import {
manifestEditorDocuments,
omitManifestFiles,
readManifestEditorDocument,
} from '../../project/workspaceManifest';
import { discoverMapEntries, visualMapAssets } from '../../map/MapLoader';
import { materializeEditableMapDrafts } from '../../map/editor/EditableMapDraftCommit';
import type { EditableMapDocument, MapEditorInteractionCallbacks } from '../../map/editor/types';
import { restoreAppliedMapScene } from '../../map/mapSceneDraft';
import {
createPlacedMapAsset,
DEFAULT_MAP_SELECTION,
DEFAULT_PHYSICAL_MAP_CONFIG,
PHYSICAL_MAP_PRESET_LABELS,
mapSelectionTransform,
updatePlacedMapAsset,
type MapSelection,
type PlacedMapAsset,
type PlacedMapSelection,
type SystemTerrainPreset,
} from '../../map/types';
import { useAppStore } from '../../stores/useAppStore';
import { diagnostic } from './useWorkbenchFeedback';
import type { useMapWorkspaceState } from './useMapWorkspaceState';
import type { useModelLoading } from './useModelLoading';
type MapStatePort = Pick<
ReturnType<typeof useMapWorkspaceState>,
| 'projectMaps'
| 'setProjectMaps'
| 'placedMapAssetsRef'
| 'setPlacedMapAssets'
| 'appliedMapAssetsRef'
| 'activeMapAssetIdRef'
| 'setActiveMapAssetId'
| 'mapSelectionRef'
| 'setMapSelection'
| 'setMapTransformMode'
| 'editorDraftsRef'
| 'setEditorDrafts'
| 'setEditorSessionStates'
| 'setEditorDocument'
| 'setEditorSelection'
| 'setCommittedEditorDocuments'
| 'provisionalMapFilesRef'
| 'setMapCommitState'
>;
type Resources = Pick<
Parameters<typeof useModelLoading>[0],
| 'manifestRef'
| 'adapterRef'
| 'viewerRef'
| 'loadInFlightRef'
| 'urdfModeRef'
| 'agentSceneSessionRef'
>;
type SceneStore = Pick<
ReturnType<typeof useAppStore.getState>,
| 'entries'
| 'selectedEntry'
| 'loading'
| 'setDiagnostic'
| 'setProject'
| 'setSnapshot'
| 'setPaused'
>;
/** 场景实例与提交事务;编辑器通过返回的预览/提交端口接入,禁止反向依赖编辑 Hook。 */
export function useMapScene({
state,
manifestRef,
adapterRef,
viewerRef,
loadInFlightRef,
urdfModeRef,
agentSceneSessionRef,
editorInteractionRef,
projectMaps,
setProjectMaps,
placedMapAssetsRef,
setPlacedMapAssets,
appliedMapAssetsRef,
activeMapAssetIdRef,
setActiveMapAssetId,
mapSelectionRef,
setMapSelection,
setMapTransformMode,
editorDraftsRef,
setEditorDrafts,
setEditorSessionStates,
setEditorDocument,
setEditorSelection,
setCommittedEditorDocuments,
provisionalMapFilesRef,
setMapCommitState,
setRightOpen,
publishExternalSnapshot,
loadEntry,
}: MapStatePort &
Resources & {
state: SceneStore;
editorInteractionRef: RefObject<MapEditorInteractionCallbacks | null>;
setRightOpen(value: boolean): void;
publishExternalSnapshot(): void;
loadEntry: ReturnType<typeof useModelLoading>['loadEntry'];
}) {
const { setPaused } = state;
const previewEditorDocument = useCallback(
(document: EditableMapDocument | null) => {
const selection = mapSelectionRef.current;
viewerRef.current?.setMapEditorDocument(
document,
selection.kind === 'project' ? mapSelectionTransform(selection) : undefined,
);
if (document) {
adapterRef.current.setPaused(true);
setPaused(true);
}
},
[mapSelectionRef, viewerRef, adapterRef, setPaused],
);
const clearEditorDrafts = useCallback(() => {
editorDraftsRef.current = new Map();
setEditorDrafts(new Map());
setEditorSessionStates(new Map());
}, [editorDraftsRef, setEditorDrafts, setEditorSessionStates]);
const readEditorDocument = useCallback(
(selection: MapSelection) => readManifestEditorDocument(manifestRef.current, selection),
[manifestRef],
);
const selectionName = (selection: PlacedMapSelection): string =>
selection.kind === 'builtin'
? PHYSICAL_MAP_PRESET_LABELS[selection.config.preset]
: (projectMaps.find((map) => map.descriptorPath === selection.descriptorPath)?.name ??
selection.descriptorPath.split('/').at(-2) ??
'工程地图');
const uniqueMapAssetName = (
base: string,
assets: readonly PlacedMapAsset[] = placedMapAssetsRef.current,
excludedId?: string,
): string => {
const names = new Set(
assets.filter((asset) => asset.id !== excludedId).map((asset) => asset.name),
);
if (!names.has(base)) return base;
let index = 2;
while (names.has(`${base} ${index}`)) index += 1;
return `${base} ${index}`;
};
const setMapScene = useCallback(
(assets: PlacedMapAsset[], activeId: string | undefined, selection?: MapSelection) => {
const active = activeId ? assets.find((asset) => asset.id === activeId) : undefined;
const nextSelection = selection ?? active?.selection ?? DEFAULT_MAP_SELECTION;
if (activeMapAssetIdRef.current !== active?.id) {
viewerRef.current?.selectParametricMapAsset(null);
viewerRef.current?.selectMapEditorObject(null);
editorInteractionRef.current = null;
}
placedMapAssetsRef.current = assets;
setPlacedMapAssets(assets);
activeMapAssetIdRef.current = active?.id;
setActiveMapAssetId(active?.id);
mapSelectionRef.current = nextSelection;
setMapSelection(nextSelection);
if (nextSelection.kind === 'builtin')
setMapTransformMode((currentMode) => (currentMode === 'scale' ? 'translate' : currentMode));
},
[
activeMapAssetIdRef,
viewerRef,
editorInteractionRef,
placedMapAssetsRef,
setPlacedMapAssets,
setActiveMapAssetId,
mapSelectionRef,
setMapSelection,
setMapTransformMode,
],
);
const focusMapProperties = useCallback(() => {
setRightOpen(true);
}, [setRightOpen]);
const previewVisualMapScene = useCallback(
(assets: readonly PlacedMapAsset[], reload: boolean) => {
const current = manifestRef.current;
const activeViewer = viewerRef.current;
if (!current || !activeViewer) return;
if (
agentSceneSessionRef.current &&
agentSceneSessionRef.current === adapterRef.current.session
) {
agentSceneSessionRef.current.stopAgent('地图编辑使旧计划失效');
agentSceneSessionRef.current.setPaused(true);
publishExternalSnapshot();
return;
}
try {
const visuals = visualMapAssets(current, assets);
if (reload)
void activeViewer.setVisualMaps(visuals).catch((error) => {
console.warn('[MuJoCo] 视觉地图草稿预览失败', error);
});
else activeViewer.setVisualMapTransforms(visuals);
} catch (error) {
console.warn('[MuJoCo] 无法解析视觉地图草稿', error);
}
},
[manifestRef, viewerRef, agentSceneSessionRef, adapterRef, publishExternalSnapshot],
);
const stageMapSelectionDraft = useCallback(
(selection: PlacedMapSelection) => {
if (useAppStore.getState().loading || loadInFlightRef.current) return;
const activeId = activeMapAssetIdRef.current;
const active = activeId
? placedMapAssetsRef.current.find((asset) => asset.id === activeId)
: undefined;
if (!active || active.selection.kind !== selection.kind) return;
if (
active.selection.kind === 'project' &&
selection.kind === 'project' &&
active.selection.descriptorPath !== selection.descriptorPath
)
return;
const assets = updatePlacedMapAsset(placedMapAssetsRef.current, active.id, selection);
setMapScene(assets, active.id, selection);
if (selection.kind === 'project') {
previewVisualMapScene(assets, false);
const committed = readEditorDocument(selection);
setEditorDocument(committed);
previewEditorDocument(editorDraftsRef.current.get(selection.descriptorPath) ?? committed);
}
adapterRef.current.setPaused(true);
useAppStore.getState().setPaused(true);
},
[
loadInFlightRef,
activeMapAssetIdRef,
placedMapAssetsRef,
setMapScene,
previewVisualMapScene,
readEditorDocument,
setEditorDocument,
previewEditorDocument,
editorDraftsRef,
adapterRef,
],
);
const appendMapSelection = (selection: PlacedMapSelection, requestedName?: string): boolean => {
const entry = state.entries.find((candidate) => candidate.path === state.selectedEntry);
if (!entry || state.loading || loadInFlightRef.current) return false;
if (entry.format === 'urdf' && urdfModeRef.current === 'native') {
state.setDiagnostic(
diagnostic('模型编译', new Error('原生 URDF 不能注入地图,请切换为转换模式'), entry.path),
);
return false;
}
const name = uniqueMapAssetName(requestedName ?? selectionName(selection));
const placed = createPlacedMapAsset(selection, name);
const assets = [...placedMapAssetsRef.current, placed];
setMapScene(assets, placed.id, selection);
setEditorSelection({ kind: 'map', mapAssetId: placed.id });
setRightOpen(true);
if (selection.kind === 'project') previewVisualMapScene(assets, true);
setEditorDocument(readEditorDocument(selection));
viewerRef.current?.setMapEditorDocument(null);
adapterRef.current.setPaused(true);
useAppStore.getState().setPaused(true);
return true;
};
const stageMapAssetRemoval = (id: string) => {
const previousAssets = placedMapAssetsRef.current;
const previousActiveId = activeMapAssetIdRef.current;
const nextAssets = previousAssets.filter((asset) => asset.id !== id);
if (nextAssets.length === previousAssets.length) return false;
const nextActive =
previousActiveId === id
? (nextAssets.at(-1) ?? undefined)
: nextAssets.find((asset) => asset.id === previousActiveId);
setMapScene(nextAssets, nextActive?.id, nextActive?.selection);
setEditorSelection(nextActive ? { kind: 'map', mapAssetId: nextActive.id } : null);
setEditorDocument(nextActive ? readEditorDocument(nextActive.selection) : null);
viewerRef.current?.setMapEditorDocument(null);
return true;
};
const removePlacedMapAsset = (id: string) => {
if (state.loading || loadInFlightRef.current) return;
const target = placedMapAssetsRef.current.find((asset) => asset.id === id);
if (!target || !stageMapAssetRemoval(id)) return;
if (target.selection.kind === 'project')
previewVisualMapScene(placedMapAssetsRef.current, true);
adapterRef.current.setPaused(true);
useAppStore.getState().setPaused(true);
};
const performMapSceneCommit = async (
requestedDrafts: ReadonlyMap<string, EditableMapDocument> = editorDraftsRef.current,
): Promise<boolean> => {
const entry = state.entries.find((candidate) => candidate.path === state.selectedEntry);
const current = manifestRef.current;
if (!entry || !current || state.loading || loadInFlightRef.current) return false;
if (entry.format === 'urdf' && urdfModeRef.current === 'native') {
state.setDiagnostic(
diagnostic('模型编译', new Error('原生 URDF 不能注入地图,请切换为转换模式'), entry.path),
);
return false;
}
const submittedDrafts = new Map(
[...requestedDrafts].map(([path, document]) => [path, structuredClone(document)]),
);
let candidate: ProjectManifest;
try {
candidate = materializeEditableMapDrafts(current, submittedDrafts);
const referencedDescriptors = new Set(
placedMapAssetsRef.current.flatMap((asset) =>
asset.selection.kind === 'project' ? [asset.selection.descriptorPath] : [],
),
);
const omittedPaths = new Set<string>();
for (const [descriptorPath, paths] of provisionalMapFilesRef.current)
if (!referencedDescriptors.has(descriptorPath))
for (const path of paths) omittedPaths.add(path);
candidate = omitManifestFiles(candidate, omittedPaths);
if (candidate.totalBytes > DEFAULT_IMPORT_LIMITS.maxTotalBytes)
throw new Error('应用场景草稿后工程总大小超过 512 MiB');
} catch (error) {
state.setDiagnostic(diagnostic('模型编译', error, entry.path));
return false;
}
manifestRef.current = candidate;
const loaded = await loadEntry(entry.path, undefined, placedMapAssetsRef.current);
if (!loaded) {
manifestRef.current = current;
return false;
}
const loadedManifest = manifestRef.current ?? candidate;
const maps = discoverMapEntries(loadedManifest.files);
const committed: ProjectManifest = { ...loadedManifest, maps };
manifestRef.current = committed;
provisionalMapFilesRef.current.clear();
const remainingDrafts = new Map(editorDraftsRef.current);
for (const [path, submitted] of submittedDrafts) {
const latest = remainingDrafts.get(path);
if (latest && JSON.stringify(latest) === JSON.stringify(submitted))
remainingDrafts.delete(path);
}
editorDraftsRef.current = remainingDrafts;
setEditorDrafts(remainingDrafts);
setEditorSessionStates((currentStates) => {
const nextStates = new Map(currentStates);
for (const path of submittedDrafts.keys())
if (!remainingDrafts.has(path)) nextStates.delete(path);
return nextStates;
});
setProjectMaps(maps);
setCommittedEditorDocuments(manifestEditorDocuments(committed));
const committedEditorDocument = readEditorDocument(mapSelectionRef.current);
const remainingEditorDraft =
mapSelectionRef.current.kind === 'project'
? remainingDrafts.get(mapSelectionRef.current.descriptorPath)
: undefined;
setEditorDocument(committedEditorDocument);
previewEditorDocument(remainingEditorDraft ?? committedEditorDocument);
state.setProject(
committed.name,
committed.files.map((file) => ({ path: file.path, size: file.size })),
committed.entries,
entry.path,
);
state.setSnapshot(adapterRef.current.snapshot() ?? undefined);
const selectedMapAssetId = activeMapAssetIdRef.current;
setEditorSelection(selectedMapAssetId ? { kind: 'map', mapAssetId: selectedMapAssetId } : null);
return true;
};
const commitMapScene = async (
requestedDrafts: ReadonlyMap<string, EditableMapDocument> = editorDraftsRef.current,
): Promise<boolean> => {
if (state.loading || loadInFlightRef.current) return false;
setMapCommitState('submitting');
try {
const applied = await performMapSceneCommit(requestedDrafts);
setMapCommitState(applied ? 'idle' : 'failed');
return applied;
} catch (error) {
setMapCommitState('failed');
throw error;
}
};
const discardMapSceneDraft = () => {
if (state.loading || loadInFlightRef.current) return;
setMapCommitState('idle');
editorInteractionRef.current?.onDiscard();
clearEditorDrafts();
const omittedPaths = new Set<string>();
for (const paths of provisionalMapFilesRef.current.values())
for (const path of paths) omittedPaths.add(path);
if (manifestRef.current && omittedPaths.size) {
const restoredManifest = omitManifestFiles(manifestRef.current, omittedPaths);
manifestRef.current = restoredManifest;
provisionalMapFilesRef.current.clear();
setProjectMaps(restoredManifest.maps);
setCommittedEditorDocuments(manifestEditorDocuments(restoredManifest));
state.setProject(
restoredManifest.name,
restoredManifest.files.map((file) => ({ path: file.path, size: file.size })),
restoredManifest.entries,
state.selectedEntry,
);
state.setSnapshot(adapterRef.current.snapshot() ?? undefined);
}
const assets = restoreAppliedMapScene(appliedMapAssetsRef.current);
const active =
assets.find((asset) => asset.id === activeMapAssetIdRef.current) ?? assets.at(-1);
setMapScene(assets, active?.id, active?.selection);
setEditorSelection(active ? { kind: 'map', mapAssetId: active.id } : null);
previewVisualMapScene(assets, true);
setEditorDocument(active ? readEditorDocument(active.selection) : null);
viewerRef.current?.setMapEditorDocument(null);
};
const applyMapSelection = (value: MapSelection) => {
const entry = state.entries.find((candidate) => candidate.path === state.selectedEntry);
if (!entry) return;
const activeId = activeMapAssetIdRef.current;
const active = activeId
? placedMapAssetsRef.current.find((asset) => asset.id === activeId)
: undefined;
if (value.kind === 'none') {
if (!active) return;
removePlacedMapAsset(active.id);
void commitMapScene();
return;
}
if (entry.format === 'urdf' && urdfModeRef.current === 'native') {
state.setDiagnostic(
diagnostic('模型编译', new Error('原生 URDF 不能注入地图,请切换为转换模式'), entry.path),
);
return;
}
if (!active) {
if (appendMapSelection(value)) void commitMapScene();
return;
}
const nextName = uniqueMapAssetName(
selectionName(value),
placedMapAssetsRef.current,
active.id,
);
const nextAssets = updatePlacedMapAsset(placedMapAssetsRef.current, active.id, value, nextName);
setMapScene(nextAssets, active.id, value);
setEditorSelection({ kind: 'map', mapAssetId: active.id });
setEditorDocument(readEditorDocument(value));
viewerRef.current?.setMapEditorDocument(null);
void commitMapScene();
};
const selectTerrainAsset = (preset: SystemTerrainPreset, position?: [number, number, number]) => {
const positionX = position ? Math.round(position[0] * 10) / 10 : 0;
const positionY = position ? Math.round(position[1] * 10) / 10 : 0;
void appendMapSelection(
{ kind: 'builtin', config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset, positionX, positionY } },
PHYSICAL_MAP_PRESET_LABELS[preset],
);
};
const addProjectMapAsset = (descriptorPath: string, position?: [number, number, number]) => {
const map = projectMaps.find((candidate) => candidate.descriptorPath === descriptorPath);
if (!map) return;
appendMapSelection(
{
kind: 'project',
descriptorPath,
positionX: position ? Math.round(position[0] * 10) / 10 : 0,
positionY: position ? Math.round(position[1] * 10) / 10 : 0,
yawDeg: 0,
},
map.name,
);
};
return {
previewEditorDocument,
readEditorDocument,
uniqueMapAssetName,
setMapScene,
focusMapProperties,
stageMapSelectionDraft,
removePlacedMapAsset,
commitMapScene,
discardMapSceneDraft,
applyMapSelection,
selectTerrainAsset,
addProjectMapAsset,
};
}
@@ -0,0 +1,99 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { createEditableObject, type EditableMapDocument } from '../../map/editor/types';
import { DEFAULT_MAP_SELECTION, type PlacedMapAsset } from '../../map/types';
import { useMapWorkspaceState } from './useMapWorkspaceState';
const path = 'maps/scene/map.json';
const asset: PlacedMapAsset = {
id: 'map-1',
name: '地图',
selection: { kind: 'project', descriptorPath: path },
};
const document: EditableMapDocument = {
schemaVersion: 1,
mapId: 'scene',
revision: 1,
objects: [],
spawnPoints: [],
};
describe('地图状态与应用基线分离', () => {
it('保留初始选择、可视化与编辑设置', () => {
const { result } = renderHook(useMapWorkspaceState);
expect(result.current.mapSelection).toBe(DEFAULT_MAP_SELECTION);
expect(result.current.mapSelectionRef.current).toBe(DEFAULT_MAP_SELECTION);
expect(result.current.mapSceneDirty).toBe(false);
expect(result.current.mapCommitState).toBe('idle');
expect(result.current.showVisualMap).toBe(true);
expect(result.current.showMapCollision).toBe(false);
expect(result.current.mapTransformMode).toBe('translate');
expect(result.current.mapSnapping).toBe(true);
});
it('界面 setter 不隐式提交应用基线,也不自动更新事务 ref', () => {
const { result } = renderHook(useMapWorkspaceState);
const refs = [
result.current.placedMapAssetsRef,
result.current.appliedMapAssetsRef,
result.current.editorDraftsRef,
];
act(() => result.current.setPlacedMapAssets([asset]));
expect(result.current.placedMapAssets).toEqual([asset]);
expect(result.current.placedMapAssetsRef.current).toEqual([]);
expect(result.current.appliedMapAssets).toEqual([]);
expect(result.current.appliedMapAssetsRef.current).toEqual([]);
expect([
result.current.placedMapAssetsRef,
result.current.appliedMapAssetsRef,
result.current.editorDraftsRef,
]).toEqual(refs);
expect(result.current.mapSceneDirty).toBe(true);
});
it('场景已覆盖的 authoring 草稿不重复计数,未覆盖时按原 changeCount 计数', () => {
const { result } = renderHook(useMapWorkspaceState);
act(() => {
result.current.setPlacedMapAssets([asset]);
result.current.setEditorDrafts(new Map([[path, document]]));
result.current.setEditorSessionStates(
new Map([[path, { dirty: true, changeCount: 5, canUndo: true, canRedo: false }]]),
);
});
expect(result.current.sceneDraftChangeCount).toBe(1);
expect(result.current.pendingSceneIds).toEqual(['map-1']);
act(() => result.current.setAppliedMapAssets([asset]));
expect(result.current.sceneDraftChangeCount).toBe(5);
expect(result.current.pendingSceneIds).toEqual(['map-1']);
act(() => result.current.setEditorDrafts(new Map()));
expect(result.current.mapSceneDirty).toBe(false);
});
it('活动对象优先使用草稿,跨实例选择不会误用当前文档对象', () => {
const original = { ...document, objects: [createEditableObject('box', 'object')] };
const draft: EditableMapDocument = {
...original,
objects: [{ ...original.objects[0], name: '草稿对象', placementMode: 'locked' }],
};
const { result } = renderHook(useMapWorkspaceState);
act(() => {
result.current.setEditorDocument(original);
result.current.setCommittedEditorDocuments(new Map([[path, original]]));
result.current.setEditorDrafts(new Map([[path, draft]]));
result.current.setMapSelection(asset.selection);
result.current.setActiveMapAssetId(asset.id);
result.current.setEditorSelection({
kind: 'map-object',
mapAssetId: asset.id,
objectId: 'object',
});
});
expect(result.current.sceneEditorDocuments.get(path)).toBe(draft);
expect(result.current.selectedMapObject?.name).toBe('草稿对象');
expect(result.current.activePlacementMode).toBe('locked');
expect(original.objects[0].name).not.toBe('草稿对象');
act(() => result.current.setActiveMapAssetId('other'));
expect(result.current.selectedMapObject).toBeUndefined();
expect(result.current.activePlacementMode).toBe('auto_ground');
});
});
@@ -0,0 +1,151 @@
import { useMemo, useRef, useState } from 'react';
import type { MapEntry } from '../../project/types';
import { DEFAULT_MAP_SELECTION, type MapSelection, type PlacedMapAsset } from '../../map/types';
import type {
EditableMapDocument,
MapEditorSessionState,
MapEditorTransformMode,
MapObjectPlacementMode,
} from '../../map/editor/types';
import { mapEditorDraftPreviewInstances, summarizeMapSceneDraft } from '../../map/mapSceneDraft';
import type { MapAssetDropTarget } from '../components/MapViewportTools';
import type { ProjectResourceTab } from '../components/ProjectSidebar';
import type { EditorSelection } from '../editorSelection';
/** 只持有地图状态/派生视图;ref 的提交时机仍由事务动作显式管理,不自动同步草稿到物理。 */
export function useMapWorkspaceState() {
const editorDraftsRef = useRef<Map<string, EditableMapDocument>>(new Map());
const provisionalMapFilesRef = useRef<Map<string, string[]>>(new Map());
const [mapCommitState, setMapCommitState] = useState<'idle' | 'submitting' | 'failed'>('idle');
const [projectMaps, setProjectMaps] = useState<MapEntry[]>([]),
[editorDocument, setEditorDocument] = useState<EditableMapDocument | null>(null),
[editorDrafts, setEditorDrafts] = useState<Map<string, EditableMapDocument>>(() => new Map()),
[committedEditorDocuments, setCommittedEditorDocuments] = useState<
Map<string, EditableMapDocument>
>(() => new Map()),
[projectSidebarTab, setProjectSidebarTab] = useState<ProjectResourceTab>('assets'),
[editorSelection, setEditorSelection] = useState<EditorSelection | null>(null),
[mapTransformMode, setMapTransformMode] = useState<MapEditorTransformMode>('translate'),
[mapSnapping, setMapSnapping] = useState(true),
[assetPlacementMode, setAssetPlacementMode] = useState<MapObjectPlacementMode>('auto_ground'),
[editorSessionStates, setEditorSessionStates] = useState<Map<string, MapEditorSessionState>>(
() => new Map(),
),
[mapAssetDropTarget, setMapAssetDropTarget] = useState<MapAssetDropTarget>();
const [mapSelection, setMapSelection] = useState<MapSelection>(DEFAULT_MAP_SELECTION),
mapSelectionRef = useRef<MapSelection>(DEFAULT_MAP_SELECTION),
[placedMapAssets, setPlacedMapAssets] = useState<PlacedMapAsset[]>([]),
placedMapAssetsRef = useRef<PlacedMapAsset[]>([]),
[appliedMapAssets, setAppliedMapAssets] = useState<PlacedMapAsset[]>([]),
appliedMapAssetsRef = useRef<PlacedMapAsset[]>([]),
[activeMapAssetId, setActiveMapAssetId] = useState<string>(),
activeMapAssetIdRef = useRef<string | undefined>(undefined),
[showVisualMap, setShowVisualMap] = useState(true),
[showMapCollision, setShowMapCollision] = useState(false);
const mapSceneDraft = useMemo(
() => summarizeMapSceneDraft(placedMapAssets, appliedMapAssets),
[placedMapAssets, appliedMapAssets],
);
const pendingSceneIds = useMemo(() => {
const ids = new Set(mapSceneDraft.changedIds);
for (const asset of placedMapAssets)
if (asset.selection.kind === 'project' && editorDrafts.has(asset.selection.descriptorPath))
ids.add(asset.id);
return [...ids];
}, [mapSceneDraft.changedIds, placedMapAssets, editorDrafts]);
const editorOnlyDraftCount = useMemo(() => {
const changedIds = new Set(mapSceneDraft.changedIds);
const coveredDescriptors = new Set(
placedMapAssets.flatMap((asset) =>
changedIds.has(asset.id) && asset.selection.kind === 'project'
? [asset.selection.descriptorPath]
: [],
),
);
return [...editorDrafts.keys()]
.filter((path) => !coveredDescriptors.has(path))
.reduce(
(count, path) => count + Math.max(1, editorSessionStates.get(path)?.changeCount ?? 1),
0,
);
}, [mapSceneDraft.changedIds, placedMapAssets, editorDrafts, editorSessionStates]);
const sceneDraftChangeCount = mapSceneDraft.changeCount + editorOnlyDraftCount,
mapSceneDirty = sceneDraftChangeCount > 0;
const activeEditorView =
mapSelection.kind === 'project'
? (editorDrafts.get(mapSelection.descriptorPath) ?? editorDocument)
: null;
const selectedMapObject =
editorSelection?.kind === 'map-object' && editorSelection.mapAssetId === activeMapAssetId
? activeEditorView?.objects.find((object) => object.id === editorSelection.objectId)
: undefined;
const mapEditingActive =
Boolean(activeMapAssetId) &&
(editorSelection?.kind === 'map' || editorSelection?.kind === 'map-object');
const activePlacementMode = selectedMapObject?.placementMode ?? assetPlacementMode;
const sceneEditorDocuments = useMemo(
() => new Map([...committedEditorDocuments, ...editorDrafts]),
[committedEditorDocuments, editorDrafts],
);
const mapEditorDraftPreviews = useMemo(
() =>
mapEditorDraftPreviewInstances(
placedMapAssets,
pendingSceneIds,
sceneEditorDocuments,
activeMapAssetId,
),
[placedMapAssets, pendingSceneIds, sceneEditorDocuments, activeMapAssetId],
);
return {
editorDraftsRef,
provisionalMapFilesRef,
mapCommitState,
setMapCommitState,
projectMaps,
setProjectMaps,
editorDocument,
setEditorDocument,
editorDrafts,
setEditorDrafts,
setCommittedEditorDocuments,
projectSidebarTab,
setProjectSidebarTab,
editorSelection,
setEditorSelection,
mapTransformMode,
setMapTransformMode,
mapSnapping,
setMapSnapping,
assetPlacementMode,
setAssetPlacementMode,
setEditorSessionStates,
mapAssetDropTarget,
setMapAssetDropTarget,
mapSelection,
setMapSelection,
mapSelectionRef,
placedMapAssets,
setPlacedMapAssets,
placedMapAssetsRef,
appliedMapAssets,
setAppliedMapAssets,
appliedMapAssetsRef,
activeMapAssetId,
setActiveMapAssetId,
activeMapAssetIdRef,
showVisualMap,
setShowVisualMap,
showMapCollision,
setShowMapCollision,
mapSceneDraft,
pendingSceneIds,
sceneDraftChangeCount,
mapSceneDirty,
selectedMapObject,
mapEditingActive,
activePlacementMode,
sceneEditorDocuments,
mapEditorDraftPreviews,
};
}
@@ -0,0 +1,151 @@
import { act, renderHook } from '@testing-library/react';
import { beforeEach, expect, it, vi } from 'vitest';
import type { MainThreadPhysicsAdapter } from '../../simulation/PhysicsAdapter';
import type { SimulationSession, SimulationSnapshot } from '../../simulation/SimulationSession';
import type { MuJoCoViewer } from '../../viewer/MuJoCoViewer';
import { useAppStore } from '../../stores/useAppStore';
import { useModelLoading } from './useModelLoading';
beforeEach(() => {
useAppStore.setState(useAppStore.getInitialState(), true);
useAppStore.getState().setEntry('old.xml');
});
function fixture() {
const events: string[] = [];
const previous = {} as SimulationSession,
next = {} as SimulationSession;
const snapshot = {
paused: false,
warnings: [],
model: { nbody: 1, njnt: 0, ngeom: 1 },
} as unknown as SimulationSnapshot;
const adapter = {
session: previous,
snapshot: () => snapshot,
load: vi.fn(async () => {
adapter.session = next;
return snapshot;
}),
setPaused: vi.fn(),
setSpeed: vi.fn(),
cachedSupportFiles: () => [],
exportMjcf: () => new TextEncoder().encode('<mujoco/>'),
rollbackRetired: vi.fn(() => {
events.push('rollback');
adapter.session = previous;
}),
releaseRetired: vi.fn(() => {
events.push('release');
}),
};
const viewer = {
attach: vi.fn((session) => {
events.push(session === previous ? 'attach:old' : 'attach:new');
}),
setVisualMaps: vi.fn(async () => {}),
setParametricMapAssets: vi.fn(),
};
const options: Parameters<typeof useModelLoading>[0] = {
manifestRef: {
current: { id: 'p', name: 'p', files: [], entries: [], maps: [], totalBytes: 0 },
},
adapterRef: { current: adapter as unknown as MainThreadPhysicsAdapter },
viewerRef: { current: viewer as unknown as MuJoCoViewer },
viewerReadyRef: { current: null },
loadInFlightRef: { current: false },
robotProfilesRef: { current: new Map() },
urdfModeRef: { current: 'mjcf' },
baseModeRef: { current: 'floating' },
urdfEnhancementsRef: {
current: { addActuators: true, addSensors: true, sensorType: 'camera' },
},
placedMapAssetsRef: { current: [] },
appliedMapAssetsRef: { current: [] },
agentSceneSessionRef: { current: undefined },
lastAgentConfigRef: { current: { seed: 0 } },
entries: [],
disconnect: vi.fn(),
publishNotice: vi.fn(),
setIgnoreJointLimits: vi.fn(),
setControllerStatus: vi.fn(),
setPolicyStatus: vi.fn(),
setImportProgress: vi.fn(),
setMapCommitState: vi.fn(),
setEditorSelection: vi.fn(),
setGeneratedMjcf: vi.fn(),
setGeneratedMjcfPath: vi.fn(),
setAppliedMapAssets: vi.fn(),
setTrainingDeployment: vi.fn(),
setAgentScene: vi.fn(),
setPendingUrdfMounts: vi.fn(),
setPendingUrdfProfile: vi.fn(),
setPendingUrdfPath: vi.fn(),
setUrdfMode: vi.fn(),
setBaseMode: vi.fn(),
};
const hook = renderHook(() => useModelLoading(options));
const load = (signal: AbortSignal) =>
hook.result.current.loadEntry(
'next.xml',
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
signal,
);
return { ...hook, options, adapter, viewer, events, load, snapshot };
}
it('预先取消不获取加载锁、不触发外控断连或编译', async () => {
const f = fixture(),
abort = new AbortController();
abort.abort();
await expect(f.load(abort.signal)).rejects.toThrow();
expect(f.options.loadInFlightRef.current).toBe(false);
expect(f.options.disconnect).not.toHaveBeenCalled();
expect(f.adapter.load).not.toHaveBeenCalled();
});
it('编译返回时已取消:先回滚再 attach 旧会话,最终释放加载锁', async () => {
const f = fixture(),
abort = new AbortController();
f.adapter.load.mockImplementationOnce(async () => {
abort.abort();
return f.snapshot;
});
let loaded: boolean | undefined;
await act(async () => {
loaded = await f.load(abort.signal);
});
expect(loaded).toBe(false);
expect(f.events).toEqual(['rollback', 'attach:old']);
expect(f.adapter.releaseRetired).not.toHaveBeenCalled();
expect(f.options.loadInFlightRef.current).toBe(false);
expect(useAppStore.getState()).toMatchObject({ selectedEntry: 'old.xml', loading: false });
});
it('attach 后的视觉异步阶段取消仍回滚旧会话,不提前 release', async () => {
const f = fixture(),
abort = new AbortController();
f.viewer.setVisualMaps.mockImplementationOnce(async () => {
abort.abort();
});
await act(async () => {
expect(await f.load(abort.signal)).toBe(false);
});
expect(f.events).toEqual(['attach:new', 'rollback', 'attach:old']);
expect(f.options.loadInFlightRef.current).toBe(false);
});
it('旧外控会话加载失败强制保持暂停,不恢复为自动运行', async () => {
const f = fixture();
f.snapshot.controlOwner = 'external';
f.adapter.load.mockRejectedValueOnce(new Error('编译失败'));
await act(async () => {
await f.result.current.loadEntry('next.xml');
});
expect(f.adapter.setPaused).toHaveBeenLastCalledWith(true);
expect(useAppStore.getState().paused).toBe(true);
});
@@ -0,0 +1,439 @@
import { useCallback, type RefObject } from 'react';
import { mobileTrainingRobot, isMobileTrainingTask } from '../../mobile/training';
import type { RobotConfig } from '../../mobile/RobotDescriptor';
import { composeLanguageScene } from '../../mobile/agent/LanguageScene';
import {
composePickPlaceScene,
PICK_PLACE_ROBOT,
type PickPlaceTaskConfig,
} from '../../mobile/agent/PickPlaceScene';
import { builtinRobotEnhancements } from '../../project/builtinRobots';
import { mergeCachedFiles } from '../../project/cachedFiles';
import { convertedCachePath, urdfLinkNames } from '../../project/workspaceManifest';
import type { ProjectManifest, ModelEntry } from '../../project/types';
import type {
MainThreadPhysicsAdapter,
UrdfBaseMode,
UrdfEnhancementOptions,
UrdfLoadMode,
} from '../../simulation/PhysicsAdapter';
import type { SimulationSession } from '../../simulation/SimulationSession';
import type { ControllerStatus } from '../../controller/types';
import type { RLPolicyStatus } from '../../rl/types';
import type { PolicyDeployment } from '../../rl/deployment';
import type { MuJoCoViewer } from '../../viewer/MuJoCoViewer';
import type { PlacedMapAsset } from '../../map/types';
import { visualMapAssets } from '../../map/MapLoader';
import {
clonePlacedMapAssets,
resolveMapSceneLoadAssets,
summarizeMapSceneDraft,
} from '../../map/mapSceneDraft';
import { useAppStore } from '../../stores/useAppStore';
import type { EditorSelection } from '../editorSelection';
import type { ImportProgress } from '../components/WorkspaceOverlays';
import { diagnostic, type useWorkbenchFeedback } from './useWorkbenchFeedback';
/** 模型替换事务;共享资源/ref 由组合根提供,地图只能显式提交。 */
export function useModelLoading({
manifestRef,
adapterRef,
viewerRef,
viewerReadyRef,
loadInFlightRef,
robotProfilesRef,
urdfModeRef,
baseModeRef,
urdfEnhancementsRef,
placedMapAssetsRef,
appliedMapAssetsRef,
agentSceneSessionRef,
lastAgentConfigRef,
selectedEntry,
entries,
pendingUrdfPath,
disconnect,
publishNotice,
setIgnoreJointLimits,
setControllerStatus,
setPolicyStatus,
setImportProgress,
setMapCommitState,
setEditorSelection,
setGeneratedMjcf,
setGeneratedMjcfPath,
setAppliedMapAssets,
setTrainingDeployment,
setAgentScene,
setPendingUrdfMounts,
setPendingUrdfProfile,
setPendingUrdfPath,
setUrdfMode,
setBaseMode,
}: {
manifestRef: RefObject<ProjectManifest | null>;
adapterRef: RefObject<MainThreadPhysicsAdapter>;
viewerRef: RefObject<MuJoCoViewer | null>;
viewerReadyRef: RefObject<Promise<MuJoCoViewer | null> | null>;
loadInFlightRef: RefObject<boolean>;
robotProfilesRef: RefObject<Map<string, string>>;
urdfModeRef: RefObject<UrdfLoadMode>;
baseModeRef: RefObject<UrdfBaseMode>;
urdfEnhancementsRef: RefObject<UrdfEnhancementOptions>;
placedMapAssetsRef: RefObject<PlacedMapAsset[]>;
appliedMapAssetsRef: RefObject<PlacedMapAsset[]>;
agentSceneSessionRef: RefObject<SimulationSession | undefined>;
lastAgentConfigRef: RefObject<PickPlaceTaskConfig>;
selectedEntry?: string;
entries: ModelEntry[];
pendingUrdfPath?: string;
disconnect(reason: string): void;
publishNotice: ReturnType<typeof useWorkbenchFeedback>['publishNotice'];
setIgnoreJointLimits(value: boolean): void;
setControllerStatus(value: ControllerStatus | undefined): void;
setPolicyStatus(value: RLPolicyStatus | undefined): void;
setImportProgress(value: ImportProgress | undefined): void;
setMapCommitState(value: 'idle' | 'submitting' | 'failed'): void;
setEditorSelection(value: EditorSelection | null): void;
setGeneratedMjcf(value: string | undefined): void;
setGeneratedMjcfPath(value: string | undefined): void;
setAppliedMapAssets(value: PlacedMapAsset[]): void;
setTrainingDeployment(value: PolicyDeployment | undefined): void;
setAgentScene(value: boolean): void;
setPendingUrdfMounts(value: string[]): void;
setPendingUrdfProfile(value: string | undefined): void;
setPendingUrdfPath(value: string | undefined): void;
setUrdfMode(value: UrdfLoadMode): void;
setBaseMode(value: UrdfBaseMode): void;
}) {
const loadEntry = useCallback(
async (
path: string,
requestedMode?: UrdfLoadMode,
requestedSceneAssets?: readonly PlacedMapAsset[],
requestedDeployment?: PolicyDeployment,
requestedPolicy?: { data: Uint8Array; path: string },
requestedMobile?: RobotConfig,
requestedTask?: PickPlaceTaskConfig,
signal?: AbortSignal,
) => {
signal?.throwIfAborted();
if (!manifestRef.current || loadInFlightRef.current) return false;
const state = useAppStore.getState();
// 普通重载只使用上次成功应用的基线,只有显式提交入口传入草稿。
const sceneAssets = resolveMapSceneLoadAssets(
appliedMapAssetsRef.current,
requestedSceneAssets,
);
const previousState = useAppStore.getState();
const previousEntry = previousState.selectedEntry;
const previousSnapshot = adapterRef.current.snapshot();
const previousPaused =
previousSnapshot?.controlOwner === 'external' ||
(previousSnapshot?.paused ?? previousState.paused);
disconnect('模型正在重载,请重新连接并授权');
loadInFlightRef.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);
// 事务失败时保留旧源码编辑器,允许用户继续修正。
adapterRef.current.setPaused(true);
state.setPaused(true);
let attachedViewer: MuJoCoViewer | null = null;
let sessionSwapped = false;
try {
const activeViewer = viewerRef.current ?? (await viewerReadyRef.current);
if (!activeViewer) throw new Error('三维视口尚未就绪,请重试');
const selectedProfile = robotProfilesRef.current.get(path);
const mobileRobot =
(requestedTask ? PICK_PLACE_ROBOT : requestedMobile) ??
(selectedProfile && isMobileTrainingTask(selectedProfile)
? mobileTrainingRobot(selectedProfile)
: undefined);
const snapshot = await adapterRef.current.load(manifestRef.current, path, {
urdfMode: requestedMode ?? urdfModeRef.current,
baseMode: requestedTask ? 'floating' : baseModeRef.current,
enhancements: requestedTask
? requestedTask.preset === 'language-v2'
? builtinRobotEnhancements('lekiwi-v1')
: { addActuators: true, addSensors: false, sensorType: 'camera' }
: urdfEnhancementsRef.current,
robotProfileId: mobileRobot
? mobileRobot.recipe === 'lekiwi-v1'
? 'lekiwi-v1'
: undefined
: selectedProfile,
mobileRobot,
configureRobotRuntime: requestedTask ? false : undefined,
sceneComposer: requestedTask
? (xml) =>
requestedTask.preset === 'language-v2'
? composeLanguageScene(xml, true)
: composePickPlaceScene(xml, requestedTask.seed, true, requestedTask.goal)
: undefined,
mapAssets: mobileRobot ? [] : sceneAssets,
map: requestedTask ? { kind: 'none' } : undefined,
trainingDeployment: requestedDeployment,
trainingPolicy: requestedPolicy,
onProgress: ({ value, label }) =>
setImportProgress({
title: '正在准备仿真',
label,
detail: path,
value: 0.4 + value * 0.53,
}),
});
const supportFiles = adapterRef.current.cachedSupportFiles();
setImportProgress({
title: '正在准备仿真',
label: '创建三维场景',
detail: path,
value: 0.94,
});
adapterRef.current.setSpeed(useAppStore.getState().speed);
try {
signal?.throwIfAborted();
activeViewer.attach(adapterRef.current.session);
attachedViewer = activeViewer;
sessionSwapped = true;
} catch (error) {
adapterRef.current.rollbackRetired();
activeViewer.attach(adapterRef.current.session);
throw error;
}
if (supportFiles.length && manifestRef.current) {
manifestRef.current = mergeCachedFiles(manifestRef.current, supportFiles);
state.setProject(
manifestRef.current.name,
manifestRef.current.files.map((file) => ({ path: file.path, size: file.size })),
manifestRef.current.entries,
path,
);
}
state.setSnapshot(snapshot);
setMapCommitState('idle');
state.setSelection(null);
setEditorSelection(null);
state.setPaused(true);
setImportProgress({
title: '正在准备仿真',
label: '加载视觉地图与材质',
detail: path,
value: 0.97,
});
await activeViewer.setVisualMaps([]);
let visualMapWarning: string | undefined;
try {
const assets =
manifestRef.current && !requestedDeployment && !requestedTask
? visualMapAssets(manifestRef.current, placedMapAssetsRef.current)
: [];
await activeViewer.setVisualMaps(assets);
} catch (error) {
visualMapWarning = `视觉地图加载失败:${error instanceof Error ? error.message : String(error)}`;
console.warn('[MuJoCo] 视觉地图加载失败', error);
}
try {
setGeneratedMjcf(new TextDecoder().decode(adapterRef.current.exportMjcf()));
setGeneratedMjcfPath(convertedCachePath(path));
} catch (error) {
console.warn('[MuJoCo] 无法生成源码预览', error);
}
publishNotice({
title:
snapshot.warnings.length || visualMapWarning
? `模型已加载 · ${snapshot.warnings.length + (visualMapWarning ? 1 : 0)} 项兼容调整`
: '模型加载完成',
detail:
[...snapshot.warnings, ...(visualMapWarning ? [visualMapWarning] : [])].join('\n') ||
path,
message: `${snapshot.model.nbody} Bodies · ${snapshot.model.njnt} Joints · ${snapshot.model.ngeom} Geoms`,
category: 'compile',
tone: snapshot.warnings.length || visualMapWarning ? 'warning' : 'success',
});
if (requestedSceneAssets !== undefined) {
const committedMapAssets = clonePlacedMapAssets(sceneAssets);
appliedMapAssetsRef.current = committedMapAssets;
setAppliedMapAssets(committedMapAssets);
}
activeViewer.setParametricMapAssets(
requestedDeployment || requestedTask ? [] : placedMapAssetsRef.current,
summarizeMapSceneDraft(placedMapAssetsRef.current, appliedMapAssetsRef.current)
.changedIds,
);
signal?.throwIfAborted();
adapterRef.current.releaseRetired();
setTrainingDeployment(requestedDeployment);
agentSceneSessionRef.current = requestedTask
? (adapterRef.current.session ?? undefined)
: undefined;
if (requestedTask) lastAgentConfigRef.current = requestedTask;
setAgentScene(Boolean(requestedTask));
sessionSwapped = false;
return true;
} catch (error) {
if (sessionSwapped && attachedViewer) {
try {
adapterRef.current.rollbackRetired();
attachedViewer.attach(adapterRef.current.session);
} catch (rollbackError) {
console.error('[MuJoCo] 无法恢复上一仿真会话', rollbackError);
}
}
state.setDiagnostic(diagnostic('模型编译', error, path));
publishNotice({
title: '模型编译失败',
detail: error instanceof Error ? error.message : String(error),
message: path,
category: 'compile',
tone: 'danger',
});
const retained = adapterRef.current.snapshot();
if (retained && previousEntry) state.setEntry(previousEntry);
adapterRef.current.setPaused(previousPaused);
state.setPaused(previousPaused);
state.setSnapshot(retained ?? undefined);
setControllerStatus(retained?.controller);
setPolicyStatus(retained?.rlPolicy);
return false;
} finally {
loadInFlightRef.current = false;
setImportProgress(undefined);
state.setLoading(false);
}
},
[
manifestRef,
loadInFlightRef,
appliedMapAssetsRef,
adapterRef,
disconnect,
setIgnoreJointLimits,
setControllerStatus,
setPolicyStatus,
setImportProgress,
viewerRef,
viewerReadyRef,
robotProfilesRef,
urdfModeRef,
baseModeRef,
urdfEnhancementsRef,
setMapCommitState,
setEditorSelection,
placedMapAssetsRef,
setGeneratedMjcf,
setGeneratedMjcfPath,
publishNotice,
setAppliedMapAssets,
setTrainingDeployment,
agentSceneSessionRef,
lastAgentConfigRef,
setAgentScene,
],
);
const requestLoadEntry = useCallback(
async (path: string) => {
const entry = manifestRef.current?.entries.find((candidate) => candidate.path === path);
if (entry?.format === 'urdf' && urdfModeRef.current === 'mjcf') {
setPendingUrdfMounts(urdfLinkNames(manifestRef.current, path));
setPendingUrdfProfile(robotProfilesRef.current.get(path));
setPendingUrdfPath(path);
return;
}
await loadEntry(path);
},
[
manifestRef,
urdfModeRef,
setPendingUrdfMounts,
setPendingUrdfProfile,
robotProfilesRef,
setPendingUrdfPath,
loadEntry,
],
);
const confirmUrdfOptions = (options: UrdfEnhancementOptions, profileId?: string) => {
const path = pendingUrdfPath;
if (!path) return;
const choices = robotProfilesRef.current,
previous = choices.get(path),
previousBase = baseModeRef.current;
urdfEnhancementsRef.current = options;
if (profileId) {
robotProfilesRef.current.set(path, profileId);
baseModeRef.current = 'floating';
setBaseMode('floating');
} else robotProfilesRef.current.delete(path);
setPendingUrdfPath(undefined);
setPendingUrdfMounts([]);
void loadEntry(path).then((loaded) => {
if (!loaded && robotProfilesRef.current === choices) {
if (previous) choices.set(path, previous);
else choices.delete(path);
baseModeRef.current = previousBase;
setBaseMode(previousBase);
}
});
};
const skipUrdfOptions = () =>
confirmUrdfOptions({ addActuators: false, addSensors: false, sensorType: 'camera' });
const applyRobotProfile = async (profileId?: string) => {
const path = useAppStore.getState().selectedEntry;
if (!path || loadInFlightRef.current) return;
const choices = robotProfilesRef.current,
previous = choices.get(path),
previousMode = urdfModeRef.current,
previousBase = baseModeRef.current;
if (profileId) {
robotProfilesRef.current.set(path, profileId);
urdfModeRef.current = 'mjcf';
setUrdfMode('mjcf');
baseModeRef.current = 'floating';
setBaseMode('floating');
} else robotProfilesRef.current.delete(path);
if (!(await loadEntry(path)) && robotProfilesRef.current === choices) {
if (previous) robotProfilesRef.current.set(path, previous);
else robotProfilesRef.current.delete(path);
urdfModeRef.current = previousMode;
setUrdfMode(previousMode);
baseModeRef.current = previousBase;
setBaseMode(previousBase);
}
};
const changeUrdfMode = (value: UrdfLoadMode) => {
setUrdfMode(value);
urdfModeRef.current = value;
const entry = entries.find((candidate) => candidate.path === selectedEntry);
if (entry?.format !== 'urdf') return;
if (value === 'mjcf') {
setPendingUrdfMounts(urdfLinkNames(manifestRef.current, entry.path));
setPendingUrdfProfile(robotProfilesRef.current.get(entry.path));
setPendingUrdfPath(entry.path);
} else void loadEntry(entry.path, value);
};
const changeBaseMode = (value: UrdfBaseMode) => {
setBaseMode(value);
baseModeRef.current = value;
const entry = entries.find((candidate) => candidate.path === selectedEntry);
if (entry?.format === 'urdf' && urdfModeRef.current === 'mjcf')
void loadEntry(entry.path, 'mjcf');
};
return {
loadEntry,
requestLoadEntry,
confirmUrdfOptions,
skipUrdfOptions,
applyRobotProfile,
changeUrdfMode,
changeBaseMode,
};
}
@@ -0,0 +1,273 @@
import type { RefObject } from 'react';
import { useAppStore } from '../../stores/useAppStore';
import { DEFAULT_IMPORT_LIMITS } from '../../project/types';
import { normalizeProjectPath } from '../../project/importer';
import { resolvePolicyDeployment, type PolicyDeployment } from '../../rl/deployment';
import type { RLCommand } from '../../rl/types';
import {
mobileTrainingRobot,
mobileSceneSnapshot,
type MobileDeployment,
} from '../../mobile/training';
import { sha256 } from '../../robot/registry';
import type { PlacedMapAsset } from '../../map/types';
import type { TrainingSceneCompiler } from '../../map/trainingMap';
import { diagnostic, type useWorkbenchFeedback } from './useWorkbenchFeedback';
import type { useModelLoading } from './useModelLoading';
type LoadingPort = Pick<
Parameters<typeof useModelLoading>[0],
| 'manifestRef'
| 'adapterRef'
| 'viewerRef'
| 'loadInFlightRef'
| 'agentSceneSessionRef'
| 'setImportProgress'
| 'setPolicyStatus'
| 'setControllerStatus'
>;
export function usePolicyWorkflow({
state,
manifestRef,
adapterRef,
viewerRef,
loadInFlightRef,
agentSceneSessionRef,
policyLoadInFlightRef,
setImportProgress,
setPolicyStatus,
setControllerStatus,
setShowSensorCamera,
setSelectedPolicyPath,
loadEntry,
notify,
mapSceneDirty,
trainingDeployment,
agentScene,
appliedMapAssets,
}: LoadingPort & {
state: Pick<
ReturnType<typeof useAppStore.getState>,
'setLoading' | 'setDiagnostic' | 'setSnapshot' | 'setPaused' | 'setProject'
>;
policyLoadInFlightRef: RefObject<boolean>;
setShowSensorCamera(value: boolean): void;
setSelectedPolicyPath(value: string): void;
loadEntry: ReturnType<typeof useModelLoading>['loadEntry'];
notify: ReturnType<typeof useWorkbenchFeedback>['notify'];
mapSceneDirty: boolean;
trainingDeployment?: PolicyDeployment;
agentScene: boolean;
appliedMapAssets: PlacedMapAsset[];
}) {
const loadPolicyBytes = async (data: Uint8Array, path: string, expected?: PolicyDeployment) => {
if (policyLoadInFlightRef.current || loadInFlightRef.current)
throw new Error('模型/策略正在加载,请稍后重试');
viewerRef.current?.setNavigationTargetMode(false);
const previousPaused = useAppStore.getState().paused;
const previousSession = adapterRef.current.session;
policyLoadInFlightRef.current = true;
state.setLoading(true);
state.setDiagnostic(undefined);
adapterRef.current.setPaused(true);
state.setPaused(true);
try {
const deployment = resolvePolicyDeployment(data, expected);
if (deployment?.terrain) {
const entry = useAppStore.getState().selectedEntry;
if (!entry || !manifestRef.current) throw new Error('请先导入并加载Go2机器人');
if (!(await loadEntry(entry, 'mjcf', undefined, deployment, { data, path })))
throw new Error(
useAppStore.getState().diagnostic?.detail ?? '配套训练地图加载失败,策略未启用',
);
}
setImportProgress({
title: '正在加载强化学习策略',
label: '初始化 ONNX Runtime',
detail: path,
value: 0.55,
});
state.setLoading(true);
const session = adapterRef.current.session;
const status = deployment?.terrain
? adapterRef.current.snapshot()!.rlPolicy!
: await adapterRef.current.loadRLPolicy(data, path, deployment);
if (session !== adapterRef.current.session) throw new Error('模型已切换,策略加载取消');
if (deployment?.terrain) {
adapterRef.current.setPaused(false);
state.setPaused(false);
setShowSensorCamera(true);
viewerRef.current?.setShowSensorCamera(true);
}
setPolicyStatus(adapterRef.current.snapshot()?.rlPolicy ?? status);
state.setSnapshot(adapterRef.current.snapshot() ?? undefined);
notify(
'ONNX 策略已加载',
`${status.taskName} · ${status.observationSize} → ${status.actionSize}`,
);
} catch (error) {
if (adapterRef.current.session === previousSession) {
adapterRef.current.setPaused(previousPaused);
state.setPaused(previousPaused);
state.setSnapshot(adapterRef.current.snapshot() ?? undefined);
setPolicyStatus(adapterRef.current.snapshot()?.rlPolicy);
}
state.setDiagnostic(diagnostic('仿真', error, path));
throw error;
} finally {
policyLoadInFlightRef.current = false;
setImportProgress(undefined);
state.setLoading(false);
}
};
const loadPolicyPath = (path: string) => {
const file = manifestRef.current?.files.find((candidate) => candidate.path === path);
if (!file) {
state.setDiagnostic(diagnostic('仿真', new Error('工程中找不到 ONNX 策略'), path));
return;
}
setSelectedPolicyPath(path);
void loadPolicyBytes(file.data, path).catch(() => {});
};
const prepareMobileScene = async (taskId: string) => {
if (loadInFlightRef.current || policyLoadInFlightRef.current)
throw new Error('模型/策略正在加载');
const config = mobileTrainingRobot(taskId);
const entry = useAppStore.getState().selectedEntry;
if (!entry || !manifestRef.current) throw new Error('请先导入对应机器人 URDF/ZIP');
if (
agentSceneSessionRef.current === adapterRef.current.session ||
adapterRef.current.session?.mobile?.env.config.id !== config.id
) {
if (!(await loadEntry(entry, 'mjcf', undefined, undefined, undefined, config)))
throw new Error(useAppStore.getState().diagnostic?.detail ?? '移动操作场景加载失败');
}
return { config, entry };
};
const importMobilePolicy = async (file: File, deployment: MobileDeployment) => {
if (deployment.robotId !== mobileTrainingRobot(deployment.trainingTaskId).id)
throw new Error('部署变体不匹配');
await prepareMobileScene(deployment.trainingTaskId);
const session = adapterRef.current.session!;
policyLoadInFlightRef.current = true;
state.setLoading(true);
const previousPaused = session.paused;
session.setPaused(true);
try {
if (deployment.sceneSha256 !== (await sha256(adapterRef.current.exportMjcf())))
throw new Error('当前场景与训练场景 SHA-256 不匹配,请恢复训练时的模型');
if (file.size > 64 * 1024 ** 2) throw new Error('策略超过 64 MiB');
const data = new Uint8Array(await file.arrayBuffer());
if (session !== adapterRef.current.session) throw new Error('场景已切换');
await session.loadMobilePolicy(data, file.name, deployment);
if (session !== adapterRef.current.session) throw new Error('场景已切换');
session.setPaused(false);
state.setPaused(false);
setPolicyStatus(session.snapshot().rlPolicy);
state.setSnapshot(session.snapshot());
notify('移动操作策略已导入并运行', deployment.trainingTaskId);
} catch (error) {
if (session === adapterRef.current.session) {
session.setPaused(previousPaused);
state.setPaused(previousPaused);
}
throw error;
} finally {
policyLoadInFlightRef.current = false;
state.setLoading(false);
}
};
const importPolicy = async (file: File, expected?: PolicyDeployment) => {
try {
if (!/\.onnx$/i.test(file.name)) throw new Error('请选择 .onnx 文件');
if (file.size > 64 * 1024 * 1024) throw new Error('ONNX 策略不能超过 64 MiB');
const project = manifestRef.current;
const path = normalizeProjectPath(file.name),
data = new Uint8Array(await file.arrayBuffer());
if (project !== manifestRef.current) throw new Error('工程已切换,策略导入取消');
const projectedBytes =
(project?.files
.filter((item) => item.path !== path)
.reduce((total, item) => total + item.size, 0) ?? 0) + data.byteLength;
if (projectedBytes > DEFAULT_IMPORT_LIMITS.maxTotalBytes)
throw new Error('加入 ONNX 后工程总大小超过 512 MiB');
await loadPolicyBytes(data, path, expected);
if (project && manifestRef.current?.id === project.id) {
const files = manifestRef.current.files.filter((candidate) => candidate.path !== path);
files.push({
path,
data,
size: data.byteLength,
source: 'file',
mimeType: file.type || 'application/octet-stream',
});
const totalBytes = files.reduce((total, item) => total + item.size, 0);
if (totalBytes > DEFAULT_IMPORT_LIMITS.maxTotalBytes)
throw new Error('加入 ONNX 后工程总大小超过 512 MiB');
manifestRef.current = { ...manifestRef.current, files, totalBytes };
state.setProject(
manifestRef.current.name,
files.map(({ path, size }) => ({ path, size })),
manifestRef.current.entries,
useAppStore.getState().selectedEntry,
);
state.setSnapshot(adapterRef.current.snapshot() ?? undefined);
}
setSelectedPolicyPath(path);
} catch (error) {
state.setDiagnostic(diagnostic('仿真', error, file.name));
throw error;
}
};
const togglePolicy = (enabled: boolean) => {
adapterRef.current.setRLPolicyEnabled(enabled);
const snapshot = adapterRef.current.snapshot() ?? undefined;
setPolicyStatus(snapshot?.rlPolicy);
setControllerStatus(snapshot?.controller);
state.setSnapshot(snapshot);
};
const setPolicyCommand = (command: RLCommand) => {
adapterRef.current.setRLCommand(command);
const snapshot = adapterRef.current.snapshot() ?? undefined;
setPolicyStatus(snapshot?.rlPolicy);
state.setSnapshot(snapshot);
};
const removePolicy = () => {
adapterRef.current.removeRLPolicy();
setPolicyStatus(undefined);
state.setSnapshot(adapterRef.current.snapshot() ?? undefined);
};
const prepareMobileTrainingScene = async (taskId: string) => {
const { config, entry } = await prepareMobileScene(taskId);
return mobileSceneSnapshot(adapterRef.current, manifestRef.current!, entry, config);
};
const compileTrainingScene: TrainingSceneCompiler = (coordinates) => {
if (
mapSceneDirty ||
trainingDeployment ||
agentScene ||
useAppStore.getState().loading ||
loadInFlightRef.current
)
throw new Error('请先应用地图草稿;训练部署/加载中的场景不能同步');
return adapterRef.current.exportTrainingTerrain(appliedMapAssets, coordinates);
};
const resetNavigationTarget = () => {
viewerRef.current?.setNavigationTargetMode(false);
adapterRef.current.resetNavigationTarget();
const snapshot = adapterRef.current.snapshot();
state.setSnapshot(snapshot ?? undefined);
setPolicyStatus(snapshot?.rlPolicy);
};
return {
loadPolicyPath,
importPolicy,
importMobilePolicy,
prepareMobileTrainingScene,
compileTrainingScene,
togglePolicy,
setPolicyCommand,
removePolicy,
resetNavigationTarget,
};
}
@@ -0,0 +1,364 @@
import { useCallback, useEffect, type ChangeEvent, type RefObject } from 'react';
import { DEFAULT_IMPORT_LIMITS } from '../../project/types';
import { importBrowserFiles, ProjectImportError } from '../../project/importer';
import {
builtinRobotFromSearch,
builtinRobotEnhancements,
downloadBuiltinRobot,
type BuiltinRobot,
} from '../../project/builtinRobots';
import { manifestEditorDocuments } from '../../project/workspaceManifest';
import { MainThreadPhysicsAdapter } from '../../simulation/PhysicsAdapter';
import { DEFAULT_MAP_SELECTION } from '../../map/types';
import { useAppStore } from '../../stores/useAppStore';
import { diagnostic } from './useWorkbenchFeedback';
import type { useModelLoading } from './useModelLoading';
import type { useMapWorkspaceState } from './useMapWorkspaceState';
type LoadingPort = Pick<
Parameters<typeof useModelLoading>[0],
| 'manifestRef'
| 'adapterRef'
| 'viewerRef'
| 'robotProfilesRef'
| 'urdfModeRef'
| 'baseModeRef'
| 'urdfEnhancementsRef'
| 'disconnect'
| 'publishNotice'
| 'setImportProgress'
| 'setGeneratedMjcf'
| 'setGeneratedMjcfPath'
| 'setPendingUrdfPath'
| 'setPendingUrdfMounts'
| 'setControllerStatus'
| 'setPolicyStatus'
| 'setTrainingDeployment'
| 'setUrdfMode'
| 'setBaseMode'
>;
type MapPort = Pick<
ReturnType<typeof useMapWorkspaceState>,
| 'setProjectMaps'
| 'setCommittedEditorDocuments'
| 'setProjectSidebarTab'
| 'setEditorSelection'
| 'setEditorDocument'
| 'editorDraftsRef'
| 'setEditorDrafts'
| 'provisionalMapFilesRef'
| 'placedMapAssetsRef'
| 'setPlacedMapAssets'
| 'appliedMapAssetsRef'
| 'setAppliedMapAssets'
| 'activeMapAssetIdRef'
| 'setActiveMapAssetId'
| 'mapSelectionRef'
| 'setMapSelection'
| 'setMapCommitState'
| 'setEditorSessionStates'
>;
/** 只接收导入/关闭需要的状态端口;不创建第二份清单、adapter 或地图基线。 */
export function useProjectImport({
manifestRef,
adapterRef,
viewerRef,
importInFlightRef,
robotProfilesRef,
urdfModeRef,
baseModeRef,
urdfEnhancementsRef,
disconnect,
publishNotice,
setImportProgress,
setGeneratedMjcf,
setGeneratedMjcfPath,
setPendingUrdfPath,
setPendingUrdfMounts,
setSelectedControllerPath,
setControllerStatus,
setSelectedPolicyPath,
setPolicyStatus,
setTrainingDeployment,
setUrdfMode,
setBaseMode,
setProjectMaps,
setCommittedEditorDocuments,
setProjectSidebarTab,
setEditorSelection,
setEditorDocument,
editorDraftsRef,
setEditorDrafts,
provisionalMapFilesRef,
placedMapAssetsRef,
setPlacedMapAssets,
appliedMapAssetsRef,
setAppliedMapAssets,
activeMapAssetIdRef,
setActiveMapAssetId,
mapSelectionRef,
setMapSelection,
setMapCommitState,
setEditorSessionStates,
projectName,
setRemoveConfirmOpen,
loadEntry,
requestLoadEntry,
}: LoadingPort &
MapPort & {
importInFlightRef: RefObject<boolean>;
projectName?: string;
setRemoveConfirmOpen(value: boolean): void;
setSelectedControllerPath(value: string | undefined): void;
setSelectedPolicyPath(value: string | undefined): void;
loadEntry: ReturnType<typeof useModelLoading>['loadEntry'];
requestLoadEntry: ReturnType<typeof useModelLoading>['requestLoadEntry'];
}) {
const ingest = useCallback(
async (files: File[], lockOwned = false, builtin?: BuiltinRobot, signal?: AbortSignal) => {
if (importInFlightRef.current && !lockOwned) return;
const state = useAppStore.getState();
importInFlightRef.current = true;
disconnect('正在导入工程,请重新连接并授权');
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 });
},
);
signal?.throwIfAborted();
if (builtin) {
if (!next.entries.some((entry) => entry.path === builtin.entryPath))
throw new Error(`内置机器人缺少入口:${builtin.entryPath}`);
next.selectedEntry = builtin.entryPath;
}
setImportProgress({
title: '正在导入工程',
label: '处理模型资源与入口',
detail: `${next.files.length} 个文件`,
value: 0.39,
});
manifestRef.current = next;
robotProfilesRef.current = new Map();
if (builtin) {
robotProfilesRef.current.set(builtin.entryPath, builtin.profileId);
urdfModeRef.current = 'mjcf';
setUrdfMode('mjcf');
baseModeRef.current = 'floating';
setBaseMode('floating');
urdfEnhancementsRef.current = builtinRobotEnhancements(builtin.id);
}
setProjectMaps(next.maps);
setCommittedEditorDocuments(manifestEditorDocuments(next));
setProjectSidebarTab('assets');
setEditorSelection(null);
setEditorDocument(null);
editorDraftsRef.current = new Map();
setEditorDrafts(new Map());
provisionalMapFilesRef.current.clear();
viewerRef.current?.setMapEditorDocument(null);
placedMapAssetsRef.current = [];
setPlacedMapAssets([]);
appliedMapAssetsRef.current = [];
setAppliedMapAssets([]);
viewerRef.current?.setParametricMapAssets([], []);
activeMapAssetIdRef.current = undefined;
setActiveMapAssetId(undefined);
mapSelectionRef.current = DEFAULT_MAP_SELECTION;
setMapSelection(DEFAULT_MAP_SELECTION);
setSelectedControllerPath(next.files.find((file) => /\.py$/i.test(file.path))?.path);
setSelectedPolicyPath(next.files.find((file) => /\.onnx$/i.test(file.path))?.path);
state.setProject(
next.name,
next.files.map(({ path, size }) => ({ path, size })),
next.entries,
next.selectedEntry,
);
publishNotice({
title: '工程导入完成',
message: `${next.files.length} files · ${next.entries.length} entries · ${next.maps.length} maps`,
detail: [
`工程:${next.name}`,
`文件:${next.files.length}`,
`模型入口:${next.entries.length}`,
`地图:${next.maps.length}`,
next.selectedEntry ? `默认入口:${next.selectedEntry}` : '默认入口:未选择',
].join('\n'),
category: 'import',
tone: 'success',
});
if (builtin) await loadEntry(builtin.entryPath);
else if (next.selectedEntry) await requestLoadEntry(next.selectedEntry);
} catch (error) {
if (signal?.aborted) return;
state.setDiagnostic(
diagnostic(
error instanceof ProjectImportError && /ZIP/.test(error.message) ? 'ZIP' : '导入',
error,
error instanceof ProjectImportError ? error.path : undefined,
),
);
publishNotice({
title: '工程导入失败',
detail: error instanceof Error ? error.message : String(error),
message: error instanceof ProjectImportError ? error.path : undefined,
category: 'import',
tone: 'danger',
});
} finally {
importInFlightRef.current = false;
setImportProgress(undefined);
state.setLoading(false);
}
},
[
importInFlightRef,
disconnect,
setImportProgress,
manifestRef,
robotProfilesRef,
urdfModeRef,
setUrdfMode,
baseModeRef,
setBaseMode,
urdfEnhancementsRef,
setProjectMaps,
setCommittedEditorDocuments,
setProjectSidebarTab,
setEditorSelection,
setEditorDocument,
editorDraftsRef,
setEditorDrafts,
provisionalMapFilesRef,
viewerRef,
placedMapAssetsRef,
setPlacedMapAssets,
appliedMapAssetsRef,
setAppliedMapAssets,
activeMapAssetIdRef,
setActiveMapAssetId,
mapSelectionRef,
setMapSelection,
setSelectedControllerPath,
setSelectedPolicyPath,
publishNotice,
loadEntry,
requestLoadEntry,
],
);
useEffect(() => {
const controller = new AbortController();
let ownsImport = false;
const state = useAppStore.getState();
const start = async () => {
// StrictMode 重放时先让出微任务,丢弃的挂载不获取共享锁或下载 ZIP。
await Promise.resolve();
if (controller.signal.aborted) return;
try {
const builtin = builtinRobotFromSearch(window.location.search);
if (!builtin || importInFlightRef.current || manifestRef.current) return;
ownsImport = true;
importInFlightRef.current = true;
state.setLoading(true);
state.setDiagnostic(undefined);
setImportProgress({
title: `正在加载内置机器人 ${builtin.label}`,
label: '下载服务器机器人资源',
detail: '首次加载约 22 MB,下载后自动导入仿真',
value: 0.02,
});
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(120_000)]);
const file = await downloadBuiltinRobot(builtin, signal);
if (!controller.signal.aborted) await ingest([file], true, builtin, controller.signal);
} catch (error) {
if (!controller.signal.aborted)
state.setDiagnostic({
category: '导入',
summary: '内置机器人加载失败',
detail: `${error instanceof Error ? error.message : String(error)}。可刷新页面重试,或移除 robot 参数后手动导入。`,
at: Date.now(),
});
} finally {
if (ownsImport) {
importInFlightRef.current = false;
setImportProgress(undefined);
state.setLoading(false);
}
}
};
void start();
return () => controller.abort();
}, [ingest, importInFlightRef, manifestRef, setImportProgress]);
const removeProject = () => {
if (projectName) setRemoveConfirmOpen(true);
};
const confirmRemoveProject = () => {
const state = useAppStore.getState();
disconnect('工程已关闭');
robotProfilesRef.current = new Map();
setMapCommitState('idle');
void viewerRef.current?.setVisualMaps([]);
viewerRef.current?.attach(null);
adapterRef.current.dispose();
adapterRef.current = new MainThreadPhysicsAdapter();
manifestRef.current = null;
setGeneratedMjcf(undefined);
setGeneratedMjcfPath(undefined);
setPendingUrdfPath(undefined);
setPendingUrdfMounts([]);
setSelectedControllerPath(undefined);
setControllerStatus(undefined);
setSelectedPolicyPath(undefined);
setTrainingDeployment(undefined);
setPolicyStatus(undefined);
setProjectMaps([]);
setCommittedEditorDocuments(new Map());
setProjectSidebarTab('assets');
setEditorSelection(null);
setEditorDocument(null);
editorDraftsRef.current = new Map();
setEditorDrafts(new Map());
setEditorSessionStates(new Map());
provisionalMapFilesRef.current.clear();
viewerRef.current?.setMapEditorDocument(null);
placedMapAssetsRef.current = [];
setPlacedMapAssets([]);
appliedMapAssetsRef.current = [];
setAppliedMapAssets([]);
viewerRef.current?.setParametricMapAssets([], []);
activeMapAssetIdRef.current = undefined;
setActiveMapAssetId(undefined);
mapSelectionRef.current = DEFAULT_MAP_SELECTION;
setMapSelection(DEFAULT_MAP_SELECTION);
state.clearProject();
setRemoveConfirmOpen(false);
};
const changeFiles = (event: ChangeEvent<HTMLInputElement>) => {
void ingest(Array.from(event.target.files ?? []));
event.target.value = '';
};
return { ingest, removeProject, confirmRemoveProject, changeFiles };
}
@@ -0,0 +1,102 @@
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { downloadBytes, readCachedText } from '../../project/cachedFiles';
import type { ProjectManifest } from '../../project/types';
import type { PhysicsAdapter } from '../../simulation/PhysicsAdapter';
import { useProjectSource } from './useProjectSource';
vi.mock('../../project/cachedFiles', async (importOriginal) => ({
...(await importOriginal<typeof import('../../project/cachedFiles')>()),
downloadBytes: vi.fn(),
}));
beforeEach(() => vi.clearAllMocks());
function fixture() {
const text = '<robot name="test"><link name="base"/></robot>',
data = new TextEncoder().encode(text);
const manifest = {
current: {
id: 'p',
name: '测试 工程.zip',
files: [{ path: 'robot.urdf', data, size: data.length, source: 'zip', mimeType: '' }],
entries: [{ path: 'robot.urdf', format: 'urdf', label: 'robot' }],
maps: [],
totalBytes: data.length,
} as ProjectManifest | null,
};
const exported = new TextEncoder().encode('<mujoco><worldbody/></mujoco>');
const backend = {
describeRobot: () => ({ profileId: 'lekiwi-v1' }),
exportMjcf: vi.fn(() => exported),
cachedSupportFiles: () => [],
};
const events: string[] = [];
const options: Parameters<typeof useProjectSource>[0] = {
manifest,
adapter: { current: backend as unknown as PhysicsAdapter },
robotProfiles: { current: new Map() },
selectedEntry: 'robot.urdf',
selectedFormat: 'urdf',
onProjectUpdated: vi.fn(() => {
events.push('project');
}),
onDiagnostic: vi.fn(),
notify: vi.fn(() => {
events.push('notice');
}),
loadEntry: vi.fn(async () => {
events.push('load');
return false;
}),
};
return { options, backend, events, text, exported };
}
describe('工程源码与导出动作', () => {
it('先保留 profile/写缓存/发布清单和通知,再调用加载;编译失败仍保留可修改源码', async () => {
const { options, events } = fixture();
const { result } = renderHook(() => useProjectSource(options));
await act(async () => {
await result.current.saveCachedSource('cache.xml', '<mujoco model="edited"/>');
});
expect(events).toEqual(['project', 'notice', 'load']);
expect(options.robotProfiles.current.get('cache.xml')).toBe('lekiwi-v1');
expect(readCachedText(options.manifest.current!, 'cache.xml')).toBe('<mujoco model="edited"/>');
expect(options.loadEntry).toHaveBeenCalledWith('cache.xml');
});
it('URDF 导出读取当前缓存,切换格式后入口失效;MJCF 保留 XML 资源提示', () => {
const { options, text, exported } = fixture();
const { result, rerender } = renderHook((opts) => useProjectSource(opts), {
initialProps: options,
});
result.current.exportUrdf();
expect(downloadBytes).toHaveBeenCalledWith(new TextEncoder().encode(text), '测试_工程.urdf');
vi.mocked(downloadBytes).mockClear();
rerender({ ...options, selectedFormat: 'mjcf' });
result.current.exportUrdf();
expect(downloadBytes).not.toHaveBeenCalled();
result.current.exportMjcf();
expect(downloadBytes).toHaveBeenCalledWith(exported, '测试_工程.xml');
expect(options.notify).toHaveBeenCalledWith('MJCF 已导出', expect.stringContaining('仅 XML'));
});
it('MJCF/ZIP 导出异常保留不同诊断类别与入口路径', () => {
const { options, backend } = fixture();
backend.exportMjcf.mockImplementation(() => {
throw new Error('导出失败');
});
const { result } = renderHook(() => useProjectSource(options));
result.current.exportMjcf();
result.current.exportProjectPackage();
expect(options.onDiagnostic).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ category: '模型编译', path: 'robot.urdf', detail: '导出失败' }),
);
expect(options.onDiagnostic).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ category: '文件系统', path: 'robot.urdf', detail: '导出失败' }),
);
expect(downloadBytes).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,85 @@
import { useCallback, type RefObject } from 'react';
import { compiledProjectPackage } from '../../project/exportPackage';
import {
downloadBytes,
exportedFileName,
readCachedText,
upsertCachedMjcf,
} from '../../project/cachedFiles';
import type { ModelEntry, ProjectManifest } from '../../project/types';
import type { PhysicsAdapter } from '../../simulation/PhysicsAdapter';
import type { AppDiagnostic } from '../../stores/useAppStore';
import { diagnostic } from './useWorkbenchFeedback';
export function useProjectSource({
manifest: manifestRef,
adapter,
robotProfiles,
selectedEntry,
selectedFormat,
onProjectUpdated,
onDiagnostic,
notify,
loadEntry,
}: {
manifest: RefObject<ProjectManifest | null>;
adapter: RefObject<PhysicsAdapter>;
robotProfiles: RefObject<Map<string, string>>;
selectedEntry?: string;
selectedFormat?: ModelEntry['format'];
onProjectUpdated(project: ProjectManifest, entry: string): void;
onDiagnostic(value: AppDiagnostic): void;
notify(title: string, detail: string): void;
loadEntry(path: string): Promise<boolean>;
}) {
const saveCachedSource = useCallback(
async (path: string, text: string) => {
if (!manifestRef.current) return;
const profileId = adapter.current.describeRobot()?.profileId;
if (profileId) robotProfiles.current.set(path, profileId);
manifestRef.current = upsertCachedMjcf(manifestRef.current, path, text);
onProjectUpdated(manifestRef.current, path);
notify('转换后的 MJCF 已保存到缓存', path);
await loadEntry(path);
},
[manifestRef, adapter, robotProfiles, onProjectUpdated, notify, loadEntry],
);
const exportUrdf = useCallback(() => {
if (!manifestRef.current || selectedFormat !== 'urdf' || !selectedEntry) return;
const text = readCachedText(manifestRef.current, selectedEntry);
downloadBytes(
new TextEncoder().encode(text),
exportedFileName(manifestRef.current.name, 'urdf'),
);
notify('URDF 已导出', selectedEntry);
}, [manifestRef, selectedFormat, selectedEntry, notify]);
const exportMjcf = useCallback(() => {
try {
const data = adapter.current.exportMjcf();
downloadBytes(data, exportedFileName(manifestRef.current?.name ?? 'model', 'xml'));
notify('MJCF 已导出', '仅 XML;如需携带原始及派生网格,请使用“导出完整仿真工程 ZIP”');
} catch (error) {
onDiagnostic(diagnostic('模型编译', error, selectedEntry));
}
}, [adapter, manifestRef, notify, onDiagnostic, selectedEntry]);
const exportProjectPackage = useCallback(() => {
if (!manifestRef.current || !selectedEntry) return;
try {
const result = compiledProjectPackage(
manifestRef.current,
selectedEntry,
adapter.current.exportMjcf(),
adapter.current.cachedSupportFiles(),
);
downloadBytes(
result.data,
exportedFileName(manifestRef.current.name, 'zip'),
'application/zip',
);
notify('完整仿真工程已导出', `包含原始资源、派生网格及 ${result.entryPath}`);
} catch (error) {
onDiagnostic(diagnostic('文件系统', error, selectedEntry));
}
}, [manifestRef, selectedEntry, adapter, notify, onDiagnostic]);
return { saveCachedSource, exportUrdf, exportMjcf, exportProjectPackage };
}
@@ -0,0 +1,128 @@
import type { useAppStore } from '../../stores/useAppStore';
import { normalizeProjectPath } from '../../project/importer';
import type { ControllerCommand } from '../../controller/types';
import { diagnostic, type useWorkbenchFeedback } from './useWorkbenchFeedback';
import type { useModelLoading } from './useModelLoading';
type LoadingPort = Pick<
Parameters<typeof useModelLoading>[0],
'manifestRef' | 'adapterRef' | 'setImportProgress' | 'setControllerStatus' | 'setPolicyStatus'
>;
export function usePythonController({
state,
manifestRef,
adapterRef,
setImportProgress,
setControllerStatus,
setPolicyStatus,
selectedControllerPath,
setSelectedControllerPath,
notify,
}: LoadingPort & {
state: Pick<
ReturnType<typeof useAppStore.getState>,
'setLoading' | 'setDiagnostic' | 'setSnapshot' | 'setProject'
>;
selectedControllerPath?: string;
setSelectedControllerPath(value: string): void;
notify: ReturnType<typeof useWorkbenchFeedback>['notify'];
}) {
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 adapterRef.current.loadPythonController(source, path);
setControllerStatus(status);
state.setSnapshot(adapterRef.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 = manifestRef.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 (manifestRef.current) {
const index = manifestRef.current.files.findIndex((candidate) => candidate.path === path),
files = manifestRef.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);
manifestRef.current = {
...manifestRef.current,
files,
totalBytes: files.reduce((total, item) => total + item.size, 0),
};
state.setProject(
manifestRef.current.name,
files.map(({ path: filePath, size }) => ({ path: filePath, size })),
manifestRef.current.entries,
manifestRef.current.selectedEntry,
);
state.setSnapshot(adapterRef.current.snapshot() ?? undefined);
}
setSelectedControllerPath(path);
await loadControllerSource(new TextDecoder().decode(data), path);
} catch (error) {
state.setDiagnostic(diagnostic('仿真', error, file.name));
}
})();
};
const toggleController = (enabled: boolean) => {
adapterRef.current.setControllerEnabled(enabled);
const snapshot = adapterRef.current.snapshot() ?? undefined;
setControllerStatus(snapshot?.controller);
setPolicyStatus(snapshot?.rlPolicy);
state.setSnapshot(snapshot);
};
const sendControllerCommand = (command: ControllerCommand) => {
try {
adapterRef.current.sendControllerCommand(command);
const snapshot = adapterRef.current.snapshot() ?? undefined;
setControllerStatus(snapshot?.controller);
state.setSnapshot(snapshot);
} catch (error) {
state.setDiagnostic(diagnostic('仿真', error, selectedControllerPath));
}
};
const removeController = () => {
adapterRef.current.removeController();
setControllerStatus(undefined);
state.setSnapshot(adapterRef.current.snapshot() ?? undefined);
};
return {
loadControllerPath,
importController,
toggleController,
sendControllerCommand,
removeController,
};
}
@@ -0,0 +1,164 @@
import type { RefObject } from 'react';
import type { MainThreadPhysicsAdapter } from '../../simulation/PhysicsAdapter';
import type { ActuatorParameters, SimulationSession } from '../../simulation/SimulationSession';
import type { ProjectManifest } from '../../project/types';
import type { PickPlaceTaskConfig } from '../../mobile/agent/PickPlaceScene';
import type { DataRecorderConfig } from '../../telemetry/DataRecorder';
import type { InteractionMode } from '../../viewer/MuJoCoViewer';
import type { useAppStore } from '../../stores/useAppStore';
import { downloadBytes } from '../../project/cachedFiles';
import { diagnostic, type useWorkbenchFeedback } from './useWorkbenchFeedback';
/** UI 意图委托,不拥有物理时钟或控制权算法。 */
export function useSimulationActions({
state,
adapterRef,
manifestRef,
agentSceneSessionRef,
lastAgentConfigRef,
prepareAgentScene,
ignoreJointLimits,
setIgnoreJointLimits,
setGeneratedMjcf,
notify,
}: {
state: Pick<
ReturnType<typeof useAppStore.getState>,
| 'paused'
| 'snapshot'
| 'selectedEntry'
| 'setPaused'
| 'setSnapshot'
| 'setSpeed'
| 'setMode'
| 'setDiagnostic'
>;
adapterRef: RefObject<MainThreadPhysicsAdapter>;
manifestRef: RefObject<ProjectManifest | null>;
agentSceneSessionRef: RefObject<SimulationSession | undefined>;
lastAgentConfigRef: RefObject<PickPlaceTaskConfig>;
prepareAgentScene(config: PickPlaceTaskConfig): Promise<SimulationSession>;
ignoreJointLimits: boolean;
setIgnoreJointLimits(value: boolean): void;
setGeneratedMjcf(value: string): void;
notify: ReturnType<typeof useWorkbenchFeedback>['notify'];
}) {
const togglePause = () => {
const value = !state.paused;
state.setPaused(value);
adapterRef.current.setPaused(value);
};
const reset = () => {
if (
agentSceneSessionRef.current &&
agentSceneSessionRef.current === adapterRef.current.session
) {
void prepareAgentScene(lastAgentConfigRef.current).catch(() => undefined);
return;
}
adapterRef.current.setPaused(true);
adapterRef.current.reset();
state.setSnapshot(adapterRef.current.snapshot() ?? undefined);
state.setPaused(true);
};
const singleStep = () => {
adapterRef.current.singleStep();
state.setSnapshot(adapterRef.current.snapshot() ?? undefined);
};
const changeSpeed = (value: number) => {
state.setSpeed(value);
adapterRef.current.setSpeed(value);
};
const jointPoseEditingDisabled = Boolean(
state.snapshot?.robot ||
state.snapshot?.mobileRobotId ||
(state.snapshot?.controlOwner && state.snapshot.controlOwner !== 'manual'),
);
const mode = (value: InteractionMode) => {
if (value === 'joint' && jointPoseEditingDisabled) return;
state.setMode(value);
};
const resetJoints = () => {
adapterRef.current.resetJoints();
state.setPaused(true);
state.setSnapshot(adapterRef.current.snapshot() ?? undefined);
};
const toggleJointLimits = () => {
const next = !ignoreJointLimits;
adapterRef.current.setIgnoreJointLimits(next);
setIgnoreJointLimits(next);
state.setSnapshot(adapterRef.current.snapshot() ?? undefined);
};
const setActuator = (id: number, value: number) => {
adapterRef.current.setActuator(id, value);
state.setSnapshot(adapterRef.current.snapshot() ?? undefined);
};
const setActuatorParameters = (id: number, parameters: ActuatorParameters) => {
if (!adapterRef.current.setActuatorParameters(id, parameters)) return;
state.setSnapshot(adapterRef.current.snapshot() ?? undefined);
try {
setGeneratedMjcf(new TextDecoder().decode(adapterRef.current.exportMjcf()));
} catch (error) {
console.warn('[MuJoCo] 无法刷新驱动器参数源码', error);
}
};
const setJoint = (id: number, value: number) => {
adapterRef.current.setJointPosition(id, value);
state.setPaused(true);
state.setSnapshot(adapterRef.current.snapshot() ?? undefined);
};
const configureDataRecorder = (patch: Partial<DataRecorderConfig>) => {
try {
adapterRef.current.configureDataRecorder(patch);
state.setSnapshot(adapterRef.current.snapshot() ?? undefined);
} catch (error) {
state.setDiagnostic(diagnostic('仿真', error, state.selectedEntry));
}
};
const startDataRecording = () => {
adapterRef.current.startDataRecording();
state.setSnapshot(adapterRef.current.snapshot() ?? undefined);
};
const stopDataRecording = () => {
adapterRef.current.stopDataRecording();
state.setSnapshot(adapterRef.current.snapshot() ?? undefined);
};
const clearDataRecording = () => {
adapterRef.current.clearDataRecording();
state.setSnapshot(adapterRef.current.snapshot() ?? undefined);
};
const exportDataRecording = (format: 'csv' | 'json') => {
try {
const stem =
(manifestRef.current?.name ?? 'simulation')
.replace(/\.(?:zip|xml|urdf)$/i, '')
.replace(/[^\p{L}\p{N}._-]+/gu, '_') || 'simulation';
downloadBytes(
adapterRef.current.exportDataRecording(format),
`${stem}-telemetry.${format}`,
format === 'csv' ? 'text/csv' : 'application/json',
);
notify(`遥测 ${format.toUpperCase()} 已导出`, `${stem}-telemetry.${format}`);
} catch (error) {
state.setDiagnostic(diagnostic('仿真', error, state.selectedEntry));
}
};
return {
togglePause,
reset,
singleStep,
changeSpeed,
jointPoseEditingDisabled,
mode,
resetJoints,
toggleJointLimits,
setActuator,
setActuatorParameters,
setJoint,
configureDataRecorder,
startDataRecording,
stopDataRecording,
clearDataRecording,
exportDataRecording,
};
}
@@ -0,0 +1,66 @@
import { useEffect } from 'react';
import { render } from '@testing-library/react';
import { expect, it, vi } from 'vitest';
import type { MuJoCoViewer } from '../../viewer/MuJoCoViewer';
import { DEFAULT_VIEWER_DISPLAY_OPTIONS } from '../../viewer/displayOptions';
import { useViewerCameraSync, useViewerDisplaySync } from './useViewerLifecycle';
it('设置组不跨越场景 effect,吸附/显示/相机设置保持原值', () => {
const order: string[] = [];
const mark = (name: string) =>
vi.fn(() => {
order.push(name);
});
const viewer = {
setMode: mark('mode'),
setMapEditorTransformMode: mark('transform'),
setMapEditorSnapping: mark('snapping'),
setDisplayOptions: mark('display'),
setMapDisplay: mark('map'),
setShowPerceptionRays: mark('rays'),
setShowSensorCamera: mark('camera'),
setTheme: mark('theme'),
forceScale: 0,
};
const viewerRef = { current: viewer as unknown as MuJoCoViewer };
function Harness({ snapping }: { snapping: boolean }) {
useViewerDisplaySync({
viewerRef,
mode: 'select',
mapTransformMode: 'translate',
mapSnapping: snapping,
forceScale: 50,
displayOptions: DEFAULT_VIEWER_DISPLAY_OPTIONS,
showVisualMap: true,
showMapCollision: false,
});
useEffect(() => {
order.push('scene');
});
useViewerCameraSync({
viewerRef,
showPerceptionRays: true,
showSensorCamera: true,
theme: 'dark',
});
return null;
}
const { rerender } = render(<Harness snapping />);
expect(order).toEqual([
'mode',
'transform',
'snapping',
'display',
'map',
'scene',
'rays',
'camera',
'theme',
]);
expect(viewer.forceScale).toBe(50);
expect(viewer.setMapEditorSnapping).toHaveBeenLastCalledWith(0.1, 5);
order.length = 0;
rerender(<Harness snapping={false} />);
expect(order).toEqual(['snapping', 'scene']);
expect(viewer.setMapEditorSnapping).toHaveBeenLastCalledWith(null, null);
});
@@ -0,0 +1,203 @@
import { useEffect, useRef, type RefObject } from 'react';
import { summarizeMapSceneDraft } from '../../map/mapSceneDraft';
import type { PlacedMapAsset } from '../../map/types';
import type { MapEditorTransformMode } from '../../map/editor/types';
import { MainThreadPhysicsAdapter } from '../../simulation/PhysicsAdapter';
import type { MuJoCoViewer, InteractionMode, ViewerTheme } from '../../viewer/MuJoCoViewer';
import type { ViewerDisplayOptions } from '../../viewer/displayOptions';
type ViewerCallbacks = ConstructorParameters<typeof MuJoCoViewer>[1];
/** 共享资源只由 App 创建;这里负责异步初始化与卸载,不另建渲染或物理时钟。 */
export function useViewerLifecycle({
hostRef,
viewerRef,
readyRef,
adapterRef,
sensorCameraFrameRef,
orientationHostRef,
placedMapAssetsRef,
appliedMapAssetsRef,
settings: {
mode,
forceScale,
displayOptions,
showVisualMap,
showMapCollision,
showSensorCamera,
theme,
},
callbacks,
onInitError,
}: {
hostRef: RefObject<HTMLDivElement | null>;
viewerRef: RefObject<MuJoCoViewer | null>;
readyRef: RefObject<Promise<MuJoCoViewer | null> | null>;
adapterRef: RefObject<MainThreadPhysicsAdapter>;
sensorCameraFrameRef: RefObject<HTMLDivElement | null>;
orientationHostRef: RefObject<HTMLDivElement | null>;
placedMapAssetsRef: RefObject<PlacedMapAsset[]>;
appliedMapAssetsRef: RefObject<PlacedMapAsset[]>;
settings: {
mode: InteractionMode;
forceScale: number;
displayOptions: ViewerDisplayOptions;
showVisualMap: boolean;
showMapCollision: boolean;
showSensorCamera: boolean;
theme: ViewerTheme;
};
callbacks: ViewerCallbacks;
onInitError(error: unknown): void;
}) {
// 与原一次性初始化 effect 一样固定动作;可变交互通过 App 提供的 ref 读取。
const callbacksRef = useRef(callbacks);
const onInitErrorRef = useRef(onInitError);
const settingsRef = useRef({
mode,
forceScale,
displayOptions,
showVisualMap,
showMapCollision,
showSensorCamera,
theme,
});
useEffect(() => {
settingsRef.current = {
mode,
forceScale,
displayOptions,
showVisualMap,
showMapCollision,
showSensorCamera,
theme,
};
}, [mode, forceScale, displayOptions, showVisualMap, showMapCollision, showSensorCamera, theme]);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
let active = true;
const ready = import('../../viewer/MuJoCoViewer')
.then(({ MuJoCoViewer: Viewer }) => {
if (!active) return null;
const next = new Viewer(host, {
...callbacksRef.current,
onSensorCamerasChanged: (cameras, selectedId) => {
if (active) callbacksRef.current.onSensorCamerasChanged?.(cameras, selectedId);
},
});
if (!active) {
next.dispose();
return null;
}
viewerRef.current = next;
const settings = settingsRef.current;
next.setMode(settings.mode);
next.forceScale = settings.forceScale;
next.setDisplayOptions(settings.displayOptions);
next.setMapDisplay(settings.showVisualMap, settings.showMapCollision);
next.setShowSensorCamera(settings.showSensorCamera);
next.setSensorCameraViewportElement(sensorCameraFrameRef.current);
if (orientationHostRef.current) next.setOrientationGizmoHost(orientationHostRef.current);
next.setTheme(settings.theme);
next.setParametricMapAssets(
placedMapAssetsRef.current,
summarizeMapSceneDraft(placedMapAssetsRef.current, appliedMapAssetsRef.current)
.changedIds,
);
return next;
})
.catch((error) => {
if (active) onInitErrorRef.current(error);
return null;
});
readyRef.current = ready;
return () => {
active = false;
if (readyRef.current === ready) readyRef.current = null;
viewerRef.current?.dispose();
viewerRef.current = null;
const retiredAdapter = adapterRef.current;
retiredAdapter.dispose();
if (adapterRef.current === retiredAdapter)
adapterRef.current = new MainThreadPhysicsAdapter();
};
}, [
hostRef,
viewerRef,
readyRef,
adapterRef,
sensorCameraFrameRef,
orientationHostRef,
placedMapAssetsRef,
appliedMapAssetsRef,
]);
}
/** 原场景/agent effect 之前的设置组;两个设置组分开以保留业务 effect 次序。 */
export function useViewerDisplaySync({
viewerRef,
mode,
mapTransformMode,
mapSnapping,
forceScale,
displayOptions,
showVisualMap,
showMapCollision,
}: {
viewerRef: RefObject<MuJoCoViewer | null>;
mode: InteractionMode;
mapTransformMode: MapEditorTransformMode;
mapSnapping: boolean;
forceScale: number;
displayOptions: ViewerDisplayOptions;
showVisualMap: boolean;
showMapCollision: boolean;
}) {
useEffect(() => {
viewerRef.current?.setMode(mode);
}, [viewerRef, mode]);
useEffect(() => {
viewerRef.current?.setMapEditorTransformMode(mapTransformMode);
}, [viewerRef, mapTransformMode]);
useEffect(() => {
viewerRef.current?.setMapEditorSnapping(mapSnapping ? 0.1 : null, mapSnapping ? 5 : null);
}, [viewerRef, mapSnapping]);
useEffect(() => {
if (viewerRef.current) viewerRef.current.forceScale = forceScale;
}, [viewerRef, forceScale]);
useEffect(() => {
viewerRef.current?.setDisplayOptions(displayOptions);
try {
localStorage.setItem('mujoco-platform-display', JSON.stringify(displayOptions));
} catch {
/* 当前会话仍可修改 */
}
}, [viewerRef, displayOptions]);
useEffect(() => {
viewerRef.current?.setMapDisplay(showVisualMap, showMapCollision);
}, [viewerRef, showVisualMap, showMapCollision]);
}
/** 原场景/agent effect 之后的相机与外观设置组。 */
export function useViewerCameraSync({
viewerRef,
showPerceptionRays,
showSensorCamera,
theme,
}: {
viewerRef: RefObject<MuJoCoViewer | null>;
showPerceptionRays: boolean;
showSensorCamera: boolean;
theme: ViewerTheme;
}) {
useEffect(() => {
viewerRef.current?.setShowPerceptionRays(showPerceptionRays);
}, [viewerRef, showPerceptionRays]);
useEffect(() => {
viewerRef.current?.setShowSensorCamera(showSensorCamera);
}, [viewerRef, showSensorCamera]);
useEffect(() => {
viewerRef.current?.setTheme(theme);
}, [viewerRef, theme]);
}
@@ -0,0 +1,45 @@
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useWorkbenchChrome } from './useWorkbenchChrome';
beforeEach(() => localStorage.clear());
describe('工作台界面状态组合', () => {
it('布局预设保持侧栏状态,控制预设打开控制工具', () => {
const root = { current: document.createElement('div') };
const { result } = renderHook(() => useWorkbenchChrome(root));
act(() => result.current.applyLayoutPreset('viewport'));
expect([result.current.leftOpen, result.current.rightOpen]).toEqual([false, false]);
act(() => result.current.applyLayoutPreset('project'));
expect([result.current.leftOpen, result.current.rightOpen]).toEqual([true, false]);
act(() => result.current.applyLayoutPreset('control'));
expect([result.current.leftOpen, result.current.rightOpen]).toEqual([false, true]);
expect(result.current.workspaceTool).toBe('controls');
act(() => result.current.applyLayoutPreset('default'));
expect([result.current.leftOpen, result.current.rightOpen]).toEqual([true, true]);
});
it('显示工具自动打开右栏,沿用传感器相机、力与角度的初始值', () => {
const root = { current: document.createElement('div') };
const { result } = renderHook(() => useWorkbenchChrome(root));
act(() => result.current.applyLayoutPreset('viewport'));
act(() => result.current.showWorkspaceTool('data'));
expect(result.current.workspaceTool).toBe('data');
expect(result.current.rightOpen).toBe(true);
expect(result.current.showSensorCamera).toBe(true);
expect(result.current.forceScale).toBe(50);
expect(result.current.angleUnit).toBe('rad');
});
it('全屏请求失败不冒泡,卸载移除全屏监听', async () => {
const root = { current: document.createElement('div') };
root.current.requestFullscreen = vi.fn(() => Promise.reject(new Error('浏览器拒绝')));
const remove = vi.spyOn(document, 'removeEventListener');
const { result, unmount } = renderHook(() => useWorkbenchChrome(root));
await act(async () => result.current.toggleFullscreen());
expect(root.current.requestFullscreen).toHaveBeenCalledOnce();
unmount();
expect(remove).toHaveBeenCalledWith('fullscreenchange', expect.any(Function));
remove.mockRestore();
});
});
@@ -0,0 +1,113 @@
import { useEffect, useState, type RefObject } from 'react';
import {
DEFAULT_VIEWER_DISPLAY_OPTIONS,
type ViewerDisplayOptions,
} from '../../viewer/displayOptions';
import { dispatchLayoutWidths, type LayoutPreset } from '../components/LayoutSettingsDialog';
import type { WorkspaceTool } from '../components/WorkspaceToolsPanel';
import { useSidebarLayout } from './useSidebarLayout';
import { useThemePreference } from './useThemePreference';
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 };
}
}
/** 仅持有工作台界面状态;不加载模型、不创建 Viewer、不持有物理时钟。 */
export function useWorkbenchChrome(root: RefObject<HTMLDivElement | null>) {
const sidebars = useSidebarLayout();
const { setLeftOpen, setRightOpen } = sidebars;
const [theme, setTheme] = useThemePreference();
const [forceScale, setForceScale] = useState(50),
[helpOpen, setHelpOpen] = useState(false),
[commandOpen, setCommandOpen] = useState(false),
[sourceOpen, setSourceOpen] = useState(false),
[removeConfirmOpen, setRemoveConfirmOpen] = useState(false),
[fullscreen, setFullscreen] = useState(false),
[settingsOpen, setSettingsOpen] = useState(false),
[layoutOpen, setLayoutOpen] = useState(false),
[diagnosticsOpen, setDiagnosticsOpen] = useState(false),
[workspaceTool, setWorkspaceTool] = useState<WorkspaceTool | null>(null),
[displayOptions, setDisplayOptions] = useState<ViewerDisplayOptions>(initialDisplayOptions),
[showSensorCamera, setShowSensorCamera] = useState(true),
[jointAdvanced, setJointAdvanced] = useState(false),
[angleUnit, setAngleUnit] = useState<'rad' | 'deg'>('rad');
useEffect(() => {
const change = () => setFullscreen(document.fullscreenElement === root.current);
document.addEventListener('fullscreenchange', change);
return () => document.removeEventListener('fullscreenchange', change);
}, [root]);
const toggleFullscreen = () => {
if (document.fullscreenElement) void document.exitFullscreen().catch(() => {});
else if (root.current) void root.current.requestFullscreen().catch(() => {});
};
const showWorkspaceTool = (tool: WorkspaceTool) => {
setWorkspaceTool(tool);
setRightOpen(true);
};
const applyLayoutPreset = (preset: LayoutPreset) => {
if (preset === 'viewport') {
setLeftOpen(false);
setRightOpen(false);
dispatchLayoutWidths(288, 288);
} else if (preset === 'project') {
setLeftOpen(true);
setRightOpen(false);
dispatchLayoutWidths(384, 288);
} else if (preset === 'control') {
setLeftOpen(false);
setRightOpen(true);
setWorkspaceTool('controls');
dispatchLayoutWidths(288, 384);
} else {
setLeftOpen(true);
setRightOpen(true);
dispatchLayoutWidths(288, 288);
}
};
return {
...sidebars,
theme,
setTheme,
forceScale,
setForceScale,
helpOpen,
setHelpOpen,
commandOpen,
setCommandOpen,
sourceOpen,
setSourceOpen,
removeConfirmOpen,
setRemoveConfirmOpen,
fullscreen,
toggleFullscreen,
settingsOpen,
setSettingsOpen,
layoutOpen,
setLayoutOpen,
diagnosticsOpen,
setDiagnosticsOpen,
workspaceTool,
setWorkspaceTool,
showWorkspaceTool,
displayOptions,
setDisplayOptions,
showSensorCamera,
setShowSensorCamera,
jointAdvanced,
setJointAdvanced,
angleUnit,
setAngleUnit,
applyLayoutPreset,
};
}
@@ -0,0 +1,65 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { diagnostic, useWorkbenchFeedback } from './useWorkbenchFeedback';
describe('工作台通知所有权', () => {
it('通知倒序封顶 20 条,清空后 ID 不重用,Toast 可独立关闭', () => {
const { result } = renderHook(useWorkbenchFeedback);
act(() => {
for (let i = 1; i <= 25; i++) result.current.notify(`通知${i}`, `详情${i}`);
});
expect(result.current.notifications.map(({ id }) => id)).toEqual(
Array.from({ length: 20 }, (_, i) => 25 - i),
);
expect(result.current.toast).toEqual(result.current.notifications[0]);
act(() => result.current.setToast(undefined));
expect(result.current.toast).toBeUndefined();
expect(result.current.notifications).toHaveLength(20);
act(() => result.current.setNotifications([]));
act(() => result.current.notify('新通知', '详情'));
expect(result.current.toast).toMatchObject({ id: 26, title: '新通知', tone: 'success' });
});
it('编译/导入上下文字段原样保留,发布动作引用稳定', () => {
const { result, rerender } = renderHook(useWorkbenchFeedback);
const publish = result.current.publishNotice,
notify = result.current.notify;
act(() =>
publish({
title: '模型编译失败',
message: 'robot.xml',
detail: '缺少资源',
category: 'compile',
tone: 'danger',
}),
);
expect(result.current.toast).toMatchObject({
id: 1,
title: '模型编译失败',
message: 'robot.xml',
detail: '缺少资源',
category: 'compile',
tone: 'danger',
at: expect.any(Number),
});
rerender();
expect(result.current.publishNotice).toBe(publish);
expect(result.current.notify).toBe(notify);
});
it('诊断保留类别、路径和原错误文本,不重写恢复条件', () => {
const clock = vi.spyOn(Date, 'now').mockReturnValue(123);
try {
expect(diagnostic('模型编译', new Error('原错误'), 'robot.xml')).toEqual({
category: '模型编译',
summary: '模型编译失败',
detail: '原错误',
path: 'robot.xml',
at: 123,
});
expect(diagnostic('导入', '原字符串')).toMatchObject({ detail: '原字符串', path: undefined });
} finally {
clock.mockRestore();
}
});
});
@@ -0,0 +1,34 @@
import { useCallback, useRef, useState } from 'react';
import type { AppDiagnostic } from '../../stores/useAppStore';
import type { WorkbenchNotification } from '../components/NotificationCenter';
export 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() };
}
/** 通知状态唯一所有者;发布函数稳定,供加载事务与用户动作共享。 */
export function useWorkbenchFeedback() {
const notificationId = useRef(0);
const [notifications, setNotifications] = useState<WorkbenchNotification[]>([]);
const [toast, setToast] = useState<WorkbenchNotification>();
const publishNotice = useCallback((input: Omit<WorkbenchNotification, 'id' | 'at'>) => {
const notice: WorkbenchNotification = {
id: ++notificationId.current,
...input,
at: Date.now(),
};
setNotifications((items) => [notice, ...items].slice(0, 20));
setToast(notice);
}, []);
const notify = useCallback(
(title: string, detail: string, tone: WorkbenchNotification['tone'] = 'success') =>
publishNotice({ title, detail, tone }),
[publishNotice],
);
return { notifications, setNotifications, toast, setToast, publishNotice, notify };
}
@@ -0,0 +1,60 @@
import { fireEvent, render } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useMapEditorShortcuts } from './useMapEditorShortcuts';
import { useWorkbenchShortcuts } from './useWorkbenchShortcuts';
function Harness({
onReset,
onTransformMode,
canScale = true,
mapEditing = true,
}: {
onReset(): void;
onTransformMode: Parameters<typeof useMapEditorShortcuts>[0]['onTransformMode'];
canScale?: boolean;
mapEditing?: boolean;
}) {
useMapEditorShortcuts({
enabled: true,
mapEditing,
canScale,
dirty: false,
loading: false,
hasSelection: true,
canDelete: true,
onTransformMode,
onFocusSelection: vi.fn(),
onDeleteSelection: vi.fn(),
onToggleSnapping: vi.fn(),
onSave: vi.fn(),
});
useWorkbenchShortcuts({ onReset, onCommands: vi.fn(), onTogglePause: vi.fn(), onMode: vi.fn() });
return null;
}
describe('全局与地图快捷键的组合顺序', () => {
it.each([true, false])('地图 R 优先消费事件且不重置物理,canScale=%s', (canScale) => {
const onReset = vi.fn(),
onTransformMode = vi.fn();
render(<Harness onReset={onReset} onTransformMode={onTransformMode} canScale={canScale} />);
fireEvent.keyDown(window, { key: 'r', code: 'KeyR' });
expect(onTransformMode.mock.calls).toEqual(canScale ? [['scale']] : []);
expect(onReset).not.toHaveBeenCalled();
});
it('rerender 使用最新动作,离开地图上下文后 R 重置;卸载取消全部监听', () => {
const first = vi.fn(),
next = vi.fn(),
onTransformMode = vi.fn();
const { rerender, unmount } = render(
<Harness onReset={first} onTransformMode={onTransformMode} />,
);
rerender(<Harness onReset={next} onTransformMode={onTransformMode} mapEditing={false} />);
fireEvent.keyDown(window, { key: 'r', code: 'KeyR' });
expect(first).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledOnce();
unmount();
fireEvent.keyDown(window, { key: 'r', code: 'KeyR' });
expect(next).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,45 @@
import { useEffect } from 'react';
import type { InteractionMode } from '../../viewer/MuJoCoViewer';
import { isTextEditingTarget } from '../keyboard';
export function useWorkbenchShortcuts({
onCommands,
onTogglePause,
onReset,
onMode,
}: {
onCommands(): void;
onTogglePause(): void;
onReset(): void;
onMode(value: InteractionMode): void;
}) {
// 和原 App 一样逐次渲染更新监听,保持最新动作闭包及 capture → bubble 优先级。
useEffect(() => {
const key = (event: KeyboardEvent) => {
if (event.defaultPrevented) return;
if (
document.activeElement instanceof HTMLElement &&
document.activeElement.closest('[role="dialog"]')
)
return;
const target = event.target instanceof HTMLElement ? event.target : null;
if (isTextEditingTarget(target)) return;
if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === 'k') {
event.preventDefault();
onCommands();
return;
}
if (target?.closest('button')) return;
if (event.code === 'Space') {
event.preventDefault();
onTogglePause();
}
if (event.key === 'r') onReset();
if (event.key === '1') onMode('select');
if (event.key === '2') onMode('joint');
if (event.key === '3') onMode('force');
};
window.addEventListener('keydown', key);
return () => window.removeEventListener('keydown', key);
});
}
@@ -0,0 +1,124 @@
import { act, renderHook } from '@testing-library/react';
import type { DragEvent } from 'react';
import { expect, it, vi } from 'vitest';
import type { MuJoCoViewer } from '../../viewer/MuJoCoViewer';
import {
encodeMapLibraryDragPayload,
MAP_LIBRARY_DRAG_MIME,
type MapLibraryDragPayload,
} from '../../map/editor/assetCatalog';
import { useWorkspaceDrop } from './useWorkspaceDrop';
function drag(types: string[], payload = '') {
return {
dataTransfer: { types, getData: () => payload, dropEffect: 'none' },
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
clientX: 25,
clientY: 55,
currentTarget: document.createElement('div'),
relatedTarget: null,
} as unknown as DragEvent;
}
function fixture() {
const viewport = document.createElement('main');
vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({ left: 10, top: 20 } as DOMRect);
const point = vi.fn((): [number, number, number] | null => [1, 2, 3]);
const options = {
viewerRef: { current: { mapPlanePoint: point } as unknown as MuJoCoViewer },
viewportShellRef: { current: viewport },
importInFlightRef: { current: false },
setMapAssetDropTarget: vi.fn(),
setImportProgress: vi.fn(),
ingest: vi.fn(async () => {}),
addCertifiedMapAsset: vi.fn(async () => {}),
selectTerrainAsset: vi.fn(),
addProjectMapAsset: vi.fn(),
};
const hook = renderHook(({ loading }) => useWorkspaceDrop({ ...options, loading }), {
initialProps: { loading: false },
});
return { ...hook, options, point };
}
it('嵌套文件拖入维持遮罩至最后一次离开,重置清空深度且 loading 禁止 copy', () => {
const f = fixture(),
event = drag(['Files']);
act(() => {
f.result.current.dragEnter(event);
f.result.current.dragEnter(event);
});
act(() => f.result.current.dragLeave(event));
expect(f.result.current.dragActive).toBe(true);
act(() => f.result.current.dragLeave(event));
expect(f.result.current.dragActive).toBe(false);
act(() => {
f.result.current.dragLeave(event);
f.result.current.dragEnter(event);
});
expect(f.result.current.dragActive).toBe(true);
act(() => f.result.current.resetDragState());
expect(f.result.current.dragActive).toBe(false);
expect(f.options.setMapAssetDropTarget).toHaveBeenLastCalledWith(undefined);
f.rerender({ loading: true });
act(() => {
f.result.current.dragEnter(event);
f.result.current.dragOver(event);
});
expect(f.result.current.dragActive).toBe(false);
expect(event.dataTransfer.dropEffect).toBe('none');
f.rerender({ loading: false });
act(() => f.result.current.dragOver(event));
expect(event.dataTransfer.dropEffect).toBe('copy');
});
it('地图库落点区分视口像素和世界坐标,内部跨元素不清除提示', () => {
const f = fixture(),
event = drag([MAP_LIBRARY_DRAG_MIME]);
act(() => f.result.current.dragEnter(event));
expect(f.options.setMapAssetDropTarget).toHaveBeenLastCalledWith({
left: 15,
top: 35,
position: [1, 2, 3],
});
const child = document.createElement('span');
event.currentTarget.appendChild(child);
act(() => f.result.current.dragLeave({ ...event, relatedTarget: child }));
expect(f.options.setMapAssetDropTarget).toHaveBeenCalledOnce();
act(() => f.result.current.dragLeave(event));
expect(f.options.setMapAssetDropTarget).toHaveBeenLastCalledWith(undefined);
act(() => f.result.current.dragOver(event));
expect(event.dataTransfer.dropEffect).toBe('copy');
f.point.mockReturnValue(null);
act(() => f.result.current.dragOver(event));
expect(event.dataTransfer.dropEffect).toBe('none');
});
it.each<MapLibraryDragPayload>([
{ kind: 'certified', type: 'box', placementMode: 'gravity' },
{ kind: 'terrain', preset: 'rough' },
{ kind: 'project', descriptorPath: 'map.json' },
])('地图库 $kind 拖放委托当前动作并阻止文件分支', (payload) => {
const f = fixture(),
event = drag([MAP_LIBRARY_DRAG_MIME, 'Files'], encodeMapLibraryDragPayload(payload));
act(() => f.result.current.drop(event));
expect(event.stopPropagation).toHaveBeenCalledOnce();
expect(f.options.ingest).not.toHaveBeenCalled();
if (payload.kind === 'certified')
expect(f.options.addCertifiedMapAsset).toHaveBeenCalledWith('box', [1, 2, 3], 'gravity');
else if (payload.kind === 'terrain')
expect(f.options.selectTerrainAsset).toHaveBeenCalledWith('rough', [1, 2, 3]);
else expect(f.options.addProjectMapAsset).toHaveBeenCalledWith('map.json', [1, 2, 3]);
});
it('无有效世界落点时不创建地图,也不回退成文件导入', () => {
const f = fixture();
f.point.mockReturnValue(null);
const event = drag(
[MAP_LIBRARY_DRAG_MIME],
encodeMapLibraryDragPayload({ kind: 'terrain', preset: 'rough' }),
);
act(() => f.result.current.drop(event));
expect(f.options.selectTerrainAsset).not.toHaveBeenCalled();
expect(f.options.ingest).not.toHaveBeenCalled();
});
@@ -0,0 +1,128 @@
import { useRef, useState, type DragEvent, type RefObject } from 'react';
import { filesFromDrop } from '../../project/importer';
import { decodeMapLibraryDragPayload, MAP_LIBRARY_DRAG_MIME } from '../../map/editor/assetCatalog';
import type { EditableMapObjectType, MapObjectPlacementMode } from '../../map/editor/types';
import type { SystemTerrainPreset } from '../../map/types';
import type { MuJoCoViewer } from '../../viewer/MuJoCoViewer';
import { useAppStore } from '../../stores/useAppStore';
import type { MapAssetDropTarget } from '../components/MapViewportTools';
import type { ImportProgress } from '../components/WorkspaceOverlays';
import { diagnostic } from './useWorkbenchFeedback';
function hasTransferType(dataTransfer: DataTransfer, type: string): boolean {
return Array.from(dataTransfer.types).includes(type);
}
/** 拖放手势及 UI 状态;文件扫描必须同步取得句柄后才能进入异步导入。 */
export function useWorkspaceDrop({
loading,
viewerRef,
viewportShellRef,
importInFlightRef,
setMapAssetDropTarget,
setImportProgress,
ingest,
addCertifiedMapAsset,
selectTerrainAsset,
addProjectMapAsset,
}: {
loading: boolean;
viewerRef: RefObject<MuJoCoViewer | null>;
viewportShellRef: RefObject<HTMLElement | null>;
importInFlightRef: RefObject<boolean>;
setMapAssetDropTarget(value: MapAssetDropTarget | undefined): void;
setImportProgress(value: ImportProgress | undefined): void;
ingest(files: File[], lockOwned: boolean): Promise<void>;
addCertifiedMapAsset(
type: EditableMapObjectType,
position: [number, number, number],
placementMode: MapObjectPlacementMode,
): Promise<unknown>;
selectTerrainAsset(preset: SystemTerrainPreset, position: [number, number, number]): void;
addProjectMapAsset(path: string, position: [number, number, number]): void;
}) {
const dragDepth = useRef(0);
const [dragActive, setDragActive] = useState(false);
const resetDragState = () => {
dragDepth.current = 0;
setDragActive(false);
setMapAssetDropTarget(undefined);
};
const dragEnter = (event: DragEvent) => {
if (hasTransferType(event.dataTransfer, MAP_LIBRARY_DRAG_MIME)) {
const bounds = viewportShellRef.current?.getBoundingClientRect();
if (bounds)
setMapAssetDropTarget({
left: event.clientX - bounds.left,
top: event.clientY - bounds.top,
position: viewerRef.current?.mapPlanePoint(event.clientX, event.clientY) ?? null,
});
return;
}
if (hasTransferType(event.dataTransfer, 'Files')) {
dragDepth.current += 1;
if (!loading) setDragActive(true);
}
};
const dragLeave = (event: DragEvent) => {
if (hasTransferType(event.dataTransfer, MAP_LIBRARY_DRAG_MIME)) {
if (!event.currentTarget.contains(event.relatedTarget as Node | null))
setMapAssetDropTarget(undefined);
return;
}
if (!hasTransferType(event.dataTransfer, 'Files')) return;
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setDragActive(false);
};
const dragOver = (event: DragEvent) => {
event.preventDefault();
if (hasTransferType(event.dataTransfer, MAP_LIBRARY_DRAG_MIME)) {
const position = viewerRef.current?.mapPlanePoint(event.clientX, event.clientY) ?? null;
const bounds = viewportShellRef.current?.getBoundingClientRect();
if (bounds)
setMapAssetDropTarget({
left: event.clientX - bounds.left,
top: event.clientY - bounds.top,
position,
});
event.dataTransfer.dropEffect = position ? 'copy' : 'none';
return;
}
if (hasTransferType(event.dataTransfer, 'Files'))
event.dataTransfer.dropEffect = loading ? 'none' : 'copy';
};
const drop = (event: DragEvent) => {
event.preventDefault();
resetDragState();
const mapAsset = decodeMapLibraryDragPayload(event.dataTransfer.getData(MAP_LIBRARY_DRAG_MIME));
if (mapAsset) {
event.stopPropagation();
const position = viewerRef.current?.mapPlanePoint(event.clientX, event.clientY);
if (!position) return;
if (mapAsset.kind === 'certified')
void addCertifiedMapAsset(mapAsset.type, position, mapAsset.placementMode);
else if (mapAsset.kind === 'terrain') selectTerrainAsset(mapAsset.preset, position);
else addProjectMapAsset(mapAsset.descriptorPath, position);
return;
}
if (loading || importInFlightRef.current) return;
// 必须在 drop 用户手势仍有效时读取句柄;Chromium 随后会清空 DataTransfer。
const filesPromise = filesFromDrop(event.dataTransfer.items, event.dataTransfer.files);
const state = useAppStore.getState();
importInFlightRef.current = true;
state.setLoading(true);
setImportProgress({ title: '正在导入工程', label: '扫描拖放的文件与文件夹', value: 0.02 });
void (async () => {
try {
const files = await filesPromise;
await ingest(files, true);
} catch (error) {
importInFlightRef.current = false;
setImportProgress(undefined);
state.setLoading(false);
state.setDiagnostic(diagnostic('导入', error));
}
})();
};
return { dragActive, resetDragState, dragEnter, dragLeave, dragOver, drop };
}
@@ -0,0 +1,99 @@
import { afterEach, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import type { ComponentProps } from 'react';
import type { WorkspaceToolsPanel as Panel } from './components/WorkspaceToolsPanel';
import type { SimulationSnapshot } from '../simulation/SimulationSession';
afterEach(() => {
cleanup();
vi.unstubAllEnvs();
vi.restoreAllMocks();
vi.resetModules();
});
const noop = () => {};
function props(): ComponentProps<typeof Panel> {
return {
active: 'controls',
snapshot: { actuators: [], joints: [] } as unknown as SimulationSnapshot,
loading: false,
ignoreJointLimits: false,
jointAdvanced: false,
angleUnit: 'rad',
forceScale: 50,
controllerPaths: [],
policyPaths: [],
agentTask: <div>language-slot</div>,
externalControl: <div>external-slot</div>,
onResetJoints: noop,
onToggleJointLimits: noop,
onToggleAdvanced: noop,
onToggleAngleUnit: noop,
onActuator: noop,
onActuatorParameters: noop,
onJoint: noop,
onForceScale: noop,
onSelectControllerPath: noop,
onLoadControllerPath: noop,
onImportController: noop,
onToggleController: noop,
onControllerCommand: noop,
onRemoveController: noop,
onSelectPolicyPath: noop,
onLoadPolicyPath: noop,
onImportPolicy: noop,
onTogglePolicy: noop,
onPolicyCommand: noop,
onRemovePolicy: noop,
onDataRecorderConfigure: noop,
onDataRecordingStart: noop,
onDataRecordingStop: noop,
onDataRecordingClear: noop,
onDataRecordingExport: noop,
};
}
it.each(['development', 'website'])(
'%s 的真实配置只隔离训练/外控,不移除 Python、ONNX、语言控制',
async (mode) => {
vi.stubEnv('MODE', mode);
vi.resetModules();
const { WEBSITE_MODE, websiteCapabilities } = await import('./websiteConfig');
expect(WEBSITE_MODE).toBe(mode === 'website');
expect(websiteCapabilities).toEqual({
training: mode !== 'website',
externalControl: mode !== 'website',
subscription: true,
});
const request = vi.spyOn(globalThis, 'fetch');
const { WorkspaceToolsPanel } = await import('./components/WorkspaceToolsPanel');
render(<WorkspaceToolsPanel {...props()} />);
expect(Boolean(screen.queryByRole('button', { name: /强化学习任务/ }))).toBe(
mode !== 'website',
);
expect(Boolean(screen.queryByRole('button', { name: /开源项目 \/ 外部控制/ }))).toBe(
mode !== 'website',
);
for (const name of ['Python 脚本控制', 'ONNX 策略运行', '机器人语言控制']) {
expect(screen.getByRole('button', { name: new RegExp(name) })).toBeVisible();
}
fireEvent.click(screen.getByRole('button', { name: /机器人语言控制/ }));
expect(screen.getByText('language-slot')).toBeVisible();
expect(request).not.toHaveBeenCalled();
},
);
it.each(['development', 'website'])(
'%s 的 URDF 训练选项与普通 profile/相机增强边界',
async (mode) => {
vi.stubEnv('MODE', mode);
vi.resetModules();
const { UrdfImportOptionsDialog } = await import('./components/UrdfImportOptionsDialog');
render(<UrdfImportOptionsDialog open onConfirm={noop} onSkip={noop} />);
expect(screen.queryAllByRole('option', { name: /移动操作训练场景/ })).toHaveLength(
mode === 'website' ? 0 : 2,
);
expect(screen.getByRole('option', { name: '不启用(通用转换)' })).toBeInTheDocument();
expect(screen.getByRole('checkbox', { name: /添加传感器/ })).toBeChecked();
expect(screen.getByRole('button', { name: '转换并加载' })).toBeEnabled();
},
);
@@ -0,0 +1,124 @@
import { createElement, type ComponentProps } from 'react';
import { render } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { CommandPalette } from './components/CommandPalette';
import { WorkbenchCommandPalette } from './workbenchCommands';
vi.mock('./components/CommandPalette', () => ({ CommandPalette: vi.fn(() => null) }));
type Options = Omit<ComponentProps<typeof WorkbenchCommandPalette>, 'open' | 'onClose'>;
function workbenchCommands(options: Options) {
render(createElement(WorkbenchCommandPalette, { ...options, open: true, onClose: vi.fn() }));
return vi.mocked(CommandPalette).mock.calls.at(-1)![0].commands;
}
function options(): Options {
return {
paused: true,
hasSnapshot: false,
loading: false,
hasSource: false,
leftOpen: true,
rightOpen: true,
theme: 'dark',
fullscreen: false,
actions: {
togglePause: vi.fn(),
reset: vi.fn(),
mode: vi.fn(),
resetCamera: vi.fn(),
openSource: vi.fn(),
exportUrdf: vi.fn(),
exportMjcf: vi.fn(),
exportProjectPackage: vi.fn(),
showWorkspaceTool: vi.fn(),
toggleLeft: vi.fn(),
toggleRight: vi.fn(),
toggleTheme: vi.fn(),
toggleFullscreen: vi.fn(),
openHelp: vi.fn(),
},
};
}
describe('工作台命令目录', () => {
it('ID/顺序/快捷键与无模型时的禁用集合不变', () => {
const commands = workbenchCommands(options());
expect(commands.map(({ id }) => id)).toEqual([
'play',
'reset',
'select',
'joint',
'force',
'camera',
'source',
'export-urdf',
'export-mjcf',
'export-project-package',
'workspace-controls',
'workspace-data',
'left',
'right',
'theme',
'fullscreen',
'help',
]);
expect(commands.filter(({ disabled }) => disabled).map(({ id }) => id)).toEqual([
'play',
'reset',
'source',
'export-urdf',
'export-mjcf',
'export-project-package',
'workspace-controls',
'workspace-data',
]);
expect(commands.filter(({ shortcut }) => shortcut).map(({ shortcut }) => shortcut)).toEqual([
'Space',
'R',
'1',
'2',
'3',
]);
});
it('状态标签与工程导出的 loading 限制保持原样', () => {
const commands = workbenchCommands({
...options(),
paused: false,
hasSnapshot: true,
hasSource: true,
selectedFormat: 'urdf',
leftOpen: false,
rightOpen: false,
theme: 'light',
fullscreen: true,
loading: true,
});
expect(commands.filter(({ disabled }) => disabled).map(({ id }) => id)).toEqual([
'export-project-package',
]);
expect(
commands
.filter(({ id }) => ['play', 'left', 'right', 'theme', 'fullscreen'].includes(id))
.map(({ label }) => label),
).toEqual(['暂停仿真', '显示工程面板', '显示右侧面板', '切换到黑夜主题', '退出全屏']);
});
it('每次构造委托当前回调,模式与工具参数不变', () => {
const first = options(),
next = options();
workbenchCommands(first);
// 命令目录渲染只构造动作引用,不执行回调(包括内部持有 ref 的动作)。
for (const action of Object.values(first.actions)) expect(action).not.toHaveBeenCalled();
const commands = workbenchCommands(next);
for (const command of commands) command.run();
expect(first.actions.reset).not.toHaveBeenCalled();
expect(next.actions.reset).toHaveBeenCalledOnce();
expect(next.actions.mode).toHaveBeenNthCalledWith(1, 'select');
expect(next.actions.mode).toHaveBeenNthCalledWith(2, 'joint');
expect(next.actions.mode).toHaveBeenNthCalledWith(3, 'force');
expect(next.actions.showWorkspaceTool).toHaveBeenNthCalledWith(1, 'controls');
expect(next.actions.showWorkspaceTool).toHaveBeenNthCalledWith(2, 'data');
expect(next.actions.openHelp).toHaveBeenCalledOnce();
});
});
+202
View File
@@ -0,0 +1,202 @@
import {
Camera,
ChevronLeft,
ChevronRight,
CircleHelp,
Code2,
Crosshair,
Database,
Download,
Hand,
Maximize,
MousePointer2,
Pause,
Play,
RotateCcw,
SlidersHorizontal,
SunMoon,
} from 'lucide-react';
import type { ModelEntry } from '../project/types';
import type { InteractionMode, ViewerTheme } from '../viewer/MuJoCoViewer';
import { CommandPalette, type WorkbenchCommand } from './components/CommandPalette';
import type { WorkspaceTool } from './components/WorkspaceToolsPanel';
/** 无状态命令目录;通过组件 Props 接收动作,渲染时不执行任何动作。 */
export function WorkbenchCommandPalette({
open,
onClose,
paused,
hasSnapshot,
loading,
hasSource,
selectedFormat,
leftOpen,
rightOpen,
theme,
fullscreen,
actions,
}: {
open: boolean;
onClose(): void;
paused: boolean;
hasSnapshot: boolean;
loading: boolean;
hasSource: boolean;
selectedFormat?: ModelEntry['format'];
leftOpen: boolean;
rightOpen: boolean;
theme: ViewerTheme;
fullscreen: boolean;
actions: {
togglePause(): void;
reset(): void;
mode(value: InteractionMode): void;
resetCamera(): void;
openSource(): void;
exportUrdf(): void;
exportMjcf(): void;
exportProjectPackage(): void;
showWorkspaceTool(tool: WorkspaceTool): void;
toggleLeft(): void;
toggleRight(): void;
toggleTheme(): void;
toggleFullscreen(): void;
openHelp(): void;
};
}) {
const commands: WorkbenchCommand[] = [
{
id: 'play',
label: paused ? '播放仿真' : '暂停仿真',
group: '仿真',
icon: paused ? <Play className="h-4 w-4" /> : <Pause className="h-4 w-4" />,
shortcut: 'Space',
disabled: !hasSnapshot,
run: actions.togglePause,
},
{
id: 'reset',
label: '重置仿真',
group: '仿真',
icon: <RotateCcw className="h-4 w-4" />,
shortcut: 'R',
disabled: !hasSnapshot,
run: actions.reset,
},
{
id: 'select',
label: '切换到选择模式',
group: '视口',
icon: <MousePointer2 className="h-4 w-4" />,
shortcut: '1',
run: () => actions.mode('select'),
},
{
id: 'joint',
label: '切换到关节拖动',
group: '视口',
icon: <Hand className="h-4 w-4" />,
shortcut: '2',
run: () => actions.mode('joint'),
},
{
id: 'force',
label: '切换到外力施加',
group: '视口',
icon: <Crosshair className="h-4 w-4" />,
shortcut: '3',
run: () => actions.mode('force'),
},
{
id: 'camera',
label: '复位相机',
group: '视口',
icon: <Camera className="h-4 w-4" />,
run: actions.resetCamera,
},
{
id: 'source',
label: '查看和修改缓存源代码',
group: '工程',
icon: <Code2 className="h-4 w-4" />,
disabled: !hasSource,
run: actions.openSource,
},
{
id: 'export-urdf',
label: '导出 URDF 文件',
group: '工程',
icon: <Download className="h-4 w-4" />,
disabled: selectedFormat !== 'urdf',
run: actions.exportUrdf,
},
{
id: 'export-mjcf',
label: '导出 MJCF 文件',
group: '工程',
icon: <Download className="h-4 w-4" />,
disabled: !hasSnapshot,
run: actions.exportMjcf,
},
{
id: 'export-project-package',
label: '导出完整仿真工程 ZIP',
group: '工程',
icon: <Download className="h-4 w-4" />,
disabled: !hasSnapshot || loading,
run: actions.exportProjectPackage,
},
{
id: 'workspace-controls',
label: '在右侧打开控制台',
group: '工具',
icon: <SlidersHorizontal className="h-4 w-4" />,
disabled: !hasSnapshot,
run: () => actions.showWorkspaceTool('controls'),
},
{
id: 'workspace-data',
label: '在右侧打开数据录制',
group: '工具',
icon: <Database className="h-4 w-4" />,
disabled: !hasSnapshot,
run: () => actions.showWorkspaceTool('data'),
},
{
id: 'left',
label: leftOpen ? '隐藏工程面板' : '显示工程面板',
group: '布局',
icon: leftOpen ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />,
run: actions.toggleLeft,
},
{
id: 'right',
label: rightOpen ? '隐藏右侧面板' : '显示右侧面板',
group: '布局',
icon: rightOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />,
run: actions.toggleRight,
},
{
id: 'theme',
label: theme === 'dark' ? '切换到白天主题' : '切换到黑夜主题',
group: '外观',
icon: <SunMoon className="h-4 w-4" />,
run: actions.toggleTheme,
},
{
id: 'fullscreen',
label: fullscreen ? '退出全屏' : '进入全屏',
group: '布局',
icon: <Maximize className="h-4 w-4" />,
run: actions.toggleFullscreen,
},
{
id: 'help',
label: '查看快捷键帮助',
group: '帮助',
icon: <CircleHelp className="h-4 w-4" />,
run: actions.openHelp,
},
];
return <CommandPalette open={open} onClose={onClose} commands={commands} />;
}
@@ -12,7 +12,7 @@ function pyodideIndexUrl(): string {
return new URL('pyodide/', document.baseURI).href;
}
export function getPythonRuntime(): Promise<PyodideInterface> {
function getPythonRuntime(): Promise<PyodideInterface> {
pyodidePromise ??= import('pyodide').then(({ loadPyodide }) =>
loadPyodide({ indexURL: pyodideIndexUrl() }),
);
+1 -1
View File
@@ -4,7 +4,7 @@ const TEXT_EXTENSIONS = /\.(?:xml|urdf|txt|obj|mtl|csv|json|yaml|yml)$/i;
const decoder = new TextDecoder('utf-8', { fatal: false });
const encoder = new TextEncoder();
export function isEditableSource(path: string): boolean {
function isEditableSource(path: string): boolean {
return TEXT_EXTENSIONS.test(path);
}
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import { discoverMapEntries } from '../map/MapLoader';
import type { ProjectFile, ProjectManifest } from './types';
import {
convertedCachePath,
urdfLinkNames,
omitManifestFiles,
manifestEditorDocuments,
readManifestEditorDocument,
} from './workspaceManifest';
function file(path: string, text: string): ProjectFile {
const data = new TextEncoder().encode(text);
return { path, data, size: data.byteLength, source: 'zip', mimeType: '' };
}
function project(files: ProjectFile[]): ProjectManifest {
return {
id: 'p',
name: 'p.zip',
files,
entries: [],
maps: discoverMapEntries(files),
totalBytes: files.reduce((sum, f) => sum + f.size, 0),
};
}
describe('工作台清单只读辅助', () => {
it('转换缓存保留原入口目录', () => {
expect(convertedCachePath('robot.urdf')).toBe('.__converted_mjcf_cache__.xml');
expect(convertedCachePath('目录/robot.urdf')).toBe('目录/.__converted_mjcf_cache__.xml');
});
it('URDF 只读取具名的直接 link,保持重复和原顺序;缺失文件不报错', () => {
const manifest = project([
file(
'robot.urdf',
'<robot><link name="B"/><link name="A"/><link name="B"/><link/><joint><link name="忽略"/></joint></robot>',
),
]);
expect(urdfLinkNames(manifest, 'robot.urdf')).toEqual(['B', 'A', 'B']);
expect(urdfLinkNames(null, 'robot.urdf')).toEqual([]);
expect(urdfLinkNames(manifest, undefined)).toEqual([]);
expect(urdfLinkNames(manifest, '其他.urdf')).toEqual([]);
});
it('删除 provisional 文件返回新清单并重算体积,空集合保持身份', () => {
const original = project([file('a.xml', '<mujoco/>'), file('b.txt', 'abc')]);
expect(omitManifestFiles(original, new Set())).toBe(original);
const next = omitManifestFiles(original, new Set(['b.txt']));
expect(next.files).toEqual([original.files[0]]);
expect(next.totalBytes).toBe(original.files[0].size);
expect(original.files).toHaveLength(2);
});
it('复用地图解析器读取 authoring,返回文档而非缓存可变引用', () => {
const document = {
schemaVersion: 1,
mapId: 'scene',
revision: 2,
objects: [],
spawnPoints: [],
};
const manifest = project([
file(
'maps/scene/map.json',
JSON.stringify({
schemaVersion: 2,
id: 'scene',
name: '场景',
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
physics: { source: 'physics/world.xml' },
authoring: { source: 'authoring/map.scene.json' },
spawnPoints: [],
}),
),
file('maps/scene/physics/world.xml', '<mujoco><worldbody/></mujoco>'),
file('maps/scene/authoring/map.scene.json', JSON.stringify(document)),
]);
const selection = { kind: 'project' as const, descriptorPath: 'maps/scene/map.json' };
expect(manifestEditorDocuments(manifest).get(selection.descriptorPath)).toMatchObject(document);
expect(readManifestEditorDocument(manifest, selection)).toMatchObject(document);
expect(readManifestEditorDocument(manifest, selection)).not.toBe(
readManifestEditorDocument(manifest, selection),
);
expect(readManifestEditorDocument(null, selection)).toBeNull();
expect(readManifestEditorDocument(manifest, { kind: 'none' })).toBeNull();
});
});
@@ -0,0 +1,59 @@
import { discoverMapEntries, resolveProjectMap } from '../map/MapLoader';
import { decodeEditableMapDocument } from '../map/editor/editorSchema';
import type { EditableMapDocument } from '../map/editor/types';
import type { MapSelection } from '../map/types';
import type { ProjectManifest } from './types';
export function convertedCachePath(entryPath: string): string {
const slash = entryPath.lastIndexOf('/');
return `${slash >= 0 ? entryPath.slice(0, slash + 1) : ''}.__converted_mjcf_cache__.xml`;
}
export 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 omitManifestFiles(
manifest: ProjectManifest,
omittedPaths: ReadonlySet<string>,
): ProjectManifest {
if (!omittedPaths.size) return manifest;
const files = manifest.files.filter((file) => !omittedPaths.has(file.path));
return {
...manifest,
files,
maps: discoverMapEntries(files),
totalBytes: files.reduce((total, file) => total + file.size, 0),
};
}
export function manifestEditorDocuments(
manifest: ProjectManifest,
): Map<string, EditableMapDocument> {
const documents = new Map<string, EditableMapDocument>();
for (const map of manifest.maps) {
if (!map.authoringPath) continue;
const file = manifest.files.find((candidate) => candidate.path === map.authoringPath);
if (file) documents.set(map.descriptorPath, decodeEditableMapDocument(file.data));
}
return documents;
}
export function readManifestEditorDocument(
manifest: ProjectManifest | null,
selection: MapSelection,
): EditableMapDocument | null {
if (selection.kind !== 'project' || !manifest) return null;
const resolved = resolveProjectMap(manifest, selection.descriptorPath);
if (!resolved.authoringPath) return null;
const file = manifest.files.find((candidate) => candidate.path === resolved.authoringPath);
return file ? decodeEditableMapDocument(file.data) : null;
}
@@ -136,7 +136,7 @@ export interface PhysicsAdapter {
}
let modulePromise: Promise<MainModule> | undefined;
export function getMujocoModule(): Promise<MainModule> {
function getMujocoModule(): Promise<MainModule> {
if (!modulePromise) {
console.info('[MuJoCo] 开始初始化单线程 WASM');
modulePromise = import('@mujoco/mujoco')
@@ -0,0 +1,228 @@
import { describe, expect, it, vi } from 'vitest';
import { ControlArbiter } from './ControlArbiter';
import { SimulationSession } from './SimulationSession';
/** 不创建物理时钟;保留原 snapshot 入口,表征提取前后的只读数值与句柄边界。 */
function fixture() {
const joints = [
{
name: '',
type: 3,
stiffness: 4,
damping: 0.2,
qposadr: 0,
bodyid: 1,
axis: [1, 0, 0],
delete: vi.fn(),
},
{
name: '滑动',
type: 2,
stiffness: 0,
damping: 0,
qposadr: 1,
bodyid: 1,
axis: [0, 0, 1],
delete: vi.fn(),
},
];
const actuators = Array.from({ length: 4 }, (_, id) => ({
name: id === 0 ? '' : `驱动${id}`,
ctrllimited: 1,
ctrlrange: [-2, 3],
forcelimited: 1,
forcerange: [-10, 20],
trntype: 0,
trnid: [id === 2 ? 1 : 0],
gaintype: 0,
dyntype: 0,
biastype: id === 1 || id === 2 ? 1 : 0,
gainprm: [id === 1 ? 5 : id === 2 ? 3 : 2],
biasprm: [0, id === 1 ? -5 : 0, id === 1 ? -2 : id === 2 ? -3 : 0],
gear: [2],
delete: vi.fn(),
}));
const bodies = [
{ name: '', delete: vi.fn() },
{ name: '机器人', delete: vi.fn() },
];
const model = {
nactuator: 4,
nu: 5,
njnt: 2,
nbody: 2,
ngeom: 3,
ncam: 0,
nq: 2,
nv: 2,
actuator_ctrladr: new Int32Array([0, 1, 2, 3]),
body_parentid: new Int32Array([0, 0]),
actuator: (id: number) => actuators[id],
jnt: (id: number) => joints[id],
body: (id: number) => bodies[id],
};
const data = {
time: 0.2,
ctrl: new Float64Array([0.1, 0.2, 0.3, 0.4, 0.5]),
qpos: new Float64Array([0.25, -0.4]),
qvel: new Float64Array([0.1, 0.2]),
};
const jointLimits = [
{ limited: true, min: -0.5, max: 0.7, type: 3 },
{ limited: false, min: 0, max: 0, type: 2 },
];
const controlArbiter = new ControlArbiter();
const module = { mj_step: vi.fn(), mj_forward: vi.fn() };
const session = Object.assign(Object.create(SimulationSession.prototype), {
model,
data,
module,
paused: true,
speed: 1,
warnings: [],
ignoreJointLimits: false,
jointLimits,
controlArbiter,
dataRecorder: { status: () => ({ recording: false }) },
}) as SimulationSession;
return { session, model, data, module, joints, actuators, bodies, jointLimits, controlArbiter };
}
describe('Session 快照的数值与句柄表征', () => {
it('保留四种执行器 kind、多控制通道、单位、名称与参数映射', () => {
const { session } = fixture();
const snapshot = session.snapshot();
expect(snapshot.actuators.map(({ kind }) => kind)).toEqual([
'motor',
'position',
'velocity',
'other',
]);
expect(snapshot.actuators[0]).toStrictEqual({
id: 0,
name: 'actuator_0',
value: 0.1,
min: -2,
max: 3,
limited: true,
jointId: 0,
jointName: 'joint_0',
jointType: 3,
unit: 'N·m',
kind: 'motor',
controlCount: 1,
gear: 2,
gain: 2,
kp: 4,
kv: 0.2,
ctrlLimited: true,
ctrlMin: -2,
ctrlMax: 3,
forceLimited: true,
forceMin: -10,
forceMax: 20,
});
expect(snapshot.actuators[1]).toMatchObject({ unit: '°', kp: 5, kv: 2 });
expect(snapshot.actuators[2]).toMatchObject({ unit: '', kp: 0, kv: 3, jointName: '滑动' });
expect(snapshot.actuators[3]).toMatchObject({ kind: 'other', controlCount: 2, value: 0.4 });
expect(snapshot.bodies).toStrictEqual([
{ id: 0, name: 'body_0', parentId: 0 },
{ id: 1, name: '机器人', parentId: 0 },
]);
});
it('保留位置/速度 kind 的判定阈值与无控制限位时的范围', () => {
const { session, actuators } = fixture();
actuators[0].ctrllimited = 0;
actuators[1].biasprm[1] = -5 + 5e-7;
actuators[2].biasprm[1] = 5e-10;
let snapshot = session.snapshot();
expect(snapshot.actuators[0]).toMatchObject({ min: -100, max: 100, ctrlLimited: false });
expect(snapshot.actuators[1].kind).toBe('position');
expect(snapshot.actuators[2].kind).toBe('velocity');
actuators[1].biasprm[1] = -5 + 2e-6;
actuators[2].biasprm[1] = 1e-9;
snapshot = session.snapshot();
expect(snapshot.actuators[1].kind).toBe('other');
expect(snapshot.actuators[2].kind).toBe('other');
});
it('原始限位、忽略限位后的显示范围和读取顺序不变', () => {
const { session, jointLimits } = fixture();
expect(session.snapshot().joints[0]).toStrictEqual({
id: 0,
name: 'joint_0',
type: 3,
value: 0.25,
min: -0.5,
max: 0.7,
limitMin: -0.5,
limitMax: 0.7,
limited: true,
limitsIgnored: false,
editable: true,
bodyId: 1,
axis: [1, 0, 0],
});
Object.assign(session, { ignoreJointLimits: true });
expect(session.snapshot().joints[0]).toMatchObject({ min: -2 * Math.PI, max: 2 * Math.PI });
expect(session.snapshot().joints[1]).toMatchObject({ min: -1, max: 1 });
Object.assign(jointLimits[1], { limited: true, min: 0, max: 0.1 });
expect(session.snapshot().joints[1]).toMatchObject({ min: -0.25, max: 0.35 });
});
it('非 manual 控制权及配置移动机器人时关节均不可直接编辑', () => {
const { session, controlArbiter } = fixture();
controlArbiter.claim('python');
expect(session.snapshot().joints.every((joint) => !joint.editable)).toBe(true);
controlArbiter.claim('manual');
Object.assign(session, {
mobile: { status: () => undefined, env: { config: { id: '测试机器人' } } },
});
expect(session.snapshot().joints.every((joint) => !joint.editable)).toBe(true);
});
it('每次采集释放全部临时句柄,不步进、不写物理数组,快照复制数组', () => {
const { session, module, data, joints, actuators, bodies } = fixture();
const before = { ctrl: data.ctrl.slice(), qpos: data.qpos.slice(), qvel: data.qvel.slice() };
const snapshot = session.snapshot();
expect(module.mj_step).not.toHaveBeenCalled();
expect(module.mj_forward).not.toHaveBeenCalled();
expect(data.ctrl).toEqual(before.ctrl);
expect(data.qpos).toEqual(before.qpos);
expect(data.qvel).toEqual(before.qvel);
snapshot.ctrl[0] = 999;
snapshot.qpos[0] = 999;
snapshot.qvel[0] = 999;
expect(data.ctrl).toEqual(before.ctrl);
expect(data.qpos).toEqual(before.qpos);
expect(data.qvel).toEqual(before.qvel);
for (const actuator of actuators) expect(actuator.delete).toHaveBeenCalledOnce();
expect(joints[0].delete).toHaveBeenCalledTimes(4);
expect(joints[1].delete).toHaveBeenCalledTimes(2);
for (const body of bodies) expect(body.delete).toHaveBeenCalledOnce();
});
it('嵌套读取关节失败时同时释放关节和执行器句柄', () => {
const { session, joints, actuators } = fixture();
Object.defineProperty(joints[0], 'name', {
get: () => {
throw new Error('关节读取失败');
},
});
expect(() => session.snapshot()).toThrow('关节读取失败');
expect(joints[0].delete).toHaveBeenCalledOnce();
expect(actuators[0].delete).toHaveBeenCalledOnce();
});
it('body 读取失败也释放它的句柄', () => {
const { session, bodies } = fixture();
Object.defineProperty(bodies[0], 'name', {
get: () => {
throw new Error('body 读取失败');
},
});
expect(() => session.snapshot()).toThrow('body 读取失败');
expect(bodies[0].delete).toHaveBeenCalledOnce();
});
});
+28 -235
View File
@@ -40,87 +40,23 @@ import {
type TelemetryChannel,
} from '../telemetry/DataRecorder';
export interface ActuatorParameters {
gear: number;
gain: number;
kp: number;
kv: number;
ctrlLimited: boolean;
ctrlMin: number;
ctrlMax: number;
forceLimited: boolean;
forceMin: number;
forceMax: number;
}
export interface ActuatorInfo extends ActuatorParameters {
id: number;
name: string;
value: number;
min: number;
max: number;
limited: boolean;
jointId?: number;
jointName?: string;
jointType?: number;
unit: string;
kind: 'motor' | 'position' | 'velocity' | 'other';
controlCount: number;
}
export interface JointInfo {
id: number;
name: string;
type: number;
value: number;
min: number;
max: number;
limitMin: number;
limitMax: number;
limited: boolean;
limitsIgnored: boolean;
editable: boolean;
bodyId: number;
axis: [number, number, number];
}
export interface BodyInfo {
id: number;
name: string;
parentId: number;
}
export interface SimulationSnapshot {
time: number;
paused?: boolean;
speed?: number;
wasmHeapBytes?: number;
qpos: number[];
qvel: number[];
ctrl: number[];
actuators: ActuatorInfo[];
joints: JointInfo[];
bodies: BodyInfo[];
warnings: string[];
controller?: ControllerStatus;
rlPolicy?: RLPolicyStatus;
mobileRobotId?: string;
controlOwner?: ControlOwner;
robot?: RobotDescriptor;
externalControl?: ExternalControlStatus;
telemetry: DataRecorderStatus;
model: {
nbody: number;
njnt: number;
ngeom: number;
ncam: number;
nactuator: number;
nu: number;
nq: number;
nv: number;
};
}
export interface FrameResult {
steps: number;
stepMs: number;
overBudget: boolean;
}
import type {
ActuatorParameters,
SimulationSnapshot,
FrameResult,
JointLimit,
} from './simulationTypes';
import { readActuators, readBodies, readJoints } from './snapshotReaders';
// 保持现有调用方的类型导入路径不变。
export type {
ActuatorParameters,
ActuatorInfo,
JointInfo,
BodyInfo,
SimulationSnapshot,
FrameResult,
} from './simulationTypes';
export class SimulationSession {
readonly model: MjModel;
@@ -136,7 +72,7 @@ export class SimulationSession {
private force: [number, number, number] = [0, 0, 0];
private disposed = false;
private ignoreJointLimits = false;
private jointLimits: { limited: boolean; min: number; max: number; type: number }[] = [];
private jointLimits: JointLimit[] = [];
private pythonController?: PythonControllerRuntime;
private controllerLoadGeneration = 0;
private rlPolicy?: OnnxPolicyRuntime;
@@ -1202,159 +1138,16 @@ export class SimulationSession {
}
snapshot(): SimulationSnapshot {
const actuators = Array.from({ length: this.model.nactuator }, (_, id): ActuatorInfo => {
const actuator = this.model.actuator(id);
try {
const limited = Boolean(actuator.ctrllimited);
const address = Number(this.model.actuator_ctrladr[id] ?? id),
nextAddress =
id + 1 < this.model.nactuator
? Number(this.model.actuator_ctrladr[id + 1])
: this.model.nu,
controlCount = Math.max(1, nextAddress - address);
const jointId =
(Number(actuator.trntype) === 0 || Number(actuator.trntype) === 1) &&
Number(actuator.trnid[0]) >= 0
? Number(actuator.trnid[0])
: undefined;
let jointName: string | undefined,
jointType: number | undefined,
jointStiffness = 0,
jointDamping = 0;
if (jointId !== undefined && jointId < this.model.njnt) {
const joint = this.model.jnt(jointId);
try {
jointName = joint.name || `joint_${jointId}`;
jointType = Number(joint.type);
jointStiffness = Number(joint.stiffness);
jointDamping = Number(joint.damping);
} finally {
joint.delete();
}
}
const ctrlMin = Number(actuator.ctrlrange[0]),
ctrlMax = Number(actuator.ctrlrange[1]),
forceLimited = Boolean(actuator.forcelimited);
const scalarJoint = controlCount === 1 && jointId !== undefined,
plainDynamics = Number(actuator.gaintype) === 0 && Number(actuator.dyntype) === 0,
gain = Number(actuator.gainprm[0]),
bias1 = Number(actuator.biasprm[1]),
bias2 = Number(actuator.biasprm[2]);
const kind: ActuatorInfo['kind'] =
scalarJoint && plainDynamics && Number(actuator.biastype) === 0
? 'motor'
: scalarJoint &&
plainDynamics &&
Number(actuator.biastype) === 1 &&
Math.abs(bias1 + gain) < 1e-6
? 'position'
: scalarJoint &&
plainDynamics &&
Number(actuator.biastype) === 1 &&
Math.abs(bias1) < 1e-9 &&
Math.abs(bias2 + gain) < 1e-6
? 'velocity'
: 'other';
return {
id,
name: actuator.name || `actuator_${id}`,
value: Number(this.data.ctrl[address]),
min: limited ? ctrlMin : -100,
max: limited ? ctrlMax : 100,
limited,
jointId,
jointName,
jointType,
unit:
kind === 'motor'
? jointType === 3
? 'N·m'
: jointType === 2
? 'N'
: ''
: kind === 'position'
? jointType === 3
? '°'
: jointType === 2
? 'm'
: ''
: '',
kind,
controlCount,
gear: Number(actuator.gear[0]),
gain,
kp: kind === 'position' ? gain : kind === 'motor' ? jointStiffness : 0,
kv:
kind === 'position'
? Math.max(0, -bias2)
: kind === 'velocity'
? gain
: kind === 'motor'
? jointDamping
: 0,
ctrlLimited: limited,
ctrlMin,
ctrlMax,
forceLimited,
forceMin: Number(actuator.forcerange[0]),
forceMax: Number(actuator.forcerange[1]),
};
} finally {
actuator.delete();
}
});
const joints = Array.from({ length: this.model.njnt }, (_, id): JointInfo => {
const joint = this.model.jnt(id);
try {
const original = this.jointLimits[id],
type = Number(joint.type),
limited = original.limited;
let min = limited ? original.min : type === 2 ? -1 : -Math.PI,
max = limited ? original.max : type === 2 ? 1 : Math.PI;
if (this.ignoreJointLimits) {
if (type === 3) {
min = -2 * Math.PI;
max = 2 * Math.PI;
} else if (type === 2) {
const span = limited ? Math.max(0.25, original.max - original.min) : 1;
min = limited ? original.min - span : -1;
max = limited ? original.max + span : 1;
}
}
return {
id,
name: joint.name || `joint_${id}`,
type,
value: Number(this.data.qpos[Number(joint.qposadr)]),
min,
max,
limitMin: original.min,
limitMax: original.max,
limited,
limitsIgnored: this.ignoreJointLimits,
editable:
!this.robotMechanicsLocked &&
(type === 2 || type === 3) &&
this.arbiter.current().owner === 'manual',
bodyId: Number(joint.bodyid),
axis: [Number(joint.axis[0]), Number(joint.axis[1]), Number(joint.axis[2])],
};
} finally {
joint.delete();
}
});
const bodies = Array.from({ length: this.model.nbody }, (_, id): BodyInfo => {
const body = this.model.body(id);
try {
return {
id,
name: body.name || `body_${id}`,
parentId: Number(this.model.body_parentid[id]),
};
} finally {
body.delete();
}
});
const actuators = readActuators(this.model, this.data);
const joints = readJoints(
this.model,
this.data,
this.jointLimits,
this.ignoreJointLimits,
this.robotMechanicsLocked,
() => this.arbiter.current().owner,
);
const bodies = readBodies(this.model);
return {
time: Number(this.data.time),
paused: this.paused,
@@ -0,0 +1,96 @@
import type { ControllerStatus } from '../controller/types';
import type { RLPolicyStatus } from '../rl/types';
import type { ExternalControlStatus } from '../robot/RobotRuntime';
import type { RobotDescriptor } from '../robot/types';
import type { DataRecorderStatus } from '../telemetry/DataRecorder';
import type { ControlOwner } from './ControlArbiter';
export interface ActuatorParameters {
gear: number;
gain: number;
kp: number;
kv: number;
ctrlLimited: boolean;
ctrlMin: number;
ctrlMax: number;
forceLimited: boolean;
forceMin: number;
forceMax: number;
}
export interface ActuatorInfo extends ActuatorParameters {
id: number;
name: string;
value: number;
min: number;
max: number;
limited: boolean;
jointId?: number;
jointName?: string;
jointType?: number;
unit: string;
kind: 'motor' | 'position' | 'velocity' | 'other';
controlCount: number;
}
export interface JointInfo {
id: number;
name: string;
type: number;
value: number;
min: number;
max: number;
limitMin: number;
limitMax: number;
limited: boolean;
limitsIgnored: boolean;
editable: boolean;
bodyId: number;
axis: [number, number, number];
}
export interface BodyInfo {
id: number;
name: string;
parentId: number;
}
export interface SimulationSnapshot {
time: number;
paused?: boolean;
speed?: number;
wasmHeapBytes?: number;
qpos: number[];
qvel: number[];
ctrl: number[];
actuators: ActuatorInfo[];
joints: JointInfo[];
bodies: BodyInfo[];
warnings: string[];
controller?: ControllerStatus;
rlPolicy?: RLPolicyStatus;
mobileRobotId?: string;
controlOwner?: ControlOwner;
robot?: RobotDescriptor;
externalControl?: ExternalControlStatus;
telemetry: DataRecorderStatus;
model: {
nbody: number;
njnt: number;
ngeom: number;
ncam: number;
nactuator: number;
nu: number;
nq: number;
nv: number;
};
}
export interface FrameResult {
steps: number;
stepMs: number;
overBudget: boolean;
}
/** 编译时保存的原始关节限位;不是新的协议字段。 */
export interface JointLimit {
limited: boolean;
min: number;
max: number;
type: number;
}
@@ -0,0 +1,168 @@
import type { MjData, MjModel } from '@mujoco/mujoco';
import type { ControlOwner } from './ControlArbiter';
import type { ActuatorInfo, BodyInfo, JointInfo, JointLimit } from './simulationTypes';
/** 仅采集快照;临时 embind 句柄在原读取范围内释放,不保存 WASM view。 */
export function readActuators(model: MjModel, data: MjData): ActuatorInfo[] {
return Array.from({ length: model.nactuator }, (_, id): ActuatorInfo => {
const actuator = model.actuator(id);
try {
const limited = Boolean(actuator.ctrllimited);
const address = Number(model.actuator_ctrladr[id] ?? id),
nextAddress = id + 1 < model.nactuator ? Number(model.actuator_ctrladr[id + 1]) : model.nu,
controlCount = Math.max(1, nextAddress - address);
const jointId =
(Number(actuator.trntype) === 0 || Number(actuator.trntype) === 1) &&
Number(actuator.trnid[0]) >= 0
? Number(actuator.trnid[0])
: undefined;
let jointName: string | undefined,
jointType: number | undefined,
jointStiffness = 0,
jointDamping = 0;
if (jointId !== undefined && jointId < model.njnt) {
const joint = model.jnt(jointId);
try {
jointName = joint.name || `joint_${jointId}`;
jointType = Number(joint.type);
jointStiffness = Number(joint.stiffness);
jointDamping = Number(joint.damping);
} finally {
joint.delete();
}
}
const ctrlMin = Number(actuator.ctrlrange[0]),
ctrlMax = Number(actuator.ctrlrange[1]),
forceLimited = Boolean(actuator.forcelimited);
const scalarJoint = controlCount === 1 && jointId !== undefined,
plainDynamics = Number(actuator.gaintype) === 0 && Number(actuator.dyntype) === 0,
gain = Number(actuator.gainprm[0]),
bias1 = Number(actuator.biasprm[1]),
bias2 = Number(actuator.biasprm[2]);
const kind: ActuatorInfo['kind'] =
scalarJoint && plainDynamics && Number(actuator.biastype) === 0
? 'motor'
: scalarJoint &&
plainDynamics &&
Number(actuator.biastype) === 1 &&
Math.abs(bias1 + gain) < 1e-6
? 'position'
: scalarJoint &&
plainDynamics &&
Number(actuator.biastype) === 1 &&
Math.abs(bias1) < 1e-9 &&
Math.abs(bias2 + gain) < 1e-6
? 'velocity'
: 'other';
return {
id,
name: actuator.name || `actuator_${id}`,
value: Number(data.ctrl[address]),
min: limited ? ctrlMin : -100,
max: limited ? ctrlMax : 100,
limited,
jointId,
jointName,
jointType,
unit:
kind === 'motor'
? jointType === 3
? 'N·m'
: jointType === 2
? 'N'
: ''
: kind === 'position'
? jointType === 3
? '°'
: jointType === 2
? 'm'
: ''
: '',
kind,
controlCount,
gear: Number(actuator.gear[0]),
gain,
kp: kind === 'position' ? gain : kind === 'motor' ? jointStiffness : 0,
kv:
kind === 'position'
? Math.max(0, -bias2)
: kind === 'velocity'
? gain
: kind === 'motor'
? jointDamping
: 0,
ctrlLimited: limited,
ctrlMin,
ctrlMax,
forceLimited,
forceMin: Number(actuator.forcerange[0]),
forceMax: Number(actuator.forcerange[1]),
};
} finally {
actuator.delete();
}
});
}
export function readJoints(
model: MjModel,
data: MjData,
jointLimits: readonly JointLimit[],
ignoreJointLimits: boolean,
robotMechanicsLocked: boolean,
controlOwner: () => ControlOwner,
): JointInfo[] {
return Array.from({ length: model.njnt }, (_, id): JointInfo => {
const joint = model.jnt(id);
try {
const original = jointLimits[id],
type = Number(joint.type),
limited = original.limited;
let min = limited ? original.min : type === 2 ? -1 : -Math.PI,
max = limited ? original.max : type === 2 ? 1 : Math.PI;
if (ignoreJointLimits) {
if (type === 3) {
min = -2 * Math.PI;
max = 2 * Math.PI;
} else if (type === 2) {
const span = limited ? Math.max(0.25, original.max - original.min) : 1;
min = limited ? original.min - span : -1;
max = limited ? original.max + span : 1;
}
}
return {
id,
name: joint.name || `joint_${id}`,
type,
value: Number(data.qpos[Number(joint.qposadr)]),
min,
max,
limitMin: original.min,
limitMax: original.max,
limited,
limitsIgnored: ignoreJointLimits,
editable:
!robotMechanicsLocked && (type === 2 || type === 3) && controlOwner() === 'manual',
bodyId: Number(joint.bodyid),
axis: [Number(joint.axis[0]), Number(joint.axis[1]), Number(joint.axis[2])],
};
} finally {
joint.delete();
}
});
}
export function readBodies(model: MjModel): BodyInfo[] {
return Array.from({ length: model.nbody }, (_, id): BodyInfo => {
const body = model.body(id);
try {
return {
id,
name: body.name || `body_${id}`,
parentId: Number(model.body_parentid[id]),
};
} finally {
body.delete();
}
});
}
@@ -0,0 +1,96 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { LocalTrainingClient } from './LocalTrainingClient';
import { LocalTrainingPanel } from './LocalTrainingPanel';
import type { TrainingJob, TrainingRequest } from './types';
const flatRequest: TrainingRequest = {
taskId: 'Unitree-Go2-Flat',
numEnvs: 4096,
maxIterations: 2000,
seed: 42,
runName: 'web',
device: 'gpu',
gpuIds: [0],
wandbMode: 'offline',
rewardPresetId: undefined,
};
const queuedJob: TrainingJob = {
id: 'a'.repeat(32),
taskId: 'Unitree-Go2-Flat',
state: 'queued',
createdAt: '2026-09-28T00:00:00Z',
iteration: 0,
maxIterations: 2000,
progress: 0,
message: '等待启动',
logs: [],
artifactReady: false,
};
beforeEach(() => {
vi.restoreAllMocks();
localStorage.clear();
sessionStorage.clear();
vi.spyOn(LocalTrainingClient.prototype, 'health').mockResolvedValue({
version: '0.1.0',
ready: true,
trainerRoot: '/测试训练目录',
python: '/测试虚拟环境/python',
tasks: ['Unitree-Go2-Flat'],
});
vi.spyOn(LocalTrainingClient.prototype, 'presets').mockResolvedValue([]);
vi.spyOn(LocalTrainingClient.prototype, 'start').mockResolvedValue(queuedJob);
vi.spyOn(LocalTrainingClient.prototype, 'job').mockResolvedValue(queuedJob);
});
async function connect() {
render(<LocalTrainingPanel onPolicyReady={vi.fn()} />);
fireEvent.change(screen.getByLabelText('训练服务访问令牌'), {
target: { value: '仅供单测的令牌' },
});
fireEvent.click(screen.getByRole('button', { name: /^连接$/ }));
const start = await screen.findByRole('button', { name: '发起本地训练' });
await waitFor(() => expect(start).toBeEnabled());
return start;
}
function submittedRequest() {
const calls = vi.mocked(LocalTrainingClient.prototype.start).mock.calls;
expect(calls).toHaveLength(1);
return calls[0][0];
}
describe('训练面板拆分前的请求表征', () => {
it('完整保留默认 Flat 请求字段,不隐式添加地形、传感器或预训练配置', async () => {
fireEvent.click(await connect());
await screen.findByText('排队中');
expect(submittedRequest()).toStrictEqual(flatRequest);
});
it('CPU 请求使用空 GPU 列表,即使输入框保留非法 GPU 文本', async () => {
const start = await connect();
fireEvent.change(screen.getByLabelText('GPU 编号'), { target: { value: '不是编号' } });
fireEvent.change(screen.getByLabelText('计算设备'), { target: { value: 'cpu' } });
fireEvent.click(start);
await screen.findByText('排队中');
expect(submittedRequest()).toStrictEqual({ ...flatRequest, device: 'cpu', gpuIds: [] });
});
it('GPU 输入接受空白与逗号,保留原顺序和重复编号', async () => {
const start = await connect();
fireEvent.change(screen.getByLabelText('GPU 编号'), { target: { value: ' 2, 1 2 ' } });
fireEvent.click(start);
await screen.findByText('排队中');
expect(submittedRequest()).toStrictEqual({ ...flatRequest, gpuIds: [2, 1, 2] });
});
it.each(['-1', '1.5', '未知'])('非法 GPU 输入 %s 不提交训练请求', async (value) => {
const start = await connect();
fireEvent.change(screen.getByLabelText('GPU 编号'), { target: { value } });
fireEvent.click(start);
expect(await screen.findByRole('alert')).toHaveTextContent('GPU 编号必须是非负整数');
expect(LocalTrainingClient.prototype.start).not.toHaveBeenCalled();
expect(start).toBeEnabled();
});
});
+119 -490
View File
@@ -6,10 +6,11 @@ import {
} from '../mobile/training';
import { MOBILE_TASK } from '../mobile/RobotDescriptor';
import type { TrainingStage } from '../mobile/TaskKernel';
import { PretrainedIdentity, PretrainedSourceSelect } from './PretrainedSourceSelect';
import { PretrainedSourceSelect } from './PretrainedSourceSelect';
import { pretrainedSelectionError } from './pretrainedSelection';
import { TrainingMetricsPanel } from './TrainingMetricsPanel';
import { trainingLosses } from './trainingLosses';
import { TrainingJobSection } from './TrainingJobSection';
import { ACTIVE_STATES, stateLabel } from './trainingPresentation';
import { MobileTaskFields, TerrainTaskFields } from './TrainingTaskFields';
import type { PolicyDeployment, TrainingTerrain } from '../rl/deployment';
import {
OBSTACLE_TASK_ID,
@@ -19,18 +20,17 @@ import {
} from '../rl/deployment';
import type { TrainingSceneCompiler } from '../map/trainingMap';
import type { PlacedMapAsset } from '../map/types';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { Download, ExternalLink, Link, Play, Server, Square } from 'lucide-react';
import {
Badge,
Button,
ProgressBar,
PropertyRow,
Select,
CollapsibleSection,
Tooltip,
} from '../components/ui';
import { useEffect, useRef, useState } from 'react';
import { ExternalLink, Link, Play, Server } from 'lucide-react';
import { Badge, Button, PropertyRow, Select, CollapsibleSection } from '../components/ui';
import { Field, NumberField } from './TrainingFields';
import { LocalTrainingClient } from './LocalTrainingClient';
import {
createTrainingRequest,
parseTrainingGpuIds,
trainingTaskDefaults,
validateTrainingParameters,
} from './trainingForm';
import type {
RewardPreset,
TrainingDevice,
@@ -48,19 +48,9 @@ import {
TRAINING_TOKEN_KEY,
} from './storage';
const ACTIVE_STATES = new Set(['queued', 'running']);
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function stateLabel(state: TrainingJob['state']): string {
return {
queued: '排队中',
running: '训练中',
succeeded: '已完成',
failed: '失败',
cancelled: '已取消',
}[state];
}
export function LocalTrainingPanel({
onPolicyReady,
@@ -128,19 +118,20 @@ export function LocalTrainingPanel({
? undefined
: pretrainedSelectionError(server?.pretrainedSources, taskId, pretrainedSourceId);
const selectTask = (id: string) => {
const defaults = trainingTaskDefaults(id);
setTaskId(id);
setMobileStage('navigate');
setSourceJobId('');
if (isMobileTrainingTask(id)) setPretrainedSourceId('');
setNumEnvs(isMobileTrainingTask(id) ? 1 : 4096);
setMaxIterations(isMobileTrainingTask(id) ? 1000 : 2000);
setDevice(isMobileTrainingTask(id) ? 'cpu' : 'gpu');
setNumEnvs(defaults.numEnvs);
setMaxIterations(defaults.maxIterations);
setDevice(defaults.device);
setCustomTerrainBoxes(undefined);
setRewardPresetId('');
setTerrainParams({});
setSensorCfg({});
setSensorMode('single_ring_raycast');
setTerrainPreset(id === OBSTACLE_TASK_ID ? 'discrete_obstacles' : '');
setTerrainPreset(defaults.terrainPreset);
};
const robotId = mobileTraining?.robotId;
const [observedRobotId, setObservedRobotId] = useState<string>();
@@ -397,35 +388,8 @@ export function LocalTrainingPanel({
!metadata?.sensorModes?.includes(sensorMode)
)
throw new Error('训练服务不支持multi_ring_raycast,请升级服务');
const ids =
device === 'gpu'
? gpuIds
.split(/[\s,]+/)
.filter(Boolean)
.map(Number)
: [];
if (ids.some((id) => !Number.isInteger(id) || id < 0))
throw new Error('GPU 编号必须是非负整数');
for (const [values, schema] of [
[terrainParams, metadata?.terrainParameters],
[sensorCfg, metadata?.sensorParameters],
] as const) {
for (const [key, value] of Object.entries(values)) {
const bounds = schema?.[key];
if (
!bounds ||
!Number.isFinite(value) ||
value < bounds.min ||
value > bounds.max ||
(bounds.integer && !Number.isInteger(value))
)
throw new Error(`参数 ${key} 超出允许范围`);
}
}
if ((terrainParams.obstacle_height_min ?? 0.2) > (terrainParams.obstacle_height_max ?? 0.6))
throw new Error('障碍物最小高度不能超过最大高度');
if ((sensorCfg.safetyDistance ?? 0.5) >= (sensorCfg.maxDistance ?? 4))
throw new Error('安全距离必须小于探测距离');
const ids = parseTrainingGpuIds(device, gpuIds);
validateTrainingParameters(terrainParams, sensorCfg, metadata);
let resolvedCustomTerrain = customTerrainBoxes;
let resolvedTerrainParams = terrainParams;
if (terrainPreset === 'custom_boxes') {
@@ -449,38 +413,37 @@ export function LocalTrainingPanel({
throw new Error('场景变体与任务不匹配');
mobilePackageId = uploaded.id;
}
const next = await client.start({
taskId,
numEnvs,
maxIterations,
seed,
runName,
device,
gpuIds: ids,
wandbMode: mobile ? 'disabled' : wandbMode,
...(mobile
? {
mobilePackageId,
mobileParams: {
rolloutSteps,
objectPosition,
goalPosition,
stage: mobileStage,
positionJitter,
evaluationEpisodes,
navigationBootstrapSteps,
...(sourceJobId ? { sourceJobId } : {}),
},
}
: {}),
rewardPresetId: taskId === 'Unitree-Go2-Flat' ? rewardPresetId || undefined : undefined,
...(pretrainedSourceId ? { pretrainedSourceId } : {}),
...(terrainPreset ? { terrainPreset, terrainParams: resolvedTerrainParams } : {}),
...(terrainPreset === 'custom_boxes' ? { customTerrainBoxes: resolvedCustomTerrain } : {}),
...(taskId === OBSTACLE_TASK_ID
? { sensorType: 'raycast' as const, sensorCfg: { ...sensorCfg, sensorMode } }
: {}),
});
const next = await client.start(
createTrainingRequest({
taskId,
numEnvs,
maxIterations,
seed,
runName,
device,
gpuIds: ids,
wandbMode,
mobile,
mobilePackageId,
mobileParams: {
rolloutSteps,
objectPosition,
goalPosition,
stage: mobileStage,
positionJitter,
evaluationEpisodes,
navigationBootstrapSteps,
sourceJobId,
},
rewardPresetId,
pretrainedSourceId,
terrainPreset,
terrainParams: resolvedTerrainParams,
customTerrainBoxes: resolvedCustomTerrain,
sensorCfg,
sensorMode,
}),
);
setJob(next);
try {
localStorage.setItem(TRAINING_JOB_KEY, next.id);
@@ -533,7 +496,6 @@ export function LocalTrainingPanel({
setBusy(false);
}
};
const active = Boolean(job && ACTIVE_STATES.has(job.state));
const summary = error ? '错误' : job ? stateLabel(job.state) : server?.ready ? '已连接' : '离线';
useEffect(() => {
onStatusChange?.(summary);
@@ -663,206 +625,47 @@ export function LocalTrainingPanel({
/>
)}
{metadata && !mobile && (
<>
<Field label="训练地形">
<Select
aria-label="训练地形"
value={terrainPreset}
onChange={(e) => {
setTerrainPreset(e.target.value);
setCustomTerrainBoxes(undefined);
setTerrainParams({});
}}
>
{taskId !== OBSTACLE_TASK_ID && <option value="">原任务默认地形</option>}
{metadata.terrainPresets.map((preset) => (
<option key={preset} value={preset}>
{TERRAIN_LABELS[preset] ?? preset}
</option>
))}
</Select>
</Field>
<Button
disabled={busy}
title="从已应用实例编译并校验世界 AABB;旋转障碍会膨胀为轴对齐包围盒"
onClick={syncMap}
>
同步场景地图
</Button>
{terrainPreset === 'custom_boxes' && customTerrainBoxes && !sceneDirty && (
<p role="status">
已读取视口中 {customTerrainBoxes.actualObstacleCount}{' '}
个自定义障碍物;启动训练时会自动重新编译并校验地图
</p>
)}
{terrainPreset && terrainPreset !== 'custom_boxes' && (
<CollapsibleSection
title="地形详细参数"
defaultOpen={false}
keepMounted
forceOpen={Boolean(error)}
>
<div className="grid grid-cols-2 gap-2">
{Object.entries(metadata.terrainParameters).map(([key, bounds]) => (
<NumberField
key={key}
label={PARAMETER_LABELS[key] ?? key}
value={terrainParams[key] ?? bounds.default}
defaultValue={bounds.default}
min={bounds.min}
max={bounds.max}
step={bounds.integer ? 1 : 0.01}
onChange={(value) => setTerrainParams((old) => ({ ...old, [key]: value }))}
/>
))}
</div>
</CollapsibleSection>
)}
{['rough', 'wave', 'pyramid_stairs'].includes(terrainPreset) && (
<div title="训练使用 box 离散近似布局,不等于编辑器高度场">
<p className="text-xs text-warning">
近似地形:训练使用 box 离散布局,非编辑器高度场。
</p>
</div>
)}
{taskId === OBSTACLE_TASK_ID && (
<p className="text-xs text-text-secondary">
{sensorMode === 'single_ring_raycast' ? '水平32射线 · 81维' : '三层48射线 · 97维'}
</p>
)}
{taskId === OBSTACLE_TASK_ID && (
<CollapsibleSection
title="避障传感器高级设置"
defaultOpen={false}
keepMounted
forceOpen={Boolean(error)}
>
<Field label="传感器模式">
<Select
aria-label="传感器模式"
value={sensorMode}
onChange={(event) => setSensorMode(event.target.value as typeof sensorMode)}
>
<option value="single_ring_raycast">水平32射线 / 81维(默认)</option>
<option
value="multi_ring_raycast"
disabled={!metadata?.sensorModes?.includes('multi_ring_raycast')}
>
三层48射线 / 97维(非高程图)
</option>
</Select>
</Field>
{Object.entries(metadata.sensorParameters).map(([key, bounds]) => (
<NumberField
key={key}
label={PARAMETER_LABELS[key] ?? key}
value={sensorCfg[key] ?? bounds.default}
defaultValue={bounds.default}
min={bounds.min}
max={bounds.max}
step={0.01}
onChange={(value) => setSensorCfg((old) => ({ ...old, [key]: value }))}
/>
))}
</CollapsibleSection>
)}
{!metadata.browserCompatible && (
<div title="浏览器不支持当前观测契约,策略仅可在后端评测">
<p className="text-xs text-warning">仅后端部署:浏览器不支持当前观测契约。</p>
</div>
)}
</>
<TerrainTaskFields
taskId={taskId}
metadata={metadata}
terrainPreset={terrainPreset}
customTerrainBoxes={customTerrainBoxes}
terrainParams={terrainParams}
sensorMode={sensorMode}
sensorCfg={sensorCfg}
busy={busy}
sceneDirty={sceneDirty}
error={error}
setTerrainPreset={setTerrainPreset}
setCustomTerrainBoxes={setCustomTerrainBoxes}
setTerrainParams={setTerrainParams}
setSensorMode={setSensorMode}
setSensorCfg={setSensorCfg}
syncMap={syncMap}
/>
)}
{mobile && (
<>
<p className="text-xs text-text-secondary">
变体:{MOBILE_TRAINING_TASKS[taskId]} · 控制步长 {MOBILE_TASK.controlDt}s ·{' '}
{MOBILE_TASK.observationSize} → 12。原生 CPU 物理,设备选项控制 PPO
网络。场景自动同步,无需下载训练包。
</p>
<Field label="移动操作训练阶段">
<Select
aria-label="移动操作训练阶段"
value={mobileStage}
onChange={(e) => setMobileStage(e.target.value as TrainingStage)}
>
<option value="navigate">1 · 底盘接近(机械臂保持)</option>
<option value="reach">2 · 末端接近(先导航,再伸臂)</option>
<option value="pick-place">3 · 抓取放置</option>
</Select>
</Field>
<Field label="接续作业 ID(留空从头训练导航)">
<input
aria-label="接续作业 ID"
className="field h-7 w-full px-2 text-xs"
value={sourceJobId}
onChange={(e) => setSourceJobId(e.target.value.trim())}
/>
</Field>
<p className="text-xs text-warning">
导航接近位为物体前方 0.3 m、朝向世界 +X,并非放置目标点。机械臂目标限速{' '}
{MOBILE_TASK.armSpeedLimit} rad/s,实测超速 {MOBILE_TASK.jointSpeedStop} rad/s
安全终止。升级阶段需前一阶段至少 10 回合评估、成功率 ≥80%、无安全终止。
</p>
<NumberField
label="位置随机范围 m"
value={positionJitter}
min={0}
max={0.3}
step={0.01}
onChange={setPositionJitter}
/>
<NumberField
label="独立评估回合"
value={evaluationEpisodes}
min={2}
max={64}
onChange={setEvaluationEpisodes}
/>
<NumberField
label="导航启动示教步数(仅初训)"
value={navigationBootstrapSteps}
min={0}
max={10000}
onChange={setNavigationBootstrapSteps}
/>
<p className="text-xs text-text-secondary">
初训可先模仿闭环底盘控制器,再用 PPO 微调;0 表示纯
PPO。导出只包含训练后的神经网络,不包含示教控制器。
</p>
<NumberField
label="每环境采样步数"
value={rolloutSteps}
min={8}
max={4096}
onChange={setRolloutSteps}
/>
{(
[
['物体', objectPosition, setObjectPosition],
['目标', goalPosition, setGoalPosition],
] as const
).map(([label, position, setPosition]) => (
<div className="grid grid-cols-3 gap-2" key={label}>
{['X', 'Y', 'Z'].map((axis, i) => (
<NumberField
key={axis}
label={`${label} ${axis}`}
value={position[i]}
min={i === 2 ? MOBILE_TASK.objectStart[2] : -MOBILE_TASK.positionScale}
max={MOBILE_TASK.positionScale}
step={0.01}
onChange={(value) =>
setPosition((old) => old.map((v, j) => (j === i ? value : v)))
}
/>
))}
</div>
))}
<p className="text-xs">
总采样步数:{numEnvs * maxIterations * rolloutSteps};训练不保证学会抓取。
</p>
</>
<MobileTaskFields
taskId={taskId}
numEnvs={numEnvs}
maxIterations={maxIterations}
stage={mobileStage}
sourceJobId={sourceJobId}
positionJitter={positionJitter}
evaluationEpisodes={evaluationEpisodes}
navigationBootstrapSteps={navigationBootstrapSteps}
rolloutSteps={rolloutSteps}
objectPosition={objectPosition}
goalPosition={goalPosition}
setMobileStage={setMobileStage}
setSourceJobId={setSourceJobId}
setPositionJitter={setPositionJitter}
setEvaluationEpisodes={setEvaluationEpisodes}
setNavigationBootstrapSteps={setNavigationBootstrapSteps}
setRolloutSteps={setRolloutSteps}
setObjectPosition={setObjectPosition}
setGoalPosition={setGoalPosition}
/>
)}
<div className="grid grid-cols-2 gap-2">
<NumberField
@@ -970,209 +773,35 @@ export function LocalTrainingPanel({
</fieldset>
)}
{job && (
<div className="mt-3 border-t border-border pt-3">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="truncate text-xs font-medium text-text-primary" title={job.id}>
{job.taskId}
</span>
<Badge
tone={
job.state === 'succeeded'
? 'success'
: job.state === 'failed' || job.state === 'cancelled'
? 'warning'
: 'accent'
}
>
{stateLabel(job.state)}
</Badge>
</div>
<PretrainedIdentity source={job.pretrained} />
{mobileDeployment?.evaluation && (
<div className="mt-2 text-xs">
<PropertyRow label="阶段" value={mobileDeployment.trainingStage} />
<PropertyRow
label="独立评估成功率"
value={`${(mobileDeployment.evaluation.successRate * 100).toFixed(1)}% / ${mobileDeployment.evaluation.episodes} 回合`}
/>
<PropertyRow
label="实测关节峰值"
value={`${mobileDeployment.evaluation.maxJointVelocity.toFixed(3)} rad/s`}
/>
<PropertyRow label="安全终止次数" value={mobileDeployment.evaluation.safetyStops} />
<p className="text-warning">
导出成功不代表策略达标;未达标策略导入仅用于调试,请先同阶段续训。
</p>
</div>
)}
{job.taskId === 'Unitree-Go2-Rough' && (
<div>
<p className="text-xs text-warning">
仅后端评测:234 维 Rough 策略无法在浏览器加载。
</p>
</div>
)}
{job.deployment?.terrain && (
<div>
<p className="text-xs text-warning">
导入会替换当前物理地图并启动配套策略,请先保存场景。
</p>
<Badge tone="warning">
{job.deployment.terrain.approximation ? '近似碰撞布局' : '配套碰撞布局'}
</Badge>
</div>
)}
<ProgressBar value={job.progress} label="训练进度" />
<div className="mt-2">
<PropertyRow label="迭代" value={`${job.iteration} / ${job.maxIterations}`} />
<p role="status" className="break-words text-xs text-text-secondary">
{job.message}
</p>
{trainingLosses(job.logs).map(({ label, value }) => (
<PropertyRow key={label} label={label} value={value} />
))}
</div>
<TrainingMetricsPanel key={job.id} jobId={job.id} logs={job.logs} />
{job.logs.length > 0 && (
<details className="mt-2">
<summary className="cursor-pointer text-xs text-text-secondary">最近日志</summary>
<pre className="mt-1 max-h-36 overflow-auto whitespace-pre-wrap break-all rounded bg-app p-2 text-xs leading-4 text-text-tertiary">
{job.logs.slice(-40).join('\n')}
</pre>
</details>
)}
{mobileDeployment && job.state === 'succeeded' && (
<Button
disabled={busy}
onClick={() => {
setSourceJobId(job.id);
setMobileStage(mobileDeployment.trainingStage ?? 'navigate');
if (mobileDeployment.resetOptions) {
setObjectPosition(mobileDeployment.resetOptions.object);
setGoalPosition(mobileDeployment.resetOptions.goal);
}
setPositionJitter(mobileDeployment.trainingParams?.positionJitter ?? 0.1);
setJob(undefined);
}}
>
接续此作业(保留权重)
</Button>
)}
<div className="mt-3 grid grid-cols-2 gap-2">
{active ? (
<Button
variant="danger"
className="col-span-2"
icon={<Square className="h-3.5 w-3.5" />}
disabled={busy}
onClick={() => void cancel()}
>
停止训练
</Button>
) : (
<>
<Button
disabled={
busy ||
!job.artifactReady ||
job.taskId === 'Unitree-Go2-Rough' ||
(job.deployment && !job.deployment.browserCompatible)
}
icon={<Download className="h-3.5 w-3.5" />}
onClick={() => void importResult()}
>
导入策略
</Button>
<Button
disabled={busy}
onClick={() => {
setJob(undefined);
setSourceJobId('');
setMobileStage('navigate');
try {
localStorage.removeItem(TRAINING_JOB_KEY);
} catch {
/* ignore */
}
}}
>
新建任务
</Button>
</>
)}
</div>
</div>
<TrainingJobSection
job={job}
mobileDeployment={mobileDeployment}
busy={busy}
onContinue={() => {
if (!mobileDeployment) return;
setSourceJobId(job.id);
setMobileStage(mobileDeployment.trainingStage ?? 'navigate');
if (mobileDeployment.resetOptions) {
setObjectPosition(mobileDeployment.resetOptions.object);
setGoalPosition(mobileDeployment.resetOptions.goal);
}
setPositionJitter(mobileDeployment.trainingParams?.positionJitter ?? 0.1);
setJob(undefined);
}}
onCancel={cancel}
onImport={importResult}
onNew={() => {
setJob(undefined);
setSourceJobId('');
setMobileStage('navigate');
try {
localStorage.removeItem(TRAINING_JOB_KEY);
} catch {
/* ignore */
}
}}
/>
)}
</div>
);
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="block text-xs text-text-tertiary">
<span className="mb-1 block">{label}</span>
{children}
</label>
);
}
function NumberField({
label,
value,
min,
max,
onChange,
step = 1,
defaultValue,
}: {
label: string;
value: number;
min: number;
max: number;
step?: number;
defaultValue?: number;
onChange(value: number): void;
}) {
return (
<Field label={label}>
<Tooltip
className="w-full"
content={`范围 ${min}–${max}${defaultValue === undefined ? '' : `;默认 ${defaultValue}`}`}
>
<input
aria-label={label}
type="number"
step={step}
className="field h-7 w-full px-2 text-xs tabular-nums text-text-primary"
value={value}
min={min}
max={max}
onChange={(event) => onChange(Number(event.target.value))}
/>
</Tooltip>
</Field>
);
}
const TERRAIN_LABELS: Record<string, string> = {
custom_boxes: '自定义场景碰撞布局(AABB近似)',
plane: '平地',
discrete_obstacles: '离散障碍物',
rough: '崎岖地面',
pyramid_stairs: '金字塔台阶',
wave: '波浪地形',
};
const PARAMETER_LABELS: Record<string, string> = {
size: '地图尺寸 m',
obstacle_count: '障碍物数量',
obstacle_height_min: '最小障碍高度 m',
obstacle_height_max: '最大障碍高度 m',
spacing: '障碍物间距 m',
friction: '地面摩擦',
roughness: '崎岖高度 m',
step_height: '台阶高度 m',
wave_amplitude: '波浪幅度 m',
fov: '感知角 FOV',
maxDistance: '探测距离 m',
safetyDistance: '安全距离 m',
avoidanceWeight: '避障权重',
};
@@ -0,0 +1,49 @@
import type { ReactNode } from 'react';
import { Tooltip } from '../components/ui';
export function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="block text-xs text-text-tertiary">
<span className="mb-1 block">{label}</span>
{children}
</label>
);
}
export function NumberField({
label,
value,
min,
max,
onChange,
step = 1,
defaultValue,
}: {
label: string;
value: number;
min: number;
max: number;
step?: number;
defaultValue?: number;
onChange(value: number): void;
}) {
return (
<Field label={label}>
<Tooltip
className="w-full"
content={`范围 ${min}–${max}${defaultValue === undefined ? '' : `;默认 ${defaultValue}`}`}
>
<input
aria-label={label}
type="number"
step={step}
className="field h-7 w-full px-2 text-xs tabular-nums text-text-primary"
value={value}
min={min}
max={max}
onChange={(event) => onChange(Number(event.target.value))}
/>
</Tooltip>
</Field>
);
}
@@ -0,0 +1,68 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { TrainingJobSection } from './TrainingJobSection';
import type { TrainingJob } from './types';
const job: TrainingJob = {
id: '测试作业',
taskId: 'Unitree-Go2-Flat',
state: 'succeeded',
createdAt: '2026-09-28T00:00:00Z',
iteration: 2,
maxIterations: 2,
progress: 1,
message: '训练已完成',
logs: [],
artifactReady: true,
};
const actions = () => ({
onContinue: vi.fn(),
onCancel: vi.fn(),
onImport: vi.fn(),
onNew: vi.fn(),
});
describe('训练作业展示保持操作边界', () => {
it('排队和运行时只提供停止入口,busy 阻止重复操作', () => {
const callbacks = actions();
const { rerender } = render(
<TrainingJobSection job={{ ...job, state: 'queued' }} busy={false} {...callbacks} />,
);
fireEvent.click(screen.getByRole('button', { name: '停止训练' }));
expect(callbacks.onCancel).toHaveBeenCalledOnce();
expect(screen.queryByRole('button', { name: '导入策略' })).not.toBeInTheDocument();
rerender(<TrainingJobSection job={{ ...job, state: 'running' }} busy {...callbacks} />);
expect(screen.getByRole('button', { name: '停止训练' })).toBeDisabled();
});
it('完成后导入/新建委托父级处理,切换到 Rough 后拒绝导入', () => {
const callbacks = actions();
const { rerender } = render(<TrainingJobSection job={job} busy={false} {...callbacks} />);
fireEvent.click(screen.getByRole('button', { name: '导入策略' }));
fireEvent.click(screen.getByRole('button', { name: '新建任务' }));
expect(callbacks.onImport).toHaveBeenCalledOnce();
expect(callbacks.onNew).toHaveBeenCalledOnce();
rerender(
<TrainingJobSection
job={{ ...job, taskId: 'Unitree-Go2-Rough' }}
busy={false}
{...callbacks}
/>,
);
expect(screen.getByRole('button', { name: '导入策略' })).toBeDisabled();
expect(screen.getByText(/234 维 Rough/)).toBeInTheDocument();
});
it('无成果物时导入禁用,原样保留最后 40 行日志', () => {
const logs = Array.from({ length: 42 }, (_, i) => `第${i}行`);
const { container } = render(
<TrainingJobSection
job={{ ...job, artifactReady: false, logs }}
busy={false}
{...actions()}
/>,
);
expect(screen.getByRole('button', { name: '导入策略' })).toBeDisabled();
expect(container.querySelector('pre')?.textContent).toBe(logs.slice(-40).join('\n'));
});
});
@@ -0,0 +1,136 @@
import { Download, Square } from 'lucide-react';
import { Badge, Button, ProgressBar, PropertyRow } from '../components/ui';
import type { MobileDeployment } from '../mobile/training';
import { PretrainedIdentity } from './PretrainedSourceSelect';
import { TrainingMetricsPanel } from './TrainingMetricsPanel';
import { trainingLosses } from './trainingLosses';
import { ACTIVE_STATES, stateLabel } from './trainingPresentation';
import type { TrainingJob } from './types';
export function TrainingJobSection({
job,
mobileDeployment,
busy,
onContinue,
onCancel,
onImport,
onNew,
}: {
job: TrainingJob;
mobileDeployment?: MobileDeployment;
busy: boolean;
onContinue(): void;
onCancel(): void | Promise<void>;
onImport(): void | Promise<void>;
onNew(): void;
}) {
const active = ACTIVE_STATES.has(job.state);
return (
<div className="mt-3 border-t border-border pt-3">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="truncate text-xs font-medium text-text-primary" title={job.id}>
{job.taskId}
</span>
<Badge
tone={
job.state === 'succeeded'
? 'success'
: job.state === 'failed' || job.state === 'cancelled'
? 'warning'
: 'accent'
}
>
{stateLabel(job.state)}
</Badge>
</div>
<PretrainedIdentity source={job.pretrained} />
{mobileDeployment?.evaluation && (
<div className="mt-2 text-xs">
<PropertyRow label="阶段" value={mobileDeployment.trainingStage} />
<PropertyRow
label="独立评估成功率"
value={`${(mobileDeployment.evaluation.successRate * 100).toFixed(1)}% / ${mobileDeployment.evaluation.episodes} 回合`}
/>
<PropertyRow
label="实测关节峰值"
value={`${mobileDeployment.evaluation.maxJointVelocity.toFixed(3)} rad/s`}
/>
<PropertyRow label="安全终止次数" value={mobileDeployment.evaluation.safetyStops} />
<p className="text-warning">
导出成功不代表策略达标;未达标策略导入仅用于调试,请先同阶段续训。
</p>
</div>
)}
{job.taskId === 'Unitree-Go2-Rough' && (
<div>
<p className="text-xs text-warning">仅后端评测:234 维 Rough 策略无法在浏览器加载。</p>
</div>
)}
{job.deployment?.terrain && (
<div>
<p className="text-xs text-warning">
导入会替换当前物理地图并启动配套策略,请先保存场景。
</p>
<Badge tone="warning">
{job.deployment.terrain.approximation ? '近似碰撞布局' : '配套碰撞布局'}
</Badge>
</div>
)}
<ProgressBar value={job.progress} label="训练进度" />
<div className="mt-2">
<PropertyRow label="迭代" value={`${job.iteration} / ${job.maxIterations}`} />
<p role="status" className="break-words text-xs text-text-secondary">
{job.message}
</p>
{trainingLosses(job.logs).map(({ label, value }) => (
<PropertyRow key={label} label={label} value={value} />
))}
</div>
<TrainingMetricsPanel key={job.id} jobId={job.id} logs={job.logs} />
{job.logs.length > 0 && (
<details className="mt-2">
<summary className="cursor-pointer text-xs text-text-secondary">最近日志</summary>
<pre className="mt-1 max-h-36 overflow-auto whitespace-pre-wrap break-all rounded bg-app p-2 text-xs leading-4 text-text-tertiary">
{job.logs.slice(-40).join('\n')}
</pre>
</details>
)}
{mobileDeployment && job.state === 'succeeded' && (
<Button disabled={busy} onClick={onContinue}>
接续此作业(保留权重)
</Button>
)}
<div className="mt-3 grid grid-cols-2 gap-2">
{active ? (
<Button
variant="danger"
className="col-span-2"
icon={<Square className="h-3.5 w-3.5" />}
disabled={busy}
onClick={() => void onCancel()}
>
停止训练
</Button>
) : (
<>
<Button
disabled={
busy ||
!job.artifactReady ||
job.taskId === 'Unitree-Go2-Rough' ||
(job.deployment && !job.deployment.browserCompatible)
}
icon={<Download className="h-3.5 w-3.5" />}
onClick={() => void onImport()}
>
导入策略
</Button>
<Button disabled={busy} onClick={onNew}>
新建任务
</Button>
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,281 @@
import type { Dispatch, SetStateAction } from 'react';
import { Button, CollapsibleSection, Select } from '../components/ui';
import { MOBILE_TASK } from '../mobile/RobotDescriptor';
import { MOBILE_TRAINING_TASKS, type MobileTrainingParams } from '../mobile/training';
import type { TrainingStage } from '../mobile/TaskKernel';
import { OBSTACLE_TASK_ID, type TrainingTerrain } from '../rl/deployment';
import type { TrainingTaskMetadata } from './types';
import { Field, NumberField } from './TrainingFields';
import { PARAMETER_LABELS, TERRAIN_LABELS } from './trainingPresentation';
type SensorMode = 'single_ring_raycast' | 'multi_ring_raycast';
export function TerrainTaskFields({
taskId,
metadata,
terrainPreset,
customTerrainBoxes,
terrainParams,
sensorMode,
sensorCfg,
busy,
sceneDirty,
error,
setTerrainPreset,
setCustomTerrainBoxes,
setTerrainParams,
setSensorMode,
setSensorCfg,
syncMap,
}: {
taskId: string;
metadata: TrainingTaskMetadata;
terrainPreset: string;
customTerrainBoxes?: TrainingTerrain;
terrainParams: Record<string, number>;
sensorMode: SensorMode;
sensorCfg: Record<string, number>;
busy: boolean;
sceneDirty: boolean;
error?: string;
setTerrainPreset(value: string): void;
setCustomTerrainBoxes(value: TrainingTerrain | undefined): void;
setTerrainParams: Dispatch<SetStateAction<Record<string, number>>>;
setSensorMode(value: SensorMode): void;
setSensorCfg: Dispatch<SetStateAction<Record<string, number>>>;
syncMap(): void;
}) {
return (
<>
<Field label="训练地形">
<Select
aria-label="训练地形"
value={terrainPreset}
onChange={(e) => {
setTerrainPreset(e.target.value);
setCustomTerrainBoxes(undefined);
setTerrainParams({});
}}
>
{taskId !== OBSTACLE_TASK_ID && <option value="">原任务默认地形</option>}
{metadata.terrainPresets.map((preset) => (
<option key={preset} value={preset}>
{TERRAIN_LABELS[preset] ?? preset}
</option>
))}
</Select>
</Field>
<Button
disabled={busy}
title="从已应用实例编译并校验世界 AABB;旋转障碍会膨胀为轴对齐包围盒"
onClick={syncMap}
>
同步场景地图
</Button>
{terrainPreset === 'custom_boxes' && customTerrainBoxes && !sceneDirty && (
<p role="status">
已读取视口中 {customTerrainBoxes.actualObstacleCount}{' '}
个自定义障碍物;启动训练时会自动重新编译并校验地图
</p>
)}
{terrainPreset && terrainPreset !== 'custom_boxes' && (
<CollapsibleSection
title="地形详细参数"
defaultOpen={false}
keepMounted
forceOpen={Boolean(error)}
>
<div className="grid grid-cols-2 gap-2">
{Object.entries(metadata.terrainParameters).map(([key, bounds]) => (
<NumberField
key={key}
label={PARAMETER_LABELS[key] ?? key}
value={terrainParams[key] ?? bounds.default}
defaultValue={bounds.default}
min={bounds.min}
max={bounds.max}
step={bounds.integer ? 1 : 0.01}
onChange={(value) => setTerrainParams((old) => ({ ...old, [key]: value }))}
/>
))}
</div>
</CollapsibleSection>
)}
{['rough', 'wave', 'pyramid_stairs'].includes(terrainPreset) && (
<div title="训练使用 box 离散近似布局,不等于编辑器高度场">
<p className="text-xs text-warning">近似地形:训练使用 box 离散布局,非编辑器高度场。</p>
</div>
)}
{taskId === OBSTACLE_TASK_ID && (
<p className="text-xs text-text-secondary">
{sensorMode === 'single_ring_raycast' ? '水平32射线 · 81维' : '三层48射线 · 97维'}
</p>
)}
{taskId === OBSTACLE_TASK_ID && (
<CollapsibleSection
title="避障传感器高级设置"
defaultOpen={false}
keepMounted
forceOpen={Boolean(error)}
>
<Field label="传感器模式">
<Select
aria-label="传感器模式"
value={sensorMode}
onChange={(event) => setSensorMode(event.target.value as typeof sensorMode)}
>
<option value="single_ring_raycast">水平32射线 / 81维(默认)</option>
<option
value="multi_ring_raycast"
disabled={!metadata?.sensorModes?.includes('multi_ring_raycast')}
>
三层48射线 / 97维(非高程图)
</option>
</Select>
</Field>
{Object.entries(metadata.sensorParameters).map(([key, bounds]) => (
<NumberField
key={key}
label={PARAMETER_LABELS[key] ?? key}
value={sensorCfg[key] ?? bounds.default}
defaultValue={bounds.default}
min={bounds.min}
max={bounds.max}
step={0.01}
onChange={(value) => setSensorCfg((old) => ({ ...old, [key]: value }))}
/>
))}
</CollapsibleSection>
)}
{!metadata.browserCompatible && (
<div title="浏览器不支持当前观测契约,策略仅可在后端评测">
<p className="text-xs text-warning">仅后端部署:浏览器不支持当前观测契约。</p>
</div>
)}
</>
);
}
export function MobileTaskFields({
taskId,
numEnvs,
maxIterations,
stage: mobileStage,
sourceJobId,
positionJitter,
evaluationEpisodes,
navigationBootstrapSteps,
rolloutSteps,
objectPosition,
goalPosition,
setMobileStage,
setSourceJobId,
setPositionJitter,
setEvaluationEpisodes,
setNavigationBootstrapSteps,
setRolloutSteps,
setObjectPosition,
setGoalPosition,
}: Required<MobileTrainingParams> & {
taskId: string;
numEnvs: number;
maxIterations: number;
setMobileStage(value: TrainingStage): void;
setSourceJobId(value: string): void;
setPositionJitter(value: number): void;
setEvaluationEpisodes(value: number): void;
setNavigationBootstrapSteps(value: number): void;
setRolloutSteps(value: number): void;
setObjectPosition: Dispatch<SetStateAction<number[]>>;
setGoalPosition: Dispatch<SetStateAction<number[]>>;
}) {
return (
<>
<p className="text-xs text-text-secondary">
变体:{MOBILE_TRAINING_TASKS[taskId]} · 控制步长 {MOBILE_TASK.controlDt}s ·{' '}
{MOBILE_TASK.observationSize} → 12。原生 CPU 物理,设备选项控制 PPO
网络。场景自动同步,无需下载训练包。
</p>
<Field label="移动操作训练阶段">
<Select
aria-label="移动操作训练阶段"
value={mobileStage}
onChange={(e) => setMobileStage(e.target.value as TrainingStage)}
>
<option value="navigate">1 · 底盘接近(机械臂保持)</option>
<option value="reach">2 · 末端接近(先导航,再伸臂)</option>
<option value="pick-place">3 · 抓取放置</option>
</Select>
</Field>
<Field label="接续作业 ID(留空从头训练导航)">
<input
aria-label="接续作业 ID"
className="field h-7 w-full px-2 text-xs"
value={sourceJobId}
onChange={(e) => setSourceJobId(e.target.value.trim())}
/>
</Field>
<p className="text-xs text-warning">
导航接近位为物体前方 0.3 m、朝向世界 +X,并非放置目标点。机械臂目标限速{' '}
{MOBILE_TASK.armSpeedLimit} rad/s,实测超速 {MOBILE_TASK.jointSpeedStop} rad/s
安全终止。升级阶段需前一阶段至少 10 回合评估、成功率 ≥80%、无安全终止。
</p>
<NumberField
label="位置随机范围 m"
value={positionJitter}
min={0}
max={0.3}
step={0.01}
onChange={setPositionJitter}
/>
<NumberField
label="独立评估回合"
value={evaluationEpisodes}
min={2}
max={64}
onChange={setEvaluationEpisodes}
/>
<NumberField
label="导航启动示教步数(仅初训)"
value={navigationBootstrapSteps}
min={0}
max={10000}
onChange={setNavigationBootstrapSteps}
/>
<p className="text-xs text-text-secondary">
初训可先模仿闭环底盘控制器,再用 PPO 微调;0 表示纯
PPO。导出只包含训练后的神经网络,不包含示教控制器。
</p>
<NumberField
label="每环境采样步数"
value={rolloutSteps}
min={8}
max={4096}
onChange={setRolloutSteps}
/>
{(
[
['物体', objectPosition, setObjectPosition],
['目标', goalPosition, setGoalPosition],
] as const
).map(([label, position, setPosition]) => (
<div className="grid grid-cols-3 gap-2" key={label}>
{['X', 'Y', 'Z'].map((axis, i) => (
<NumberField
key={axis}
label={`${label} ${axis}`}
value={position[i]}
min={i === 2 ? MOBILE_TASK.objectStart[2] : -MOBILE_TASK.positionScale}
max={MOBILE_TASK.positionScale}
step={0.01}
onChange={(value) => setPosition((old) => old.map((v, j) => (j === i ? value : v)))}
/>
))}
</div>
))}
<p className="text-xs">
总采样步数:{numEnvs * maxIterations * rolloutSteps};训练不保证学会抓取。
</p>
</>
);
}
@@ -0,0 +1,168 @@
import { describe, expect, it } from 'vitest';
import { OBSTACLE_TASK_ID } from '../rl/deployment';
import {
createTrainingRequest,
parseTrainingGpuIds,
trainingTaskDefaults,
validateTrainingParameters,
} from './trainingForm';
import type { TrainingTaskMetadata } from './types';
type Form = Parameters<typeof createTrainingRequest>[0];
const form = (patch: Partial<Form> = {}): Form => ({
taskId: 'Unitree-Go2-Flat',
numEnvs: 4096,
maxIterations: 2000,
seed: 42,
runName: 'web',
device: 'gpu',
gpuIds: [0],
wandbMode: 'offline',
mobile: false,
mobileParams: {
rolloutSteps: 128,
objectPosition: [0.4, 0, 0.03],
goalPosition: [0.4, 0.5, 0.03],
stage: 'navigate',
sourceJobId: '',
},
rewardPresetId: '',
pretrainedSourceId: '',
terrainPreset: '',
terrainParams: {},
customTerrainBoxes: undefined,
sensorCfg: {},
sensorMode: 'single_ring_raycast',
...patch,
});
const metadata: TrainingTaskMetadata = {
id: OBSTACLE_TASK_ID,
name: '避障任务',
browserCompatible: true,
terrainPresets: ['discrete_obstacles'],
terrainParameters: {
obstacle_count: { min: 1, max: 50, default: 25, integer: true },
obstacle_height_min: { min: 0.1, max: 1, default: 0.2 },
obstacle_height_max: { min: 0.1, max: 1, default: 0.6 },
},
sensorTypes: ['raycast'],
sensorParameters: {
safetyDistance: { min: 0.1, max: 4, default: 0.5 },
maxDistance: { min: 1, max: 8, default: 4 },
},
mapSyncScope: 'custom_boxes',
};
describe('训练表单的纯转换边界', () => {
it.each(['MobileManipulator-LeKiwi-v1', 'MobileManipulator-LeKiwi-Bundle'])(
'移动任务 %s 的 CPU 默认值不变',
(id) => {
expect(trainingTaskDefaults(id)).toEqual({
numEnvs: 1,
maxIterations: 1000,
device: 'cpu',
terrainPreset: '',
});
},
);
it('Go2 默认 GPU 参数不变,只有避障任务默认使用离散障碍物', () => {
const defaults = { numEnvs: 4096, maxIterations: 2000, device: 'gpu', terrainPreset: '' };
expect(trainingTaskDefaults('Unitree-Go2-Flat')).toEqual(defaults);
expect(trainingTaskDefaults('Unitree-Go2-Rough')).toEqual(defaults);
expect(trainingTaskDefaults(OBSTACLE_TASK_ID)).toEqual({
...defaults,
terrainPreset: 'discrete_obstacles',
});
});
it('GPU 保留顺序和重复值,CPU 忽略文本', () => {
expect(parseTrainingGpuIds('gpu', '2, 1 2')).toEqual([2, 1, 2]);
expect(parseTrainingGpuIds('gpu', ' , ')).toEqual([]);
expect(parseTrainingGpuIds('cpu', '非法')).toEqual([]);
});
it.each(['-1', '1.5', '未知'])('拒绝非法 GPU 编号 %s', (text) => {
expect(() => parseTrainingGpuIds('gpu', text)).toThrow('GPU 编号必须是非负整数');
});
it('保持先地形后传感器的校验顺序,拒绝未知参数、非有限数和非整数', () => {
expect(() => validateTrainingParameters({}, {}, undefined)).not.toThrow();
expect(() => validateTrainingParameters({ missing: 0 }, { other: 0 }, metadata)).toThrow(
'参数 missing 超出允许范围',
);
for (const value of [NaN, Infinity, 0, 51, 1.5])
expect(() => validateTrainingParameters({ obstacle_count: value }, {}, metadata)).toThrow(
'参数 obstacle_count 超出允许范围',
);
for (const value of [1, 50])
expect(() =>
validateTrainingParameters({ obstacle_count: value }, {}, metadata),
).not.toThrow();
});
it('保留默认值比较及高度/感知距离约束', () => {
expect(() => validateTrainingParameters({ obstacle_height_min: 0.7 }, {}, metadata)).toThrow(
'障碍物最小高度不能超过最大高度',
);
expect(() =>
validateTrainingParameters({}, { safetyDistance: 4, maxDistance: 4 }, metadata),
).toThrow('安全距离必须小于探测距离');
});
it('Flat 请求不会添加未选择的地形和传感器字段,保留明确的 rewardPresetId', () => {
expect(createTrainingRequest(form())).toStrictEqual({
taskId: 'Unitree-Go2-Flat',
numEnvs: 4096,
maxIterations: 2000,
seed: 42,
runName: 'web',
device: 'gpu',
gpuIds: [0],
wandbMode: 'offline',
rewardPresetId: undefined,
});
});
it('避障请求不带 Flat 奖励,保留地形及传感器模式并且不修改输入', () => {
const input = form({
taskId: OBSTACLE_TASK_ID,
rewardPresetId: '仅属于Flat',
pretrainedSourceId: '预训练来源',
terrainPreset: 'discrete_obstacles',
terrainParams: { obstacle_count: 12 },
sensorCfg: { safetyDistance: 0.5 },
sensorMode: 'multi_ring_raycast',
});
const before = structuredClone(input);
const request = createTrainingRequest(input);
expect(request).toStrictEqual({
...createTrainingRequest(form()),
taskId: OBSTACLE_TASK_ID,
pretrainedSourceId: '预训练来源',
terrainPreset: 'discrete_obstacles',
terrainParams: { obstacle_count: 12 },
sensorType: 'raycast',
sensorCfg: { safetyDistance: 0.5, sensorMode: 'multi_ring_raycast' },
});
expect(input).toStrictEqual(before);
});
it('移动请求禁用 W&B,省略空接续 ID,非空 ID 原样保留', () => {
const input = form({
mobile: true,
taskId: 'MobileManipulator-LeKiwi-v1',
mobilePackageId: '包',
});
const mobileParams = { ...input.mobileParams };
delete mobileParams.sourceJobId;
expect(createTrainingRequest(input)).toStrictEqual({
...createTrainingRequest(form()),
taskId: input.taskId,
wandbMode: 'disabled',
mobilePackageId: '包',
mobileParams,
});
input.mobileParams.sourceJobId = '已完成作业';
expect(createTrainingRequest(input).mobileParams).toStrictEqual(input.mobileParams);
});
});
+116
View File
@@ -0,0 +1,116 @@
import { OBSTACLE_TASK_ID } from '../rl/deployment';
import { isMobileTrainingTask, type MobileTrainingParams } from '../mobile/training';
import type { TrainingDevice, TrainingRequest, TrainingTaskMetadata } from './types';
export function trainingTaskDefaults(taskId: string) {
const mobile = isMobileTrainingTask(taskId);
return {
numEnvs: mobile ? 1 : 4096,
maxIterations: mobile ? 1000 : 2000,
device: (mobile ? 'cpu' : 'gpu') as TrainingDevice,
terrainPreset: taskId === OBSTACLE_TASK_ID ? 'discrete_obstacles' : '',
};
}
/** 只解析界面输入;不去重、不排序,也不改变 CPU 模式忽略 GPU 文本的行为。 */
export function parseTrainingGpuIds(device: TrainingDevice, gpuIds: string): number[] {
const ids =
device === 'gpu'
? gpuIds
.split(/[\s,]+/)
.filter(Boolean)
.map(Number)
: [];
if (ids.some((id) => !Number.isInteger(id) || id < 0)) throw new Error('GPU 编号必须是非负整数');
return ids;
}
export function validateTrainingParameters(
terrainParams: Record<string, number>,
sensorCfg: Record<string, number>,
metadata: TrainingTaskMetadata | undefined,
): void {
for (const [values, schema] of [
[terrainParams, metadata?.terrainParameters],
[sensorCfg, metadata?.sensorParameters],
] as const) {
for (const [key, value] of Object.entries(values)) {
const bounds = schema?.[key];
if (
!bounds ||
!Number.isFinite(value) ||
value < bounds.min ||
value > bounds.max ||
(bounds.integer && !Number.isInteger(value))
)
throw new Error(`参数 ${key} 超出允许范围`);
}
}
if ((terrainParams.obstacle_height_min ?? 0.2) > (terrainParams.obstacle_height_max ?? 0.6))
throw new Error('障碍物最小高度不能超过最大高度');
if ((sensorCfg.safetyDistance ?? 0.5) >= (sensorCfg.maxDistance ?? 4))
throw new Error('安全距离必须小于探测距离');
}
type TrainingForm = Pick<
TrainingRequest,
'taskId' | 'numEnvs' | 'maxIterations' | 'seed' | 'runName' | 'device' | 'gpuIds' | 'wandbMode'
> & {
mobile: boolean;
mobilePackageId?: string;
mobileParams: MobileTrainingParams;
rewardPresetId: string;
pretrainedSourceId: string;
terrainPreset: string;
terrainParams: Record<string, number>;
customTerrainBoxes: TrainingRequest['customTerrainBoxes'];
sensorCfg: Record<string, number>;
sensorMode: 'single_ring_raycast' | 'multi_ring_raycast';
};
/** 场景编译/上传由调用方完成后再构造请求,保留原可选字段与插入顺序。 */
export function createTrainingRequest({
taskId,
numEnvs,
maxIterations,
seed,
runName,
device,
gpuIds,
wandbMode,
mobile,
mobilePackageId,
mobileParams,
rewardPresetId,
pretrainedSourceId,
terrainPreset,
terrainParams,
customTerrainBoxes,
sensorCfg,
sensorMode,
}: TrainingForm): TrainingRequest {
const { sourceJobId, ...mobileValues } = mobileParams;
return {
taskId,
numEnvs,
maxIterations,
seed,
runName,
device,
gpuIds,
wandbMode: mobile ? 'disabled' : wandbMode,
...(mobile
? {
mobilePackageId,
mobileParams: { ...mobileValues, ...(sourceJobId ? { sourceJobId } : {}) },
}
: {}),
rewardPresetId: taskId === 'Unitree-Go2-Flat' ? rewardPresetId || undefined : undefined,
...(pretrainedSourceId ? { pretrainedSourceId } : {}),
...(terrainPreset ? { terrainPreset, terrainParams } : {}),
...(terrainPreset === 'custom_boxes' ? { customTerrainBoxes } : {}),
...(taskId === OBSTACLE_TASK_ID
? { sensorType: 'raycast' as const, sensorCfg: { ...sensorCfg, sensorMode } }
: {}),
};
}
@@ -0,0 +1,37 @@
import type { TrainingJob } from './types';
export const ACTIVE_STATES = new Set(['queued', 'running']);
export function stateLabel(state: TrainingJob['state']): string {
return {
queued: '排队中',
running: '训练中',
succeeded: '已完成',
failed: '失败',
cancelled: '已取消',
}[state];
}
export const TERRAIN_LABELS: Record<string, string> = {
custom_boxes: '自定义场景碰撞布局(AABB近似)',
plane: '平地',
discrete_obstacles: '离散障碍物',
rough: '崎岖地面',
pyramid_stairs: '金字塔台阶',
wave: '波浪地形',
};
export const PARAMETER_LABELS: Record<string, string> = {
size: '地图尺寸 m',
obstacle_count: '障碍物数量',
obstacle_height_min: '最小障碍高度 m',
obstacle_height_max: '最大障碍高度 m',
spacing: '障碍物间距 m',
friction: '地面摩擦',
roughness: '崎岖高度 m',
step_height: '台阶高度 m',
wave_amplitude: '波浪幅度 m',
fov: '感知角 FOV',
maxDistance: '探测距离 m',
safetyDistance: '安全距离 m',
avoidanceWeight: '避障权重',
};
+1 -11
View File
@@ -1,4 +1,4 @@
import type { ScalarPoint, ScalarSeries } from '../training/types';
import type { ScalarPoint } from '../training/types';
/**
* 固定容量、按 step 去重的数值环形缓冲。
@@ -84,13 +84,3 @@ export class ScalarRingBuffer {
this.length = 0;
}
}
export interface BufferedMetricSeries {
trialId: string;
tag: string;
buffer: ScalarRingBuffer;
}
export function snapshotSeries(series: BufferedMetricSeries): ScalarSeries {
return { tag: series.tag, points: series.buffer.snapshot() };
}
-7
View File
@@ -2,7 +2,6 @@ import type {
ObjectiveWeights,
RewardConfiguration,
TuningSessionState,
TuningTrial,
} from '../training/types';
export type RewardSection = keyof RewardConfiguration;
@@ -232,12 +231,6 @@ export function mergeRewardPatch(
};
}
export function latestCompletedTrial(trials: readonly TuningTrial[]): TuningTrial | undefined {
return [...trials]
.reverse()
.find((trial) => trial.state === 'completed' && trial.evaluation !== undefined);
}
export function formatMetric(value: number | null | undefined, digits = 4): string {
if (value === null || value === undefined || !Number.isFinite(value)) return '—';
const magnitude = Math.abs(value);
@@ -0,0 +1,220 @@
import * as THREE from 'three';
import type { MjvGeom } from '@mujoco/mujoco';
import { describe, expect, it, vi } from 'vitest';
import { MuJoCoViewer } from './MuJoCoViewer';
const enums = Object.fromEntries(
['PLANE', 'HFIELD', 'SPHERE', 'CAPSULE', 'ELLIPSOID', 'CYLINDER', 'BOX', 'MESH'].map(
(name, value) => [`mjGEOM_${name}`, { value }],
),
);
function geom(patch: Partial<MjvGeom> = {}): MjvGeom {
return {
type: 7,
dataid: 0,
objtype: 5,
objid: 0,
texid: -1,
size: new Float64Array([1, 2, 3]),
rgba: new Float32Array([0.2, 0.3, 0.4, 1]),
shininess: 0.5,
reflectance: 0.1,
mat: new Float64Array([0, -1, 0, 1, 0, 0, 0, 0, 1]),
pos: new Float64Array([1, 2, 3]),
...patch,
} as MjvGeom;
}
interface MeshTestView {
primitive(g: MjvGeom): THREE.BufferGeometry;
geometryKey(g: MjvGeom): string;
createMesh(g: MjvGeom, key: string): THREE.Mesh;
updateMesh(mesh: THREE.Mesh, g: MjvGeom): void;
disposeMesh(mesh: THREE.Mesh): void;
releaseModel(): void;
meshes: THREE.Mesh[];
geometries: Map<string, THREE.BufferGeometry>;
textures: Map<number, THREE.DataTexture>;
selected: THREE.Mesh | null;
highlightedBodyId: number;
}
function fixture() {
const model = {
mesh_vertadr: [0],
mesh_vertnum: [3],
mesh_faceadr: [0],
mesh_facenum: [1],
mesh_vert: new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]),
mesh_face: new Int32Array([0, 1, 2]),
mesh_normaladr: [0],
mesh_normalnum: [3],
mesh_normal: new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1]),
mesh_texcoordadr: [0],
mesh_texcoordnum: [3],
mesh_texcoord: new Float32Array([0, 0, 1, 0, 0, 1]),
hfield_adr: [0],
hfield_nrow: [2],
hfield_ncol: [2],
hfield_data: new Float32Array([0, 0.5, 0.75, 1]),
hfield_size: [2, 3, 4, 0.1],
geom_bodyid: [1],
geom_group: [0],
};
const viewer = Object.assign(Object.create(MuJoCoViewer.prototype), {
session: {
model,
module: { mjtGeom: enums, mjtObj: { mjOBJ_GEOM: { value: 5 } } },
clearExternalForce: vi.fn(),
},
scene: new THREE.Scene(),
meshes: [],
geometries: new Map(),
textures: new Map(),
renderer: { capabilities: { getMaxAnisotropy: () => 8 } },
modelHasVisuals: true,
highlightedBodyId: -1,
selected: null,
navigationGoal: { clear: vi.fn() },
trainingTerrainIds: new Set(),
visualizationHelpers: { attach: vi.fn() },
}) as MeshTestView;
return { viewer, model };
}
function expectGeometry(actual: THREE.BufferGeometry, expected: THREE.BufferGeometry) {
for (const key of Object.keys(expected.attributes))
expect(Array.from(actual.getAttribute(key).array)).toEqual(
Array.from(expected.getAttribute(key).array),
);
expect(actual.getIndex() && Array.from(actual.getIndex()!.array)).toEqual(
expected.getIndex() && Array.from(expected.getIndex()!.array),
);
actual.dispose();
expected.dispose();
}
describe('Viewer 网格提取前的几何/资源表征', () => {
it('模型 key 不受尺寸变化影响,凸包最低位不改变 mesh 索引,动态 key 包含尺寸', () => {
const { viewer } = fixture();
expect(viewer.geometryKey(geom())).toBe('model:0:7:0');
expect(viewer.geometryKey(geom({ dataid: 1, size: new Float64Array([4, 5, 6]) }))).toBe(
'model:0:7:0',
);
expect(viewer.geometryKey(geom({ objtype: 0, dataid: 1 }))).toBe('dynamic:7:0:1,2,3');
});
it('primitive 保留原细分、半尺寸和旋转,不替换胶囊算法', () => {
const { viewer } = fixture();
expectGeometry(viewer.primitive(geom({ type: 0 })), new THREE.PlaneGeometry(2, 4));
expectGeometry(viewer.primitive(geom({ type: 2 })), new THREE.SphereGeometry(1, 24, 16));
expectGeometry(viewer.primitive(geom({ type: 6 })), new THREE.BoxGeometry(2, 4, 6));
const cylinder = new THREE.CylinderGeometry(1, 1, 6, 24).rotateX(Math.PI / 2);
expectGeometry(viewer.primitive(geom({ type: 5 })), cylinder);
expectGeometry(
viewer.primitive(geom({ type: 4 })),
new THREE.SphereGeometry(1, 24, 16).scale(1, 2, 3),
);
const path = new THREE.Path();
path.absarc(0, -3, 1, Math.PI * 1.5, 0);
path.absarc(0, 3, 1, 0, Math.PI * 0.5);
expectGeometry(
viewer.primitive(geom({ type: 3 })),
new THREE.LatheGeometry(path.getPoints(24), 16).rotateX(Math.PI / 2),
);
});
it('mesh 复制顶点/索引/法线/UV,不持有随 WASM 变化的原数组', () => {
const { viewer, model } = fixture();
const geometry = viewer.primitive(geom({ dataid: 1 }));
expect(Array.from(geometry.getAttribute('position').array)).toEqual(
Array.from(model.mesh_vert),
);
expect(Array.from(geometry.getIndex()!.array)).toEqual([0, 1, 2]);
expect(Array.from(geometry.getAttribute('normal').array)).toEqual(
Array.from(model.mesh_normal),
);
expect(Array.from(geometry.getAttribute('uv').array)).toEqual(Array.from(model.mesh_texcoord));
model.mesh_vert[0] = 99;
expect(geometry.getAttribute('position').getX(0)).toBe(0);
geometry.dispose();
});
it('高度场复用原位置/索引转换', () => {
const { viewer } = fixture();
const geometry = viewer.primitive(geom({ type: 1 }));
expect(Array.from(geometry.getAttribute('position').array)).toEqual([
-2, -3, 0, 2, -3, 2, -2, 3, 3, 2, 3, 4,
]);
expect(Array.from(geometry.getIndex()!.array)).toEqual([0, 1, 2, 1, 3, 2]);
geometry.dispose();
});
it('共享 mesh 几何只在释放模型时释放一次,每个材质独立释放', () => {
const { viewer } = fixture();
const first = viewer.createMesh(geom(), 'first');
const second = viewer.createMesh(geom({ dataid: 1 }), 'second');
expect(first.geometry).toBe(second.geometry);
expect(viewer.geometries.size).toBe(1);
const disposeGeometry = vi.spyOn(first.geometry, 'dispose');
const disposeFirst = vi.spyOn(first.material as THREE.Material, 'dispose');
const disposeSecond = vi.spyOn(second.material as THREE.Material, 'dispose');
viewer.disposeMesh(first);
expect(disposeGeometry).not.toHaveBeenCalled();
viewer.meshes = [second];
viewer.releaseModel();
expect(disposeGeometry).toHaveBeenCalledOnce();
expect(disposeFirst).toHaveBeenCalledOnce();
expect(disposeSecond).toHaveBeenCalledOnce();
expect(viewer.geometries.size).toBe(0);
});
it('动态几何归 mesh 所有,替换/清理即释放,不进入共享缓存', () => {
const { viewer } = fixture();
const mesh = viewer.createMesh(geom({ type: 2, objtype: 0 }), 'dynamic');
const disposeGeometry = vi.spyOn(mesh.geometry, 'dispose');
const disposeMaterial = vi.spyOn(mesh.material as THREE.Material, 'dispose');
viewer.disposeMesh(mesh);
expect(disposeGeometry).toHaveBeenCalledOnce();
expect(disposeMaterial).toHaveBeenCalledOnce();
expect(viewer.geometries.size).toBe(0);
});
it('保持矩阵排列、碰撞体透明度/深度规则和选中高亮', () => {
const { viewer, model } = fixture();
const g = geom();
const mesh = viewer.createMesh(g, 'model');
const material = mesh.material as THREE.MeshStandardMaterial;
viewer.updateMesh(mesh, g);
expect(mesh.matrix.elements).toEqual([0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1, 0, 1, 2, 3, 1]);
expect(mesh.userData).toMatchObject({ geomId: 0, bodyId: 1, geomType: 7, isCollision: true });
expect(material.opacity).toBe(0.28);
expect(material.color.getHex()).toBe(0x22d3ee);
expect(material.roughness).toBe(0.85);
expect(material.metalness).toBe(0);
expect(material.depthTest).toBe(false);
expect(material.depthWrite).toBe(false);
expect(mesh.renderOrder).toBe(60);
model.geom_bodyid[0] = 0;
viewer.updateMesh(mesh, g);
expect(material.opacity).toBe(0.12);
expect(material.color.getHex()).toBe(0x64748b);
expect(material.depthTest).toBe(true);
model.geom_group[0] = 1;
viewer.selected = mesh;
viewer.updateMesh(mesh, g);
expect(material.opacity).toBe(1);
expect(material.depthWrite).toBe(true);
expect(material.emissive.getHex()).toBe(0x14532d);
expect(material.emissiveIntensity).toBe(1);
expect(material.roughness).toBe(0.5);
expect(material.metalness).toBe(0.1);
viewer.highlightedBodyId = 0;
g.rgba[3] = 0.9995;
viewer.updateMesh(mesh, g);
expect(material.emissive.getHex()).toBe(0x8a6d00);
expect(material.emissiveIntensity).toBe(0.85);
expect(material.transparent).toBe(true);
expect(material.depthWrite).toBe(true);
viewer.meshes = [mesh];
viewer.releaseModel();
});
});
+7 -131
View File
@@ -7,7 +7,9 @@ import type {
SimulationSession,
SimulationSnapshot,
} from '../simulation/SimulationSession';
import { heightfieldGeometryData, meshIdFromSceneDataId } from '../simulation/geometry';
import { meshIdFromSceneDataId } from '../simulation/geometry';
import { createMuJoCoGeometry } from './geometryFactory';
import { applyMeshHighlight, updateMeshPresentation } from './meshPresentation';
import { OrientationGizmo } from './OrientationGizmo';
import {
closestRayAxisParameter,
@@ -61,19 +63,6 @@ interface ViewerCallbacks {
): void;
}
class CapsuleGeometry extends THREE.BufferGeometry {
constructor(radius: number, length: number) {
super();
const path = new THREE.Path();
path.absarc(0, -length / 2, radius, Math.PI * 1.5, 0);
path.absarc(0, length / 2, radius, 0, Math.PI * 0.5);
const source = new THREE.LatheGeometry(path.getPoints(24), 16);
this.copy(source);
source.dispose();
this.rotateX(Math.PI / 2);
}
}
export class MuJoCoViewer {
private readonly navigationGoal: NavigationGoal;
private readonly trainingTerrainIds = new Set<number>();
@@ -613,17 +602,7 @@ export class MuJoCoViewer {
);
}
private applyMeshHighlight(mesh: THREE.Mesh): void {
const material = mesh.material as THREE.MeshStandardMaterial;
if (Number(mesh.userData.bodyId) === this.highlightedBodyId) {
material.emissive.setHex(0x8a6d00);
material.emissiveIntensity = 0.85;
} else if (mesh === this.selected) {
material.emissive.setHex(0x14532d);
material.emissiveIntensity = 1;
} else {
material.emissive.setHex(0);
material.emissiveIntensity = 1;
}
applyMeshHighlight(mesh, this.selected, this.highlightedBodyId);
}
private fitCamera(session: SimulationSession): void {
@@ -911,67 +890,7 @@ export class MuJoCoViewer {
return `dynamic:${g.type}:${dataId}:${Array.from(g.size).join(',')}`;
}
private primitive(g: MjvGeom): THREE.BufferGeometry {
const m = this.session!.module,
t = g.type,
s = g.size;
if (t === m.mjtGeom.mjGEOM_HFIELD.value && g.dataid >= 0)
return this.heightfieldGeometry(g.dataid);
if (t === m.mjtGeom.mjGEOM_PLANE.value)
return new THREE.PlaneGeometry(2 * (s[0] || 1e3), 2 * (s[1] || 1e3));
if (t === m.mjtGeom.mjGEOM_SPHERE.value) return new THREE.SphereGeometry(s[0], 24, 16);
if (t === m.mjtGeom.mjGEOM_CAPSULE.value) return new CapsuleGeometry(s[0], 2 * s[2]);
if (t === m.mjtGeom.mjGEOM_BOX.value)
return new THREE.BoxGeometry(2 * s[0], 2 * s[1], 2 * s[2]);
if (t === m.mjtGeom.mjGEOM_CYLINDER.value) {
const x = new THREE.CylinderGeometry(s[0], s[0], 2 * s[2], 24);
x.rotateX(Math.PI / 2);
return x;
}
if (t === m.mjtGeom.mjGEOM_ELLIPSOID.value) {
const x = new THREE.SphereGeometry(1, 24, 16);
x.scale(s[0], s[1], s[2]);
return x;
}
if (t === m.mjtGeom.mjGEOM_MESH.value && g.dataid >= 0)
return this.meshGeometry(meshIdFromSceneDataId(g.dataid));
return new THREE.BufferGeometry();
}
private heightfieldGeometry(id: number): THREE.BufferGeometry {
const data = heightfieldGeometryData(this.session!.model, id),
geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(data.positions, 3));
geometry.setIndex(new THREE.BufferAttribute(data.indices, 1));
geometry.computeVertexNormals();
geometry.computeBoundingSphere();
return geometry;
}
private meshGeometry(id: number): THREE.BufferGeometry {
const m = this.session!.model;
const va = Number(m.mesh_vertadr[id]),
vn = Number(m.mesh_vertnum[id]),
fa = Number(m.mesh_faceadr[id]),
fn = Number(m.mesh_facenum[id]);
// Each embind field access creates a view. Fetch once, then copy in bulk;
// a complete wheel otherwise creates millions of temporary typed arrays.
const positions = new Float32Array(m.mesh_vert.subarray(va * 3, (va + vn) * 3));
const indices = new Uint32Array(m.mesh_face.subarray(fa * 3, (fa + fn) * 3));
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setIndex(new THREE.BufferAttribute(indices, 1));
const na = Number(m.mesh_normaladr[id]),
nn = Number(m.mesh_normalnum[id]);
if (nn === vn) {
const normals = new Float32Array(m.mesh_normal.subarray(na * 3, (na + nn) * 3));
geometry.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
} else geometry.computeVertexNormals();
const ta = Number(m.mesh_texcoordadr[id]),
tn = Number(m.mesh_texcoordnum[id]);
if (tn === vn && ta >= 0) {
const uv = new Float32Array(m.mesh_texcoord.subarray(ta * 2, (ta + tn) * 2));
geometry.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
}
geometry.computeBoundingSphere();
return geometry;
return createMuJoCoGeometry(this.session!.module, this.session!.model, g);
}
private texture(id: number): THREE.DataTexture | undefined {
if (id < 0) return;
@@ -1027,54 +946,11 @@ export class MuJoCoViewer {
return mesh;
}
private updateMesh(mesh: THREE.Mesh, g: MjvGeom): void {
const mat = mesh.material as THREE.MeshStandardMaterial,
geomId = g.objtype === this.session!.module.mjtObj.mjOBJ_GEOM.value ? g.objid : -1,
const geomId = g.objtype === this.session!.module.mjtObj.mjOBJ_GEOM.value ? g.objid : -1,
bodyId = geomId >= 0 ? Number(this.session!.model.geom_bodyid[geomId]) : -1,
isCollision =
this.modelHasVisuals && geomId >= 0 && Number(this.session!.model.geom_group[geomId]) === 0;
if (isCollision) {
const worldCollision = bodyId === 0;
mat.color.setHex(worldCollision ? 0x64748b : 0x22d3ee);
mat.opacity = worldCollision ? 0.12 : 0.28;
mat.transparent = true;
mat.depthTest = worldCollision;
mat.depthWrite = false;
mat.roughness = 0.85;
mat.metalness = 0;
mesh.renderOrder = worldCollision ? 0 : 60;
} else {
mat.color.setRGB(g.rgba[0], g.rgba[1], g.rgba[2]);
mat.opacity = g.rgba[3];
mat.transparent = g.rgba[3] < 1;
mat.depthTest = true;
mat.depthWrite = true;
mat.roughness = Math.max(0.05, 1 - g.shininess);
mat.metalness = g.reflectance;
mesh.renderOrder = 0;
}
mesh.matrix.set(
g.mat[0],
g.mat[1],
g.mat[2],
g.pos[0],
g.mat[3],
g.mat[4],
g.mat[5],
g.pos[1],
g.mat[6],
g.mat[7],
g.mat[8],
g.pos[2],
0,
0,
0,
1,
);
mesh.matrixWorldNeedsUpdate = true;
mesh.userData.geomId = geomId;
mesh.userData.bodyId = bodyId;
mesh.userData.geomType = g.type;
mesh.userData.isCollision = isCollision;
updateMeshPresentation(mesh, g, { geomId, bodyId, isCollision });
this.applyMeshHighlight(mesh);
}
@@ -0,0 +1,84 @@
import * as THREE from 'three';
import type { MainModule, MjModel, MjvGeom } from '@mujoco/mujoco';
import { heightfieldGeometryData, meshIdFromSceneDataId } from '../simulation/geometry';
class CapsuleGeometry extends THREE.BufferGeometry {
constructor(radius: number, length: number) {
super();
const path = new THREE.Path();
path.absarc(0, -length / 2, radius, Math.PI * 1.5, 0);
path.absarc(0, length / 2, radius, 0, Math.PI * 0.5);
const source = new THREE.LatheGeometry(path.getPoints(24), 16);
this.copy(source);
source.dispose();
this.rotateX(Math.PI / 2);
}
}
/** 仅创建几何;缓存与释放所有权仍由 Viewer 管理。 */
export function createMuJoCoGeometry(
m: MainModule,
model: MjModel,
g: MjvGeom,
): THREE.BufferGeometry {
const t = g.type,
s = g.size;
if (t === m.mjtGeom.mjGEOM_HFIELD.value && g.dataid >= 0)
return heightfieldGeometry(model, g.dataid);
if (t === m.mjtGeom.mjGEOM_PLANE.value)
return new THREE.PlaneGeometry(2 * (s[0] || 1e3), 2 * (s[1] || 1e3));
if (t === m.mjtGeom.mjGEOM_SPHERE.value) return new THREE.SphereGeometry(s[0], 24, 16);
if (t === m.mjtGeom.mjGEOM_CAPSULE.value) return new CapsuleGeometry(s[0], 2 * s[2]);
if (t === m.mjtGeom.mjGEOM_BOX.value)
return new THREE.BoxGeometry(2 * s[0], 2 * s[1], 2 * s[2]);
if (t === m.mjtGeom.mjGEOM_CYLINDER.value) {
const x = new THREE.CylinderGeometry(s[0], s[0], 2 * s[2], 24);
x.rotateX(Math.PI / 2);
return x;
}
if (t === m.mjtGeom.mjGEOM_ELLIPSOID.value) {
const x = new THREE.SphereGeometry(1, 24, 16);
x.scale(s[0], s[1], s[2]);
return x;
}
if (t === m.mjtGeom.mjGEOM_MESH.value && g.dataid >= 0)
return meshGeometry(model, meshIdFromSceneDataId(g.dataid));
return new THREE.BufferGeometry();
}
function heightfieldGeometry(model: MjModel, id: number): THREE.BufferGeometry {
const data = heightfieldGeometryData(model, id),
geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(data.positions, 3));
geometry.setIndex(new THREE.BufferAttribute(data.indices, 1));
geometry.computeVertexNormals();
geometry.computeBoundingSphere();
return geometry;
}
function meshGeometry(m: MjModel, id: number): THREE.BufferGeometry {
const va = Number(m.mesh_vertadr[id]),
vn = Number(m.mesh_vertnum[id]),
fa = Number(m.mesh_faceadr[id]),
fn = Number(m.mesh_facenum[id]);
// 每个 embind 字段只读取一次并批量复制,避免逐顶点产生临时 WASM view。
const positions = new Float32Array(m.mesh_vert.subarray(va * 3, (va + vn) * 3));
const indices = new Uint32Array(m.mesh_face.subarray(fa * 3, (fa + fn) * 3));
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setIndex(new THREE.BufferAttribute(indices, 1));
const na = Number(m.mesh_normaladr[id]),
nn = Number(m.mesh_normalnum[id]);
if (nn === vn) {
const normals = new Float32Array(m.mesh_normal.subarray(na * 3, (na + nn) * 3));
geometry.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
} else geometry.computeVertexNormals();
const ta = Number(m.mesh_texcoordadr[id]),
tn = Number(m.mesh_texcoordnum[id]);
if (tn === vn && ta >= 0) {
const uv = new Float32Array(m.mesh_texcoord.subarray(ta * 2, (ta + tn) * 2));
geometry.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
}
geometry.computeBoundingSphere();
return geometry;
}
@@ -0,0 +1,72 @@
import type * as THREE from 'three';
import type { MjvGeom } from '@mujoco/mujoco';
/** 更新既有网格展示,不创建/缓存/释放任何 Three.js 或 MuJoCo 资源。 */
export function updateMeshPresentation(
mesh: THREE.Mesh,
g: MjvGeom,
{ geomId, bodyId, isCollision }: { geomId: number; bodyId: number; isCollision: boolean },
): void {
const mat = mesh.material as THREE.MeshStandardMaterial;
if (isCollision) {
const worldCollision = bodyId === 0;
mat.color.setHex(worldCollision ? 0x64748b : 0x22d3ee);
mat.opacity = worldCollision ? 0.12 : 0.28;
mat.transparent = true;
mat.depthTest = worldCollision;
mat.depthWrite = false;
mat.roughness = 0.85;
mat.metalness = 0;
mesh.renderOrder = worldCollision ? 0 : 60;
} else {
mat.color.setRGB(g.rgba[0], g.rgba[1], g.rgba[2]);
mat.opacity = g.rgba[3];
mat.transparent = g.rgba[3] < 1;
mat.depthTest = true;
mat.depthWrite = true;
mat.roughness = Math.max(0.05, 1 - g.shininess);
mat.metalness = g.reflectance;
mesh.renderOrder = 0;
}
mesh.matrix.set(
g.mat[0],
g.mat[1],
g.mat[2],
g.pos[0],
g.mat[3],
g.mat[4],
g.mat[5],
g.pos[1],
g.mat[6],
g.mat[7],
g.mat[8],
g.pos[2],
0,
0,
0,
1,
);
mesh.matrixWorldNeedsUpdate = true;
mesh.userData.geomId = geomId;
mesh.userData.bodyId = bodyId;
mesh.userData.geomType = g.type;
mesh.userData.isCollision = isCollision;
}
export function applyMeshHighlight(
mesh: THREE.Mesh,
selected: THREE.Mesh | null,
highlightedBodyId: number,
): void {
const material = mesh.material as THREE.MeshStandardMaterial;
if (Number(mesh.userData.bodyId) === highlightedBodyId) {
material.emissive.setHex(0x8a6d00);
material.emissiveIntensity = 0.85;
} else if (mesh === selected) {
material.emissive.setHex(0x14532d);
material.emissiveIntensity = 1;
} else {
material.emissive.setHex(0);
material.emissiveIntensity = 1;
}
}