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
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:
@@ -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
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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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() }),
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,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() };
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user