feat(web-platform): release V0.8.3 常规界面优化
web-platform-release / Build and publish release (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (pull_request) Has been cancelled
web-platform-ci / Playwright E2E (pull_request) Has been cancelled

This commit is contained in:
2026-09-04 15:34:24 +08:00
parent 63d67a645b
commit fa5485049a
60 changed files with 4494 additions and 2073 deletions
+98 -63
View File
@@ -145,6 +145,7 @@ test('显示中文平台骨架并加载单文件模型', async ({ page }) => {
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 });
await page.getByRole('button', { name: '显示设置' }).click();
const displayDialog = page.getByRole('dialog', { name: '视图显示设置' });
await expect(displayDialog).toBeVisible();
@@ -162,16 +163,22 @@ test('显示中文平台骨架并加载单文件模型', async ({ page }) => {
await page.getByRole('button', { name: /FPS .*物理/ }).click();
await expect(page.getByRole('dialog', { name: '性能详情' })).toBeVisible();
await page.keyboard.press('Escape');
await page.getByRole('tab', { name: '控制' }).click();
await page.getByRole('button', { name: 'Actuator' }).click();
await expect(page.getByText('motor', { exact: true })).toBeVisible();
await expect(
page.getByRole('tabpanel', { name: '控制' }).getByText('slide', { exact: true }),
).toBeVisible();
await page.getByRole('tab', { name: '模型结构' }).click();
await page.getByRole('tab', { name: '控制' }).click();
const tools = page.getByRole('tabpanel', { name: '控制台' });
await expect(page.getByRole('dialog', { name: '工作区工具' })).toHaveCount(0);
await expect(page.locator('main canvas')).toBeVisible();
await tools.getByRole('button', { name: /执行器实时控制/ }).click();
await expect(tools.getByText('motor', { exact: true })).toBeVisible();
await expect(tools.getByText('关节:slide', { exact: true })).toBeVisible();
await page.getByRole('tab', { name: '数据录制' }).click();
await expect(page.getByRole('tabpanel', { name: '数据录制' })).toContainText('仿真遥测记录');
await expect(page.locator('main canvas')).toBeVisible();
await page.getByRole('tab', { name: '检查器' }).click();
const structure = page.getByRole('navigation', { name: '模型结构树' });
await expect(structure).toBeVisible();
await structure.getByRole('treeitem', { name: /hinge/ }).hover();
const hinge = structure.getByRole('treeitem', { name: /hinge/ });
await hinge.hover();
await hinge.click();
await expect(page.getByRole('alert')).toHaveCount(0);
await expect(page.getByRole('button', { name: '重置关节' })).toBeVisible();
await page.getByRole('button', { name: '高级' }).click();
@@ -195,10 +202,10 @@ test('窄视口默认保留完整视口并可按需打开侧栏', async ({ page
await page.goto('/');
await expect(page.getByRole('main')).toBeInViewport();
await expect(page.getByRole('button', { name: '显示工程面板' })).toBeVisible();
await expect(page.getByRole('button', { name: '显示属性面板' })).toBeVisible();
await page.getByRole('button', { name: '显示属性面板' }).click();
await expect(page.getByRole('button', { name: '显示右侧面板' })).toBeVisible();
await page.getByRole('button', { name: '显示右侧面板' }).click();
await expect(
page.getByRole('complementary').filter({ hasText: '导入模型后显示属性' }),
page.getByRole('complementary').filter({ hasText: '导入模型后显示检查器' }),
).toBeVisible();
});
@@ -296,7 +303,6 @@ test('加载引用 OBJ 的 URDF 工程', async ({ page }) => {
await options.getByRole('button', { name: '转换并加载' }).click();
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await expect(page.getByText('2 个文件')).toBeVisible();
await page.getByRole('button', { name: 'URDF 处理方式' }).click();
await expect(page.getByLabel('URDF 处理方式')).toHaveValue('mjcf');
await expect(page.getByLabel('URDF 基座类型')).toHaveValue('floating');
await page.getByRole('button', { name: '通知中心' }).click();
@@ -344,8 +350,11 @@ test('URDF 自动生成的关节驱动器与摄像头可通过 MuJoCo 编译', a
await expect(notifications).toContainText('已为 1 个 hinge/slide 关节生成 motor 驱动器');
await expect(notifications).toContainText('已将 640×480 摄像头固连到 arm');
await page.keyboard.press('Escape');
await page.getByRole('tab', { name: '控制' }).click();
await page.getByRole('button', { name: 'Actuator' }).click();
await page.getByRole('tab', { name: '控制' }).click();
await page
.getByRole('tabpanel', { name: '控制台' })
.getByRole('button', { name: /执行器实时控制/ })
.click();
await expect(page.getByText('shoulder_motor')).toBeVisible();
await expect(page.getByText('关节:shoulder')).toBeVisible();
await expect(page.getByText('N·m', { exact: true })).toBeVisible();
@@ -413,8 +422,10 @@ test('slide 关节向屏幕轴正方向拖动时 qpos 同向增加', async ({ pa
await page.mouse.down();
await page.mouse.move(x + 70, y, { steps: 8 });
await page.mouse.up();
await page.getByRole('tab', { name: '控制' }).click();
const jointSection = page.getByRole('button', { name: '关节 1' });
await page.getByRole('tab', { name: '控制' }).click();
const jointSection = page
.getByRole('tabpanel', { name: '控制台' })
.getByRole('button', { name: /关节姿态调试/ });
if ((await jointSection.getAttribute('aria-expanded')) === 'false') await jointSection.click();
const output = page.getByText('screen_x').locator('..').locator('output');
await expect
@@ -429,7 +440,11 @@ test('可导入并启用 Python 控制器', async ({ page }) => {
.first()
.setInputFiles({ name: 'model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL) });
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '控制' }).click();
await page.getByRole('tab', { name: '控制' }).click();
await page
.getByRole('tabpanel', { name: '控制台' })
.getByRole('button', { name: /Python 脚本控制/ })
.click();
const python = `NAME = "测试 PD 控制器"\nCONTROL_HZ = 100\ndef init(api):\n return {"joint": api.joint("slide"), "actuator": api.actuator("motor"), "body": api.body("box")}\ndef step(ctx, state):\n assert len(ctx.body_quat(state["body"])) == 4\n assert len(ctx.body_position(state["body"])) == 3\n ctx.set_control(state["actuator"], -ctx.qpos(state["joint"]) - 0.1 * ctx.qvel(state["joint"]))\n`;
await page
.locator('input[accept=".py,text/x-python"]')
@@ -437,7 +452,9 @@ test('可导入并启用 Python 控制器', async ({ page }) => {
await expect(page.getByText('测试 PD 控制器', { exact: true })).toBeVisible({ timeout: 30_000 });
await expect(page.getByText('Python / Pyodide')).toBeVisible();
await page.getByRole('button', { name: '启用', exact: true }).click();
await expect(page.getByText('运行中')).toBeVisible();
await expect(
page.getByRole('tabpanel', { name: '控制台' }).getByRole('button', { name: /Python 脚本控制/ }),
).toContainText('运行');
});
test('中等规模模型持续步进并可重复加载', async ({ page }) => {
@@ -479,16 +496,15 @@ test('认证资产可点击创建场景并拖到画布落位', async ({ page })
buffer: Buffer.from(SIMPLE_MODEL),
});
await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
const library = page.getByLabel('地图资产库');
await expect(library.getByText('点击添加,或按住资产拖到画布落位。')).toBeVisible();
await expect(library.getByText('物理几何原语', { exact: true })).toBeVisible();
await library.getByRole('button', { name: '添加基础方盒' }).click();
await expect(page.getByText('正在加载 MuJoCo 与模型…')).toHaveCount(0);
await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/scene_1/map.json', {
timeout: 30_000,
});
await expect(page.getByText('认证资产与场景对象属性')).toBeVisible();
await expect(page.getByLabel('地图物体检查器')).toBeVisible();
await expect(page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒')).toBeVisible();
await page.locator('[data-map-asset="ramp"]').dragTo(page.locator('main canvas'));
@@ -496,9 +512,9 @@ test('认证资产可点击创建场景并拖到画布落位', async ({ page })
await expect(page.getByText('地图草稿尚未应用')).toBeVisible();
await page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒').click();
await page.getByLabel('对象放置方式').selectOption('locked');
await page.getByLabel('贴地检测模式').selectOption('locked');
await expect(page.getByLabel('对象位置X')).toBeDisabled();
await page.getByLabel('对象放置方式').selectOption('auto_ground');
await page.getByLabel('贴地检测模式').selectOption('auto_ground');
await expect(page.getByLabel('对象位置X')).toBeEnabled();
const gizmoLine = page.getByRole('img', { name: 'XYZ 方向指示器' }).locator('line').first();
@@ -526,20 +542,19 @@ test('认证资产自动打开地图属性并与参数地形一次编译', async
buffer: Buffer.from(SIMPLE_MODEL),
});
await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
const library = page.getByLabel('地图资产库');
await page.getByRole('tab', { name: '属性' }).click();
await library.getByRole('button', { name: '添加基础方盒' }).click();
await expect(page.getByRole('tab', { name: '地图' })).toHaveAttribute('aria-selected', 'true');
await expect(page.getByText('认证资产与场景对象属性')).toBeVisible();
await expect(page.getByText('Map / Object')).toBeVisible();
await expect(page.getByLabel('地图物体检查器')).toBeVisible();
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
const sceneTree = page.getByLabel('场景资产树');
await expect(sceneTree.getByText('基础方盒')).toBeVisible();
await page.getByRole('tab', { name: '属性' }).click();
await sceneTree.getByRole('button', { name: /基础方盒/ }).click();
await expect(page.getByRole('tab', { name: '地图' })).toHaveAttribute('aria-selected', 'true');
await sceneTree.getByRole('treeitem', { name: 'box', exact: true }).click();
await expect(page.getByText('Robot / Body')).toBeVisible();
await sceneTree.getByRole('treeitem', { name: /基础方盒/ }).click();
await expect(page.getByText('Map / Object')).toBeVisible();
await library.getByRole('button', { name: '添加随机粗糙地形' }).click();
await page.getByLabel('位置 Xm').fill('4');
@@ -564,10 +579,10 @@ test('认证资产自动打开地图属性并与参数地形一次编译', async
}
expect(selectedDraftAsset).toBe(true);
await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/scene_1/map.json');
await sceneTree.getByRole('button', { name: '选择地图实例 随机粗糙地形' }).click();
await sceneTree.getByRole('treeitem', { name: /随机粗糙地形/ }).click();
await expect(page.getByLabel('地图来源')).toHaveValue('builtin');
await page.getByRole('button', { name: '一次编译应用' }).click();
await page.getByRole('button', { name: '应用场景' }).click();
await expect(page.getByText('2 项场景更改待应用')).toHaveCount(0, { timeout: 30_000 });
await expect(page.getByText(/已加载工程地图“场景 1”(1 个物理几何/)).toBeVisible();
@@ -610,15 +625,14 @@ test('放弃首次认证资产会完整回滚临时场景和工程文件', async
buffer: Buffer.from(SIMPLE_MODEL),
});
await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
const library = page.getByLabel('地图资产库');
await library.getByRole('button', { name: '添加基础方盒' }).click();
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
await expect(page.getByLabel('场景资产树')).toContainText('场景 1');
await page.getByRole('button', { name: '放弃场景更改' }).click();
await page.getByRole('button', { name: '放弃更改' }).click();
await expect(page.getByText('1 项场景更改待应用')).toHaveCount(0);
await expect(page.getByLabel('场景资产树')).toContainText('0 个实例');
await expect(page.getByLabel('场景资产树')).not.toContainText('场景 1');
await expect(library.getByText('场景 1')).toHaveCount(0);
// 文件和 map id 也必须回滚;再次创建应复用 scene_1,而不是泄漏出 scene_2。
@@ -654,7 +668,6 @@ test('工程地图与参数地形共享放置草稿、实例变换和回滚入
buffer: Buffer.from(project),
});
await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
const library = page.getByLabel('地图资产库');
await library.getByRole('button', { name: '放置工程地图 草稿仓库' }).click();
@@ -667,7 +680,7 @@ test('工程地图与参数地形共享放置草稿、实例变换和回滚入
await library.getByRole('button', { name: '添加波浪地形' }).click();
await expect(page.getByText('2 项场景更改待应用')).toBeVisible();
await page.getByRole('button', { name: '一次编译应用' }).click();
await page.getByRole('button', { name: '应用场景' }).click();
await expect(page.getByText('2 项场景更改待应用')).toHaveCount(0, { timeout: 30_000 });
await expect(page.getByText(/已加载工程地图“草稿仓库”/)).toBeVisible();
await expect(page.getByText(/已加载波浪地形物理地图/)).toBeVisible();
@@ -675,7 +688,7 @@ test('工程地图与参数地形共享放置草稿、实例变换和回滚入
await page.getByRole('button', { name: '删除地图实例 草稿仓库' }).click();
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
await expect(page.getByLabel('场景资产树')).not.toContainText('草稿仓库');
await page.getByRole('button', { name: '放弃场景更改' }).click();
await page.getByRole('button', { name: '放弃更改' }).click();
await expect(page.getByText('1 项场景更改待应用')).toHaveCount(0);
await expect(page.getByLabel('场景资产树')).toContainText('草稿仓库');
});
@@ -692,7 +705,6 @@ test('认证资产重力放置使用参数地形的真实承载高度', async ({
buffer: Buffer.from(SIMPLE_MODEL),
});
await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
const library = page.getByLabel('地图资产库');
await library.getByRole('button', { name: '添加深坑地形' }).click();
@@ -700,7 +712,7 @@ test('认证资产重力放置使用参数地形的真实承载高度', async ({
await library.getByLabel('新增资产放置方式').selectOption('gravity');
await library.getByRole('button', { name: '添加基础方盒' }).click();
await expect(page.getByLabel('对象放置方式')).toHaveValue('gravity');
await expect(page.getByLabel('贴地检测模式')).toHaveValue('gravity');
await expect(page.getByLabel('对象位置Z')).toHaveValue('-0.3');
await expect(page.getByText('2 项场景更改待应用')).toBeVisible();
});
@@ -717,7 +729,6 @@ test('参数化地形可连续拖到画布并一次性编译', async ({ page })
buffer: Buffer.from(SIMPLE_MODEL),
});
await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
await page.locator('[data-system-terrain="rough"]').dragTo(page.locator('main canvas'));
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
@@ -727,18 +738,19 @@ test('参数化地形可连续拖到画布并一次性编译', async ({ page })
await expect(page.getByLabel('场景资产树')).toContainText('随机粗糙地形');
await expect(page.getByLabel('场景资产树')).toContainText('波浪地形');
await page.getByRole('tab', { name: '属性' }).click();
await page.getByLabel('场景资产树').getByRole('treeitem', { name: 'box' }).click();
await expect(page.getByText('Robot / Body')).toBeVisible();
const canvasBox = await page.locator('main canvas').first().boundingBox();
expect(canvasBox).not.toBeNull();
await page.mouse.click(
canvasBox!.x + canvasBox!.width * 0.5,
canvasBox!.y + canvasBox!.height * 0.78,
);
await expect(page.getByRole('tab', { name: '地图' })).toHaveAttribute('aria-selected', 'true');
await expect(page.getByLabel('视口选择与变换')).toBeVisible();
await expect(page.getByText('Map / Instance')).toBeVisible();
await expect(page.getByLabel('地图视口工具')).toBeVisible();
await expect(page.getByText('地图与对象属性')).toBeVisible();
await page.getByRole('button', { name: '一次编译应用' }).click();
await page.getByRole('button', { name: '应用场景' }).click();
await expect(page.getByText('2 项场景更改待应用')).toHaveCount(0, { timeout: 30_000 });
await expect(page.getByText(/已加载随机粗糙地形物理地图/)).toBeVisible({
timeout: 30_000,
@@ -758,7 +770,6 @@ test('应用内置 MJCF 楼梯物理地图', async ({ page }) => {
buffer: Buffer.from(SIMPLE_MODEL),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
await page.getByLabel('地图来源').selectOption('builtin');
await page.getByLabel('物理地图预设').selectOption('stairs');
await page.getByLabel('台阶数量').fill('6');
@@ -790,7 +801,6 @@ test('依次应用全部系统参数化地形', async ({ page }) => {
buffer: Buffer.from(SIMPLE_MODEL),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
await page.getByLabel('地图来源').selectOption('builtin');
for (const [preset, label] of terrains) {
await page.getByLabel('物理地图预设').selectOption(preset);
@@ -834,7 +844,6 @@ test('导入并应用分层工程地图包', async ({ page }) => {
buffer: Buffer.from(project),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
await page.getByLabel('地图来源').selectOption({ label: '测试场景' });
await expect(page.getByLabel('地图出生点')).toHaveValue('start');
await page.getByRole('button', { name: '应用并重新编译' }).click();
@@ -869,14 +878,15 @@ test('将受支持的只读物理地图转换为可编辑副本', async ({ page
buffer: Buffer.from(project),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
await page.getByLabel('地图来源').selectOption({ label: '旧版基础场景' });
await page.getByRole('button', { name: '应用并重新编译' }).click();
const editor = page.getByText('认证资产与场景对象属性').locator('..');
await expect(editor.getByText(/保持只读/)).toBeVisible({ timeout: 30_000 });
await editor.getByRole('button', { name: '创建可编辑副本' }).click();
await expect(page.getByText('已创建可编辑地图副本')).toBeVisible({ timeout: 30_000 });
await expect(editor.getByRole('button', { name: '移动工具 W' })).toBeVisible();
await expect(
page.getByLabel('地图视口工具').getByRole('button', { name: '移动工具 W' }),
).toBeVisible();
await expect(editor.getByRole('button', { name: /floor · 方盒/ })).toBeVisible();
await expect(editor.getByRole('button', { name: /wall · 方盒/ })).toBeVisible();
});
@@ -915,30 +925,56 @@ test('编辑 V3 地图对象并事务式应用', async ({ page }) => {
buffer: Buffer.from(project),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
await page.getByLabel('地图来源').selectOption({ label: '可编辑场景' });
await page.getByRole('button', { name: '应用并重新编译' }).click();
await expect(page.getByText('认证资产与场景对象属性')).toBeVisible({ timeout: 30_000 });
const editor = page.getByText('认证资产与场景对象属性').locator('..');
await expect(editor.getByRole('button', { name: '移动工具 W' })).toHaveAttribute(
const mapTools = page.getByLabel('地图视口工具');
await expect(mapTools.getByRole('button', { name: '移动工具 W' })).toHaveAttribute(
'aria-pressed',
'true',
);
await page.keyboard.press('e');
await expect(editor.getByRole('button', { name: '旋转工具 E' })).toHaveAttribute(
await expect(mapTools.getByRole('button', { name: '旋转工具 E' })).toHaveAttribute(
'aria-pressed',
'true',
);
await page.keyboard.press('s');
await expect(editor.getByRole('button', { name: '缩放工具 S' })).toHaveAttribute(
await page.keyboard.press('r');
await expect(mapTools.getByRole('button', { name: '缩放工具 R' })).toHaveAttribute(
'aria-pressed',
'true',
);
await editor.getByRole('button', { name: '新增', exact: true }).click();
await editor.getByLabel('对象位置X').fill('2');
await editor.getByRole('button', { name: '应用并重新编译' }).click();
await expect(editor.getByRole('button', { name: /box · 方盒/ })).toBeVisible({ timeout: 30_000 });
await expect(editor.getByText('地图草稿尚未应用')).toHaveCount(0);
await page.getByRole('button', { name: '新增', exact: true }).click();
await page.getByLabel('对象位置X').fill('2');
await page.getByLabel('对象位置X').blur();
await page.keyboard.press('f');
const draftStatus = page.getByLabel('地图草稿状态');
await expect(draftStatus).toContainText('未保存改动');
await page.keyboard.press('Control+s');
await expect(
page.getByLabel('地图对象列表').getByRole('button', { name: /box · 方盒/ }),
).toBeVisible({
timeout: 30_000,
});
await expect(draftStatus).toContainText('地图草稿已同步');
await page
.getByLabel('地图对象列表')
.getByRole('button', { name: /box · 方盒/ })
.click();
await page.getByLabel('对象位置X').fill('3');
await expect(draftStatus).toContainText('未保存改动');
await draftStatus.getByRole('button', { name: '丢弃地图草稿' }).click();
await page
.getByLabel('地图对象列表')
.getByRole('button', { name: /box · 方盒/ })
.click();
await expect(page.getByLabel('对象位置X')).toHaveValue('2');
await expect(draftStatus).toContainText('地图草稿已同步');
await page.getByRole('button', { name: '新增', exact: true }).click();
await expect(page.getByLabel('地图对象列表').getByRole('button')).toHaveCount(2);
await page.keyboard.press('Delete');
await expect(page.getByLabel('地图对象列表').getByRole('button')).toHaveCount(1);
await expect(page.getByText('WASM 已加载')).toBeVisible();
});
@@ -974,7 +1010,6 @@ test('工程地图编译失败时保留上一仿真会话', async ({ page }) =>
const runningTime = Number(
(await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1] ?? 0,
);
await page.getByRole('tab', { name: '地图' }).click();
await page.getByLabel('地图来源').selectOption({ label: '动态错误地图' });
await page.getByRole('button', { name: '应用并重新编译' }).click();
await expect(page.getByRole('alert')).toContainText('模型编译失败', { timeout: 30_000 });
@@ -989,7 +1024,7 @@ test('工程地图编译失败时保留上一仿真会话', async ({ page }) =>
Number((await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1] ?? 0),
)
.toBeGreaterThan(runningTime);
await page.getByRole('button', { name: '放弃场景更改' }).click();
await page.getByRole('button', { name: '放弃更改' }).click();
await expect(page.getByText('1 项场景更改待应用')).toHaveCount(0);
await expect(page.getByLabel('地图来源')).toHaveValue('none');
});
+358 -83
View File
@@ -19,6 +19,7 @@ import {
CircleHelp,
Code2,
Crosshair,
Database,
Download,
Hand,
Maximize,
@@ -28,6 +29,7 @@ import {
Play,
RotateCcw,
Settings as SettingsIcon,
SlidersHorizontal,
SunMoon,
} from 'lucide-react';
import { DEFAULT_IMPORT_LIMITS, type MapEntry, type ProjectManifest } from '../project/types';
@@ -55,8 +57,17 @@ import {
import { useAppStore, type AppDiagnostic } from '../stores/useAppStore';
import { WorkbenchHeader } from './components/WorkbenchHeader';
import { ViewerToolDock } from './components/ViewerToolDock';
import {
MapAssetDropIndicator,
MapDraftStatusOverlay,
MapViewportToolbar,
type MapAssetDropTarget,
} from './components/MapViewportTools';
import { ModelControlsSidebar } from './components/ModelControlsSidebar';
import { ProjectSidebar } from './components/ProjectSidebar';
import { ProjectSidebar, type ProjectResourceTab } from './components/ProjectSidebar';
import type { WorkspaceTool } from './components/WorkspaceToolsPanel';
import type { EditorSelection } from './editorSelection';
import { useMapEditorShortcuts } from './hooks/useMapEditorShortcuts';
import { WorkspaceOverlays, type ImportProgress } from './components/WorkspaceOverlays';
import { EntrySelectionDialog } from './components/EntrySelectionDialog';
import { ErrorRecoveryPanel } from './components/ErrorRecoveryPanel';
@@ -107,6 +118,7 @@ import type {
EditableMapDocument,
EditableMapObjectType,
MapEditorInteractionCallbacks,
MapEditorSessionState,
MapEditorTransformMode,
MapObjectPlacementMode,
} from '../map/editor/types';
@@ -131,6 +143,11 @@ const SourceEditorDialog = lazy(() =>
default: module.SourceEditorDialog,
})),
);
const WorkspaceToolsPanel = lazy(() =>
import('./components/WorkspaceToolsPanel').then((module) => ({
default: module.WorkspaceToolsPanel,
})),
);
function hasTransferType(dataTransfer: DataTransfer, type: string): boolean {
return Array.from(dataTransfer.types).includes(type);
@@ -253,6 +270,7 @@ export function App() {
importInFlight = useRef(false),
adapter = useRef(new MainThreadPhysicsAdapter()),
root = useRef<HTMLDivElement>(null),
viewportShell = useRef<HTMLElement>(null),
viewerHost = useRef<HTMLDivElement>(null),
viewer = useRef<MuJoCoViewer | null>(null),
viewerReady = useRef<Promise<MuJoCoViewer | null> | null>(null),
@@ -307,12 +325,16 @@ export function App() {
[committedEditorDocuments, setCommittedEditorDocuments] = useState<
Map<string, EditableMapDocument>
>(() => new Map()),
[projectSidebarTab, setProjectSidebarTab] = useState<'project' | 'structure' | 'assets'>(
'project',
[projectSidebarTab, setProjectSidebarTab] = useState<ProjectResourceTab>('assets'),
[editorSelection, setEditorSelection] = useState<EditorSelection | null>(null),
[workspaceTool, setWorkspaceTool] = useState<WorkspaceTool | null>(null),
[mapTransformMode, setMapTransformMode] = useState<MapEditorTransformMode>('translate'),
[mapSnapping, setMapSnapping] = useState(true),
[assetPlacementMode, setAssetPlacementMode] = useState<MapObjectPlacementMode>('auto_ground'),
[editorSessionStates, setEditorSessionStates] = useState<Map<string, MapEditorSessionState>>(
() => new Map(),
),
[modelControlsTab, setModelControlsTab] = useState<'properties' | 'controls' | 'data' | 'map'>(
'properties',
);
[mapAssetDropTarget, setMapAssetDropTarget] = useState<MapAssetDropTarget>();
const [urdfMode, setUrdfMode] = useState<UrdfLoadMode>('mjcf'),
urdfModeRef = useRef<UrdfLoadMode>('mjcf');
const [baseMode, setBaseMode] = useState<UrdfBaseMode>('floating'),
@@ -356,10 +378,27 @@ export function App() {
: [],
),
);
return [...editorDrafts.keys()].filter((path) => !coveredDescriptors.has(path)).length;
}, [mapSceneDraft.changedIds, placedMapAssets, editorDrafts]);
return [...editorDrafts.keys()]
.filter((path) => !coveredDescriptors.has(path))
.reduce(
(count, path) => count + Math.max(1, editorSessionStates.get(path)?.changeCount ?? 1),
0,
);
}, [mapSceneDraft.changedIds, placedMapAssets, editorDrafts, editorSessionStates]);
const sceneDraftChangeCount = mapSceneDraft.changeCount + editorOnlyDraftCount,
mapSceneDirty = sceneDraftChangeCount > 0;
const activeEditorView =
mapSelection.kind === 'project'
? (editorDrafts.get(mapSelection.descriptorPath) ?? editorDocument)
: null;
const selectedMapObject =
editorSelection?.kind === 'map-object' && editorSelection.mapAssetId === activeMapAssetId
? activeEditorView?.objects.find((object) => object.id === editorSelection.objectId)
: undefined;
const mapEditingActive =
Boolean(activeMapAssetId) &&
(editorSelection?.kind === 'map' || editorSelection?.kind === 'map-object');
const activePlacementMode = selectedMapObject?.placementMode ?? assetPlacementMode;
const viewerSettings = useRef({
mode: state.mode,
forceScale,
@@ -402,10 +441,8 @@ export function App() {
return;
}
state.setSelection(selection);
if (selection) {
setRightOpen(true);
setModelControlsTab('properties');
}
setEditorSelection(selection ? { kind: 'body', bodyId: selection.bodyId } : null);
if (selection) setRightOpen(true);
},
onFrame: (frame, fps, snapshot) => {
const memory = (performance as Performance & { memory?: { usedJSHeapSize: number } })
@@ -434,10 +471,15 @@ export function App() {
},
onMapEditorSelect: (id) => {
editorInteraction.current?.onSelect(id);
if (id) {
setRightOpen(true);
setModelControlsTab('map');
}
const mapAssetId = activeMapAssetIdRef.current;
setEditorSelection(
mapAssetId
? id
? { kind: 'map-object', mapAssetId, objectId: id }
: { kind: 'map', mapAssetId }
: null,
);
if (mapAssetId) setRightOpen(true);
},
onMapEditorPreviewSelect: (mapAssetId, objectId) =>
mapEditorPreviewInteraction.current(mapAssetId, objectId),
@@ -487,6 +529,12 @@ export function App() {
useEffect(() => {
viewer.current?.setMode(state.mode);
}, [state.mode]);
useEffect(() => {
viewer.current?.setMapEditorTransformMode(mapTransformMode);
}, [mapTransformMode]);
useEffect(() => {
viewer.current?.setMapEditorSnapping(mapSnapping ? 0.1 : null, mapSnapping ? 5 : null);
}, [mapSnapping]);
useEffect(() => {
if (viewer.current) viewer.current.forceScale = forceScale;
}, [forceScale]);
@@ -611,6 +659,7 @@ export function App() {
}
state.setSnapshot(snapshot);
state.setSelection(null);
setEditorSelection(null);
state.setPaused(true);
setImportProgress({
title: '正在准备仿真',
@@ -759,7 +808,8 @@ export function App() {
manifest.current = next;
setProjectMaps(next.maps);
setCommittedEditorDocuments(manifestEditorDocuments(next));
setProjectSidebarTab('project');
setProjectSidebarTab('assets');
setEditorSelection(null);
setEditorDocument(null);
editorDraftsRef.current = new Map();
setEditorDrafts(new Map());
@@ -827,10 +877,12 @@ export function App() {
setPolicyStatus(undefined);
setProjectMaps([]);
setCommittedEditorDocuments(new Map());
setProjectSidebarTab('project');
setProjectSidebarTab('assets');
setEditorSelection(null);
setEditorDocument(null);
editorDraftsRef.current = new Map();
setEditorDrafts(new Map());
setEditorSessionStates(new Map());
provisionalMapFilesRef.current.clear();
viewer.current?.setMapEditorDocument(null);
placedMapAssetsRef.current = [];
@@ -902,6 +954,7 @@ export function App() {
const clearEditorDrafts = useCallback(() => {
editorDraftsRef.current = new Map();
setEditorDrafts(new Map());
setEditorSessionStates(new Map());
}, []);
const bindEditorInteraction = useCallback((callbacks: MapEditorInteractionCallbacks | null) => {
editorInteraction.current = callbacks;
@@ -923,16 +976,62 @@ export function App() {
}, []);
const selectEditorObject = useCallback((id: string | null) => {
viewer.current?.selectMapEditorObject(id);
const mapAssetId = activeMapAssetIdRef.current;
setEditorSelection(
mapAssetId
? id
? { kind: 'map-object', mapAssetId, objectId: id }
: { kind: 'map', mapAssetId }
: null,
);
}, []);
const setEditorTransformMode = useCallback((mode: MapEditorTransformMode) => {
viewer.current?.setMapEditorTransformMode(mode);
}, []);
const setEditorSnapping = useCallback(
(translation: number | null, rotationDegrees: number | null) => {
viewer.current?.setMapEditorSnapping(translation, rotationDegrees);
const updateEditorSessionState = useCallback(
(descriptorPath: string, sessionState: MapEditorSessionState | null) => {
setEditorSessionStates((current) => {
const next = new Map(current);
if (sessionState?.dirty) next.set(descriptorPath, sessionState);
else next.delete(descriptorPath);
return next;
});
},
[],
);
const activateMapEditing = useCallback(() => {
state.setMode('select');
}, []);
const changeMapTransformMode = useCallback((nextMode: MapEditorTransformMode) => {
setMapTransformMode(nextMode);
state.setMode('select');
}, []);
const changeMapPlacementMode = useCallback(
(placementMode: MapObjectPlacementMode) => {
setAssetPlacementMode(placementMode);
if (placementMode === 'locked')
setMapTransformMode((currentMode) => (currentMode === 'scale' ? 'translate' : currentMode));
if (editorSelection?.kind === 'map-object') {
editorInteraction.current?.onSetPlacementMode(editorSelection.objectId, placementMode);
if (placementMode !== 'locked')
viewer.current?.flashMapEditorSurfaceAlignment(editorSelection.objectId);
}
},
[editorSelection],
);
const alignSelectedMapObject = useCallback(() => {
if (editorSelection?.kind !== 'map-object') return;
editorInteraction.current?.onAlignToSurface(editorSelection.objectId);
viewer.current?.flashMapEditorSurfaceAlignment(editorSelection.objectId);
}, [editorSelection]);
const focusSelectedObject = useCallback(() => {
const bodyId =
editorSelection?.kind === 'body' || editorSelection?.kind === 'joint'
? editorSelection.bodyId
: undefined;
viewer.current?.focusSelection(bodyId);
}, [editorSelection]);
const deleteSelectedMapObject = useCallback(() => {
if (editorSelection?.kind === 'map-object')
editorInteraction.current?.onDelete(editorSelection.objectId);
}, [editorSelection]);
const readEditorDocument = useCallback((selection: MapSelection): EditableMapDocument | null => {
if (selection.kind !== 'project' || !manifest.current) return null;
const resolved = resolveProjectMap(manifest.current, selection.descriptorPath);
@@ -1001,12 +1100,13 @@ export function App() {
setActiveMapAssetId(active?.id);
mapSelectionRef.current = nextSelection;
setMapSelection(nextSelection);
if (nextSelection.kind === 'builtin')
setMapTransformMode((currentMode) => (currentMode === 'scale' ? 'translate' : currentMode));
},
[],
);
const focusMapProperties = useCallback(() => {
setRightOpen(true);
setModelControlsTab('map');
}, []);
const previewVisualMapScene = useCallback(
(assets: readonly PlacedMapAsset[], reload: boolean) => {
@@ -1070,7 +1170,19 @@ export function App() {
: null;
setEditorDocument(committedEditorDocument);
previewEditorDocument(previewDocument);
if (objectId) viewer.current?.selectMapEditorObject(objectId);
if (objectId) {
viewer.current?.selectMapEditorObject(objectId);
if (editorInteraction.current) {
editorInteraction.current.onSelect(objectId);
pendingEditorObjectId.current = undefined;
}
}
setEditorSelection(
objectId
? { kind: 'map-object', mapAssetId: id, objectId }
: { kind: 'map', mapAssetId: id },
);
useAppStore.getState().setSelection(null);
focusMapProperties();
},
[focusMapProperties, previewEditorDocument, readEditorDocument, setMapScene],
@@ -1116,8 +1228,8 @@ export function App() {
adapter.current.setPaused(true);
useAppStore.getState().setPaused(true);
useAppStore.getState().setSelection(null);
setEditorSelection({ kind: 'map', mapAssetId: id });
setRightOpen(true);
setModelControlsTab('map');
},
[activateMapAsset],
);
@@ -1133,8 +1245,8 @@ export function App() {
setEditorDocument(null);
adapter.current.setPaused(true);
useAppStore.getState().setPaused(true);
setEditorSelection({ kind: 'map', mapAssetId: id });
setRightOpen(true);
setModelControlsTab('map');
},
[setMapScene],
);
@@ -1252,6 +1364,8 @@ export function App() {
setProjectMaps(maps);
setCommittedEditorDocuments(manifestEditorDocuments(candidate));
setMapScene([...placedMapAssetsRef.current, placed], placed.id, selection);
setEditorSelection({ kind: 'map', mapAssetId: placed.id });
setRightOpen(true);
setEditorDocument(document);
previewEditorDocument(document);
state.setProject(
@@ -1304,6 +1418,8 @@ export function App() {
const placed = createPlacedMapAsset(selection, name);
const assets = [...placedMapAssetsRef.current, placed];
setMapScene(assets, placed.id, selection);
setEditorSelection({ kind: 'map', mapAssetId: placed.id });
setRightOpen(true);
if (selection.kind === 'project') previewVisualMapScene(assets, true);
setEditorDocument(readEditorDocument(selection));
viewer.current?.setMapEditorDocument(null);
@@ -1321,6 +1437,7 @@ export function App() {
? (nextAssets.at(-1) ?? undefined)
: nextAssets.find((asset) => asset.id === previousActiveId);
setMapScene(nextAssets, nextActive?.id, nextActive?.selection);
setEditorSelection(nextActive ? { kind: 'map', mapAssetId: nextActive.id } : null);
setEditorDocument(nextActive ? readEditorDocument(nextActive.selection) : null);
viewer.current?.setMapEditorDocument(null);
return true;
@@ -1390,6 +1507,12 @@ export function App() {
}
editorDraftsRef.current = remainingDrafts;
setEditorDrafts(remainingDrafts);
setEditorSessionStates((currentStates) => {
const nextStates = new Map(currentStates);
for (const path of submittedDrafts.keys())
if (!remainingDrafts.has(path)) nextStates.delete(path);
return nextStates;
});
setProjectMaps(maps);
setCommittedEditorDocuments(manifestEditorDocuments(committed));
const committedEditorDocument = readEditorDocument(mapSelectionRef.current);
@@ -1406,10 +1529,13 @@ export function App() {
entry.path,
);
state.setSnapshot(adapter.current.snapshot() ?? undefined);
const selectedMapAssetId = activeMapAssetIdRef.current;
setEditorSelection(selectedMapAssetId ? { kind: 'map', mapAssetId: selectedMapAssetId } : null);
return true;
};
const discardMapSceneDraft = () => {
if (state.loading || loadInFlight.current) return;
editorInteraction.current?.onDiscard();
clearEditorDrafts();
const omittedPaths = new Set<string>();
for (const paths of provisionalMapFilesRef.current.values())
@@ -1426,11 +1552,13 @@ export function App() {
restoredManifest.entries,
state.selectedEntry,
);
state.setSnapshot(adapter.current.snapshot() ?? undefined);
}
const assets = restoreAppliedMapScene(appliedMapAssetsRef.current);
const active =
assets.find((asset) => asset.id === activeMapAssetIdRef.current) ?? assets.at(-1);
setMapScene(assets, active?.id, active?.selection);
setEditorSelection(active ? { kind: 'map', mapAssetId: active.id } : null);
previewVisualMapScene(assets, true);
setEditorDocument(active ? readEditorDocument(active.selection) : null);
viewer.current?.setMapEditorDocument(null);
@@ -1465,6 +1593,7 @@ export function App() {
);
const nextAssets = updatePlacedMapAsset(placedMapAssetsRef.current, active.id, value, nextName);
setMapScene(nextAssets, active.id, value);
setEditorSelection({ kind: 'map', mapAssetId: active.id });
setEditorDocument(readEditorDocument(value));
viewer.current?.setMapEditorDocument(null);
void commitMapScene();
@@ -1578,6 +1707,10 @@ export function App() {
entryPath,
);
state.setSnapshot(adapter.current.snapshot() ?? undefined);
const selectedMapAssetId = activeMapAssetIdRef.current;
setEditorSelection(
selectedMapAssetId ? { kind: 'map', mapAssetId: selectedMapAssetId } : null,
);
notify('已创建可编辑地图副本', authoringPath);
return true;
} catch (error) {
@@ -1608,17 +1741,30 @@ export function App() {
const resetDragState = () => {
dragDepth.current = 0;
setDragActive(false);
setMapAssetDropTarget(undefined);
};
const dragEnter = (event: DragEvent) => {
if (
hasTransferType(event.dataTransfer, 'Files') &&
!hasTransferType(event.dataTransfer, MAP_LIBRARY_DRAG_MIME)
) {
if (hasTransferType(event.dataTransfer, MAP_LIBRARY_DRAG_MIME)) {
const bounds = viewportShell.current?.getBoundingClientRect();
if (bounds)
setMapAssetDropTarget({
left: event.clientX - bounds.left,
top: event.clientY - bounds.top,
position: viewer.current?.mapPlanePoint(event.clientX, event.clientY) ?? null,
});
return;
}
if (hasTransferType(event.dataTransfer, 'Files')) {
dragDepth.current += 1;
if (!state.loading) setDragActive(true);
}
};
const dragLeave = (event: DragEvent) => {
if (hasTransferType(event.dataTransfer, MAP_LIBRARY_DRAG_MIME)) {
if (!event.currentTarget.contains(event.relatedTarget as Node | null))
setMapAssetDropTarget(undefined);
return;
}
if (!hasTransferType(event.dataTransfer, 'Files')) return;
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setDragActive(false);
@@ -1626,9 +1772,15 @@ export function App() {
const dragOver = (event: DragEvent) => {
event.preventDefault();
if (hasTransferType(event.dataTransfer, MAP_LIBRARY_DRAG_MIME)) {
event.dataTransfer.dropEffect = viewer.current?.mapPlanePoint(event.clientX, event.clientY)
? 'copy'
: 'none';
const position = viewer.current?.mapPlanePoint(event.clientX, event.clientY) ?? null;
const bounds = viewportShell.current?.getBoundingClientRect();
if (bounds)
setMapAssetDropTarget({
left: event.clientX - bounds.left,
top: event.clientY - bounds.top,
position,
});
event.dataTransfer.dropEffect = position ? 'copy' : 'none';
return;
}
if (hasTransferType(event.dataTransfer, 'Files'))
@@ -1981,6 +2133,10 @@ export function App() {
if (document.fullscreenElement) void document.exitFullscreen().catch(() => {});
else if (root.current) void root.current.requestFullscreen().catch(() => {});
};
const showWorkspaceTool = (tool: WorkspaceTool) => {
setWorkspaceTool(tool);
setRightOpen(true);
};
const applyLayoutPreset = (preset: LayoutPreset) => {
if (preset === 'viewport') {
setLeftOpen(false);
@@ -1993,6 +2149,7 @@ export function App() {
} else if (preset === 'control') {
setLeftOpen(false);
setRightOpen(true);
setWorkspaceTool('controls');
dispatchLayoutWidths(288, 384);
} else {
setLeftOpen(true);
@@ -2000,19 +2157,36 @@ export function App() {
dispatchLayoutWidths(288, 288);
}
};
useMapEditorShortcuts({
enabled: Boolean(state.snapshot),
mapEditing: mapEditingActive,
dirty: mapSceneDirty,
loading: state.loading,
hasSelection: Boolean(editorSelection ?? state.selection),
canDelete: editorSelection?.kind === 'map-object',
canScale: mapSelection.kind === 'project' && selectedMapObject?.placementMode !== 'locked',
onTransformMode: changeMapTransformMode,
onFocusSelection: focusSelectedObject,
onDeleteSelection: deleteSelectedMapObject,
onToggleSnapping: () => setMapSnapping((value) => !value),
onSave: () => void commitMapScene(),
});
useEffect(() => {
const key = (event: KeyboardEvent) => {
if (event.defaultPrevented) return;
if (
document.activeElement instanceof HTMLElement &&
document.activeElement.closest('[role="dialog"]')
)
return;
const target = event.target instanceof HTMLElement ? event.target : null;
if (target?.closest('input,select,textarea,[contenteditable="true"]')) return;
if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === 'k') {
event.preventDefault();
setCommandOpen(true);
return;
}
if ((event.target as HTMLElement).matches('input,select,button')) return;
if (target?.closest('button')) return;
if (event.code === 'Space') {
event.preventDefault();
togglePause();
@@ -2100,6 +2274,22 @@ export function App() {
disabled: !state.snapshot,
run: exportMjcf,
},
{
id: 'workspace-controls',
label: '在右侧打开控制台',
group: '工具',
icon: <SlidersHorizontal className="h-4 w-4" />,
disabled: !state.snapshot,
run: () => showWorkspaceTool('controls'),
},
{
id: 'workspace-data',
label: '在右侧打开数据录制',
group: '工具',
icon: <Database className="h-4 w-4" />,
disabled: !state.snapshot,
run: () => showWorkspaceTool('data'),
},
{
id: 'left',
label: leftOpen ? '隐藏工程面板' : '显示工程面板',
@@ -2109,7 +2299,7 @@ export function App() {
},
{
id: 'right',
label: rightOpen ? '隐藏属性面板' : '显示属性面板',
label: rightOpen ? '隐藏右侧面板' : '显示右侧面板',
group: '布局',
icon: rightOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />,
run: () => setRightOpen((value) => !value),
@@ -2136,6 +2326,63 @@ export function App() {
run: () => setHelpOpen(true),
},
];
const workspaceTools = workspaceTool ? (
<Suspense
fallback={
<div
role="status"
className="grid min-h-0 flex-1 place-items-center p-4 text-sm text-text-tertiary"
>
</div>
}
>
<WorkspaceToolsPanel
active={workspaceTool}
snapshot={state.snapshot}
loading={state.loading}
ignoreJointLimits={ignoreJointLimits}
jointAdvanced={jointAdvanced}
angleUnit={angleUnit}
forceScale={forceScale}
controllerPaths={state.files
.filter((file) => /\.py$/i.test(file.path))
.map((file) => file.path)}
selectedControllerPath={selectedControllerPath}
controllerStatus={controllerStatus}
policyPaths={state.files
.filter((file) => /\.onnx$/i.test(file.path))
.map((file) => file.path)}
selectedPolicyPath={selectedPolicyPath}
policyStatus={policyStatus}
onResetJoints={resetJoints}
onToggleJointLimits={toggleJointLimits}
onToggleAdvanced={() => setJointAdvanced((value) => !value)}
onToggleAngleUnit={() => setAngleUnit((value) => (value === 'rad' ? 'deg' : 'rad'))}
onActuator={setActuator}
onActuatorParameters={setActuatorParameters}
onJoint={setJoint}
onForceScale={setForceScale}
onSelectControllerPath={setSelectedControllerPath}
onLoadControllerPath={loadControllerPath}
onImportController={importController}
onToggleController={toggleController}
onControllerCommand={sendControllerCommand}
onRemoveController={removeController}
onSelectPolicyPath={setSelectedPolicyPath}
onLoadPolicyPath={loadPolicyPath}
onImportPolicy={importPolicy}
onTogglePolicy={togglePolicy}
onPolicyCommand={setPolicyCommand}
onRemovePolicy={removePolicy}
onDataRecorderConfigure={configureDataRecorder}
onDataRecordingStart={startDataRecording}
onDataRecordingStop={stopDataRecording}
onDataRecordingClear={clearDataRecording}
onDataRecordingExport={exportDataRecording}
/>
</Suspense>
) : undefined;
return (
<div
ref={root}
@@ -2212,6 +2459,15 @@ export function App() {
<ViewerToolDock
mode={state.mode}
display={displayOptions}
mapEditContext={
mapEditingActive
? {
active: state.mode === 'select',
label: `地图 · ${{ translate: '移动', rotate: '旋转', scale: '缩放' }[mapTransformMode]}`,
onActivate: activateMapEditing,
}
: undefined
}
onModeChange={mode}
onDisplayChange={setDisplayOptions}
onResetCamera={() => viewer.current?.resetCamera()}
@@ -2226,44 +2482,87 @@ export function App() {
entries={state.entries}
selectedEntry={state.selectedEntry}
snapshot={state.snapshot}
selection={editorSelection}
loading={state.loading}
nativeUrdf={selectedFormat === 'urdf' && urdfMode === 'native'}
mapSelection={mapSelection}
maps={projectMaps}
placedMaps={placedMapAssets}
activeMapId={activeMapAssetId}
pendingSceneChangeCount={sceneDraftChangeCount}
pendingSceneIds={pendingSceneIds}
editorDocument={
mapSelection.kind === 'project'
? (editorDrafts.get(mapSelection.descriptorPath) ?? editorDocument)
: editorDocument
}
editorDocuments={sceneEditorDocuments}
assetPlacementMode={assetPlacementMode}
activeTab={projectSidebarTab}
onActiveTabChange={setProjectSidebarTab}
onRemove={removeProject}
onSelectEntry={requestLoadEntry}
onJointHover={(jointId) => viewer.current?.highlightJoint(jointId)}
onSelectBody={(bodyId) => {
state.setSelection(null);
setEditorSelection({ kind: 'body', bodyId });
viewer.current?.selectMapEditorObject(null);
viewer.current?.selectParametricMapAsset(null);
viewer.current?.highlightJoint(null);
setRightOpen(true);
}}
onSelectJoint={(jointId, bodyId) => {
state.setSelection(null);
setEditorSelection({ kind: 'joint', jointId, bodyId });
viewer.current?.selectMapEditorObject(null);
viewer.current?.selectParametricMapAsset(null);
viewer.current?.highlightJoint(jointId);
setRightOpen(true);
}}
onJointHover={(jointId) =>
viewer.current?.highlightJoint(
jointId ?? (editorSelection?.kind === 'joint' ? editorSelection.jointId : null),
)
}
onAddMapAsset={(type, placementMode) =>
addCertifiedMapAsset(type, undefined, placementMode)
}
onAddProjectMap={addProjectMapAsset}
onSelectTerrain={selectTerrainAsset}
onAssetPlacementModeChange={setAssetPlacementMode}
onApplyScene={() => void commitMapScene()}
onDiscardScene={discardMapSceneDraft}
onSelectMap={activateMapAsset}
onRemoveMap={removePlacedMapAsset}
onSelectMapObject={(mapId, objectId) => activateMapAsset(mapId, objectId)}
/>
<main className="viewport-shell relative min-w-0 flex-1">
<main ref={viewportShell} className="viewport-shell relative min-w-0 flex-1">
<div ref={viewerHost} className="absolute inset-0" />
<ViewportHUD
paused={state.paused}
mode={state.mode}
selection={state.selection}
ready={Boolean(state.snapshot)}
mapEditing={mapEditingActive}
/>
<MapViewportToolbar
visible={mapEditingActive && Boolean(state.snapshot)}
interactionActive={state.mode === 'select'}
mode={mapTransformMode}
snapping={mapSnapping}
placementMode={activePlacementMode}
hasSelectedObject={Boolean(selectedMapObject)}
allowScale={
mapSelection.kind === 'project' && selectedMapObject?.placementMode !== 'locked'
}
loading={state.loading}
onActivate={activateMapEditing}
onModeChange={changeMapTransformMode}
onSnappingChange={setMapSnapping}
onPlacementModeChange={changeMapPlacementMode}
onAlignToSurface={alignSelectedMapObject}
/>
<MapDraftStatusOverlay
visible={Boolean(state.snapshot)}
changeCount={sceneDraftChangeCount}
loading={state.loading}
onCommit={() => void commitMapScene()}
onDiscard={discardMapSceneDraft}
/>
<MapAssetDropIndicator target={mapAssetDropTarget} />
<WorkspaceOverlays
loading={state.loading}
hasSnapshot={Boolean(state.snapshot)}
@@ -2322,10 +2621,9 @@ export function App() {
</main>
<ModelControlsSidebar
visible={rightOpen}
activeTab={modelControlsTab}
onActiveTabChange={setModelControlsTab}
snapshot={state.snapshot}
selection={state.selection}
selection={editorSelection}
viewerSelection={state.selection}
selectedFormat={selectedFormat}
loading={state.loading}
urdfMode={urdfMode}
@@ -2334,19 +2632,9 @@ export function App() {
ignoreJointLimits={ignoreJointLimits}
jointAdvanced={jointAdvanced}
angleUnit={angleUnit}
forceScale={forceScale}
controllerPaths={state.files
.filter((file) => /\.py$/i.test(file.path))
.map((file) => file.path)}
selectedControllerPath={selectedControllerPath}
controllerStatus={controllerStatus}
policyPaths={state.files
.filter((file) => /\.onnx$/i.test(file.path))
.map((file) => file.path)}
selectedPolicyPath={selectedPolicyPath}
policyStatus={policyStatus}
mapSelection={mapSelection}
activeMapAssetId={activeMapAssetId}
activeMapAssetName={placedMapAssets.find((asset) => asset.id === activeMapAssetId)?.name}
mapSceneDirty={mapSceneDirty}
maps={projectMaps}
showVisualMap={showVisualMap}
@@ -2357,6 +2645,16 @@ export function App() {
? editorDrafts.get(mapSelection.descriptorPath)
: undefined
}
workspaceTool={workspaceTool}
workspaceTools={workspaceTools}
onWorkspaceToolChange={(tool) => {
setWorkspaceTool(tool);
if (tool) setRightOpen(true);
}}
onSelectJoint={(jointId, bodyId) => {
setEditorSelection({ kind: 'joint', jointId, bodyId });
viewer.current?.highlightJoint(jointId);
}}
onUrdfMode={changeUrdfMode}
onBaseMode={changeBaseMode}
onShowCollision={setShowCollision}
@@ -2367,19 +2665,6 @@ export function App() {
onActuator={setActuator}
onActuatorParameters={setActuatorParameters}
onJoint={setJoint}
onForceScale={setForceScale}
onSelectControllerPath={setSelectedControllerPath}
onLoadControllerPath={loadControllerPath}
onImportController={importController}
onToggleController={toggleController}
onControllerCommand={sendControllerCommand}
onRemoveController={removeController}
onSelectPolicyPath={setSelectedPolicyPath}
onLoadPolicyPath={loadPolicyPath}
onImportPolicy={importPolicy}
onTogglePolicy={togglePolicy}
onPolicyCommand={setPolicyCommand}
onRemovePolicy={removePolicy}
onApplyMap={applyMapSelection}
onMapDraft={stageMapSelectionDraft}
onEditorPreview={previewEditorDocument}
@@ -2389,22 +2674,12 @@ export function App() {
onEditorConvert={convertSelectedMap}
onEditorBindInteraction={bindEditorInteraction}
onEditorSelect={selectEditorObject}
onEditorTransformMode={setEditorTransformMode}
onEditorSnapping={setEditorSnapping}
onEditorSessionStateChange={updateEditorSessionState}
onEditorSurfaceHeight={editorSurfaceHeight}
onMapDisplay={(visual, collision) => {
setShowVisualMap(visual);
setShowMapCollision(collision);
}}
onMapTabOpen={() => {
setLeftOpen(true);
setProjectSidebarTab('assets');
}}
onDataRecorderConfigure={configureDataRecorder}
onDataRecordingStart={startDataRecording}
onDataRecordingStop={stopDataRecording}
onDataRecordingClear={clearDataRecording}
onDataRecordingExport={exportDataRecording}
/>
</div>
{pendingUrdfPath && (
@@ -43,7 +43,7 @@ export function LayoutSettingsDialog({
onClick={() => onRightOpen(!rightOpen)}
icon={<PanelRight className="h-3.5 w-3.5" />}
>
</Button>
</div>
<h3 className="mb-2 mt-4 text-xs font-semibold"></h3>
@@ -0,0 +1,99 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { MapDraftStatusOverlay, MapViewportToolbar } from './MapViewportTools';
const noop = () => {};
describe('MapViewportToolbar', () => {
it('统一联动变换、吸附与贴地检测命令', () => {
const onModeChange = vi.fn();
const onSnappingChange = vi.fn();
const onPlacementModeChange = vi.fn();
const onAlignToSurface = vi.fn();
render(
<MapViewportToolbar
visible
interactionActive
mode="translate"
snapping
placementMode="auto_ground"
hasSelectedObject
allowScale
loading={false}
onActivate={noop}
onModeChange={onModeChange}
onSnappingChange={onSnappingChange}
onPlacementModeChange={onPlacementModeChange}
onAlignToSurface={onAlignToSurface}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '旋转工具 E' }));
fireEvent.click(screen.getByRole('button', { name: '网格吸附' }));
fireEvent.change(screen.getByLabelText('贴地检测模式'), {
target: { value: 'gravity' },
});
fireEvent.click(screen.getByRole('button', { name: '立即贴合承载面' }));
expect(onModeChange).toHaveBeenCalledWith('rotate');
expect(onSnappingChange).toHaveBeenCalledWith(false);
expect(onPlacementModeChange).toHaveBeenCalledWith('gravity');
expect(onAlignToSurface).toHaveBeenCalledOnce();
});
it('参数地形或未选中对象时不提供缩放与贴合动作', () => {
render(
<MapViewportToolbar
visible
interactionActive
mode="translate"
snapping={false}
placementMode="auto_ground"
hasSelectedObject={false}
allowScale={false}
loading={false}
onActivate={noop}
onModeChange={noop}
onSnappingChange={noop}
onPlacementModeChange={noop}
onAlignToSurface={noop}
/>,
);
expect(screen.queryByRole('button', { name: '缩放工具 R' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '立即贴合承载面' })).toBeDisabled();
});
});
describe('MapDraftStatusOverlay', () => {
it('常驻显示未保存数量并提供提交与丢弃入口', () => {
const onCommit = vi.fn();
const onDiscard = vi.fn();
render(
<MapDraftStatusOverlay
visible
changeCount={3}
loading={false}
onCommit={onCommit}
onDiscard={onDiscard}
/>,
);
expect(screen.getByText('3 项未保存改动')).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: '提交地图草稿' }));
fireEvent.click(screen.getByRole('button', { name: '丢弃地图草稿' }));
expect(onCommit).toHaveBeenCalledOnce();
expect(onDiscard).toHaveBeenCalledOnce();
});
it('无草稿时显示已同步并禁用动作', () => {
render(
<MapDraftStatusOverlay
visible
changeCount={0}
loading={false}
onCommit={noop}
onDiscard={noop}
/>,
);
expect(screen.getByText('地图草稿已同步')).toBeVisible();
expect(screen.getByRole('button', { name: '提交地图草稿' })).toBeDisabled();
});
});
@@ -0,0 +1,202 @@
import {
ArrowDownToLine,
Check,
Grid3X3,
LockKeyhole,
MapPinned,
Move3d,
Rotate3d,
Scaling,
Trash2,
} from 'lucide-react';
import {
Button,
IconButton,
Select,
ToolbarToggleGroup,
type ToolbarItem,
} from '../../components/ui';
import {
MAP_OBJECT_PLACEMENT_LABELS,
type MapEditorTransformMode,
type MapObjectPlacementMode,
} from '../../map/editor/types';
const TRANSFORM_TOOLS: ToolbarItem<MapEditorTransformMode>[] = [
{ value: 'translate', label: '移动工具 W', icon: Move3d },
{ value: 'rotate', label: '旋转工具 E', icon: Rotate3d },
{ value: 'scale', label: '缩放工具 R', icon: Scaling },
];
export function MapViewportToolbar({
visible,
interactionActive,
mode,
snapping,
placementMode,
hasSelectedObject,
allowScale,
loading,
onActivate,
onModeChange,
onSnappingChange,
onPlacementModeChange,
onAlignToSurface,
}: {
visible: boolean;
interactionActive: boolean;
mode: MapEditorTransformMode;
snapping: boolean;
placementMode: MapObjectPlacementMode;
hasSelectedObject: boolean;
allowScale: boolean;
loading: boolean;
onActivate: () => void;
onModeChange: (mode: MapEditorTransformMode) => void;
onSnappingChange: (value: boolean) => void;
onPlacementModeChange: (mode: MapObjectPlacementMode) => void;
onAlignToSurface: () => void;
}) {
if (!visible) return null;
return (
<div
aria-label="地图视口工具"
className="engineering-glass map-tool-enter absolute left-1/2 top-3 z-20 flex max-w-[calc(100%-24px)] -translate-x-1/2 items-center gap-1.5 rounded-xl border p-1.5"
>
<button
type="button"
aria-label="激活地图编辑"
aria-pressed={interactionActive}
disabled={loading}
onClick={onActivate}
className={`hidden h-8 items-center gap-1.5 rounded-lg px-2 text-[10px] font-semibold transition-colors sm:flex ${interactionActive ? 'bg-accent-soft text-accent' : 'bg-surface/80 text-text-secondary hover:bg-element-hover'}`}
>
<MapPinned className="h-3.5 w-3.5" aria-hidden="true" />
</button>
<ToolbarToggleGroup
items={TRANSFORM_TOOLS.filter((item) => allowScale || item.value !== 'scale')}
value={mode}
onChange={onModeChange}
label="地图变换模式"
/>
<IconButton
active={snapping}
tooltip={snapping ? '关闭网格吸附(当前 0.1 m / 5°)' : '开启网格吸附'}
aria-label="网格吸附"
aria-pressed={snapping}
disabled={loading}
onClick={() => onSnappingChange(!snapping)}
>
<Grid3X3 className="h-3.5 w-3.5" />
</IconButton>
<span aria-hidden="true" className="mx-0.5 h-5 w-px bg-border" />
<label className="flex items-center gap-1 text-[10px] text-text-tertiary">
<span className="hidden lg:inline"></span>
<Select
aria-label="贴地检测模式"
className="w-[112px]"
value={placementMode}
disabled={loading}
onChange={(event) => onPlacementModeChange(event.target.value as MapObjectPlacementMode)}
>
{(Object.keys(MAP_OBJECT_PLACEMENT_LABELS) as MapObjectPlacementMode[]).map((value) => (
<option key={value} value={value}>
{value === 'auto_ground' ? '地面 z=0' : value === 'gravity' ? '表面检测' : '锁定位姿'}
</option>
))}
</Select>
</label>
<IconButton
tooltip={hasSelectedObject ? '重新检测并贴合承载面' : '选中地图物体后可贴合表面'}
aria-label="立即贴合承载面"
disabled={loading || !hasSelectedObject || placementMode === 'locked'}
onClick={onAlignToSurface}
>
{placementMode === 'locked' ? (
<LockKeyhole className="h-3.5 w-3.5" />
) : (
<ArrowDownToLine className="h-3.5 w-3.5" />
)}
</IconButton>
</div>
);
}
export function MapDraftStatusOverlay({
visible,
changeCount,
loading,
onCommit,
onDiscard,
}: {
visible: boolean;
changeCount: number;
loading: boolean;
onCommit: () => void;
onDiscard: () => void;
}) {
if (!visible) return null;
const dirty = changeCount > 0;
return (
<div
aria-label="地图草稿状态"
role="status"
className="engineering-glass absolute bottom-3 left-1/2 z-20 flex max-w-[calc(100%-24px)] -translate-x-1/2 items-center gap-2 rounded-xl border px-2 py-1.5"
>
<span
aria-hidden="true"
className={`h-2 w-2 shrink-0 rounded-full ${dirty ? 'draft-dirty-dot bg-warning' : 'bg-success'}`}
/>
<span className="min-w-0 whitespace-nowrap text-[10px] font-medium text-text-secondary sm:text-[11px]">
{dirty ? `${changeCount} 项未保存改动` : '地图草稿已同步'}
</span>
<Button
variant="ghost"
aria-label="丢弃地图草稿"
disabled={loading || !dirty}
icon={<Trash2 className="h-3 w-3" />}
onClick={onDiscard}
>
<span className="hidden sm:inline"></span>
</Button>
<Button
variant="primary"
aria-label="提交地图草稿"
disabled={loading || !dirty}
icon={<Check className="h-3 w-3" />}
onClick={onCommit}
>
<span className="hidden sm:inline"></span>
</Button>
</div>
);
}
export interface MapAssetDropTarget {
left: number;
top: number;
position: readonly [number, number, number] | null;
}
export function MapAssetDropIndicator({ target }: { target?: MapAssetDropTarget }) {
if (!target) return null;
const valid = Boolean(target.position);
return (
<div className="pointer-events-none absolute inset-0 z-30" aria-label="地图资产放置预览">
<div
className={`absolute -translate-x-1/2 -translate-y-1/2 rounded-full border-2 p-1 shadow-2xl ${valid ? 'border-accent bg-accent/25 text-accent' : 'border-danger bg-danger/20 text-danger'}`}
style={{ left: target.left, top: target.top }}
>
<span className="grid h-8 w-8 place-items-center rounded-full border border-current bg-panel/85 backdrop-blur">
<MapPinned className="h-4 w-4" />
</span>
<span className="absolute left-1/2 top-full mt-2 -translate-x-1/2 whitespace-nowrap rounded-lg border border-border-strong bg-panel/90 px-2 py-1 text-[10px] font-medium shadow-xl backdrop-blur">
{valid
? `释放放置 · ${target.position![0].toFixed(1)}, ${target.position![1].toFixed(1)}`
: '请拖到 3D 地面'}
</span>
</div>
</div>
);
}
@@ -0,0 +1,162 @@
import type { ComponentProps } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { DEFAULT_PHYSICAL_MAP_CONFIG } from '../../map/types';
import type { SimulationSnapshot } from '../../simulation/SimulationSession';
import { ModelControlsSidebar } from './ModelControlsSidebar';
const snapshot = {
bodies: [
{ id: 0, name: 'world', parentId: 0 },
{ id: 1, name: 'base', parentId: 0 },
{ id: 2, name: 'arm', parentId: 1 },
],
joints: [
{
id: 7,
name: 'arm_joint',
bodyId: 2,
type: 3,
value: 0.25,
min: -1,
max: 1,
limitMin: -1,
limitMax: 1,
limited: true,
limitsIgnored: false,
editable: true,
axis: [0, 0, 1],
},
],
actuators: [],
model: { nbody: 3, njnt: 1, ngeom: 2, ncam: 0, nactuator: 0, nu: 0, nq: 1, nv: 1 },
} as unknown as SimulationSnapshot;
const noop = () => {};
const asyncTrue = async () => true;
function props(
patch: Partial<ComponentProps<typeof ModelControlsSidebar>> = {},
): ComponentProps<typeof ModelControlsSidebar> {
return {
snapshot,
selection: null,
viewerSelection: null,
loading: false,
urdfMode: 'mjcf',
baseMode: 'floating',
showCollision: false,
ignoreJointLimits: false,
jointAdvanced: false,
angleUnit: 'rad',
mapSelection: { kind: 'none' },
mapSceneDirty: false,
maps: [],
showVisualMap: true,
showMapCollision: false,
editorDocument: null,
workspaceTool: null,
onWorkspaceToolChange: noop,
onSelectJoint: noop,
onUrdfMode: noop,
onBaseMode: noop,
onShowCollision: noop,
onResetJoints: noop,
onToggleJointLimits: noop,
onToggleAdvanced: noop,
onToggleAngleUnit: noop,
onActuator: noop,
onActuatorParameters: noop,
onJoint: noop,
onApplyMap: noop,
onMapDraft: noop,
onEditorPreview: noop,
onEditorDraftChange: noop,
onEditorApply: asyncTrue,
onEditorExport: noop,
onEditorConvert: asyncTrue,
onEditorBindInteraction: noop,
onEditorSelect: noop,
onEditorSessionStateChange: noop,
onMapDisplay: noop,
...patch,
};
}
describe('ModelControlsSidebar', () => {
it('没有选择时显示场景摘要,并保留基础地图快速入口', () => {
render(<ModelControlsSidebar {...props()} />);
expect(screen.getByText('未选择对象')).toBeVisible();
expect(screen.getByText('模型摘要')).toBeVisible();
expect(screen.getByText('未选择地图实例')).toBeVisible();
});
it('按照统一选择自动路由 Body 与 Joint 检查器', () => {
const view = render(
<ModelControlsSidebar {...props({ selection: { kind: 'body', bodyId: 2 } })} />,
);
expect(screen.getByText('Robot / Body')).toBeVisible();
expect(screen.getByText('arm', { selector: '[title="arm"]' })).toBeVisible();
view.rerender(
<ModelControlsSidebar {...props({ selection: { kind: 'joint', jointId: 7, bodyId: 2 } })} />,
);
expect(screen.getByText('Robot / Joint')).toBeVisible();
expect(screen.getByText('Hinge · arm')).toBeVisible();
expect(screen.getByText('关联 Actuator')).toBeVisible();
});
it('控制台与数据录制在右侧面板内切换,不创建模态窗口', () => {
const onWorkspaceToolChange = vi.fn();
render(
<ModelControlsSidebar
{...props({
selection: { kind: 'body', bodyId: 2 },
workspaceTool: 'controls',
workspaceTools: (
<div role="tabpanel" aria-label="控制台">
内嵌控制工具
</div>
),
onWorkspaceToolChange,
})}
/>,
);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.getByRole('tabpanel', { name: '控制台' })).toBeVisible();
expect(screen.getByText('内嵌控制工具')).toBeVisible();
const inspector = document.querySelector('[role="tabpanel"][aria-label="检查器"]');
expect(inspector).not.toBeNull();
expect(inspector).not.toBeVisible();
const controlsTab = screen.getByRole('tab', { name: '控制台' });
expect(controlsTab).toHaveAttribute('aria-selected', 'true');
fireEvent.keyDown(controlsTab, { key: 'ArrowRight' });
expect(onWorkspaceToolChange).toHaveBeenCalledWith('data');
fireEvent.click(screen.getByRole('tab', { name: '检查器' }));
expect(onWorkspaceToolChange).toHaveBeenCalledWith(null);
});
it('选择地图实例后自动展示地图检查器,并把全局工具移到右侧入口', () => {
const onWorkspaceToolChange = vi.fn();
render(
<ModelControlsSidebar
{...props({
selection: { kind: 'map', mapAssetId: 'terrain-a' },
activeMapAssetId: 'terrain-a',
activeMapAssetName: '随机粗糙地形',
mapSelection: {
kind: 'builtin',
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'rough' },
},
onWorkspaceToolChange,
})}
/>,
);
expect(screen.getByText('Map / Instance')).toBeVisible();
expect(screen.getByText('随机粗糙地形', { selector: '[title="随机粗糙地形"]' })).toBeVisible();
fireEvent.click(screen.getByRole('tab', { name: '控制台' }));
fireEvent.click(screen.getByRole('tab', { name: '数据录制' }));
expect(onWorkspaceToolChange.mock.calls).toEqual([['controls'], ['data']]);
});
});
@@ -1,38 +1,31 @@
import { useState } from 'react';
import { Database, Info, Map as MapIcon, SlidersHorizontal } from 'lucide-react';
import type { ReactNode } from 'react';
import { Box, Database, MapPinned, PanelRight, 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, PlacedMapSelection } from '../../map/types';
import type {
EditableMapDocument,
MapEditorInteractionCallbacks,
MapEditorTransformMode,
MapEditorSessionState,
} 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 {
BodyInspector,
InspectorIdentity,
JointInspector,
ModelSummaryInspector,
} from '../../simulation/RobotInspector';
import type { EditorSelection } from '../editorSelection';
import { SidebarPanel } from './SidebarPanel';
import type { WorkspaceTool } from './WorkspaceToolsPanel';
import { RightSidebarTabs } from './RightSidebarTabs';
interface ModelControlsProps {
snapshot?: SimulationSnapshot;
selection: ViewerSelection | null;
selection: EditorSelection | null;
viewerSelection: ViewerSelection | null;
selectedFormat?: ModelEntry['format'];
loading: boolean;
visible?: boolean;
@@ -42,23 +35,19 @@ interface ModelControlsProps {
ignoreJointLimits: boolean;
jointAdvanced: boolean;
angleUnit: 'rad' | 'deg';
forceScale: number;
controllerPaths: string[];
selectedControllerPath?: string;
controllerStatus?: ControllerStatus;
policyPaths: string[];
selectedPolicyPath?: string;
policyStatus?: RLPolicyStatus;
mapSelection: MapSelection;
activeMapAssetId?: string;
activeMapAssetName?: string;
mapSceneDirty: boolean;
maps: MapEntry[];
showVisualMap: boolean;
showMapCollision: boolean;
editorDocument: EditableMapDocument | null;
editorDraftDocument?: EditableMapDocument;
activeTab?: 'properties' | 'controls' | 'data' | 'map';
onActiveTabChange?: (value: 'properties' | 'controls' | 'data' | 'map') => void;
workspaceTool: WorkspaceTool | null;
workspaceTools?: ReactNode;
onWorkspaceToolChange: (tool: WorkspaceTool | null) => void;
onSelectJoint: (jointId: number, bodyId: number) => void;
onUrdfMode: (value: UrdfLoadMode) => void;
onBaseMode: (value: UrdfBaseMode) => void;
onShowCollision: (value: boolean) => void;
@@ -69,19 +58,6 @@ interface ModelControlsProps {
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;
onMapDraft: (value: PlacedMapSelection) => void;
onEditorPreview: (document: EditableMapDocument | null) => void;
@@ -95,320 +71,183 @@ interface ModelControlsProps {
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;
onEditorSessionStateChange?: (
descriptorPath: string,
state: MapEditorSessionState | null,
) => void;
onEditorSurfaceHeight?: (position: readonly [number, number, number]) => number | null;
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 [internalTab, setInternalTab] = useState<'properties' | 'controls' | 'data' | 'map'>(
'properties',
),
tab = props.activeTab ?? internalTab,
s = props.snapshot;
if (!s)
const snapshot = props.snapshot;
const activeView = props.workspaceTool ?? 'inspector';
const title =
activeView === 'controls' ? '控制台' : activeView === 'data' ? '数据录制' : '检查器';
const icon =
activeView === 'controls' ? (
<SlidersHorizontal />
) : activeView === 'data' ? (
<Database />
) : (
<PanelRight />
);
const tabs = (
<RightSidebarTabs
value={activeView}
onChange={(value) => props.onWorkspaceToolChange(value === 'inspector' ? null : value)}
/>
);
if (!snapshot)
return (
<SidebarPanel title="属性与参数" side="right" visible={props.visible}>
<div className="p-4 text-sm text-text-tertiary"></div>
<SidebarPanel title={title} side="right" visible={props.visible} icon={icon}>
{tabs}
{props.workspaceTool ? (
props.workspaceTools
) : (
<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 selection = props.selection;
const selectedBody =
selection?.kind === 'body'
? snapshot.bodies.find((body) => body.id === selection.bodyId)
: undefined;
const selectedJoint =
selection?.kind === 'joint'
? snapshot.joints.find((joint) => joint.id === selection.jointId)
: undefined;
const mapSelected =
(selection?.kind === 'map' || selection?.kind === 'map-object') &&
selection.mapAssetId === props.activeMapAssetId;
const selectedMapObject =
selection?.kind === 'map-object' && mapSelected
? (props.editorDraftDocument ?? props.editorDocument)?.objects.find(
(object) => object.id === selection.objectId,
)
: undefined;
const mapPanel = (
<PhysicalMapPanel
key={`${props.activeMapAssetId ?? 'none'}:${
props.mapSelection.kind === 'project'
? props.mapSelection.descriptorPath
: props.mapSelection.kind
}`}
value={props.mapSelection}
maps={props.maps}
rootBodies={snapshot.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}
editorDraftDocument={props.editorDraftDocument}
sceneDirty={props.mapSceneDirty}
onEditorPreview={props.onEditorPreview}
onEditorDraftChange={props.onEditorDraftChange}
onEditorApply={props.onEditorApply}
onEditorExport={props.onEditorExport}
onEditorConvert={props.onEditorConvert}
selectedEditorObjectId={
selection?.kind === 'map-object' && mapSelected ? selection.objectId : undefined
}
onEditorBindInteraction={props.onEditorBindInteraction}
onEditorSelect={props.onEditorSelect}
onEditorSessionStateChange={props.onEditorSessionStateChange}
onEditorSurfaceHeight={props.onEditorSurfaceHeight}
onMapDisplay={props.onMapDisplay}
onDraft={props.onMapDraft}
onApply={props.onApplyMap}
/>
);
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) => {
setInternalTab(value);
props.onActiveTabChange?.(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={`${props.activeMapAssetId ?? 'none'}:${
props.mapSelection.kind === 'project'
? props.mapSelection.descriptorPath
: props.mapSelection.kind
}`}
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}
editorDraftDocument={props.editorDraftDocument}
sceneDirty={props.mapSceneDirty}
onEditorPreview={props.onEditorPreview}
onEditorDraftChange={props.onEditorDraftChange}
onEditorApply={props.onEditorApply}
onEditorExport={props.onEditorExport}
onEditorConvert={props.onEditorConvert}
onEditorBindInteraction={props.onEditorBindInteraction}
onEditorSelect={props.onEditorSelect}
onEditorTransformMode={props.onEditorTransformMode}
onEditorSnapping={props.onEditorSnapping}
onEditorSurfaceHeight={props.onEditorSurfaceHeight}
onMapDisplay={props.onMapDisplay}
onDraft={props.onMapDraft}
onApply={props.onApplyMap}
/>
),
},
]}
let inspector: ReactNode;
if (selectedBody) {
inspector = (
<BodyInspector
body={selectedBody}
snapshot={snapshot}
viewerSelection={props.viewerSelection}
showCollision={props.showCollision}
onShowCollision={props.onShowCollision}
onSelectJoint={props.onSelectJoint}
/>
);
} else if (selectedJoint) {
inspector = (
<JointInspector
joint={selectedJoint}
snapshot={snapshot}
loading={props.loading}
ignoreJointLimits={props.ignoreJointLimits}
jointAdvanced={props.jointAdvanced}
angleUnit={props.angleUnit}
onResetJoints={props.onResetJoints}
onToggleJointLimits={props.onToggleJointLimits}
onToggleAdvanced={props.onToggleAdvanced}
onToggleAngleUnit={props.onToggleAngleUnit}
onJoint={props.onJoint}
onActuator={props.onActuator}
onActuatorParameters={props.onActuatorParameters}
/>
);
} else if (mapSelected) {
const mapName = selectedMapObject?.name ?? props.activeMapAssetName ?? '地图实例';
inspector = (
<>
<InspectorIdentity
icon={selectedMapObject ? <Box /> : <MapPinned />}
eyebrow={selectedMapObject ? 'Map / Object' : 'Map / Instance'}
name={mapName}
meta={
selectedMapObject
? `${selectedMapObject.type} · ${props.activeMapAssetName ?? '地图'}`
: props.mapSelection.kind === 'builtin'
? '参数化地形'
: '工程地图包实例'
}
/>
{mapPanel}
</>
);
} else {
inspector = (
<>
<ModelSummaryInspector
snapshot={snapshot}
selectedFormat={props.selectedFormat}
loading={props.loading}
urdfMode={props.urdfMode}
baseMode={props.baseMode}
showCollision={props.showCollision}
onUrdfMode={props.onUrdfMode}
onBaseMode={props.onBaseMode}
onShowCollision={props.onShowCollision}
/>
{props.mapSelection.kind === 'none' && mapPanel}
</>
);
}
return (
<SidebarPanel title={title} side="right" visible={props.visible} icon={icon}>
{tabs}
<div
hidden={props.workspaceTool !== null}
role="tabpanel"
aria-label="检查器"
className="min-h-0 flex-1 overflow-auto panel-scroll"
>
{inspector}
</div>
{props.workspaceTool && props.workspaceTools}
</SidebarPanel>
);
}
+141 -122
View File
@@ -1,17 +1,13 @@
import { useState } from 'react';
import { Box, FolderTree, Map as MapIcon } from 'lucide-react';
import { FolderTree, Library, Search } 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 { SimulationSnapshot } from '../../simulation/SimulationSession';
import { Button, Tabs } from '../../components/ui';
import { Button, Tabs, VerticalSplitPane } from '../../components/ui';
import {
DEFAULT_PHYSICAL_MAP_CONFIG,
type MapSelection,
@@ -24,35 +20,42 @@ import type {
MapObjectPlacementMode,
} from '../../map/editor/types';
import { MapAssetLibrary } from '../../map/MapAssetLibrary';
import type { EditorSelection } from '../editorSelection';
import { ProjectBreadcrumb } from './ProjectBreadcrumb';
import { SceneOutliner, countSceneSearchResults } from './SceneOutliner';
import { SidebarPanel } from './SidebarPanel';
import { TreeSearchField } from './TreeSearchField';
export type ProjectResourceTab = 'assets' | 'files';
export function ProjectSidebar({
projectName,
files,
entries,
selectedEntry,
snapshot,
selection,
loading,
visible = true,
nativeUrdf,
mapSelection,
maps,
placedMaps,
activeMapId,
pendingSceneChangeCount,
pendingSceneIds,
editorDocument,
editorDocuments,
assetPlacementMode,
activeTab,
onActiveTabChange,
onRemove,
onSelectEntry,
onSelectBody,
onSelectJoint,
onJointHover,
onAddMapAsset,
onAddProjectMap,
onSelectTerrain,
onAssetPlacementModeChange,
onApplyScene,
onDiscardScene,
onSelectMap,
@@ -64,21 +67,23 @@ export function ProjectSidebar({
entries: ModelEntry[];
selectedEntry?: string;
snapshot?: SimulationSnapshot;
selection: EditorSelection | null;
loading: boolean;
visible?: boolean;
nativeUrdf: boolean;
mapSelection: MapSelection;
maps: MapEntry[];
placedMaps: PlacedMapAsset[];
activeMapId?: string;
pendingSceneChangeCount: number;
pendingSceneIds: string[];
editorDocument: EditableMapDocument | null;
editorDocuments?: ReadonlyMap<string, EditableMapDocument>;
activeTab?: 'project' | 'structure' | 'assets';
onActiveTabChange?: (value: 'project' | 'structure' | 'assets') => void;
editorDocuments: ReadonlyMap<string, EditableMapDocument>;
assetPlacementMode?: MapObjectPlacementMode;
activeTab?: ProjectResourceTab;
onActiveTabChange?: (value: ProjectResourceTab) => void;
onRemove: () => void;
onSelectEntry: (path: string) => void;
onSelectBody: (bodyId: number) => void;
onSelectJoint: (jointId: number, bodyId: number) => void;
onJointHover: (jointId: number | null) => void;
onAddMapAsset: (
type: EditableMapObjectType,
@@ -86,22 +91,22 @@ export function ProjectSidebar({
) => void | Promise<void>;
onAddProjectMap: (descriptorPath: string) => void;
onSelectTerrain: (preset: SystemTerrainPreset) => void;
onAssetPlacementModeChange?: (mode: MapObjectPlacementMode) => void;
onApplyScene: () => void;
onDiscardScene: () => void;
onSelectMap: (id: string) => void;
onRemoveMap: (id: string) => void;
onSelectMapObject: (mapId: string, objectId: 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;
const [internalTab, setInternalTab] = useState<ProjectResourceTab>('assets');
const [fileQuery, setFileQuery] = useState('');
const [sceneQuery, setSceneQuery] = useState('');
const tab = activeTab ?? internalTab;
const fileMatches = countProjectSearchResults(files, fileQuery);
const sceneMatches = countSceneSearchResults(snapshot, placedMaps, editorDocuments, sceneQuery);
return (
<SidebarPanel title="资产与结构" side="left" visible={visible}>
<SidebarPanel title="场景大纲与资产中心" side="left" visible={visible} icon={<Library />}>
{projectName ? (
<>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2.5">
@@ -109,115 +114,129 @@ export function ProjectSidebar({
<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 className="mt-0.5 truncate text-[10px] text-text-tertiary">
{snapshot
? `${snapshot.bodies.filter((body) => body.id > 0 && !body.name.startsWith('__platform_map_')).length} Body`
: '模型未加载'}{' '}
· {placedMaps.length} · {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}
<VerticalSplitPane
storageKey="mujoco-project-sidebar-scene-ratio"
separatorLabel="调整场景大纲与资源面板高度"
defaultRatio={0.42}
minFirstSize={160}
minSecondSize={160}
firstClassName="flex flex-col"
secondClassName="flex flex-col"
first={
<>
<div className="flex h-8 shrink-0 items-center gap-2 px-3 text-[11px] font-semibold uppercase tracking-wide text-text-tertiary">
<Search className="h-3.5 w-3.5" aria-hidden="true" />
</div>
<TreeSearchField
value={sceneQuery}
onChange={setSceneQuery}
resultCount={sceneMatches}
label="搜索场景对象"
placeholder="搜索 Body、关节或地图物体…"
/>
<SceneOutliner
snapshot={snapshot}
maps={placedMaps}
documents={editorDocuments}
selection={selection}
query={sceneQuery}
loading={loading}
pendingSceneChangeCount={pendingSceneChangeCount}
pendingSceneIds={pendingSceneIds}
onSelectBody={onSelectBody}
onSelectJoint={onSelectJoint}
onJointHover={onJointHover}
onSelectMap={onSelectMap}
onSelectMapObject={onSelectMapObject}
onRemoveMap={onRemoveMap}
onApplyScene={onApplyScene}
onDiscardScene={onDiscardScene}
/>
</>
}
second={
<Tabs
label="资产与工程文件"
value={tab}
onValueChange={(value) => {
setInternalTab(value);
onActiveTabChange?.(value);
}}
items={[
{
value: 'assets',
label: '资产库',
icon: <Library 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
}
maps={maps}
placementMode={assetPlacementMode}
onPlacementModeChange={onAssetPlacementModeChange}
onAdd={onAddMapAsset}
onAddProjectMap={onAddProjectMap}
onSelectTerrain={onSelectTerrain}
/>
</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}
documents={editorDocuments}
maps={maps}
placedMaps={placedMaps}
activeMapId={activeMapId}
pendingSceneChangeCount={pendingSceneChangeCount}
pendingSceneIds={pendingSceneIds}
onAdd={onAddMapAsset}
onAddProjectMap={onAddProjectMap}
onSelectTerrain={onSelectTerrain}
onApplyScene={onApplyScene}
onDiscardScene={onDiscardScene}
onSelectMap={onSelectMap}
onRemoveMap={onRemoveMap}
onSelectObject={onSelectMapObject}
/>
),
},
]}
),
},
{
value: 'files',
label: '工程文件',
icon: <FolderTree className="h-3.5 w-3.5" />,
content: (
<>
<ProjectBreadcrumb
projectName={projectName}
entries={entries}
selectedEntry={selectedEntry}
loading={loading}
onSelect={onSelectEntry}
/>
<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>
</>
),
},
]}
/>
}
/>
</>
) : (
<div className="p-4 text-center text-sm text-text-tertiary"></div>
<div className="p-4 text-center text-sm text-text-tertiary"></div>
)}
</SidebarPanel>
);
@@ -0,0 +1,65 @@
import { Database, PanelRight, SlidersHorizontal, type LucideIcon } from 'lucide-react';
export type RightSidebarView = 'inspector' | 'controls' | 'data';
const VIEWS: ReadonlyArray<{
value: RightSidebarView;
label: string;
icon: LucideIcon;
}> = [
{ value: 'inspector', label: '检查器', icon: PanelRight },
{ value: 'controls', label: '控制台', icon: SlidersHorizontal },
{ value: 'data', label: '数据录制', icon: Database },
];
export function RightSidebarTabs({
value,
onChange,
}: {
value: RightSidebarView;
onChange: (value: RightSidebarView) => void;
}) {
return (
<div
role="tablist"
aria-label="右侧工作区视图"
className="grid shrink-0 grid-cols-3 gap-1 border-b border-border bg-panel-muted/40 p-2"
>
{VIEWS.map((view, index) => {
const Icon = view.icon;
const selected = value === view.value;
return (
<button
key={view.value}
type="button"
role="tab"
aria-selected={selected}
tabIndex={selected ? 0 : -1}
className={`flex min-w-0 items-center justify-center gap-1.5 rounded-md px-1 py-2 text-[11px] font-medium transition-colors ${
selected
? 'bg-panel text-accent shadow-sm'
: 'text-text-tertiary hover:bg-element-hover hover:text-text-primary'
}`}
onClick={() => onChange(view.value)}
onKeyDown={(event) => {
let nextIndex: number | undefined;
if (event.key === 'ArrowLeft') nextIndex = (index + VIEWS.length - 1) % VIEWS.length;
else if (event.key === 'ArrowRight') nextIndex = (index + 1) % VIEWS.length;
else if (event.key === 'Home') nextIndex = 0;
else if (event.key === 'End') nextIndex = VIEWS.length - 1;
if (nextIndex === undefined) return;
event.preventDefault();
onChange(VIEWS[nextIndex].value);
const tabs =
event.currentTarget.parentElement?.querySelectorAll<HTMLElement>('[role="tab"]');
tabs?.[nextIndex]?.focus();
}}
>
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="truncate">{view.label}</span>
</button>
);
})}
</div>
);
}
@@ -0,0 +1,120 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type { EditableMapDocument } from '../../map/editor/types';
import type { SimulationSnapshot } from '../../simulation/SimulationSession';
import { SceneOutliner, countSceneSearchResults } from './SceneOutliner';
const snapshot = {
bodies: [
{ id: 0, name: 'world', parentId: 0 },
{ id: 1, name: 'base', parentId: 0 },
{ id: 2, name: 'arm', parentId: 1 },
],
joints: [
{
id: 7,
name: 'arm_joint',
bodyId: 2,
type: 3,
value: 0,
min: -1,
max: 1,
limitMin: -1,
limitMax: 1,
limited: true,
limitsIgnored: false,
editable: true,
axis: [0, 0, 1],
},
],
model: { nbody: 3 },
} as SimulationSnapshot;
const document: EditableMapDocument = {
schemaVersion: 1,
mapId: 'warehouse',
revision: 0,
objects: [
{
id: 'crate-1',
name: '木箱',
type: 'box',
pose: { position: [0, 0, 0.5], quaternion: [1, 0, 0, 0] },
parameters: { sizeX: 1, sizeY: 1, sizeZ: 1 },
friction: [1, 0.005, 0.0001],
rgba: [0.5, 0.6, 0.7, 1],
placementMode: 'auto_ground',
enabled: true,
},
],
spawnPoints: [],
};
const maps = [
{
id: 'map-a',
name: '仓库',
selection: { kind: 'project' as const, descriptorPath: 'maps/warehouse/map.json' },
},
];
const documents = new Map([['maps/warehouse/map.json', document]]);
function renderOutliner(overrides: Record<string, unknown> = {}) {
const props = {
snapshot,
maps,
documents,
selection: null,
query: '',
loading: false,
pendingSceneChangeCount: 0,
pendingSceneIds: [],
onSelectBody: vi.fn(),
onSelectJoint: vi.fn(),
onJointHover: vi.fn(),
onSelectMap: vi.fn(),
onSelectMapObject: vi.fn(),
onRemoveMap: vi.fn(),
onApplyScene: vi.fn(),
onDiscardScene: vi.fn(),
...overrides,
};
render(<SceneOutliner {...props} />);
return props;
}
describe('SceneOutliner', () => {
it('在一棵场景大纲中统一展示机器人、地图实例和地图物体', () => {
renderOutliner();
expect(screen.getByLabelText('场景资产树')).toHaveTextContent('机器人');
expect(screen.getByLabelText('场景资产树')).toHaveTextContent('地图与环境');
expect(screen.getByRole('treeitem', { name: /arm_joint/ })).toBeVisible();
expect(screen.getByRole('treeitem', { name: /木箱/ })).toBeVisible();
expect(countSceneSearchResults(snapshot, maps, documents, '木箱')).toBe(2);
});
it('把所有选择统一上报为稳定 id', () => {
const props = renderOutliner();
fireEvent.click(screen.getByRole('treeitem', { name: 'base' }));
fireEvent.click(screen.getByRole('treeitem', { name: /arm_joint/ }));
fireEvent.click(screen.getByRole('treeitem', { name: /^仓库/ }));
fireEvent.click(screen.getByRole('treeitem', { name: /木箱/ }));
expect(props.onSelectBody).toHaveBeenCalledWith(1);
expect(props.onSelectJoint).toHaveBeenCalledWith(7, 2);
expect(props.onSelectMap).toHaveBeenCalledWith('map-a');
expect(props.onSelectMapObject).toHaveBeenCalledWith('map-a', 'crate-1');
});
it('在大纲中集中处理场景草稿与实例删除', () => {
const props = renderOutliner({
pendingSceneChangeCount: 2,
pendingSceneIds: ['map-a'],
});
expect(screen.getByRole('status')).toHaveTextContent('2 项场景更改待应用');
fireEvent.click(screen.getByRole('button', { name: '应用场景' }));
fireEvent.click(screen.getByRole('button', { name: '放弃更改' }));
fireEvent.click(screen.getByRole('button', { name: '删除地图实例 仓库' }));
expect(props.onApplyScene).toHaveBeenCalledOnce();
expect(props.onDiscardScene).toHaveBeenCalledOnce();
expect(props.onRemoveMap).toHaveBeenCalledWith('map-a');
});
});
@@ -0,0 +1,275 @@
import { useMemo } from 'react';
import { Box, Bot, Layers3, MapPinned, Trash2, Zap } from 'lucide-react';
import { Button, EmptySearchState, SearchHighlight } from '../../components/ui';
import type { EditableMapDocument } from '../../map/editor/types';
import type { PlacedMapAsset } from '../../map/types';
import {
countModelStructureSearchResults,
ModelStructureTree,
} from '../../project/ModelStructureTree';
import type { SimulationSnapshot } from '../../simulation/SimulationSession';
import type { EditorSelection } from '../editorSelection';
interface VisibleMap {
asset: PlacedMapAsset;
objects: EditableMapDocument['objects'];
}
function mapDocument(
asset: PlacedMapAsset,
documents: ReadonlyMap<string, EditableMapDocument>,
): EditableMapDocument | undefined {
return asset.selection.kind === 'project'
? documents.get(asset.selection.descriptorPath)
: undefined;
}
// eslint-disable-next-line react-refresh/only-export-components
export function countSceneSearchResults(
snapshot: SimulationSnapshot | undefined,
maps: readonly PlacedMapAsset[],
documents: ReadonlyMap<string, EditableMapDocument>,
query: string,
): number {
const normalized = query.trim().toLocaleLowerCase();
const robotBodies =
snapshot?.bodies.filter((body) => body.id > 0 && !body.name.startsWith('__platform_map_')) ??
[];
const robotBodyIds = new Set(robotBodies.map((body) => body.id));
const robotCount = snapshot
? countModelStructureSearchResults(
robotBodies,
snapshot.joints.filter((joint) => robotBodyIds.has(joint.bodyId)),
normalized,
)
: 0;
if (!normalized)
return (
robotCount +
maps.reduce((count, map) => count + 1 + (mapDocument(map, documents)?.objects.length ?? 0), 0)
);
return (
robotCount +
maps.reduce((count, map) => {
const document = mapDocument(map, documents);
if (map.name.toLocaleLowerCase().includes(normalized))
return count + 1 + (document?.objects.length ?? 0);
const objectCount =
document?.objects.filter((object) =>
`${object.name} ${object.type}`.toLocaleLowerCase().includes(normalized),
).length ?? 0;
return count + (objectCount ? 1 + objectCount : 0);
}, 0)
);
}
export function SceneOutliner({
snapshot,
maps,
documents,
selection,
query,
loading,
pendingSceneChangeCount,
pendingSceneIds,
onSelectBody,
onSelectJoint,
onJointHover,
onSelectMap,
onSelectMapObject,
onRemoveMap,
onApplyScene,
onDiscardScene,
}: {
snapshot?: SimulationSnapshot;
maps: readonly PlacedMapAsset[];
documents: ReadonlyMap<string, EditableMapDocument>;
selection: EditorSelection | null;
query: string;
loading: boolean;
pendingSceneChangeCount: number;
pendingSceneIds: readonly string[];
onSelectBody: (bodyId: number) => void;
onSelectJoint: (jointId: number, bodyId: number) => void;
onJointHover: (jointId: number | null) => void;
onSelectMap: (mapId: string) => void;
onSelectMapObject: (mapId: string, objectId: string) => void;
onRemoveMap: (mapId: string) => void;
onApplyScene: () => void;
onDiscardScene: () => void;
}) {
const normalized = query.trim().toLocaleLowerCase();
const robotBodies = useMemo(
() =>
snapshot?.bodies.filter((body) => body.id > 0 && !body.name.startsWith('__platform_map_')) ??
[],
[snapshot?.bodies],
);
const robotBodyIds = useMemo(() => new Set(robotBodies.map((body) => body.id)), [robotBodies]);
const robotJoints = useMemo(
() => snapshot?.joints.filter((joint) => robotBodyIds.has(joint.bodyId)) ?? [],
[snapshot?.joints, robotBodyIds],
);
const visibleMaps = useMemo<VisibleMap[]>(
() =>
maps.flatMap((asset) => {
const objects = mapDocument(asset, documents)?.objects ?? [];
if (!normalized || asset.name.toLocaleLowerCase().includes(normalized))
return [{ asset, objects }];
const matches = objects.filter((object) =>
`${object.name} ${object.type}`.toLocaleLowerCase().includes(normalized),
);
return matches.length ? [{ asset, objects: matches }] : [];
}),
[documents, maps, normalized],
);
const hasRobotMatches = snapshot
? countModelStructureSearchResults(robotBodies, robotJoints, normalized) > 0
: false;
const pending = new Set(pendingSceneIds);
return (
<section
aria-label="场景资产树"
className="min-h-0 flex-1 overflow-auto px-2 pb-2 panel-scroll"
>
{pendingSceneChangeCount > 0 && (
<div
role="status"
className="mb-2 rounded-lg border border-accent/30 bg-accent-soft p-2.5 text-[10px] text-text-secondary"
>
<div className="flex items-center gap-1.5 font-medium text-accent">
<Zap className="h-3.5 w-3.5" aria-hidden="true" />
{pendingSceneChangeCount}
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
<Button variant="primary" disabled={loading} onClick={onApplyScene}>
</Button>
<Button variant="ghost" disabled={loading} onClick={onDiscardScene}>
</Button>
</div>
</div>
)}
{snapshot && hasRobotMatches && (
<details open className="group/robot">
<summary className="flex cursor-pointer select-none items-center gap-2 rounded px-1.5 py-1.5 text-xs font-semibold text-text-primary hover:bg-element-hover">
<Bot className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate"></span>
<span className="technical-value text-[9px] font-normal text-text-tertiary">
{robotBodies.length} Body
</span>
</summary>
<div className="ml-2 border-l border-border pl-1">
<ModelStructureTree
bodies={robotBodies}
joints={robotJoints}
query={query}
selectedBodyId={selection?.kind === 'body' ? selection.bodyId : undefined}
selectedJointId={selection?.kind === 'joint' ? selection.jointId : undefined}
onSelectBody={onSelectBody}
onSelectJoint={(joint) => onSelectJoint(joint.id, joint.bodyId)}
onJointHover={onJointHover}
/>
</div>
</details>
)}
{visibleMaps.length > 0 && (
<details open className="mt-1">
<summary className="flex cursor-pointer select-none items-center gap-2 rounded px-1.5 py-1.5 text-xs font-semibold text-text-primary hover:bg-element-hover">
<Layers3 className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate"></span>
<span className="technical-value text-[9px] font-normal text-text-tertiary">
{maps.length}
</span>
</summary>
<ul role="tree" aria-label="地图物体树" className="ml-2 border-l border-border pl-1">
{visibleMaps.map(({ asset, objects }) => {
const mapSelected =
(selection?.kind === 'map' || selection?.kind === 'map-object') &&
selection.mapAssetId === asset.id;
return (
<li key={asset.id} role="none">
<div
className={`flex items-center rounded ${
selection?.kind === 'map' && mapSelected
? 'bg-accent-soft text-accent'
: 'text-text-secondary hover:bg-element-hover'
}`}
>
<button
type="button"
role="treeitem"
aria-selected={selection?.kind === 'map' && mapSelected}
className="flex min-w-0 flex-1 items-center gap-1.5 px-1.5 py-1 text-left text-xs"
disabled={loading}
onClick={() => onSelectMap(asset.id)}
>
<MapPinned className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">
<SearchHighlight text={asset.name} query={query} />
</span>
{pending.has(asset.id) && (
<span className="rounded bg-warning/10 px-1 text-[9px] text-warning">
</span>
)}
</button>
<button
type="button"
aria-label={`删除地图实例 ${asset.name}`}
className="mr-1 rounded p-1 text-text-tertiary hover:bg-danger/10 hover:text-danger"
disabled={loading}
onClick={() => onRemoveMap(asset.id)}
>
<Trash2 className="h-3 w-3" aria-hidden="true" />
</button>
</div>
{objects.length > 0 && (
<ul role="group" className="ml-3 border-l border-border pl-1">
{objects.map((object) => {
const selected =
selection?.kind === 'map-object' &&
selection.mapAssetId === asset.id &&
selection.objectId === object.id;
return (
<li role="none" key={object.id}>
<button
type="button"
role="treeitem"
aria-selected={selected}
disabled={loading}
className={`flex w-full items-center gap-1.5 rounded px-1.5 py-1 text-left text-[11px] ${
selected
? 'bg-accent-soft text-accent'
: 'text-text-secondary hover:bg-element-hover'
}`}
onClick={() => onSelectMapObject(asset.id, object.id)}
>
<Box className="h-3 w-3 shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">
<SearchHighlight text={object.name} query={query} />
</span>
<span className="text-[9px] text-text-tertiary">{object.type}</span>
</button>
</li>
);
})}
</ul>
)}
</li>
);
})}
</ul>
</details>
)}
{!hasRobotMatches && !visibleMaps.length && (
<EmptySearchState label={normalized ? '没有匹配的场景对象' : '加载模型后显示场景层级'} />
)}
</section>
);
}
@@ -1,10 +1,18 @@
import { Dialog, Kbd, Separator } from '../../components/ui';
const shortcuts = [
['Space', '播放 / 暂停'],
['R', '重置仿真'],
['R', '重置仿真(非地图编辑)'],
['1', '选择模式'],
['2', '关节拖动'],
['3', '外力施加'],
['W / E / R', '地图移动 / 旋转 / 缩放'],
['F', '聚焦当前对象'],
['Delete', '删除当前地图对象'],
['Ctrl / Cmd + S', '保存并编译地图草稿'],
['Ctrl / Cmd + Z', '撤销地图编辑'],
['Ctrl / Cmd + Y / Shift + Z', '重做地图编辑'],
['G', '切换地图网格吸附'],
['Esc', '取消选择或关闭浮层'],
];
export function ShortcutHelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
return (
@@ -29,7 +37,8 @@ export function ShortcutHelpDialog({ open, onClose }: { open: boolean; onClose:
<li></li>
<li></li>
<li></li>
<li></li>
<li> Inspector 姿</li>
<li></li>
</ul>
</section>
</Dialog>
@@ -7,20 +7,22 @@ export function SidebarPanel({
side,
children,
visible = true,
icon,
}: {
title: string;
side: 'left' | 'right';
children: ReactNode;
visible?: boolean;
icon?: ReactNode;
}) {
return (
<ResizablePanel side={side} storageKey={`mujoco-${side}-sidebar-width`} visible={visible}>
<aside
className={`flex h-full w-full min-w-0 flex-col overflow-hidden bg-panel ${side === 'left' ? 'border-r' : 'border-l'} border-border`}
>
<h2 className="flex h-10 shrink-0 items-center gap-2 border-b border-border bg-panel/95 px-3 text-sm font-semibold tracking-tight text-text-primary">
<span className="grid h-6 w-6 place-items-center rounded-md bg-accent-soft text-accent">
<Settings2 aria-hidden="true" className="h-3.5 w-3.5" />
<h2 className="flex h-10 shrink-0 items-center gap-2 border-b border-border bg-gradient-to-r from-panel via-surface/70 to-panel px-3 text-sm font-semibold tracking-tight text-text-primary shadow-[inset_0_-1px_0_rgb(255_255_255/0.025)]">
<span className="grid h-6 w-6 place-items-center rounded-md bg-accent-soft text-accent [&>svg]:h-3.5 [&>svg]:w-3.5">
{icon ?? <Settings2 aria-hidden="true" className="h-3.5 w-3.5" />}
</span>
{title}
</h2>
@@ -1,4 +1,4 @@
import { Crosshair, Hand, MousePointer2, RotateCcw } from 'lucide-react';
import { Crosshair, Hand, MapPinned, MousePointer2, RotateCcw } from 'lucide-react';
import type { InteractionMode } from '../../viewer/MuJoCoViewer';
import type { ViewerDisplayOptions } from '../../viewer/displayOptions';
import { IconButton, ToolbarToggleGroup, type ToolbarItem } from '../../components/ui';
@@ -11,12 +11,18 @@ const tools: ToolbarItem<InteractionMode>[] = [
export function ViewerToolDock({
mode,
display,
mapEditContext,
onModeChange,
onDisplayChange,
onResetCamera,
}: {
mode: InteractionMode;
display: ViewerDisplayOptions;
mapEditContext?: {
active: boolean;
label: string;
onActivate: () => void;
};
onModeChange: (mode: InteractionMode) => void;
onDisplayChange: (next: ViewerDisplayOptions) => void;
onResetCamera: () => void;
@@ -24,6 +30,18 @@ export function ViewerToolDock({
return (
<div className="flex items-center gap-1">
<ToolbarToggleGroup items={tools} value={mode} onChange={onModeChange} label="视口交互模式" />
{mapEditContext && (
<button
type="button"
aria-label={`地图编辑联动:${mapEditContext.label}`}
aria-pressed={mapEditContext.active}
onClick={mapEditContext.onActivate}
className={`hidden h-7 items-center gap-1.5 rounded-lg border px-2 text-[10px] font-semibold transition-colors md:flex ${mapEditContext.active ? 'border-accent/40 bg-accent-soft text-accent' : 'border-border bg-surface/80 text-text-tertiary hover:bg-element-hover hover:text-text-primary'}`}
>
<MapPinned className="h-3.5 w-3.5" aria-hidden="true" />
{mapEditContext.label}
</button>
)}
<ViewerDisplayPopover value={display} onChange={onDisplayChange} />
<IconButton tooltip="相机复位" aria-label="相机复位" onClick={onResetCamera}>
<RotateCcw className="h-3.5 w-3.5" />
@@ -16,11 +16,13 @@ export function ViewportHUD({
mode,
selection,
ready,
mapEditing = false,
}: {
paused: boolean;
mode: InteractionMode;
selection: ViewerSelection | null;
ready: boolean;
mapEditing?: boolean;
}) {
if (!ready) return null;
return (
@@ -45,7 +47,7 @@ export function ViewportHUD({
</div>
<div
aria-label="视口操作提示"
className="pointer-events-none absolute bottom-3 left-1/2 z-10 hidden -translate-x-1/2 items-center gap-2 whitespace-nowrap rounded-full border border-border-strong bg-panel px-3 py-1.5 text-[10px] text-text-secondary shadow-xl lg:flex"
className="pointer-events-none absolute bottom-14 left-1/2 z-10 hidden -translate-x-1/2 items-center gap-2 whitespace-nowrap rounded-full border border-border-strong bg-panel/85 px-3 py-1.5 text-[10px] text-text-secondary shadow-xl backdrop-blur lg:flex"
>
<Mouse aria-hidden="true" className="h-3 w-3 text-text-secondary" />
<span>{primaryGestures[mode]}</span>
@@ -57,14 +59,28 @@ export function ViewportHUD({
·
</span>
<span></span>
{mode !== 'select' && (
{mapEditing && mode === 'select' ? (
<>
<span aria-hidden="true" className="text-border-strong">
·
</span>
<Kbd>1</Kbd>
<span></span>
<Kbd>W</Kbd>
<Kbd>E</Kbd>
<Kbd>R</Kbd>
<span></span>
<Kbd>F</Kbd>
<span></span>
</>
) : (
mode !== 'select' && (
<>
<span aria-hidden="true" className="text-border-strong">
·
</span>
<Kbd>1</Kbd>
<span></span>
</>
)
)}
</div>
</>
@@ -75,7 +75,7 @@ export function WorkbenchHeader({
onToggleFullscreen: () => void;
}) {
return (
<header className="relative z-40 grid h-10 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(max-content,1fr)] items-center gap-2 border-b border-border bg-panel/95 px-2.5 shadow-[0_1px_0_rgba(255,255,255,0.02)] backdrop-blur-md">
<header className="workbench-header relative z-40 grid h-10 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(max-content,1fr)] items-center gap-2 border-b border-border px-2.5 shadow-[0_1px_0_rgba(255,255,255,0.02)] backdrop-blur-md">
<div className="flex min-w-0 items-center gap-1">
<div className="mr-2 hidden items-center gap-2 border-r border-border pr-3 xl:flex">
<span className="grid h-7 w-7 place-items-center rounded-lg border border-accent/25 bg-accent-soft text-accent shadow-sm">
@@ -170,8 +170,8 @@ export function WorkbenchHeader({
<PanelLeft className="h-4 w-4" />
</IconButton>
<IconButton
tooltip={rightOpen ? '隐藏属性面板' : '显示属性面板'}
aria-label={rightOpen ? '隐藏属性面板' : '显示属性面板'}
tooltip={rightOpen ? '隐藏右侧面板' : '显示右侧面板'}
aria-label={rightOpen ? '隐藏右侧面板' : '显示右侧面板'}
aria-expanded={rightOpen}
onClick={onToggleRight}
>
@@ -0,0 +1,80 @@
import type { ComponentProps } from 'react';
import { render, screen } from '@testing-library/react';
import type { SimulationSnapshot } from '../../simulation/SimulationSession';
import { WorkspaceToolsPanel } from './WorkspaceToolsPanel';
const snapshot = {
actuators: [],
joints: [],
controller: { enabled: true },
rlPolicy: { enabled: false },
} as unknown as SimulationSnapshot;
const noop = () => {};
function props(): ComponentProps<typeof WorkspaceToolsPanel> {
return {
active: 'controls',
snapshot,
loading: false,
ignoreJointLimits: false,
jointAdvanced: false,
angleUnit: 'rad',
forceScale: 50,
controllerPaths: [],
controllerStatus: { enabled: true } as ComponentProps<
typeof WorkspaceToolsPanel
>['controllerStatus'],
policyPaths: [],
policyStatus: { enabled: false } as ComponentProps<typeof WorkspaceToolsPanel>['policyStatus'],
onResetJoints: noop,
onToggleJointLimits: noop,
onToggleAdvanced: noop,
onToggleAngleUnit: noop,
onActuator: noop,
onActuatorParameters: noop,
onJoint: noop,
onForceScale: noop,
onSelectControllerPath: noop,
onLoadControllerPath: noop,
onImportController: noop,
onToggleController: noop,
onControllerCommand: noop,
onRemoveController: noop,
onSelectPolicyPath: noop,
onLoadPolicyPath: noop,
onImportPolicy: noop,
onTogglePolicy: noop,
onPolicyCommand: noop,
onRemovePolicy: noop,
onDataRecorderConfigure: noop,
onDataRecordingStart: noop,
onDataRecordingStop: noop,
onDataRecordingClear: noop,
onDataRecordingExport: noop,
};
}
describe('WorkspaceToolsPanel', () => {
it('按实时控制到自动化流程排序,并默认折叠所有控制台组件', () => {
render(<WorkspaceToolsPanel {...props()} />);
const sections = screen
.getAllByRole('button')
.filter((button) => button.hasAttribute('aria-expanded'));
const expected = [
['执行器实时控制', '控制'],
['关节姿态调试', '调试'],
['外力交互参数', '交互'],
['Python 脚本控制', '控制', '运行'],
['ONNX 策略运行', '推理', '停止'],
['强化学习任务', '训练'],
];
expect(sections).toHaveLength(expected.length);
sections.forEach((section, index) => {
expect(section).toHaveAttribute('aria-expanded', 'false');
expected[index].forEach((label) => expect(section).toHaveTextContent(label));
});
});
});
@@ -0,0 +1,295 @@
import type { ControllerCommand, ControllerStatus } from '../../controller/types';
import { PythonControllerPanel } from '../../controller/PythonControllerPanel';
import type { RLCommand, RLPolicyStatus } from '../../rl/types';
import { RLPolicyPanel } from '../../rl/RLPolicyPanel';
import { ActuatorControl, ControlSlider } from '../../simulation/ActuatorControl';
import type { ActuatorParameters, SimulationSnapshot } from '../../simulation/SimulationSession';
import type { DataRecorderConfig } from '../../telemetry/DataRecorder';
import { DataRecordingPanel } from '../../telemetry/DataRecordingPanel';
import { LocalTrainingPanel } from '../../training/LocalTrainingPanel';
import { Badge, Button, CollapsibleSection } from '../../components/ui';
import type { RightSidebarView } from './RightSidebarTabs';
export type WorkspaceTool = Exclude<RightSidebarView, 'inspector'>;
function ConsoleSectionBadges({
category,
count,
state,
running = false,
}: {
category: string;
count?: number;
state?: string;
running?: boolean;
}) {
return (
<span className="flex shrink-0 items-center gap-1">
<Badge tone="accent">{category}</Badge>
{count !== undefined && <Badge>{count}</Badge>}
{state && <Badge tone={running ? 'success' : 'neutral'}>{state}</Badge>}
</span>
);
}
export function WorkspaceToolsPanel({
active,
snapshot,
loading,
ignoreJointLimits,
jointAdvanced,
angleUnit,
forceScale,
controllerPaths,
selectedControllerPath,
controllerStatus,
policyPaths,
selectedPolicyPath,
policyStatus,
onResetJoints,
onToggleJointLimits,
onToggleAdvanced,
onToggleAngleUnit,
onActuator,
onActuatorParameters,
onJoint,
onForceScale,
onSelectControllerPath,
onLoadControllerPath,
onImportController,
onToggleController,
onControllerCommand,
onRemoveController,
onSelectPolicyPath,
onLoadPolicyPath,
onImportPolicy,
onTogglePolicy,
onPolicyCommand,
onRemovePolicy,
onDataRecorderConfigure,
onDataRecordingStart,
onDataRecordingStop,
onDataRecordingClear,
onDataRecordingExport,
}: {
active: WorkspaceTool;
snapshot?: SimulationSnapshot;
loading: boolean;
ignoreJointLimits: boolean;
jointAdvanced: boolean;
angleUnit: 'rad' | 'deg';
forceScale: number;
controllerPaths: string[];
selectedControllerPath?: string;
controllerStatus?: ControllerStatus;
policyPaths: string[];
selectedPolicyPath?: string;
policyStatus?: RLPolicyStatus;
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;
onDataRecorderConfigure: (patch: Partial<DataRecorderConfig>) => void;
onDataRecordingStart: () => void;
onDataRecordingStop: () => void;
onDataRecordingClear: () => void;
onDataRecordingExport: (format: 'csv' | 'json') => void;
}) {
const resolvedControllerStatus = controllerStatus ?? snapshot?.controller;
const resolvedPolicyStatus = policyStatus ?? snapshot?.rlPolicy;
const controls = snapshot ? (
<>
<CollapsibleSection
title="执行器实时控制"
defaultOpen={false}
badge={<ConsoleSectionBadges category="控制" count={snapshot.actuators.length} />}
>
{snapshot.actuators.length ? (
snapshot.actuators.map((actuator) => (
<ActuatorControl
key={actuator.id}
actuator={actuator}
onControl={(value) => onActuator(actuator.id, value)}
onParameters={(parameters) => onActuatorParameters(actuator.id, parameters)}
/>
))
) : (
<p className="text-xs text-text-tertiary"></p>
)}
</CollapsibleSection>
<CollapsibleSection
title="关节姿态调试"
defaultOpen={false}
badge={<ConsoleSectionBadges category="调试" count={snapshot.joints.length} />}
>
<div className="mb-4 grid grid-cols-2 gap-2">
<Button onClick={onResetJoints}></Button>
<Button
variant={ignoreJointLimits ? 'primary' : 'secondary'}
aria-pressed={ignoreJointLimits}
onClick={onToggleJointLimits}
>
</Button>
<Button
variant={jointAdvanced ? 'primary' : 'secondary'}
aria-pressed={jointAdvanced}
onClick={onToggleAdvanced}
>
</Button>
<Button
variant={angleUnit === 'deg' ? 'primary' : 'secondary'}
aria-pressed={angleUnit === 'deg'}
onClick={onToggleAngleUnit}
>
{angleUnit === 'rad' ? 'rad 弧度制' : '° 角度制'}
</Button>
</div>
{snapshot.joints.map((joint) => {
const scale = joint.type === 3 && angleUnit === 'deg' ? 180 / Math.PI : 1;
const unit =
joint.type === 3 ? (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={jointAdvanced}
limited={joint.limited}
limitsIgnored={joint.limitsIgnored}
limitMin={joint.limitMin * scale}
limitMax={joint.limitMax * scale}
disabled={!joint.editable}
onChange={(value) => onJoint(joint.id, value / scale)}
/>
);
})}
</CollapsibleSection>
<CollapsibleSection
title="外力交互参数"
defaultOpen={false}
badge={<ConsoleSectionBadges category="交互" />}
>
<ControlSlider
label={`${forceScale.toFixed(0)} N/屏幕单位`}
value={forceScale}
min={5}
max={200}
onChange={onForceScale}
/>
<p className="text-xs text-text-tertiary">
</p>
</CollapsibleSection>
<CollapsibleSection
title="Python 脚本控制"
defaultOpen={false}
badge={
<ConsoleSectionBadges
category="控制"
state={
resolvedControllerStatus
? resolvedControllerStatus.enabled
? '运行'
: '停止'
: undefined
}
running={Boolean(resolvedControllerStatus?.enabled)}
/>
}
>
<PythonControllerPanel
paths={controllerPaths}
selectedPath={selectedControllerPath}
status={resolvedControllerStatus}
loading={loading}
onSelectPath={onSelectControllerPath}
onLoadPath={onLoadControllerPath}
onImport={onImportController}
onToggle={onToggleController}
onCommand={onControllerCommand}
onRemove={onRemoveController}
/>
</CollapsibleSection>
<CollapsibleSection
title="ONNX 策略运行"
defaultOpen={false}
badge={
<ConsoleSectionBadges
category="推理"
state={
resolvedPolicyStatus ? (resolvedPolicyStatus.enabled ? '运行' : '停止') : undefined
}
running={Boolean(resolvedPolicyStatus?.enabled)}
/>
}
>
<RLPolicyPanel
paths={policyPaths}
selectedPath={selectedPolicyPath}
status={resolvedPolicyStatus}
loading={loading}
onSelectPath={onSelectPolicyPath}
onLoadPath={onLoadPolicyPath}
onImport={onImportPolicy}
onToggle={onTogglePolicy}
onCommand={onPolicyCommand}
onRemove={onRemovePolicy}
/>
</CollapsibleSection>
<CollapsibleSection
title="强化学习任务"
defaultOpen={false}
badge={<ConsoleSectionBadges category="训练" />}
>
<LocalTrainingPanel onPolicyReady={onImportPolicy} />
</CollapsibleSection>
</>
) : (
<p className="p-4 text-sm text-text-tertiary"></p>
);
return (
<div
role="tabpanel"
aria-label={active === 'controls' ? '控制台' : '数据录制'}
className="min-h-0 flex-1 overflow-y-auto panel-scroll"
>
{active === 'controls' ? (
controls
) : snapshot ? (
<DataRecordingPanel
status={snapshot.telemetry}
bodies={snapshot.bodies}
onConfigure={onDataRecorderConfigure}
onStart={onDataRecordingStart}
onStop={onDataRecordingStop}
onClear={onDataRecordingClear}
onExport={onDataRecordingExport}
/>
) : (
<p className="p-4 text-sm text-text-tertiary"></p>
)}
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
export type EditorSelection =
| { kind: 'body'; bodyId: number }
| { kind: 'joint'; jointId: number; bodyId: number }
| { kind: 'map'; mapAssetId: string }
| { kind: 'map-object'; mapAssetId: string; objectId: string };
export function isMapEditorSelection(
selection: EditorSelection | null,
): selection is Extract<EditorSelection, { kind: 'map' | 'map-object' }> {
return selection?.kind === 'map' || selection?.kind === 'map-object';
}
@@ -0,0 +1,103 @@
import { render } from '@testing-library/react';
import { useMapEditorShortcuts, type MapEditorShortcutOptions } from './useMapEditorShortcuts';
function Harness({ options }: { options: MapEditorShortcutOptions }) {
useMapEditorShortcuts(options);
return <input aria-label="数值字段" />;
}
function press(code: string, init: KeyboardEventInit = {}): KeyboardEvent {
const event = new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
code,
key: init.key ?? code.replace('Key', '').toLowerCase(),
...init,
});
window.dispatchEvent(event);
return event;
}
function options(patch: Partial<MapEditorShortcutOptions> = {}): MapEditorShortcutOptions {
return {
enabled: true,
mapEditing: true,
dirty: true,
loading: false,
hasSelection: true,
canDelete: true,
canScale: true,
onTransformMode: vi.fn(),
onFocusSelection: vi.fn(),
onDeleteSelection: vi.fn(),
onToggleSnapping: vi.fn(),
onSave: vi.fn(),
...patch,
};
}
describe('useMapEditorShortcuts', () => {
it('处理 W/E/R、F、Delete 与 Ctrl+S', () => {
const value = options();
render(<Harness options={value} />);
press('KeyW');
press('KeyE');
press('KeyR');
press('KeyF');
press('KeyG');
press('Delete', { key: 'Delete' });
const saveEvent = press('KeyS', { key: 's', ctrlKey: true });
expect(value.onTransformMode).toHaveBeenNthCalledWith(1, 'translate');
expect(value.onTransformMode).toHaveBeenNthCalledWith(2, 'rotate');
expect(value.onTransformMode).toHaveBeenNthCalledWith(3, 'scale');
expect(value.onFocusSelection).toHaveBeenCalledOnce();
expect(value.onDeleteSelection).toHaveBeenCalledOnce();
expect(value.onToggleSnapping).toHaveBeenCalledOnce();
expect(value.onSave).toHaveBeenCalledOnce();
expect(saveEvent.defaultPrevented).toBe(true);
});
it('文本输入时避免误触变换和删除,但仍允许保存', () => {
const value = options();
const view = render(<Harness options={value} />);
const input = view.getByLabelText('数值字段');
input.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'Delete',
code: 'Delete',
}),
);
input.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 's',
code: 'KeyS',
ctrlKey: true,
}),
);
expect(value.onDeleteSelection).not.toHaveBeenCalled();
expect(value.onSave).toHaveBeenCalledOnce();
});
it('非地图上下文仍支持 F 聚焦,但不劫持变换键', () => {
const value = options({ mapEditing: false });
render(<Harness options={value} />);
press('KeyF');
const transformEvent = press('KeyW');
expect(value.onFocusSelection).toHaveBeenCalledOnce();
expect(value.onTransformMode).not.toHaveBeenCalled();
expect(transformEvent.defaultPrevented).toBe(false);
});
it('缩放不可用时消费 R 但不切换模式', () => {
const value = options({ canScale: false });
render(<Harness options={value} />);
const event = press('KeyR');
expect(value.onTransformMode).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(true);
});
});
@@ -0,0 +1,101 @@
import { useEffect } from 'react';
import type { MapEditorTransformMode } from '../../map/editor/types';
export interface MapEditorShortcutOptions {
enabled: boolean;
mapEditing: boolean;
dirty: boolean;
loading: boolean;
hasSelection: boolean;
canDelete: boolean;
canScale: boolean;
onTransformMode: (mode: MapEditorTransformMode) => void;
onFocusSelection: () => void;
onDeleteSelection: () => void;
onToggleSnapping: () => void;
onSave: () => void;
}
function isTextEditingTarget(target: EventTarget | null): boolean {
return (
target instanceof HTMLElement &&
(target.matches('input, textarea, select') || target.isContentEditable)
);
}
/**
* 地图编辑快捷键的唯一监听入口。
*
* W/E/R: 平移/旋转/缩放,F: 聚焦,Delete: 删除,Ctrl/Cmd+S: 提交草稿。
* 对话框优先持有键盘;文本输入期间只保留 Ctrl/Cmd+S,避免误删对象。
*/
export function useMapEditorShortcuts({
enabled,
mapEditing,
dirty,
loading,
hasSelection,
canDelete,
canScale,
onTransformMode,
onFocusSelection,
onDeleteSelection,
onToggleSnapping,
onSave,
}: MapEditorShortcutOptions): void {
useEffect(() => {
if (!enabled) return;
const keydown = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.isComposing) return;
const target = event.target;
if (target instanceof HTMLElement && target.closest('[role="dialog"]')) return;
const save =
(mapEditing || dirty) && (event.ctrlKey || event.metaKey) && event.code === 'KeyS';
if (save) {
event.preventDefault();
event.stopImmediatePropagation();
if (!loading && dirty && !event.repeat) onSave();
return;
}
if (loading || isTextEditingTarget(target) || event.ctrlKey || event.metaKey || event.altKey)
return;
if (event.code === 'KeyF' && hasSelection) {
event.preventDefault();
event.stopImmediatePropagation();
onFocusSelection();
return;
}
if (!mapEditing) return;
let handled = true;
if (event.code === 'KeyW') onTransformMode('translate');
else if (event.code === 'KeyE') onTransformMode('rotate');
else if (event.code === 'KeyR') {
if (canScale) onTransformMode('scale');
} else if (event.code === 'KeyG') onToggleSnapping();
else if (event.key === 'Delete' && canDelete && !event.repeat) onDeleteSelection();
else handled = false;
if (handled) {
event.preventDefault();
event.stopImmediatePropagation();
}
};
window.addEventListener('keydown', keydown, true);
return () => window.removeEventListener('keydown', keydown, true);
}, [
enabled,
mapEditing,
dirty,
loading,
hasSelection,
canDelete,
canScale,
onTransformMode,
onFocusSelection,
onDeleteSelection,
onToggleSnapping,
onSave,
]);
}
+1 -1
View File
@@ -19,7 +19,7 @@ export function Badge({
return (
<span
title={title}
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${toneClass} ${className}`}
className={`engineering-badge inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${toneClass} ${className}`}
>
{children}
</span>
+1 -1
View File
@@ -30,7 +30,7 @@ export function Button({
return (
<button
type={type}
className={`inline-flex shrink-0 select-none items-center justify-center border font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-40 ${variants[variant]} ${sizes[size]} ${className}`.trim()}
className={`inline-flex shrink-0 select-none items-center justify-center border font-medium transition-[color,background-color,border-color,transform,box-shadow] duration-150 ease-out enabled:hover:-translate-y-px enabled:active:translate-y-0 enabled:active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-40 ${variants[variant]} ${sizes[size]} ${className}`.trim()}
{...props}
>
{icon && (
@@ -16,7 +16,7 @@ export function IconButton({
<button
type={type}
aria-pressed={active || undefined}
className={`inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-40 ${active ? 'border-accent/40 bg-accent-soft text-accent' : 'border-transparent bg-transparent text-text-tertiary hover:bg-element-hover hover:text-text-primary'} ${className}`.trim()}
className={`inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border transition-[color,background-color,border-color,transform,box-shadow] duration-150 ease-out enabled:active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-40 ${active ? 'tool-active-glow border-accent/40 bg-accent-soft text-accent' : 'border-transparent bg-transparent text-text-tertiary hover:bg-element-hover hover:text-text-primary'} ${className}`.trim()}
{...props}
/>
);
@@ -106,7 +106,7 @@ export function ResizablePanel({
<button
type="button"
role="separator"
aria-label={side === 'left' ? '调整工程面板宽度' : '调整属性面板宽度'}
aria-label={side === 'left' ? '调整工程面板宽度' : '调整右侧面板宽度'}
aria-orientation="vertical"
aria-valuemin={minWidth}
aria-valuemax={Math.round(panelMaxWidth(minWidth))}
@@ -0,0 +1,45 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { ScrubbableNumberInput } from './ScrubbableNumberInput';
describe('ScrubbableNumberInput', () => {
it('保留标准数值输入行为', () => {
const onValueChange = vi.fn();
render(
<ScrubbableNumberInput label="位置 X" value={1} step={0.1} onValueChange={onValueChange} />,
);
fireEvent.change(screen.getByLabelText('位置 X'), { target: { value: '2.5' } });
expect(onValueChange).toHaveBeenCalledWith(2.5);
});
it('在标签上水平拖拽并支持 Shift 精调', () => {
const onValueChange = vi.fn();
render(
<ScrubbableNumberInput
label="摩擦力"
value={1}
step={0.1}
min={0}
onValueChange={onValueChange}
/>,
);
const handle = screen.getByRole('button', { name: '拖拽调节数值' });
fireEvent.pointerDown(handle, { button: 0, pointerId: 3, clientX: 20 });
fireEvent.pointerMove(window, { pointerId: 3, clientX: 60 });
expect(onValueChange).toHaveBeenLastCalledWith(2);
fireEvent.pointerMove(window, { pointerId: 3, clientX: 80, shiftKey: true });
expect(onValueChange).toHaveBeenLastCalledWith(1.15);
fireEvent.pointerUp(window, { pointerId: 3, clientX: 80 });
expect(document.body).not.toHaveClass('is-scrubbing-number');
});
it('通过键盘箭头进行无鼠标调节', () => {
const onValueChange = vi.fn();
render(
<ScrubbableNumberInput label="尺寸" value={2} step={0.5} onValueChange={onValueChange} />,
);
fireEvent.keyDown(screen.getByRole('button', { name: '拖拽调节数值' }), {
key: 'ArrowRight',
});
expect(onValueChange).toHaveBeenCalledWith(2.5);
});
});
@@ -0,0 +1,190 @@
import {
useEffect,
useId,
useRef,
useState,
type InputHTMLAttributes,
type PointerEvent as ReactPointerEvent,
} from 'react';
import { GripHorizontal } from 'lucide-react';
function clamp(value: number, min?: number, max?: number): number {
return Math.min(
max ?? Number.POSITIVE_INFINITY,
Math.max(min ?? Number.NEGATIVE_INFINITY, value),
);
}
function decimals(step: number): number {
const source = String(step);
if (source.includes('e-')) return Math.min(8, Number(source.split('e-')[1]));
return Math.min(8, source.split('.')[1]?.length ?? 0);
}
function normalize(value: number, step: number, min?: number, max?: number): number {
const bounded = clamp(value, min, max);
return Number(bounded.toFixed(decimals(step)));
}
export interface ScrubbableNumberInputProps extends Omit<
InputHTMLAttributes<HTMLInputElement>,
'type' | 'value' | 'defaultValue' | 'step' | 'min' | 'max' | 'onChange'
> {
label: string;
value: number;
step?: number;
min?: number;
max?: number;
onValueChange: (value: number) => void;
containerClassName?: string;
labelClassName?: string;
inputClassName?: string;
}
/**
*
* Shift Alt Ctrl/Meta
*/
export function ScrubbableNumberInput({
label,
value,
step = 0.1,
min,
max,
disabled,
onValueChange,
containerClassName = '',
labelClassName = '',
inputClassName = '',
'aria-label': ariaLabel,
onFocus: onInputFocus,
onBlur: onInputBlur,
...inputProps
}: ScrubbableNumberInputProps) {
const [text, setText] = useState(() => String(value));
const [scrubbing, setScrubbing] = useState(false);
const labelId = useId();
const editing = useRef(false);
const cleanup = useRef<() => void>(() => {});
useEffect(() => {
if (!editing.current && !scrubbing) setText(String(value));
}, [value, scrubbing]);
useEffect(
() => () => {
cleanup.current();
},
[],
);
const emit = (next: number, quantizationStep = step) => {
const normalized = normalize(next, quantizationStep, min, max);
setText(String(normalized));
onValueChange(normalized);
};
const startScrub = (event: ReactPointerEvent<HTMLButtonElement>) => {
if (disabled || event.button !== 0) return;
event.preventDefault();
cleanup.current();
const startX = event.clientX;
const startValue = Number.isFinite(value) ? value : 0;
const pointerId = event.pointerId;
setScrubbing(true);
document.body.classList.add('is-scrubbing-number');
const move = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== pointerId) return;
const modifier = moveEvent.altKey
? 0.01
: moveEvent.shiftKey
? 0.1
: moveEvent.ctrlKey || moveEvent.metaKey
? 10
: 1;
emit(startValue + (moveEvent.clientX - startX) * step * 0.25 * modifier, step * modifier);
};
const stop = (stopEvent: PointerEvent) => {
if (stopEvent.pointerId !== pointerId) return;
cleanup.current();
};
cleanup.current = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', stop);
window.removeEventListener('pointercancel', stop);
document.body.classList.remove('is-scrubbing-number');
setScrubbing(false);
cleanup.current = () => {};
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', stop);
window.addEventListener('pointercancel', stop);
};
return (
<div
className={`scrubbable-number ${scrubbing ? 'scrubbable-number-active' : ''} ${containerClassName}`.trim()}
>
<button
type="button"
className={`scrubbable-number-label ${labelClassName}`.trim()}
aria-label="拖拽调节数值"
aria-describedby={labelId}
title={`拖拽调节 ${label}Shift 精调,Alt 超精调,Ctrl 粗调`}
disabled={disabled}
onPointerDown={startScrub}
onKeyDown={(event) => {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
event.preventDefault();
const modifier = event.altKey
? 0.01
: event.shiftKey
? 0.1
: event.ctrlKey || event.metaKey
? 10
: 1;
emit(value + (event.key === 'ArrowRight' ? 1 : -1) * step * modifier, step * modifier);
}}
>
<span id={labelId} className="min-w-0 truncate">
{label}
</span>
<GripHorizontal className="h-3 w-3 shrink-0 opacity-45" aria-hidden="true" />
</button>
<input
{...inputProps}
aria-label={ariaLabel ?? label}
type="number"
inputMode="decimal"
value={text}
step={step}
min={min}
max={max}
disabled={disabled}
className={`scrubbable-number-input ${inputClassName}`.trim()}
onFocus={(event) => {
editing.current = true;
onInputFocus?.(event);
}}
onChange={(event) => {
const nextText = event.currentTarget.value;
setText(nextText);
const next = Number(nextText);
if (nextText !== '' && Number.isFinite(next)) onValueChange(clamp(next, min, max));
}}
onBlur={(event) => {
editing.current = false;
const next = Number(text);
if (text !== '' && Number.isFinite(next)) emit(next);
else setText(String(value));
onInputBlur?.(event);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
inputProps.onKeyDown?.(event);
}}
/>
</div>
);
}
@@ -0,0 +1,55 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { VerticalSplitPane } from './VerticalSplitPane';
const storageKey = 'test-vertical-split-ratio';
function renderSplit() {
render(
<VerticalSplitPane
first={<div></div>}
second={<div></div>}
storageKey={storageKey}
separatorLabel="调整上下区域高度"
defaultRatio={0.4}
/>,
);
const separator = screen.getByRole('separator', { name: '调整上下区域高度' });
Object.defineProperty(separator.parentElement, 'clientHeight', {
configurable: true,
value: 600,
});
return separator;
}
describe('VerticalSplitPane', () => {
beforeEach(() => localStorage.removeItem(storageKey));
it('支持通过指针上下拖动并持久化分割比例', () => {
const separator = renderSplit();
fireEvent.pointerDown(separator, { button: 0, clientY: 200, pointerId: 1 });
fireEvent.pointerMove(window, { clientY: 320, pointerId: 1 });
expect(separator).toHaveAttribute('aria-valuenow', '60');
expect(
Number.parseFloat((separator.previousElementSibling as HTMLElement).style.flexBasis),
).toBeCloseTo(60);
fireEvent.pointerUp(window, { pointerId: 1 });
expect(Number(localStorage.getItem(storageKey))).toBeCloseTo(0.6);
expect(document.body.style.cursor).toBe('');
});
it('支持键盘调整、边界限制和双击复位', () => {
const separator = renderSplit();
fireEvent.keyDown(separator, { key: 'End' });
expect(separator).toHaveAttribute('aria-valuenow', '72');
fireEvent.keyDown(separator, { key: 'Home' });
expect(separator).toHaveAttribute('aria-valuenow', '27');
fireEvent.doubleClick(separator);
expect(separator).toHaveAttribute('aria-valuenow', '40');
expect(Number(localStorage.getItem(storageKey))).toBeCloseTo(0.4);
});
});
@@ -0,0 +1,210 @@
import {
useEffect,
useId,
useRef,
useState,
type KeyboardEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from 'react';
const HANDLE_SIZE = 8;
const FALLBACK_MIN_RATIO = 0.15;
const FALLBACK_MAX_RATIO = 0.85;
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
function storedRatio(storageKey: string, fallback: number): number {
try {
const value = Number(localStorage.getItem(storageKey));
return clamp(
Number.isFinite(value) && value > 0 ? value : fallback,
FALLBACK_MIN_RATIO,
FALLBACK_MAX_RATIO,
);
} catch {
return clamp(fallback, FALLBACK_MIN_RATIO, FALLBACK_MAX_RATIO);
}
}
function ratioBounds(height: number, minFirstSize: number, minSecondSize: number) {
if (height <= HANDLE_SIZE) {
return { min: FALLBACK_MIN_RATIO, max: FALLBACK_MAX_RATIO };
}
const min = minFirstSize / height;
const max = (height - HANDLE_SIZE - minSecondSize) / height;
if (min <= max) return { min, max };
const available = Math.max(0, height - HANDLE_SIZE);
const totalMinimum = minFirstSize + minSecondSize;
const shared = totalMinimum > 0 ? (available * minFirstSize) / totalMinimum / height : 0.5;
return { min: shared, max: shared };
}
export function VerticalSplitPane({
first,
second,
storageKey,
separatorLabel,
defaultRatio = 0.5,
minFirstSize = 160,
minSecondSize = 160,
className = '',
firstClassName = '',
secondClassName = '',
}: {
first: ReactNode;
second: ReactNode;
storageKey: string;
separatorLabel: string;
defaultRatio?: number;
minFirstSize?: number;
minSecondSize?: number;
className?: string;
firstClassName?: string;
secondClassName?: string;
}) {
const [ratio, setRatio] = useState(() => storedRatio(storageKey, defaultRatio));
const [dragging, setDragging] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const ratioRef = useRef(ratio);
const cleanupRef = useRef<() => void>(() => {});
const id = useId();
const constrain = (next: number, height = containerRef.current?.clientHeight ?? 0) => {
const bounds = ratioBounds(height, minFirstSize, minSecondSize);
return clamp(next, bounds.min, bounds.max);
};
const persist = (value: number) => {
try {
localStorage.setItem(storageKey, String(value));
} catch {
/* 无持久化权限时仍可调整 */
}
};
const update = (next: number, save = false) => {
const value = constrain(next);
ratioRef.current = value;
setRatio(value);
if (save) persist(value);
};
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver(() => {
if (container.clientHeight <= HANDLE_SIZE) return;
const bounds = ratioBounds(container.clientHeight, minFirstSize, minSecondSize);
const value = clamp(ratioRef.current, bounds.min, bounds.max);
if (value === ratioRef.current) return;
ratioRef.current = value;
setRatio(value);
});
observer.observe(container);
return () => observer.disconnect();
}, [minFirstSize, minSecondSize]);
useEffect(
() => () => {
cleanupRef.current();
},
[],
);
const start = (event: ReactPointerEvent<HTMLButtonElement>) => {
if (event.button !== 0) return;
event.preventDefault();
cleanupRef.current();
const container = containerRef.current;
if (!container) return;
const origin = event.clientY;
const startRatio = ratioRef.current;
const height = container.clientHeight;
const pointerId = event.pointerId;
const previousCursor = document.body.style.cursor;
const previousUserSelect = document.body.style.userSelect;
document.body.style.cursor = 'row-resize';
document.body.style.userSelect = 'none';
setDragging(true);
const move = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== pointerId) return;
moveEvent.preventDefault();
update(startRatio + (moveEvent.clientY - origin) / Math.max(height, 1));
};
const stop = (stopEvent: PointerEvent) => {
if (stopEvent.pointerId !== pointerId) return;
persist(ratioRef.current);
cleanup();
};
const cleanup = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', stop);
window.removeEventListener('pointercancel', stop);
document.body.style.cursor = previousCursor;
document.body.style.userSelect = previousUserSelect;
setDragging(false);
cleanupRef.current = () => {};
};
cleanupRef.current = cleanup;
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', stop);
window.addEventListener('pointercancel', stop);
};
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
const height = containerRef.current?.clientHeight ?? 0;
const bounds = ratioBounds(height, minFirstSize, minSecondSize);
const step = (event.shiftKey ? 48 : 16) / Math.max(height, 320);
let next: number;
if (event.key === 'Home') next = bounds.min;
else if (event.key === 'End') next = bounds.max;
else if (event.key === 'ArrowUp') next = ratioRef.current - step;
else if (event.key === 'ArrowDown') next = ratioRef.current + step;
else return;
event.preventDefault();
update(next, true);
};
return (
<div ref={containerRef} className={`flex min-h-0 flex-1 flex-col ${className}`}>
<div
id={`${id}-first`}
className={`min-h-0 shrink-0 overflow-hidden ${firstClassName}`}
style={{ flexBasis: `${ratio * 100}%` }}
>
{first}
</div>
<button
type="button"
role="separator"
aria-label={separatorLabel}
aria-orientation="horizontal"
aria-controls={`${id}-first ${id}-second`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(ratio * 100)}
aria-valuetext={`上方区域占 ${Math.round(ratio * 100)}%`}
data-dragging={dragging || undefined}
onPointerDown={start}
onKeyDown={onKeyDown}
onDoubleClick={() => update(defaultRatio, true)}
className="group relative z-20 h-2 w-full shrink-0 touch-none select-none cursor-row-resize bg-transparent outline-none before:absolute before:inset-x-0 before:top-1/2 before:h-px before:-translate-y-1/2 before:bg-border before:transition-colors hover:before:bg-accent focus-visible:before:h-0.5 focus-visible:before:bg-accent data-[dragging=true]:before:h-0.5 data-[dragging=true]:before:bg-accent"
>
<span className="absolute left-1/2 top-1/2 h-1 w-9 -translate-x-1/2 -translate-y-1/2 rounded-full border border-border bg-panel transition-colors group-hover:border-accent group-focus-visible:border-accent group-data-[dragging=true]:border-accent group-data-[dragging=true]:bg-accent-soft" />
</button>
<div id={`${id}-second`} className={`min-h-0 flex-1 overflow-hidden ${secondClassName}`}>
{second}
</div>
</div>
);
}
+2
View File
@@ -10,6 +10,7 @@ export * from './Separator';
export * from './Skeleton';
export * from './Tabs';
export * from './ResizablePanel';
export * from './VerticalSplitPane';
export * from './Kbd';
export * from './PropertyRow';
export * from './CopyButton';
@@ -22,3 +23,4 @@ export * from './DropdownMenu';
export * from './ProgressBar';
export * from './LiveRegion';
export * from './SearchableCombobox';
export * from './ScrubbableNumberInput';
+39 -185
View File
@@ -16,131 +16,20 @@ const renderLibrary = (
);
describe('MapAssetLibrary', () => {
it('把全部来源呈现在同一个放置与应用工作流中', () => {
it('只负责地图包、物理原语和预设地形的放置入口', () => {
expect(decodeMapLibraryDragPayload('not-json')).toBeNull();
expect(decodeMapLibraryDragPayload('{"kind":"terrain","preset":"unknown"}')).toBeNull();
renderLibrary();
expect(screen.getByLabelText('地图资产库')).toHaveTextContent('统一地图资产库');
expect(screen.getByLabelText('地图资产库')).toHaveTextContent('放置 → 预览 → 一次应用');
const library = screen.getByLabelText('地图资产库');
expect(library).toHaveTextContent('场景资产库');
expect(library).toHaveTextContent('物理几何原语');
expect(library).toHaveTextContent('预设地形');
expect(library).not.toHaveTextContent('已放置地图');
});
it('显示已放置地图树并支持选择、删除和重复添加工程地图源', () => {
const onSelectMap = vi.fn();
const onRemoveMap = vi.fn();
const onAddProjectMap = vi.fn();
render(
<MapAssetLibrary
disabled={false}
terrainSize={8}
maps={[
{
descriptorPath: 'maps/warehouse/map.json',
schemaVersion: 1,
id: 'warehouse',
name: '仓库',
spawnPoints: [],
},
]}
placedMaps={[
{
id: 'map-a',
name: '仓库',
selection: { kind: 'project', descriptorPath: 'maps/warehouse/map.json' },
},
{
id: 'map-b',
name: '粗糙地形',
selection: {
kind: 'builtin',
config: {
preset: 'rough',
size: 8,
friction: 1,
positionX: 0,
positionY: 0,
yawDeg: 0,
slopeAngle: 12,
stairCount: 8,
obstacleCount: 10,
seed: 1,
terrainDifficulty: 0.5,
terrainHorizontalScale: 0.12,
terrainVerticalScale: 0.01,
},
},
},
]}
activeMapId="map-a"
onAdd={() => {}}
onAddProjectMap={onAddProjectMap}
onSelectTerrain={() => {}}
onSelectMap={onSelectMap}
onRemoveMap={onRemoveMap}
/>,
);
expect(screen.getByLabelText('场景资产树')).toHaveTextContent('2 个实例');
fireEvent.click(screen.getByRole('button', { name: '选择地图实例 粗糙地形' }));
fireEvent.click(screen.getByRole('button', { name: '删除地图实例 仓库' }));
fireEvent.click(screen.getByRole('button', { name: '放置工程地图 仓库' }));
expect(onSelectMap).toHaveBeenCalledWith('map-b');
expect(onRemoveMap).toHaveBeenCalledWith('map-a');
expect(onAddProjectMap).toHaveBeenCalledWith('maps/warehouse/map.json');
});
it('场景树为所有地图实例展示认证对象并可跨实例直接选择', () => {
const onSelectObject = vi.fn();
const editable = {
schemaVersion: 1 as const,
mapId: 'warehouse',
revision: 0,
objects: [
{
id: 'box-1',
name: '共享方盒',
type: 'box' as const,
pose: {
position: [0, 0, 0.5] as [number, number, number],
quaternion: [1, 0, 0, 0] as [number, number, number, number],
},
parameters: { sizeX: 1, sizeY: 1, sizeZ: 1 },
friction: [1, 0.005, 0.0001] as [number, number, number],
rgba: [0.5, 0.6, 0.7, 1] as [number, number, number, number],
placementMode: 'auto_ground' as const,
enabled: true,
},
],
spawnPoints: [],
};
render(
<MapAssetLibrary
disabled={false}
terrainSize={8}
documents={new Map([['maps/warehouse/map.json', editable]])}
placedMaps={[
{
id: 'map-a',
name: '仓库 A',
selection: { kind: 'project', descriptorPath: 'maps/warehouse/map.json' },
},
{
id: 'map-b',
name: '仓库 B',
selection: { kind: 'project', descriptorPath: 'maps/warehouse/map.json' },
},
]}
activeMapId="map-a"
onAdd={() => {}}
onSelectTerrain={() => {}}
onSelectObject={onSelectObject}
/>,
);
expect(screen.getByLabelText('场景资产树')).toHaveTextContent('2 个实例 · 2 个对象');
fireEvent.click(screen.getAllByRole('button', { name: /共享方盒/ })[1]);
expect(onSelectObject).toHaveBeenCalledWith('map-b', 'box-1');
});
it('工程地图源使用与资产卡片一致的拖放入口', () => {
it('地图包使用与资产卡片一致的添加和拖放入口', () => {
const values = new Map<string, string>();
const onAddProjectMap = vi.fn();
const dataTransfer = {
effectAllowed: 'none',
setData: (type: string, value: string) => values.set(type, value),
@@ -159,20 +48,21 @@ describe('MapAssetLibrary', () => {
},
]}
onAdd={() => {}}
onAddProjectMap={onAddProjectMap}
onSelectTerrain={() => {}}
/>,
);
const source = document.querySelector('[data-project-map="maps/warehouse/map.json"]')!;
expect(source).toHaveAttribute('draggable', 'true');
fireEvent.dragStart(source, { dataTransfer });
fireEvent.click(screen.getByRole('button', { name: '放置工程地图 仓库' }));
expect(decodeMapLibraryDragPayload(values.get(MAP_LIBRARY_DRAG_MIME) ?? '')).toEqual({
kind: 'project',
descriptorPath: 'maps/warehouse/map.json',
});
expect(values.get('text/plain')).toBe('仓库');
expect(onAddProjectMap).toHaveBeenCalledWith('maps/warehouse/map.json');
});
it('按所选放置方式添加认证资产', () => {
it('按所选放置方式添加物理几何原语', () => {
const onAdd = vi.fn();
renderLibrary(onAdd);
fireEvent.change(screen.getByLabelText('新增资产放置方式'), {
@@ -182,7 +72,26 @@ describe('MapAssetLibrary', () => {
expect(onAdd).toHaveBeenCalledWith('box', 'gravity');
});
it('拖动资产时写入类型和放置方式', () => {
it('可与视口工具条共享受控放置方式', () => {
const onPlacementModeChange = vi.fn();
render(
<MapAssetLibrary
disabled={false}
terrainSize={8}
placementMode="locked"
onPlacementModeChange={onPlacementModeChange}
onAdd={() => {}}
onSelectTerrain={() => {}}
/>,
);
expect(screen.getByLabelText('新增资产放置方式')).toHaveValue('locked');
fireEvent.change(screen.getByLabelText('新增资产放置方式'), {
target: { value: 'gravity' },
});
expect(onPlacementModeChange).toHaveBeenCalledWith('gravity');
});
it('拖动物理原语时写入类型和放置方式', () => {
const values = new Map<string, string>();
const dataTransfer = {
effectAllowed: 'none',
@@ -200,76 +109,21 @@ describe('MapAssetLibrary', () => {
});
});
it('以示意图卡片选择系统参数化地形', () => {
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');
});
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();
const terrain = document.querySelector('[data-system-terrain="rough"]')!;
expect(terrain).toHaveAttribute('draggable', 'true');
fireEvent.dragStart(terrain, { dataTransfer });
renderLibrary(undefined, onSelectTerrain);
expect(screen.getAllByText('8.00 × 8.00 m')).toHaveLength(9);
fireEvent.click(screen.getByRole('button', { name: '添加随机粗糙地形' }));
fireEvent.dragStart(document.querySelector('[data-system-terrain="wave"]')!, { dataTransfer });
expect(onSelectTerrain).toHaveBeenCalledWith('rough');
expect(decodeMapLibraryDragPayload(values.get(MAP_LIBRARY_DRAG_MIME) ?? '')).toEqual({
kind: 'terrain',
preset: 'rough',
preset: 'wave',
});
expect(values.get('text/plain')).toBe('随机粗糙地形');
});
it('集中显示认证资产与参数地形更改,并只触发一次场景编译', () => {
const onApplyScene = vi.fn();
const onDiscardScene = vi.fn();
render(
<MapAssetLibrary
disabled={false}
terrainSize={8}
pendingSceneChangeCount={3}
pendingSceneIds={['terrain-a']}
placedMaps={[
{
id: 'terrain-a',
name: '粗糙地形',
selection: {
kind: 'builtin',
config: {
preset: 'rough',
size: 8,
friction: 1,
positionX: 0,
positionY: 0,
yawDeg: 0,
slopeAngle: 12,
stairCount: 8,
obstacleCount: 10,
seed: 1,
terrainDifficulty: 0.5,
terrainHorizontalScale: 0.12,
terrainVerticalScale: 0.01,
},
},
},
]}
onAdd={() => {}}
onSelectTerrain={() => {}}
onApplyScene={onApplyScene}
onDiscardScene={onDiscardScene}
/>,
);
expect(screen.getByRole('status')).toHaveTextContent('3 项场景更改待应用');
expect(screen.getByRole('status')).toHaveTextContent('认证资产与参数地形共享同一草稿');
expect(screen.getByLabelText('场景资产树')).toHaveTextContent('待应用');
fireEvent.click(screen.getByRole('button', { name: '一次编译应用' }));
fireEvent.click(screen.getByRole('button', { name: '放弃场景更改' }));
expect(onApplyScene).toHaveBeenCalledTimes(1);
expect(onDiscardScene).toHaveBeenCalledTimes(1);
});
});
+18 -166
View File
@@ -1,15 +1,5 @@
import { useState } from 'react';
import {
BadgeCheck,
CheckCircle2,
Layers3,
MapPinned,
Mountain,
Plus,
RotateCcw,
Trash2,
Zap,
} from 'lucide-react';
import { BadgeCheck, CheckCircle2, MapPinned, Mountain, Plus } from 'lucide-react';
import { Button, Select } from '../components/ui';
import {
CERTIFIED_MAP_ASSETS,
@@ -18,7 +8,6 @@ import {
} from './editor/assetCatalog';
import {
MAP_OBJECT_PLACEMENT_LABELS,
type EditableMapDocument,
type EditableMapObjectType,
type MapObjectPlacementMode,
} from './editor/types';
@@ -26,7 +15,6 @@ import type { MapEntry } from '../project/types';
import {
PHYSICAL_MAP_PRESET_LABELS,
SYSTEM_TERRAIN_PRESETS,
type PlacedMapAsset,
type SystemTerrainPreset,
} from './types';
@@ -147,182 +135,46 @@ function AddAction({ label }: { label: string }) {
export function MapAssetLibrary({
disabled,
terrainSize,
document,
documents,
maps = [],
placedMaps = [],
activeMapId,
pendingSceneChangeCount = 0,
pendingSceneIds = [],
placementMode: controlledPlacementMode,
onPlacementModeChange,
onAdd,
onAddProjectMap,
onSelectTerrain,
onApplyScene,
onDiscardScene,
onSelectMap,
onRemoveMap,
onSelectObject,
}: {
disabled: boolean;
terrainSize: number;
document?: EditableMapDocument | null;
documents?: ReadonlyMap<string, EditableMapDocument>;
maps?: MapEntry[];
placedMaps?: PlacedMapAsset[];
activeMapId?: string;
pendingSceneChangeCount?: number;
pendingSceneIds?: string[];
placementMode?: MapObjectPlacementMode;
onPlacementModeChange?: (mode: MapObjectPlacementMode) => void;
onAdd: (
type: EditableMapObjectType,
placementMode: MapObjectPlacementMode,
) => void | Promise<void>;
onAddProjectMap?: (descriptorPath: string) => void;
onSelectTerrain: (preset: SystemTerrainPreset) => void;
onApplyScene?: () => void;
onDiscardScene?: () => void;
onSelectMap?: (id: string) => void;
onRemoveMap?: (id: string) => void;
onSelectObject?: (mapId: string, objectId: string) => void;
}) {
const [placementMode, setPlacementMode] = useState<MapObjectPlacementMode>('auto_ground');
const pendingSceneIdSet = new Set(pendingSceneIds);
const placedObjectCount = placedMaps.reduce(
(total, map) =>
total +
(map.selection.kind === 'project'
? (documents?.get(map.selection.descriptorPath)?.objects.length ?? 0)
: 0),
0,
);
const [internalPlacementMode, setInternalPlacementMode] =
useState<MapObjectPlacementMode>('auto_ground');
const placementMode = controlledPlacementMode ?? internalPlacementMode;
const changePlacementMode = (mode: MapObjectPlacementMode) => {
setInternalPlacementMode(mode);
onPlacementModeChange?.(mode);
};
return (
<section aria-label="地图资产库" className="space-y-3 p-3">
<header>
<div className="flex items-center gap-1.5 text-xs font-semibold text-text-primary">
<MapPinned 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>
</header>
<div
className="rounded-lg border border-border-subtle bg-panel-muted/50 p-2.5"
aria-label="场景资产树"
>
<div className="flex items-center justify-between text-xs font-medium text-text-primary">
<span className="flex items-center gap-1.5">
<Layers3 className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
</span>
<span className="technical-value text-[10px] text-text-tertiary">
{placedMaps.length}
{placedObjectCount ? ` · ${placedObjectCount} 个对象` : ''}
</span>
</div>
{placedMaps.length ? (
<div className="mt-2 max-h-52 space-y-0.5 overflow-auto panel-scroll" role="tree">
{placedMaps.map((map) => {
const active = map.id === activeMapId;
const mapDocument =
map.selection.kind === 'project'
? (documents?.get(map.selection.descriptorPath) ?? (active ? document : null))
: null;
return (
<div key={map.id} role="treeitem" aria-selected={active}>
<div
className={`flex items-center gap-1 rounded ${active ? 'bg-accent-soft text-accent' : 'text-text-secondary hover:bg-element-hover'}`}
>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left text-[11px] disabled:cursor-not-allowed disabled:opacity-50"
aria-label={`选择地图实例 ${map.name}`}
disabled={disabled}
onClick={() => onSelectMap?.(map.id)}
>
<MapPinned className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">{map.name}</span>
<span className="text-[9px] text-text-tertiary">
{pendingSceneIdSet.has(map.id)
? '待应用'
: map.selection.kind === 'builtin'
? '参数地形'
: '工程地图'}
</span>
</button>
<button
type="button"
className="mr-1 rounded p-1 text-text-tertiary hover:bg-danger/10 hover:text-danger"
aria-label={`删除地图实例 ${map.name}`}
disabled={disabled}
onClick={() => onRemoveMap?.(map.id)}
>
<Trash2 className="h-3 w-3" aria-hidden="true" />
</button>
</div>
{mapDocument?.objects.length ? (
<div className="ml-4 border-l border-border pl-1" role="group">
{mapDocument.objects.map((object) => (
<button
key={object.id}
type="button"
className={`flex w-full items-center gap-2 rounded px-2 py-1 text-left text-[10px] hover:bg-element-hover hover:text-text-primary ${active ? 'text-text-secondary' : 'text-text-tertiary'}`}
onClick={() => onSelectObject?.(map.id, 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>
) : null}
</div>
);
})}
</div>
) : (
<p className="mt-2 text-[10px] leading-relaxed text-text-tertiary">
</p>
)}
</div>
{pendingSceneChangeCount > 0 && (
<div
role="status"
className="rounded-lg border border-accent/30 bg-accent-soft p-2.5 text-[10px] text-text-secondary"
>
<div className="flex items-center gap-1.5 font-medium text-accent">
<Zap className="h-3.5 w-3.5" aria-hidden="true" />
{pendingSceneChangeCount}
</div>
<p className="mt-1 leading-relaxed">
稿 MuJoCo
</p>
<div className="mt-2 flex gap-2">
<Button
variant="primary"
className="min-w-0 flex-1"
disabled={disabled}
onClick={onApplyScene}
>
</Button>
<Button
variant="ghost"
aria-label="放弃场景更改"
disabled={disabled}
onClick={onDiscardScene}
>
<RotateCcw className="mr-1 h-3 w-3" aria-hidden="true" />
</Button>
</div>
</div>
)}
{maps.length > 0 && (
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-2.5">
<div className="text-xs font-medium text-text-primary"></div>
<div className="text-xs font-medium text-text-primary"></div>
<p className="mt-1 text-[10px] text-text-tertiary">
姿
</p>
@@ -370,7 +222,7 @@ export function MapAssetLibrary({
<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">
@@ -383,7 +235,7 @@ export function MapAssetLibrary({
className="mt-1 w-full"
value={placementMode}
disabled={disabled}
onChange={(event) => setPlacementMode(event.target.value as MapObjectPlacementMode)}
onChange={(event) => changePlacementMode(event.target.value as MapObjectPlacementMode)}
>
{(Object.keys(MAP_OBJECT_PLACEMENT_LABELS) as MapObjectPlacementMode[]).map((mode) => (
<option key={mode} value={mode}>
@@ -444,7 +296,7 @@ export function MapAssetLibrary({
<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">
+43 -5
View File
@@ -9,8 +9,7 @@ const interactionProps = {
canConvert: true,
onBindInteraction: () => {},
onSelectPreview: () => {},
onTransformMode: () => {},
onSnapping: () => {},
onSessionStateChange: () => {},
};
const document: EditableMapDocument = {
schemaVersion: 1,
@@ -114,22 +113,61 @@ describe('MapEditorPanel', () => {
expect(screen.getByLabelText('对象名称')).toHaveValue('认证方盒');
});
it('锁定放置方式后禁用位姿编辑', () => {
it('视口贴地命令通过会话端口锁定位姿编辑', async () => {
let interaction: MapEditorInteractionCallbacks | null = null;
let selectedId: string | null = null;
render(
<MapEditorPanel
{...interactionProps}
document={document}
loading={false}
onPreview={() => {}}
onSelectPreview={(id) => {
selectedId = id;
}}
onBindInteraction={(callbacks) => {
interaction = callbacks;
}}
onApply={async () => true}
onExport={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '新增' }));
fireEvent.change(screen.getByLabelText('对象放置方式'), { target: { value: 'locked' } });
expect(selectedId).toBeTruthy();
act(() =>
(interaction as unknown as MapEditorInteractionCallbacks).onSetPlacementMode(
selectedId!,
'locked',
),
);
await waitFor(() => expect(screen.getByText('锁定位姿')).toBeVisible());
expect(screen.getByLabelText('对象位置X')).toBeDisabled();
expect(screen.getByLabelText('对象绕Z旋转')).toBeDisabled();
expect(screen.getByRole('button', { name: '对齐地面' })).toBeDisabled();
});
it('把表面材质预设与三轴摩擦写入同一对象草稿', () => {
const onDraftChange = vi.fn();
render(
<MapEditorPanel
{...interactionProps}
document={document}
loading={false}
onPreview={() => {}}
onDraftChange={onDraftChange}
onApply={async () => true}
onExport={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '新增' }));
fireEvent.change(screen.getByLabelText('对象表面材质'), { target: { value: 'rubber' } });
expect(screen.getByLabelText('对象滑动摩擦')).toHaveValue(1.5);
expect(onDraftChange).toHaveBeenLastCalledWith(
'maps/map/map.json',
expect.objectContaining({
objects: [expect.objectContaining({ friction: [1.5, 0.008, 0.0002] })],
}),
true,
);
});
it('编辑出生点并包含在应用文档中', async () => {
+205 -388
View File
@@ -1,17 +1,14 @@
import { useEffect, useMemo, useState } from 'react';
import { Button, Select } from '../components/ui';
import { Button, ScrubbableNumberInput, Select } from '../components/ui';
import { MapEditSession } from './editor/MapEditSession';
import { MapTransformToolbar } from './MapTransformToolbar';
import {
editableObjectGroundHeight,
MAP_OBJECT_PLACEMENT_LABELS,
type EditableMapDocument,
type EditableMapObject,
type EditableMapObjectType,
type MapEditorInteractionCallbacks,
type MapEditorTransformMode,
type MapObjectPlacementMode,
import type {
EditableMapDocument,
EditableMapObject,
EditableMapObjectType,
MapEditorInteractionCallbacks,
MapEditorSessionState,
} from './editor/types';
import { MapObjectInspector } from './MapObjectInspector';
const labels: Record<EditableMapObjectType, string> = {
box: '方盒',
@@ -20,10 +17,6 @@ const labels: Record<EditableMapObjectType, string> = {
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],
@@ -65,10 +58,10 @@ export function MapEditorPanel({
onExport,
onConvert,
canConvert,
selectedObjectId,
onBindInteraction,
onSelectPreview,
onTransformMode,
onSnapping,
onSessionStateChange,
onSurfaceHeight,
}: {
document: EditableMapDocument | null;
@@ -81,10 +74,10 @@ export function MapEditorPanel({
onExport: () => void;
onConvert: () => Promise<boolean>;
canConvert: boolean;
selectedObjectId?: string;
onBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
onSelectPreview: (id: string | null) => void;
onTransformMode: (mode: MapEditorTransformMode) => void;
onSnapping: (translation: number | null, rotationDegrees: number | null) => void;
onSessionStateChange?: (descriptorPath: string, state: MapEditorSessionState | null) => void;
onSurfaceHeight?: (position: readonly [number, number, number]) => number | null;
}) {
// draftDocument 仅用于恢复重新挂载的编辑器;会话存活期间由 MapEditSession 持有最新草稿。
@@ -95,30 +88,71 @@ export function MapEditorPanel({
[document, descriptorPath, onSurfaceHeight],
);
const [, render] = useState(0),
[selected, setSelected] = useState<string>(),
[selected, setSelected] = useState<string | undefined>(selectedObjectId),
[selectedType, setSelectedType] = useState<EditableMapObjectType>('box'),
[transformMode, setTransformMode] = useState<MapEditorTransformMode>('translate'),
[snapping, setSnapping] = useState(true),
[converting, setConverting] = useState(false);
const selectedId = selectedObjectId ?? selected;
const reportSessionState = () =>
onSessionStateChange?.(
descriptorPath,
session
? {
dirty: session.dirty,
changeCount: session.changeCount,
canUndo: session.canUndo,
canRedo: session.canRedo,
}
: null,
);
const refresh = () => {
const next = session?.document ?? null;
render((value) => value + 1);
onPreview(next);
if (session && next) onDraftChange(descriptorPath, next, session.dirty);
reportSessionState();
};
useEffect(() => {
const next = session?.document ?? null;
onPreview(next);
if (session && next) onDraftChange(descriptorPath, next, session.dirty);
return () => onPreview(null);
}, [session, descriptorPath, onPreview, onDraftChange]);
onSessionStateChange?.(
descriptorPath,
session
? {
dirty: session.dirty,
changeCount: session.changeCount,
canUndo: session.canUndo,
canRedo: session.canRedo,
}
: null,
);
return () => {
onPreview(null);
onSessionStateChange?.(descriptorPath, null);
};
}, [session, descriptorPath, onPreview, onDraftChange, onSessionStateChange]);
useEffect(() => {
if (!session) {
onBindInteraction(null);
return;
}
const publish = () => {
const next = session.document;
render((value) => value + 1);
onPreview(next);
onDraftChange(descriptorPath, next, session.dirty);
onSessionStateChange?.(descriptorPath, {
dirty: session.dirty,
changeCount: session.changeCount,
canUndo: session.canUndo,
canRedo: session.canRedo,
});
};
const callbacks: MapEditorInteractionCallbacks = {
onSelect: (id) => setSelected(id ?? undefined),
onSelect: (id) => {
session.selectedId = id ?? undefined;
setSelected(id ?? undefined);
},
onTransform: ({ id, position, quaternion, scale }) => {
if (loading) return;
const object = session.document.objects.find((item) => item.id === id);
@@ -127,20 +161,46 @@ export function MapEditorPanel({
pose: { position, quaternion },
parameters: scaledParameters(object, scale),
});
session.selectedId = id;
setSelected(id);
render((value) => value + 1);
onPreview(session.document);
onDraftChange(descriptorPath, session.document, session.dirty);
publish();
},
onAddAsset: (type, position, placementMode, externalSupportTop) => {
if (loading) return;
const object = session.addAsset(type, position, placementMode, externalSupportTop);
setSelected(object.id);
render((value) => value + 1);
onPreview(session.document);
onDraftChange(descriptorPath, session.document, session.dirty);
publish();
onSelectPreview(object.id);
},
onSetPlacementMode: (id, placementMode) => {
if (loading || !session.document.objects.some((object) => object.id === id)) return;
session.update(id, { placementMode });
session.selectedId = id;
setSelected(id);
publish();
},
onAlignToSurface: (id) => {
if (loading) return;
const object = session.document.objects.find((candidate) => candidate.id === id);
if (!object || object.placementMode === 'locked') return;
session.update(id, { placementMode: object.placementMode });
session.selectedId = id;
setSelected(id);
publish();
},
onDelete: (id) => {
if (loading || !session.document.objects.some((object) => object.id === id)) return;
session.remove(id);
setSelected(undefined);
publish();
onSelectPreview(null);
},
onDiscard: () => {
session.discard();
setSelected(undefined);
publish();
onSelectPreview(null);
},
};
onBindInteraction(callbacks);
return () => onBindInteraction(null);
@@ -152,14 +212,11 @@ export function MapEditorPanel({
onPreview,
onDraftChange,
onSelectPreview,
onSessionStateChange,
]);
useEffect(() => {
if (session && selected) onSelectPreview(selected);
}, [session, selected, onSelectPreview]);
useEffect(() => {
onTransformMode(transformMode);
onSnapping(snapping ? 0.1 : null, snapping ? 5 : null);
}, [transformMode, snapping, onTransformMode, onSnapping]);
if (session && selectedId) onSelectPreview(selectedId);
}, [session, selectedId, onSelectPreview]);
useEffect(() => {
if (!session) return;
const keydown = (event: KeyboardEvent) => {
@@ -172,38 +229,48 @@ export function MapEditorPanel({
event.preventDefault();
if (event.shiftKey) session.redo();
else session.undo();
if (selected && !session.document.objects.some((object) => object.id === selected)) {
if (selectedId && !session.document.objects.some((object) => object.id === selectedId)) {
setSelected(undefined);
onSelectPreview(null);
}
render((value) => value + 1);
onPreview(session.document);
onDraftChange(descriptorPath, session.document, session.dirty);
onSessionStateChange?.(descriptorPath, {
dirty: session.dirty,
changeCount: session.changeCount,
canUndo: session.canUndo,
canRedo: session.canRedo,
});
} else if ((event.ctrlKey || event.metaKey) && key === 'y') {
event.preventDefault();
session.redo();
render((value) => value + 1);
onPreview(session.document);
onDraftChange(descriptorPath, session.document, session.dirty);
} 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') {
onSessionStateChange?.(descriptorPath, {
dirty: session.dirty,
changeCount: session.changeCount,
canUndo: session.canUndo,
canRedo: session.canRedo,
});
} 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);
onDraftChange(descriptorPath, session.document, session.dirty);
onSelectPreview(null);
}
};
window.addEventListener('keydown', keydown);
return () => window.removeEventListener('keydown', keydown);
}, [session, descriptorPath, selected, loading, onPreview, onDraftChange, onSelectPreview]);
}, [
session,
descriptorPath,
selectedId,
loading,
onPreview,
onDraftChange,
onSelectPreview,
onSessionStateChange,
]);
if (!session)
return (
<div className="space-y-2 rounded border border-border-subtle p-3 text-xs text-text-tertiary">
@@ -231,41 +298,7 @@ export function MapEditorPanel({
</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 (loading || !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 (loading || !active || !Number.isFinite(value) || value <= 0) return;
session.update(active.id, {
parameters: { ...active.parameters, [key]: key === 'count' ? Math.round(value) : value },
});
refresh();
};
const updateFriction = (index: number, value: number) => {
if (loading || !active || !Number.isFinite(value) || value < 0) return;
const friction = [...active.friction] as [number, number, number];
friction[index] = value;
session.update(active.id, { friction });
refresh();
};
const updateColor = (value: string) => {
if (loading || !active || !/^#[0-9a-f]{6}$/i.test(value)) return;
session.update(active.id, {
rgba: [
Number.parseInt(value.slice(1, 3), 16) / 255,
Number.parseInt(value.slice(3, 5), 16) / 255,
Number.parseInt(value.slice(5, 7), 16) / 255,
active.rgba[3],
],
});
refresh();
};
const active = current.objects.find((object) => object.id === selectedId);
const updateSpawnPosition = (
id: string,
position: [number, number, number],
@@ -281,281 +314,79 @@ export function MapEditorPanel({
return (
<div className="space-y-3">
<MapTransformToolbar
mode={transformMode}
snapping={snapping}
loading={loading}
hint="在视口点击对象后拖动操纵轴;点击空白区域取消选择。只允许绕世界 Z 轴旋转,缩放会写入原语尺寸。"
onModeChange={setTransformMode}
onSnappingChange={setSnapping}
/>
<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);
{active && (
<MapObjectInspector
object={active}
loading={loading}
onUpdate={(patch) => {
session.update(active.id, patch);
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'}`}
onDuplicate={() => {
const copy = session.duplicate(active.id);
setSelected(copy.id);
refresh();
onSelectPreview(copy.id);
}}
onDelete={() => {
session.remove(active.id);
setSelected(undefined);
refresh();
onSelectPreview(null);
}}
/>
)}
<section className="space-y-2 rounded-lg border border-border-subtle p-2.5">
<div className="text-[10px] font-semibold uppercase tracking-[0.12em] text-text-tertiary">
</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);
}}
>
{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}
disabled={loading}
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}
disabled={loading}
onChange={(event) => updateParameter(key, Number(event.target.value))}
/>
</label>
))}
</div>
<div className="rounded border border-border-subtle p-2">
<div className="mb-2 text-[10px] font-medium text-text-primary"></div>
<div className="grid grid-cols-3 gap-2">
{active.friction.map((value, index) => (
<label key={index} className="text-[10px] text-text-secondary">
{['滑动摩擦', '扭转摩擦', '滚动摩擦'][index]}
<input
aria-label={`对象${['滑动摩擦', '扭转摩擦', '滚动摩擦'][index]}`}
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
type="number"
min="0"
max="5"
step={index === 0 ? 0.05 : 0.0001}
value={value}
disabled={loading}
onChange={(event) => updateFriction(index, Number(event.target.value))}
/>
</label>
))}
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
<label className="text-[10px] text-text-secondary">
<input
aria-label="对象颜色"
className="mt-1 h-7 w-full rounded border border-border-strong bg-input p-0.5"
type="color"
value={`#${active.rgba
.slice(0, 3)
.map((channel) =>
Math.round(Math.min(1, Math.max(0, channel)) * 255)
.toString(16)
.padStart(2, '0'),
)
.join('')}`}
disabled={loading}
onChange={(event) => updateColor(event.target.value)}
/>
</label>
<label className="text-[10px] text-text-secondary">
<input
aria-label="对象不透明度"
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
type="number"
min="0"
max="1"
step="0.05"
value={active.rgba[3]}
disabled={loading}
onChange={(event) => {
const alpha = Number(event.target.value);
if (!Number.isFinite(alpha)) return;
session.update(active.id, {
rgba: [
active.rgba[0],
active.rgba[1],
active.rgba[2],
Math.min(1, Math.max(0, alpha)),
],
});
refresh();
}}
/>
</label>
</div>
</div>
<label className="flex items-center justify-between text-[11px] text-text-secondary">
<span></span>
<input
aria-label="启用地图对象"
type="checkbox"
checked={active.enabled}
disabled={loading}
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>
</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 ${selectedId === 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>
</section>
<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">
@@ -585,43 +416,29 @@ export function MapEditorPanel({
/>
<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}
disabled={loading}
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}
<ScrubbableNumberInput
key={index}
label={['X', 'Y', 'Z'][index]}
aria-label={`出生点${spawn.id}位置${['X', 'Y', 'Z'][index]}`}
value={value}
step={0.1}
disabled={loading}
onChange={(event) => {
const value = Number(event.target.value);
if (!Number.isFinite(value)) return;
session.updateSpawn(spawn.id, { yawDeg: value });
refresh();
}}
onValueChange={(next) =>
updateSpawnPosition(spawn.id, spawn.position, index, next)
}
/>
</label>
))}
<ScrubbableNumberInput
label="Yaw°"
aria-label={`出生点${spawn.id}朝向`}
value={spawn.yawDeg}
step={1}
disabled={loading}
onValueChange={(yawDeg) => {
session.updateSpawn(spawn.id, { yawDeg });
refresh();
}}
/>
</div>
<Button
variant="danger"
+321
View File
@@ -0,0 +1,321 @@
import { Copy, LockKeyhole, Trash2 } from 'lucide-react';
import { Button, ScrubbableNumberInput, Select } from '../components/ui';
import { MAP_OBJECT_PLACEMENT_LABELS, type EditableMapObject } from './editor/types';
const PARAMETER_LABELS: Record<string, string> = {
sizeX: '尺寸 Xm',
sizeY: '尺寸 Ym',
sizeZ: '尺寸 Zm',
radius: '半径(m',
height: '高度(m',
length: '长度(m',
width: '宽度(m',
rise: '抬升(m',
thickness: '厚度(m',
stepDepth: '踏步深度(m',
stepHeight: '踏步高度(m',
count: '踏步数量',
};
const SURFACE_PRESETS = {
standard: {
label: '标准防滑材质',
rgb: [0.55, 0.6, 0.68] as const,
friction: [1, 0.005, 0.0001] as const,
},
concrete: {
label: '混凝土',
rgb: [0.55, 0.58, 0.62] as const,
friction: [1, 0.005, 0.0001] as const,
},
rubber: {
label: '橡胶',
rgb: [0.12, 0.14, 0.16] as const,
friction: [1.5, 0.008, 0.0002] as const,
},
metal: {
label: '金属',
rgb: [0.42, 0.48, 0.54] as const,
friction: [0.45, 0.003, 0.0001] as const,
},
ice: {
label: '低摩擦冰面',
rgb: [0.58, 0.82, 0.94] as const,
friction: [0.08, 0.001, 0.00001] as const,
},
} as const;
type SurfacePreset = keyof typeof SURFACE_PRESETS;
function close(a: number, b: number): boolean {
return Math.abs(a - b) < 0.015;
}
function selectedSurfacePreset(object: EditableMapObject): SurfacePreset | 'custom' {
for (const [key, preset] of Object.entries(SURFACE_PRESETS) as [
SurfacePreset,
(typeof SURFACE_PRESETS)[SurfacePreset],
][]) {
if (
preset.rgb.every((channel, index) => close(channel, object.rgba[index])) &&
close(preset.friction[0], object.friction[0])
)
return key;
}
return 'custom';
}
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 colorValue(object: EditableMapObject): string {
return `#${object.rgba
.slice(0, 3)
.map((channel) =>
Math.round(Math.min(1, Math.max(0, channel)) * 255)
.toString(16)
.padStart(2, '0'),
)
.join('')}`;
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="engineering-card rounded-lg border p-2.5">
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-[0.12em] text-text-tertiary">
{title}
</h3>
{children}
</section>
);
}
export function MapObjectInspector({
object,
loading,
onUpdate,
onDuplicate,
onDelete,
}: {
object: EditableMapObject;
loading: boolean;
onUpdate: (patch: Partial<Omit<EditableMapObject, 'id' | 'type'>>) => void;
onDuplicate: () => void;
onDelete: () => void;
}) {
const poseLocked = object.placementMode === 'locked';
const updatePosition = (index: number, value: number) => {
if (!Number.isFinite(value)) return;
const position = [...object.pose.position] as [number, number, number];
position[index] = value;
onUpdate({ pose: { ...object.pose, position } });
};
const updateParameter = (key: string, value: number) => {
if (!Number.isFinite(value) || value <= 0) return;
onUpdate({
parameters: {
...object.parameters,
[key]: key === 'count' ? Math.round(value) : value,
},
});
};
const updateFriction = (index: number, value: number) => {
if (!Number.isFinite(value) || value < 0) return;
const friction = [...object.friction] as [number, number, number];
friction[index] = value;
onUpdate({ friction });
};
return (
<div className="space-y-2" aria-label="地图物体检查器">
<Section title="对象">
<div className="space-y-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-2"
value={object.name}
disabled={loading}
onChange={(event) => {
if (event.target.value.trim()) onUpdate({ name: event.target.value });
}}
/>
</label>
<label className="flex items-center justify-between text-[11px] text-text-secondary">
<span></span>
<input
aria-label="启用地图对象"
type="checkbox"
checked={object.enabled}
disabled={loading}
onChange={(event) => onUpdate({ enabled: event.target.checked })}
/>
</label>
</div>
</Section>
<Section title="位姿">
<div className="mb-2 flex items-center justify-between rounded bg-surface/55 px-2 py-1.5 text-[10px] text-text-secondary">
<span>{MAP_OBJECT_PLACEMENT_LABELS[object.placementMode]}</span>
<span className="flex items-center gap-1 text-text-tertiary">
{poseLocked && <LockKeyhole className="h-3 w-3" aria-hidden="true" />}
</span>
</div>
<div className="grid grid-cols-3 gap-2">
{object.pose.position.map((value, index) => (
<ScrubbableNumberInput
key={index}
label={`${['X', 'Y', 'Z'][index]}m`}
aria-label={`对象位置${['X', 'Y', 'Z'][index]}`}
value={value}
step={0.1}
disabled={loading || poseLocked || index === 2}
onValueChange={(next) => updatePosition(index, next)}
/>
))}
</div>
<ScrubbableNumberInput
containerClassName="mt-2"
label="绕 Z 旋转(°)"
aria-label="对象绕Z旋转"
value={Number(yawDegrees(object).toFixed(4))}
step={1}
disabled={loading || poseLocked}
onValueChange={(next) => {
const half = (next * Math.PI) / 360;
onUpdate({
pose: {
...object.pose,
quaternion: [Math.cos(half), 0, 0, Math.sin(half)],
},
});
}}
/>
</Section>
<Section title="尺寸">
<div className="grid grid-cols-2 gap-2">
{Object.entries(object.parameters).map(([key, value]) => (
<ScrubbableNumberInput
key={key}
label={PARAMETER_LABELS[key] ?? key}
aria-label={`对象参数${key}`}
value={value}
min={0.001}
step={key === 'count' ? 1 : 0.1}
disabled={loading}
onValueChange={(next) => updateParameter(key, next)}
/>
))}
</div>
</Section>
<Section title="表面材质">
<div className="grid grid-cols-2 gap-2">
<label className="text-[10px] text-text-secondary">
<Select
aria-label="对象表面材质"
className="mt-1 w-full"
value={selectedSurfacePreset(object)}
disabled={loading}
onChange={(event) => {
if (event.target.value === 'custom') return;
const preset = SURFACE_PRESETS[event.target.value as SurfacePreset];
onUpdate({
rgba: [...preset.rgb, object.rgba[3]],
friction: [...preset.friction],
});
}}
>
<option value="custom"></option>
{(
Object.entries(SURFACE_PRESETS) as [
SurfacePreset,
(typeof SURFACE_PRESETS)[SurfacePreset],
][]
).map(([value, preset]) => (
<option key={value} value={value}>
{preset.label}
</option>
))}
</Select>
</label>
<label className="text-[10px] text-text-secondary">
<input
aria-label="对象颜色"
className="mt-1 h-7 w-full rounded border border-border-strong bg-input p-0.5"
type="color"
value={colorValue(object)}
disabled={loading}
onChange={(event) => {
const value = event.target.value;
if (!/^#[0-9a-f]{6}$/i.test(value)) return;
onUpdate({
rgba: [
Number.parseInt(value.slice(1, 3), 16) / 255,
Number.parseInt(value.slice(3, 5), 16) / 255,
Number.parseInt(value.slice(5, 7), 16) / 255,
object.rgba[3],
],
});
}}
/>
</label>
</div>
<ScrubbableNumberInput
containerClassName="mt-2"
label="不透明度"
aria-label="对象不透明度"
value={object.rgba[3]}
min={0}
max={1}
step={0.05}
disabled={loading}
onValueChange={(alpha) =>
onUpdate({
rgba: [object.rgba[0], object.rgba[1], object.rgba[2], alpha],
})
}
/>
</Section>
<Section title="摩擦力">
<div className="grid grid-cols-3 gap-2">
{object.friction.map((value, index) => (
<ScrubbableNumberInput
key={index}
label={['滑动', '扭转', '滚动'][index]}
aria-label={`对象${['滑动摩擦', '扭转摩擦', '滚动摩擦'][index]}`}
value={value}
min={0}
max={5}
step={index === 0 ? 0.05 : 0.0001}
disabled={loading}
onValueChange={(next) => updateFriction(index, next)}
/>
))}
</div>
</Section>
<div className="grid grid-cols-2 gap-2">
<Button disabled={loading} icon={<Copy className="h-3 w-3" />} onClick={onDuplicate}>
</Button>
<Button
variant="danger"
disabled={loading}
icon={<Trash2 className="h-3 w-3" />}
onClick={onDelete}
>
</Button>
</div>
</div>
);
}
@@ -1,64 +0,0 @@
import { Button } from '../components/ui';
import type { MapEditorTransformMode } from './editor/types';
export function MapTransformToolbar({
mode,
snapping,
loading,
allowScale = true,
hint,
onModeChange,
onSnappingChange,
}: {
mode: MapEditorTransformMode;
snapping: boolean;
loading: boolean;
allowScale?: boolean;
hint: string;
onModeChange: (mode: MapEditorTransformMode) => void;
onSnappingChange: (value: boolean) => void;
}) {
return (
<div
className="grid grid-cols-3 gap-2 rounded border border-border-subtle p-2"
aria-label="视口选择与变换"
>
<Button
variant={mode === 'translate' ? 'primary' : 'secondary'}
aria-pressed={mode === 'translate'}
disabled={loading}
onClick={() => onModeChange('translate')}
>
W
</Button>
<Button
variant={mode === 'rotate' ? 'primary' : 'secondary'}
aria-pressed={mode === 'rotate'}
disabled={loading}
onClick={() => onModeChange('rotate')}
>
E
</Button>
<Button
variant={mode === 'scale' ? 'primary' : 'secondary'}
aria-pressed={mode === 'scale'}
disabled={loading || !allowScale}
title={allowScale ? undefined : '参数地形请通过尺寸参数调整大小'}
onClick={() => onModeChange('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) => onSnappingChange(event.target.checked)}
/>
</label>
<p className="col-span-3 text-[10px] leading-relaxed text-text-tertiary">{hint}</p>
</div>
);
}
+4 -14
View File
@@ -17,8 +17,7 @@ const common = {
onEditorConvert: async () => true,
onEditorBindInteraction: () => {},
onEditorSelect: () => {},
onEditorTransformMode: () => {},
onEditorSnapping: () => {},
onEditorSessionStateChange: () => {},
onMapDisplay: () => {},
onDraft: () => {},
};
@@ -88,25 +87,16 @@ describe('PhysicalMapPanel', () => {
});
});
it('参数地形与认证资产共用视口变换界面', () => {
const onTransformMode = vi.fn();
const onSnapping = vi.fn();
it('参数地形属性不再承载视口变换工具', () => {
render(
<PhysicalMapPanel
{...common}
value={{ kind: 'builtin', config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'rough' } }}
onEditorTransformMode={onTransformMode}
onEditorSnapping={onSnapping}
onApply={() => {}}
/>,
);
expect(screen.getByLabelText('视口选择与变换')).toBeInTheDocument();
expect(screen.getByText(/点击空白区域取消选择/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: '缩放工具 S' })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: '旋转工具 E' }));
expect(onTransformMode).toHaveBeenLastCalledWith('rotate');
fireEvent.click(screen.getByLabelText('地图编辑吸附'));
expect(onSnapping).toHaveBeenLastCalledWith(null, null);
expect(screen.queryByLabelText('地图变换模式')).not.toBeInTheDocument();
expect(screen.getByText(/已统一到视口浮动工具条/)).toBeInTheDocument();
});
it('表单参数与外部视口变换合并到同一份草稿', () => {
+56 -65
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Button, Select } from '../components/ui';
import { useState } from 'react';
import { Button, ScrubbableNumberInput, Select } from '../components/ui';
import type { MapEntry } from '../project/types';
import {
DEFAULT_PHYSICAL_MAP_CONFIG,
@@ -15,10 +15,9 @@ import { normalizePhysicalMapConfig } from './physicalMap';
import type {
EditableMapDocument,
MapEditorInteractionCallbacks,
MapEditorTransformMode,
MapEditorSessionState,
} from './editor/types';
import { MapEditorPanel } from './MapEditorPanel';
import { MapTransformToolbar } from './MapTransformToolbar';
function NumberField({
label,
@@ -38,19 +37,15 @@ function NumberField({
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>
<ScrubbableNumberInput
label={label}
value={value}
min={min}
max={max}
step={step}
disabled={disabled}
onValueChange={onChange}
/>
);
}
@@ -87,10 +82,10 @@ export function PhysicalMapPanel({
onEditorApply,
onEditorExport,
onEditorConvert,
selectedEditorObjectId,
onEditorBindInteraction,
onEditorSelect,
onEditorTransformMode,
onEditorSnapping,
onEditorSessionStateChange,
onEditorSurfaceHeight,
onMapDisplay,
onDraft,
@@ -115,28 +110,24 @@ export function PhysicalMapPanel({
onEditorApply: (document: EditableMapDocument) => Promise<boolean>;
onEditorExport: () => void;
onEditorConvert: () => Promise<boolean>;
selectedEditorObjectId?: string;
onEditorBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
onEditorSelect: (id: string | null) => void;
onEditorTransformMode: (mode: MapEditorTransformMode) => void;
onEditorSnapping: (translation: number | null, rotationDegrees: number | null) => void;
onEditorSessionStateChange?: (
descriptorPath: string,
state: MapEditorSessionState | null,
) => void;
onEditorSurfaceHeight?: (position: readonly [number, number, number]) => number | null;
onMapDisplay: (visual: boolean, collision: boolean) => void;
onDraft: (value: PlacedMapSelection) => void;
onApply: (value: MapSelection) => void;
}) {
const [draft, setDraft] = useState<MapSelection>(value),
[previousValue, setPreviousValue] = useState(value),
[terrainTransformMode, setTerrainTransformMode] = useState<MapEditorTransformMode>('translate'),
[terrainSnapping, setTerrainSnapping] = useState(true);
[previousValue, setPreviousValue] = useState(value);
if (previousValue !== value) {
setPreviousValue(value);
setDraft(value);
}
useEffect(() => {
if (draft.kind !== 'builtin') return;
onEditorTransformMode(terrainTransformMode);
onEditorSnapping(terrainSnapping ? 0.1 : null, terrainSnapping ? 5 : null);
}, [draft.kind, terrainTransformMode, terrainSnapping, onEditorTransformMode, onEditorSnapping]);
const normalized = normalizeSelection(draft);
const changed = JSON.stringify(normalized) !== JSON.stringify(normalizeSelection(value));
const compileDisabled = loading || (nativeUrdf && normalized.kind !== 'none');
@@ -187,6 +178,37 @@ export function PhysicalMapPanel({
});
}
};
const editorPanel =
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">
{selectedEditorObjectId ? '选中物体属性' : '认证资产与场景对象属性'}
</div>
<p className="text-[10px] leading-relaxed text-text-tertiary">
{selectedEditorObjectId
? '尺寸、位姿、表面材质与摩擦力会实时写入 MapEditSession 草稿。'
: '此处编辑地图源内容;同源实例共享内容,实例位姿彼此独立。'}
</p>
<MapEditorPanel
document={editorDocument}
draftDocument={editorDraftDocument}
descriptorPath={draft.descriptorPath}
loading={loading}
onPreview={onEditorPreview}
onDraftChange={onEditorDraftChange}
onApply={onEditorApply}
onExport={onEditorExport}
onConvert={onEditorConvert}
canConvert={Boolean(projectMap?.physicsPath)}
selectedObjectId={selectedEditorObjectId}
onBindInteraction={onEditorBindInteraction}
onSelectPreview={onEditorSelect}
onSessionStateChange={onEditorSessionStateChange}
onSurfaceHeight={onEditorSurfaceHeight}
/>
</div>
) : null;
if (value.kind === 'none' && draft.kind === 'none')
return (
@@ -245,6 +267,8 @@ export function PhysicalMapPanel({
</p>
</div>
{editorPanel}
<label className="block text-xs text-text-secondary">
<span className="mb-1 block"></span>
<Select
@@ -267,15 +291,9 @@ export function PhysicalMapPanel({
{draft.kind === 'builtin' && (
<>
<div className="text-xs font-medium text-text-primary"></div>
<MapTransformToolbar
mode={terrainTransformMode}
snapping={terrainSnapping}
loading={loading}
allowScale={false}
hint="在视口点击参数地形后拖动操纵轴;点击空白区域取消选择。移动和旋转先进入草稿,确认后再编译。"
onModeChange={setTerrainTransformMode}
onSnappingChange={setTerrainSnapping}
/>
<p className="rounded border border-border-subtle bg-panel-muted/50 p-2 text-[10px] leading-relaxed text-text-tertiary">
</p>
<label className="block text-xs text-text-secondary">
<span className="mb-1 block"></span>
<Select
@@ -558,33 +576,6 @@ export function PhysicalMapPanel({
</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"></div>
<p className="text-[10px] leading-relaxed text-text-tertiary">
姿
</p>
<MapEditorPanel
document={editorDocument}
draftDocument={editorDraftDocument}
descriptorPath={draft.descriptorPath}
loading={loading}
onPreview={onEditorPreview}
onDraftChange={onEditorDraftChange}
onApply={onEditorApply}
onExport={onEditorExport}
onConvert={onEditorConvert}
canConvert={Boolean(projectMap?.physicsPath)}
onBindInteraction={onEditorBindInteraction}
onSelectPreview={onEditorSelect}
onTransformMode={onEditorTransformMode}
onSnapping={onEditorSnapping}
onSurfaceHeight={onEditorSurfaceHeight}
/>
</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>
@@ -41,6 +41,12 @@ export class MapEditSession {
get dirty(): boolean {
return JSON.stringify(this.current) !== JSON.stringify(this.saved);
}
/**
* 稿
*/
get changeCount(): number {
return this.dirty ? Math.max(1, this.undoStack.length) : 0;
}
get canUndo(): boolean {
return this.undoStack.length > 0;
}
@@ -73,7 +73,9 @@ describe('地图 V3 编辑核心', () => {
const session = new MapEditSession(document());
const object = session.add('box');
expect(session.dirty).toBe(true);
expect(session.changeCount).toBe(1);
session.update(object.id, { name: '墙' });
expect(session.changeCount).toBe(2);
expect(session.document.objects[0].name).toBe('墙');
const copy = session.duplicate(object.id);
expect(copy).toMatchObject({ name: '墙 副本', pose: { position: [0.2, 0.2, 0.5] } });
@@ -95,6 +97,7 @@ describe('地图 V3 编辑核心', () => {
expect(session.document.spawnPoints).toHaveLength(1);
session.discard();
expect(session.dirty).toBe(false);
expect(session.changeCount).toBe(0);
});
it('重新挂载时恢复未提交草稿并保持 dirty', () => {
@@ -103,6 +106,7 @@ describe('地图 V3 编辑核心', () => {
draft.objects.push(createEditableObject('box', 'pending_box'));
const session = new MapEditSession(saved, draft);
expect(session.dirty).toBe(true);
expect(session.changeCount).toBe(1);
expect(session.document.objects[0].id).toBe('pending_box');
session.discard();
expect(session.dirty).toBe(false);
+14
View File
@@ -21,6 +21,16 @@ export interface MapEditorTransform {
scale: [number, number, number];
}
export interface MapEditorSessionState {
dirty: boolean;
changeCount: number;
canUndo: boolean;
canRedo: boolean;
}
/**
* MapEditSession
*/
export interface MapEditorInteractionCallbacks {
onSelect(id: string | null): void;
onTransform(transform: MapEditorTransform): void;
@@ -30,6 +40,10 @@ export interface MapEditorInteractionCallbacks {
placementMode?: MapObjectPlacementMode,
externalSupportTop?: number,
): void;
onSetPlacementMode(id: string, mode: MapObjectPlacementMode): void;
onAlignToSurface(id: string): void;
onDelete(id: string): void;
onDiscard(): void;
}
export interface EditableMapObject {
+57 -11
View File
@@ -36,12 +36,20 @@ function BodyBranch({
node,
depth,
onJointHover,
onSelectBody,
onSelectJoint,
selectedBodyId,
selectedJointId,
searching,
query,
}: {
node: BodyNode;
depth: number;
onJointHover: (jointId: number | null) => void;
onSelectBody?: (bodyId: number) => void;
onSelectJoint?: (joint: JointInfo) => void;
selectedBodyId?: number;
selectedJointId?: number;
searching: boolean;
query: string;
}) {
@@ -60,11 +68,15 @@ function BodyBranch({
<summary
role="treeitem"
aria-expanded={shownOpen}
aria-selected={selectedBodyId === node.id}
tabIndex={0}
onClick={(event) => {
onSelectBody?.(node.id);
if (searching) event.preventDefault();
}}
className="flex cursor-pointer select-none items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary hover:bg-element-hover focus-visible:ring-2 focus-visible:ring-accent/30"
className={`flex cursor-pointer select-none items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs hover:bg-element-hover focus-visible:ring-2 focus-visible:ring-accent/30 ${
selectedBodyId === node.id ? 'bg-accent-soft text-accent' : 'text-text-secondary'
}`}
>
<Box aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent" />
<span className="truncate">
@@ -74,10 +86,14 @@ function BodyBranch({
<ul role="group" className="ml-3 border-l border-border pl-1">
{node.joints.map((joint) => (
<li role="none" key={joint.id}>
<span
<button
type="button"
role="treeitem"
tabIndex={0}
className="flex cursor-default items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-warning hover:bg-warning-soft focus:bg-warning-soft focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/30"
aria-selected={selectedJointId === joint.id}
className={`flex w-full items-center gap-1.5 truncate rounded px-1.5 py-1 text-left text-xs text-warning hover:bg-warning-soft focus:bg-warning-soft focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 ${
selectedJointId === joint.id ? 'bg-warning-soft' : ''
}`}
onClick={() => onSelectJoint?.(joint)}
onMouseEnter={() => onJointHover(joint.id)}
onMouseLeave={() => onJointHover(null)}
onFocus={() => onJointHover(joint.id)}
@@ -86,7 +102,7 @@ function BodyBranch({
>
<Disc3 aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
<SearchHighlight text={joint.name} query={query} />
</span>
</button>
</li>
))}
{node.children.map((child) => (
@@ -95,6 +111,10 @@ function BodyBranch({
node={child}
depth={depth + 1}
onJointHover={onJointHover}
onSelectBody={onSelectBody}
onSelectJoint={onSelectJoint}
selectedBodyId={selectedBodyId}
selectedJointId={selectedJointId}
searching={searching}
query={query}
/>
@@ -102,14 +122,20 @@ function BodyBranch({
</ul>
</details>
) : (
<div
<button
type="button"
role="treeitem"
tabIndex={0}
className="flex items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary focus-visible:ring-2 focus-visible:ring-accent/30"
aria-selected={selectedBodyId === node.id}
className={`flex w-full items-center gap-1.5 truncate rounded px-1.5 py-1 text-left text-xs focus-visible:ring-2 focus-visible:ring-accent/30 ${
selectedBodyId === node.id
? 'bg-accent-soft text-accent'
: 'text-text-secondary hover:bg-element-hover'
}`}
onClick={() => onSelectBody?.(node.id)}
>
<Box aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent" />
<SearchHighlight text={node.name} query={query} />
</div>
</button>
)}
</li>
);
@@ -169,11 +195,19 @@ export function ModelStructureTree({
bodies,
joints,
onJointHover,
onSelectBody,
onSelectJoint,
selectedBodyId,
selectedJointId,
query = '',
}: {
bodies: BodyInfo[];
joints: JointInfo[];
onJointHover: (jointId: number | null) => void;
onSelectBody?: (bodyId: number) => void;
onSelectJoint?: (joint: JointInfo) => void;
selectedBodyId?: number;
selectedJointId?: number;
query?: string;
}) {
const normalized = query.trim().toLocaleLowerCase(),
@@ -211,8 +245,12 @@ export function ModelStructureTree({
renderRow={(item) =>
item.kind === 'body' ? (
<div
aria-selected={selectedBodyId === item.body.id}
onClick={() => onSelectBody?.(item.body.id)}
onDoubleClick={() => toggle(item)}
className="flex h-full items-center gap-1.5 px-1.5 text-xs text-text-secondary"
className={`flex h-full items-center gap-1.5 px-1.5 text-xs ${
selectedBodyId === item.body.id ? 'text-accent' : 'text-text-secondary'
}`}
style={{ paddingLeft: item.depth * 12 + 6 }}
>
<Box className="h-3.5 w-3.5 text-accent" />
@@ -220,8 +258,12 @@ export function ModelStructureTree({
</div>
) : (
<div
className="flex h-full items-center gap-1.5 px-1.5 text-xs text-warning"
aria-selected={selectedJointId === item.joint.id}
className={`flex h-full items-center gap-1.5 px-1.5 text-xs text-warning ${
selectedJointId === item.joint.id ? 'bg-warning-soft' : ''
}`}
style={{ paddingLeft: item.depth * 12 + 6 }}
onClick={() => onSelectJoint?.(item.joint)}
onMouseEnter={() => onJointHover(item.joint.id)}
onMouseLeave={() => onJointHover(null)}
>
@@ -244,6 +286,10 @@ export function ModelStructureTree({
node={root}
depth={0}
onJointHover={onJointHover}
onSelectBody={onSelectBody}
onSelectJoint={onSelectJoint}
selectedBodyId={selectedBodyId}
selectedJointId={selectedJointId}
searching={Boolean(normalized)}
query={query}
/>
+25
View File
@@ -2,6 +2,7 @@ import { zipSync } from 'fflate';
import {
choosePreferredEntry,
discoverEntries,
filesFromDrop,
importBrowserFiles,
normalizeProjectPath,
prepareProjectForMujoco,
@@ -35,6 +36,30 @@ describe('project importer', () => {
expect(entries).toHaveLength(3);
expect(choosePreferredEntry(entries)).toBe('model.xml');
});
it('通过现代文件系统句柄递归读取拖入的文件夹', async () => {
const model = new File(['<mujoco/>'], 'model.xml', { type: 'text/xml' });
const fileHandle = {
kind: 'file',
name: 'model.xml',
getFile: async () => model,
};
const directoryHandle = {
kind: 'directory',
name: 'robot',
async *values() {
yield fileHandle;
},
};
const items = [
{
kind: 'file',
getAsFileSystemHandle: async () => directoryHandle,
},
] as unknown as DataTransferItemList;
const files = await filesFromDrop(items, [] as unknown as FileList);
expect(files).toHaveLength(1);
expect(files[0].webkitRelativePath).toBe('robot/model.xml');
});
it('异步解压 ZIP、保留二进制数据并报告阶段进度', async () => {
const zipped = zipSync({
'robot/model.urdf': encode('<robot name="r"/>'),
+44 -1
View File
@@ -427,6 +427,29 @@ export async function importBrowserFiles(
return manifest(files[0].path.split('/')[0] || '工程', files);
}
interface DroppedFileSystemHandle {
kind: 'file' | 'directory';
name: string;
getFile?: () => Promise<File>;
values?: () => AsyncIterableIterator<DroppedFileSystemHandle>;
}
async function readFileSystemHandle(handle: DroppedFileSystemHandle, prefix = ''): Promise<File[]> {
if (handle.kind === 'file' && handle.getFile) {
const file = await handle.getFile();
Object.defineProperty(file, 'webkitRelativePath', {
configurable: true,
value: `${prefix}${file.name}`,
});
return [file];
}
if (handle.kind !== 'directory' || !handle.values) return [];
const files: File[] = [];
for await (const child of handle.values())
files.push(...(await readFileSystemHandle(child, `${prefix}${handle.name}/`)));
return files;
}
interface LegacyEntry {
isFile: boolean;
isDirectory: boolean;
@@ -464,7 +487,27 @@ export async function filesFromDrop(
items: DataTransferItemList,
fallback: FileList,
): Promise<File[]> {
const entries = Array.from(items)
const fileItems = Array.from(items).filter((item) => item.kind === 'file');
const handleRequests = fileItems
.map((item) => {
const getter = (
item as unknown as {
getAsFileSystemHandle?: () => Promise<DroppedFileSystemHandle | null>;
}
).getAsFileSystemHandle;
return getter?.call(item);
})
.filter((request): request is Promise<DroppedFileSystemHandle | null> => Boolean(request));
if (handleRequests.length > 0 && handleRequests.length === fileItems.length) {
const settled = await Promise.allSettled(handleRequests);
const handles = settled.flatMap((result) =>
result.status === 'fulfilled' && result.value ? [result.value] : [],
);
if (handles.length === fileItems.length)
return (await Promise.all(handles.map((handle) => readFileSystemHandle(handle)))).flat();
}
const entries = fileItems
.map(
(item) =>
(item as unknown as { webkitGetAsEntry?: () => LegacyEntry | null }).webkitGetAsEntry?.() ??
+10 -20
View File
@@ -1,5 +1,5 @@
import type { ActuatorInfo, ActuatorParameters } from './SimulationSession';
import { Badge } from '../components/ui';
import { Badge, ScrubbableNumberInput } from '../components/ui';
export function ActuatorControl({
actuator,
@@ -187,25 +187,15 @@ function ParameterInput({
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>
<ScrubbableNumberInput
label={label}
value={Number.isFinite(value) ? value : 0}
step={Math.max(0.001, Math.abs(value) * 0.01)}
disabled={disabled}
onValueChange={(next) => {
if (next !== value) onCommit(next);
}}
/>
);
}
function ParameterToggle({
@@ -0,0 +1,327 @@
import type { ReactNode } from 'react';
import { Box, Disc3, Info } from 'lucide-react';
import type { ModelEntry } from '../project/types';
import type { UrdfBaseMode, UrdfLoadMode } from './PhysicsAdapter';
import type { ViewerSelection } from '../viewer/MuJoCoViewer';
import {
Badge,
Button,
CollapsibleSection,
CopyButton,
PropertyRow,
Select,
} from '../components/ui';
import { ActuatorControl, Check, ControlSlider } from './ActuatorControl';
import type {
ActuatorParameters,
BodyInfo,
JointInfo,
SimulationSnapshot,
} from './SimulationSession';
const JOINT_TYPE_LABELS: Record<number, string> = {
0: 'Free',
1: 'Ball',
2: 'Slide',
3: 'Hinge',
};
export function InspectorIdentity({
icon,
eyebrow,
name,
meta,
}: {
icon: ReactNode;
eyebrow: string;
name: string;
meta?: string;
}) {
return (
<header className="flex items-center gap-3 border-b border-border bg-surface/70 px-3 py-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-lg border border-border-subtle bg-panel-muted text-accent [&>svg]:h-4 [&>svg]:w-4">
{icon}
</span>
<div className="min-w-0 flex-1">
<div className="text-[10px] font-medium uppercase tracking-wider text-text-tertiary">
{eyebrow}
</div>
<div className="truncate text-sm font-semibold text-text-primary" title={name}>
{name}
</div>
{meta && <div className="mt-0.5 truncate text-[10px] text-text-tertiary">{meta}</div>}
</div>
</header>
);
}
export function ModelSummaryInspector({
snapshot,
selectedFormat,
loading,
urdfMode,
baseMode,
showCollision,
onUrdfMode,
onBaseMode,
onShowCollision,
}: {
snapshot: SimulationSnapshot;
selectedFormat?: ModelEntry['format'];
loading: boolean;
urdfMode: UrdfLoadMode;
baseMode: UrdfBaseMode;
showCollision: boolean;
onUrdfMode: (value: UrdfLoadMode) => void;
onBaseMode: (value: UrdfBaseMode) => void;
onShowCollision: (value: boolean) => void;
}) {
return (
<>
<InspectorIdentity
icon={<Info />}
eyebrow="场景"
name="未选择对象"
meta="从场景大纲或视口选择 Body、Joint 或地图物体"
/>
<CollapsibleSection
title="模型摘要"
defaultOpen
badge={<Badge>{snapshot.model.nbody} Body</Badge>}
>
<PropertyRow label="Body" value={snapshot.model.nbody} />
<PropertyRow label="Joint" value={snapshot.model.njnt} />
<PropertyRow label="Geom" value={snapshot.model.ngeom} />
<PropertyRow label="Actuator" value={snapshot.model.nactuator} />
<PropertyRow label="qpos / qvel" value={`${snapshot.model.nq} / ${snapshot.model.nv}`} />
</CollapsibleSection>
{selectedFormat === 'urdf' && (
<CollapsibleSection title="URDF 导入设置" defaultOpen>
<Select
aria-label="URDF 处理方式"
className="w-full"
value={urdfMode}
disabled={loading}
onChange={(event) => 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={baseMode}
disabled={loading || urdfMode === 'native'}
onChange={(event) => onBaseMode(event.target.value as UrdfBaseMode)}
>
<option value="floating">Free Joint</option>
<option value="fixed"></option>
</Select>
</label>
<Check label="显示碰撞几何" checked={showCollision} onChange={onShowCollision} />
</CollapsibleSection>
)}
</>
);
}
export function BodyInspector({
body,
snapshot,
viewerSelection,
showCollision,
onShowCollision,
onSelectJoint,
}: {
body: BodyInfo;
snapshot: SimulationSnapshot;
viewerSelection: ViewerSelection | null;
showCollision: boolean;
onShowCollision: (value: boolean) => void;
onSelectJoint: (jointId: number, bodyId: number) => void;
}) {
const parent = snapshot.bodies.find((candidate) => candidate.id === body.parentId);
const joints = snapshot.joints.filter((joint) => joint.bodyId === body.id);
const children = snapshot.bodies.filter((candidate) => candidate.parentId === body.id);
const hit = viewerSelection?.bodyId === body.id ? viewerSelection : null;
return (
<>
<InspectorIdentity
icon={<Box />}
eyebrow="Robot / Body"
name={body.name}
meta={`Body #${body.id}`}
/>
<CollapsibleSection title="标识与层级" defaultOpen>
<PropertyRow
label="名称"
value={body.name}
action={<CopyButton value={body.name} label="复制 Body 名称" />}
/>
<PropertyRow label="Body ID" value={body.id} />
<PropertyRow label="父级" value={parent?.name ?? 'world'} />
<PropertyRow label="子 Body" value={children.length} />
<PropertyRow label="Joint" value={joints.length} />
</CollapsibleSection>
{hit && (
<CollapsibleSection title="视口命中" defaultOpen>
<PropertyRow label="Geom ID" value={hit.geomId} />
<PropertyRow label="Geom Type" value={hit.geomType} />
<PropertyRow
label="世界位置"
value={hit.position.map((value) => value.toFixed(3)).join(', ')}
action={<CopyButton value={hit.position.join(', ')} label="复制世界位置" />}
/>
</CollapsibleSection>
)}
<CollapsibleSection title="显示" defaultOpen>
<Check label="显示碰撞几何" checked={showCollision} onChange={onShowCollision} />
</CollapsibleSection>
{joints.length > 0 && (
<CollapsibleSection title="所属关节" defaultOpen badge={<Badge>{joints.length}</Badge>}>
<div className="space-y-1">
{joints.map((joint) => (
<button
key={joint.id}
type="button"
className="flex w-full items-center gap-2 rounded border border-border-subtle px-2 py-1.5 text-left text-xs text-text-secondary hover:border-accent hover:bg-accent-soft"
onClick={() => onSelectJoint(joint.id, joint.bodyId)}
>
<Disc3 className="h-3.5 w-3.5 text-warning" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">{joint.name}</span>
<span className="text-[9px] text-text-tertiary">
{JOINT_TYPE_LABELS[joint.type] ?? joint.type}
</span>
</button>
))}
</div>
</CollapsibleSection>
)}
{!hit && (
<p className="flex items-center gap-2 p-3 text-[10px] leading-relaxed text-text-tertiary">
<Info className="h-3.5 w-3.5 shrink-0" />
Body Body
</p>
)}
</>
);
}
function jointScale(joint: JointInfo, angleUnit: 'rad' | 'deg'): number {
return joint.type === 3 && angleUnit === 'deg' ? 180 / Math.PI : 1;
}
export function JointInspector({
joint,
snapshot,
loading,
ignoreJointLimits,
jointAdvanced,
angleUnit,
onResetJoints,
onToggleJointLimits,
onToggleAdvanced,
onToggleAngleUnit,
onJoint,
onActuator,
onActuatorParameters,
}: {
joint: JointInfo;
snapshot: SimulationSnapshot;
loading: boolean;
ignoreJointLimits: boolean;
jointAdvanced: boolean;
angleUnit: 'rad' | 'deg';
onResetJoints: () => void;
onToggleJointLimits: () => void;
onToggleAdvanced: () => void;
onToggleAngleUnit: () => void;
onJoint: (id: number, value: number) => void;
onActuator: (id: number, value: number) => void;
onActuatorParameters: (id: number, parameters: ActuatorParameters) => void;
}) {
const body = snapshot.bodies.find((candidate) => candidate.id === joint.bodyId);
const actuators = snapshot.actuators.filter((actuator) => actuator.jointId === joint.id);
const scale = jointScale(joint, angleUnit);
const unit =
joint.type === 3 ? (angleUnit === 'deg' ? '°' : ' rad') : joint.type === 2 ? ' m' : '';
return (
<>
<InspectorIdentity
icon={<Disc3 />}
eyebrow="Robot / Joint"
name={joint.name}
meta={`${JOINT_TYPE_LABELS[joint.type] ?? `Type ${joint.type}`} · ${body?.name ?? `Body ${joint.bodyId}`}`}
/>
<CollapsibleSection title="关节属性" defaultOpen>
<PropertyRow label="Joint ID" value={joint.id} />
<PropertyRow label="所属 Body" value={body?.name ?? joint.bodyId} />
<PropertyRow label="轴向" value={joint.axis.map((value) => value.toFixed(3)).join(', ')} />
<PropertyRow label="限位" value={joint.limited ? '启用' : '未启用'} />
<div className="mt-3">
<ControlSlider
label={`${joint.name}${joint.editable ? '' : '(只读)'}`}
value={joint.value * scale}
min={joint.min * scale}
max={joint.max * scale}
unit={unit}
advanced={jointAdvanced}
limited={joint.limited}
limitsIgnored={joint.limitsIgnored}
limitMin={joint.limitMin * scale}
limitMax={joint.limitMax * scale}
disabled={loading || !joint.editable}
onChange={(value) => onJoint(joint.id, value / scale)}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<Button disabled={loading} onClick={onResetJoints}>
</Button>
<Button
disabled={loading}
variant={ignoreJointLimits ? 'primary' : 'secondary'}
aria-pressed={ignoreJointLimits}
onClick={onToggleJointLimits}
>
</Button>
<Button
variant={jointAdvanced ? 'primary' : 'secondary'}
aria-pressed={jointAdvanced}
onClick={onToggleAdvanced}
>
</Button>
<Button
variant={angleUnit === 'deg' ? 'primary' : 'secondary'}
aria-pressed={angleUnit === 'deg'}
onClick={onToggleAngleUnit}
>
{angleUnit === 'rad' ? 'rad 弧度制' : '° 角度制'}
</Button>
</div>
</CollapsibleSection>
<CollapsibleSection
title="关联 Actuator"
defaultOpen
badge={<Badge>{actuators.length}</Badge>}
>
{actuators.length ? (
actuators.map((actuator) => (
<ActuatorControl
key={actuator.id}
actuator={actuator}
onControl={(value) => onActuator(actuator.id, value)}
onParameters={(parameters) => onActuatorParameters(actuator.id, parameters)}
/>
))
) : (
<p className="text-xs text-text-tertiary"></p>
)}
</CollapsibleSection>
</>
);
}
+104 -3
View File
@@ -29,6 +29,9 @@
--ui-success-border: #a7ddca;
--ui-scrollbar: #a9b5c4;
--ui-scrollbar-hover: #7f8ea1;
--ui-glass: rgb(255 255 255 / 78%);
--ui-glass-highlight: rgb(255 255 255 / 72%);
--ui-shadow: rgb(15 23 42 / 18%);
color-scheme: light;
}
.theme-dark {
@@ -58,6 +61,9 @@
--ui-success-border: #285f50;
--ui-scrollbar: #46566c;
--ui-scrollbar-hover: #61728a;
--ui-glass: rgb(14 25 39 / 78%);
--ui-glass-highlight: rgb(255 255 255 / 7%);
--ui-shadow: rgb(0 0 0 / 48%);
color-scheme: dark;
}
@layer base {
@@ -106,15 +112,91 @@
.technical-value {
font-variant-numeric: tabular-nums;
}
.workbench-header {
background: linear-gradient(
90deg,
color-mix(in srgb, var(--ui-panel) 94%, var(--ui-surface)),
var(--ui-panel) 42%,
color-mix(in srgb, var(--ui-panel) 88%, var(--ui-accent) 12%)
);
}
.viewport-shell {
isolation: isolate;
background:
radial-gradient(
circle at 50% 18%,
color-mix(in srgb, var(--ui-accent) 8%, transparent),
transparent 34%
circle at 50% 14%,
color-mix(in srgb, var(--ui-accent) 10%, transparent),
transparent 35%
),
linear-gradient(145deg, var(--ui-surface), var(--ui-bg));
}
.viewport-shell::before {
content: '';
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background: linear-gradient(180deg, rgb(255 255 255 / 2%), transparent 22%, rgb(0 0 0 / 7%));
}
.engineering-glass {
border-color: color-mix(in srgb, var(--ui-border-strong) 72%, transparent);
background:
linear-gradient(135deg, var(--ui-glass-highlight), transparent 42%), var(--ui-glass);
box-shadow:
0 16px 44px var(--ui-shadow),
inset 0 1px 0 color-mix(in srgb, white 10%, transparent),
inset 0 -1px 0 color-mix(in srgb, black 10%, transparent);
backdrop-filter: blur(16px) saturate(135%);
}
.engineering-card {
border-color: color-mix(in srgb, var(--ui-border) 88%, transparent);
background: linear-gradient(
145deg,
color-mix(in srgb, var(--ui-surface) 88%, transparent),
color-mix(in srgb, var(--ui-panel) 72%, transparent)
);
box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent);
}
.tool-active-glow {
box-shadow: 0 0 14px color-mix(in srgb, var(--ui-accent) 14%, transparent);
}
.engineering-badge {
background-image: linear-gradient(180deg, rgb(255 255 255 / 5%), transparent);
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 5%),
0 3px 10px rgb(0 0 0 / 9%);
letter-spacing: 0.01em;
}
.map-tool-enter {
animation: map-tool-enter 220ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.draft-dirty-dot {
animation: draft-dirty-pulse 1.8s ease-in-out infinite;
}
.scrubbable-number {
@apply min-w-0 text-[10px] text-text-secondary;
}
.scrubbable-number-label {
@apply mb-1 flex h-4 max-w-full cursor-ew-resize select-none items-center gap-1 border-0 bg-transparent p-0 text-left text-inherit outline-none transition-colors hover:text-accent disabled:cursor-not-allowed disabled:opacity-40;
}
.scrubbable-number-input {
@apply h-7 w-full rounded-md border border-border-strong bg-input px-2 text-xs text-text-primary outline-none transition-[border-color,box-shadow,background-color] hover:border-accent focus:border-accent focus:ring-2 focus:ring-accent disabled:cursor-not-allowed disabled:opacity-40;
font-variant-numeric: tabular-nums;
}
.scrubbable-number-active .scrubbable-number-label {
@apply text-accent;
}
.scrubbable-number-active .scrubbable-number-input {
border-color: var(--ui-accent);
box-shadow:
0 0 0 2px color-mix(in srgb, var(--ui-accent) 20%, transparent),
0 0 18px color-mix(in srgb, var(--ui-accent) 12%, transparent);
}
body.is-scrubbing-number,
body.is-scrubbing-number * {
cursor: ew-resize !important;
user-select: none !important;
}
.workspace-welcome {
animation: workspace-enter 360ms cubic-bezier(0.22, 1, 0.36, 1) both;
box-shadow:
@@ -134,6 +216,25 @@
transform: translateX(-50%);
}
}
@keyframes map-tool-enter {
from {
opacity: 0;
transform: translate(-50%, -6px) scale(0.985);
}
to {
opacity: 1;
transform: translate(-50%, 0) scale(1);
}
}
@keyframes draft-dirty-pulse {
0%,
100% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--ui-warning) 28%, transparent);
}
50% {
box-shadow: 0 0 0 5px transparent;
}
}
@keyframes workspace-enter {
from {
opacity: 0;
@@ -70,16 +70,18 @@ describe('LocalTrainingPanel', () => {
);
vi.stubGlobal('fetch', fetchMock);
render(<LocalTrainingPanel onPolicyReady={vi.fn()} />);
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
const dashboardButton = screen.getByRole('button', { name: '打开自调参 Agent 工作台' });
expect(dashboardButton).toBeEnabled();
fireEvent.click(dashboardButton);
expect(open).toHaveBeenCalledWith(expect.any(URL), '_blank');
expect(String(open.mock.calls[0][0])).toContain('tuning.html');
expect(String(open.mock.calls[0][0])).not.toContain('secret-token');
fireEvent.change(screen.getByLabelText('训练服务访问令牌'), {
target: { value: 'secret-token' },
});
fireEvent.click(screen.getByRole('button', { name: '连接' }));
expect(await screen.findByText('/opt/unitree_rl_mjlab')).toBeInTheDocument();
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
fireEvent.click(screen.getByRole('button', { name: '打开自调参 Agent 工作台' }));
expect(open).toHaveBeenCalledWith(expect.any(URL), '_blank');
expect(String(open.mock.calls[0][0])).toContain('tuning.html');
expect(String(open.mock.calls[0][0])).not.toContain('secret-token');
fireEvent.change(screen.getByLabelText('并行环境'), { target: { value: '32' } });
fireEvent.click(screen.getByRole('button', { name: '发起本地训练' }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3));
@@ -280,15 +280,13 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
{server?.ready ? '可用' : '离线'}
</Badge>
</div>
{server?.ready && (
<Button
className="mt-2 w-full"
icon={<ExternalLink className="h-3.5 w-3.5" />}
onClick={openTuningDashboard}
>
Agent
</Button>
)}
<Button
className="mt-2 w-full"
icon={<ExternalLink className="h-3.5 w-3.5" />}
onClick={openTuningDashboard}
>
Agent
</Button>
{server?.ready && !job && (
<div className="mt-3 space-y-2">
<Field label="训练任务">
+133 -9
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef } from 'react';
import { RotateCcw, ZoomIn, ZoomOut } from 'lucide-react';
import uPlot from 'uplot';
import type { ScalarSeries } from '../training/types';
@@ -14,8 +15,19 @@ function smooth(values: (number | null)[], factor: number): (number | null)[] {
});
}
export function ScalarChart({ series, smoothing }: { series: ScalarSeries[]; smoothing: number }) {
export function ScalarChart({
series,
smoothing,
title = '训练与评估 Scalars',
}: {
series: ScalarSeries[];
smoothing: number;
title?: string;
}) {
const host = useRef<HTMLDivElement>(null);
const chartRef = useRef<uPlot | null>(null);
const trackZoom = useRef(false);
const zoomRanges = useRef<Partial<Record<'x' | 'y', { min: number; max: number }>>>({});
const prepared = useMemo(() => {
const steps = Array.from(
new Set(series.flatMap((item) => item.points.map((point) => point.step))),
@@ -36,13 +48,24 @@ export function ScalarChart({ series, smoothing }: { series: ScalarSeries[]; smo
useEffect(() => {
if (!host.current || series.length === 0 || prepared[0].length === 0) return;
const element = host.current;
const width = Math.max(280, Math.floor(element.getBoundingClientRect().width));
trackZoom.current = false;
const chart = new uPlot(
{
width: Math.max(320, element.clientWidth),
height: 360,
title: '训练与评估 Scalars',
width,
height: 280,
cursor: { drag: { x: true, y: true, setScale: true } },
scales: { x: { time: false } },
hooks: {
setScale: [
(instance, key) => {
if (!trackZoom.current || (key !== 'x' && key !== 'y')) return;
const scale = instance.scales[key];
if (typeof scale.min === 'number' && typeof scale.max === 'number')
zoomRanges.current[key] = { min: scale.min, max: scale.max };
},
],
},
axes: [
{ stroke: '#8fa0b5', grid: { stroke: '#213044' } },
{ stroke: '#8fa0b5', grid: { stroke: '#213044' } },
@@ -60,22 +83,123 @@ export function ScalarChart({ series, smoothing }: { series: ScalarSeries[]; smo
prepared,
element,
);
const observer = new ResizeObserver((entries) => {
const width = entries[0]?.contentRect.width;
if (width) chart.setSize({ width: Math.max(320, Math.floor(width)), height: 360 });
chartRef.current = chart;
trackZoom.current = true;
for (const key of ['x', 'y'] as const) {
const range = zoomRanges.current[key];
if (range) chart.setScale(key, range);
}
const wheelZoom = (event: WheelEvent) => {
if (!event.deltaY) return;
event.preventDefault();
event.stopPropagation();
const bounds = chart.over.getBoundingClientRect();
if (!bounds.width || !bounds.height) return;
const xRatio = Math.min(1, Math.max(0, (event.clientX - bounds.left) / bounds.width));
const yRatio = Math.min(1, Math.max(0, (event.clientY - bounds.top) / bounds.height));
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? window.innerHeight : 1;
const factor = Math.min(2, Math.max(0.5, Math.exp(event.deltaY * unit * 0.002)));
for (const key of ['x', 'y'] as const) {
const scale = chart.scales[key];
if (typeof scale.min !== 'number' || typeof scale.max !== 'number') continue;
const ratio = key === 'x' ? xRatio : 1 - yRatio;
const anchor = scale.min + (scale.max - scale.min) * ratio;
chart.setScale(key, {
min: anchor - (anchor - scale.min) * factor,
max: anchor + (scale.max - anchor) * factor,
});
}
};
chart.over.addEventListener('wheel', wheelZoom, { passive: false });
let frame = 0;
let lastWidth = width;
const observer = new ResizeObserver(() => {
window.cancelAnimationFrame(frame);
frame = window.requestAnimationFrame(() => {
const nextWidth = Math.max(280, Math.floor(element.getBoundingClientRect().width));
if (nextWidth !== lastWidth) {
lastWidth = nextWidth;
chart.setSize({ width: nextWidth, height: 280 });
}
});
});
observer.observe(element);
return () => {
observer.disconnect();
chart.over.removeEventListener('wheel', wheelZoom);
window.cancelAnimationFrame(frame);
trackZoom.current = false;
chartRef.current = null;
chart.destroy();
};
}, [prepared, series]);
const zoom = (factor: number) => {
const chart = chartRef.current;
if (!chart) return;
for (const key of ['x', 'y']) {
const scale = chart.scales[key];
if (typeof scale.min !== 'number' || typeof scale.max !== 'number') continue;
const center = (scale.min + scale.max) / 2;
const radius = ((scale.max - scale.min) * factor) / 2 || 1;
chart.setScale(key, { min: center - radius, max: center + radius });
}
};
const resetZoom = () => {
const chart = chartRef.current;
if (!chart) return;
zoomRanges.current = {};
trackZoom.current = false;
chart.setData(chart.data, true);
trackZoom.current = true;
};
if (series.length === 0)
return (
<div className="grid h-[360px] place-items-center rounded-lg border border-border bg-app text-xs text-text-tertiary">
<div className="grid h-[280px] place-items-center rounded-lg border border-border bg-app text-xs text-text-tertiary">
trial scalar
</div>
);
return <div ref={host} className="min-w-0 overflow-hidden rounded-lg bg-app p-2" />;
return (
<section className="min-w-0 overflow-hidden rounded-lg border border-border bg-app">
<header className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
<h3 className="min-w-0 truncate text-[10px] font-semibold" title={title}>
{title}
</h3>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
aria-label={`${title} 放大`}
title="放大"
className="rounded p-1 text-text-tertiary hover:bg-element-hover hover:text-text-primary"
onClick={() => zoom(0.7)}
>
<ZoomIn className="h-3.5 w-3.5" />
</button>
<button
type="button"
aria-label={`${title} 缩小`}
title="缩小"
className="rounded p-1 text-text-tertiary hover:bg-element-hover hover:text-text-primary"
onClick={() => zoom(1.4)}
>
<ZoomOut className="h-3.5 w-3.5" />
</button>
<button
type="button"
aria-label={`${title} 重置缩放`}
title="重置缩放"
className="rounded p-1 text-text-tertiary hover:bg-element-hover hover:text-text-primary"
onClick={resetZoom}
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
</div>
</header>
<div ref={host} className="min-w-0 w-full overflow-hidden" />
<p className="border-t border-border px-3 py-1.5 text-[9px] text-text-tertiary">
使
</p>
</section>
);
}
+15 -1
View File
@@ -139,6 +139,20 @@ describe('MapEditorLayer', () => {
layer.dispose();
});
it('选中对象时显示贴地对齐指示器并提供聚焦边界', () => {
const { scene, layer } = createLayer();
layer.setDocument(documentValue);
layer.selectObject('box_1');
const indicator = scene.getObjectByName('__platform_snap_surface_indicator__');
expect(indicator?.visible).toBe(true);
expect(layer.selectedBoundingSphere()?.radius).toBeGreaterThan(0);
layer.flashSurfaceAlignment('box_1');
layer.update(performance.now() + 100);
expect(indicator?.scale.x).toBeGreaterThan(0);
layer.dispose();
expect(scene.getObjectByName('__platform_snap_surface_indicator__')).toBeUndefined();
});
it('锁定对象可选中但不挂载变换操纵器', () => {
const { layer } = createLayer();
layer.setDocument({
@@ -151,7 +165,7 @@ describe('MapEditorLayer', () => {
layer.dispose();
});
it('提交操纵器变换并保持 W/E 模式约束', () => {
it('提交操纵器变换并保持 W/E/R 模式约束', () => {
const { callbacks, layer } = createLayer();
layer.setDocument(documentValue);
layer.selectObject('box_1');
+109 -2
View File
@@ -67,6 +67,7 @@ function objectPreview(object: EditableMapObject): THREE.Group {
group.name = `__platform_map_editor_${object.id}`;
group.userData.mapEditorObjectId = object.id;
group.userData.mapEditorLocked = object.placementMode === 'locked';
group.userData.mapEditorPlacementMode = object.placementMode;
group.position.fromArray(object.pose.position);
group.quaternion.set(
object.pose.quaternion[1],
@@ -132,8 +133,25 @@ export class MapEditorLayer {
private readonly pointer = new THREE.Vector2();
private readonly objects = new Map<string, THREE.Group>();
private readonly previewInstances = new Map<string, THREE.Group>();
private readonly surfaceIndicator = new THREE.Group();
private readonly surfaceRingMaterial = new THREE.MeshBasicMaterial({
color: 0x34d399,
transparent: true,
opacity: 0.42,
depthTest: false,
depthWrite: false,
side: THREE.DoubleSide,
});
private readonly surfaceLineMaterial = new THREE.LineBasicMaterial({
color: 0x6ee7b7,
transparent: true,
opacity: 0.7,
depthTest: false,
});
private selectedId: string | null = null;
private documentLoaded = false;
private surfaceIndicatorScale = 0.25;
private surfacePulseStarted = 0;
constructor(
private readonly scene: THREE.Scene,
@@ -143,7 +161,25 @@ export class MapEditorLayer {
) {
this.previewGroup.name = '__platform_map_editor_scene_previews__';
this.group.name = '__platform_map_editor__';
scene.add(this.previewGroup, this.group);
this.surfaceIndicator.name = '__platform_snap_surface_indicator__';
const surfaceRing = new THREE.Mesh(
new THREE.RingGeometry(0.72, 1, 48),
this.surfaceRingMaterial,
);
surfaceRing.renderOrder = 110;
const surfaceCross = new THREE.LineSegments(
new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(-1.18, 0, 0),
new THREE.Vector3(1.18, 0, 0),
new THREE.Vector3(0, -1.18, 0),
new THREE.Vector3(0, 1.18, 0),
]),
this.surfaceLineMaterial,
);
surfaceCross.renderOrder = 110;
this.surfaceIndicator.add(surfaceRing, surfaceCross);
this.surfaceIndicator.visible = false;
scene.add(this.previewGroup, this.group, this.surfaceIndicator);
this.transform = new TransformControls(camera, domElement);
this.transform.setSpace('world');
this.transform.setSize(0.8);
@@ -164,6 +200,39 @@ export class MapEditorLayer {
return this.documentLoaded || this.previewInstances.size > 0;
}
selectedBoundingSphere(): THREE.Sphere | null {
const selected = this.selectedId ? this.objects.get(this.selectedId) : undefined;
if (!selected) return null;
selected.updateWorldMatrix(true, true);
const bounds = new THREE.Box3().setFromObject(selected);
if (bounds.isEmpty()) return null;
return bounds.getBoundingSphere(new THREE.Sphere());
}
flashSurfaceAlignment(id?: string): void {
if (id && id !== this.selectedId) return;
const selected = this.selectedId ? this.objects.get(this.selectedId) : undefined;
if (!selected || selected.userData.mapEditorLocked) return;
this.positionSurfaceIndicator(selected);
this.startSurfacePulse();
this.surfaceIndicator.visible = true;
}
update(now: number): void {
const selected = this.selectedId ? this.objects.get(this.selectedId) : undefined;
if (!selected || selected.userData.mapEditorLocked) {
this.surfaceIndicator.visible = false;
return;
}
this.positionSurfaceIndicator(selected);
const elapsed = now - this.surfacePulseStarted;
const pulse = elapsed >= 0 && elapsed < 720 ? 1 - elapsed / 720 : 0;
const wave = pulse * (0.16 + Math.sin((elapsed / 720) * Math.PI * 4) * 0.08);
this.surfaceIndicator.scale.setScalar(this.surfaceIndicatorScale * (1 + wave));
this.surfaceRingMaterial.opacity = 0.32 + pulse * 0.38;
this.surfaceLineMaterial.opacity = 0.5 + pulse * 0.4;
}
setDocument(
document: EditableMapDocument | null,
instanceTransform: MapInstanceTransform = DEFAULT_MAP_INSTANCE_TRANSFORM,
@@ -231,6 +300,7 @@ export class MapEditorLayer {
}
selectObject(id: string | null, notify = false): void {
const previousId = this.selectedId;
this.selectedId = id && this.objects.has(id) ? id : null;
this.transform.detach();
for (const [objectId, root] of this.objects)
@@ -246,7 +316,11 @@ export class MapEditorLayer {
});
const selected = this.selectedId ? this.objects.get(this.selectedId) : undefined;
const editable = Boolean(selected && !selected.userData.mapEditorLocked);
if (selected && editable) this.transform.attach(selected);
if (selected && editable) {
this.transform.attach(selected);
this.positionSurfaceIndicator(selected);
if (previousId !== this.selectedId) this.startSurfacePulse();
} else this.surfaceIndicator.visible = false;
this.helper.visible = editable;
if (notify) this.callbacks.onSelect(this.selectedId);
}
@@ -285,6 +359,32 @@ export class MapEditorLayer {
return false;
}
private startSurfacePulse(): void {
const now = performance.now();
this.surfacePulseStarted = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
? now - 720
: now;
}
private positionSurfaceIndicator(selected: THREE.Group): void {
selected.updateWorldMatrix(true, true);
const bounds = new THREE.Box3().setFromObject(selected);
if (bounds.isEmpty()) {
this.surfaceIndicator.visible = false;
return;
}
const center = bounds.getCenter(new THREE.Vector3());
const size = bounds.getSize(new THREE.Vector3());
const placementMode = selected.userData.mapEditorPlacementMode;
const color = placementMode === 'gravity' ? 0x38bdf8 : 0x34d399;
this.surfaceRingMaterial.color.setHex(color);
this.surfaceLineMaterial.color.setHex(color);
this.surfaceIndicatorScale = Math.min(1.5, Math.max(0.16, Math.max(size.x, size.y) * 0.62));
this.surfaceIndicator.position.set(center.x, center.y, bounds.min.z + 0.004);
this.surfaceIndicator.scale.setScalar(this.surfaceIndicatorScale);
this.surfaceIndicator.visible = true;
}
private commitTransform(): void {
if (!this.selectedId) return;
const object = this.objects.get(this.selectedId);
@@ -323,6 +423,8 @@ export class MapEditorLayer {
this.clearPreviewInstances();
this.group.position.set(0, 0, 0);
this.group.rotation.set(0, 0, 0);
this.surfaceIndicator.visible = false;
this.surfacePulseStarted = 0;
this.transform.enabled = false;
this.callbacks.onDragging(false);
}
@@ -332,6 +434,11 @@ export class MapEditorLayer {
this.callbacks.onDragging(false);
this.transform.dispose();
this.helper.removeFromParent();
for (const child of this.surfaceIndicator.children)
if (child instanceof THREE.Mesh || child instanceof THREE.Line) child.geometry.dispose();
this.surfaceRingMaterial.dispose();
this.surfaceLineMaterial.dispose();
this.surfaceIndicator.removeFromParent();
this.previewGroup.removeFromParent();
this.group.removeFromParent();
}
+70
View File
@@ -130,6 +130,13 @@ export class MuJoCoViewer {
fromGround: THREE.Color;
toGround: THREE.Color;
};
private cameraFocusTransition?: {
started: number;
fromPosition: THREE.Vector3;
fromTarget: THREE.Vector3;
toPosition: THREE.Vector3;
toTarget: THREE.Vector3;
};
constructor(
private readonly host: HTMLElement,
@@ -150,6 +157,9 @@ export class MuJoCoViewer {
this.camera.position.set(3, -3, 2);
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.addEventListener('start', () => {
this.cameraFocusTransition = undefined;
});
this.scene.background = new THREE.Color(0x0b1220);
this.hemisphere = new THREE.HemisphereLight(0xffffff, 0x223344, 1.3);
this.scene.add(this.hemisphere);
@@ -405,7 +415,55 @@ export class MuJoCoViewer {
this.option.geomgroup[5] = 0;
this.visualMapLayer.visible = this.showVisualMap && this.displayOptions.showVisual;
}
focusSelection(bodyId?: number): boolean {
let sphere =
this.mapEditorLayer.selectedBoundingSphere() ??
this.parametricMapPreviewLayer.selectedBoundingSphere();
if (!sphere && bodyId !== undefined) {
const bounds = new THREE.Box3();
let found = false;
for (const mesh of this.meshes) {
if (Number(mesh.userData.bodyId) !== bodyId) continue;
mesh.updateWorldMatrix(true, true);
bounds.expandByObject(mesh);
found = true;
}
if (found && !bounds.isEmpty()) sphere = bounds.getBoundingSphere(new THREE.Sphere());
}
if (!sphere && this.selected) {
this.selected.updateWorldMatrix(true, true);
const bounds = new THREE.Box3().setFromObject(this.selected);
if (!bounds.isEmpty()) sphere = bounds.getBoundingSphere(new THREE.Sphere());
}
if (!sphere) return false;
const direction = this.camera.position.clone().sub(this.controls.target);
if (direction.lengthSq() < 1e-6) direction.set(1, -1, 0.7);
direction.normalize();
const verticalFov = THREE.MathUtils.degToRad(this.camera.fov);
const fitDistance = sphere.radius / Math.max(0.1, Math.sin(verticalFov / 2));
const distance = Math.max(0.65, fitDistance * 1.35);
const toPosition = sphere.center.clone().addScaledVector(direction, distance);
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
this.cameraFocusTransition = undefined;
this.camera.position.copy(toPosition);
this.controls.target.copy(sphere.center);
this.controls.update();
} else {
this.cameraFocusTransition = {
started: performance.now(),
fromPosition: this.camera.position.clone(),
fromTarget: this.controls.target.clone(),
toPosition,
toTarget: sphere.center.clone(),
};
}
return true;
}
flashMapEditorSurfaceAlignment(id?: string): void {
this.mapEditorLayer.flashSurfaceAlignment(id);
}
resetCamera(): void {
this.cameraFocusTransition = undefined;
if (this.session) this.fitCamera(this.session);
}
highlightJoint(jointId: number | null): void {
@@ -461,6 +519,7 @@ export class MuJoCoViewer {
}
private fitCamera(session: SimulationSession): void {
this.cameraFocusTransition = undefined;
const { extent, center } = session.geometryBounds();
this.modelExtent = extent;
this.controls.target.set(center[0], center[1], center[2]);
@@ -474,6 +533,15 @@ export class MuJoCoViewer {
this.camera.updateProjectionMatrix();
this.controls.update();
}
private updateCameraFocusTransition(now: number): void {
const transition = this.cameraFocusTransition;
if (!transition) return;
const progress = Math.min(1, Math.max(0, (now - transition.started) / 320));
const eased = 1 - Math.pow(1 - progress, 3);
this.camera.position.lerpVectors(transition.fromPosition, transition.toPosition, eased);
this.controls.target.lerpVectors(transition.fromTarget, transition.toTarget, eased);
if (progress === 1) this.cameraFocusTransition = undefined;
}
private resize(): void {
const w = Math.max(1, this.host.clientWidth),
h = Math.max(1, this.host.clientHeight);
@@ -489,6 +557,8 @@ export class MuJoCoViewer {
try {
const result = this.session?.advance(now) ?? { steps: 0, stepMs: 0, overBudget: false };
this.updateThemeTransition(now);
this.updateCameraFocusTransition(now);
this.mapEditorLayer.update(now);
this.controls.update();
this.orientationGizmo.update(this.camera);
if (this.session) {
@@ -171,6 +171,15 @@ export class ParametricMapPreviewLayer {
return this.objects.size > 0;
}
selectedBoundingSphere(): THREE.Sphere | null {
const selected = this.selectedId ? this.objects.get(this.selectedId)?.root : undefined;
if (!selected) return null;
selected.updateWorldMatrix(true, true);
const bounds = new THREE.Box3().setFromObject(selected);
if (bounds.isEmpty()) return null;
return bounds.getBoundingSphere(new THREE.Sphere());
}
setAssets(assets: readonly PlacedMapAsset[], previewIds?: readonly string[]): void {
this.previewIds = new Set(previewIds ?? assets.map((asset) => asset.id));
const desired = new Map(