refactor(web-platform): release V0.7.2 项目常规优化
This commit is contained in:
@@ -1,98 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { ActuatorControl } from './SidebarPanel';
|
||||
import type { ActuatorInfo } from '../../simulation/SimulationSession';
|
||||
|
||||
const actuator: ActuatorInfo = {
|
||||
id: 0,
|
||||
name: 'shoulder_motor',
|
||||
value: 0.5,
|
||||
min: -1,
|
||||
max: 1,
|
||||
limited: true,
|
||||
jointId: 0,
|
||||
jointName: 'shoulder',
|
||||
jointType: 3,
|
||||
unit: 'N·m',
|
||||
kind: 'motor',
|
||||
controlCount: 1,
|
||||
gear: 2,
|
||||
gain: 1,
|
||||
kp: 0,
|
||||
kv: 0,
|
||||
ctrlLimited: true,
|
||||
ctrlMin: -1,
|
||||
ctrlMax: 1,
|
||||
forceLimited: true,
|
||||
forceMin: -20,
|
||||
forceMax: 20,
|
||||
};
|
||||
|
||||
describe('ActuatorControl', () => {
|
||||
it('显示对应关节和常用力矩单位', () => {
|
||||
render(<ActuatorControl actuator={actuator} onControl={() => {}} onParameters={() => {}} />);
|
||||
expect(screen.getByText('shoulder_motor')).toBeVisible();
|
||||
expect(screen.getByText('关节:shoulder')).toBeVisible();
|
||||
expect(screen.getByText('1.000 N·m')).toBeVisible();
|
||||
});
|
||||
|
||||
it('内部按 gear 换算输出,但参数面板只开放 kp、kv 等业务参数', () => {
|
||||
const onControl = vi.fn(),
|
||||
onParameters = vi.fn();
|
||||
render(
|
||||
<ActuatorControl actuator={actuator} onControl={onControl} onParameters={onParameters} />,
|
||||
);
|
||||
fireEvent.change(screen.getByRole('slider'), { target: { value: '2' } });
|
||||
expect(onControl).toHaveBeenCalledWith(1);
|
||||
fireEvent.click(screen.getByText('常用参数'));
|
||||
expect(screen.queryByLabelText('传动比 gear')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('固定增益 gain')).not.toBeInTheDocument();
|
||||
const kp = screen.getByLabelText(/kp(MJCF stiffness/);
|
||||
fireEvent.change(kp, { target: { value: '3' } });
|
||||
fireEvent.blur(kp);
|
||||
expect(onParameters).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kp: 3, ctrlLimited: true, forceLimited: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('position 伺服使用角度目标并开放 kp、kv', () => {
|
||||
const onParameters = vi.fn();
|
||||
render(
|
||||
<ActuatorControl
|
||||
actuator={{
|
||||
...actuator,
|
||||
name: 'shoulder_servo',
|
||||
kind: 'position',
|
||||
unit: '°',
|
||||
value: Math.PI / 2,
|
||||
min: -Math.PI,
|
||||
max: Math.PI,
|
||||
gear: 1,
|
||||
kp: 100,
|
||||
kv: 10,
|
||||
gain: 100,
|
||||
}}
|
||||
onControl={() => {}}
|
||||
onParameters={onParameters}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('90.000 °')).toBeVisible();
|
||||
fireEvent.click(screen.getByText('常用参数'));
|
||||
const kp = screen.getByLabelText(/位置增益 kp/);
|
||||
fireEvent.change(kp, { target: { value: '150' } });
|
||||
fireEvent.blur(kp);
|
||||
expect(onParameters).toHaveBeenCalledWith(expect.objectContaining({ kp: 150, kv: 10 }));
|
||||
});
|
||||
|
||||
it('非 motor 驱动器保持原始控制单位且不开放通用参数编辑', () => {
|
||||
render(
|
||||
<ActuatorControl
|
||||
actuator={{ ...actuator, name: 'custom', kind: 'other', unit: '', value: 0.25 }}
|
||||
onControl={() => {}}
|
||||
onParameters={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('0.250')).toBeVisible();
|
||||
expect(screen.queryByText('常用参数')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/不是可直接编辑的 motor\/position/)).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { DataRecordingPanel } from './DataRecordingPanel';
|
||||
import type { DataRecorderStatus } from '../../simulation/DataRecorder';
|
||||
|
||||
const status: DataRecorderStatus = {
|
||||
recording: false,
|
||||
limitReached: false,
|
||||
sampleCount: 2,
|
||||
segmentCount: 1,
|
||||
config: { bodyId: 1, sampleRateHz: 50, maxSamples: 30_000 },
|
||||
body: { id: 1, name: 'base' },
|
||||
latest: {
|
||||
sequence: 1,
|
||||
segment: 0,
|
||||
simulationTime: 0.02,
|
||||
values: {
|
||||
speed_horizontal: 1.25,
|
||||
speed_3d: 1.3,
|
||||
velocity_x: 1.25,
|
||||
velocity_y: 0,
|
||||
velocity_z: 0.1,
|
||||
pitch: 0.1,
|
||||
roll: -0.05,
|
||||
yaw: 0.2,
|
||||
height: 0.45,
|
||||
position_x: 0.1,
|
||||
position_y: 0,
|
||||
contact_count: 4,
|
||||
control_rms: 2,
|
||||
actuator_force_rms: 3,
|
||||
actuator_power_abs: 8,
|
||||
},
|
||||
},
|
||||
summary: {
|
||||
duration: 0.02,
|
||||
distanceHorizontal: 0.1,
|
||||
maxHorizontalSpeed: 1.25,
|
||||
maxAbsRoll: 0.05,
|
||||
maxAbsPitch: 0.1,
|
||||
minHeight: 0.44,
|
||||
maxHeight: 0.46,
|
||||
},
|
||||
};
|
||||
|
||||
describe('DataRecordingPanel', () => {
|
||||
it('展示实时遥测并提供配置、记录和导出操作', () => {
|
||||
const configure = vi.fn(),
|
||||
start = vi.fn(),
|
||||
exportData = vi.fn();
|
||||
render(
|
||||
<DataRecordingPanel
|
||||
status={status}
|
||||
bodies={[
|
||||
{ id: 0, name: 'world', parentId: 0 },
|
||||
{ id: 1, name: 'base', parentId: 0 },
|
||||
{ id: 2, name: 'payload', parentId: 1 },
|
||||
]}
|
||||
onConfigure={configure}
|
||||
onStart={start}
|
||||
onStop={vi.fn()}
|
||||
onClear={vi.fn()}
|
||||
onExport={exportData}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getAllByText('1.250 m/s').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('5.73°').length).toBeGreaterThan(0);
|
||||
fireEvent.change(screen.getByLabelText('记录 Body'), { target: { value: '2' } });
|
||||
expect(configure).toHaveBeenCalledWith({ bodyId: 2 });
|
||||
fireEvent.click(screen.getByRole('button', { name: '开始记录' }));
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole('button', { name: '导出 CSV' }));
|
||||
expect(exportData).toHaveBeenCalledWith('csv');
|
||||
});
|
||||
});
|
||||
@@ -1,198 +0,0 @@
|
||||
import { Circle, Download, Square, Trash2 } from 'lucide-react';
|
||||
import type { BodyInfo } from '../../simulation/SimulationSession';
|
||||
import type { DataRecorderConfig, DataRecorderStatus } from '../../simulation/DataRecorder';
|
||||
import { Badge, Button, PropertyRow, Select } from '../../components/ui';
|
||||
|
||||
const SAMPLE_RATES = [10, 20, 50, 100, 200];
|
||||
const radiansToDegrees = 180 / Math.PI;
|
||||
|
||||
function number(value: number | undefined, digits = 3): string {
|
||||
return Number.isFinite(value) ? value!.toFixed(digits) : '—';
|
||||
}
|
||||
|
||||
function degrees(value: number | undefined): string {
|
||||
return Number.isFinite(value) ? `${(value! * radiansToDegrees).toFixed(2)}°` : '—';
|
||||
}
|
||||
|
||||
export function DataRecordingPanel({
|
||||
status,
|
||||
bodies,
|
||||
onConfigure,
|
||||
onStart,
|
||||
onStop,
|
||||
onClear,
|
||||
onExport,
|
||||
}: {
|
||||
status: DataRecorderStatus;
|
||||
bodies: BodyInfo[];
|
||||
onConfigure: (patch: Partial<DataRecorderConfig>) => void;
|
||||
onStart: () => void;
|
||||
onStop: () => void;
|
||||
onClear: () => void;
|
||||
onExport: (format: 'csv' | 'json') => void;
|
||||
}) {
|
||||
const values = status.latest?.values,
|
||||
availableBodies = bodies.filter(
|
||||
(body) => body.id > 0 && !body.name.startsWith('__platform_map_'),
|
||||
);
|
||||
return (
|
||||
<div className="space-y-3 p-3">
|
||||
<section className="rounded-lg border border-border bg-surface p-3">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-text-primary">仿真遥测记录</h3>
|
||||
<p className="mt-0.5 text-[10px] text-text-tertiary">
|
||||
数据仅保存在当前浏览器会话,可导出 CSV 或 JSON。
|
||||
</p>
|
||||
</div>
|
||||
<Badge tone={status.recording ? 'success' : status.limitReached ? 'warning' : 'neutral'}>
|
||||
{status.recording ? '记录中' : status.limitReached ? '已达上限' : '已停止'}
|
||||
</Badge>
|
||||
</div>
|
||||
<label className="block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">记录 Body</span>
|
||||
<Select
|
||||
aria-label="记录 Body"
|
||||
className="w-full"
|
||||
value={status.config.bodyId}
|
||||
disabled={status.recording}
|
||||
onChange={(event) => onConfigure({ bodyId: Number(event.target.value) })}
|
||||
>
|
||||
{(availableBodies.length ? availableBodies : [status.body]).map((body) => (
|
||||
<option key={body.id} value={body.id}>
|
||||
{body.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
<label className="block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">采样频率</span>
|
||||
<Select
|
||||
aria-label="采样频率"
|
||||
className="w-full"
|
||||
value={status.config.sampleRateHz}
|
||||
disabled={status.recording}
|
||||
onChange={(event) => onConfigure({ sampleRateHz: Number(event.target.value) })}
|
||||
>
|
||||
{SAMPLE_RATES.map((rate) => (
|
||||
<option key={rate} value={rate}>
|
||||
{rate} Hz
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
<label className="block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">样本上限</span>
|
||||
<input
|
||||
aria-label="样本上限"
|
||||
type="number"
|
||||
min={100}
|
||||
max={1_000_000}
|
||||
step={1000}
|
||||
className="field h-8 w-full px-2 text-xs"
|
||||
value={status.config.maxSamples}
|
||||
disabled={status.recording}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
if (Number.isFinite(value) && value >= 100) onConfigure({ maxSamples: value });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
{status.recording ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Square className="h-3 w-3 fill-current" />}
|
||||
onClick={onStop}
|
||||
>
|
||||
停止记录
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Circle className="h-3 w-3 fill-current" />}
|
||||
onClick={onStart}
|
||||
disabled={status.limitReached}
|
||||
>
|
||||
开始记录
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="danger"
|
||||
icon={<Trash2 className="h-3.5 w-3.5" />}
|
||||
onClick={onClear}
|
||||
disabled={!status.sampleCount}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 border-t border-border pt-2">
|
||||
<PropertyRow label="样本" value={`${status.sampleCount} / ${status.config.maxSamples}`} />
|
||||
<PropertyRow label="有效时长" value={`${number(status.summary.duration, 2)} s`} />
|
||||
<PropertyRow label="分段" value={status.segmentCount} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-border bg-surface p-3">
|
||||
<h3 className="mb-2 text-xs font-semibold text-text-primary">实时运动状态</h3>
|
||||
<PropertyRow label="水平速度" value={`${number(values?.speed_horizontal)} m/s`} />
|
||||
<PropertyRow label="三维速度" value={`${number(values?.speed_3d)} m/s`} />
|
||||
<PropertyRow
|
||||
label="世界系速度 XYZ"
|
||||
value={`${number(values?.velocity_x)}, ${number(values?.velocity_y)}, ${number(values?.velocity_z)} m/s`}
|
||||
/>
|
||||
<PropertyRow label="俯仰角" value={degrees(values?.pitch)} />
|
||||
<PropertyRow label="侧倾角" value={degrees(values?.roll)} />
|
||||
<PropertyRow label="偏航角" value={degrees(values?.yaw)} />
|
||||
<PropertyRow label="机身高度" value={`${number(values?.height)} m`} />
|
||||
<PropertyRow
|
||||
label="世界系位置 XY"
|
||||
value={`${number(values?.position_x)}, ${number(values?.position_y)} m`}
|
||||
/>
|
||||
<PropertyRow label="场景接触数" value={number(values?.contact_count, 0)} />
|
||||
<PropertyRow label="控制输入 RMS" value={number(values?.control_rms)} />
|
||||
<PropertyRow label="驱动力 RMS" value={number(values?.actuator_force_rms)} />
|
||||
<PropertyRow label="绝对驱动功率" value={`${number(values?.actuator_power_abs)} W`} />
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-border bg-surface p-3">
|
||||
<h3 className="mb-2 text-xs font-semibold text-text-primary">本次记录摘要</h3>
|
||||
<PropertyRow
|
||||
label="累计水平里程"
|
||||
value={`${number(status.summary.distanceHorizontal)} m`}
|
||||
/>
|
||||
<PropertyRow
|
||||
label="最大水平速度"
|
||||
value={`${number(status.summary.maxHorizontalSpeed)} m/s`}
|
||||
/>
|
||||
<PropertyRow label="最大俯仰角" value={degrees(status.summary.maxAbsPitch)} />
|
||||
<PropertyRow label="最大侧倾角" value={degrees(status.summary.maxAbsRoll)} />
|
||||
<PropertyRow
|
||||
label="高度范围"
|
||||
value={`${number(status.summary.minHeight)} – ${number(status.summary.maxHeight)} m`}
|
||||
/>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
icon={<Download className="h-3.5 w-3.5" />}
|
||||
disabled={!status.sampleCount}
|
||||
onClick={() => onExport('csv')}
|
||||
>
|
||||
导出 CSV
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Download className="h-3.5 w-3.5" />}
|
||||
disabled={!status.sampleCount}
|
||||
onClick={() => onExport('json')}
|
||||
>
|
||||
导出 JSON
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<p className="text-[10px] leading-4 text-text-tertiary">
|
||||
每次仿真重置会创建新分段,避免跨重置计算出错误速度。开发接口支持注册额外标量通道,导出列名保持稳定。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { LocalTrainingPanel } from './LocalTrainingPanel';
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('LocalTrainingPanel', () => {
|
||||
it('连接本地服务并从图形界面发起训练请求', async () => {
|
||||
const health = {
|
||||
version: '0.1.0',
|
||||
ready: true,
|
||||
trainerRoot: '/opt/unitree_rl_mjlab',
|
||||
python: '/env/bin/python',
|
||||
tasks: ['Unitree-Go2-Flat'],
|
||||
};
|
||||
const job = {
|
||||
id: 'a'.repeat(32),
|
||||
state: 'queued',
|
||||
taskId: 'Unitree-Go2-Flat',
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
iteration: 0,
|
||||
maxIterations: 2000,
|
||||
progress: 0,
|
||||
message: '等待启动',
|
||||
logs: [],
|
||||
artifactReady: false,
|
||||
};
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify(health), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify(job), {
|
||||
status: 202,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify(health), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ error: '训练任务不存在或服务已重启' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
render(<LocalTrainingPanel onPolicyReady={vi.fn()} />);
|
||||
fireEvent.change(screen.getByLabelText('训练服务访问令牌'), {
|
||||
target: { value: 'secret-token' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '连接' }));
|
||||
expect(await screen.findByText('/opt/unitree_rl_mjlab')).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText('并行环境'), { target: { value: '32' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '发起本地训练' }));
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||||
const request = fetchMock.mock.calls[1][1] as RequestInit;
|
||||
expect(JSON.parse(String(request.body))).toMatchObject({
|
||||
taskId: 'Unitree-Go2-Flat',
|
||||
numEnvs: 32,
|
||||
device: 'gpu',
|
||||
gpuIds: [0],
|
||||
wandbMode: 'offline',
|
||||
});
|
||||
expect(new Headers(request.headers).get('Authorization')).toBe('Bearer secret-token');
|
||||
expect(await screen.findByText('排队中')).toBeInTheDocument();
|
||||
|
||||
const tokenInput = screen.getByLabelText('训练服务访问令牌');
|
||||
expect(tokenInput).toBeEnabled();
|
||||
fireEvent.change(tokenInput, { target: { value: 'new-secret-token' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '连接' }));
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(4));
|
||||
expect(await screen.findByRole('button', { name: '发起本地训练' })).toBeInTheDocument();
|
||||
expect(sessionStorage.getItem('mujoco-local-training-token')).toBe('new-secret-token');
|
||||
});
|
||||
});
|
||||
@@ -1,435 +0,0 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { Download, Link, Play, Server, Square } from 'lucide-react';
|
||||
import { Badge, Button, ProgressBar, PropertyRow, Select } from '../../components/ui';
|
||||
import { LocalTrainingClient } from '../../training/LocalTrainingClient';
|
||||
import type {
|
||||
TrainingDevice,
|
||||
TrainingJob,
|
||||
TrainingServerInfo,
|
||||
WandbMode,
|
||||
} from '../../training/types';
|
||||
|
||||
const ENDPOINT_KEY = 'mujoco-local-training-endpoint',
|
||||
JOB_KEY = 'mujoco-local-training-job',
|
||||
TOKEN_KEY = 'mujoco-local-training-token';
|
||||
const DEFAULT_ENDPOINT = 'http://127.0.0.1:8765';
|
||||
const ACTIVE_STATES = new Set(['queued', 'running']);
|
||||
function stored(key: string, fallback = ''): string {
|
||||
try {
|
||||
return localStorage.getItem(key) ?? fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
function sessionStored(key: string): string {
|
||||
try {
|
||||
return sessionStorage.getItem(key) ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
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 }: { onPolicyReady(file: File): void }) {
|
||||
const [endpoint, setEndpoint] = useState(() => stored(ENDPOINT_KEY, DEFAULT_ENDPOINT));
|
||||
const [token, setToken] = useState(() => sessionStored(TOKEN_KEY));
|
||||
const [server, setServer] = useState<TrainingServerInfo>();
|
||||
const [job, setJob] = useState<TrainingJob>();
|
||||
const [busy, setBusy] = useState(false),
|
||||
[error, setError] = useState<string>();
|
||||
const [taskId, setTaskId] = useState('Unitree-Go2-Flat'),
|
||||
[numEnvs, setNumEnvs] = useState(4096),
|
||||
[maxIterations, setMaxIterations] = useState(2000),
|
||||
[seed, setSeed] = useState(42),
|
||||
[runName, setRunName] = useState('web'),
|
||||
[device, setDevice] = useState<TrainingDevice>('gpu'),
|
||||
[gpuIds, setGpuIds] = useState('0'),
|
||||
[wandbMode, setWandbMode] = useState<WandbMode>('offline');
|
||||
|
||||
const connect = async () => {
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const client = new LocalTrainingClient(endpoint, token),
|
||||
info = await client.health();
|
||||
setServer(info);
|
||||
try {
|
||||
localStorage.setItem(ENDPOINT_KEY, client.endpoint);
|
||||
sessionStorage.setItem(TOKEN_KEY, client.token);
|
||||
} catch {
|
||||
/* 当前会话仍可连接 */
|
||||
}
|
||||
if (info.tasks.length && !info.tasks.includes(taskId)) setTaskId(info.tasks[0]);
|
||||
const remembered = info.activeJobId ?? stored(JOB_KEY);
|
||||
if (remembered) {
|
||||
try {
|
||||
const recovered = await client.job(remembered);
|
||||
setJob(recovered);
|
||||
try {
|
||||
localStorage.setItem(JOB_KEY, recovered.id);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} catch {
|
||||
setJob(undefined);
|
||||
try {
|
||||
localStorage.removeItem(JOB_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setJob(undefined);
|
||||
}
|
||||
if (!info.ready) setError(info.error ?? '训练服务尚未就绪');
|
||||
} catch (value) {
|
||||
setServer(undefined);
|
||||
setError(errorText(value));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const jobId = job?.id,
|
||||
jobState = job?.state;
|
||||
useEffect(() => {
|
||||
if (!jobId || !jobState || !ACTIVE_STATES.has(jobState)) return;
|
||||
let disposed = false;
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const next = await new LocalTrainingClient(endpoint, token).job(jobId);
|
||||
if (!disposed) setJob(next);
|
||||
} catch (value) {
|
||||
if (!disposed) setError(errorText(value));
|
||||
}
|
||||
};
|
||||
const timer = window.setInterval(() => void refresh(), 1500);
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [endpoint, jobId, jobState, token]);
|
||||
|
||||
const start = async () => {
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const ids =
|
||||
device === 'gpu'
|
||||
? gpuIds
|
||||
.split(/[\s,]+/)
|
||||
.filter(Boolean)
|
||||
.map(Number)
|
||||
: [];
|
||||
if (ids.some((id) => !Number.isInteger(id) || id < 0))
|
||||
throw new Error('GPU 编号必须是非负整数');
|
||||
const next = await new LocalTrainingClient(endpoint, token).start({
|
||||
taskId,
|
||||
numEnvs,
|
||||
maxIterations,
|
||||
seed,
|
||||
runName,
|
||||
device,
|
||||
gpuIds: ids,
|
||||
wandbMode,
|
||||
});
|
||||
setJob(next);
|
||||
try {
|
||||
localStorage.setItem(JOB_KEY, next.id);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} catch (value) {
|
||||
setError(errorText(value));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const cancel = async () => {
|
||||
if (!job) return;
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
setJob(await new LocalTrainingClient(endpoint, token).cancel(job.id));
|
||||
} catch (value) {
|
||||
setError(errorText(value));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const importResult = async () => {
|
||||
if (!job) return;
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
onPolicyReady(await new LocalTrainingClient(endpoint, token).downloadPolicy(job.id));
|
||||
} catch (value) {
|
||||
setError(errorText(value));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const active = Boolean(job && ACTIVE_STATES.has(job.state));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">本地训练服务</span>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
aria-label="本地训练服务地址"
|
||||
className="field h-7 min-w-0 flex-1 px-2 text-xs text-text-primary"
|
||||
value={endpoint}
|
||||
onChange={(event) => setEndpoint(event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
icon={<Link className="h-3.5 w-3.5" />}
|
||||
disabled={busy || !token.trim()}
|
||||
onClick={() => void connect()}
|
||||
>
|
||||
连接
|
||||
</Button>
|
||||
</div>
|
||||
</label>
|
||||
<label className="mt-2 block text-[10px] text-text-tertiary">
|
||||
<span className="mb-1 block">访问令牌(服务启动时显示)</span>
|
||||
<input
|
||||
aria-label="训练服务访问令牌"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
className="field h-7 w-full px-2 text-xs text-text-primary"
|
||||
value={token}
|
||||
onChange={(event) => setToken(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-2 flex items-center justify-between rounded-md border border-border bg-surface px-2 py-1.5 text-[10px] text-text-tertiary">
|
||||
<span className="flex min-w-0 items-center gap-1.5 truncate">
|
||||
<Server className="h-3.5 w-3.5" />
|
||||
{server?.trainerRoot ?? '请先启动本地训练服务'}
|
||||
</span>
|
||||
<Badge tone={server?.ready ? 'success' : 'warning'}>
|
||||
{server?.ready ? '可用' : '离线'}
|
||||
</Badge>
|
||||
</div>
|
||||
{server?.ready && !job && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<Field label="训练任务">
|
||||
<Select
|
||||
aria-label="训练任务"
|
||||
className="w-full"
|
||||
value={taskId}
|
||||
onChange={(event) => setTaskId(event.target.value)}
|
||||
>
|
||||
{server.tasks.map((task) => (
|
||||
<option key={task} value={task}>
|
||||
{task}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<NumberField
|
||||
label="并行环境"
|
||||
value={numEnvs}
|
||||
min={1}
|
||||
max={16384}
|
||||
onChange={setNumEnvs}
|
||||
/>
|
||||
<NumberField
|
||||
label="训练迭代"
|
||||
value={maxIterations}
|
||||
min={1}
|
||||
max={1000000}
|
||||
onChange={setMaxIterations}
|
||||
/>
|
||||
<NumberField
|
||||
label="随机种子"
|
||||
value={seed}
|
||||
min={0}
|
||||
max={2147483647}
|
||||
onChange={setSeed}
|
||||
/>
|
||||
<Field label="运行名称">
|
||||
<input
|
||||
aria-label="运行名称"
|
||||
className="field h-7 w-full px-2 text-xs text-text-primary"
|
||||
value={runName}
|
||||
onChange={(event) => setRunName(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="计算设备">
|
||||
<Select
|
||||
aria-label="计算设备"
|
||||
className="w-full"
|
||||
value={device}
|
||||
onChange={(event) => setDevice(event.target.value as TrainingDevice)}
|
||||
>
|
||||
<option value="gpu">GPU</option>
|
||||
<option value="cpu">CPU</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="GPU 编号">
|
||||
<input
|
||||
aria-label="GPU 编号"
|
||||
className="field h-7 w-full px-2 text-xs text-text-primary disabled:opacity-40"
|
||||
value={gpuIds}
|
||||
disabled={device === 'cpu'}
|
||||
onChange={(event) => setGpuIds(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="实验记录">
|
||||
<Select
|
||||
aria-label="W&B 模式"
|
||||
className="w-full"
|
||||
value={wandbMode}
|
||||
onChange={(event) => setWandbMode(event.target.value as WandbMode)}
|
||||
>
|
||||
<option value="offline">本地离线(默认,无需登录)</option>
|
||||
<option value="disabled">完全禁用 W&B</option>
|
||||
<option value="online">在线 W&B(需要 API Key)</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
icon={<Play className="h-3.5 w-3.5" />}
|
||||
disabled={busy}
|
||||
onClick={() => void start()}
|
||||
>
|
||||
发起本地训练
|
||||
</Button>
|
||||
<p className="text-[10px] leading-4 text-text-tertiary">
|
||||
训练使用本地 mjlab
|
||||
任务资产,不会把浏览器中的模型上传到网络。服务一次只运行一个训练任务。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{job && (
|
||||
<div className="mt-3 rounded-lg border border-border bg-surface p-2.5">
|
||||
<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>
|
||||
<ProgressBar value={job.progress} label="训练进度" />
|
||||
<div className="mt-2">
|
||||
<PropertyRow label="迭代" value={`${job.iteration} / ${job.maxIterations}`} />
|
||||
<PropertyRow label="状态" value={job.message} />
|
||||
</div>
|
||||
{job.logs.length > 0 && (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-[10px] text-text-secondary">最近日志</summary>
|
||||
<pre className="mt-1 max-h-36 overflow-auto whitespace-pre-wrap break-all rounded bg-app p-2 text-[9px] leading-4 text-text-tertiary">
|
||||
{job.logs.slice(-40).join('\n')}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
<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}
|
||||
icon={<Download className="h-3.5 w-3.5" />}
|
||||
onClick={() => void importResult()}
|
||||
>
|
||||
导入策略
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setJob(undefined);
|
||||
try {
|
||||
localStorage.removeItem(JOB_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}}
|
||||
>
|
||||
新建任务
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mt-2 break-words rounded bg-danger/10 p-2 text-[10px] leading-4 text-danger"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block text-[10px] text-text-tertiary">
|
||||
<span className="mb-1 block">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
function NumberField({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
onChange(value: number): void;
|
||||
}) {
|
||||
return (
|
||||
<Field label={label}>
|
||||
<input
|
||||
aria-label={label}
|
||||
type="number"
|
||||
className="field h-7 w-full px-2 text-xs text-text-primary"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { MAP_ASSET_DRAG_MIME, MAP_ASSET_PLACEMENT_MIME } from '../../map/editor/assetCatalog';
|
||||
import { MapAssetLibrary } from './MapAssetLibrary';
|
||||
|
||||
const renderLibrary = (
|
||||
onAdd: (type: string, placementMode: string) => void = () => {},
|
||||
onSelectTerrain: (preset: string) => void = () => {},
|
||||
) =>
|
||||
render(
|
||||
<MapAssetLibrary
|
||||
disabled={false}
|
||||
terrainSize={8}
|
||||
onAdd={onAdd}
|
||||
onSelectTerrain={onSelectTerrain}
|
||||
/>,
|
||||
);
|
||||
|
||||
describe('MapAssetLibrary', () => {
|
||||
it('按所选放置方式添加认证资产', () => {
|
||||
const onAdd = vi.fn();
|
||||
renderLibrary(onAdd);
|
||||
fireEvent.change(screen.getByLabelText('新增资产放置方式'), {
|
||||
target: { value: 'gravity' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加基础方盒' }));
|
||||
expect(onAdd).toHaveBeenCalledWith('box', 'gravity');
|
||||
});
|
||||
|
||||
it('拖动资产时写入类型和放置方式', () => {
|
||||
const values = new Map<string, string>();
|
||||
const dataTransfer = {
|
||||
effectAllowed: 'none',
|
||||
setData: (type: string, value: string) => values.set(type, value),
|
||||
} as unknown as DataTransfer;
|
||||
renderLibrary();
|
||||
fireEvent.change(screen.getByLabelText('新增资产放置方式'), {
|
||||
target: { value: 'locked' },
|
||||
});
|
||||
fireEvent.dragStart(document.querySelector('[data-map-asset="ramp"]')!, { dataTransfer });
|
||||
expect(values.get(MAP_ASSET_DRAG_MIME)).toBe('ramp');
|
||||
expect(values.get(MAP_ASSET_PLACEMENT_MIME)).toBe('locked');
|
||||
});
|
||||
|
||||
it('以示意图卡片选择系统参数化地形', () => {
|
||||
const onSelectTerrain = vi.fn();
|
||||
renderLibrary(undefined, onSelectTerrain);
|
||||
expect(screen.getAllByText('8.00 × 8.00 m')).toHaveLength(9);
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加随机粗糙地形' }));
|
||||
expect(onSelectTerrain).toHaveBeenCalledWith('rough');
|
||||
});
|
||||
});
|
||||
@@ -1,298 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { BadgeCheck, CheckCircle2, Mountain, Plus } from 'lucide-react';
|
||||
import { Button, Select } from '../../components/ui';
|
||||
import {
|
||||
CERTIFIED_MAP_ASSETS,
|
||||
MAP_ASSET_DRAG_MIME,
|
||||
MAP_ASSET_PLACEMENT_MIME,
|
||||
} from '../../map/editor/assetCatalog';
|
||||
import {
|
||||
MAP_OBJECT_PLACEMENT_LABELS,
|
||||
type EditableMapDocument,
|
||||
type EditableMapObjectType,
|
||||
type MapObjectPlacementMode,
|
||||
} from '../../map/editor/types';
|
||||
import {
|
||||
PHYSICAL_MAP_PRESET_LABELS,
|
||||
SYSTEM_TERRAIN_PRESETS,
|
||||
type SystemTerrainPreset,
|
||||
} from '../../map/types';
|
||||
|
||||
function AssetPreview({ type, color }: { type: EditableMapObjectType; color: string }) {
|
||||
const shape =
|
||||
type === 'cylinder'
|
||||
? 'h-9 w-9 rounded-full'
|
||||
: type === 'capsule'
|
||||
? 'h-10 w-6 rounded-full'
|
||||
: type === 'ramp'
|
||||
? 'h-0 w-0 border-b-[34px] border-l-[48px] border-l-transparent'
|
||||
: type === 'stairs'
|
||||
? 'h-9 w-11 [clip-path:polygon(0_100%,0_66%,34%_66%,34%_33%,67%_33%,67%_0,100%_0,100%_100%)]'
|
||||
: 'h-9 w-9 rounded-sm';
|
||||
return (
|
||||
<div className="flex h-16 items-center justify-center rounded bg-black/10" aria-hidden="true">
|
||||
<div
|
||||
className={`${shape} shadow-[0_6px_14px_rgba(0,0,0,0.22)]`}
|
||||
style={type === 'ramp' ? { borderBottomColor: color } : { backgroundColor: color }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TerrainPreview({ preset }: { preset: SystemTerrainPreset }) {
|
||||
const grid = (
|
||||
<g stroke="currentColor" strokeWidth="0.45" opacity="0.18">
|
||||
<path d="M14 50 60 23l46 27-46 27z" />
|
||||
<path d="m26 57 46-27m-34 34 46-27m-58 6 46 27m-34-34 46 27" />
|
||||
</g>
|
||||
);
|
||||
let shape;
|
||||
if (preset === 'discrete_obstacles')
|
||||
shape = (
|
||||
<g fill="currentColor" fillOpacity="0.16" stroke="currentColor">
|
||||
<path d="m31 48 9-5 9 5-9 5zM40 53V39l9-5v14M40 39l-9-5v14" />
|
||||
<path d="m55 39 8-5 8 5-8 5zM63 44V27l8-5v17M63 27l-8-5v17" />
|
||||
<path d="m77 51 10-6 10 6-10 6zM87 57V38l10-6v19M87 38l-10-6v19" />
|
||||
</g>
|
||||
);
|
||||
else if (preset === 'gap')
|
||||
shape = (
|
||||
<g fill="currentColor" fillOpacity="0.12" stroke="currentColor">
|
||||
<path d="m21 48 31-18 15 9-31 18zM69 40l15 9 17-10-15-9z" />
|
||||
<path d="m36 57 31-18v8L36 65zM69 40l17-10v8L69 48z" fillOpacity="0.24" />
|
||||
</g>
|
||||
);
|
||||
else if (preset === 'inverted_pyramid_stairs' || preset === 'pit')
|
||||
shape = (
|
||||
<g fill="currentColor" fillOpacity="0.08" stroke="currentColor">
|
||||
<path d="m22 47 38-22 38 22-38 22z" />
|
||||
<path d="m32 47 28-16 28 16-28 16z" />
|
||||
<path d="m43 47 17-10 17 10-17 10z" fillOpacity="0.2" />
|
||||
{preset === 'inverted_pyramid_stairs' && (
|
||||
<path d="M32 47v7m11-7v10m17-10v16m17-16v10m11-10v7" />
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
else if (preset === 'pyramid_stairs')
|
||||
shape = (
|
||||
<g fill="currentColor" stroke="currentColor" strokeLinejoin="round">
|
||||
<path d="m25 52 12 7 52-30-12-7z" fillOpacity="0.1" />
|
||||
<path d="m37 45 12 7 40-23-12-7z" fillOpacity="0.17" />
|
||||
<path d="m49 38 12 7 28-16-12-7z" fillOpacity="0.24" />
|
||||
<path d="m61 31 12 7 16-9-12-7z" fillOpacity="0.32" />
|
||||
</g>
|
||||
);
|
||||
else if (preset === 'rails')
|
||||
shape = (
|
||||
<g fill="none" stroke="currentColor" strokeLinecap="round">
|
||||
<path d="m26 58 53-31m15 40 12-7" strokeWidth="2.3" opacity="0.25" />
|
||||
<path d="m26 58 53-31m15 40 12-7M33 58V45m14 5V37m30 12V27m20 39V53" strokeWidth="1.2" />
|
||||
</g>
|
||||
);
|
||||
else if (preset === 'rough')
|
||||
shape = (
|
||||
<path
|
||||
d="m20 54 10-18 9 9 10-22 11 17 10-13 10 20 10-8 10 15-40 18z"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.16"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
);
|
||||
else if (preset === 'stepping_stones')
|
||||
shape = (
|
||||
<g fill="currentColor" fillOpacity="0.2" stroke="currentColor">
|
||||
<path d="m27 52 10-6 10 6-10 6zM46 39l10-6 10 6-10 6zM66 54l10-6 10 6-10 6zM83 36l10-6 10 6-10 6z" />
|
||||
</g>
|
||||
);
|
||||
else
|
||||
shape = (
|
||||
<g fill="none" stroke="currentColor" strokeLinecap="round">
|
||||
<path d="M19 48c10-17 20-17 30 0s20 17 30 0 20-17 29-2" strokeWidth="5" opacity="0.16" />
|
||||
<path d="M19 48c10-17 20-17 30 0s20 17 30 0 20-17 29-2" strokeWidth="1.4" />
|
||||
<path d="M25 59c10-17 20-17 30 0s20 17 30 0 15-13 23-7" opacity="0.55" />
|
||||
</g>
|
||||
);
|
||||
return (
|
||||
<svg viewBox="0 0 120 80" className="h-[76px] w-full text-accent" aria-hidden="true">
|
||||
{grid}
|
||||
{shape}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AddAction({ label }: { label: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-accent">
|
||||
{label}
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-accent text-white">
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function MapAssetLibrary({
|
||||
disabled,
|
||||
terrainSize,
|
||||
document,
|
||||
onAdd,
|
||||
onSelectTerrain,
|
||||
onSelectObject,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
terrainSize: number;
|
||||
document?: EditableMapDocument | null;
|
||||
onAdd: (
|
||||
type: EditableMapObjectType,
|
||||
placementMode: MapObjectPlacementMode,
|
||||
) => void | Promise<void>;
|
||||
onSelectTerrain: (preset: SystemTerrainPreset) => void;
|
||||
onSelectObject?: (id: string) => void;
|
||||
}) {
|
||||
const [placementMode, setPlacementMode] = useState<MapObjectPlacementMode>('auto_ground');
|
||||
return (
|
||||
<section aria-label="认证资产" className="space-y-3 p-3">
|
||||
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-2.5">
|
||||
<div className="flex items-center justify-between text-xs font-medium text-text-primary">
|
||||
<span>场景结构</span>
|
||||
<span className="technical-value text-[10px] text-text-tertiary">
|
||||
{document?.objects.length ?? 0} 个对象
|
||||
</span>
|
||||
</div>
|
||||
{document?.objects.length ? (
|
||||
<div className="mt-2 max-h-36 space-y-0.5 overflow-auto panel-scroll">
|
||||
{document.objects.map((object) => (
|
||||
<button
|
||||
key={object.id}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[11px] text-text-secondary hover:bg-element-hover hover:text-text-primary"
|
||||
onClick={() => onSelectObject?.(object.id)}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent" />
|
||||
<span className="min-w-0 flex-1 truncate">{object.name}</span>
|
||||
<span className="text-[9px] text-text-tertiary">{object.type}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-[10px] leading-relaxed text-text-tertiary">
|
||||
选择工程地图后,这里显示场景对象层级;可从下方资产库创建场景。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-text-primary">
|
||||
<BadgeCheck className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
|
||||
认证资产
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
|
||||
点击添加,或按住资产拖到画布落位。
|
||||
</p>
|
||||
</div>
|
||||
<label className="w-36 shrink-0 text-[10px] text-text-secondary">
|
||||
放置方式
|
||||
<Select
|
||||
aria-label="新增资产放置方式"
|
||||
className="mt-1 w-full"
|
||||
value={placementMode}
|
||||
disabled={disabled}
|
||||
onChange={(event) => setPlacementMode(event.target.value as MapObjectPlacementMode)}
|
||||
>
|
||||
{(Object.keys(MAP_OBJECT_PLACEMENT_LABELS) as MapObjectPlacementMode[]).map((mode) => (
|
||||
<option key={mode} value={mode}>
|
||||
{MAP_OBJECT_PLACEMENT_LABELS[mode]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{CERTIFIED_MAP_ASSETS.map((asset) => (
|
||||
<article
|
||||
key={asset.type}
|
||||
draggable={!disabled}
|
||||
data-map-asset={asset.type}
|
||||
className={`overflow-hidden rounded-lg border border-border-subtle bg-panel-muted shadow-sm transition-colors ${disabled ? 'opacity-50' : 'cursor-grab hover:border-accent active:cursor-grabbing'}`}
|
||||
onDragStart={(event) => {
|
||||
if (disabled) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
event.dataTransfer.setData(MAP_ASSET_DRAG_MIME, asset.type);
|
||||
event.dataTransfer.setData(MAP_ASSET_PLACEMENT_MIME, placementMode);
|
||||
event.dataTransfer.setData('text/plain', asset.name);
|
||||
}}
|
||||
>
|
||||
<div className="p-2 pb-0">
|
||||
<AssetPreview type={asset.type} color={asset.color} />
|
||||
</div>
|
||||
<div className="border-t border-border-subtle p-2">
|
||||
<div className="truncate text-[11px] font-semibold text-text-primary">
|
||||
{asset.name}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[9px] text-text-tertiary">
|
||||
{asset.description}
|
||||
</div>
|
||||
<Button
|
||||
className="mt-2 w-full"
|
||||
disabled={disabled}
|
||||
aria-label={`添加${asset.name}`}
|
||||
onClick={() => void onAdd(asset.type, placementMode)}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" aria-hidden="true" />
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border-subtle pt-3">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-text-primary">
|
||||
<Mountain className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
|
||||
系统参数化地形
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
|
||||
本地确定性生成;点击“添加”后在右侧“地图”调整尺寸、难度和随机种子。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{SYSTEM_TERRAIN_PRESETS.map((preset) => (
|
||||
<article
|
||||
key={preset}
|
||||
data-system-terrain={preset}
|
||||
className={`group relative overflow-hidden rounded-lg border border-border-subtle bg-panel-muted shadow-sm transition-colors ${disabled ? 'opacity-50' : 'hover:border-accent'}`}
|
||||
>
|
||||
<div className="absolute right-2 top-2 z-10 rounded-full bg-panel-bg/90 p-0.5 text-success">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="px-1 pt-1">
|
||||
<TerrainPreview preset={preset} />
|
||||
</div>
|
||||
<div className="border-t border-border-subtle px-2 pb-2 pt-1.5">
|
||||
<div className="truncate text-[11px] font-semibold text-text-primary">
|
||||
{PHYSICAL_MAP_PRESET_LABELS[preset]}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[9px] text-text-tertiary">
|
||||
{terrainSize.toFixed(2)} × {terrainSize.toFixed(2)} m
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1 flex w-full justify-end rounded outline-none focus-visible:ring-2 focus-visible:ring-accent/30"
|
||||
disabled={disabled}
|
||||
aria-label={`添加${PHYSICAL_MAP_PRESET_LABELS[preset]}`}
|
||||
onClick={() => onSelectTerrain(preset)}
|
||||
>
|
||||
<AddAction label="添加" />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import type { EditableMapDocument } from '../../map/editor/types';
|
||||
import { MapEditorPanel } from './MapEditorPanel';
|
||||
|
||||
const interactionProps = {
|
||||
onConvert: async () => true,
|
||||
canConvert: true,
|
||||
onBindInteraction: () => {},
|
||||
onSelectPreview: () => {},
|
||||
onTransformMode: () => {},
|
||||
onSnapping: () => {},
|
||||
};
|
||||
const document: EditableMapDocument = {
|
||||
schemaVersion: 1,
|
||||
mapId: 'map',
|
||||
revision: 0,
|
||||
objects: [],
|
||||
spawnPoints: [],
|
||||
};
|
||||
|
||||
describe('MapEditorPanel', () => {
|
||||
it('新增并编辑对象后应用递增 revision', async () => {
|
||||
const apply = vi.fn(async (documentValue: EditableMapDocument) => Boolean(documentValue)),
|
||||
preview = vi.fn();
|
||||
render(
|
||||
<MapEditorPanel
|
||||
{...interactionProps}
|
||||
document={document}
|
||||
loading={false}
|
||||
onPreview={preview}
|
||||
onApply={apply}
|
||||
onExport={() => {}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '新增' }));
|
||||
fireEvent.change(screen.getByLabelText('对象位置X'), { target: { value: '2' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
|
||||
await waitFor(() => expect(apply).toHaveBeenCalled());
|
||||
expect(apply.mock.calls[0][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
revision: 1,
|
||||
objects: [
|
||||
expect.objectContaining({ pose: expect.objectContaining({ position: [2, 0, 0.5] }) }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('锁定放置方式后禁用位姿编辑', () => {
|
||||
render(
|
||||
<MapEditorPanel
|
||||
{...interactionProps}
|
||||
document={document}
|
||||
loading={false}
|
||||
onPreview={() => {}}
|
||||
onApply={async () => true}
|
||||
onExport={() => {}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '新增' }));
|
||||
fireEvent.change(screen.getByLabelText('对象放置方式'), { target: { value: 'locked' } });
|
||||
expect(screen.getByLabelText('对象位置X')).toBeDisabled();
|
||||
expect(screen.getByLabelText('对象绕Z旋转')).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: '对齐地面' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('编辑出生点并包含在应用文档中', async () => {
|
||||
const apply = vi.fn(async (value: EditableMapDocument) => Boolean(value));
|
||||
render(
|
||||
<MapEditorPanel
|
||||
{...interactionProps}
|
||||
document={document}
|
||||
loading={false}
|
||||
onPreview={() => {}}
|
||||
onApply={apply}
|
||||
onExport={() => {}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '新增出生点' }));
|
||||
const name = screen.getByLabelText(/出生点名称/);
|
||||
fireEvent.change(name, { target: { value: '装卸区' } });
|
||||
const yaw = screen.getByLabelText(/出生点.*朝向/);
|
||||
fireEvent.change(yaw, { target: { value: '90' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
|
||||
await waitFor(() => expect(apply).toHaveBeenCalled());
|
||||
expect(apply.mock.calls[0][0].spawnPoints[0]).toMatchObject({ name: '装卸区', yawDeg: 90 });
|
||||
});
|
||||
|
||||
it('没有 authoring 文档时可请求创建可编辑副本', async () => {
|
||||
const convert = vi.fn(async () => true);
|
||||
render(
|
||||
<MapEditorPanel
|
||||
{...interactionProps}
|
||||
onConvert={convert}
|
||||
document={null}
|
||||
loading={false}
|
||||
onPreview={() => {}}
|
||||
onApply={async () => true}
|
||||
onExport={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/保持只读/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建可编辑副本' }));
|
||||
await waitFor(() => expect(convert).toHaveBeenCalledOnce());
|
||||
});
|
||||
});
|
||||
@@ -1,597 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Select } from '../../components/ui';
|
||||
import { MapEditSession } from '../../map/editor/MapEditSession';
|
||||
import {
|
||||
editableObjectGroundHeight,
|
||||
MAP_OBJECT_PLACEMENT_LABELS,
|
||||
type EditableMapDocument,
|
||||
type EditableMapObject,
|
||||
type EditableMapObjectType,
|
||||
type MapEditorInteractionCallbacks,
|
||||
type MapEditorTransformMode,
|
||||
type MapObjectPlacementMode,
|
||||
} from '../../map/editor/types';
|
||||
|
||||
const labels: Record<EditableMapObjectType, string> = {
|
||||
box: '方盒',
|
||||
cylinder: '圆柱',
|
||||
capsule: '胶囊',
|
||||
ramp: '坡道',
|
||||
stairs: '楼梯',
|
||||
};
|
||||
function yawDegrees(object: EditableMapObject): number {
|
||||
const [w, x, y, z] = object.pose.quaternion;
|
||||
return (Math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z)) * 180) / Math.PI;
|
||||
}
|
||||
function scaledParameters(
|
||||
object: EditableMapObject,
|
||||
scale: [number, number, number],
|
||||
): Record<string, number> {
|
||||
const [sx, sy, sz] = scale.map((value) => Math.max(0.001, Math.abs(value))) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
const p = object.parameters;
|
||||
if (object.type === 'box')
|
||||
return { sizeX: p.sizeX * sx, sizeY: p.sizeY * sy, sizeZ: p.sizeZ * sz };
|
||||
if (object.type === 'cylinder')
|
||||
return { radius: p.radius * Math.max(sx, sy), height: p.height * sz };
|
||||
if (object.type === 'capsule')
|
||||
return { radius: p.radius * Math.max(sx, sy), length: p.length * sz };
|
||||
if (object.type === 'ramp')
|
||||
return {
|
||||
length: p.length * sx,
|
||||
width: p.width * sy,
|
||||
rise: p.rise * sz,
|
||||
thickness: p.thickness * sz,
|
||||
};
|
||||
return {
|
||||
stepDepth: p.stepDepth * sx,
|
||||
width: p.width * sy,
|
||||
stepHeight: p.stepHeight * sz,
|
||||
count: p.count,
|
||||
};
|
||||
}
|
||||
export function MapEditorPanel({
|
||||
document,
|
||||
loading,
|
||||
onPreview,
|
||||
onApply,
|
||||
onExport,
|
||||
onConvert,
|
||||
canConvert,
|
||||
onBindInteraction,
|
||||
onSelectPreview,
|
||||
onTransformMode,
|
||||
onSnapping,
|
||||
}: {
|
||||
document: EditableMapDocument | null;
|
||||
loading: boolean;
|
||||
onPreview: (document: EditableMapDocument | null) => void;
|
||||
onApply: (document: EditableMapDocument) => Promise<boolean>;
|
||||
onExport: () => void;
|
||||
onConvert: () => Promise<boolean>;
|
||||
canConvert: boolean;
|
||||
onBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
|
||||
onSelectPreview: (id: string | null) => void;
|
||||
onTransformMode: (mode: MapEditorTransformMode) => void;
|
||||
onSnapping: (translation: number | null, rotationDegrees: number | null) => void;
|
||||
}) {
|
||||
const session = useMemo(() => (document ? new MapEditSession(document) : null), [document]);
|
||||
const [, render] = useState(0),
|
||||
[selected, setSelected] = useState<string>(),
|
||||
[selectedType, setSelectedType] = useState<EditableMapObjectType>('box'),
|
||||
[transformMode, setTransformMode] = useState<MapEditorTransformMode>('translate'),
|
||||
[snapping, setSnapping] = useState(true),
|
||||
[converting, setConverting] = useState(false);
|
||||
const refresh = () => {
|
||||
render((value) => value + 1);
|
||||
onPreview(session?.document ?? null);
|
||||
};
|
||||
useEffect(() => {
|
||||
onPreview(session?.document ?? null);
|
||||
return () => onPreview(null);
|
||||
}, [session, onPreview]);
|
||||
useEffect(() => {
|
||||
if (!session) {
|
||||
onBindInteraction(null);
|
||||
return;
|
||||
}
|
||||
const callbacks: MapEditorInteractionCallbacks = {
|
||||
onSelect: (id) => setSelected(id ?? undefined),
|
||||
onTransform: ({ id, position, quaternion, scale }) => {
|
||||
const object = session.document.objects.find((item) => item.id === id);
|
||||
if (!object) return;
|
||||
session.update(id, {
|
||||
pose: { position, quaternion },
|
||||
parameters: scaledParameters(object, scale),
|
||||
});
|
||||
setSelected(id);
|
||||
render((value) => value + 1);
|
||||
onPreview(session.document);
|
||||
},
|
||||
onAddAsset: (type, position, placementMode) => {
|
||||
const object = session.addAsset(type, position, placementMode);
|
||||
setSelected(object.id);
|
||||
render((value) => value + 1);
|
||||
onPreview(session.document);
|
||||
onSelectPreview(object.id);
|
||||
},
|
||||
};
|
||||
onBindInteraction(callbacks);
|
||||
return () => onBindInteraction(null);
|
||||
}, [session, onBindInteraction, onPreview, onSelectPreview]);
|
||||
useEffect(() => {
|
||||
onTransformMode(transformMode);
|
||||
onSnapping(snapping ? 0.1 : null, snapping ? 5 : null);
|
||||
}, [transformMode, snapping, onTransformMode, onSnapping]);
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
const keydown = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.matches('input, textarea, select') || target?.isContentEditable || event.altKey)
|
||||
return;
|
||||
const key = event.key.toLowerCase();
|
||||
if ((event.ctrlKey || event.metaKey) && key === 'z') {
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) session.redo();
|
||||
else session.undo();
|
||||
if (selected && !session.document.objects.some((object) => object.id === selected)) {
|
||||
setSelected(undefined);
|
||||
onSelectPreview(null);
|
||||
}
|
||||
render((value) => value + 1);
|
||||
onPreview(session.document);
|
||||
} else if ((event.ctrlKey || event.metaKey) && key === 'y') {
|
||||
event.preventDefault();
|
||||
session.redo();
|
||||
render((value) => value + 1);
|
||||
onPreview(session.document);
|
||||
} else if (!event.ctrlKey && !event.metaKey && key === 'w') setTransformMode('translate');
|
||||
else if (!event.ctrlKey && !event.metaKey && key === 'e') setTransformMode('rotate');
|
||||
else if (!event.ctrlKey && !event.metaKey && key === 's') setTransformMode('scale');
|
||||
else if (event.key === 'Escape') {
|
||||
setSelected(undefined);
|
||||
onSelectPreview(null);
|
||||
} else if ((event.key === 'Delete' || event.key === 'Backspace') && selected) {
|
||||
event.preventDefault();
|
||||
session.remove(selected);
|
||||
setSelected(undefined);
|
||||
render((value) => value + 1);
|
||||
onPreview(session.document);
|
||||
onSelectPreview(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', keydown);
|
||||
return () => window.removeEventListener('keydown', keydown);
|
||||
}, [session, selected, onPreview, onSelectPreview]);
|
||||
if (!session)
|
||||
return (
|
||||
<div className="space-y-2 rounded border border-border-subtle p-3 text-xs text-text-tertiary">
|
||||
<p>当前地图没有 V3 authoring.source,保持只读。</p>
|
||||
<p className="text-[10px] leading-relaxed">
|
||||
仅由 box、cylinder、capsule 组成且不含资产、材质或碰撞过滤的静态 MJCF
|
||||
可以安全转换;遇到无法表达的语义会整体拒绝,不会静默丢失内容。
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={loading || converting || !canConvert}
|
||||
onClick={() => {
|
||||
setConverting(true);
|
||||
void onConvert().finally(() => setConverting(false));
|
||||
}}
|
||||
>
|
||||
{converting ? '正在转换…' : '创建可编辑副本'}
|
||||
</Button>
|
||||
<Button disabled={loading || converting} onClick={onExport}>
|
||||
导出原地图 ZIP
|
||||
</Button>
|
||||
</div>
|
||||
{!canConvert && <p className="text-warning">该地图没有 MJCF 物理层,无法转换。</p>}
|
||||
</div>
|
||||
);
|
||||
const current = session.document;
|
||||
const active = current.objects.find((object) => object.id === selected);
|
||||
const poseLocked = active?.placementMode === 'locked';
|
||||
const updatePosition = (index: number, value: number) => {
|
||||
if (!active || !Number.isFinite(value)) return;
|
||||
const position = [...active.pose.position] as [number, number, number];
|
||||
position[index] = value;
|
||||
session.update(active.id, { pose: { ...active.pose, position } });
|
||||
refresh();
|
||||
};
|
||||
const updateParameter = (key: string, value: number) => {
|
||||
if (!active || !Number.isFinite(value) || value <= 0) return;
|
||||
session.update(active.id, {
|
||||
parameters: { ...active.parameters, [key]: key === 'count' ? Math.round(value) : value },
|
||||
});
|
||||
refresh();
|
||||
};
|
||||
const updateSpawnPosition = (
|
||||
id: string,
|
||||
position: [number, number, number],
|
||||
index: number,
|
||||
value: number,
|
||||
) => {
|
||||
if (!Number.isFinite(value)) return;
|
||||
const next = [...position] as [number, number, number];
|
||||
next[index] = value;
|
||||
session.updateSpawn(id, { position: next });
|
||||
refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-2 rounded border border-border-subtle p-2">
|
||||
<Button
|
||||
variant={transformMode === 'translate' ? 'primary' : 'secondary'}
|
||||
aria-pressed={transformMode === 'translate'}
|
||||
disabled={loading}
|
||||
onClick={() => setTransformMode('translate')}
|
||||
>
|
||||
移动工具 W
|
||||
</Button>
|
||||
<Button
|
||||
variant={transformMode === 'rotate' ? 'primary' : 'secondary'}
|
||||
aria-pressed={transformMode === 'rotate'}
|
||||
disabled={loading}
|
||||
onClick={() => setTransformMode('rotate')}
|
||||
>
|
||||
旋转工具 E
|
||||
</Button>
|
||||
<Button
|
||||
variant={transformMode === 'scale' ? 'primary' : 'secondary'}
|
||||
aria-pressed={transformMode === 'scale'}
|
||||
disabled={loading}
|
||||
onClick={() => setTransformMode('scale')}
|
||||
>
|
||||
缩放工具 S
|
||||
</Button>
|
||||
<label className="col-span-3 flex items-center justify-between text-[11px] text-text-secondary">
|
||||
<span>吸附(移动 0.1 m / 旋转 5°)</span>
|
||||
<input
|
||||
aria-label="地图编辑吸附"
|
||||
type="checkbox"
|
||||
checked={snapping}
|
||||
disabled={loading}
|
||||
onChange={(event) => setSnapping(event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
<p className="col-span-3 text-[10px] text-text-tertiary">
|
||||
在视口点击对象后拖动操纵轴;只允许绕世界 Z 轴旋转,缩放会写入原语尺寸。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
aria-label="新增地图对象类型"
|
||||
className="min-w-0 flex-1"
|
||||
value={selectedType}
|
||||
disabled={loading}
|
||||
onChange={(event) => setSelectedType(event.target.value as EditableMapObjectType)}
|
||||
>
|
||||
{(Object.keys(labels) as EditableMapObjectType[]).map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{labels[type]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
const object = session.add(selectedType);
|
||||
setSelected(object.id);
|
||||
refresh();
|
||||
onSelectPreview(object.id);
|
||||
}}
|
||||
>
|
||||
新增
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-36 space-y-1 overflow-auto" aria-label="地图对象列表">
|
||||
{current.objects.map((object) => (
|
||||
<button
|
||||
key={object.id}
|
||||
type="button"
|
||||
className={`w-full rounded border px-2 py-1 text-left text-xs ${selected === object.id ? 'border-accent bg-accent/10' : 'border-border-subtle'}`}
|
||||
onClick={() => {
|
||||
setSelected(object.id);
|
||||
onSelectPreview(object.id);
|
||||
}}
|
||||
>
|
||||
{object.name} · {labels[object.type]}
|
||||
{object.placementMode === 'locked' ? ' · 已锁定' : ''}
|
||||
</button>
|
||||
))}
|
||||
{!current.objects.length && <div className="text-xs text-text-tertiary">暂无对象</div>}
|
||||
</div>
|
||||
{active && (
|
||||
<div className="space-y-2 rounded border border-border-subtle p-2">
|
||||
<label className="block text-[11px] text-text-secondary">
|
||||
名称
|
||||
<input
|
||||
aria-label="对象名称"
|
||||
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
||||
value={active.name}
|
||||
onChange={(event) => {
|
||||
if (!event.target.value.trim()) return;
|
||||
session.update(active.id, { name: event.target.value });
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-[11px] text-text-secondary">
|
||||
放置方式
|
||||
<Select
|
||||
aria-label="对象放置方式"
|
||||
className="mt-1 w-full"
|
||||
value={active.placementMode}
|
||||
disabled={loading}
|
||||
onChange={(event) => {
|
||||
session.update(active.id, {
|
||||
placementMode: event.target.value as MapObjectPlacementMode,
|
||||
});
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
{(Object.keys(MAP_OBJECT_PLACEMENT_LABELS) as MapObjectPlacementMode[]).map(
|
||||
(mode) => (
|
||||
<option key={mode} value={mode}>
|
||||
{MAP_OBJECT_PLACEMENT_LABELS[mode]}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</Select>
|
||||
<span className="mt-1 block text-[9px] leading-3.5 text-text-tertiary">
|
||||
{active.placementMode === 'auto_ground'
|
||||
? '始终将对象底部对齐 z=0。'
|
||||
: active.placementMode === 'gravity'
|
||||
? '沿世界 -Z 落到 XY 范围内最高的静态承载面。'
|
||||
: '保持当前位姿,并禁用表单和视口变换。'}
|
||||
</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{active.pose.position.map((value, index) => (
|
||||
<label key={index} className="text-[11px] text-text-secondary">
|
||||
{['X', 'Y', 'Z'][index]}
|
||||
<input
|
||||
aria-label={`对象位置${['X', 'Y', 'Z'][index]}`}
|
||||
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={value}
|
||||
disabled={loading || poseLocked || index === 2}
|
||||
onChange={(event) => updatePosition(index, Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label className="block text-[11px] text-text-secondary">
|
||||
绕 Z 旋转(°)
|
||||
<input
|
||||
aria-label="对象绕Z旋转"
|
||||
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
||||
type="number"
|
||||
step="1"
|
||||
value={Number(yawDegrees(active).toFixed(4))}
|
||||
disabled={loading || poseLocked}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
if (!Number.isFinite(value)) return;
|
||||
const half = (value * Math.PI) / 360;
|
||||
session.update(active.id, {
|
||||
pose: { ...active.pose, quaternion: [Math.cos(half), 0, 0, Math.sin(half)] },
|
||||
});
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Object.entries(active.parameters).map(([key, value]) => (
|
||||
<label key={key} className="text-[11px] text-text-secondary">
|
||||
{key}
|
||||
<input
|
||||
aria-label={`对象参数${key}`}
|
||||
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
||||
type="number"
|
||||
min="0.001"
|
||||
step={key === 'count' ? 1 : 0.1}
|
||||
value={value}
|
||||
onChange={(event) => updateParameter(key, Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex items-center justify-between text-[11px] text-text-secondary">
|
||||
<span>启用对象</span>
|
||||
<input
|
||||
aria-label="启用地图对象"
|
||||
type="checkbox"
|
||||
checked={active.enabled}
|
||||
onChange={(event) => {
|
||||
session.update(active.id, { enabled: event.target.checked });
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
disabled={loading || poseLocked}
|
||||
onClick={() => {
|
||||
session.update(active.id, {
|
||||
pose: {
|
||||
...active.pose,
|
||||
position: [
|
||||
active.pose.position[0],
|
||||
active.pose.position[1],
|
||||
editableObjectGroundHeight(active),
|
||||
],
|
||||
},
|
||||
});
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
对齐地面
|
||||
</Button>
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
const copy = session.duplicate(active.id);
|
||||
setSelected(copy.id);
|
||||
refresh();
|
||||
onSelectPreview(copy.id);
|
||||
}}
|
||||
>
|
||||
复制对象
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
session.remove(active.id);
|
||||
setSelected(undefined);
|
||||
refresh();
|
||||
onSelectPreview(null);
|
||||
}}
|
||||
>
|
||||
删除对象
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 rounded border border-border-subtle p-2">
|
||||
<div className="flex items-center justify-between text-xs font-medium text-text-primary">
|
||||
<span>出生点</span>
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
session.addSpawn();
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
新增出生点
|
||||
</Button>
|
||||
</div>
|
||||
{current.spawnPoints.map((spawn) => (
|
||||
<div key={spawn.id} className="space-y-2 rounded bg-panel-muted p-2">
|
||||
<input
|
||||
aria-label={`出生点名称 ${spawn.id}`}
|
||||
className="h-7 w-full rounded border border-border-strong bg-input px-1 text-xs"
|
||||
value={spawn.name}
|
||||
onChange={(event) => {
|
||||
if (!event.target.value.trim()) return;
|
||||
session.updateSpawn(spawn.id, { name: event.target.value });
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{spawn.position.map((value, index) => (
|
||||
<label key={index} className="text-[10px] text-text-secondary">
|
||||
{['X', 'Y', 'Z'][index]}
|
||||
<input
|
||||
aria-label={`出生点${spawn.id}位置${['X', 'Y', 'Z'][index]}`}
|
||||
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={value}
|
||||
onChange={(event) =>
|
||||
updateSpawnPosition(
|
||||
spawn.id,
|
||||
spawn.position,
|
||||
index,
|
||||
Number(event.target.value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<label className="text-[10px] text-text-secondary">
|
||||
Yaw°
|
||||
<input
|
||||
aria-label={`出生点${spawn.id}朝向`}
|
||||
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
||||
type="number"
|
||||
step="1"
|
||||
value={spawn.yawDeg}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
if (!Number.isFinite(value)) return;
|
||||
session.updateSpawn(spawn.id, { yawDeg: value });
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
session.removeSpawn(spawn.id);
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
删除出生点
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{!current.spawnPoints.length && (
|
||||
<div className="text-[11px] text-text-tertiary">暂无出生点</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
disabled={loading || !session.canUndo}
|
||||
onClick={() => {
|
||||
session.undo();
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
撤销
|
||||
</Button>
|
||||
<Button
|
||||
disabled={loading || !session.canRedo}
|
||||
onClick={() => {
|
||||
session.redo();
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
重做
|
||||
</Button>
|
||||
<Button
|
||||
disabled={loading || !session.dirty}
|
||||
onClick={() => {
|
||||
session.discard();
|
||||
setSelected(undefined);
|
||||
refresh();
|
||||
onSelectPreview(null);
|
||||
}}
|
||||
>
|
||||
放弃草稿
|
||||
</Button>
|
||||
<Button disabled={loading} onClick={onExport}>
|
||||
导出地图 ZIP
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={loading || !session.dirty}
|
||||
onClick={async () => {
|
||||
const latest = session.document;
|
||||
const candidate = { ...latest, revision: latest.revision + 1 };
|
||||
if (await onApply(candidate)) onPreview(null);
|
||||
else onPreview(session.document);
|
||||
}}
|
||||
>
|
||||
应用并重新编译
|
||||
</Button>
|
||||
{session.dirty && (
|
||||
<div role="status" className="text-xs text-warning">
|
||||
地图草稿尚未应用
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
import { useState } from 'react';
|
||||
import { Database, Info, Map as MapIcon, SlidersHorizontal } from 'lucide-react';
|
||||
import type { MapEntry, ModelEntry } from '../../project/types';
|
||||
import type { ActuatorParameters, SimulationSnapshot } from '../../simulation/SimulationSession';
|
||||
import type { UrdfBaseMode, UrdfLoadMode } from '../../simulation/PhysicsAdapter';
|
||||
import type { DataRecorderConfig } from '../../telemetry/DataRecorder';
|
||||
import type { ViewerSelection } from '../../viewer/MuJoCoViewer';
|
||||
import type { ControllerCommand, ControllerStatus } from '../../controller/types';
|
||||
import type { RLCommand, RLPolicyStatus } from '../../rl/types';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
CollapsibleSection,
|
||||
CopyButton,
|
||||
PropertyRow,
|
||||
Select,
|
||||
Tabs,
|
||||
} from '../../components/ui';
|
||||
import type { MapSelection } from '../../map/types';
|
||||
import type {
|
||||
EditableMapDocument,
|
||||
MapEditorInteractionCallbacks,
|
||||
MapEditorTransformMode,
|
||||
} from '../../map/editor/types';
|
||||
import { DataRecordingPanel } from '../../telemetry/DataRecordingPanel';
|
||||
import { ActuatorControl, Check, ControlSlider } from '../../simulation/ActuatorControl';
|
||||
import { LocalTrainingPanel } from '../../training/LocalTrainingPanel';
|
||||
import { PhysicalMapPanel } from '../../map/PhysicalMapPanel';
|
||||
import { PythonControllerPanel } from '../../controller/PythonControllerPanel';
|
||||
import { RLPolicyPanel } from '../../rl/RLPolicyPanel';
|
||||
import { SidebarPanel } from './SidebarPanel';
|
||||
|
||||
interface ModelControlsProps {
|
||||
snapshot?: SimulationSnapshot;
|
||||
selection: ViewerSelection | null;
|
||||
selectedFormat?: ModelEntry['format'];
|
||||
loading: boolean;
|
||||
visible?: boolean;
|
||||
urdfMode: UrdfLoadMode;
|
||||
baseMode: UrdfBaseMode;
|
||||
showCollision: boolean;
|
||||
ignoreJointLimits: boolean;
|
||||
jointAdvanced: boolean;
|
||||
angleUnit: 'rad' | 'deg';
|
||||
forceScale: number;
|
||||
controllerPaths: string[];
|
||||
selectedControllerPath?: string;
|
||||
controllerStatus?: ControllerStatus;
|
||||
policyPaths: string[];
|
||||
selectedPolicyPath?: string;
|
||||
policyStatus?: RLPolicyStatus;
|
||||
mapSelection: MapSelection;
|
||||
maps: MapEntry[];
|
||||
showVisualMap: boolean;
|
||||
showMapCollision: boolean;
|
||||
editorDocument: EditableMapDocument | null;
|
||||
onUrdfMode: (value: UrdfLoadMode) => void;
|
||||
onBaseMode: (value: UrdfBaseMode) => void;
|
||||
onShowCollision: (value: boolean) => void;
|
||||
onResetJoints: () => void;
|
||||
onToggleJointLimits: () => void;
|
||||
onToggleAdvanced: () => void;
|
||||
onToggleAngleUnit: () => void;
|
||||
onActuator: (id: number, value: number) => void;
|
||||
onActuatorParameters: (id: number, parameters: ActuatorParameters) => void;
|
||||
onJoint: (id: number, value: number) => void;
|
||||
onForceScale: (value: number) => void;
|
||||
onSelectControllerPath: (path: string) => void;
|
||||
onLoadControllerPath: (path: string) => void;
|
||||
onImportController: (file: File) => void;
|
||||
onToggleController: (enabled: boolean) => void;
|
||||
onControllerCommand: (command: ControllerCommand) => void;
|
||||
onRemoveController: () => void;
|
||||
onSelectPolicyPath: (path: string) => void;
|
||||
onLoadPolicyPath: (path: string) => void;
|
||||
onImportPolicy: (file: File) => void;
|
||||
onTogglePolicy: (enabled: boolean) => void;
|
||||
onPolicyCommand: (command: RLCommand) => void;
|
||||
onRemovePolicy: () => void;
|
||||
onApplyMap: (value: MapSelection) => void;
|
||||
onEditorPreview: (document: EditableMapDocument | null) => void;
|
||||
onEditorApply: (document: EditableMapDocument) => Promise<boolean>;
|
||||
onEditorExport: () => void;
|
||||
onEditorConvert: () => Promise<boolean>;
|
||||
onEditorBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
|
||||
onEditorSelect: (id: string | null) => void;
|
||||
onEditorTransformMode: (mode: MapEditorTransformMode) => void;
|
||||
onEditorSnapping: (translation: number | null, rotationDegrees: number | null) => void;
|
||||
onMapDisplay: (visual: boolean, collision: boolean) => void;
|
||||
onMapTabOpen?: () => void;
|
||||
onDataRecorderConfigure: (patch: Partial<DataRecorderConfig>) => void;
|
||||
onDataRecordingStart: () => void;
|
||||
onDataRecordingStop: () => void;
|
||||
onDataRecordingClear: () => void;
|
||||
onDataRecordingExport: (format: 'csv' | 'json') => void;
|
||||
}
|
||||
export function ModelControlsSidebar(props: ModelControlsProps) {
|
||||
const [tab, setTab] = useState<'properties' | 'controls' | 'data' | 'map'>('properties'),
|
||||
s = props.snapshot;
|
||||
if (!s)
|
||||
return (
|
||||
<SidebarPanel title="属性与参数" side="right" visible={props.visible}>
|
||||
<div className="p-4 text-sm text-text-tertiary">导入模型后显示属性</div>
|
||||
</SidebarPanel>
|
||||
);
|
||||
const properties = (
|
||||
<>
|
||||
<CollapsibleSection title="模型信息" defaultOpen badge={<Badge>{s.model.nbody} Body</Badge>}>
|
||||
<div>
|
||||
<PropertyRow label="Body" value={s.model.nbody} />
|
||||
<PropertyRow label="Joint" value={s.model.njnt} />
|
||||
<PropertyRow label="Geom" value={s.model.ngeom} />
|
||||
<PropertyRow label="Actuator" value={s.model.nactuator} />
|
||||
<PropertyRow label="qpos / qvel" value={`${s.model.nq} / ${s.model.nv}`} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
{props.selectedFormat === 'urdf' && (
|
||||
<CollapsibleSection title="URDF 处理方式" defaultOpen={false}>
|
||||
<Select
|
||||
aria-label="URDF 处理方式"
|
||||
className="w-full"
|
||||
value={props.urdfMode}
|
||||
disabled={props.loading}
|
||||
onChange={(event) => props.onUrdfMode(event.target.value as UrdfLoadMode)}
|
||||
>
|
||||
<option value="mjcf">转换为 MJCF(推荐)</option>
|
||||
<option value="native">MuJoCo 原生 URDF</option>
|
||||
</Select>
|
||||
<label className="mt-3 block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">基座类型</span>
|
||||
<Select
|
||||
aria-label="URDF 基座类型"
|
||||
className="w-full"
|
||||
value={props.baseMode}
|
||||
disabled={props.loading || props.urdfMode === 'native'}
|
||||
onChange={(event) => props.onBaseMode(event.target.value as UrdfBaseMode)}
|
||||
>
|
||||
<option value="floating">浮动基座(Free Joint)</option>
|
||||
<option value="fixed">固定基座(连接世界)</option>
|
||||
</Select>
|
||||
</label>
|
||||
<p className="mt-2 text-xs text-text-tertiary">
|
||||
MJCF 模式保留 visual mesh、添加物理地面,并将模型最低点对齐到 z=0。
|
||||
</p>
|
||||
<Check
|
||||
label="显示碰撞几何"
|
||||
checked={props.showCollision}
|
||||
onChange={props.onShowCollision}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
<CollapsibleSection title="当前选择" defaultOpen>
|
||||
{props.selection ? (
|
||||
<div className="text-xs">
|
||||
<PropertyRow
|
||||
label="Body"
|
||||
value={props.selection.bodyName}
|
||||
action={<CopyButton value={props.selection.bodyName} label="复制 Body 名称" />}
|
||||
/>
|
||||
<PropertyRow
|
||||
label="标识"
|
||||
value={`${props.selection.bodyId} / ${props.selection.geomId} / ${props.selection.geomType}`}
|
||||
action={
|
||||
<CopyButton
|
||||
value={`body ${props.selection.bodyId}, geom ${props.selection.geomId}, type ${props.selection.geomType}`}
|
||||
label="复制标识"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<PropertyRow
|
||||
label="位置"
|
||||
value={props.selection.position.map((value) => value.toFixed(3)).join(', ')}
|
||||
action={<CopyButton value={props.selection.position.join(', ')} label="复制位置" />}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="flex items-center gap-2 text-xs text-text-tertiary">
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
在视口中单击物体
|
||||
</p>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
const controls = (
|
||||
<>
|
||||
<CollapsibleSection
|
||||
title="ONNX 强化学习策略"
|
||||
defaultOpen
|
||||
badge={s.rlPolicy ? <Badge>{s.rlPolicy.enabled ? '推理' : '停止'}</Badge> : undefined}
|
||||
>
|
||||
<RLPolicyPanel
|
||||
paths={props.policyPaths}
|
||||
selectedPath={props.selectedPolicyPath}
|
||||
status={props.policyStatus ?? s.rlPolicy}
|
||||
loading={props.loading}
|
||||
onSelectPath={props.onSelectPolicyPath}
|
||||
onLoadPath={props.onLoadPolicyPath}
|
||||
onImport={props.onImportPolicy}
|
||||
onToggle={props.onTogglePolicy}
|
||||
onCommand={props.onPolicyCommand}
|
||||
onRemove={props.onRemovePolicy}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
<CollapsibleSection title="本地强化学习训练" defaultOpen={false}>
|
||||
<LocalTrainingPanel onPolicyReady={props.onImportPolicy} />
|
||||
</CollapsibleSection>
|
||||
<CollapsibleSection
|
||||
title="Python 控制器"
|
||||
defaultOpen
|
||||
badge={s.controller ? <Badge>{s.controller.enabled ? '运行' : '停止'}</Badge> : undefined}
|
||||
>
|
||||
<PythonControllerPanel
|
||||
paths={props.controllerPaths}
|
||||
selectedPath={props.selectedControllerPath}
|
||||
status={props.controllerStatus ?? s.controller}
|
||||
loading={props.loading}
|
||||
onSelectPath={props.onSelectControllerPath}
|
||||
onLoadPath={props.onLoadControllerPath}
|
||||
onImport={props.onImportController}
|
||||
onToggle={props.onToggleController}
|
||||
onCommand={props.onControllerCommand}
|
||||
onRemove={props.onRemoveController}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
<CollapsibleSection
|
||||
title="Actuator"
|
||||
defaultOpen={false}
|
||||
badge={<Badge>{s.actuators.length}</Badge>}
|
||||
>
|
||||
{s.actuators.length ? (
|
||||
s.actuators.map((actuator) => (
|
||||
<ActuatorControl
|
||||
key={actuator.id}
|
||||
actuator={actuator}
|
||||
onControl={(value) => props.onActuator(actuator.id, value)}
|
||||
onParameters={(parameters) => props.onActuatorParameters(actuator.id, parameters)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="text-xs text-text-tertiary">模型没有驱动器</p>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
<CollapsibleSection title="关节" defaultOpen badge={<Badge>{s.joints.length}</Badge>}>
|
||||
<div className="mb-4 grid grid-cols-2 gap-2">
|
||||
<Button onClick={props.onResetJoints}>重置关节</Button>
|
||||
<Button
|
||||
variant={props.ignoreJointLimits ? 'primary' : 'secondary'}
|
||||
aria-pressed={props.ignoreJointLimits}
|
||||
onClick={props.onToggleJointLimits}
|
||||
>
|
||||
忽略关节限位
|
||||
</Button>
|
||||
<Button
|
||||
variant={props.jointAdvanced ? 'primary' : 'secondary'}
|
||||
aria-pressed={props.jointAdvanced}
|
||||
onClick={props.onToggleAdvanced}
|
||||
>
|
||||
高级
|
||||
</Button>
|
||||
<Button
|
||||
variant={props.angleUnit === 'deg' ? 'primary' : 'secondary'}
|
||||
aria-pressed={props.angleUnit === 'deg'}
|
||||
onClick={props.onToggleAngleUnit}
|
||||
>
|
||||
{props.angleUnit === 'rad' ? 'rad 弧度制' : '° 角度制'}
|
||||
</Button>
|
||||
</div>
|
||||
{s.joints.map((joint) => {
|
||||
const scale = joint.type === 3 && props.angleUnit === 'deg' ? 180 / Math.PI : 1,
|
||||
unit =
|
||||
joint.type === 3
|
||||
? props.angleUnit === 'deg'
|
||||
? '°'
|
||||
: ' rad'
|
||||
: joint.type === 2
|
||||
? ' m'
|
||||
: '';
|
||||
return (
|
||||
<ControlSlider
|
||||
key={joint.id}
|
||||
label={`${joint.name}${joint.editable ? '' : '(只读)'}`}
|
||||
value={joint.value * scale}
|
||||
min={joint.min * scale}
|
||||
max={joint.max * scale}
|
||||
unit={unit}
|
||||
advanced={props.jointAdvanced}
|
||||
limited={joint.limited}
|
||||
limitsIgnored={joint.limitsIgnored}
|
||||
limitMin={joint.limitMin * scale}
|
||||
limitMax={joint.limitMax * scale}
|
||||
disabled={!joint.editable}
|
||||
onChange={(value) => props.onJoint(joint.id, value / scale)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</CollapsibleSection>
|
||||
<CollapsibleSection title="外力强度" defaultOpen={false}>
|
||||
<ControlSlider
|
||||
label={`${props.forceScale.toFixed(0)} N/屏幕单位`}
|
||||
value={props.forceScale}
|
||||
min={5}
|
||||
max={200}
|
||||
onChange={props.onForceScale}
|
||||
/>
|
||||
<p className="text-xs text-text-tertiary">
|
||||
选择“外力施加”,在动态物体上按住拖动,松开即清零。
|
||||
</p>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<SidebarPanel title="属性与参数" side="right" visible={props.visible}>
|
||||
<Tabs
|
||||
label="模型控制侧栏"
|
||||
value={tab}
|
||||
onValueChange={(value) => {
|
||||
setTab(value);
|
||||
if (value === 'map') props.onMapTabOpen?.();
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
value: 'properties',
|
||||
label: '属性',
|
||||
icon: <Info className="h-3.5 w-3.5" />,
|
||||
content: properties,
|
||||
},
|
||||
{
|
||||
value: 'controls',
|
||||
label: '控制',
|
||||
icon: <SlidersHorizontal className="h-3.5 w-3.5" />,
|
||||
content: controls,
|
||||
},
|
||||
{
|
||||
value: 'data',
|
||||
label: '数据',
|
||||
icon: <Database className="h-3.5 w-3.5" />,
|
||||
content: (
|
||||
<DataRecordingPanel
|
||||
status={s.telemetry}
|
||||
bodies={s.bodies}
|
||||
onConfigure={props.onDataRecorderConfigure}
|
||||
onStart={props.onDataRecordingStart}
|
||||
onStop={props.onDataRecordingStop}
|
||||
onClear={props.onDataRecordingClear}
|
||||
onExport={props.onDataRecordingExport}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'map',
|
||||
label: '地图',
|
||||
icon: <MapIcon className="h-3.5 w-3.5" />,
|
||||
content: (
|
||||
<PhysicalMapPanel
|
||||
key={JSON.stringify(props.mapSelection)}
|
||||
value={props.mapSelection}
|
||||
maps={props.maps}
|
||||
rootBodies={s.bodies
|
||||
.filter(
|
||||
(body) =>
|
||||
body.id !== 0 &&
|
||||
body.parentId === 0 &&
|
||||
!body.name.startsWith('__platform_map_'),
|
||||
)
|
||||
.map((body) => body.name)}
|
||||
loading={props.loading}
|
||||
nativeUrdf={props.selectedFormat === 'urdf' && props.urdfMode === 'native'}
|
||||
showVisualMap={props.showVisualMap}
|
||||
showMapCollision={props.showMapCollision}
|
||||
editorDocument={props.editorDocument}
|
||||
onEditorPreview={props.onEditorPreview}
|
||||
onEditorApply={props.onEditorApply}
|
||||
onEditorExport={props.onEditorExport}
|
||||
onEditorConvert={props.onEditorConvert}
|
||||
onEditorBindInteraction={props.onEditorBindInteraction}
|
||||
onEditorSelect={props.onEditorSelect}
|
||||
onEditorTransformMode={props.onEditorTransformMode}
|
||||
onEditorSnapping={props.onEditorSnapping}
|
||||
onMapDisplay={props.onMapDisplay}
|
||||
onApply={props.onApplyMap}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SidebarPanel>
|
||||
);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { DEFAULT_PHYSICAL_MAP_CONFIG } from '../../map/types';
|
||||
import { PhysicalMapPanel } from './PhysicalMapPanel';
|
||||
|
||||
const common = {
|
||||
maps: [],
|
||||
rootBodies: ['robot'],
|
||||
loading: false,
|
||||
nativeUrdf: false,
|
||||
showVisualMap: true,
|
||||
showMapCollision: false,
|
||||
editorDocument: null,
|
||||
onEditorPreview: () => {},
|
||||
onEditorApply: async () => true,
|
||||
onEditorExport: () => {},
|
||||
onEditorConvert: async () => true,
|
||||
onEditorBindInteraction: () => {},
|
||||
onEditorSelect: () => {},
|
||||
onEditorTransformMode: () => {},
|
||||
onEditorSnapping: () => {},
|
||||
onMapDisplay: () => {},
|
||||
};
|
||||
|
||||
describe('PhysicalMapPanel', () => {
|
||||
it('选择楼梯参数后请求重新编译', () => {
|
||||
const onApply = vi.fn();
|
||||
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={onApply} />);
|
||||
fireEvent.change(screen.getByLabelText('地图来源'), { target: { value: 'builtin' } });
|
||||
fireEvent.change(screen.getByLabelText('物理地图预设'), { target: { value: 'stairs' } });
|
||||
fireEvent.change(screen.getByLabelText('台阶数量'), { target: { value: '6' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
|
||||
expect(onApply).toHaveBeenCalledWith({
|
||||
kind: 'builtin',
|
||||
config: expect.objectContaining({ preset: 'stairs', stairCount: 6 }),
|
||||
});
|
||||
});
|
||||
|
||||
it('在右侧参数面板调整系统地形并提交', () => {
|
||||
const onApply = vi.fn();
|
||||
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={onApply} />);
|
||||
fireEvent.change(screen.getByLabelText('地图来源'), { target: { value: 'builtin' } });
|
||||
fireEvent.change(screen.getByLabelText('物理地图预设'), { target: { value: 'rough' } });
|
||||
fireEvent.change(screen.getByLabelText('地形难度(0–1)'), { target: { value: '0.8' } });
|
||||
fireEvent.change(screen.getByLabelText('随机种子'), { target: { value: '9' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
|
||||
expect(onApply).toHaveBeenCalledWith({
|
||||
kind: 'builtin',
|
||||
config: expect.objectContaining({
|
||||
preset: 'rough',
|
||||
terrainDifficulty: 0.8,
|
||||
seed: 9,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('可以移动并旋转资产地图', () => {
|
||||
const onApply = vi.fn();
|
||||
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={onApply} />);
|
||||
fireEvent.change(screen.getByLabelText('地图来源'), { target: { value: 'builtin' } });
|
||||
fireEvent.change(screen.getByLabelText('位置 X(m)'), { target: { value: '2.5' } });
|
||||
fireEvent.change(screen.getByLabelText('位置 Y(m)'), { target: { value: '-1.5' } });
|
||||
fireEvent.change(screen.getByLabelText('旋转 Z(°)'), { target: { value: '45' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
|
||||
expect(onApply).toHaveBeenCalledWith({
|
||||
kind: 'builtin',
|
||||
config: expect.objectContaining({ positionX: 2.5, positionY: -1.5, yawDeg: 45 }),
|
||||
});
|
||||
});
|
||||
|
||||
it('显示工程地图、出生点和根 Body', () => {
|
||||
const onApply = vi.fn();
|
||||
render(
|
||||
<PhysicalMapPanel
|
||||
{...common}
|
||||
value={{ kind: 'none' }}
|
||||
maps={[
|
||||
{
|
||||
descriptorPath: 'maps/warehouse/map.json',
|
||||
schemaVersion: 1 as const,
|
||||
id: 'warehouse',
|
||||
name: '仓库',
|
||||
spawnPoints: [{ id: 'door', name: '入口' }],
|
||||
},
|
||||
]}
|
||||
rootBodies={['robot', 'payload']}
|
||||
onApply={onApply}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText('地图来源'), {
|
||||
target: { value: 'project:maps/warehouse/map.json' },
|
||||
});
|
||||
expect(screen.getByLabelText('地图出生点')).toHaveValue('door');
|
||||
fireEvent.change(screen.getByLabelText('机器人根 Body'), { target: { value: 'robot' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
|
||||
expect(onApply).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'project',
|
||||
descriptorPath: 'maps/warehouse/map.json',
|
||||
spawnPointId: 'door',
|
||||
robotRootBody: 'robot',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('原生 URDF 模式只允许移除地图', () => {
|
||||
render(
|
||||
<PhysicalMapPanel
|
||||
{...common}
|
||||
value={{ kind: 'builtin', config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'flat' } }}
|
||||
nativeUrdf
|
||||
onApply={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('status')).toHaveTextContent('原生 URDF');
|
||||
expect(screen.getByRole('button', { name: '应用并重新编译' })).toBeDisabled();
|
||||
expect(screen.getByLabelText('地图来源')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -1,474 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Select } from '../../components/ui';
|
||||
import type { MapEntry } from '../../project/types';
|
||||
import {
|
||||
DEFAULT_PHYSICAL_MAP_CONFIG,
|
||||
PHYSICAL_MAP_PRESET_LABELS,
|
||||
isSystemTerrainPreset,
|
||||
type MapSelection,
|
||||
type PhysicalMapConfig,
|
||||
type PhysicalMapPreset,
|
||||
} from '../../map/types';
|
||||
import { normalizePhysicalMapConfig } from '../../map/physicalMap';
|
||||
import type {
|
||||
EditableMapDocument,
|
||||
MapEditorInteractionCallbacks,
|
||||
MapEditorTransformMode,
|
||||
} from '../../map/editor/types';
|
||||
import { MapEditorPanel } from './MapEditorPanel';
|
||||
|
||||
function NumberField({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
disabled: boolean;
|
||||
onChange: (value: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">{label}</span>
|
||||
<input
|
||||
type="number"
|
||||
className="h-8 w-full rounded border border-border-strong bg-input px-2 text-xs text-text-primary outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeSelection(selection: MapSelection): MapSelection {
|
||||
if (selection.kind === 'project') {
|
||||
const friction = selection.frictionOverride;
|
||||
return {
|
||||
...selection,
|
||||
frictionOverride:
|
||||
friction === undefined || !Number.isFinite(friction)
|
||||
? undefined
|
||||
: Math.min(5, Math.max(0.05, friction)),
|
||||
};
|
||||
}
|
||||
if (selection.kind !== 'builtin') return selection;
|
||||
const config = normalizePhysicalMapConfig(selection.config);
|
||||
return config.preset === 'none' ? { kind: 'none' } : { kind: 'builtin', config };
|
||||
}
|
||||
|
||||
export function PhysicalMapPanel({
|
||||
value,
|
||||
maps,
|
||||
rootBodies,
|
||||
loading,
|
||||
nativeUrdf,
|
||||
showVisualMap,
|
||||
showMapCollision,
|
||||
editorDocument,
|
||||
onEditorPreview,
|
||||
onEditorApply,
|
||||
onEditorExport,
|
||||
onEditorConvert,
|
||||
onEditorBindInteraction,
|
||||
onEditorSelect,
|
||||
onEditorTransformMode,
|
||||
onEditorSnapping,
|
||||
onMapDisplay,
|
||||
onApply,
|
||||
}: {
|
||||
value: MapSelection;
|
||||
maps: MapEntry[];
|
||||
rootBodies: string[];
|
||||
loading: boolean;
|
||||
nativeUrdf: boolean;
|
||||
showVisualMap: boolean;
|
||||
showMapCollision: boolean;
|
||||
editorDocument: EditableMapDocument | null;
|
||||
onEditorPreview: (document: EditableMapDocument | null) => void;
|
||||
onEditorApply: (document: EditableMapDocument) => Promise<boolean>;
|
||||
onEditorExport: () => void;
|
||||
onEditorConvert: () => Promise<boolean>;
|
||||
onEditorBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
|
||||
onEditorSelect: (id: string | null) => void;
|
||||
onEditorTransformMode: (mode: MapEditorTransformMode) => void;
|
||||
onEditorSnapping: (translation: number | null, rotationDegrees: number | null) => void;
|
||||
onMapDisplay: (visual: boolean, collision: boolean) => void;
|
||||
onApply: (value: MapSelection) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<MapSelection>(value);
|
||||
const normalized = normalizeSelection(draft);
|
||||
const changed = JSON.stringify(normalized) !== JSON.stringify(value);
|
||||
const compileDisabled = loading || (nativeUrdf && normalized.kind !== 'none');
|
||||
const sourceValue =
|
||||
draft.kind === 'none'
|
||||
? 'none'
|
||||
: draft.kind === 'builtin'
|
||||
? 'builtin'
|
||||
: `project:${draft.descriptorPath}`;
|
||||
const projectMap =
|
||||
draft.kind === 'project'
|
||||
? maps.find((entry) => entry.descriptorPath === draft.descriptorPath)
|
||||
: undefined;
|
||||
const updateBuiltin = <K extends keyof PhysicalMapConfig>(key: K, next: PhysicalMapConfig[K]) => {
|
||||
if (draft.kind !== 'builtin') return;
|
||||
setDraft({ kind: 'builtin', config: { ...draft.config, [key]: next } });
|
||||
};
|
||||
|
||||
const changeSource = (source: string) => {
|
||||
if (source === 'none') setDraft({ kind: 'none' });
|
||||
else if (source === 'builtin')
|
||||
setDraft({
|
||||
kind: 'builtin',
|
||||
config: {
|
||||
...DEFAULT_PHYSICAL_MAP_CONFIG,
|
||||
preset: value.kind === 'builtin' ? value.config.preset : 'flat',
|
||||
},
|
||||
});
|
||||
else {
|
||||
const descriptorPath = source.slice('project:'.length);
|
||||
const map = maps.find((entry) => entry.descriptorPath === descriptorPath);
|
||||
setDraft({
|
||||
kind: 'project',
|
||||
descriptorPath,
|
||||
spawnPointId: map?.spawnPoints[0]?.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-3">
|
||||
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-3">
|
||||
<div className="text-xs font-semibold text-text-primary">地图参数</div>
|
||||
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
|
||||
从左侧“资产”选择地形或添加场景对象,在这里调整物理参数与编辑属性。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">地图来源</span>
|
||||
<Select
|
||||
aria-label="地图来源"
|
||||
className="w-full"
|
||||
value={sourceValue}
|
||||
disabled={loading}
|
||||
onChange={(event) => changeSource(event.target.value)}
|
||||
>
|
||||
<option value="none">不使用地图</option>
|
||||
<option value="builtin">内置程序地图</option>
|
||||
{maps.map((map) => (
|
||||
<option key={map.descriptorPath} value={`project:${map.descriptorPath}`}>
|
||||
{map.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
{draft.kind === 'builtin' && (
|
||||
<>
|
||||
<label className="block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">地图预设</span>
|
||||
<Select
|
||||
aria-label="物理地图预设"
|
||||
className="w-full"
|
||||
value={draft.config.preset}
|
||||
disabled={loading}
|
||||
onChange={(event) => updateBuiltin('preset', event.target.value as PhysicalMapPreset)}
|
||||
>
|
||||
{(Object.keys(PHYSICAL_MAP_PRESET_LABELS) as PhysicalMapPreset[])
|
||||
.filter((preset) => preset !== 'none')
|
||||
.map((preset) => (
|
||||
<option key={preset} value={preset}>
|
||||
{PHYSICAL_MAP_PRESET_LABELS[preset]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-2.5">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-text-primary">地图变换</span>
|
||||
<Button
|
||||
disabled={
|
||||
loading ||
|
||||
(draft.config.positionX === 0 &&
|
||||
draft.config.positionY === 0 &&
|
||||
draft.config.yawDeg === 0)
|
||||
}
|
||||
onClick={() =>
|
||||
setDraft({
|
||||
kind: 'builtin',
|
||||
config: { ...draft.config, positionX: 0, positionY: 0, yawDeg: 0 },
|
||||
})
|
||||
}
|
||||
>
|
||||
复位
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<NumberField
|
||||
label="位置 X(m)"
|
||||
value={draft.config.positionX}
|
||||
min={-100}
|
||||
max={100}
|
||||
step={0.1}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('positionX', next)}
|
||||
/>
|
||||
<NumberField
|
||||
label="位置 Y(m)"
|
||||
value={draft.config.positionY}
|
||||
min={-100}
|
||||
max={100}
|
||||
step={0.1}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('positionY', next)}
|
||||
/>
|
||||
<NumberField
|
||||
label="旋转 Z(°)"
|
||||
value={draft.config.yawDeg}
|
||||
min={-180}
|
||||
max={180}
|
||||
step={1}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('yawDeg', next)}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] leading-relaxed text-text-tertiary">
|
||||
调整资产地图在世界坐标中的位置和朝向,应用后同步更新物理碰撞。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<NumberField
|
||||
label={isSystemTerrainPreset(draft.config.preset) ? '地形边长(m)' : '地图半径(m)'}
|
||||
value={draft.config.size}
|
||||
min={4}
|
||||
max={isSystemTerrainPreset(draft.config.preset) ? 30 : 100}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('size', next)}
|
||||
/>
|
||||
<NumberField
|
||||
label="摩擦系数"
|
||||
value={draft.config.friction}
|
||||
min={0.05}
|
||||
max={5}
|
||||
step={0.05}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('friction', next)}
|
||||
/>
|
||||
{draft.config.preset === 'slope' && (
|
||||
<NumberField
|
||||
label="坡度(°)"
|
||||
value={draft.config.slopeAngle}
|
||||
min={5}
|
||||
max={30}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('slopeAngle', next)}
|
||||
/>
|
||||
)}
|
||||
{draft.config.preset === 'stairs' && (
|
||||
<NumberField
|
||||
label="台阶数量"
|
||||
value={draft.config.stairCount}
|
||||
min={2}
|
||||
max={20}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('stairCount', next)}
|
||||
/>
|
||||
)}
|
||||
{draft.config.preset === 'obstacles' && (
|
||||
<NumberField
|
||||
label="障碍物数量"
|
||||
value={draft.config.obstacleCount}
|
||||
min={1}
|
||||
max={30}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('obstacleCount', next)}
|
||||
/>
|
||||
)}
|
||||
{(draft.config.preset === 'obstacles' ||
|
||||
isSystemTerrainPreset(draft.config.preset)) && (
|
||||
<NumberField
|
||||
label="随机种子"
|
||||
value={draft.config.seed}
|
||||
min={0}
|
||||
max={2_147_483_647}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('seed', next)}
|
||||
/>
|
||||
)}
|
||||
{isSystemTerrainPreset(draft.config.preset) && (
|
||||
<NumberField
|
||||
label="地形难度(0–1)"
|
||||
value={draft.config.terrainDifficulty}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('terrainDifficulty', next)}
|
||||
/>
|
||||
)}
|
||||
{(draft.config.preset === 'rough' || draft.config.preset === 'wave') && (
|
||||
<>
|
||||
<NumberField
|
||||
label="水平采样间距(m)"
|
||||
value={draft.config.terrainHorizontalScale}
|
||||
min={0.03}
|
||||
max={1}
|
||||
step={0.01}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('terrainHorizontalScale', next)}
|
||||
/>
|
||||
<NumberField
|
||||
label="垂直量化(m)"
|
||||
value={draft.config.terrainVerticalScale}
|
||||
min={0.001}
|
||||
max={0.1}
|
||||
step={0.001}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('terrainVerticalScale', next)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{draft.kind === 'project' && projectMap && (
|
||||
<div className="space-y-3 rounded border border-border-subtle bg-panel-muted p-3">
|
||||
<div className="text-xs font-medium text-text-primary">{projectMap.name}</div>
|
||||
<div className="break-all text-[11px] text-text-tertiary">
|
||||
{projectMap.descriptorPath}
|
||||
</div>
|
||||
{projectMap.spawnPoints.length > 0 && (
|
||||
<label className="block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">出生点</span>
|
||||
<Select
|
||||
aria-label="地图出生点"
|
||||
className="w-full"
|
||||
value={draft.spawnPointId ?? ''}
|
||||
disabled={loading}
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, spawnPointId: event.target.value || undefined })
|
||||
}
|
||||
>
|
||||
<option value="">不改变机器人位置</option>
|
||||
{projectMap.spawnPoints.map((spawn) => (
|
||||
<option key={spawn.id} value={spawn.id}>
|
||||
{spawn.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
)}
|
||||
{rootBodies.length > 1 && (
|
||||
<label className="block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">机器人根 Body</span>
|
||||
<Select
|
||||
aria-label="机器人根 Body"
|
||||
className="w-full"
|
||||
value={draft.robotRootBody ?? ''}
|
||||
disabled={loading}
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, robotRootBody: event.target.value || undefined })
|
||||
}
|
||||
>
|
||||
<option value="">自动识别</option>
|
||||
{rootBodies.map((body) => (
|
||||
<option key={body} value={body}>
|
||||
{body}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
)}
|
||||
<label className="block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">覆盖摩擦系数(留空则使用地图值)</span>
|
||||
<input
|
||||
aria-label="地图摩擦系数覆盖"
|
||||
type="number"
|
||||
min={0.05}
|
||||
max={5}
|
||||
step={0.05}
|
||||
value={draft.frictionOverride ?? ''}
|
||||
disabled={loading}
|
||||
className="h-8 w-full rounded border border-border-strong bg-input px-2 text-xs text-text-primary"
|
||||
onChange={(event) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
frictionOverride: event.target.value ? Number(event.target.value) : undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{draft.kind === 'project' &&
|
||||
draft.descriptorPath === (value.kind === 'project' ? value.descriptorPath : '') && (
|
||||
<div className="space-y-2 rounded border border-border-subtle p-3">
|
||||
<div className="text-xs font-medium text-text-primary">V3 地图编辑器</div>
|
||||
<MapEditorPanel
|
||||
document={editorDocument}
|
||||
loading={loading}
|
||||
onPreview={onEditorPreview}
|
||||
onApply={onEditorApply}
|
||||
onExport={onEditorExport}
|
||||
onConvert={onEditorConvert}
|
||||
canConvert={Boolean(projectMap?.physicsPath)}
|
||||
onBindInteraction={onEditorBindInteraction}
|
||||
onSelectPreview={onEditorSelect}
|
||||
onTransformMode={onEditorTransformMode}
|
||||
onSnapping={onEditorSnapping}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 rounded border border-border-subtle p-3 text-xs text-text-secondary">
|
||||
<label className="flex items-center justify-between gap-3">
|
||||
<span>显示视觉地图</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showVisualMap}
|
||||
onChange={(event) => onMapDisplay(event.target.checked, showMapCollision)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between gap-3">
|
||||
<span>显示地图碰撞层</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showMapCollision}
|
||||
onChange={(event) => onMapDisplay(showVisualMap, event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{nativeUrdf && normalized.kind !== 'none' && (
|
||||
<p
|
||||
role="status"
|
||||
className="rounded border border-warning/30 bg-warning/10 p-2 text-xs text-warning"
|
||||
>
|
||||
原生 URDF 不能注入地图。请在“属性”中切换为“转换为 MJCF”。
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
disabled={compileDisabled || !changed}
|
||||
onClick={() => onApply(normalized)}
|
||||
>
|
||||
应用并重新编译
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useState } from 'react';
|
||||
import { Box, FolderTree, Map as MapIcon } from 'lucide-react';
|
||||
import type { ModelEntry } from '../../project/types';
|
||||
import {
|
||||
countProjectSearchResults,
|
||||
ProjectTree,
|
||||
type ProjectTreeFile,
|
||||
} from '../../project/ProjectTree';
|
||||
import {
|
||||
countModelStructureSearchResults,
|
||||
ModelStructureTree,
|
||||
} from '../../project/ModelStructureTree';
|
||||
import type { SimulationSnapshot } from '../../simulation/SimulationSession';
|
||||
import { Button, Tabs } from '../../components/ui';
|
||||
import {
|
||||
DEFAULT_PHYSICAL_MAP_CONFIG,
|
||||
type MapSelection,
|
||||
type SystemTerrainPreset,
|
||||
} from '../../map/types';
|
||||
import type {
|
||||
EditableMapDocument,
|
||||
EditableMapObjectType,
|
||||
MapObjectPlacementMode,
|
||||
} from '../../map/editor/types';
|
||||
import { MapAssetLibrary } from '../../map/MapAssetLibrary';
|
||||
import { ProjectBreadcrumb } from './ProjectBreadcrumb';
|
||||
import { SidebarPanel } from './SidebarPanel';
|
||||
import { TreeSearchField } from './TreeSearchField';
|
||||
|
||||
export function ProjectSidebar({
|
||||
projectName,
|
||||
files,
|
||||
entries,
|
||||
selectedEntry,
|
||||
snapshot,
|
||||
loading,
|
||||
visible = true,
|
||||
nativeUrdf,
|
||||
mapSelection,
|
||||
editorDocument,
|
||||
activeTab,
|
||||
onActiveTabChange,
|
||||
onRemove,
|
||||
onSelectEntry,
|
||||
onJointHover,
|
||||
onAddMapAsset,
|
||||
onSelectTerrain,
|
||||
onSelectMapObject,
|
||||
}: {
|
||||
projectName?: string;
|
||||
files: ProjectTreeFile[];
|
||||
entries: ModelEntry[];
|
||||
selectedEntry?: string;
|
||||
snapshot?: SimulationSnapshot;
|
||||
loading: boolean;
|
||||
visible?: boolean;
|
||||
nativeUrdf: boolean;
|
||||
mapSelection: MapSelection;
|
||||
editorDocument: EditableMapDocument | null;
|
||||
activeTab?: 'project' | 'structure' | 'assets';
|
||||
onActiveTabChange?: (value: 'project' | 'structure' | 'assets') => void;
|
||||
onRemove: () => void;
|
||||
onSelectEntry: (path: string) => void;
|
||||
onJointHover: (jointId: number | null) => void;
|
||||
onAddMapAsset: (
|
||||
type: EditableMapObjectType,
|
||||
placementMode: MapObjectPlacementMode,
|
||||
) => void | Promise<void>;
|
||||
onSelectTerrain: (preset: SystemTerrainPreset) => void;
|
||||
onSelectMapObject: (id: string) => void;
|
||||
}) {
|
||||
const [internalTab, setInternalTab] = useState<'project' | 'structure' | 'assets'>('project'),
|
||||
[fileQuery, setFileQuery] = useState(''),
|
||||
[structureQuery, setStructureQuery] = useState('');
|
||||
const tab = activeTab ?? internalTab,
|
||||
fileMatches = countProjectSearchResults(files, fileQuery),
|
||||
structureMatches = snapshot
|
||||
? countModelStructureSearchResults(snapshot.bodies, snapshot.joints, structureQuery)
|
||||
: 0;
|
||||
return (
|
||||
<SidebarPanel title="资产与结构" side="left" visible={visible}>
|
||||
{projectName ? (
|
||||
<>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-accent" title={projectName}>
|
||||
{projectName}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] text-text-tertiary">{files.length} 个文件</div>
|
||||
</div>
|
||||
<Button variant="danger" onClick={onRemove} disabled={loading}>
|
||||
移除
|
||||
</Button>
|
||||
</div>
|
||||
<ProjectBreadcrumb
|
||||
projectName={projectName}
|
||||
entries={entries}
|
||||
selectedEntry={selectedEntry}
|
||||
loading={loading}
|
||||
onSelect={onSelectEntry}
|
||||
/>
|
||||
<Tabs
|
||||
label="工程侧栏"
|
||||
value={tab}
|
||||
onValueChange={(value) => {
|
||||
setInternalTab(value);
|
||||
onActiveTabChange?.(value);
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
value: 'project',
|
||||
label: '工程',
|
||||
icon: <FolderTree className="h-3.5 w-3.5" />,
|
||||
content: (
|
||||
<>
|
||||
<TreeSearchField
|
||||
value={fileQuery}
|
||||
onChange={setFileQuery}
|
||||
resultCount={fileMatches}
|
||||
label="搜索工程文件"
|
||||
placeholder="搜索文件或目录…"
|
||||
/>
|
||||
<div className="px-2 pb-3">
|
||||
<ProjectTree
|
||||
key={projectName}
|
||||
files={files}
|
||||
entries={entries}
|
||||
selectedEntry={selectedEntry}
|
||||
query={fileQuery}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'structure',
|
||||
label: '模型结构',
|
||||
icon: <Box className="h-3.5 w-3.5" />,
|
||||
disabled: !snapshot,
|
||||
content: snapshot ? (
|
||||
<>
|
||||
<TreeSearchField
|
||||
value={structureQuery}
|
||||
onChange={setStructureQuery}
|
||||
resultCount={structureMatches}
|
||||
label="搜索模型结构"
|
||||
placeholder="搜索 Body 或关节…"
|
||||
/>
|
||||
<div className="px-2 pb-3">
|
||||
<ModelStructureTree
|
||||
bodies={snapshot.bodies}
|
||||
joints={snapshot.joints}
|
||||
onJointHover={onJointHover}
|
||||
query={structureQuery}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="p-4 text-center text-xs text-text-tertiary">加载模型后显示结构</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'assets',
|
||||
label: '资产',
|
||||
icon: <MapIcon className="h-3.5 w-3.5" />,
|
||||
disabled: !snapshot,
|
||||
content: (
|
||||
<MapAssetLibrary
|
||||
disabled={loading || nativeUrdf}
|
||||
terrainSize={
|
||||
mapSelection.kind === 'builtin'
|
||||
? mapSelection.config.size
|
||||
: DEFAULT_PHYSICAL_MAP_CONFIG.size
|
||||
}
|
||||
document={editorDocument}
|
||||
onAdd={onAddMapAsset}
|
||||
onSelectTerrain={onSelectTerrain}
|
||||
onSelectObject={onSelectMapObject}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="p-4 text-center text-sm text-text-tertiary">导入模型后显示工程资源</div>
|
||||
)}
|
||||
</SidebarPanel>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { PythonControllerPanel } from './PythonControllerPanel';
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
describe('PythonControllerPanel', () => {
|
||||
it('向支持 command 的已启用控制器发送基本移动指令', () => {
|
||||
const onCommand = vi.fn();
|
||||
render(
|
||||
<PythonControllerPanel
|
||||
paths={[]}
|
||||
loading={false}
|
||||
status={{
|
||||
language: 'python',
|
||||
path: 'go2.py',
|
||||
name: 'Go2',
|
||||
controlHz: 200,
|
||||
loaded: true,
|
||||
enabled: true,
|
||||
acceptsCommands: true,
|
||||
activeCommand: 'stop',
|
||||
lastStepMs: 0.1,
|
||||
}}
|
||||
onSelectPath={noop}
|
||||
onLoadPath={noop}
|
||||
onImport={noop}
|
||||
onToggle={noop}
|
||||
onCommand={onCommand}
|
||||
onRemove={noop}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '前进' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '左转' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '起跳' }));
|
||||
expect(onCommand.mock.calls).toEqual([['forward'], ['turn_left'], ['jump']]);
|
||||
expect(screen.getByRole('button', { name: '移动停止' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('控制器未启用时禁用基本移动按钮', () => {
|
||||
render(
|
||||
<PythonControllerPanel
|
||||
paths={[]}
|
||||
loading={false}
|
||||
status={{
|
||||
language: 'python',
|
||||
path: 'go2.py',
|
||||
name: 'Go2',
|
||||
controlHz: 200,
|
||||
loaded: true,
|
||||
enabled: false,
|
||||
acceptsCommands: true,
|
||||
lastStepMs: 0,
|
||||
}}
|
||||
onSelectPath={noop}
|
||||
onLoadPath={noop}
|
||||
onImport={noop}
|
||||
onToggle={noop}
|
||||
onCommand={noop}
|
||||
onRemove={noop}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: '前进' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: '起跳' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -1,184 +0,0 @@
|
||||
import { useRef, type ChangeEvent } from 'react';
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
FileUp,
|
||||
Octagon,
|
||||
Power,
|
||||
RotateCw,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import type { ControllerCommand, ControllerStatus } from '../../controller/types';
|
||||
import { Badge, Button, PropertyRow, Select } from '../../components/ui';
|
||||
|
||||
export interface PythonControllerPanelProps {
|
||||
paths: string[];
|
||||
selectedPath?: string;
|
||||
status?: ControllerStatus;
|
||||
loading: boolean;
|
||||
onSelectPath(path: string): void;
|
||||
onLoadPath(path: string): void;
|
||||
onImport(file: File): void;
|
||||
onToggle(enabled: boolean): void;
|
||||
onCommand(command: ControllerCommand): void;
|
||||
onRemove(): void;
|
||||
}
|
||||
|
||||
export function PythonControllerPanel({
|
||||
paths,
|
||||
selectedPath,
|
||||
status,
|
||||
loading,
|
||||
onSelectPath,
|
||||
onLoadPath,
|
||||
onImport,
|
||||
onToggle,
|
||||
onCommand,
|
||||
onRemove,
|
||||
}: PythonControllerPanelProps) {
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const importFile = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) onImport(file);
|
||||
event.target.value = '';
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
ref={input}
|
||||
className="hidden"
|
||||
type="file"
|
||||
accept=".py,text/x-python"
|
||||
onChange={importFile}
|
||||
/>
|
||||
{paths.length > 0 && (
|
||||
<label className="mb-3 block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">工程内脚本</span>
|
||||
<Select
|
||||
aria-label="Python 控制脚本"
|
||||
className="w-full"
|
||||
value={selectedPath ?? ''}
|
||||
disabled={loading}
|
||||
onChange={(event) => onSelectPath(event.target.value)}
|
||||
>
|
||||
<option value="">选择 .py 文件</option>
|
||||
{paths.map((path) => (
|
||||
<option key={path} value={path}>
|
||||
{path}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
icon={<FileUp className="h-3.5 w-3.5" />}
|
||||
disabled={loading}
|
||||
onClick={() => input.current?.click()}
|
||||
>
|
||||
导入 .py
|
||||
</Button>
|
||||
<Button
|
||||
icon={<RotateCw className="h-3.5 w-3.5" />}
|
||||
disabled={loading || !selectedPath}
|
||||
onClick={() => selectedPath && onLoadPath(selectedPath)}
|
||||
>
|
||||
加载脚本
|
||||
</Button>
|
||||
</div>
|
||||
{status ? (
|
||||
<div className="mt-3 rounded-lg border border-border bg-surface p-2.5">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="truncate text-xs font-medium text-text-primary" title={status.path}>
|
||||
{status.name}
|
||||
</span>
|
||||
<Badge>{status.enabled ? '运行中' : '已停止'}</Badge>
|
||||
</div>
|
||||
<PropertyRow label="语言" value="Python / Pyodide" />
|
||||
<PropertyRow label="控制频率" value={`${status.controlHz} Hz`} />
|
||||
<PropertyRow label="上次耗时" value={`${status.lastStepMs.toFixed(3)} ms`} />
|
||||
{status.error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mt-2 break-words rounded bg-danger/10 p-2 text-[10px] leading-4 text-danger"
|
||||
>
|
||||
{status.error}
|
||||
</p>
|
||||
)}
|
||||
{status.acceptsCommands && (
|
||||
<div className="mt-3 border-t border-border pt-3">
|
||||
<p className="mb-2 text-[10px] text-text-tertiary">基本移动指令</p>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
<span />
|
||||
<Button
|
||||
aria-pressed={status.activeCommand === 'forward'}
|
||||
disabled={!status.enabled}
|
||||
icon={<ArrowUp className="h-3.5 w-3.5" />}
|
||||
onClick={() => onCommand('forward')}
|
||||
>
|
||||
前进
|
||||
</Button>
|
||||
<span />
|
||||
<Button
|
||||
aria-pressed={status.activeCommand === 'turn_left'}
|
||||
disabled={!status.enabled}
|
||||
icon={<ArrowLeft className="h-3.5 w-3.5" />}
|
||||
onClick={() => onCommand('turn_left')}
|
||||
>
|
||||
左转
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="移动停止"
|
||||
aria-pressed={status.activeCommand === 'stop'}
|
||||
disabled={!status.enabled}
|
||||
icon={<Octagon className="h-3.5 w-3.5" />}
|
||||
onClick={() => onCommand('stop')}
|
||||
>
|
||||
停止
|
||||
</Button>
|
||||
<Button
|
||||
aria-pressed={status.activeCommand === 'turn_right'}
|
||||
disabled={!status.enabled}
|
||||
icon={<ArrowRight className="h-3.5 w-3.5" />}
|
||||
onClick={() => onCommand('turn_right')}
|
||||
>
|
||||
右转
|
||||
</Button>
|
||||
<span />
|
||||
<Button
|
||||
aria-pressed={status.activeCommand === 'backward'}
|
||||
disabled={!status.enabled}
|
||||
icon={<ArrowDown className="h-3.5 w-3.5" />}
|
||||
onClick={() => onCommand('backward')}
|
||||
>
|
||||
后退
|
||||
</Button>
|
||||
<Button disabled={!status.enabled} onClick={() => onCommand('jump')}>
|
||||
起跳
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
variant={status.enabled ? 'secondary' : 'primary'}
|
||||
icon={<Power className="h-3.5 w-3.5" />}
|
||||
onClick={() => onToggle(!status.enabled)}
|
||||
>
|
||||
{status.enabled ? '停止' : '启用'}
|
||||
</Button>
|
||||
<Button variant="danger" icon={<Trash2 className="h-3.5 w-3.5" />} onClick={onRemove}>
|
||||
卸载
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 text-xs leading-5 text-text-tertiary">
|
||||
加载可信的单文件 Python 控制器。脚本在每次 mj_step 前按仿真时间同步执行,默认 100 Hz。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import { useRef, type ChangeEvent } from 'react';
|
||||
import { BrainCircuit, FileUp, Power, RotateCw, Trash2 } from 'lucide-react';
|
||||
import type { RLCommand, RLPolicyStatus } from '../../rl/types';
|
||||
import { Badge, Button, PropertyRow, Select } from '../../components/ui';
|
||||
|
||||
export interface RLPolicyPanelProps {
|
||||
paths: string[];
|
||||
selectedPath?: string;
|
||||
status?: RLPolicyStatus;
|
||||
loading: boolean;
|
||||
onSelectPath(path: string): void;
|
||||
onLoadPath(path: string): void;
|
||||
onImport(file: File): void;
|
||||
onToggle(enabled: boolean): void;
|
||||
onCommand(command: RLCommand): void;
|
||||
onRemove(): void;
|
||||
}
|
||||
|
||||
export function RLPolicyPanel({
|
||||
paths,
|
||||
selectedPath,
|
||||
status,
|
||||
loading,
|
||||
onSelectPath,
|
||||
onLoadPath,
|
||||
onImport,
|
||||
onToggle,
|
||||
onCommand,
|
||||
onRemove,
|
||||
}: RLPolicyPanelProps) {
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const importFile = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) onImport(file);
|
||||
event.target.value = '';
|
||||
};
|
||||
const command = status?.command ?? { linearX: 0, linearY: 0, angularZ: 0 };
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
ref={input}
|
||||
className="hidden"
|
||||
type="file"
|
||||
accept=".onnx,application/octet-stream"
|
||||
onChange={importFile}
|
||||
/>
|
||||
{paths.length > 0 && (
|
||||
<label className="mb-3 block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">工程内策略</span>
|
||||
<Select
|
||||
aria-label="ONNX 策略"
|
||||
className="w-full"
|
||||
value={selectedPath ?? ''}
|
||||
disabled={loading}
|
||||
onChange={(event) => onSelectPath(event.target.value)}
|
||||
>
|
||||
<option value="">选择 .onnx 文件</option>
|
||||
{paths.map((path) => (
|
||||
<option key={path} value={path}>
|
||||
{path}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
icon={<FileUp className="h-3.5 w-3.5" />}
|
||||
disabled={loading}
|
||||
onClick={() => input.current?.click()}
|
||||
>
|
||||
导入 ONNX
|
||||
</Button>
|
||||
<Button
|
||||
icon={<RotateCw className="h-3.5 w-3.5" />}
|
||||
disabled={loading || !selectedPath}
|
||||
onClick={() => selectedPath && onLoadPath(selectedPath)}
|
||||
>
|
||||
加载策略
|
||||
</Button>
|
||||
</div>
|
||||
{status ? (
|
||||
<div className="mt-3 rounded-lg border border-border bg-surface p-2.5">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span
|
||||
className="flex min-w-0 items-center gap-1.5 truncate text-xs font-medium text-text-primary"
|
||||
title={status.path}
|
||||
>
|
||||
<BrainCircuit className="h-3.5 w-3.5 shrink-0 text-accent" />
|
||||
{status.taskName}
|
||||
</span>
|
||||
<Badge>{status.enabled ? '推理中' : '已停止'}</Badge>
|
||||
</div>
|
||||
<PropertyRow label="控制频率" value={`${status.controlHz} Hz`} />
|
||||
<PropertyRow
|
||||
label="观测 / 动作"
|
||||
value={`${status.observationSize} / ${status.actionSize}`}
|
||||
/>
|
||||
<PropertyRow label="推理次数" value={status.inferenceCount} />
|
||||
<PropertyRow label="上次推理" value={`${status.lastInferenceMs.toFixed(2)} ms`} />
|
||||
<div className="mt-3 border-t border-border pt-3">
|
||||
<p className="mb-2 text-[10px] text-text-tertiary">速度指令(机身坐标系)</p>
|
||||
<CommandInput
|
||||
label="前向 m/s"
|
||||
value={command.linearX}
|
||||
min={-0.5}
|
||||
max={1}
|
||||
onChange={(linearX) => onCommand({ ...command, linearX })}
|
||||
/>
|
||||
<CommandInput
|
||||
label="侧向 m/s"
|
||||
value={command.linearY}
|
||||
min={-0.5}
|
||||
max={0.5}
|
||||
onChange={(linearY) => onCommand({ ...command, linearY })}
|
||||
/>
|
||||
<CommandInput
|
||||
label="偏航 rad/s"
|
||||
value={command.angularZ}
|
||||
min={-1}
|
||||
max={1}
|
||||
onChange={(angularZ) => onCommand({ ...command, angularZ })}
|
||||
/>
|
||||
<Button
|
||||
className="mt-1 w-full"
|
||||
onClick={() => onCommand({ linearX: 0, linearY: 0, angularZ: 0 })}
|
||||
>
|
||||
停止移动
|
||||
</Button>
|
||||
</div>
|
||||
{status.error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mt-2 break-words rounded bg-danger/10 p-2 text-[10px] leading-4 text-danger"
|
||||
>
|
||||
{status.error}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
variant={status.enabled ? 'secondary' : 'primary'}
|
||||
icon={<Power className="h-3.5 w-3.5" />}
|
||||
disabled={Boolean(status.error)}
|
||||
onClick={() => onToggle(!status.enabled)}
|
||||
>
|
||||
{status.enabled ? '停止' : '启用'}
|
||||
</Button>
|
||||
<Button variant="danger" icon={<Trash2 className="h-3.5 w-3.5" />} onClick={onRemove}>
|
||||
卸载
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 text-xs leading-5 text-text-tertiary">
|
||||
加载 mjlab 导出的单输入、单动作输出 policy.onnx。首个内置任务使用 47 维 Go2 actor 观测和
|
||||
12 维腿部关节位置动作;Go2-W 轮电机保持零力矩。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
onChange(value: number): void;
|
||||
}) {
|
||||
return (
|
||||
<label className="mb-2 grid grid-cols-[1fr_72px] items-center gap-2 text-[10px] text-text-tertiary">
|
||||
<span>{label}</span>
|
||||
<input
|
||||
className="field h-7 w-full px-2 text-right text-xs text-text-primary"
|
||||
type="number"
|
||||
step="0.05"
|
||||
min={min}
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +1,6 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Database,
|
||||
FolderTree,
|
||||
Info,
|
||||
Map as MapIcon,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
} from 'lucide-react';
|
||||
import type { MapEntry, ModelEntry } from '../../project/types';
|
||||
import {
|
||||
countProjectSearchResults,
|
||||
ProjectTree,
|
||||
type ProjectTreeFile,
|
||||
} from '../../project/ProjectTree';
|
||||
import {
|
||||
countModelStructureSearchResults,
|
||||
ModelStructureTree,
|
||||
} from '../../project/ModelStructureTree';
|
||||
import type {
|
||||
ActuatorInfo,
|
||||
ActuatorParameters,
|
||||
SimulationSnapshot,
|
||||
} from '../../simulation/SimulationSession';
|
||||
import type { UrdfBaseMode, UrdfLoadMode } from '../../simulation/PhysicsAdapter';
|
||||
import type { DataRecorderConfig } from '../../simulation/DataRecorder';
|
||||
import type { ViewerSelection } from '../../viewer/MuJoCoViewer';
|
||||
import type { ControllerCommand, ControllerStatus } from '../../controller/types';
|
||||
import type { RLCommand, RLPolicyStatus } from '../../rl/types';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
CollapsibleSection,
|
||||
CopyButton,
|
||||
PropertyRow,
|
||||
ResizablePanel,
|
||||
Select,
|
||||
Tabs,
|
||||
} from '../../components/ui';
|
||||
import { TreeSearchField } from './TreeSearchField';
|
||||
import { ProjectBreadcrumb } from './ProjectBreadcrumb';
|
||||
import { PythonControllerPanel } from './PythonControllerPanel';
|
||||
import { RLPolicyPanel } from './RLPolicyPanel';
|
||||
import { LocalTrainingPanel } from './LocalTrainingPanel';
|
||||
import { DataRecordingPanel } from './DataRecordingPanel';
|
||||
import { PhysicalMapPanel } from './PhysicalMapPanel';
|
||||
import { MapAssetLibrary } from './MapAssetLibrary';
|
||||
import {
|
||||
DEFAULT_PHYSICAL_MAP_CONFIG,
|
||||
type MapSelection,
|
||||
type SystemTerrainPreset,
|
||||
} from '../../map/types';
|
||||
import type {
|
||||
EditableMapDocument,
|
||||
EditableMapObjectType,
|
||||
MapEditorInteractionCallbacks,
|
||||
MapEditorTransformMode,
|
||||
MapObjectPlacementMode,
|
||||
} from '../../map/editor/types';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Settings2 } from 'lucide-react';
|
||||
import { ResizablePanel } from '../../components/ui';
|
||||
|
||||
export function SidebarPanel({
|
||||
title,
|
||||
@@ -86,828 +29,3 @@ export function SidebarPanel({
|
||||
</ResizablePanel>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectSidebar({
|
||||
projectName,
|
||||
files,
|
||||
entries,
|
||||
selectedEntry,
|
||||
snapshot,
|
||||
loading,
|
||||
visible = true,
|
||||
nativeUrdf,
|
||||
mapSelection,
|
||||
editorDocument,
|
||||
activeTab,
|
||||
onActiveTabChange,
|
||||
onRemove,
|
||||
onSelectEntry,
|
||||
onJointHover,
|
||||
onAddMapAsset,
|
||||
onSelectTerrain,
|
||||
onSelectMapObject,
|
||||
}: {
|
||||
projectName?: string;
|
||||
files: ProjectTreeFile[];
|
||||
entries: ModelEntry[];
|
||||
selectedEntry?: string;
|
||||
snapshot?: SimulationSnapshot;
|
||||
loading: boolean;
|
||||
visible?: boolean;
|
||||
nativeUrdf: boolean;
|
||||
mapSelection: MapSelection;
|
||||
editorDocument: EditableMapDocument | null;
|
||||
activeTab?: 'project' | 'structure' | 'assets';
|
||||
onActiveTabChange?: (value: 'project' | 'structure' | 'assets') => void;
|
||||
onRemove: () => void;
|
||||
onSelectEntry: (path: string) => void;
|
||||
onJointHover: (jointId: number | null) => void;
|
||||
onAddMapAsset: (
|
||||
type: EditableMapObjectType,
|
||||
placementMode: MapObjectPlacementMode,
|
||||
) => void | Promise<void>;
|
||||
onSelectTerrain: (preset: SystemTerrainPreset) => void;
|
||||
onSelectMapObject: (id: string) => void;
|
||||
}) {
|
||||
const [internalTab, setInternalTab] = useState<'project' | 'structure' | 'assets'>('project'),
|
||||
[fileQuery, setFileQuery] = useState(''),
|
||||
[structureQuery, setStructureQuery] = useState('');
|
||||
const tab = activeTab ?? internalTab,
|
||||
fileMatches = countProjectSearchResults(files, fileQuery),
|
||||
structureMatches = snapshot
|
||||
? countModelStructureSearchResults(snapshot.bodies, snapshot.joints, structureQuery)
|
||||
: 0;
|
||||
return (
|
||||
<SidebarPanel title="资产与结构" side="left" visible={visible}>
|
||||
{projectName ? (
|
||||
<>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-accent" title={projectName}>
|
||||
{projectName}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] text-text-tertiary">{files.length} 个文件</div>
|
||||
</div>
|
||||
<Button variant="danger" onClick={onRemove} disabled={loading}>
|
||||
移除
|
||||
</Button>
|
||||
</div>
|
||||
<ProjectBreadcrumb
|
||||
projectName={projectName}
|
||||
entries={entries}
|
||||
selectedEntry={selectedEntry}
|
||||
loading={loading}
|
||||
onSelect={onSelectEntry}
|
||||
/>
|
||||
<Tabs
|
||||
label="工程侧栏"
|
||||
value={tab}
|
||||
onValueChange={(value) => {
|
||||
setInternalTab(value);
|
||||
onActiveTabChange?.(value);
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
value: 'project',
|
||||
label: '工程',
|
||||
icon: <FolderTree className="h-3.5 w-3.5" />,
|
||||
content: (
|
||||
<>
|
||||
<TreeSearchField
|
||||
value={fileQuery}
|
||||
onChange={setFileQuery}
|
||||
resultCount={fileMatches}
|
||||
label="搜索工程文件"
|
||||
placeholder="搜索文件或目录…"
|
||||
/>
|
||||
<div className="px-2 pb-3">
|
||||
<ProjectTree
|
||||
key={projectName}
|
||||
files={files}
|
||||
entries={entries}
|
||||
selectedEntry={selectedEntry}
|
||||
query={fileQuery}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'structure',
|
||||
label: '模型结构',
|
||||
icon: <Box className="h-3.5 w-3.5" />,
|
||||
disabled: !snapshot,
|
||||
content: snapshot ? (
|
||||
<>
|
||||
<TreeSearchField
|
||||
value={structureQuery}
|
||||
onChange={setStructureQuery}
|
||||
resultCount={structureMatches}
|
||||
label="搜索模型结构"
|
||||
placeholder="搜索 Body 或关节…"
|
||||
/>
|
||||
<div className="px-2 pb-3">
|
||||
<ModelStructureTree
|
||||
bodies={snapshot.bodies}
|
||||
joints={snapshot.joints}
|
||||
onJointHover={onJointHover}
|
||||
query={structureQuery}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="p-4 text-center text-xs text-text-tertiary">加载模型后显示结构</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'assets',
|
||||
label: '资产',
|
||||
icon: <MapIcon className="h-3.5 w-3.5" />,
|
||||
disabled: !snapshot,
|
||||
content: (
|
||||
<MapAssetLibrary
|
||||
disabled={loading || nativeUrdf}
|
||||
terrainSize={
|
||||
mapSelection.kind === 'builtin'
|
||||
? mapSelection.config.size
|
||||
: DEFAULT_PHYSICAL_MAP_CONFIG.size
|
||||
}
|
||||
document={editorDocument}
|
||||
onAdd={onAddMapAsset}
|
||||
onSelectTerrain={onSelectTerrain}
|
||||
onSelectObject={onSelectMapObject}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="p-4 text-center text-sm text-text-tertiary">导入模型后显示工程资源</div>
|
||||
)}
|
||||
</SidebarPanel>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModelControlsProps {
|
||||
snapshot?: SimulationSnapshot;
|
||||
selection: ViewerSelection | null;
|
||||
selectedFormat?: ModelEntry['format'];
|
||||
loading: boolean;
|
||||
visible?: boolean;
|
||||
urdfMode: UrdfLoadMode;
|
||||
baseMode: UrdfBaseMode;
|
||||
showCollision: boolean;
|
||||
ignoreJointLimits: boolean;
|
||||
jointAdvanced: boolean;
|
||||
angleUnit: 'rad' | 'deg';
|
||||
forceScale: number;
|
||||
controllerPaths: string[];
|
||||
selectedControllerPath?: string;
|
||||
controllerStatus?: ControllerStatus;
|
||||
policyPaths: string[];
|
||||
selectedPolicyPath?: string;
|
||||
policyStatus?: RLPolicyStatus;
|
||||
mapSelection: MapSelection;
|
||||
maps: MapEntry[];
|
||||
showVisualMap: boolean;
|
||||
showMapCollision: boolean;
|
||||
editorDocument: EditableMapDocument | null;
|
||||
onUrdfMode: (value: UrdfLoadMode) => void;
|
||||
onBaseMode: (value: UrdfBaseMode) => void;
|
||||
onShowCollision: (value: boolean) => void;
|
||||
onResetJoints: () => void;
|
||||
onToggleJointLimits: () => void;
|
||||
onToggleAdvanced: () => void;
|
||||
onToggleAngleUnit: () => void;
|
||||
onActuator: (id: number, value: number) => void;
|
||||
onActuatorParameters: (id: number, parameters: ActuatorParameters) => void;
|
||||
onJoint: (id: number, value: number) => void;
|
||||
onForceScale: (value: number) => void;
|
||||
onSelectControllerPath: (path: string) => void;
|
||||
onLoadControllerPath: (path: string) => void;
|
||||
onImportController: (file: File) => void;
|
||||
onToggleController: (enabled: boolean) => void;
|
||||
onControllerCommand: (command: ControllerCommand) => void;
|
||||
onRemoveController: () => void;
|
||||
onSelectPolicyPath: (path: string) => void;
|
||||
onLoadPolicyPath: (path: string) => void;
|
||||
onImportPolicy: (file: File) => void;
|
||||
onTogglePolicy: (enabled: boolean) => void;
|
||||
onPolicyCommand: (command: RLCommand) => void;
|
||||
onRemovePolicy: () => void;
|
||||
onApplyMap: (value: MapSelection) => void;
|
||||
onEditorPreview: (document: EditableMapDocument | null) => void;
|
||||
onEditorApply: (document: EditableMapDocument) => Promise<boolean>;
|
||||
onEditorExport: () => void;
|
||||
onEditorConvert: () => Promise<boolean>;
|
||||
onEditorBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
|
||||
onEditorSelect: (id: string | null) => void;
|
||||
onEditorTransformMode: (mode: MapEditorTransformMode) => void;
|
||||
onEditorSnapping: (translation: number | null, rotationDegrees: number | null) => void;
|
||||
onMapDisplay: (visual: boolean, collision: boolean) => void;
|
||||
onMapTabOpen?: () => void;
|
||||
onDataRecorderConfigure: (patch: Partial<DataRecorderConfig>) => void;
|
||||
onDataRecordingStart: () => void;
|
||||
onDataRecordingStop: () => void;
|
||||
onDataRecordingClear: () => void;
|
||||
onDataRecordingExport: (format: 'csv' | 'json') => void;
|
||||
}
|
||||
export function ModelControlsSidebar(props: ModelControlsProps) {
|
||||
const [tab, setTab] = useState<'properties' | 'controls' | 'data' | 'map'>('properties'),
|
||||
s = props.snapshot;
|
||||
if (!s)
|
||||
return (
|
||||
<SidebarPanel title="属性与参数" side="right" visible={props.visible}>
|
||||
<div className="p-4 text-sm text-text-tertiary">导入模型后显示属性</div>
|
||||
</SidebarPanel>
|
||||
);
|
||||
const properties = (
|
||||
<>
|
||||
<CollapsibleSection title="模型信息" defaultOpen badge={<Badge>{s.model.nbody} Body</Badge>}>
|
||||
<div>
|
||||
<PropertyRow label="Body" value={s.model.nbody} />
|
||||
<PropertyRow label="Joint" value={s.model.njnt} />
|
||||
<PropertyRow label="Geom" value={s.model.ngeom} />
|
||||
<PropertyRow label="Actuator" value={s.model.nactuator} />
|
||||
<PropertyRow label="qpos / qvel" value={`${s.model.nq} / ${s.model.nv}`} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
{props.selectedFormat === 'urdf' && (
|
||||
<CollapsibleSection title="URDF 处理方式" defaultOpen={false}>
|
||||
<Select
|
||||
aria-label="URDF 处理方式"
|
||||
className="w-full"
|
||||
value={props.urdfMode}
|
||||
disabled={props.loading}
|
||||
onChange={(event) => props.onUrdfMode(event.target.value as UrdfLoadMode)}
|
||||
>
|
||||
<option value="mjcf">转换为 MJCF(推荐)</option>
|
||||
<option value="native">MuJoCo 原生 URDF</option>
|
||||
</Select>
|
||||
<label className="mt-3 block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">基座类型</span>
|
||||
<Select
|
||||
aria-label="URDF 基座类型"
|
||||
className="w-full"
|
||||
value={props.baseMode}
|
||||
disabled={props.loading || props.urdfMode === 'native'}
|
||||
onChange={(event) => props.onBaseMode(event.target.value as UrdfBaseMode)}
|
||||
>
|
||||
<option value="floating">浮动基座(Free Joint)</option>
|
||||
<option value="fixed">固定基座(连接世界)</option>
|
||||
</Select>
|
||||
</label>
|
||||
<p className="mt-2 text-xs text-text-tertiary">
|
||||
MJCF 模式保留 visual mesh、添加物理地面,并将模型最低点对齐到 z=0。
|
||||
</p>
|
||||
<Check
|
||||
label="显示碰撞几何"
|
||||
checked={props.showCollision}
|
||||
onChange={props.onShowCollision}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
<CollapsibleSection title="当前选择" defaultOpen>
|
||||
{props.selection ? (
|
||||
<div className="text-xs">
|
||||
<PropertyRow
|
||||
label="Body"
|
||||
value={props.selection.bodyName}
|
||||
action={<CopyButton value={props.selection.bodyName} label="复制 Body 名称" />}
|
||||
/>
|
||||
<PropertyRow
|
||||
label="标识"
|
||||
value={`${props.selection.bodyId} / ${props.selection.geomId} / ${props.selection.geomType}`}
|
||||
action={
|
||||
<CopyButton
|
||||
value={`body ${props.selection.bodyId}, geom ${props.selection.geomId}, type ${props.selection.geomType}`}
|
||||
label="复制标识"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<PropertyRow
|
||||
label="位置"
|
||||
value={props.selection.position.map((value) => value.toFixed(3)).join(', ')}
|
||||
action={<CopyButton value={props.selection.position.join(', ')} label="复制位置" />}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="flex items-center gap-2 text-xs text-text-tertiary">
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
在视口中单击物体
|
||||
</p>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
const controls = (
|
||||
<>
|
||||
<CollapsibleSection
|
||||
title="ONNX 强化学习策略"
|
||||
defaultOpen
|
||||
badge={s.rlPolicy ? <Badge>{s.rlPolicy.enabled ? '推理' : '停止'}</Badge> : undefined}
|
||||
>
|
||||
<RLPolicyPanel
|
||||
paths={props.policyPaths}
|
||||
selectedPath={props.selectedPolicyPath}
|
||||
status={props.policyStatus ?? s.rlPolicy}
|
||||
loading={props.loading}
|
||||
onSelectPath={props.onSelectPolicyPath}
|
||||
onLoadPath={props.onLoadPolicyPath}
|
||||
onImport={props.onImportPolicy}
|
||||
onToggle={props.onTogglePolicy}
|
||||
onCommand={props.onPolicyCommand}
|
||||
onRemove={props.onRemovePolicy}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
<CollapsibleSection title="本地强化学习训练" defaultOpen={false}>
|
||||
<LocalTrainingPanel onPolicyReady={props.onImportPolicy} />
|
||||
</CollapsibleSection>
|
||||
<CollapsibleSection
|
||||
title="Python 控制器"
|
||||
defaultOpen
|
||||
badge={s.controller ? <Badge>{s.controller.enabled ? '运行' : '停止'}</Badge> : undefined}
|
||||
>
|
||||
<PythonControllerPanel
|
||||
paths={props.controllerPaths}
|
||||
selectedPath={props.selectedControllerPath}
|
||||
status={props.controllerStatus ?? s.controller}
|
||||
loading={props.loading}
|
||||
onSelectPath={props.onSelectControllerPath}
|
||||
onLoadPath={props.onLoadControllerPath}
|
||||
onImport={props.onImportController}
|
||||
onToggle={props.onToggleController}
|
||||
onCommand={props.onControllerCommand}
|
||||
onRemove={props.onRemoveController}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
<CollapsibleSection
|
||||
title="Actuator"
|
||||
defaultOpen={false}
|
||||
badge={<Badge>{s.actuators.length}</Badge>}
|
||||
>
|
||||
{s.actuators.length ? (
|
||||
s.actuators.map((actuator) => (
|
||||
<ActuatorControl
|
||||
key={actuator.id}
|
||||
actuator={actuator}
|
||||
onControl={(value) => props.onActuator(actuator.id, value)}
|
||||
onParameters={(parameters) => props.onActuatorParameters(actuator.id, parameters)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="text-xs text-text-tertiary">模型没有驱动器</p>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
<CollapsibleSection title="关节" defaultOpen badge={<Badge>{s.joints.length}</Badge>}>
|
||||
<div className="mb-4 grid grid-cols-2 gap-2">
|
||||
<Button onClick={props.onResetJoints}>重置关节</Button>
|
||||
<Button
|
||||
variant={props.ignoreJointLimits ? 'primary' : 'secondary'}
|
||||
aria-pressed={props.ignoreJointLimits}
|
||||
onClick={props.onToggleJointLimits}
|
||||
>
|
||||
忽略关节限位
|
||||
</Button>
|
||||
<Button
|
||||
variant={props.jointAdvanced ? 'primary' : 'secondary'}
|
||||
aria-pressed={props.jointAdvanced}
|
||||
onClick={props.onToggleAdvanced}
|
||||
>
|
||||
高级
|
||||
</Button>
|
||||
<Button
|
||||
variant={props.angleUnit === 'deg' ? 'primary' : 'secondary'}
|
||||
aria-pressed={props.angleUnit === 'deg'}
|
||||
onClick={props.onToggleAngleUnit}
|
||||
>
|
||||
{props.angleUnit === 'rad' ? 'rad 弧度制' : '° 角度制'}
|
||||
</Button>
|
||||
</div>
|
||||
{s.joints.map((joint) => {
|
||||
const scale = joint.type === 3 && props.angleUnit === 'deg' ? 180 / Math.PI : 1,
|
||||
unit =
|
||||
joint.type === 3
|
||||
? props.angleUnit === 'deg'
|
||||
? '°'
|
||||
: ' rad'
|
||||
: joint.type === 2
|
||||
? ' m'
|
||||
: '';
|
||||
return (
|
||||
<ControlSlider
|
||||
key={joint.id}
|
||||
label={`${joint.name}${joint.editable ? '' : '(只读)'}`}
|
||||
value={joint.value * scale}
|
||||
min={joint.min * scale}
|
||||
max={joint.max * scale}
|
||||
unit={unit}
|
||||
advanced={props.jointAdvanced}
|
||||
limited={joint.limited}
|
||||
limitsIgnored={joint.limitsIgnored}
|
||||
limitMin={joint.limitMin * scale}
|
||||
limitMax={joint.limitMax * scale}
|
||||
disabled={!joint.editable}
|
||||
onChange={(value) => props.onJoint(joint.id, value / scale)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</CollapsibleSection>
|
||||
<CollapsibleSection title="外力强度" defaultOpen={false}>
|
||||
<ControlSlider
|
||||
label={`${props.forceScale.toFixed(0)} N/屏幕单位`}
|
||||
value={props.forceScale}
|
||||
min={5}
|
||||
max={200}
|
||||
onChange={props.onForceScale}
|
||||
/>
|
||||
<p className="text-xs text-text-tertiary">
|
||||
选择“外力施加”,在动态物体上按住拖动,松开即清零。
|
||||
</p>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<SidebarPanel title="属性与参数" side="right" visible={props.visible}>
|
||||
<Tabs
|
||||
label="模型控制侧栏"
|
||||
value={tab}
|
||||
onValueChange={(value) => {
|
||||
setTab(value);
|
||||
if (value === 'map') props.onMapTabOpen?.();
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
value: 'properties',
|
||||
label: '属性',
|
||||
icon: <Info className="h-3.5 w-3.5" />,
|
||||
content: properties,
|
||||
},
|
||||
{
|
||||
value: 'controls',
|
||||
label: '控制',
|
||||
icon: <SlidersHorizontal className="h-3.5 w-3.5" />,
|
||||
content: controls,
|
||||
},
|
||||
{
|
||||
value: 'data',
|
||||
label: '数据',
|
||||
icon: <Database className="h-3.5 w-3.5" />,
|
||||
content: (
|
||||
<DataRecordingPanel
|
||||
status={s.telemetry}
|
||||
bodies={s.bodies}
|
||||
onConfigure={props.onDataRecorderConfigure}
|
||||
onStart={props.onDataRecordingStart}
|
||||
onStop={props.onDataRecordingStop}
|
||||
onClear={props.onDataRecordingClear}
|
||||
onExport={props.onDataRecordingExport}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'map',
|
||||
label: '地图',
|
||||
icon: <MapIcon className="h-3.5 w-3.5" />,
|
||||
content: (
|
||||
<PhysicalMapPanel
|
||||
key={JSON.stringify(props.mapSelection)}
|
||||
value={props.mapSelection}
|
||||
maps={props.maps}
|
||||
rootBodies={s.bodies
|
||||
.filter(
|
||||
(body) =>
|
||||
body.id !== 0 &&
|
||||
body.parentId === 0 &&
|
||||
!body.name.startsWith('__platform_map_'),
|
||||
)
|
||||
.map((body) => body.name)}
|
||||
loading={props.loading}
|
||||
nativeUrdf={props.selectedFormat === 'urdf' && props.urdfMode === 'native'}
|
||||
showVisualMap={props.showVisualMap}
|
||||
showMapCollision={props.showMapCollision}
|
||||
editorDocument={props.editorDocument}
|
||||
onEditorPreview={props.onEditorPreview}
|
||||
onEditorApply={props.onEditorApply}
|
||||
onEditorExport={props.onEditorExport}
|
||||
onEditorConvert={props.onEditorConvert}
|
||||
onEditorBindInteraction={props.onEditorBindInteraction}
|
||||
onEditorSelect={props.onEditorSelect}
|
||||
onEditorTransformMode={props.onEditorTransformMode}
|
||||
onEditorSnapping={props.onEditorSnapping}
|
||||
onMapDisplay={props.onMapDisplay}
|
||||
onApply={props.onApplyMap}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SidebarPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActuatorControl({
|
||||
actuator,
|
||||
onControl,
|
||||
onParameters,
|
||||
}: {
|
||||
actuator: ActuatorInfo;
|
||||
onControl: (value: number) => void;
|
||||
onParameters: (parameters: ActuatorParameters) => void;
|
||||
}) {
|
||||
const isMotor = actuator.kind === 'motor',
|
||||
isPosition = actuator.kind === 'position',
|
||||
editable = isMotor || isPosition,
|
||||
baseTargetScale = isPosition && actuator.jointType === 3 ? 180 / Math.PI : 1,
|
||||
targetScale =
|
||||
isPosition && Math.abs(actuator.gear) > 1e-9
|
||||
? baseTargetScale / actuator.gear
|
||||
: baseTargetScale;
|
||||
const clampForce = (value: number) =>
|
||||
actuator.forceLimited
|
||||
? Math.min(actuator.forceMax, Math.max(actuator.forceMin, value))
|
||||
: value,
|
||||
physicalScale = actuator.gear * actuator.gain;
|
||||
const forceA = clampForce(actuator.min * actuator.gain) * actuator.gear,
|
||||
forceB = clampForce(actuator.max * actuator.gain) * actuator.gear;
|
||||
const targetA = actuator.min * targetScale,
|
||||
targetB = actuator.max * targetScale,
|
||||
outputMin = isMotor ? Math.min(forceA, forceB) : Math.min(targetA, targetB),
|
||||
outputMax = isMotor ? Math.max(forceA, forceB) : Math.max(targetA, targetB);
|
||||
const outputValue = isMotor
|
||||
? clampForce(actuator.value * actuator.gain) * actuator.gear
|
||||
: actuator.value * targetScale,
|
||||
outputDisabled =
|
||||
(isMotor && Math.abs(physicalScale) <= 1e-9) ||
|
||||
(isPosition && Math.abs(actuator.gear) <= 1e-9);
|
||||
const outputLabel = isMotor
|
||||
? actuator.jointType === 3
|
||||
? '输出力矩'
|
||||
: '输出力'
|
||||
: isPosition
|
||||
? actuator.jointType === 3
|
||||
? '目标角度'
|
||||
: '目标位置'
|
||||
: '控制输入';
|
||||
const forceUnit = actuator.jointType === 3 ? 'N·m' : actuator.jointType === 2 ? 'N' : '',
|
||||
jointForceA = actuator.forceMin * actuator.gear,
|
||||
jointForceB = actuator.forceMax * actuator.gear,
|
||||
jointForceMin = Math.min(jointForceA, jointForceB),
|
||||
jointForceMax = Math.max(jointForceA, jointForceB);
|
||||
const update = (patch: Partial<ActuatorParameters>) => onParameters({ ...actuator, ...patch }),
|
||||
controlLabel = isPosition ? (actuator.jointType === 3 ? '角度' : '位置') : '控制',
|
||||
gearSquared = actuator.gear * actuator.gear;
|
||||
return (
|
||||
<div className="mb-3 rounded-lg border border-border bg-surface p-2.5">
|
||||
<div className="mb-2 flex min-w-0 items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs font-medium text-text-primary" title={actuator.name}>
|
||||
{actuator.name}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[10px] text-text-tertiary">
|
||||
{actuator.jointName ? `关节:${actuator.jointName}` : '未关联标量关节'}
|
||||
</div>
|
||||
</div>
|
||||
<Badge>{actuator.unit || 'u'}</Badge>
|
||||
</div>
|
||||
{actuator.controlCount === 1 ? (
|
||||
<ControlSlider
|
||||
label={outputLabel}
|
||||
value={outputValue}
|
||||
min={outputMin}
|
||||
max={outputMax}
|
||||
disabled={outputDisabled}
|
||||
unit={actuator.unit ? ` ${actuator.unit}` : ''}
|
||||
onChange={(value) => {
|
||||
if (outputDisabled) return;
|
||||
onControl(isMotor ? value / physicalScale : value / targetScale);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<p className="mb-2 text-[10px] leading-4 text-text-tertiary">
|
||||
该驱动器包含 {actuator.controlCount} 个控制分量,请在 MJCF 源码或专用控制器中设置。
|
||||
</p>
|
||||
)}
|
||||
{actuator.controlCount === 1 && !actuator.ctrlLimited && (
|
||||
<div className="mb-2">
|
||||
<ParameterInput
|
||||
label={`${controlLabel}输入(不限幅)`}
|
||||
value={actuator.value * targetScale}
|
||||
onCommit={(value) => onControl(value / targetScale)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{editable ? (
|
||||
<details className="group border-t border-border pt-2">
|
||||
<summary className="cursor-pointer select-none text-xs font-medium text-text-secondary hover:text-text-primary">
|
||||
常用参数
|
||||
</summary>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
{isPosition ? (
|
||||
<>
|
||||
<ParameterInput
|
||||
label={`位置增益 kp(${forceUnit}/${actuator.jointType === 3 ? 'rad' : 'm'})`}
|
||||
value={actuator.kp * gearSquared}
|
||||
disabled={gearSquared <= 1e-18}
|
||||
onCommit={(kp) => update({ kp: kp / gearSquared })}
|
||||
/>
|
||||
<ParameterInput
|
||||
label={`速度增益 kv(${forceUnit}·s/${actuator.jointType === 3 ? 'rad' : 'm'})`}
|
||||
value={actuator.kv * gearSquared}
|
||||
disabled={gearSquared <= 1e-18}
|
||||
onCommit={(kv) => update({ kv: kv / gearSquared })}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ParameterInput
|
||||
label={`kp(MJCF stiffness,${forceUnit}/${actuator.jointType === 3 ? 'rad' : 'm'})`}
|
||||
value={actuator.kp}
|
||||
onCommit={(kp) => update({ kp })}
|
||||
/>
|
||||
<ParameterInput
|
||||
label={`kv(MJCF damping,${forceUnit}·s/${actuator.jointType === 3 ? 'rad' : 'm'})`}
|
||||
value={actuator.kv}
|
||||
onCommit={(kv) => update({ kv })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ParameterToggle
|
||||
label={`限制输出${actuator.jointType === 3 ? '力矩' : '力'}${forceUnit ? `(${forceUnit})` : ''}`}
|
||||
checked={actuator.forceLimited}
|
||||
onChange={(forceLimited) => update({ forceLimited })}
|
||||
/>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
<ParameterInput
|
||||
label="输出下限"
|
||||
value={jointForceMin}
|
||||
disabled={!actuator.forceLimited || Math.abs(actuator.gear) <= 1e-9}
|
||||
onCommit={(value) =>
|
||||
update(
|
||||
actuator.gear >= 0
|
||||
? { forceMin: value / actuator.gear }
|
||||
: { forceMax: value / actuator.gear },
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ParameterInput
|
||||
label="输出上限"
|
||||
value={jointForceMax}
|
||||
disabled={!actuator.forceLimited || Math.abs(actuator.gear) <= 1e-9}
|
||||
onCommit={(value) =>
|
||||
update(
|
||||
actuator.gear >= 0
|
||||
? { forceMax: value / actuator.gear }
|
||||
: { forceMin: value / actuator.gear },
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] leading-4 text-text-tertiary">
|
||||
{isPosition
|
||||
? 'position 伺服使用 kp 跟踪目标位置,kv 提供速度阻尼。'
|
||||
: 'motor 保持力/力矩控制且控制输入不限幅。MJCF 的 motor 没有 kp/kv 属性;这里的 kp、kv 会分别保存为对应 joint 的 stiffness、damping。'}{' '}
|
||||
参数修改会立即作用于当前模型,并可随 MJCF 导出。
|
||||
</p>
|
||||
</details>
|
||||
) : (
|
||||
<p className="border-t border-border pt-2 text-[10px] leading-4 text-text-tertiary">
|
||||
该驱动器不是可直接编辑的 motor/position 类型,控制值按模型原始单位显示;请在 MJCF
|
||||
源码中修改专用参数。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function ParameterInput({
|
||||
label,
|
||||
value,
|
||||
onCommit,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
onCommit: (value: number) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="block text-[10px] text-text-tertiary">
|
||||
<span className="mb-1 block truncate">{label}</span>
|
||||
<input
|
||||
key={value}
|
||||
type="number"
|
||||
step="any"
|
||||
defaultValue={Number.isFinite(value) ? value : 0}
|
||||
disabled={disabled}
|
||||
className="field h-7 w-full px-2 text-xs text-text-primary disabled:opacity-40"
|
||||
onBlur={(event) => {
|
||||
const next = Number(event.currentTarget.value);
|
||||
if (Number.isFinite(next) && next !== value) onCommit(next);
|
||||
else event.currentTarget.value = String(value);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') event.currentTarget.blur();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
function ParameterToggle({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="mt-2 flex items-center gap-2 text-[11px] text-text-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-accent"
|
||||
checked={checked}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
function Check({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="mt-3 flex items-center gap-2 text-xs text-text-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded accent-accent focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-panel"
|
||||
checked={checked}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
function ControlSlider({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
onChange,
|
||||
disabled = false,
|
||||
unit = '',
|
||||
advanced = false,
|
||||
limited = false,
|
||||
limitsIgnored = false,
|
||||
limitMin = 0,
|
||||
limitMax = 0,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
onChange: (value: number) => void;
|
||||
disabled?: boolean;
|
||||
unit?: string;
|
||||
advanced?: boolean;
|
||||
limited?: boolean;
|
||||
limitsIgnored?: boolean;
|
||||
limitMin?: number;
|
||||
limitMax?: number;
|
||||
}) {
|
||||
const sane = Number.isFinite(value) ? value : 0,
|
||||
format = (number: number) => `${number.toFixed(3)}${unit}`;
|
||||
return (
|
||||
<label className="mb-3 block text-xs">
|
||||
<span className="mb-1 flex justify-between gap-2">
|
||||
<span className="truncate text-text-secondary">{label}</span>
|
||||
<output className="technical-value text-text-primary">{format(sane)}</output>
|
||||
</span>
|
||||
<input
|
||||
className="control-slider rounded focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-panel"
|
||||
type="range"
|
||||
disabled={disabled}
|
||||
value={Math.min(max, Math.max(min, sane))}
|
||||
min={min}
|
||||
max={max}
|
||||
step={(max - min) / 500 || 0.001}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
/>
|
||||
{advanced && (
|
||||
<span className="mt-1 flex justify-between text-[10px] text-text-tertiary">
|
||||
<span>下限 {limited ? format(limitMin) : '无限制'}</span>
|
||||
{limitsIgnored && limited && <span className="text-warning">已忽略</span>}
|
||||
<span>上限 {limited ? format(limitMax) : '无限制'}</span>
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user