feat(web-platform): release V0.8.1 常规地图优化
This commit is contained in:
@@ -65,7 +65,7 @@ python3 -m http.server 8080 --directory web-platform-dist
|
||||
|
||||
## 地图模块
|
||||
|
||||
模型加载后打开右侧“地图”标签,可以选择平地、坡道、楼梯、随机障碍物、系统参数化地形或工程内地图包。参数化地形包括离散障碍、沟壑、倒金字塔阶梯、深坑、金字塔阶梯、轨道、随机粗糙、踏石和波浪地形;相同参数与随机种子会确定性生成相同碰撞层。点击“应用并重新编译”后,平台会在当前入口同目录生成临时组合 MJCF;原始工程文件不会被修改。模型编译失败时保留上一个可用仿真会话。
|
||||
模型加载后打开右侧“地图”标签,即可使用统一地图资产库。工程地图、认证资产、内置程序地图和系统参数化地形共用一条“点击/拖放 → 轻量预览 → 场景树管理 → 一次应用”链路;任何来源的增删或参数修改都先进入同一个场景草稿,不会隐式触发 MuJoCo 编译。参数化地形包括离散障碍、沟壑、倒金字塔阶梯、深坑、金字塔阶梯、轨道、随机粗糙、踏石和波浪地形;相同参数与随机种子会确定性生成相同碰撞层。卡片可直接拖到三维画布,落点按 0.1 m 吸附;所有内置程序地图都可在视口选择、移动和绕 Z 旋转。认证资产的“自动落位(重力)”会查询程序地图真实表面,可落到坡道、楼梯、坑底或高度场,而不是固定假设 `z=0`。最后点击“一次编译应用”才组合 MJCF;编译失败时保留上一个可用仿真会话和完整草稿,普通模型重载也不会提前提交草稿。
|
||||
|
||||
工程地图由 `map.json`、静态 MJCF 碰撞层和可选的自包含 GLB 视觉层组成:
|
||||
|
||||
@@ -94,7 +94,7 @@ maps/warehouse/
|
||||
|
||||
物理地图仅允许静态 `worldbody` 以及 mesh、heightfield、texture、material 等基础 asset,不允许 joint、mocap body、actuator、sensor、include 或 default class。OBJ/STL 应使用简化碰撞模型;高精度模型只放入 GLB。GLB 必须是 2.0 自包含文件,外部 URI 会被拒绝。地图统一使用米制、Z-up、+X 前向坐标系。
|
||||
|
||||
V3 可编辑地图使用 `schemaVersion: 2`,并增加 `"authoring": { "source": "authoring/map.scene.json" }`。创作层支持方盒、圆柱、胶囊、坡道、楼梯和出生点。“场景 · 资产库”中的认证资产可点击添加或拖到画布落位;没有可编辑地图时,首个资产会立即创建 Schema V2 场景草稿,不触发 MuJoCo 重编译。新增对象支持三种放置方式:自动贴地、沿世界 `-Z` 落到最高静态承载面的自动重力落位,以及禁止位姿编辑的锁定模式。只有点击“应用并重新编译”后才提交物理层。可以通过表单或视口 TransformControls 修改位置、绕 Z 轴旋转和原语尺寸,支持移动/旋转吸附、视口拾取、复制、删除及对齐地面;`W`/`E`/`S` 切换移动、旋转和缩放工具,`Delete` 删除,`Ctrl+Z`/`Ctrl+Y` 撤销重做。编辑只更新 Three.js 草稿预览,点击编辑器内“应用并重新编译”后才生成确定性的静态 MJCF。失败时保留旧仿真和草稿。浏览器不会直接写回原目录,可使用“导出地图 ZIP”下载当前已提交地图包。没有 `authoring.source` 的 V1/V2 地图默认只读;仅由 `box`、`cylinder`、`capsule` 构成且不含 asset、材质、碰撞过滤或隐藏姿态语义的静态 MJCF,可通过“创建可编辑副本”显式升级。转换会生成 `authoring/map.scene.json`、Schema V2 描述和确定性物理层;任何不可逆语义都会导致整体拒绝,不会静默丢失内容。
|
||||
V3 可编辑地图使用 `schemaVersion: 2`,并增加 `"authoring": { "source": "authoring/map.scene.json" }`。创作层支持方盒、圆柱、胶囊、坡道、楼梯和出生点。“场景 · 资产库”中的认证资产可点击添加或拖到画布落位;没有可编辑地图时,首个资产会创建临时 Schema V2 场景,但不触发 MuJoCo 重编译,“放弃场景更改”会同时移除临时实例和三份临时工程文件。新增对象支持自动贴地、沿世界 `-Z` 自动重力落位及锁定位姿;属性面板可编辑位置、绕 Z 旋转、原语尺寸、摩擦、颜色和透明度。工程地图源与场景实例分离:同一来源可以重复放置,实例拥有独立 XY/绕 Z 位姿,物理层、GLB 视觉层和出生点同步变换;编辑 authoring 源内容会明确同步到同源实例。视口支持吸附、拾取、空白取消选择、复制、删除及对齐地面;`W`/`E`/`S` 切换移动、旋转和缩放工具,`Delete` 删除,`Ctrl+Z`/`Ctrl+Y` 撤销重做。浏览器不会直接写回原目录,可使用“导出地图 ZIP”下载当前已提交地图包。没有 `authoring.source` 的 V1/V2 地图默认只读;仅由 `box`、`cylinder`、`capsule` 构成且不含 asset、材质、碰撞过滤或隐藏姿态语义的静态 MJCF,可通过“创建可编辑副本”显式升级;任何不可逆语义都会导致整体拒绝,不会静默丢失内容。
|
||||
|
||||
物理地图超过 2000 个 geom 会产生性能警告,超过 10000 个会被拒绝;GLB 超过 100 万三角面会警告,超过 300 万会被拒绝。当前原生 URDF 模式不支持地图,请切换到“转换为 MJCF”。
|
||||
|
||||
|
||||
@@ -481,14 +481,14 @@ test('认证资产可点击创建场景并拖到画布落位', async ({ page })
|
||||
await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('tab', { name: '地图' }).click();
|
||||
|
||||
const library = page.getByLabel('认证资产');
|
||||
const library = page.getByLabel('地图资产库');
|
||||
await expect(library.getByText('点击添加,或按住资产拖到画布落位。')).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('V3 地图编辑器')).toBeVisible();
|
||||
await expect(page.getByText('认证资产与场景对象属性')).toBeVisible();
|
||||
await expect(page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒')).toBeVisible();
|
||||
|
||||
await page.locator('[data-map-asset="ramp"]').dragTo(page.locator('main canvas'));
|
||||
@@ -510,6 +510,240 @@ test('认证资产可点击创建场景并拖到画布落位', async ({ page })
|
||||
await page.mouse.move(canvasBox!.x + 104, canvasBox!.y + 50, { steps: 8 });
|
||||
await page.mouse.up({ button: 'left' });
|
||||
await expect.poll(() => gizmoLine.getAttribute('x2')).not.toBe(beforeRotation);
|
||||
await expect(page.getByLabel('对象名称')).toHaveCount(0);
|
||||
await expect(page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒')).toBeVisible();
|
||||
});
|
||||
|
||||
test('认证资产自动打开地图属性并与参数地形一次编译', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page
|
||||
.locator('input[type="file"]')
|
||||
.first()
|
||||
.setInputFiles({
|
||||
name: 'unified-map-model.xml',
|
||||
mimeType: 'text/xml',
|
||||
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('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 library.getByRole('button', { name: '添加随机粗糙地形' }).click();
|
||||
await page.getByLabel('位置 X(m)').fill('4');
|
||||
await expect(page.getByText('2 项场景更改待应用')).toBeVisible();
|
||||
|
||||
// 切换到参数地形后,尚未编译的认证资产仍保留在画布中并可直接选回。
|
||||
const draftCanvasBox = await page.locator('main canvas').first().boundingBox();
|
||||
expect(draftCanvasBox).not.toBeNull();
|
||||
let selectedDraftAsset = false;
|
||||
for (let y = 0.15; y <= 0.85 && !selectedDraftAsset; y += 0.05) {
|
||||
for (let x = 0.05; x <= 0.95; x += 0.05) {
|
||||
await page.mouse.click(
|
||||
draftCanvasBox!.x + draftCanvasBox!.width * x,
|
||||
draftCanvasBox!.y + draftCanvasBox!.height * y,
|
||||
);
|
||||
await page.waitForTimeout(20);
|
||||
if (await page.getByLabel('对象名称').count()) {
|
||||
selectedDraftAsset = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(selectedDraftAsset).toBe(true);
|
||||
await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/scene_1/map.json');
|
||||
await sceneTree.getByRole('button', { name: '选择地图实例 随机粗糙地形' }).click();
|
||||
await expect(page.getByLabel('地图来源')).toHaveValue('builtin');
|
||||
|
||||
await page.getByRole('button', { name: '一次编译应用' }).click();
|
||||
|
||||
await expect(page.getByText('2 项场景更改待应用')).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(page.getByText(/已加载工程地图“场景 1”(1 个物理几何/)).toBeVisible();
|
||||
await expect(page.getByText(/已加载随机粗糙地形物理地图/)).toBeVisible();
|
||||
await expect(page.getByLabel('地图来源')).toHaveValue('builtin');
|
||||
|
||||
// 当前仍选中参数地形时,直接点已编译的认证资产也必须反查场景与对象,首次点击即挂载操纵器。
|
||||
const canvasBox = await page.locator('main canvas').first().boundingBox();
|
||||
expect(canvasBox).not.toBeNull();
|
||||
let selectedCompiledAsset = false;
|
||||
for (let y = 0.15; y <= 0.85 && !selectedCompiledAsset; y += 0.05) {
|
||||
for (let x = 0.05; x <= 0.95; x += 0.05) {
|
||||
await page.mouse.click(
|
||||
canvasBox!.x + canvasBox!.width * x,
|
||||
canvasBox!.y + canvasBox!.height * y,
|
||||
);
|
||||
await page.waitForTimeout(20);
|
||||
if (await page.getByLabel('对象名称').count()) {
|
||||
selectedCompiledAsset = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(selectedCompiledAsset).toBe(true);
|
||||
await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/scene_1/map.json');
|
||||
await expect(page.getByLabel('对象名称')).toHaveValue('基础方盒');
|
||||
await expect(page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒')).toBeVisible();
|
||||
await expect(page.getByText('地图草稿尚未应用')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('放弃首次认证资产会完整回滚临时场景和工程文件', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page
|
||||
.locator('input[type="file"]')
|
||||
.first()
|
||||
.setInputFiles({
|
||||
name: 'discard-map-model.xml',
|
||||
mimeType: 'text/xml',
|
||||
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 expect(page.getByText('1 项场景更改待应用')).toHaveCount(0);
|
||||
await expect(page.getByLabel('场景资产树')).toContainText('0 个实例');
|
||||
await expect(library.getByText('场景 1')).toHaveCount(0);
|
||||
|
||||
// 文件和 map id 也必须回滚;再次创建应复用 scene_1,而不是泄漏出 scene_2。
|
||||
await library.getByRole('button', { name: '添加基础方盒' }).click();
|
||||
await expect(page.getByLabel('场景资产树')).toContainText('场景 1');
|
||||
await expect(page.getByLabel('场景资产树')).not.toContainText('场景 2');
|
||||
});
|
||||
|
||||
test('工程地图与参数地形共享放置草稿、实例变换和回滚入口', async ({ page }) => {
|
||||
const mapJson = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: 'warehouse-draft',
|
||||
name: '草稿仓库',
|
||||
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
|
||||
physics: { source: 'physics/world.xml' },
|
||||
spawnPoints: [],
|
||||
});
|
||||
const project = zipSync({
|
||||
'model.xml': Buffer.from(SIMPLE_MODEL),
|
||||
'maps/warehouse/map.json': Buffer.from(mapJson),
|
||||
'maps/warehouse/physics/world.xml': Buffer.from(
|
||||
'<mujoco><worldbody><geom name="wall" type="box" pos="0 0 .5" size=".1 1 .5"/></worldbody></mujoco>',
|
||||
),
|
||||
});
|
||||
await page.goto('/');
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page
|
||||
.locator('input[type="file"]')
|
||||
.first()
|
||||
.setInputFiles({
|
||||
name: 'unified-project-map.zip',
|
||||
mimeType: 'application/zip',
|
||||
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();
|
||||
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
|
||||
await expect(page.getByLabel('场景资产树')).toContainText('待应用');
|
||||
await expect(page.getByText('正在准备仿真')).toHaveCount(0);
|
||||
await page.getByLabel('位置 X(m)').fill('3');
|
||||
await page.getByLabel('位置 Y(m)').fill('-2');
|
||||
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
|
||||
|
||||
await library.getByRole('button', { name: '添加波浪地形' }).click();
|
||||
await expect(page.getByText('2 项场景更改待应用')).toBeVisible();
|
||||
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();
|
||||
|
||||
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 expect(page.getByText('1 项场景更改待应用')).toHaveCount(0);
|
||||
await expect(page.getByLabel('场景资产树')).toContainText('草稿仓库');
|
||||
});
|
||||
|
||||
test('认证资产重力放置使用参数地形的真实承载高度', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page
|
||||
.locator('input[type="file"]')
|
||||
.first()
|
||||
.setInputFiles({
|
||||
name: 'terrain-surface-model.xml',
|
||||
mimeType: 'text/xml',
|
||||
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 page.getByLabel('地形难度(0–1)').fill('1');
|
||||
await library.getByLabel('新增资产放置方式').selectOption('gravity');
|
||||
await library.getByRole('button', { name: '添加基础方盒' }).click();
|
||||
|
||||
await expect(page.getByLabel('对象放置方式')).toHaveValue('gravity');
|
||||
await expect(page.getByLabel('对象位置Z')).toHaveValue('-0.3');
|
||||
await expect(page.getByText('2 项场景更改待应用')).toBeVisible();
|
||||
});
|
||||
|
||||
test('参数化地形可连续拖到画布并一次性编译', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page
|
||||
.locator('input[type="file"]')
|
||||
.first()
|
||||
.setInputFiles({
|
||||
name: 'terrain-draft-model.xml',
|
||||
mimeType: 'text/xml',
|
||||
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();
|
||||
await expect(page.getByText('正在准备仿真')).toHaveCount(0);
|
||||
await page.locator('[data-system-terrain="wave"]').dragTo(page.locator('main canvas'));
|
||||
await expect(page.getByText('2 项场景更改待应用')).toBeVisible();
|
||||
await expect(page.getByLabel('场景资产树')).toContainText('随机粗糙地形');
|
||||
await expect(page.getByLabel('场景资产树')).toContainText('波浪地形');
|
||||
|
||||
await page.getByRole('tab', { name: '属性' }).click();
|
||||
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('地图与对象属性')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: '一次编译应用' }).click();
|
||||
await expect(page.getByText('2 项场景更改待应用')).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(page.getByText(/已加载随机粗糙地形物理地图/)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByText(/已加载波浪地形物理地图/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('应用内置 MJCF 楼梯物理地图', async ({ page }) => {
|
||||
@@ -638,7 +872,7 @@ test('将受支持的只读物理地图转换为可编辑副本', async ({ page
|
||||
await page.getByRole('tab', { name: '地图' }).click();
|
||||
await page.getByLabel('地图来源').selectOption({ label: '旧版基础场景' });
|
||||
await page.getByRole('button', { name: '应用并重新编译' }).click();
|
||||
const editor = page.getByText('V3 地图编辑器').locator('..');
|
||||
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 });
|
||||
@@ -684,8 +918,8 @@ test('编辑 V3 地图对象并事务式应用', async ({ page }) => {
|
||||
await page.getByRole('tab', { name: '地图' }).click();
|
||||
await page.getByLabel('地图来源').selectOption({ label: '可编辑场景' });
|
||||
await page.getByRole('button', { name: '应用并重新编译' }).click();
|
||||
await expect(page.getByText('V3 地图编辑器')).toBeVisible({ timeout: 30_000 });
|
||||
const editor = page.getByText('V3 地图编辑器').locator('..');
|
||||
await expect(page.getByText('认证资产与场景对象属性')).toBeVisible({ timeout: 30_000 });
|
||||
const editor = page.getByText('认证资产与场景对象属性').locator('..');
|
||||
await expect(editor.getByRole('button', { name: '移动工具 W' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
@@ -735,15 +969,29 @@ test('工程地图编译失败时保留上一仿真会话', async ({ page }) =>
|
||||
buffer: Buffer.from(project),
|
||||
});
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('button', { name: '▶ 播放' }).click();
|
||||
await page.waitForTimeout(200);
|
||||
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 });
|
||||
await expect(page.getByLabel('地图来源')).toHaveValue('none');
|
||||
await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/invalid/map.json');
|
||||
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
|
||||
await expect(page.getByLabel('场景资产树')).toContainText('待应用');
|
||||
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeVisible();
|
||||
await page.getByRole('button', { name: '关闭错误' }).click();
|
||||
await page.getByRole('button', { name: '▶ 播放' }).click();
|
||||
await page.waitForTimeout(300);
|
||||
await expect(page.locator('footer')).not.toContainText('时间 0.000 s');
|
||||
await expect
|
||||
.poll(async () =>
|
||||
Number((await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1] ?? 0),
|
||||
)
|
||||
.toBeGreaterThan(runningTime);
|
||||
await page.getByRole('button', { name: '放弃场景更改' }).click();
|
||||
await expect(page.getByText('1 项场景更改待应用')).toHaveCount(0);
|
||||
await expect(page.getByLabel('地图来源')).toHaveValue('none');
|
||||
});
|
||||
|
||||
test('无效模型显示中文诊断且保留工程树', async ({ page }) => {
|
||||
|
||||
+829
-236
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,7 @@ import {
|
||||
Select,
|
||||
Tabs,
|
||||
} from '../../components/ui';
|
||||
import type { MapSelection } from '../../map/types';
|
||||
import type { MapSelection, PlacedMapSelection } from '../../map/types';
|
||||
import type {
|
||||
EditableMapDocument,
|
||||
MapEditorInteractionCallbacks,
|
||||
@@ -50,10 +50,15 @@ interface ModelControlsProps {
|
||||
selectedPolicyPath?: string;
|
||||
policyStatus?: RLPolicyStatus;
|
||||
mapSelection: MapSelection;
|
||||
activeMapAssetId?: 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;
|
||||
onUrdfMode: (value: UrdfLoadMode) => void;
|
||||
onBaseMode: (value: UrdfBaseMode) => void;
|
||||
onShowCollision: (value: boolean) => void;
|
||||
@@ -78,7 +83,13 @@ interface ModelControlsProps {
|
||||
onPolicyCommand: (command: RLCommand) => void;
|
||||
onRemovePolicy: () => void;
|
||||
onApplyMap: (value: MapSelection) => void;
|
||||
onMapDraft: (value: PlacedMapSelection) => void;
|
||||
onEditorPreview: (document: EditableMapDocument | null) => void;
|
||||
onEditorDraftChange: (
|
||||
descriptorPath: string,
|
||||
document: EditableMapDocument,
|
||||
dirty: boolean,
|
||||
) => void;
|
||||
onEditorApply: (document: EditableMapDocument) => Promise<boolean>;
|
||||
onEditorExport: () => void;
|
||||
onEditorConvert: () => Promise<boolean>;
|
||||
@@ -86,6 +97,7 @@ interface ModelControlsProps {
|
||||
onEditorSelect: (id: string | null) => void;
|
||||
onEditorTransformMode: (mode: MapEditorTransformMode) => void;
|
||||
onEditorSnapping: (translation: number | null, rotationDegrees: number | null) => void;
|
||||
onEditorSurfaceHeight?: (position: readonly [number, number, number]) => number | null;
|
||||
onMapDisplay: (visual: boolean, collision: boolean) => void;
|
||||
onMapTabOpen?: () => void;
|
||||
onDataRecorderConfigure: (patch: Partial<DataRecorderConfig>) => void;
|
||||
@@ -95,7 +107,10 @@ interface ModelControlsProps {
|
||||
onDataRecordingExport: (format: 'csv' | 'json') => void;
|
||||
}
|
||||
export function ModelControlsSidebar(props: ModelControlsProps) {
|
||||
const [tab, setTab] = useState<'properties' | 'controls' | 'data' | 'map'>('properties'),
|
||||
const [internalTab, setInternalTab] = useState<'properties' | 'controls' | 'data' | 'map'>(
|
||||
'properties',
|
||||
),
|
||||
tab = props.activeTab ?? internalTab,
|
||||
s = props.snapshot;
|
||||
if (!s)
|
||||
return (
|
||||
@@ -315,7 +330,8 @@ export function ModelControlsSidebar(props: ModelControlsProps) {
|
||||
label="模型控制侧栏"
|
||||
value={tab}
|
||||
onValueChange={(value) => {
|
||||
setTab(value);
|
||||
setInternalTab(value);
|
||||
props.onActiveTabChange?.(value);
|
||||
if (value === 'map') props.onMapTabOpen?.();
|
||||
}}
|
||||
items={[
|
||||
@@ -353,7 +369,11 @@ export function ModelControlsSidebar(props: ModelControlsProps) {
|
||||
icon: <MapIcon className="h-3.5 w-3.5" />,
|
||||
content: (
|
||||
<PhysicalMapPanel
|
||||
key={JSON.stringify(props.mapSelection)}
|
||||
key={`${props.activeMapAssetId ?? 'none'}:${
|
||||
props.mapSelection.kind === 'project'
|
||||
? props.mapSelection.descriptorPath
|
||||
: props.mapSelection.kind
|
||||
}`}
|
||||
value={props.mapSelection}
|
||||
maps={props.maps}
|
||||
rootBodies={s.bodies
|
||||
@@ -369,7 +389,10 @@ export function ModelControlsSidebar(props: ModelControlsProps) {
|
||||
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}
|
||||
@@ -377,7 +400,9 @@ export function ModelControlsSidebar(props: ModelControlsProps) {
|
||||
onEditorSelect={props.onEditorSelect}
|
||||
onEditorTransformMode={props.onEditorTransformMode}
|
||||
onEditorSnapping={props.onEditorSnapping}
|
||||
onEditorSurfaceHeight={props.onEditorSurfaceHeight}
|
||||
onMapDisplay={props.onMapDisplay}
|
||||
onDraft={props.onMapDraft}
|
||||
onApply={props.onApplyMap}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Box, FolderTree, Map as MapIcon } from 'lucide-react';
|
||||
import type { ModelEntry } from '../../project/types';
|
||||
import type { MapEntry, ModelEntry } from '../../project/types';
|
||||
import {
|
||||
countProjectSearchResults,
|
||||
ProjectTree,
|
||||
@@ -15,6 +15,7 @@ import { Button, Tabs } from '../../components/ui';
|
||||
import {
|
||||
DEFAULT_PHYSICAL_MAP_CONFIG,
|
||||
type MapSelection,
|
||||
type PlacedMapAsset,
|
||||
type SystemTerrainPreset,
|
||||
} from '../../map/types';
|
||||
import type {
|
||||
@@ -37,14 +38,25 @@ export function ProjectSidebar({
|
||||
visible = true,
|
||||
nativeUrdf,
|
||||
mapSelection,
|
||||
maps,
|
||||
placedMaps,
|
||||
activeMapId,
|
||||
pendingSceneChangeCount,
|
||||
pendingSceneIds,
|
||||
editorDocument,
|
||||
editorDocuments,
|
||||
activeTab,
|
||||
onActiveTabChange,
|
||||
onRemove,
|
||||
onSelectEntry,
|
||||
onJointHover,
|
||||
onAddMapAsset,
|
||||
onAddProjectMap,
|
||||
onSelectTerrain,
|
||||
onApplyScene,
|
||||
onDiscardScene,
|
||||
onSelectMap,
|
||||
onRemoveMap,
|
||||
onSelectMapObject,
|
||||
}: {
|
||||
projectName?: string;
|
||||
@@ -56,7 +68,13 @@ export function ProjectSidebar({
|
||||
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;
|
||||
onRemove: () => void;
|
||||
@@ -66,8 +84,13 @@ export function ProjectSidebar({
|
||||
type: EditableMapObjectType,
|
||||
placementMode: MapObjectPlacementMode,
|
||||
) => void | Promise<void>;
|
||||
onAddProjectMap: (descriptorPath: string) => void;
|
||||
onSelectTerrain: (preset: SystemTerrainPreset) => void;
|
||||
onSelectMapObject: (id: string) => 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(''),
|
||||
@@ -173,8 +196,19 @@ export function ProjectSidebar({
|
||||
: 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}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { MAP_ASSET_DRAG_MIME, MAP_ASSET_PLACEMENT_MIME } from './editor/assetCatalog';
|
||||
import { decodeMapLibraryDragPayload, MAP_LIBRARY_DRAG_MIME } from './editor/assetCatalog';
|
||||
import { MapAssetLibrary } from './MapAssetLibrary';
|
||||
|
||||
const renderLibrary = (
|
||||
@@ -16,6 +16,162 @@ const renderLibrary = (
|
||||
);
|
||||
|
||||
describe('MapAssetLibrary', () => {
|
||||
it('把全部来源呈现在同一个放置与应用工作流中', () => {
|
||||
expect(decodeMapLibraryDragPayload('not-json')).toBeNull();
|
||||
expect(decodeMapLibraryDragPayload('{"kind":"terrain","preset":"unknown"}')).toBeNull();
|
||||
renderLibrary();
|
||||
expect(screen.getByLabelText('地图资产库')).toHaveTextContent('统一地图资产库');
|
||||
expect(screen.getByLabelText('地图资产库')).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('工程地图源使用与资产卡片一致的拖放入口', () => {
|
||||
const values = new Map<string, string>();
|
||||
const dataTransfer = {
|
||||
effectAllowed: 'none',
|
||||
setData: (type: string, value: string) => values.set(type, value),
|
||||
} as unknown as DataTransfer;
|
||||
render(
|
||||
<MapAssetLibrary
|
||||
disabled={false}
|
||||
terrainSize={8}
|
||||
maps={[
|
||||
{
|
||||
descriptorPath: 'maps/warehouse/map.json',
|
||||
schemaVersion: 1,
|
||||
id: 'warehouse',
|
||||
name: '仓库',
|
||||
spawnPoints: [],
|
||||
},
|
||||
]}
|
||||
onAdd={() => {}}
|
||||
onSelectTerrain={() => {}}
|
||||
/>,
|
||||
);
|
||||
const source = document.querySelector('[data-project-map="maps/warehouse/map.json"]')!;
|
||||
expect(source).toHaveAttribute('draggable', 'true');
|
||||
fireEvent.dragStart(source, { dataTransfer });
|
||||
expect(decodeMapLibraryDragPayload(values.get(MAP_LIBRARY_DRAG_MIME) ?? '')).toEqual({
|
||||
kind: 'project',
|
||||
descriptorPath: 'maps/warehouse/map.json',
|
||||
});
|
||||
expect(values.get('text/plain')).toBe('仓库');
|
||||
});
|
||||
|
||||
it('按所选放置方式添加认证资产', () => {
|
||||
const onAdd = vi.fn();
|
||||
renderLibrary(onAdd);
|
||||
@@ -37,8 +193,11 @@ describe('MapAssetLibrary', () => {
|
||||
target: { value: 'locked' },
|
||||
});
|
||||
fireEvent.dragStart(document.querySelector('[data-map-asset="ramp"]')!, { dataTransfer });
|
||||
expect(values.get(MAP_ASSET_DRAG_MIME)).toBe('ramp');
|
||||
expect(values.get(MAP_ASSET_PLACEMENT_MIME)).toBe('locked');
|
||||
expect(decodeMapLibraryDragPayload(values.get(MAP_LIBRARY_DRAG_MIME) ?? '')).toEqual({
|
||||
kind: 'certified',
|
||||
type: 'ramp',
|
||||
placementMode: 'locked',
|
||||
});
|
||||
});
|
||||
|
||||
it('以示意图卡片选择系统参数化地形', () => {
|
||||
@@ -48,4 +207,69 @@ describe('MapAssetLibrary', () => {
|
||||
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 });
|
||||
expect(decodeMapLibraryDragPayload(values.get(MAP_LIBRARY_DRAG_MIME) ?? '')).toEqual({
|
||||
kind: 'terrain',
|
||||
preset: 'rough',
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { useState } from 'react';
|
||||
import { BadgeCheck, CheckCircle2, Mountain, Plus } from 'lucide-react';
|
||||
import {
|
||||
BadgeCheck,
|
||||
CheckCircle2,
|
||||
Layers3,
|
||||
MapPinned,
|
||||
Mountain,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { Button, Select } from '../components/ui';
|
||||
import {
|
||||
CERTIFIED_MAP_ASSETS,
|
||||
MAP_ASSET_DRAG_MIME,
|
||||
MAP_ASSET_PLACEMENT_MIME,
|
||||
encodeMapLibraryDragPayload,
|
||||
MAP_LIBRARY_DRAG_MIME,
|
||||
} from './editor/assetCatalog';
|
||||
import {
|
||||
MAP_OBJECT_PLACEMENT_LABELS,
|
||||
@@ -12,9 +22,11 @@ import {
|
||||
type EditableMapObjectType,
|
||||
type MapObjectPlacementMode,
|
||||
} from './editor/types';
|
||||
import type { MapEntry } from '../project/types';
|
||||
import {
|
||||
PHYSICAL_MAP_PRESET_LABELS,
|
||||
SYSTEM_TERRAIN_PRESETS,
|
||||
type PlacedMapAsset,
|
||||
type SystemTerrainPreset,
|
||||
} from './types';
|
||||
|
||||
@@ -136,52 +148,224 @@ export function MapAssetLibrary({
|
||||
disabled,
|
||||
terrainSize,
|
||||
document,
|
||||
documents,
|
||||
maps = [],
|
||||
placedMaps = [],
|
||||
activeMapId,
|
||||
pendingSceneChangeCount = 0,
|
||||
pendingSceneIds = [],
|
||||
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[];
|
||||
onAdd: (
|
||||
type: EditableMapObjectType,
|
||||
placementMode: MapObjectPlacementMode,
|
||||
) => void | Promise<void>;
|
||||
onAddProjectMap?: (descriptorPath: string) => void;
|
||||
onSelectTerrain: (preset: SystemTerrainPreset) => void;
|
||||
onSelectObject?: (id: string) => 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,
|
||||
);
|
||||
return (
|
||||
<section aria-label="认证资产" className="space-y-3 p-3">
|
||||
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-2.5">
|
||||
<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>场景结构</span>
|
||||
<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">
|
||||
{document?.objects.length ?? 0} 个对象
|
||||
{placedMaps.length} 个实例
|
||||
{placedObjectCount ? ` · ${placedObjectCount} 个对象` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{document?.objects.length ? (
|
||||
<div className="mt-2 max-h-36 space-y-0.5 overflow-auto panel-scroll">
|
||||
{document.objects.map((object) => (
|
||||
<button
|
||||
key={object.id}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[11px] text-text-secondary hover:bg-element-hover hover:text-text-primary"
|
||||
onClick={() => onSelectObject?.(object.id)}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent" />
|
||||
<span className="min-w-0 flex-1 truncate">{object.name}</span>
|
||||
<span className="text-[9px] text-text-tertiary">{object.type}</span>
|
||||
</button>
|
||||
))}
|
||||
{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>
|
||||
<p className="mt-1 text-[10px] text-text-tertiary">
|
||||
点击添加或拖到画布定位;实例位姿独立,源内容由同源实例共享。
|
||||
</p>
|
||||
<div className="mt-2 space-y-1">
|
||||
{maps.map((map) => (
|
||||
<div
|
||||
key={map.descriptorPath}
|
||||
draggable={!disabled}
|
||||
data-project-map={map.descriptorPath}
|
||||
className={`flex items-center gap-2 rounded border border-border-subtle px-2 py-1.5 ${disabled ? 'opacity-50' : 'cursor-grab hover:border-accent active:cursor-grabbing'}`}
|
||||
onDragStart={(event) => {
|
||||
if (disabled) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
event.dataTransfer.setData(
|
||||
MAP_LIBRARY_DRAG_MIME,
|
||||
encodeMapLibraryDragPayload({
|
||||
kind: 'project',
|
||||
descriptorPath: map.descriptorPath,
|
||||
}),
|
||||
);
|
||||
event.dataTransfer.setData('text/plain', map.name);
|
||||
}}
|
||||
>
|
||||
<MapPinned className="h-3.5 w-3.5 shrink-0 text-accent" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] text-text-secondary">
|
||||
{map.name}
|
||||
</span>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
aria-label={`放置工程地图 ${map.name}`}
|
||||
onClick={() => onAddProjectMap?.(map.descriptorPath)}
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-text-primary">
|
||||
@@ -222,8 +406,14 @@ export function MapAssetLibrary({
|
||||
return;
|
||||
}
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
event.dataTransfer.setData(MAP_ASSET_DRAG_MIME, asset.type);
|
||||
event.dataTransfer.setData(MAP_ASSET_PLACEMENT_MIME, placementMode);
|
||||
event.dataTransfer.setData(
|
||||
MAP_LIBRARY_DRAG_MIME,
|
||||
encodeMapLibraryDragPayload({
|
||||
kind: 'certified',
|
||||
type: asset.type,
|
||||
placementMode,
|
||||
}),
|
||||
);
|
||||
event.dataTransfer.setData('text/plain', asset.name);
|
||||
}}
|
||||
>
|
||||
@@ -257,15 +447,28 @@ export function MapAssetLibrary({
|
||||
系统参数化地形
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
|
||||
本地确定性生成;点击“添加”后在右侧“地图”调整尺寸、难度和随机种子。
|
||||
点击添加或拖到画布定位。新增先进入轻量预览,可连续放置后一次编译。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{SYSTEM_TERRAIN_PRESETS.map((preset) => (
|
||||
<article
|
||||
key={preset}
|
||||
draggable={!disabled}
|
||||
data-system-terrain={preset}
|
||||
className={`group relative overflow-hidden rounded-lg border border-border-subtle bg-panel-muted shadow-sm transition-colors ${disabled ? 'opacity-50' : 'hover:border-accent'}`}
|
||||
className={`group relative overflow-hidden rounded-lg border border-border-subtle bg-panel-muted shadow-sm transition-colors ${disabled ? 'opacity-50' : 'cursor-grab hover:border-accent active:cursor-grabbing'}`}
|
||||
onDragStart={(event) => {
|
||||
if (disabled) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
event.dataTransfer.setData(
|
||||
MAP_LIBRARY_DRAG_MIME,
|
||||
encodeMapLibraryDragPayload({ kind: 'terrain', preset }),
|
||||
);
|
||||
event.dataTransfer.setData('text/plain', PHYSICAL_MAP_PRESET_LABELS[preset]);
|
||||
}}
|
||||
>
|
||||
<div className="absolute right-2 top-2 z-10 rounded-full bg-panel-bg/90 p-0.5 text-success">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
|
||||
@@ -87,6 +87,39 @@ describe('composeProjectMap', () => {
|
||||
expect(result.warnings.join(' ')).toContain('入口');
|
||||
});
|
||||
|
||||
it('对工程地图物理层和出生点应用同一个实例变换', () => {
|
||||
const result = composeProjectMap(robot, 'robot/scene.xml', manifest(), definition, {
|
||||
kind: 'project',
|
||||
descriptorPath: definition.descriptorPath,
|
||||
positionX: 2,
|
||||
positionY: -3,
|
||||
yawDeg: 90,
|
||||
spawnPointId: 'door',
|
||||
});
|
||||
const document = new DOMParser().parseFromString(
|
||||
decoder.decode(result.data),
|
||||
'application/xml',
|
||||
);
|
||||
const instanceRoot = document.querySelector(
|
||||
'body[name="__platform_map_warehouse_instance_root"]',
|
||||
);
|
||||
expect(instanceRoot?.getAttribute('pos')).toBe('2 -3 0');
|
||||
expect(instanceRoot?.getAttribute('quat')?.split(/\s+/).map(Number)).toEqual([
|
||||
expect.closeTo(Math.SQRT1_2),
|
||||
0,
|
||||
0,
|
||||
expect.closeTo(Math.SQRT1_2),
|
||||
]);
|
||||
const position = document
|
||||
.querySelector('body[name="robot"]')!
|
||||
.getAttribute('pos')!
|
||||
.split(/\s+/)
|
||||
.map(Number);
|
||||
expect(position[0]).toBeCloseTo(-19);
|
||||
expect(position[1]).toBeCloseTo(7);
|
||||
expect(position[2]).toBeCloseTo(0.5);
|
||||
});
|
||||
|
||||
it('地图明确提供地面时替换平台基础地面', () => {
|
||||
const floorMap = manifest(
|
||||
'<mujoco><worldbody><geom name="floor" type="box" pos="0 0 -.1" size="5 5 .1"/></worldbody></mujoco>',
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { ProjectManifest } from '../project/types';
|
||||
import { resolveProjectAssetPath, relativeAssetPath } from './mapPaths';
|
||||
import type { MapSelection, ResolvedProjectMap, SpawnPoint } from './types';
|
||||
import {
|
||||
mapLocalPointToWorld,
|
||||
mapSelectionTransform,
|
||||
type MapSelection,
|
||||
type ResolvedProjectMap,
|
||||
type SpawnPoint,
|
||||
} from './types';
|
||||
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
const encoder = new TextEncoder();
|
||||
@@ -23,6 +29,13 @@ export interface ProjectMapComposition {
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface ProjectMapCompositionOptions {
|
||||
/** 场景实例命名空间;允许同一 map.json 被重复放置。 */
|
||||
instanceId?: string;
|
||||
/** 多地图场景中只允许一个实例移动机器人到出生点。 */
|
||||
applySpawnPoint?: boolean;
|
||||
}
|
||||
|
||||
function xml(data: Uint8Array, label: string): Document {
|
||||
const document = new DOMParser().parseFromString(decoder.decode(data), 'application/xml');
|
||||
if (document.querySelector('parsererror')) throw new Error(`${label} XML 无法解析`);
|
||||
@@ -46,6 +59,19 @@ function multiplyQuaternion(a: number[], b: number[]): [number, number, number,
|
||||
];
|
||||
}
|
||||
|
||||
function transformSpawnPoint(
|
||||
spawn: SpawnPoint | undefined,
|
||||
selection: Extract<MapSelection, { kind: 'project' }>,
|
||||
): SpawnPoint | undefined {
|
||||
if (!spawn) return undefined;
|
||||
const transform = mapSelectionTransform(selection);
|
||||
return {
|
||||
...spawn,
|
||||
position: mapLocalPointToWorld(spawn.position, selection),
|
||||
yawDeg: spawn.yawDeg + transform.yawDeg,
|
||||
};
|
||||
}
|
||||
|
||||
function applySpawnPoint(
|
||||
document: Document,
|
||||
spawn: SpawnPoint | undefined,
|
||||
@@ -145,6 +171,22 @@ function mapProvidesGround(document: Document): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function applyMapInstanceTransform(
|
||||
document: Document,
|
||||
selection: Extract<MapSelection, { kind: 'project' }>,
|
||||
): void {
|
||||
const worldbody = document.querySelector('mujoco > worldbody');
|
||||
if (!worldbody) throw new Error('物理地图缺少 worldbody');
|
||||
const transform = mapSelectionTransform(selection);
|
||||
const root = document.createElement('body');
|
||||
const halfYaw = (transform.yawDeg * Math.PI) / 360;
|
||||
root.setAttribute('name', 'instance_root');
|
||||
root.setAttribute('pos', `${transform.positionX} ${transform.positionY} 0`);
|
||||
root.setAttribute('quat', `${Math.cos(halfYaw)} 0 0 ${Math.sin(halfYaw)}`);
|
||||
for (const child of Array.from(worldbody.children)) root.append(child);
|
||||
worldbody.append(root);
|
||||
}
|
||||
|
||||
function namespaceMap(document: Document, mapId: string): void {
|
||||
const prefix = `${MAP_PREFIX}${mapId.replace(/[^a-zA-Z0-9_-]/g, '_')}_`;
|
||||
const names = new Map<string, string>();
|
||||
@@ -203,19 +245,22 @@ export function composeProjectMap(
|
||||
manifest: ProjectManifest,
|
||||
resolvedMap: ResolvedProjectMap,
|
||||
selection: Extract<MapSelection, { kind: 'project' }>,
|
||||
options: ProjectMapCompositionOptions = {},
|
||||
): ProjectMapComposition {
|
||||
const robot = xml(robotSource, '机器人 MJCF');
|
||||
const robotWorldbody = robot.querySelector('mujoco > worldbody');
|
||||
if (!robotWorldbody) throw new Error('机器人 MJCF 缺少 worldbody');
|
||||
const warnings: string[] = [];
|
||||
const spawn = selection.spawnPointId
|
||||
? resolvedMap.definition.spawnPoints.find(
|
||||
(candidate) => candidate.id === selection.spawnPointId,
|
||||
)
|
||||
: undefined;
|
||||
if (selection.spawnPointId && !spawn)
|
||||
const shouldApplySpawn = options.applySpawnPoint ?? true;
|
||||
const spawn =
|
||||
shouldApplySpawn && selection.spawnPointId
|
||||
? resolvedMap.definition.spawnPoints.find(
|
||||
(candidate) => candidate.id === selection.spawnPointId,
|
||||
)
|
||||
: undefined;
|
||||
if (shouldApplySpawn && selection.spawnPointId && !spawn)
|
||||
throw new Error(`地图中不存在出生点:${selection.spawnPointId}`);
|
||||
applySpawnPoint(robot, spawn, selection.robotRootBody, warnings);
|
||||
applySpawnPoint(robot, transformSpawnPoint(spawn, selection), selection.robotRootBody, warnings);
|
||||
|
||||
let geomCount = 0;
|
||||
if (resolvedMap.physicsPath) {
|
||||
@@ -225,7 +270,13 @@ export function composeProjectMap(
|
||||
validateMapStructure(map);
|
||||
const replacesGround = mapProvidesGround(map);
|
||||
rebaseAssets(map, robot, manifest, resolvedMap.physicsPath, generatedScenePath);
|
||||
namespaceMap(map, resolvedMap.definition.id);
|
||||
applyMapInstanceTransform(map, selection);
|
||||
namespaceMap(
|
||||
map,
|
||||
options.instanceId
|
||||
? `${resolvedMap.definition.id}_${options.instanceId}`
|
||||
: resolvedMap.definition.id,
|
||||
);
|
||||
if (selection.frictionOverride !== undefined) {
|
||||
const friction = Math.min(5, Math.max(0.05, selection.frictionOverride));
|
||||
for (const geom of Array.from(map.querySelectorAll('worldbody geom')))
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import type { EditableMapDocument } from './editor/types';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import type { EditableMapDocument, MapEditorInteractionCallbacks } from './editor/types';
|
||||
import { MapEditorPanel } from './MapEditorPanel';
|
||||
|
||||
const interactionProps = {
|
||||
descriptorPath: 'maps/map/map.json',
|
||||
onDraftChange: () => {},
|
||||
onConvert: async () => true,
|
||||
canConvert: true,
|
||||
onBindInteraction: () => {},
|
||||
@@ -34,18 +36,84 @@ describe('MapEditorPanel', () => {
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '新增' }));
|
||||
fireEvent.change(screen.getByLabelText('对象位置X'), { target: { value: '2' } });
|
||||
fireEvent.change(screen.getByLabelText('对象滑动摩擦'), { target: { value: '0.8' } });
|
||||
fireEvent.change(screen.getByLabelText('对象颜色'), { target: { value: '#ff0000' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
|
||||
await waitFor(() => expect(apply).toHaveBeenCalled());
|
||||
expect(preview).not.toHaveBeenLastCalledWith(null);
|
||||
expect(apply.mock.calls[0][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
revision: 1,
|
||||
objects: [
|
||||
expect.objectContaining({ pose: expect.objectContaining({ position: [2, 0, 0.5] }) }),
|
||||
expect.objectContaining({
|
||||
pose: expect.objectContaining({ position: [2, 0, 0.5] }),
|
||||
friction: [0.8, 0.005, 0.0001],
|
||||
rgba: [1, 0, 0, 1],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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: '新增' }));
|
||||
expect(onDraftChange).toHaveBeenLastCalledWith(
|
||||
'maps/map/map.json',
|
||||
expect.objectContaining({ objects: [expect.objectContaining({ type: 'box' })] }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('把外部拾取同步到属性表单和视口操纵器', async () => {
|
||||
const onSelectPreview = vi.fn();
|
||||
let interaction: MapEditorInteractionCallbacks | null = null;
|
||||
render(
|
||||
<MapEditorPanel
|
||||
{...interactionProps}
|
||||
document={{
|
||||
...document,
|
||||
objects: [
|
||||
{
|
||||
id: 'box_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.55, 0.6, 0.68, 1],
|
||||
placementMode: 'auto_ground',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}}
|
||||
loading={false}
|
||||
onPreview={() => {}}
|
||||
onSelectPreview={onSelectPreview}
|
||||
onBindInteraction={(callbacks) => {
|
||||
interaction = callbacks;
|
||||
}}
|
||||
onApply={async () => true}
|
||||
onExport={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => interaction?.onSelect('box_1'));
|
||||
await waitFor(() => expect(onSelectPreview).toHaveBeenLastCalledWith('box_1'));
|
||||
expect(screen.getByLabelText('对象名称')).toHaveValue('认证方盒');
|
||||
});
|
||||
|
||||
it('锁定放置方式后禁用位姿编辑', () => {
|
||||
render(
|
||||
<MapEditorPanel
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Select } from '../components/ui';
|
||||
import { MapEditSession } from './editor/MapEditSession';
|
||||
import { MapTransformToolbar } from './MapTransformToolbar';
|
||||
import {
|
||||
editableObjectGroundHeight,
|
||||
MAP_OBJECT_PLACEMENT_LABELS,
|
||||
@@ -55,8 +56,11 @@ function scaledParameters(
|
||||
}
|
||||
export function MapEditorPanel({
|
||||
document,
|
||||
draftDocument,
|
||||
descriptorPath,
|
||||
loading,
|
||||
onPreview,
|
||||
onDraftChange,
|
||||
onApply,
|
||||
onExport,
|
||||
onConvert,
|
||||
@@ -65,10 +69,14 @@ export function MapEditorPanel({
|
||||
onSelectPreview,
|
||||
onTransformMode,
|
||||
onSnapping,
|
||||
onSurfaceHeight,
|
||||
}: {
|
||||
document: EditableMapDocument | null;
|
||||
draftDocument?: EditableMapDocument;
|
||||
descriptorPath: string;
|
||||
loading: boolean;
|
||||
onPreview: (document: EditableMapDocument | null) => void;
|
||||
onDraftChange: (descriptorPath: string, document: EditableMapDocument, dirty: boolean) => void;
|
||||
onApply: (document: EditableMapDocument) => Promise<boolean>;
|
||||
onExport: () => void;
|
||||
onConvert: () => Promise<boolean>;
|
||||
@@ -77,8 +85,15 @@ export function MapEditorPanel({
|
||||
onSelectPreview: (id: string | null) => void;
|
||||
onTransformMode: (mode: MapEditorTransformMode) => void;
|
||||
onSnapping: (translation: number | null, rotationDegrees: number | null) => void;
|
||||
onSurfaceHeight?: (position: readonly [number, number, number]) => number | null;
|
||||
}) {
|
||||
const session = useMemo(() => (document ? new MapEditSession(document) : null), [document]);
|
||||
// draftDocument 仅用于恢复重新挂载的编辑器;会话存活期间由 MapEditSession 持有最新草稿。
|
||||
const session = useMemo(
|
||||
() =>
|
||||
document ? new MapEditSession(document, draftDocument ?? document, onSurfaceHeight) : null,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[document, descriptorPath, onSurfaceHeight],
|
||||
);
|
||||
const [, render] = useState(0),
|
||||
[selected, setSelected] = useState<string>(),
|
||||
[selectedType, setSelectedType] = useState<EditableMapObjectType>('box'),
|
||||
@@ -86,13 +101,17 @@ export function MapEditorPanel({
|
||||
[snapping, setSnapping] = useState(true),
|
||||
[converting, setConverting] = useState(false);
|
||||
const refresh = () => {
|
||||
const next = session?.document ?? null;
|
||||
render((value) => value + 1);
|
||||
onPreview(session?.document ?? null);
|
||||
onPreview(next);
|
||||
if (session && next) onDraftChange(descriptorPath, next, session.dirty);
|
||||
};
|
||||
useEffect(() => {
|
||||
onPreview(session?.document ?? null);
|
||||
const next = session?.document ?? null;
|
||||
onPreview(next);
|
||||
if (session && next) onDraftChange(descriptorPath, next, session.dirty);
|
||||
return () => onPreview(null);
|
||||
}, [session, onPreview]);
|
||||
}, [session, descriptorPath, onPreview, onDraftChange]);
|
||||
useEffect(() => {
|
||||
if (!session) {
|
||||
onBindInteraction(null);
|
||||
@@ -101,6 +120,7 @@ export function MapEditorPanel({
|
||||
const callbacks: MapEditorInteractionCallbacks = {
|
||||
onSelect: (id) => setSelected(id ?? undefined),
|
||||
onTransform: ({ id, position, quaternion, scale }) => {
|
||||
if (loading) return;
|
||||
const object = session.document.objects.find((item) => item.id === id);
|
||||
if (!object) return;
|
||||
session.update(id, {
|
||||
@@ -110,18 +130,32 @@ export function MapEditorPanel({
|
||||
setSelected(id);
|
||||
render((value) => value + 1);
|
||||
onPreview(session.document);
|
||||
onDraftChange(descriptorPath, session.document, session.dirty);
|
||||
},
|
||||
onAddAsset: (type, position, placementMode) => {
|
||||
const object = session.addAsset(type, position, placementMode);
|
||||
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);
|
||||
onSelectPreview(object.id);
|
||||
},
|
||||
};
|
||||
onBindInteraction(callbacks);
|
||||
return () => onBindInteraction(null);
|
||||
}, [session, onBindInteraction, onPreview, onSelectPreview]);
|
||||
}, [
|
||||
session,
|
||||
descriptorPath,
|
||||
loading,
|
||||
onBindInteraction,
|
||||
onPreview,
|
||||
onDraftChange,
|
||||
onSelectPreview,
|
||||
]);
|
||||
useEffect(() => {
|
||||
if (session && selected) onSelectPreview(selected);
|
||||
}, [session, selected, onSelectPreview]);
|
||||
useEffect(() => {
|
||||
onTransformMode(transformMode);
|
||||
onSnapping(snapping ? 0.1 : null, snapping ? 5 : null);
|
||||
@@ -129,6 +163,7 @@ export function MapEditorPanel({
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
const keydown = (event: KeyboardEvent) => {
|
||||
if (loading) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.matches('input, textarea, select') || target?.isContentEditable || event.altKey)
|
||||
return;
|
||||
@@ -143,11 +178,13 @@ export function MapEditorPanel({
|
||||
}
|
||||
render((value) => value + 1);
|
||||
onPreview(session.document);
|
||||
onDraftChange(descriptorPath, session.document, session.dirty);
|
||||
} 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');
|
||||
@@ -160,12 +197,13 @@ export function MapEditorPanel({
|
||||
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, selected, onPreview, onSelectPreview]);
|
||||
}, [session, descriptorPath, selected, loading, onPreview, onDraftChange, onSelectPreview]);
|
||||
if (!session)
|
||||
return (
|
||||
<div className="space-y-2 rounded border border-border-subtle p-3 text-xs text-text-tertiary">
|
||||
@@ -196,26 +234,45 @@ export function MapEditorPanel({
|
||||
const active = current.objects.find((object) => object.id === selected);
|
||||
const poseLocked = active?.placementMode === 'locked';
|
||||
const updatePosition = (index: number, value: number) => {
|
||||
if (!active || !Number.isFinite(value)) return;
|
||||
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 (!active || !Number.isFinite(value) || value <= 0) return;
|
||||
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 updateSpawnPosition = (
|
||||
id: string,
|
||||
position: [number, number, number],
|
||||
index: number,
|
||||
value: number,
|
||||
) => {
|
||||
if (!Number.isFinite(value)) return;
|
||||
if (loading || !Number.isFinite(value)) return;
|
||||
const next = [...position] as [number, number, number];
|
||||
next[index] = value;
|
||||
session.updateSpawn(id, { position: next });
|
||||
@@ -224,45 +281,14 @@ export function MapEditorPanel({
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-2 rounded border border-border-subtle p-2">
|
||||
<Button
|
||||
variant={transformMode === 'translate' ? 'primary' : 'secondary'}
|
||||
aria-pressed={transformMode === 'translate'}
|
||||
disabled={loading}
|
||||
onClick={() => setTransformMode('translate')}
|
||||
>
|
||||
移动工具 W
|
||||
</Button>
|
||||
<Button
|
||||
variant={transformMode === 'rotate' ? 'primary' : 'secondary'}
|
||||
aria-pressed={transformMode === 'rotate'}
|
||||
disabled={loading}
|
||||
onClick={() => setTransformMode('rotate')}
|
||||
>
|
||||
旋转工具 E
|
||||
</Button>
|
||||
<Button
|
||||
variant={transformMode === 'scale' ? 'primary' : 'secondary'}
|
||||
aria-pressed={transformMode === 'scale'}
|
||||
disabled={loading}
|
||||
onClick={() => setTransformMode('scale')}
|
||||
>
|
||||
缩放工具 S
|
||||
</Button>
|
||||
<label className="col-span-3 flex items-center justify-between text-[11px] text-text-secondary">
|
||||
<span>吸附(移动 0.1 m / 旋转 5°)</span>
|
||||
<input
|
||||
aria-label="地图编辑吸附"
|
||||
type="checkbox"
|
||||
checked={snapping}
|
||||
disabled={loading}
|
||||
onChange={(event) => setSnapping(event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
<p className="col-span-3 text-[10px] text-text-tertiary">
|
||||
在视口点击对象后拖动操纵轴;只允许绕世界 Z 轴旋转,缩放会写入原语尺寸。
|
||||
</p>
|
||||
</div>
|
||||
<MapTransformToolbar
|
||||
mode={transformMode}
|
||||
snapping={snapping}
|
||||
loading={loading}
|
||||
hint="在视口点击对象后拖动操纵轴;点击空白区域取消选择。只允许绕世界 Z 轴旋转,缩放会写入原语尺寸。"
|
||||
onModeChange={setTransformMode}
|
||||
onSnappingChange={setSnapping}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
aria-label="新增地图对象类型"
|
||||
@@ -314,6 +340,7 @@ export function MapEditorPanel({
|
||||
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 });
|
||||
@@ -348,7 +375,7 @@ export function MapEditorPanel({
|
||||
? '始终将对象底部对齐 z=0。'
|
||||
: active.placementMode === 'gravity'
|
||||
? '沿世界 -Z 落到 XY 范围内最高的静态承载面。'
|
||||
: '保持当前位姿,并禁用表单和视口变换。'}
|
||||
: '保持当前位姿,并禁用位置、旋转及视口变换;名称、尺寸和材质仍可编辑。'}
|
||||
</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
@@ -398,17 +425,86 @@ export function MapEditorPanel({
|
||||
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();
|
||||
@@ -480,6 +576,7 @@ export function MapEditorPanel({
|
||||
aria-label={`出生点名称 ${spawn.id}`}
|
||||
className="h-7 w-full rounded border border-border-strong bg-input px-1 text-xs"
|
||||
value={spawn.name}
|
||||
disabled={loading}
|
||||
onChange={(event) => {
|
||||
if (!event.target.value.trim()) return;
|
||||
session.updateSpawn(spawn.id, { name: event.target.value });
|
||||
@@ -496,6 +593,7 @@ export function MapEditorPanel({
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={value}
|
||||
disabled={loading}
|
||||
onChange={(event) =>
|
||||
updateSpawnPosition(
|
||||
spawn.id,
|
||||
@@ -515,6 +613,7 @@ export function MapEditorPanel({
|
||||
type="number"
|
||||
step="1"
|
||||
value={spawn.yawDeg}
|
||||
disabled={loading}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
if (!Number.isFinite(value)) return;
|
||||
@@ -581,8 +680,7 @@ export function MapEditorPanel({
|
||||
onClick={async () => {
|
||||
const latest = session.document;
|
||||
const candidate = { ...latest, revision: latest.revision + 1 };
|
||||
if (await onApply(candidate)) onPreview(null);
|
||||
else onPreview(session.document);
|
||||
if (!(await onApply(candidate))) onPreview(session.document);
|
||||
}}
|
||||
>
|
||||
应用并重新编译
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { discoverMapEntries, resolveProjectMap } from './MapLoader';
|
||||
import { discoverMapEntries, resolveProjectMap, visualMapAssets } from './MapLoader';
|
||||
import type { ProjectFile, ProjectManifest } from '../project/types';
|
||||
|
||||
const encode = (value: string) => new TextEncoder().encode(value);
|
||||
@@ -52,6 +52,47 @@ describe('MapLoader', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('只为已放置实例解析视觉地图,并保留实例 id', () => {
|
||||
const files = [
|
||||
file('maps/warehouse/map.json', descriptor),
|
||||
file('maps/warehouse/physics/world.xml', '<mujoco><worldbody/></mujoco>'),
|
||||
file('maps/warehouse/visuals/scene.glb', 'glb'),
|
||||
];
|
||||
const projectManifest: ProjectManifest = {
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
files,
|
||||
entries: [],
|
||||
maps: discoverMapEntries(files),
|
||||
totalBytes: files.reduce((sum, item) => sum + item.size, 0),
|
||||
};
|
||||
expect(visualMapAssets(projectManifest, [])).toEqual([]);
|
||||
expect(
|
||||
visualMapAssets(projectManifest, [
|
||||
{
|
||||
id: 'instance-a',
|
||||
name: '仓库副本',
|
||||
selection: {
|
||||
kind: 'project',
|
||||
descriptorPath: 'maps/warehouse/map.json',
|
||||
positionX: 2,
|
||||
positionY: -1,
|
||||
yawDeg: 30,
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'instance-a',
|
||||
name: '仓库副本',
|
||||
path: 'maps/warehouse/visuals/scene.glb',
|
||||
positionX: 2,
|
||||
positionY: -1,
|
||||
yawDeg: 30,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('发现 schema V2 authoring 创作层', () => {
|
||||
const editable = JSON.stringify({
|
||||
...JSON.parse(descriptor),
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { MapEntry, ProjectFile, ProjectManifest } from '../project/types';
|
||||
import { decodeMapDefinition } from './mapSchema';
|
||||
import { resolveProjectAssetPath } from './mapPaths';
|
||||
import type { MapSelection, ResolvedProjectMap, VisualMapAsset } from './types';
|
||||
import {
|
||||
mapSelectionTransform,
|
||||
type MapSelection,
|
||||
type PlacedMapAsset,
|
||||
type ResolvedProjectMap,
|
||||
type VisualMapAsset,
|
||||
} from './types';
|
||||
import { decodeEditableMapDocument } from './editor/editorSchema';
|
||||
|
||||
function projectFile(manifest: ProjectManifest, path: string): ProjectFile {
|
||||
@@ -92,6 +98,7 @@ export function visualMapAsset(
|
||||
const resolved = resolveProjectMap(manifest, selection.descriptorPath);
|
||||
if (!resolved.visualPath || !resolved.definition.visual) return null;
|
||||
const visual = projectFile(manifest, resolved.visualPath);
|
||||
const transform = mapSelectionTransform(selection);
|
||||
return {
|
||||
id: resolved.definition.id,
|
||||
name: resolved.definition.name,
|
||||
@@ -99,5 +106,17 @@ export function visualMapAsset(
|
||||
data: visual.data,
|
||||
castShadow: resolved.definition.visual.castShadow ?? true,
|
||||
receiveShadow: resolved.definition.visual.receiveShadow ?? true,
|
||||
...transform,
|
||||
};
|
||||
}
|
||||
|
||||
/** 将已放置的 project 地图实例解析为可同时挂载的视觉层。 */
|
||||
export function visualMapAssets(
|
||||
manifest: ProjectManifest,
|
||||
assets: readonly PlacedMapAsset[],
|
||||
): VisualMapAsset[] {
|
||||
return assets.flatMap((asset) => {
|
||||
const visual = visualMapAsset(manifest, asset.selection);
|
||||
return visual ? [{ ...visual, id: asset.id, name: asset.name }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { composePlacedMapAssets } from './MapStackComposer';
|
||||
import { DEFAULT_PHYSICAL_MAP_CONFIG, type PlacedMapAsset } from './types';
|
||||
import type { ProjectFile, ProjectManifest } from '../project/types';
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
const file = (path: string, text: string): ProjectFile => ({
|
||||
path,
|
||||
data: encoder.encode(text),
|
||||
size: encoder.encode(text).byteLength,
|
||||
source: 'directory',
|
||||
mimeType: '',
|
||||
});
|
||||
|
||||
const robot = encoder.encode(`
|
||||
<mujoco>
|
||||
<worldbody>
|
||||
<geom name="__platform_ground__" type="plane" size="2 2 .1"/>
|
||||
<body name="robot"><freejoint/><geom type="box" size=".1 .1 .1"/></body>
|
||||
</worldbody>
|
||||
</mujoco>`);
|
||||
|
||||
function manifest(): ProjectManifest {
|
||||
const files = [
|
||||
file(
|
||||
'maps/warehouse/map.json',
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: 'warehouse',
|
||||
name: '仓库',
|
||||
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
|
||||
physics: { source: 'physics/world.xml' },
|
||||
spawnPoints: [],
|
||||
}),
|
||||
),
|
||||
file(
|
||||
'maps/warehouse/physics/world.xml',
|
||||
'<mujoco><worldbody><body name="building"><geom name="wall" type="box" size="1 .1 1"/></body></worldbody></mujoco>',
|
||||
),
|
||||
file(
|
||||
'maps/overlay/map.json',
|
||||
JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
id: 'overlay',
|
||||
name: '体素层',
|
||||
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
|
||||
physics: { source: 'physics/world.xml' },
|
||||
authoring: { source: 'authoring/map.scene.json' },
|
||||
spawnPoints: [],
|
||||
}),
|
||||
),
|
||||
file(
|
||||
'maps/overlay/physics/world.xml',
|
||||
'<mujoco><worldbody><body name="edit_body_box"><geom name="edit_box" type="box" size=".5 .5 .5"/></body></worldbody></mujoco>',
|
||||
),
|
||||
file(
|
||||
'maps/overlay/authoring/map.scene.json',
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
mapId: 'overlay',
|
||||
revision: 0,
|
||||
objects: [],
|
||||
spawnPoints: [],
|
||||
}),
|
||||
),
|
||||
];
|
||||
return {
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
files,
|
||||
entries: [{ path: 'robot.xml', format: 'mjcf', label: 'robot' }],
|
||||
maps: [],
|
||||
totalBytes: files.reduce((sum, item) => sum + item.size, 0),
|
||||
};
|
||||
}
|
||||
|
||||
function documentOf(data: Uint8Array): Document {
|
||||
return new DOMParser().parseFromString(decoder.decode(data), 'application/xml');
|
||||
}
|
||||
|
||||
function project(id: string, descriptorPath = 'maps/warehouse/map.json'): PlacedMapAsset {
|
||||
return { id, name: `仓库 ${id}`, selection: { kind: 'project', descriptorPath } };
|
||||
}
|
||||
|
||||
describe('composePlacedMapAssets', () => {
|
||||
it('同时保留工程地图、编辑方盒层和系统参数地形', () => {
|
||||
const assets: PlacedMapAsset[] = [
|
||||
project('warehouse-a'),
|
||||
project('voxel-a', 'maps/overlay/map.json'),
|
||||
{
|
||||
id: 'terrain-a',
|
||||
name: '粗糙地形',
|
||||
selection: {
|
||||
kind: 'builtin',
|
||||
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'rough', size: 6 },
|
||||
},
|
||||
},
|
||||
];
|
||||
const result = composePlacedMapAssets(robot, 'scene.xml', manifest(), assets);
|
||||
const document = documentOf(result.data);
|
||||
expect(
|
||||
document.querySelector('[name="__platform_map_warehouse_warehouse-a_wall"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
document.querySelector('[name="__platform_map_overlay_voxel-a_edit_box"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
document.querySelector('[name^="__platform_map_terrain-a__"][type="hfield"]'),
|
||||
).not.toBeNull();
|
||||
expect(document.querySelector('[name="__platform_ground__"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('无论场景顺序如何,程序地形都会移除覆盖坑洞的工程无限平面', () => {
|
||||
const projectManifest = manifest();
|
||||
const physics = projectManifest.files.find(
|
||||
(candidate) => candidate.path === 'maps/warehouse/physics/world.xml',
|
||||
)!;
|
||||
physics.data = encoder.encode(
|
||||
'<mujoco><worldbody><geom name="floor" type="plane" size="8 8 .1"/></worldbody></mujoco>',
|
||||
);
|
||||
physics.size = physics.data.byteLength;
|
||||
const result = composePlacedMapAssets(robot, 'scene.xml', projectManifest, [
|
||||
{
|
||||
id: 'pit-a',
|
||||
name: '深坑',
|
||||
selection: {
|
||||
kind: 'builtin',
|
||||
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'pit' },
|
||||
},
|
||||
},
|
||||
project('warehouse-a'),
|
||||
]);
|
||||
const document = documentOf(result.data);
|
||||
expect(document.querySelectorAll('geom[type="plane"]')).toHaveLength(0);
|
||||
expect(document.querySelector('[name^="__platform_map_pit-a__pit-bottom"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('非系统内置地图复用已有承载面,不生成重叠地面', () => {
|
||||
const result = composePlacedMapAssets(robot, 'scene.xml', manifest(), [
|
||||
{
|
||||
id: 'stairs-a',
|
||||
name: '楼梯',
|
||||
selection: {
|
||||
kind: 'builtin',
|
||||
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'stairs', stairCount: 4 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'obstacles-a',
|
||||
name: '障碍物',
|
||||
selection: {
|
||||
kind: 'builtin',
|
||||
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'obstacles', obstacleCount: 3 },
|
||||
},
|
||||
},
|
||||
]);
|
||||
const document = documentOf(result.data);
|
||||
expect(document.querySelectorAll('geom[type="plane"]')).toHaveLength(1);
|
||||
expect(document.querySelector('[name$="__ground__"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('同一工程地图重复放置时使用实例命名空间和独立位姿', () => {
|
||||
const first = project('copy-a');
|
||||
first.selection = {
|
||||
kind: 'project',
|
||||
descriptorPath: 'maps/warehouse/map.json',
|
||||
positionX: 2,
|
||||
positionY: -1,
|
||||
yawDeg: 30,
|
||||
};
|
||||
const second = project('copy-b');
|
||||
second.selection = {
|
||||
kind: 'project',
|
||||
descriptorPath: 'maps/warehouse/map.json',
|
||||
positionX: -3,
|
||||
positionY: 4,
|
||||
yawDeg: -45,
|
||||
};
|
||||
const result = composePlacedMapAssets(robot, 'scene.xml', manifest(), [first, second]);
|
||||
const document = documentOf(result.data);
|
||||
expect(document.querySelector('[name="__platform_map_warehouse_copy-a_wall"]')).not.toBeNull();
|
||||
expect(document.querySelector('[name="__platform_map_warehouse_copy-b_wall"]')).not.toBeNull();
|
||||
expect(
|
||||
document
|
||||
.querySelector('[name="__platform_map_warehouse_copy-a_instance_root"]')
|
||||
?.getAttribute('pos'),
|
||||
).toBe('2 -1 0');
|
||||
expect(
|
||||
document
|
||||
.querySelector('[name="__platform_map_warehouse_copy-b_instance_root"]')
|
||||
?.getAttribute('pos'),
|
||||
).toBe('-3 4 0');
|
||||
const names = Array.from(document.querySelectorAll('[name]'), (element) =>
|
||||
element.getAttribute('name'),
|
||||
);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
});
|
||||
|
||||
it('多个实例声明出生点时只确定性应用第一个', () => {
|
||||
const projectManifest = manifest();
|
||||
const descriptorFile = projectManifest.files.find(
|
||||
(candidate) => candidate.path === 'maps/warehouse/map.json',
|
||||
)!;
|
||||
const descriptorValue = JSON.parse(decoder.decode(descriptorFile.data));
|
||||
descriptorValue.spawnPoints = [
|
||||
{ id: 'first', name: '第一出生点', position: [1, 0, 0], yawDeg: 0 },
|
||||
{ id: 'second', name: '第二出生点', position: [10, 0, 0], yawDeg: 0 },
|
||||
];
|
||||
descriptorFile.data = encoder.encode(JSON.stringify(descriptorValue));
|
||||
descriptorFile.size = descriptorFile.data.byteLength;
|
||||
const result = composePlacedMapAssets(robot, 'scene.xml', projectManifest, [
|
||||
{
|
||||
...project('copy-a'),
|
||||
selection: {
|
||||
kind: 'project',
|
||||
descriptorPath: 'maps/warehouse/map.json',
|
||||
spawnPointId: 'first',
|
||||
},
|
||||
},
|
||||
{
|
||||
...project('copy-b'),
|
||||
selection: {
|
||||
kind: 'project',
|
||||
descriptorPath: 'maps/warehouse/map.json',
|
||||
spawnPointId: 'second',
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
documentOf(result.data)
|
||||
.querySelector('body[name="robot"]')
|
||||
?.getAttribute('pos')
|
||||
?.split(/\s+/)
|
||||
.map(Number),
|
||||
).toEqual([1, 0, 0]);
|
||||
expect(result.warnings.join(' ')).toContain('第一出生点');
|
||||
expect(result.warnings.join(' ')).not.toContain('第二出生点');
|
||||
});
|
||||
|
||||
it('多个内置实例同时存在且名称不冲突', () => {
|
||||
const result = composePlacedMapAssets(robot, 'scene.xml', manifest(), [
|
||||
{
|
||||
id: 'stairs-a',
|
||||
name: '楼梯',
|
||||
selection: {
|
||||
kind: 'builtin',
|
||||
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'stairs', stairCount: 3 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'obstacles-a',
|
||||
name: '障碍物',
|
||||
selection: {
|
||||
kind: 'builtin',
|
||||
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'obstacles', obstacleCount: 2 },
|
||||
},
|
||||
},
|
||||
]);
|
||||
const document = documentOf(result.data);
|
||||
expect(document.querySelectorAll('[name^="__platform_map_stairs-a__stair_"]')).toHaveLength(3);
|
||||
expect(
|
||||
document.querySelectorAll('[name^="__platform_map_obstacles-a__obstacle_"]'),
|
||||
).toHaveLength(2);
|
||||
expect(document.querySelector('[name="__platform_map_stairs-a__root__"]')).not.toBeNull();
|
||||
expect(document.querySelector('[name="__platform_map_obstacles-a__root__"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ProjectManifest } from '../project/types';
|
||||
import { resolveProjectMap } from './MapLoader';
|
||||
import { composeProjectMap } from './MapComposer';
|
||||
import { composePhysicalMap } from './physicalMap';
|
||||
import { isSystemTerrainPreset, type PlacedMapAsset } from './types';
|
||||
|
||||
export interface MapStackComposition {
|
||||
data: Uint8Array;
|
||||
geomCount: number;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** 将全部已放置地图实例依次合成到同一 MJCF;源地图条目本身不会自动进入场景。 */
|
||||
export function composePlacedMapAssets(
|
||||
source: Uint8Array,
|
||||
generatedScenePath: string,
|
||||
manifest: ProjectManifest,
|
||||
assets: readonly PlacedMapAsset[],
|
||||
): MapStackComposition {
|
||||
let data = source;
|
||||
let geomCount = 0;
|
||||
const warnings: string[] = [];
|
||||
const spawnOwnerId = assets.find(
|
||||
(asset) => asset.selection.kind === 'project' && asset.selection.spawnPointId,
|
||||
)?.id;
|
||||
// 程序地形最后合成,确保工程地图或机器人自带的无限 plane 不会覆盖沟壑/深坑。
|
||||
const orderedAssets = [
|
||||
...assets.filter(
|
||||
(asset) =>
|
||||
asset.selection.kind !== 'builtin' || !isSystemTerrainPreset(asset.selection.config.preset),
|
||||
),
|
||||
...assets.filter(
|
||||
(asset) =>
|
||||
asset.selection.kind === 'builtin' && isSystemTerrainPreset(asset.selection.config.preset),
|
||||
),
|
||||
];
|
||||
|
||||
for (const asset of orderedAssets) {
|
||||
if (asset.selection.kind === 'builtin') {
|
||||
const composed = composePhysicalMap(data, asset.selection.config, {
|
||||
instanceId: asset.id,
|
||||
preserveExistingGround: true,
|
||||
});
|
||||
data = composed.data;
|
||||
geomCount += composed.geomCount;
|
||||
if (composed.summary) warnings.push(`${asset.name}:${composed.summary}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolvedMap = resolveProjectMap(manifest, asset.selection.descriptorPath);
|
||||
const composed = composeProjectMap(
|
||||
data,
|
||||
generatedScenePath,
|
||||
manifest,
|
||||
resolvedMap,
|
||||
asset.selection,
|
||||
{
|
||||
instanceId: asset.id,
|
||||
applySpawnPoint: asset.id === spawnOwnerId,
|
||||
},
|
||||
);
|
||||
data = composed.data;
|
||||
geomCount += composed.geomCount;
|
||||
warnings.push(...composed.warnings, `${asset.name}:${composed.summary}`);
|
||||
}
|
||||
|
||||
return { data, geomCount, warnings };
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ const common = {
|
||||
showMapCollision: false,
|
||||
editorDocument: null,
|
||||
onEditorPreview: () => {},
|
||||
onEditorDraftChange: () => {},
|
||||
onEditorApply: async () => true,
|
||||
onEditorExport: () => {},
|
||||
onEditorConvert: async () => true,
|
||||
@@ -19,13 +20,42 @@ const common = {
|
||||
onEditorTransformMode: () => {},
|
||||
onEditorSnapping: () => {},
|
||||
onMapDisplay: () => {},
|
||||
onDraft: () => {},
|
||||
};
|
||||
|
||||
describe('PhysicalMapPanel', () => {
|
||||
it('无选中实例时显示引导空态', () => {
|
||||
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={() => {}} />);
|
||||
expect(screen.getByText('未选择地图实例')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('地图来源')).toHaveValue('none');
|
||||
});
|
||||
|
||||
it('已有场景草稿时无需再次改参数也可一次编译', () => {
|
||||
const onApply = vi.fn();
|
||||
render(
|
||||
<PhysicalMapPanel
|
||||
{...common}
|
||||
value={{ kind: 'builtin', config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'rough' } }}
|
||||
sceneDirty
|
||||
onApply={onApply}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/场景草稿正在轻量预览/)).toBeInTheDocument();
|
||||
const apply = screen.getByRole('button', { name: '应用并重新编译' });
|
||||
expect(apply).toBeEnabled();
|
||||
fireEvent.click(apply);
|
||||
expect(onApply).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('选择楼梯参数后请求重新编译', () => {
|
||||
const onApply = vi.fn();
|
||||
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={onApply} />);
|
||||
fireEvent.change(screen.getByLabelText('地图来源'), { target: { value: 'builtin' } });
|
||||
render(
|
||||
<PhysicalMapPanel
|
||||
{...common}
|
||||
value={{ kind: 'builtin', config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'flat' } }}
|
||||
onApply={onApply}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText('物理地图预设'), { target: { value: 'stairs' } });
|
||||
fireEvent.change(screen.getByLabelText('台阶数量'), { target: { value: '6' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
|
||||
@@ -37,8 +67,13 @@ describe('PhysicalMapPanel', () => {
|
||||
|
||||
it('在右侧参数面板调整系统地形并提交', () => {
|
||||
const onApply = vi.fn();
|
||||
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={onApply} />);
|
||||
fireEvent.change(screen.getByLabelText('地图来源'), { target: { value: 'builtin' } });
|
||||
render(
|
||||
<PhysicalMapPanel
|
||||
{...common}
|
||||
value={{ kind: 'builtin', config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'flat' } }}
|
||||
onApply={onApply}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText('物理地图预设'), { target: { value: 'rough' } });
|
||||
fireEvent.change(screen.getByLabelText('地形难度(0–1)'), { target: { value: '0.8' } });
|
||||
fireEvent.change(screen.getByLabelText('随机种子'), { target: { value: '9' } });
|
||||
@@ -53,10 +88,73 @@ describe('PhysicalMapPanel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('参数地形与认证资产共用视口变换界面', () => {
|
||||
const onTransformMode = vi.fn();
|
||||
const onSnapping = vi.fn();
|
||||
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);
|
||||
});
|
||||
|
||||
it('表单参数与外部视口变换合并到同一份草稿', () => {
|
||||
const onDraft = vi.fn();
|
||||
const onApply = vi.fn();
|
||||
const initial = {
|
||||
kind: 'builtin' as const,
|
||||
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'rough' as const },
|
||||
};
|
||||
const view = render(
|
||||
<PhysicalMapPanel
|
||||
{...common}
|
||||
value={initial}
|
||||
sceneDirty
|
||||
onDraft={onDraft}
|
||||
onApply={onApply}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText('地形边长(m)'), { target: { value: '7' } });
|
||||
const staged = onDraft.mock.calls.at(-1)?.[0];
|
||||
expect(staged).toMatchObject({ kind: 'builtin', config: { size: 7 } });
|
||||
const moved = {
|
||||
...staged,
|
||||
config: { ...staged.config, positionX: 2.4, positionY: -1.2 },
|
||||
};
|
||||
view.rerender(
|
||||
<PhysicalMapPanel {...common} value={moved} sceneDirty onDraft={onDraft} onApply={onApply} />,
|
||||
);
|
||||
expect(screen.getByLabelText('地形边长(m)')).toHaveValue(7);
|
||||
expect(screen.getByLabelText('位置 X(m)')).toHaveValue(2.4);
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
|
||||
expect(onApply).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'builtin',
|
||||
config: expect.objectContaining({ size: 7, positionX: 2.4, positionY: -1.2 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('可以移动并旋转资产地图', () => {
|
||||
const onApply = vi.fn();
|
||||
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={onApply} />);
|
||||
fireEvent.change(screen.getByLabelText('地图来源'), { target: { value: 'builtin' } });
|
||||
render(
|
||||
<PhysicalMapPanel
|
||||
{...common}
|
||||
value={{ kind: 'builtin', config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'flat' } }}
|
||||
onApply={onApply}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText('位置 X(m)'), { target: { value: '2.5' } });
|
||||
fireEvent.change(screen.getByLabelText('位置 Y(m)'), { target: { value: '-1.5' } });
|
||||
fireEvent.change(screen.getByLabelText('旋转 Z(°)'), { target: { value: '45' } });
|
||||
@@ -67,12 +165,13 @@ describe('PhysicalMapPanel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('显示工程地图、出生点和根 Body', () => {
|
||||
it('显示工程地图、实例变换、出生点和根 Body', () => {
|
||||
const onApply = vi.fn();
|
||||
const onDraft = vi.fn();
|
||||
render(
|
||||
<PhysicalMapPanel
|
||||
{...common}
|
||||
value={{ kind: 'none' }}
|
||||
value={{ kind: 'project', descriptorPath: 'maps/warehouse/map.json' }}
|
||||
maps={[
|
||||
{
|
||||
descriptorPath: 'maps/warehouse/map.json',
|
||||
@@ -83,12 +182,14 @@ describe('PhysicalMapPanel', () => {
|
||||
},
|
||||
]}
|
||||
rootBodies={['robot', 'payload']}
|
||||
onDraft={onDraft}
|
||||
onApply={onApply}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText('地图来源'), {
|
||||
target: { value: 'project:maps/warehouse/map.json' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('位置 X(m)'), { target: { value: '2.5' } });
|
||||
fireEvent.change(screen.getByLabelText('位置 Y(m)'), { target: { value: '-1.5' } });
|
||||
fireEvent.change(screen.getByLabelText('旋转 Z(°)'), { target: { value: '45' } });
|
||||
fireEvent.change(screen.getByLabelText('地图出生点'), { target: { value: 'door' } });
|
||||
expect(screen.getByLabelText('地图出生点')).toHaveValue('door');
|
||||
fireEvent.change(screen.getByLabelText('机器人根 Body'), { target: { value: 'robot' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
|
||||
@@ -96,10 +197,16 @@ describe('PhysicalMapPanel', () => {
|
||||
expect.objectContaining({
|
||||
kind: 'project',
|
||||
descriptorPath: 'maps/warehouse/map.json',
|
||||
positionX: 2.5,
|
||||
positionY: -1.5,
|
||||
yawDeg: 45,
|
||||
spawnPointId: 'door',
|
||||
robotRootBody: 'robot',
|
||||
}),
|
||||
);
|
||||
expect(onDraft).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ kind: 'project', robotRootBody: 'robot' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('原生 URDF 模式只允许移除地图', () => {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Select } from '../components/ui';
|
||||
import type { MapEntry } from '../project/types';
|
||||
import {
|
||||
DEFAULT_PHYSICAL_MAP_CONFIG,
|
||||
PHYSICAL_MAP_PRESET_LABELS,
|
||||
isSystemTerrainPreset,
|
||||
mapSelectionTransform,
|
||||
type MapSelection,
|
||||
type PhysicalMapConfig,
|
||||
type PhysicalMapPreset,
|
||||
type PlacedMapSelection,
|
||||
} from './types';
|
||||
import { normalizePhysicalMapConfig } from './physicalMap';
|
||||
import type {
|
||||
@@ -16,6 +18,7 @@ import type {
|
||||
MapEditorTransformMode,
|
||||
} from './editor/types';
|
||||
import { MapEditorPanel } from './MapEditorPanel';
|
||||
import { MapTransformToolbar } from './MapTransformToolbar';
|
||||
|
||||
function NumberField({
|
||||
label,
|
||||
@@ -56,6 +59,7 @@ function normalizeSelection(selection: MapSelection): MapSelection {
|
||||
const friction = selection.frictionOverride;
|
||||
return {
|
||||
...selection,
|
||||
...mapSelectionTransform(selection),
|
||||
frictionOverride:
|
||||
friction === undefined || !Number.isFinite(friction)
|
||||
? undefined
|
||||
@@ -76,7 +80,10 @@ export function PhysicalMapPanel({
|
||||
showVisualMap,
|
||||
showMapCollision,
|
||||
editorDocument,
|
||||
editorDraftDocument,
|
||||
sceneDirty = false,
|
||||
onEditorPreview,
|
||||
onEditorDraftChange,
|
||||
onEditorApply,
|
||||
onEditorExport,
|
||||
onEditorConvert,
|
||||
@@ -84,7 +91,9 @@ export function PhysicalMapPanel({
|
||||
onEditorSelect,
|
||||
onEditorTransformMode,
|
||||
onEditorSnapping,
|
||||
onEditorSurfaceHeight,
|
||||
onMapDisplay,
|
||||
onDraft,
|
||||
onApply,
|
||||
}: {
|
||||
value: MapSelection;
|
||||
@@ -95,7 +104,14 @@ export function PhysicalMapPanel({
|
||||
showVisualMap: boolean;
|
||||
showMapCollision: boolean;
|
||||
editorDocument: EditableMapDocument | null;
|
||||
editorDraftDocument?: EditableMapDocument;
|
||||
sceneDirty?: boolean;
|
||||
onEditorPreview: (document: EditableMapDocument | null) => void;
|
||||
onEditorDraftChange: (
|
||||
descriptorPath: string,
|
||||
document: EditableMapDocument,
|
||||
dirty: boolean,
|
||||
) => void;
|
||||
onEditorApply: (document: EditableMapDocument) => Promise<boolean>;
|
||||
onEditorExport: () => void;
|
||||
onEditorConvert: () => Promise<boolean>;
|
||||
@@ -103,12 +119,26 @@ export function PhysicalMapPanel({
|
||||
onEditorSelect: (id: string | null) => void;
|
||||
onEditorTransformMode: (mode: MapEditorTransformMode) => void;
|
||||
onEditorSnapping: (translation: number | null, rotationDegrees: number | 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);
|
||||
const [draft, setDraft] = useState<MapSelection>(value),
|
||||
[previousValue, setPreviousValue] = useState(value),
|
||||
[terrainTransformMode, setTerrainTransformMode] = useState<MapEditorTransformMode>('translate'),
|
||||
[terrainSnapping, setTerrainSnapping] = useState(true);
|
||||
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(value);
|
||||
const changed = JSON.stringify(normalized) !== JSON.stringify(normalizeSelection(value));
|
||||
const compileDisabled = loading || (nativeUrdf && normalized.kind !== 'none');
|
||||
const sourceValue =
|
||||
draft.kind === 'none'
|
||||
@@ -120,9 +150,18 @@ export function PhysicalMapPanel({
|
||||
draft.kind === 'project'
|
||||
? maps.find((entry) => entry.descriptorPath === draft.descriptorPath)
|
||||
: undefined;
|
||||
const replaceBuiltin = (config: PhysicalMapConfig) => {
|
||||
const next: Extract<MapSelection, { kind: 'builtin' }> = { kind: 'builtin', config };
|
||||
setDraft(next);
|
||||
if (value.kind === 'builtin') onDraft(next);
|
||||
};
|
||||
const updateBuiltin = <K extends keyof PhysicalMapConfig>(key: K, next: PhysicalMapConfig[K]) => {
|
||||
if (draft.kind !== 'builtin') return;
|
||||
setDraft({ kind: 'builtin', config: { ...draft.config, [key]: next } });
|
||||
replaceBuiltin({ ...draft.config, [key]: next });
|
||||
};
|
||||
const replaceProject = (next: Extract<MapSelection, { kind: 'project' }>) => {
|
||||
setDraft(next);
|
||||
if (value.kind === 'project' && value.descriptorPath === next.descriptorPath) onDraft(next);
|
||||
};
|
||||
|
||||
const changeSource = (source: string) => {
|
||||
@@ -141,17 +180,68 @@ export function PhysicalMapPanel({
|
||||
setDraft({
|
||||
kind: 'project',
|
||||
descriptorPath,
|
||||
positionX: 0,
|
||||
positionY: 0,
|
||||
yawDeg: 0,
|
||||
spawnPointId: map?.spawnPoints[0]?.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (value.kind === 'none' && draft.kind === 'none')
|
||||
return (
|
||||
<div className="space-y-3 p-3">
|
||||
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-3">
|
||||
<div className="text-xs font-semibold text-text-primary">未选择地图实例</div>
|
||||
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
|
||||
请从左侧“资产”页放置或选择地图。参数修改只会作用于当前选中的实例。
|
||||
</p>
|
||||
<label className="mt-3 block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">快速放置地图</span>
|
||||
<Select
|
||||
aria-label="地图来源"
|
||||
className="w-full"
|
||||
value="none"
|
||||
disabled={loading}
|
||||
onChange={(event) => changeSource(event.target.value)}
|
||||
>
|
||||
<option value="none">请选择地图源</option>
|
||||
<option value="builtin">内置程序地图</option>
|
||||
{maps.map((map) => (
|
||||
<option key={map.descriptorPath} value={`project:${map.descriptorPath}`}>
|
||||
{map.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-2 rounded border border-border-subtle p-3 text-xs text-text-secondary">
|
||||
<label className="flex items-center justify-between gap-3">
|
||||
<span>显示视觉地图</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showVisualMap}
|
||||
onChange={(event) => onMapDisplay(event.target.checked, showMapCollision)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between gap-3">
|
||||
<span>显示地图碰撞层</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showMapCollision}
|
||||
onChange={(event) => onMapDisplay(showVisualMap, event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-3">
|
||||
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-3">
|
||||
<div className="text-xs font-semibold text-text-primary">地图参数</div>
|
||||
<div className="text-xs font-semibold text-text-primary">地图与对象属性</div>
|
||||
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
|
||||
从左侧“资产”选择地形或添加场景对象,在这里调整物理参数与编辑属性。
|
||||
参数地形与认证资产统一在此管理。视口选中对象后可调整变换和专属参数。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -176,6 +266,16 @@ 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}
|
||||
/>
|
||||
<label className="block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">地图预设</span>
|
||||
<Select
|
||||
@@ -205,10 +305,7 @@ export function PhysicalMapPanel({
|
||||
draft.config.yawDeg === 0)
|
||||
}
|
||||
onClick={() =>
|
||||
setDraft({
|
||||
kind: 'builtin',
|
||||
config: { ...draft.config, positionX: 0, positionY: 0, yawDeg: 0 },
|
||||
})
|
||||
replaceBuiltin({ ...draft.config, positionX: 0, positionY: 0, yawDeg: 0 })
|
||||
}
|
||||
>
|
||||
复位
|
||||
@@ -349,6 +446,54 @@ export function PhysicalMapPanel({
|
||||
<div className="break-all text-[11px] text-text-tertiary">
|
||||
{projectMap.descriptorPath}
|
||||
</div>
|
||||
<div className="rounded-lg border border-border-subtle bg-panel-bg/40 p-2.5">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-text-primary">实例变换</span>
|
||||
<Button
|
||||
disabled={
|
||||
loading ||
|
||||
((draft.positionX ?? 0) === 0 &&
|
||||
(draft.positionY ?? 0) === 0 &&
|
||||
(draft.yawDeg ?? 0) === 0)
|
||||
}
|
||||
onClick={() => replaceProject({ ...draft, positionX: 0, positionY: 0, yawDeg: 0 })}
|
||||
>
|
||||
复位
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<NumberField
|
||||
label="位置 X(m)"
|
||||
value={draft.positionX ?? 0}
|
||||
min={-100}
|
||||
max={100}
|
||||
step={0.1}
|
||||
disabled={loading}
|
||||
onChange={(positionX) => replaceProject({ ...draft, positionX })}
|
||||
/>
|
||||
<NumberField
|
||||
label="位置 Y(m)"
|
||||
value={draft.positionY ?? 0}
|
||||
min={-100}
|
||||
max={100}
|
||||
step={0.1}
|
||||
disabled={loading}
|
||||
onChange={(positionY) => replaceProject({ ...draft, positionY })}
|
||||
/>
|
||||
<NumberField
|
||||
label="旋转 Z(°)"
|
||||
value={draft.yawDeg ?? 0}
|
||||
min={-180}
|
||||
max={180}
|
||||
step={1}
|
||||
disabled={loading}
|
||||
onChange={(yawDeg) => replaceProject({ ...draft, yawDeg })}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] leading-relaxed text-text-tertiary">
|
||||
位姿属于当前场景实例;编辑下方源内容会同步影响引用同一地图的实例。
|
||||
</p>
|
||||
</div>
|
||||
{projectMap.spawnPoints.length > 0 && (
|
||||
<label className="block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">出生点</span>
|
||||
@@ -358,7 +503,7 @@ export function PhysicalMapPanel({
|
||||
value={draft.spawnPointId ?? ''}
|
||||
disabled={loading}
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, spawnPointId: event.target.value || undefined })
|
||||
replaceProject({ ...draft, spawnPointId: event.target.value || undefined })
|
||||
}
|
||||
>
|
||||
<option value="">不改变机器人位置</option>
|
||||
@@ -379,7 +524,7 @@ export function PhysicalMapPanel({
|
||||
value={draft.robotRootBody ?? ''}
|
||||
disabled={loading}
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, robotRootBody: event.target.value || undefined })
|
||||
replaceProject({ ...draft, robotRootBody: event.target.value || undefined })
|
||||
}
|
||||
>
|
||||
<option value="">自动识别</option>
|
||||
@@ -403,7 +548,7 @@ export function PhysicalMapPanel({
|
||||
disabled={loading}
|
||||
className="h-8 w-full rounded border border-border-strong bg-input px-2 text-xs text-text-primary"
|
||||
onChange={(event) =>
|
||||
setDraft({
|
||||
replaceProject({
|
||||
...draft,
|
||||
frictionOverride: event.target.value ? Number(event.target.value) : undefined,
|
||||
})
|
||||
@@ -416,11 +561,17 @@ export function PhysicalMapPanel({
|
||||
{draft.kind === 'project' &&
|
||||
draft.descriptorPath === (value.kind === 'project' ? value.descriptorPath : '') && (
|
||||
<div className="space-y-2 rounded border border-border-subtle p-3">
|
||||
<div className="text-xs font-medium text-text-primary">V3 地图编辑器</div>
|
||||
<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}
|
||||
@@ -429,6 +580,7 @@ export function PhysicalMapPanel({
|
||||
onSelectPreview={onEditorSelect}
|
||||
onTransformMode={onEditorTransformMode}
|
||||
onSnapping={onEditorSnapping}
|
||||
onSurfaceHeight={onEditorSurfaceHeight}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -461,10 +613,19 @@ export function PhysicalMapPanel({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{sceneDirty && (
|
||||
<p
|
||||
role="status"
|
||||
className="rounded border border-accent/30 bg-accent-soft p-2 text-xs text-accent"
|
||||
>
|
||||
场景草稿正在轻量预览;工程地图、认证资产与参数地形会在确认后一次性编译。
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
disabled={compileDisabled || !changed}
|
||||
disabled={compileDisabled || (!changed && !sceneDirty)}
|
||||
onClick={() => onApply(normalized)}
|
||||
>
|
||||
应用并重新编译
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { ProjectFile, ProjectManifest } from '../../project/types';
|
||||
import { discoverMapEntries } from '../MapLoader';
|
||||
import { materializeEditableMapDrafts } from './EditableMapDraftCommit';
|
||||
import { decodeEditableMapDocument } from './editorSchema';
|
||||
import { createEditableObject, type EditableMapDocument } from './types';
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
function file(path: string, text: string): ProjectFile {
|
||||
const data = encoder.encode(text);
|
||||
return { path, data, size: data.byteLength, source: 'zip', mimeType: '' };
|
||||
}
|
||||
|
||||
function manifest(): ProjectManifest {
|
||||
const document: EditableMapDocument = {
|
||||
schemaVersion: 1,
|
||||
mapId: 'scene',
|
||||
revision: 2,
|
||||
objects: [],
|
||||
spawnPoints: [],
|
||||
};
|
||||
const files = [
|
||||
file(
|
||||
'maps/scene/map.json',
|
||||
JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
id: 'scene',
|
||||
name: '场景',
|
||||
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
|
||||
physics: { source: 'physics/world.xml' },
|
||||
authoring: { source: 'authoring/map.scene.json' },
|
||||
spawnPoints: [],
|
||||
}),
|
||||
),
|
||||
file('maps/scene/physics/world.xml', '<mujoco><worldbody/></mujoco>'),
|
||||
file('maps/scene/authoring/map.scene.json', JSON.stringify(document)),
|
||||
file('robot.xml', '<mujoco><worldbody/></mujoco>'),
|
||||
];
|
||||
return {
|
||||
id: 'project',
|
||||
name: 'project',
|
||||
files,
|
||||
entries: [{ path: 'robot.xml', format: 'mjcf', label: 'robot.xml' }],
|
||||
maps: discoverMapEntries(files),
|
||||
totalBytes: files.reduce((total, item) => total + item.size, 0),
|
||||
};
|
||||
}
|
||||
|
||||
describe('认证资产场景草稿提交', () => {
|
||||
it('在不修改原清单的前提下同步生成 authoring、physics 与出生点描述', () => {
|
||||
const original = manifest();
|
||||
const draft: EditableMapDocument = {
|
||||
schemaVersion: 1,
|
||||
mapId: 'scene',
|
||||
revision: 2,
|
||||
objects: [createEditableObject('box', 'certified_box')],
|
||||
spawnPoints: [{ id: 'start', name: '起点', position: [1, 2, 0], yawDeg: 90 }],
|
||||
};
|
||||
|
||||
const candidate = materializeEditableMapDrafts(
|
||||
original,
|
||||
new Map([['maps/scene/map.json', draft]]),
|
||||
);
|
||||
|
||||
expect(candidate).not.toBe(original);
|
||||
expect(decoder.decode(original.files[1].data)).not.toContain('certified_box');
|
||||
expect(decoder.decode(candidate.files[1].data)).toContain('edit_certified_box');
|
||||
expect(decodeEditableMapDocument(candidate.files[2].data)).toMatchObject({
|
||||
revision: 3,
|
||||
objects: [{ id: 'certified_box' }],
|
||||
});
|
||||
expect(JSON.parse(decoder.decode(candidate.files[0].data))).toMatchObject({
|
||||
spawnPoints: [{ id: 'start', name: '起点' }],
|
||||
});
|
||||
expect(candidate.maps[0].spawnPoints).toEqual([{ id: 'start', name: '起点' }]);
|
||||
});
|
||||
|
||||
it('拒绝把其他地图的草稿写入当前描述文件', () => {
|
||||
const original = manifest();
|
||||
const draft: EditableMapDocument = {
|
||||
schemaVersion: 1,
|
||||
mapId: 'other',
|
||||
revision: 0,
|
||||
objects: [],
|
||||
spawnPoints: [],
|
||||
};
|
||||
expect(() =>
|
||||
materializeEditableMapDrafts(original, new Map([['maps/scene/map.json', draft]])),
|
||||
).toThrow('mapId');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ProjectManifest } from '../../project/types';
|
||||
import { discoverMapEntries, resolveProjectMap } from '../MapLoader';
|
||||
import { decodeMapDefinition } from '../mapSchema';
|
||||
import { compileEditableMapDocument } from './MapDocumentCompiler';
|
||||
import { decodeEditableMapDocument, encodeEditableMapDocument } from './editorSchema';
|
||||
import type { EditableMapDocument } from './types';
|
||||
|
||||
/**
|
||||
* 将场景编辑草稿写入一个新的工程清单,供后续 MuJoCo 事务式编译使用。
|
||||
* 原清单不会被修改;只有仿真编译成功后,调用方才应提交返回值。
|
||||
*/
|
||||
export function materializeEditableMapDrafts(
|
||||
manifest: ProjectManifest,
|
||||
drafts: ReadonlyMap<string, EditableMapDocument>,
|
||||
): ProjectManifest {
|
||||
if (!drafts.size) return manifest;
|
||||
|
||||
const replacements = new Map<string, Uint8Array>();
|
||||
for (const [descriptorPath, requestedDocument] of drafts) {
|
||||
const resolved = resolveProjectMap(manifest, descriptorPath);
|
||||
if (!resolved.authoringPath || !resolved.physicsPath)
|
||||
throw new Error(
|
||||
`可编辑地图 ${descriptorPath} 必须同时声明 authoring.source 和 physics.source`,
|
||||
);
|
||||
|
||||
const authoringFile = manifest.files.find((file) => file.path === resolved.authoringPath);
|
||||
if (!authoringFile) throw new Error(`地图创作层不存在:${resolved.authoringPath}`);
|
||||
const savedDocument = decodeEditableMapDocument(authoringFile.data);
|
||||
if (requestedDocument.mapId !== savedDocument.mapId)
|
||||
throw new Error(`地图草稿 mapId 必须为 ${savedDocument.mapId}`);
|
||||
|
||||
const document: EditableMapDocument = {
|
||||
...structuredClone(requestedDocument),
|
||||
revision: Math.max(savedDocument.revision + 1, requestedDocument.revision),
|
||||
};
|
||||
const definition = decodeMapDefinition(
|
||||
manifest.files.find((file) => file.path === resolved.descriptorPath)!.data,
|
||||
);
|
||||
definition.spawnPoints = document.spawnPoints;
|
||||
|
||||
replacements.set(resolved.authoringPath, encodeEditableMapDocument(document));
|
||||
replacements.set(resolved.physicsPath, compileEditableMapDocument(document));
|
||||
replacements.set(
|
||||
resolved.descriptorPath,
|
||||
new TextEncoder().encode(`${JSON.stringify(definition, null, 2)}\n`),
|
||||
);
|
||||
}
|
||||
|
||||
const files = manifest.files.map((file) => {
|
||||
const data = replacements.get(file.path);
|
||||
return data ? { ...file, data, size: data.byteLength } : file;
|
||||
});
|
||||
return {
|
||||
...manifest,
|
||||
files,
|
||||
maps: discoverMapEntries(files),
|
||||
totalBytes: files.reduce((total, file) => total + file.size, 0),
|
||||
};
|
||||
}
|
||||
@@ -23,9 +23,17 @@ export class MapEditSession {
|
||||
private redoStack: HistoryEntry[] = [];
|
||||
selectedId?: string;
|
||||
|
||||
constructor(document: EditableMapDocument) {
|
||||
this.current = clone(parseEditableMapDocument(document));
|
||||
this.saved = clone(this.current);
|
||||
constructor(
|
||||
document: EditableMapDocument,
|
||||
draft: EditableMapDocument = document,
|
||||
private readonly externalSurfaceHeight?: (
|
||||
position: readonly [number, number, number],
|
||||
) => number | null,
|
||||
) {
|
||||
this.saved = clone(parseEditableMapDocument(document));
|
||||
this.current = clone(parseEditableMapDocument(draft));
|
||||
if (this.current.mapId !== this.saved.mapId)
|
||||
throw new Error(`地图草稿 mapId 必须为 ${this.saved.mapId}`);
|
||||
}
|
||||
get document(): EditableMapDocument {
|
||||
return clone(this.current);
|
||||
@@ -39,6 +47,10 @@ export class MapEditSession {
|
||||
get canRedo(): boolean {
|
||||
return this.redoStack.length > 0;
|
||||
}
|
||||
private supportTop(object: EditableMapObject, fallback = 0): number {
|
||||
if (object.placementMode !== 'gravity') return fallback;
|
||||
return this.externalSurfaceHeight?.(object.pose.position) ?? fallback;
|
||||
}
|
||||
private commit(mutator: (draft: EditableMapDocument) => void): void {
|
||||
const before = clone(this.current),
|
||||
after = clone(this.current);
|
||||
@@ -51,7 +63,7 @@ export class MapEditSession {
|
||||
}
|
||||
add(type: EditableMapObjectType): EditableMapObject {
|
||||
const object = createEditableObject(type);
|
||||
applyObjectPlacement(object, this.current.objects);
|
||||
applyObjectPlacement(object, this.current.objects, this.supportTop(object));
|
||||
this.commit((draft) => draft.objects.push(object));
|
||||
this.selectedId = object.id;
|
||||
return structuredClone(object);
|
||||
@@ -60,9 +72,10 @@ export class MapEditSession {
|
||||
type: EditableMapObjectType,
|
||||
position?: [number, number, number],
|
||||
placementMode: MapObjectPlacementMode = 'auto_ground',
|
||||
externalSupportTop = 0,
|
||||
): EditableMapObject {
|
||||
const object = createPlacedMapAsset(type, this.current.objects.length, position, placementMode);
|
||||
applyObjectPlacement(object, this.current.objects);
|
||||
applyObjectPlacement(object, this.current.objects, this.supportTop(object, externalSupportTop));
|
||||
this.commit((draft) => draft.objects.push(object));
|
||||
this.selectedId = object.id;
|
||||
return structuredClone(object);
|
||||
@@ -81,7 +94,7 @@ export class MapEditSession {
|
||||
copy.pose.position[1] + 0.2,
|
||||
copy.pose.position[2],
|
||||
];
|
||||
applyObjectPlacement(copy, this.current.objects);
|
||||
applyObjectPlacement(copy, this.current.objects, this.supportTop(copy));
|
||||
this.commit((draft) => draft.objects.push(copy));
|
||||
this.selectedId = copy.id;
|
||||
return structuredClone(copy);
|
||||
@@ -93,7 +106,7 @@ export class MapEditSession {
|
||||
const next = structuredClone(patch);
|
||||
if (object.placementMode === 'locked' && next.placementMode === undefined) delete next.pose;
|
||||
Object.assign(object, next);
|
||||
applyObjectPlacement(object, draft.objects);
|
||||
applyObjectPlacement(object, draft.objects, this.supportTop(object));
|
||||
});
|
||||
}
|
||||
remove(id: string): void {
|
||||
|
||||
@@ -1,13 +1,56 @@
|
||||
import {
|
||||
createEditableObject,
|
||||
editableObjectGroundHeight,
|
||||
isMapObjectPlacementMode,
|
||||
type EditableMapObject,
|
||||
type EditableMapObjectType,
|
||||
type MapObjectPlacementMode,
|
||||
} from './types';
|
||||
import { isSystemTerrainPreset, type SystemTerrainPreset } from '../types';
|
||||
|
||||
export const MAP_ASSET_DRAG_MIME = 'application/x-mujoco-map-asset';
|
||||
export const MAP_ASSET_PLACEMENT_MIME = 'application/x-mujoco-map-placement';
|
||||
export const MAP_LIBRARY_DRAG_MIME = 'application/x-mujoco-map-library-item';
|
||||
|
||||
export type MapLibraryDragPayload =
|
||||
| {
|
||||
kind: 'certified';
|
||||
type: EditableMapObjectType;
|
||||
placementMode: MapObjectPlacementMode;
|
||||
}
|
||||
| { kind: 'terrain'; preset: SystemTerrainPreset }
|
||||
| { kind: 'project'; descriptorPath: string };
|
||||
|
||||
export function encodeMapLibraryDragPayload(payload: MapLibraryDragPayload): string {
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
export function decodeMapLibraryDragPayload(value: string): MapLibraryDragPayload | null {
|
||||
try {
|
||||
const payload = JSON.parse(value) as Record<string, unknown>;
|
||||
if (
|
||||
payload.kind === 'certified' &&
|
||||
typeof payload.type === 'string' &&
|
||||
isEditableMapObjectType(payload.type) &&
|
||||
typeof payload.placementMode === 'string' &&
|
||||
isMapObjectPlacementMode(payload.placementMode)
|
||||
)
|
||||
return { kind: 'certified', type: payload.type, placementMode: payload.placementMode };
|
||||
if (
|
||||
payload.kind === 'terrain' &&
|
||||
typeof payload.preset === 'string' &&
|
||||
isSystemTerrainPreset(payload.preset)
|
||||
)
|
||||
return { kind: 'terrain', preset: payload.preset };
|
||||
if (
|
||||
payload.kind === 'project' &&
|
||||
typeof payload.descriptorPath === 'string' &&
|
||||
payload.descriptorPath.trim()
|
||||
)
|
||||
return { kind: 'project', descriptorPath: payload.descriptorPath };
|
||||
} catch {
|
||||
// 非本平台的拖放数据交给文件导入分支处理。
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface CertifiedMapAsset {
|
||||
type: EditableMapObjectType;
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { ProjectFile, ProjectManifest } from '../../project/types';
|
||||
import { discoverMapEntries } from '../MapLoader';
|
||||
import type { PlacedMapAsset } from '../types';
|
||||
import { encodeEditableMapDocument } from './editorSchema';
|
||||
import { compiledEditableMapBodyName, findCompiledEditableMapPick } from './compiledMapPick';
|
||||
import { createEditableObject, type EditableMapDocument } from './types';
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function file(path: string, data: Uint8Array | string): ProjectFile {
|
||||
const bytes = typeof data === 'string' ? encoder.encode(data) : data;
|
||||
return { path, data: bytes, size: bytes.byteLength, source: 'zip', mimeType: '' };
|
||||
}
|
||||
|
||||
function fixture(): { manifest: ProjectManifest; assets: PlacedMapAsset[] } {
|
||||
const document: EditableMapDocument = {
|
||||
schemaVersion: 1,
|
||||
mapId: 'scene',
|
||||
revision: 1,
|
||||
objects: [createEditableObject('box', 'box_1')],
|
||||
spawnPoints: [],
|
||||
};
|
||||
const files = [
|
||||
file(
|
||||
'maps/scene/map.json',
|
||||
JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
id: 'scene',
|
||||
name: '场景',
|
||||
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
|
||||
physics: { source: 'physics/world.xml' },
|
||||
authoring: { source: 'authoring/map.scene.json' },
|
||||
spawnPoints: [],
|
||||
}),
|
||||
),
|
||||
file('maps/scene/physics/world.xml', '<mujoco><worldbody/></mujoco>'),
|
||||
file('maps/scene/authoring/map.scene.json', encodeEditableMapDocument(document)),
|
||||
file('robot.xml', '<mujoco><worldbody/></mujoco>'),
|
||||
];
|
||||
return {
|
||||
manifest: {
|
||||
id: 'project',
|
||||
name: 'project',
|
||||
files,
|
||||
entries: [{ path: 'robot.xml', format: 'mjcf', label: 'robot.xml' }],
|
||||
maps: discoverMapEntries(files),
|
||||
totalBytes: files.reduce((total, item) => total + item.size, 0),
|
||||
},
|
||||
assets: [
|
||||
{
|
||||
id: 'map_instance-1',
|
||||
name: '场景',
|
||||
selection: { kind: 'project', descriptorPath: 'maps/scene/map.json' },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('已编译认证资产拾取', () => {
|
||||
it('按地图实例命名空间反查创作对象', () => {
|
||||
const { manifest, assets } = fixture();
|
||||
const bodyName = compiledEditableMapBodyName('scene', 'map_instance-1', 'box_1');
|
||||
|
||||
expect(bodyName).toBe('__platform_map_scene_map_instance-1_edit_body_box_1');
|
||||
expect(findCompiledEditableMapPick(bodyName, manifest, assets)).toEqual({
|
||||
mapAssetId: 'map_instance-1',
|
||||
descriptorPath: 'maps/scene/map.json',
|
||||
objectId: 'box_1',
|
||||
});
|
||||
});
|
||||
|
||||
it('不接管机器人 Body、其他实例或已禁用对象', () => {
|
||||
const { manifest, assets } = fixture();
|
||||
expect(findCompiledEditableMapPick('robot_base', manifest, assets)).toBeNull();
|
||||
expect(
|
||||
findCompiledEditableMapPick(
|
||||
compiledEditableMapBodyName('scene', 'other_instance', 'box_1'),
|
||||
manifest,
|
||||
assets,
|
||||
),
|
||||
).toBeNull();
|
||||
|
||||
const authoring = manifest.files.find(
|
||||
(item) => item.path === 'maps/scene/authoring/map.scene.json',
|
||||
)!;
|
||||
const disabled = fixture().manifest;
|
||||
const disabledAuthoring = disabled.files.find((item) => item.path === authoring.path)!;
|
||||
const document: EditableMapDocument = {
|
||||
schemaVersion: 1,
|
||||
mapId: 'scene',
|
||||
revision: 2,
|
||||
objects: [{ ...createEditableObject('box', 'box_1'), enabled: false }],
|
||||
spawnPoints: [],
|
||||
};
|
||||
const data = encodeEditableMapDocument(document);
|
||||
disabledAuthoring.data = data;
|
||||
disabledAuthoring.size = data.byteLength;
|
||||
expect(
|
||||
findCompiledEditableMapPick(
|
||||
compiledEditableMapBodyName('scene', 'map_instance-1', 'box_1'),
|
||||
disabled,
|
||||
assets,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ProjectManifest } from '../../project/types';
|
||||
import { resolveProjectMap } from '../MapLoader';
|
||||
import type { PlacedMapAsset } from '../types';
|
||||
import { decodeEditableMapDocument } from './editorSchema';
|
||||
|
||||
export interface CompiledEditableMapPick {
|
||||
mapAssetId: string;
|
||||
descriptorPath: string;
|
||||
objectId: string;
|
||||
}
|
||||
|
||||
function namespaceToken(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
}
|
||||
|
||||
/** 与 MapComposer.namespaceMap 保持一致的已编译可编辑对象 Body 名。 */
|
||||
export function compiledEditableMapBodyName(
|
||||
mapId: string,
|
||||
mapAssetId: string,
|
||||
objectId: string,
|
||||
): string {
|
||||
const namespace = namespaceToken(`${mapId}_${mapAssetId}`);
|
||||
return `__platform_map_${namespace}_edit_body_${objectId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 MuJoCo 中命中的静态 Body 反查为认证资产对象。这样当前选中的是参数地形或其他
|
||||
* 地图实例时,点击已编译对象仍能激活正确的创作地图并重新挂载操纵器。
|
||||
*/
|
||||
export function findCompiledEditableMapPick(
|
||||
bodyName: string,
|
||||
manifest: ProjectManifest,
|
||||
assets: readonly PlacedMapAsset[],
|
||||
): CompiledEditableMapPick | null {
|
||||
for (const asset of assets) {
|
||||
if (asset.selection.kind !== 'project') continue;
|
||||
try {
|
||||
const resolved = resolveProjectMap(manifest, asset.selection.descriptorPath);
|
||||
if (!resolved.authoringPath) continue;
|
||||
const prefix = compiledEditableMapBodyName(resolved.definition.id, asset.id, '');
|
||||
if (!bodyName.startsWith(prefix)) continue;
|
||||
const objectId = bodyName.slice(prefix.length);
|
||||
if (!objectId) continue;
|
||||
const authoring = manifest.files.find((file) => file.path === resolved.authoringPath);
|
||||
if (!authoring) continue;
|
||||
const document = decodeEditableMapDocument(authoring.data);
|
||||
if (!document.objects.some((object) => object.enabled && object.id === objectId)) continue;
|
||||
return {
|
||||
mapAssetId: asset.id,
|
||||
descriptorPath: asset.selection.descriptorPath,
|
||||
objectId,
|
||||
};
|
||||
} catch {
|
||||
// 单个损坏或只读地图不应阻断普通视口拾取。
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -97,6 +97,18 @@ describe('地图 V3 编辑核心', () => {
|
||||
expect(session.dirty).toBe(false);
|
||||
});
|
||||
|
||||
it('重新挂载时恢复未提交草稿并保持 dirty', () => {
|
||||
const saved = document();
|
||||
const draft = document();
|
||||
draft.objects.push(createEditableObject('box', 'pending_box'));
|
||||
const session = new MapEditSession(saved, draft);
|
||||
expect(session.dirty).toBe(true);
|
||||
expect(session.document.objects[0].id).toBe('pending_box');
|
||||
session.discard();
|
||||
expect(session.dirty).toBe(false);
|
||||
expect(session.document.objects).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('将认证资产按画布落点加入草稿并自动对齐地面', () => {
|
||||
const session = new MapEditSession(document());
|
||||
const object = session.addAsset('box', [1.26, -2.34, 0]);
|
||||
@@ -107,6 +119,20 @@ describe('地图 V3 编辑核心', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('重力落位可使用程序地图提供的外部承载高度', () => {
|
||||
const session = new MapEditSession(document());
|
||||
const settled = session.addAsset('box', [0, 0, 0], 'gravity', -0.8);
|
||||
expect(settled.pose.position[2]).toBeCloseTo(-0.3);
|
||||
|
||||
const terrainSession = new MapEditSession(document(), document(), ([x]) => (x > 1 ? 2 : 1));
|
||||
const terrainObject = terrainSession.addAsset('box', [0, 0, 0], 'gravity');
|
||||
expect(terrainObject.pose.position[2]).toBeCloseTo(1.5);
|
||||
terrainSession.update(terrainObject.id, {
|
||||
pose: { ...terrainObject.pose, position: [2, 0, terrainObject.pose.position[2]] },
|
||||
});
|
||||
expect(terrainSession.document.objects[0].pose.position[2]).toBeCloseTo(2.5);
|
||||
});
|
||||
|
||||
it('支持重力落位到最高承载面并锁定位姿', () => {
|
||||
const session = new MapEditSession(document());
|
||||
const support = session.addAsset('box', [0, 0, 0]);
|
||||
|
||||
@@ -54,28 +54,31 @@ function objectTop(object: EditableMapObject): number {
|
||||
export function gravitySettledPosition(
|
||||
object: EditableMapObject,
|
||||
objects: EditableMapObject[],
|
||||
externalSupportTop = 0,
|
||||
): [number, number, number] {
|
||||
const target = footprint(object);
|
||||
let supportTop = 0;
|
||||
let supportTop = externalSupportTop;
|
||||
for (const candidate of objects) {
|
||||
if (candidate.id === object.id || !candidate.enabled || !overlaps(target, footprint(candidate)))
|
||||
continue;
|
||||
supportTop = Math.max(supportTop, objectTop(candidate));
|
||||
}
|
||||
const settledZ = supportTop + editableObjectGroundHeight(object);
|
||||
return [
|
||||
object.pose.position[0],
|
||||
object.pose.position[1],
|
||||
supportTop + editableObjectGroundHeight(object),
|
||||
Math.round(settledZ * 1_000_000_000) / 1_000_000_000,
|
||||
];
|
||||
}
|
||||
|
||||
export function applyObjectPlacement(
|
||||
object: EditableMapObject,
|
||||
objects: EditableMapObject[],
|
||||
externalSupportTop = 0,
|
||||
): void {
|
||||
if (object.placementMode === 'locked') return;
|
||||
object.pose.position =
|
||||
object.placementMode === 'gravity'
|
||||
? gravitySettledPosition(object, objects)
|
||||
? gravitySettledPosition(object, objects, externalSupportTop)
|
||||
: [object.pose.position[0], object.pose.position[1], editableObjectGroundHeight(object)];
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ export type MapObjectPlacementMode = 'auto_ground' | 'gravity' | 'locked';
|
||||
export const MAP_OBJECT_PLACEMENT_LABELS: Record<MapObjectPlacementMode, string> = {
|
||||
auto_ground: '自动贴地',
|
||||
gravity: '自动落位(重力)',
|
||||
locked: '锁定',
|
||||
locked: '锁定位姿',
|
||||
};
|
||||
|
||||
export function isMapObjectPlacementMode(value: string): value is MapObjectPlacementMode {
|
||||
@@ -28,6 +28,7 @@ export interface MapEditorInteractionCallbacks {
|
||||
type: EditableMapObjectType,
|
||||
position?: [number, number, number],
|
||||
placementMode?: MapObjectPlacementMode,
|
||||
externalSupportTop?: number,
|
||||
): void;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { createEditableObject, type EditableMapDocument } from './editor/types';
|
||||
import {
|
||||
mapEditorDraftPreviewInstances,
|
||||
resolveMapSceneLoadAssets,
|
||||
restoreAppliedMapScene,
|
||||
summarizeMapSceneDraft,
|
||||
transformParametricMapAsset,
|
||||
} from './mapSceneDraft';
|
||||
import { DEFAULT_PHYSICAL_MAP_CONFIG, type PlacedMapAsset } from './types';
|
||||
|
||||
function terrain(id: string, x = 0): PlacedMapAsset {
|
||||
return {
|
||||
id,
|
||||
name: `地形 ${id}`,
|
||||
selection: {
|
||||
kind: 'builtin',
|
||||
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'rough', positionX: x },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const project = (id: string): PlacedMapAsset => ({
|
||||
id,
|
||||
name: `工程 ${id}`,
|
||||
selection: { kind: 'project', descriptorPath: `maps/${id}/map.json` },
|
||||
});
|
||||
|
||||
describe('参数地图批量草稿', () => {
|
||||
it('只统计新增、修改和删除的参数地图,并为新增/修改项生成预览', () => {
|
||||
const applied = [terrain('same'), terrain('changed'), terrain('removed'), project('room')];
|
||||
const current = [terrain('same'), terrain('changed', 2), terrain('added', -3), project('room')];
|
||||
const summary = summarizeMapSceneDraft(current, applied);
|
||||
expect(summary).toMatchObject({
|
||||
dirty: true,
|
||||
changeCount: 3,
|
||||
changedIds: ['changed', 'added'],
|
||||
});
|
||||
expect(summary.previewAssets.map((asset) => asset.id)).toEqual(['changed', 'added']);
|
||||
});
|
||||
|
||||
it('把工程地图增删也纳入同一个场景草稿', () => {
|
||||
const applied = [project('removed')];
|
||||
const summary = summarizeMapSceneDraft([project('new')], applied);
|
||||
expect(summary).toEqual({
|
||||
dirty: true,
|
||||
changeCount: 2,
|
||||
changedIds: ['new'],
|
||||
previewAssets: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('放弃草稿时严格恢复完整应用基线', () => {
|
||||
const applied = [terrain('terrain', 1), project('room')];
|
||||
const current = [terrain('terrain', 8), project('room'), terrain('new'), project('overlay')];
|
||||
expect(summarizeMapSceneDraft(current, applied).changeCount).toBe(3);
|
||||
const restored = restoreAppliedMapScene(applied);
|
||||
expect(restored.map((asset) => asset.id)).toEqual(['terrain', 'room']);
|
||||
expect(restored[0].selection).toEqual(terrain('terrain', 1).selection);
|
||||
expect(restored[0]).not.toBe(applied[0]);
|
||||
});
|
||||
|
||||
it('普通重载保持应用基线,显式提交才使用候选场景', () => {
|
||||
const applied = [terrain('applied', 1)];
|
||||
const draft = [terrain('draft', 9)];
|
||||
expect(resolveMapSceneLoadAssets(applied).map((asset) => asset.id)).toEqual(['applied']);
|
||||
expect(resolveMapSceneLoadAssets(applied, draft).map((asset) => asset.id)).toEqual(['draft']);
|
||||
expect(resolveMapSceneLoadAssets(applied)[0]).not.toBe(applied[0]);
|
||||
});
|
||||
|
||||
it('切换到参数地形后仍保留待提交认证资产的只读预览', () => {
|
||||
const certified: PlacedMapAsset = {
|
||||
id: 'certified',
|
||||
name: '工程 certified',
|
||||
selection: {
|
||||
kind: 'project',
|
||||
descriptorPath: 'maps/certified/map.json',
|
||||
positionX: 2,
|
||||
positionY: -1,
|
||||
yawDeg: 45,
|
||||
},
|
||||
};
|
||||
const activeTerrain = terrain('terrain');
|
||||
const document: EditableMapDocument = {
|
||||
schemaVersion: 1,
|
||||
mapId: 'certified',
|
||||
revision: 1,
|
||||
objects: [createEditableObject('box', 'box_1')],
|
||||
spawnPoints: [],
|
||||
};
|
||||
const documents = new Map([['maps/certified/map.json', document]]);
|
||||
|
||||
expect(
|
||||
mapEditorDraftPreviewInstances(
|
||||
[certified, activeTerrain],
|
||||
['certified', 'terrain'],
|
||||
documents,
|
||||
'terrain',
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
id: 'certified',
|
||||
document,
|
||||
transform: { positionX: 2, positionY: -1, yawDeg: 45 },
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
mapEditorDraftPreviewInstances([certified], ['certified'], documents, 'certified'),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('视口移动只更新目标参数地形并保留操纵器结果', () => {
|
||||
const terrainAsset = terrain('terrain');
|
||||
if (terrainAsset.selection.kind === 'builtin') terrainAsset.selection.config.size = 9;
|
||||
const assets = [terrainAsset, project('room')];
|
||||
const transformed = transformParametricMapAsset(assets, 'terrain', [1.26, -2.34], 35);
|
||||
expect(transformed).not.toBe(assets);
|
||||
expect(transformed[0].selection).toMatchObject({
|
||||
kind: 'builtin',
|
||||
config: { size: 9, positionX: 1.26, positionY: -2.34, yawDeg: 35 },
|
||||
});
|
||||
expect(transformed[1]).toBe(assets[1]);
|
||||
expect(transformParametricMapAsset(assets, 'room', [5, 5], 0)).toBe(assets);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { EditableMapDocument } from './editor/types';
|
||||
import { normalizePhysicalMapConfig } from './physicalMap';
|
||||
import {
|
||||
mapSelectionTransform,
|
||||
updatePlacedMapAsset,
|
||||
type MapInstanceTransform,
|
||||
type PlacedMapAsset,
|
||||
} from './types';
|
||||
|
||||
function sameAsset(left: PlacedMapAsset | undefined, right: PlacedMapAsset | undefined): boolean {
|
||||
return Boolean(
|
||||
left &&
|
||||
right &&
|
||||
left.id === right.id &&
|
||||
left.name === right.name &&
|
||||
JSON.stringify(left.selection) === JSON.stringify(right.selection),
|
||||
);
|
||||
}
|
||||
|
||||
function cloneAsset(asset: PlacedMapAsset): PlacedMapAsset {
|
||||
return { ...asset, selection: structuredClone(asset.selection) };
|
||||
}
|
||||
|
||||
export interface MapSceneDraftSummary {
|
||||
dirty: boolean;
|
||||
changeCount: number;
|
||||
/** 当前仍存在且相对应用基线新增或发生修改的实例 ID。 */
|
||||
changedIds: string[];
|
||||
/** 可直接在 Three.js 中预览的新增或已修改程序地图。 */
|
||||
previewAssets: PlacedMapAsset[];
|
||||
}
|
||||
|
||||
export interface MapEditorDraftPreviewInstance {
|
||||
id: string;
|
||||
document: EditableMapDocument;
|
||||
transform: MapInstanceTransform;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前激活的认证场景由交互编辑层绘制;其余待提交工程地图必须保留只读预览,
|
||||
* 否则切换到参数地形时会因为清空交互文档而从画布消失。
|
||||
*/
|
||||
export function mapEditorDraftPreviewInstances(
|
||||
assets: readonly PlacedMapAsset[],
|
||||
pendingIds: readonly string[],
|
||||
documents: ReadonlyMap<string, EditableMapDocument>,
|
||||
activeId?: string,
|
||||
): MapEditorDraftPreviewInstance[] {
|
||||
const pending = new Set(pendingIds);
|
||||
return assets.flatMap((asset) => {
|
||||
if (asset.id === activeId || !pending.has(asset.id) || asset.selection.kind !== 'project')
|
||||
return [];
|
||||
const document = documents.get(asset.selection.descriptorPath);
|
||||
return document
|
||||
? [{ id: asset.id, document, transform: mapSelectionTransform(asset.selection) }]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 所有地图实例共享同一个场景草稿边界。新增、删除、替换和实例参数修改只更新
|
||||
* `current`,显式应用成功后才推进 `applied` 基线。
|
||||
*/
|
||||
export function summarizeMapSceneDraft(
|
||||
current: readonly PlacedMapAsset[],
|
||||
applied: readonly PlacedMapAsset[],
|
||||
): MapSceneDraftSummary {
|
||||
const currentById = new Map(current.map((asset) => [asset.id, asset]));
|
||||
const appliedById = new Map(applied.map((asset) => [asset.id, asset]));
|
||||
const changed = current.filter((asset) => !sameAsset(asset, appliedById.get(asset.id)));
|
||||
const removed = applied.filter((asset) => !currentById.has(asset.id));
|
||||
return {
|
||||
dirty: changed.length + removed.length > 0,
|
||||
changeCount: changed.length + removed.length,
|
||||
changedIds: changed.map((asset) => asset.id),
|
||||
previewAssets: changed.filter((asset) => asset.selection.kind === 'builtin').map(cloneAsset),
|
||||
};
|
||||
}
|
||||
|
||||
/** 放弃完整场景草稿,严格恢复上一次成功编译的实例顺序和状态。 */
|
||||
export function restoreAppliedMapScene(applied: readonly PlacedMapAsset[]): PlacedMapAsset[] {
|
||||
return applied.map(cloneAsset);
|
||||
}
|
||||
|
||||
export function clonePlacedMapAssets(assets: readonly PlacedMapAsset[]): PlacedMapAsset[] {
|
||||
return assets.map(cloneAsset);
|
||||
}
|
||||
|
||||
/** 普通模型重载使用应用基线;只有显式场景提交才传入候选实例。 */
|
||||
export function resolveMapSceneLoadAssets(
|
||||
applied: readonly PlacedMapAsset[],
|
||||
requested?: readonly PlacedMapAsset[],
|
||||
): PlacedMapAsset[] {
|
||||
return clonePlacedMapAssets(requested ?? applied);
|
||||
}
|
||||
|
||||
/** 视口操纵器只更新参数地形草稿,不触发 MuJoCo 编译。 */
|
||||
export function transformParametricMapAsset(
|
||||
assets: readonly PlacedMapAsset[],
|
||||
id: string,
|
||||
position: readonly [number, number],
|
||||
yawDeg: number,
|
||||
): PlacedMapAsset[] | readonly PlacedMapAsset[] {
|
||||
const target = assets.find((asset) => asset.id === id);
|
||||
if (!target || target.selection.kind !== 'builtin' || target.selection.config.preset === 'none')
|
||||
return assets;
|
||||
const clean = (value: number) => Math.round(value * 1_000_000) / 1_000_000;
|
||||
const config = normalizePhysicalMapConfig({
|
||||
...target.selection.config,
|
||||
positionX: clean(position[0]),
|
||||
positionY: clean(position[1]),
|
||||
yawDeg: clean(yawDeg),
|
||||
});
|
||||
if (
|
||||
config.positionX === target.selection.config.positionX &&
|
||||
config.positionY === target.selection.config.positionY &&
|
||||
config.yawDeg === target.selection.config.yawDeg
|
||||
)
|
||||
return assets;
|
||||
return updatePlacedMapAsset(assets, id, { kind: 'builtin', config });
|
||||
}
|
||||
@@ -76,6 +76,36 @@ describe('composePhysicalMap', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('程序地形在实例叠加模式移除所有无限平面,避免填平坑洞和负高度', () => {
|
||||
const source = encoder.encode(
|
||||
'<mujoco><worldbody><geom name="__platform_ground__" type="plane"/><geom name="external_map_floor" type="plane"/></worldbody></mujoco>',
|
||||
);
|
||||
const document = documentOf(
|
||||
composePhysicalMap(source, config({ preset: 'rough', size: 6 }), {
|
||||
instanceId: 'terrain-a',
|
||||
preserveExistingGround: true,
|
||||
}).data,
|
||||
);
|
||||
expect(document.querySelector('[name="__platform_ground__"]')).toBeNull();
|
||||
expect(document.querySelector('[name="external_map_floor"]')).toBeNull();
|
||||
expect(document.querySelector('[name="__platform_map_terrain-a__root__"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('平地实例在已有地面上仍生成可移动的有限地块', () => {
|
||||
const source = encoder.encode(
|
||||
'<mujoco><worldbody><geom name="floor" type="plane"/></worldbody></mujoco>',
|
||||
);
|
||||
const document = documentOf(
|
||||
composePhysicalMap(source, config({ preset: 'flat', positionX: 3 }), {
|
||||
instanceId: 'flat-a',
|
||||
preserveExistingGround: true,
|
||||
}).data,
|
||||
);
|
||||
const root = document.querySelector('[name="__platform_map_flat-a__root__"]');
|
||||
expect(root?.getAttribute('pos')).toBe('3 0 0');
|
||||
expect(root?.querySelector('[name="__platform_map_flat-a__ground__"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('系统粗糙地形生成内联高度场并替换原平面', () => {
|
||||
const source = encoder.encode(
|
||||
'<mujoco><worldbody><geom name="__platform_ground__" type="plane"/></worldbody></mujoco>',
|
||||
|
||||
@@ -17,6 +17,18 @@ export interface PhysicalMapComposition {
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export interface PhysicalMapCompositionOptions {
|
||||
/** 场景实例命名空间;省略时保留旧的单地图命名。 */
|
||||
instanceId?: string;
|
||||
/** 叠加模式保留机器人或其他地图已经提供的地面。 */
|
||||
preserveExistingGround?: boolean;
|
||||
}
|
||||
|
||||
function instancePrefix(instanceId?: string): string {
|
||||
if (!instanceId) return MAP_NAME_PREFIX;
|
||||
return `${MAP_NAME_PREFIX}${instanceId.replace(/[^a-zA-Z0-9_-]/g, '_')}__`;
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number, fallback: number): number {
|
||||
return Number.isFinite(value) ? Math.min(maximum, Math.max(minimum, value)) : fallback;
|
||||
}
|
||||
@@ -68,13 +80,21 @@ function isGroundPlane(element: Element): boolean {
|
||||
return Math.abs(position[2] ?? 0) < 1e-6;
|
||||
}
|
||||
|
||||
function hasSupportingGround(worldbody: Element): boolean {
|
||||
return Array.from(worldbody.querySelectorAll('geom')).some((geom) => {
|
||||
if (isGroundPlane(geom) || geom.getAttribute('type') === 'hfield') return true;
|
||||
return /(?:^|[_-])(?:floor|ground)(?:__|[_-]|$)/i.test(geom.getAttribute('name') ?? '');
|
||||
});
|
||||
}
|
||||
|
||||
function setCommonGeomAttributes(
|
||||
geom: Element,
|
||||
name: string,
|
||||
config: PhysicalMapConfig,
|
||||
rgba: string,
|
||||
prefix = MAP_NAME_PREFIX,
|
||||
): void {
|
||||
geom.setAttribute('name', `${MAP_NAME_PREFIX}${name}__`);
|
||||
geom.setAttribute('name', `${prefix}${name}__`);
|
||||
geom.setAttribute('friction', `${config.friction} 0.005 0.0001`);
|
||||
geom.setAttribute('group', '2');
|
||||
geom.setAttribute('rgba', rgba);
|
||||
@@ -90,9 +110,10 @@ function addBox(
|
||||
halfSize: [number, number, number],
|
||||
rgba: string,
|
||||
quaternion?: [number, number, number, number],
|
||||
prefix = MAP_NAME_PREFIX,
|
||||
): void {
|
||||
const geom = document.createElement('geom');
|
||||
setCommonGeomAttributes(geom, name, config, rgba);
|
||||
setCommonGeomAttributes(geom, name, config, rgba, prefix);
|
||||
geom.setAttribute('type', 'box');
|
||||
geom.setAttribute('pos', position.join(' '));
|
||||
geom.setAttribute('size', halfSize.join(' '));
|
||||
@@ -105,6 +126,7 @@ function addHeightfield(
|
||||
worldbody: Element,
|
||||
config: PhysicalMapConfig,
|
||||
heightfield: GeneratedTerrainHeightfield,
|
||||
prefix = MAP_NAME_PREFIX,
|
||||
): void {
|
||||
let asset = document.querySelector('mujoco > asset');
|
||||
if (!asset) {
|
||||
@@ -116,7 +138,7 @@ function addHeightfield(
|
||||
const minimum = Math.min(...heightfield.heights),
|
||||
maximum = Math.max(...heightfield.heights),
|
||||
range = Math.max(maximum - minimum, 1e-6),
|
||||
name = `${MAP_NAME_PREFIX}${heightfield.name.replace(/[^a-zA-Z0-9_-]/g, '_')}__`;
|
||||
name = `${prefix}${heightfield.name.replace(/[^a-zA-Z0-9_-]/g, '_')}__`;
|
||||
const source = document.createElement('hfield');
|
||||
source.setAttribute('name', name);
|
||||
source.setAttribute('nrow', String(heightfield.rowSegments + 1));
|
||||
@@ -129,17 +151,22 @@ function addHeightfield(
|
||||
asset.append(source);
|
||||
|
||||
const geom = document.createElement('geom');
|
||||
setCommonGeomAttributes(geom, heightfield.name, config, '0.32 0.42 0.28 1');
|
||||
setCommonGeomAttributes(geom, heightfield.name, config, '0.32 0.42 0.28 1', prefix);
|
||||
geom.setAttribute('type', 'hfield');
|
||||
geom.setAttribute('hfield', name);
|
||||
geom.setAttribute('pos', `0 0 ${minimum}`);
|
||||
worldbody.append(geom);
|
||||
}
|
||||
|
||||
function createMapRoot(document: Document, worldbody: Element, config: PhysicalMapConfig): Element {
|
||||
function createMapRoot(
|
||||
document: Document,
|
||||
worldbody: Element,
|
||||
config: PhysicalMapConfig,
|
||||
prefix = MAP_NAME_PREFIX,
|
||||
): Element {
|
||||
const root = document.createElement('body');
|
||||
const halfYaw = (config.yawDeg * Math.PI) / 360;
|
||||
root.setAttribute('name', `${MAP_NAME_PREFIX}root__`);
|
||||
root.setAttribute('name', `${prefix}root__`);
|
||||
root.setAttribute('pos', `${config.positionX} ${config.positionY} 0`);
|
||||
root.setAttribute('quat', `${Math.cos(halfYaw)} 0 0 ${Math.sin(halfYaw)}`);
|
||||
worldbody.append(root);
|
||||
@@ -155,6 +182,83 @@ function seededRandom(seed: number): () => number {
|
||||
};
|
||||
}
|
||||
|
||||
export interface GeneratedPhysicalMapBox {
|
||||
name: string;
|
||||
position: [number, number, number];
|
||||
/** 完整边长,与 Three.js BoxGeometry 约定一致。 */
|
||||
size: [number, number, number];
|
||||
rgba: string;
|
||||
quaternion?: [number, number, number, number];
|
||||
}
|
||||
|
||||
/** 单一确定性几何源同时供 MuJoCo 合成和 Three.js 草稿预览使用。 */
|
||||
export function generateBuiltinMapBoxes(
|
||||
requestedConfig: PhysicalMapConfig,
|
||||
): GeneratedPhysicalMapBox[] {
|
||||
const config = normalizePhysicalMapConfig(requestedConfig);
|
||||
if (config.preset === 'none' || isSystemTerrainPreset(config.preset)) return [];
|
||||
if (config.preset === 'flat')
|
||||
return [
|
||||
{
|
||||
name: 'ground',
|
||||
position: [0, 0, -0.05],
|
||||
size: [config.size * 2, config.size * 2, 0.1],
|
||||
rgba: '0.18 0.24 0.2 1',
|
||||
},
|
||||
];
|
||||
if (config.preset === 'slope') {
|
||||
const angleRadians = (config.slopeAngle * Math.PI) / 180;
|
||||
const rampLength = Math.min(config.size * 0.42, 6);
|
||||
const thickness = 0.12;
|
||||
const startX = 1.25;
|
||||
return [
|
||||
{
|
||||
name: 'slope',
|
||||
position: [
|
||||
startX + (rampLength * Math.cos(angleRadians)) / 2,
|
||||
0,
|
||||
thickness / 2 + (rampLength * Math.sin(angleRadians)) / 2,
|
||||
],
|
||||
size: [rampLength, Math.min(config.size * 0.44, 4), thickness],
|
||||
rgba: '0.3 0.42 0.58 1',
|
||||
quaternion: [Math.cos(angleRadians / 2), 0, -Math.sin(angleRadians / 2), 0],
|
||||
},
|
||||
];
|
||||
}
|
||||
if (config.preset === 'stairs') {
|
||||
const run = Math.min(0.42, Math.max(0.25, config.size / (config.stairCount * 3)));
|
||||
const rise = Math.min(0.2, run * 0.5);
|
||||
const width = Math.min(config.size * 0.22, 2);
|
||||
return Array.from({ length: config.stairCount }, (_, offset) => {
|
||||
const index = offset + 1;
|
||||
const height = index * rise;
|
||||
return {
|
||||
name: `stair_${index}`,
|
||||
position: [1 + (index - 0.5) * run, 0, height / 2],
|
||||
size: [run, width * 2, height],
|
||||
rgba: index % 2 ? '0.42 0.45 0.5 1' : '0.35 0.38 0.44 1',
|
||||
};
|
||||
});
|
||||
}
|
||||
const random = seededRandom(config.seed);
|
||||
const radius = Math.max(2.2, config.size * 0.38);
|
||||
return Array.from({ length: config.obstacleCount }, (_, index) => {
|
||||
const angle = (index / config.obstacleCount) * Math.PI * 2 + (random() - 0.5) * 0.4;
|
||||
const distance = 1.8 + random() * Math.max(0.4, radius - 1.8);
|
||||
const width = 0.18 + random() * 0.42;
|
||||
const depth = 0.18 + random() * 0.42;
|
||||
const height = 0.25 + random() * 0.9;
|
||||
const yaw = random() * Math.PI;
|
||||
return {
|
||||
name: `obstacle_${index + 1}`,
|
||||
position: [Math.cos(angle) * distance, Math.sin(angle) * distance, height / 2],
|
||||
size: [width * 2, depth * 2, height],
|
||||
rgba: '0.52 0.33 0.2 1',
|
||||
quaternion: [Math.cos(yaw / 2), 0, 0, Math.sin(yaw / 2)],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function ensureGround(document: Document, worldbody: Element, config: PhysicalMapConfig): number {
|
||||
const existing = Array.from(worldbody.children).find(isGroundPlane);
|
||||
if (existing) {
|
||||
@@ -177,6 +281,7 @@ function ensureGround(document: Document, worldbody: Element, config: PhysicalMa
|
||||
export function composePhysicalMap(
|
||||
source: Uint8Array,
|
||||
requestedConfig: PhysicalMapConfig,
|
||||
options: PhysicalMapCompositionOptions = {},
|
||||
): PhysicalMapComposition {
|
||||
const config = normalizePhysicalMapConfig(requestedConfig);
|
||||
if (config.preset === 'none') return { data: source, config, geomCount: 0 };
|
||||
@@ -185,16 +290,20 @@ export function composePhysicalMap(
|
||||
if (document.querySelector('parsererror')) throw new Error('地图合成失败:MJCF XML 无法解析');
|
||||
const worldbody = document.querySelector('mujoco > worldbody');
|
||||
if (!worldbody) throw new Error('地图合成失败:MJCF 缺少 worldbody');
|
||||
const prefix = instancePrefix(options.instanceId);
|
||||
|
||||
for (const generated of Array.from(document.querySelectorAll(`[name^="${MAP_NAME_PREFIX}"]`)))
|
||||
for (const generated of Array.from(document.querySelectorAll(`[name^="${prefix}"]`)))
|
||||
generated.remove();
|
||||
for (const emptyAsset of Array.from(document.querySelectorAll('mujoco > asset:empty')))
|
||||
emptyAsset.remove();
|
||||
|
||||
let geomCount: number;
|
||||
let geomCount = 0;
|
||||
if (isSystemTerrainPreset(config.preset)) {
|
||||
for (const child of Array.from(worldbody.children)) if (isGroundPlane(child)) child.remove();
|
||||
const mapRoot = createMapRoot(document, worldbody, config);
|
||||
// 无限平面会把深坑、沟壑及高度场的负高度全部填平。程序地形是明确的地面
|
||||
// 提供者,因此不论平面来自机器人还是先前工程地图,都必须统一移除。
|
||||
for (const geom of Array.from(worldbody.querySelectorAll('geom')))
|
||||
if (isGroundPlane(geom)) geom.remove();
|
||||
const mapRoot = createMapRoot(document, worldbody, config, prefix);
|
||||
const terrain = generateSystemTerrain(config.preset, config);
|
||||
const colors: Record<string, string> = {
|
||||
obstacle: '0.48 0.34 0.2 1',
|
||||
@@ -215,75 +324,38 @@ export function composePhysicalMap(
|
||||
item.position,
|
||||
[item.size[0] / 2, item.size[1] / 2, item.size[2] / 2],
|
||||
colors[role],
|
||||
undefined,
|
||||
prefix,
|
||||
);
|
||||
}
|
||||
if (terrain.heightfield) addHeightfield(document, mapRoot, config, terrain.heightfield);
|
||||
if (terrain.heightfield) addHeightfield(document, mapRoot, config, terrain.heightfield, prefix);
|
||||
geomCount = terrain.boxes.length + (terrain.heightfield ? 1 : 0);
|
||||
} else {
|
||||
geomCount = ensureGround(document, worldbody, config);
|
||||
}
|
||||
if (config.preset === 'slope') {
|
||||
const mapRoot = createMapRoot(document, worldbody, config);
|
||||
const angleRadians = (config.slopeAngle * Math.PI) / 180;
|
||||
const rampLength = Math.min(config.size * 0.42, 6);
|
||||
const thickness = 0.12;
|
||||
const startX = 1.25;
|
||||
addBox(
|
||||
document,
|
||||
mapRoot,
|
||||
config,
|
||||
'slope',
|
||||
[
|
||||
startX + (rampLength * Math.cos(angleRadians)) / 2,
|
||||
0,
|
||||
thickness / 2 + (rampLength * Math.sin(angleRadians)) / 2,
|
||||
],
|
||||
[rampLength / 2, Math.min(config.size * 0.22, 2), thickness / 2],
|
||||
'0.3 0.42 0.58 1',
|
||||
[Math.cos(angleRadians / 2), 0, -Math.sin(angleRadians / 2), 0],
|
||||
);
|
||||
geomCount += 1;
|
||||
} else if (config.preset === 'stairs') {
|
||||
const mapRoot = createMapRoot(document, worldbody, config);
|
||||
const run = Math.min(0.42, Math.max(0.25, config.size / (config.stairCount * 3)));
|
||||
const rise = Math.min(0.2, run * 0.5);
|
||||
const width = Math.min(config.size * 0.22, 2);
|
||||
for (let index = 1; index <= config.stairCount; index += 1) {
|
||||
const height = index * rise;
|
||||
addBox(
|
||||
document,
|
||||
mapRoot,
|
||||
config,
|
||||
`stair_${index}`,
|
||||
[1 + (index - 0.5) * run, 0, height / 2],
|
||||
[run / 2, width, height / 2],
|
||||
index % 2 ? '0.42 0.45 0.5 1' : '0.35 0.38 0.44 1',
|
||||
);
|
||||
let boxes = generateBuiltinMapBoxes(config);
|
||||
if (options.preserveExistingGround) {
|
||||
// 场景实例中的平地必须是可移动的有限地块;其他旧版预设则复用已有承载面。
|
||||
if (config.preset !== 'flat' && !hasSupportingGround(worldbody))
|
||||
boxes = generateBuiltinMapBoxes({ ...config, preset: 'flat' }).concat(boxes);
|
||||
} else {
|
||||
geomCount = ensureGround(document, worldbody, config);
|
||||
boxes = boxes.filter((item) => item.name !== 'ground');
|
||||
}
|
||||
geomCount += config.stairCount;
|
||||
} else if (config.preset === 'obstacles') {
|
||||
const mapRoot = createMapRoot(document, worldbody, config);
|
||||
const random = seededRandom(config.seed);
|
||||
const radius = Math.max(2.2, config.size * 0.38);
|
||||
for (let index = 0; index < config.obstacleCount; index += 1) {
|
||||
const angle = (index / config.obstacleCount) * Math.PI * 2 + (random() - 0.5) * 0.4;
|
||||
const distance = 1.8 + random() * Math.max(0.4, radius - 1.8);
|
||||
const width = 0.18 + random() * 0.42;
|
||||
const depth = 0.18 + random() * 0.42;
|
||||
const height = 0.25 + random() * 0.9;
|
||||
const yaw = random() * Math.PI;
|
||||
addBox(
|
||||
document,
|
||||
mapRoot,
|
||||
config,
|
||||
`obstacle_${index + 1}`,
|
||||
[Math.cos(angle) * distance, Math.sin(angle) * distance, height / 2],
|
||||
[width, depth, height / 2],
|
||||
'0.52 0.33 0.2 1',
|
||||
[Math.cos(yaw / 2), 0, 0, Math.sin(yaw / 2)],
|
||||
);
|
||||
if (boxes.length) {
|
||||
const mapRoot = createMapRoot(document, worldbody, config, prefix);
|
||||
for (const item of boxes)
|
||||
addBox(
|
||||
document,
|
||||
mapRoot,
|
||||
config,
|
||||
item.name,
|
||||
item.position,
|
||||
[item.size[0] / 2, item.size[1] / 2, item.size[2] / 2],
|
||||
item.rgba,
|
||||
item.quaternion,
|
||||
prefix,
|
||||
);
|
||||
geomCount += boxes.length;
|
||||
}
|
||||
geomCount += config.obstacleCount;
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { generateSystemTerrain } from './terrainGenerator';
|
||||
import { mapSceneSurfaceHeightAt } from './sceneSurface';
|
||||
import { DEFAULT_PHYSICAL_MAP_CONFIG, type PhysicalMapPreset, type PlacedMapAsset } from './types';
|
||||
|
||||
function terrain(
|
||||
id: string,
|
||||
preset: PhysicalMapPreset,
|
||||
overrides: Partial<typeof DEFAULT_PHYSICAL_MAP_CONFIG> = {},
|
||||
): PlacedMapAsset {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
selection: {
|
||||
kind: 'builtin',
|
||||
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset, ...overrides },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('统一地图表面查询', () => {
|
||||
it('应用实例平移和旋转后查询有限平地', () => {
|
||||
const asset = terrain('flat', 'flat', { size: 4, positionX: 3, positionY: -2, yawDeg: 90 });
|
||||
expect(mapSceneSurfaceHeightAt([asset], 3, -2)).toBeCloseTo(0);
|
||||
expect(mapSceneSurfaceHeightAt([asset], 20, 20)).toBeNull();
|
||||
});
|
||||
|
||||
it('深坑中心返回负高度而不是被默认地面填平', () => {
|
||||
const asset = terrain('pit', 'pit', { size: 6, terrainDifficulty: 1 });
|
||||
expect(mapSceneSurfaceHeightAt([asset], 0, 0)).toBeLessThan(-0.5);
|
||||
expect(mapSceneSurfaceHeightAt([asset], 2.5, 0)).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it('高度场使用与生成器一致的采样高度', () => {
|
||||
const config = {
|
||||
...DEFAULT_PHYSICAL_MAP_CONFIG,
|
||||
preset: 'wave' as const,
|
||||
size: 4,
|
||||
seed: 7,
|
||||
terrainHorizontalScale: 0.5,
|
||||
};
|
||||
const generated = generateSystemTerrain('wave', config).heightfield!;
|
||||
const center =
|
||||
generated.heights[
|
||||
(generated.rowSegments / 2) * (generated.columnSegments + 1) + generated.columnSegments / 2
|
||||
];
|
||||
expect(mapSceneSurfaceHeightAt([terrain('wave', 'wave', config)], 0, 0)).toBeCloseTo(center);
|
||||
});
|
||||
|
||||
it('重叠地图选择最高承载面并支持倾斜坡道 OBB', () => {
|
||||
const flat = terrain('flat', 'flat', { size: 5 });
|
||||
const slope = terrain('slope', 'slope', { size: 5, slopeAngle: 20 });
|
||||
const height = mapSceneSurfaceHeightAt([flat, slope], 2.2, 0);
|
||||
expect(height).not.toBeNull();
|
||||
expect(height!).toBeGreaterThan(0.1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
generateBuiltinMapBoxes,
|
||||
normalizePhysicalMapConfig,
|
||||
type GeneratedPhysicalMapBox,
|
||||
} from './physicalMap';
|
||||
import { generateSystemTerrain, type GeneratedTerrainBox } from './terrainGenerator';
|
||||
import { isSystemTerrainPreset, mapSelectionTransform, type PlacedMapAsset } from './types';
|
||||
|
||||
type SurfaceBox = GeneratedTerrainBox | GeneratedPhysicalMapBox;
|
||||
|
||||
function mapLocalPoint(asset: PlacedMapAsset, worldX: number, worldY: number): [number, number] {
|
||||
const transform = mapSelectionTransform(asset.selection);
|
||||
const yaw = (-transform.yawDeg * Math.PI) / 180;
|
||||
const cosine = Math.cos(yaw);
|
||||
const sine = Math.sin(yaw);
|
||||
const dx = worldX - transform.positionX;
|
||||
const dy = worldY - transform.positionY;
|
||||
return [cosine * dx - sine * dy, sine * dx + cosine * dy];
|
||||
}
|
||||
|
||||
function rotateByInverseQuaternion(
|
||||
vector: readonly [number, number, number],
|
||||
quaternion: readonly [number, number, number, number],
|
||||
): [number, number, number] {
|
||||
const [w, x, y, z] = quaternion;
|
||||
const length = Math.hypot(w, x, y, z) || 1;
|
||||
const qw = w / length;
|
||||
const qx = -x / length;
|
||||
const qy = -y / length;
|
||||
const qz = -z / length;
|
||||
const [vx, vy, vz] = vector;
|
||||
const ix = qw * vx + qy * vz - qz * vy;
|
||||
const iy = qw * vy + qz * vx - qx * vz;
|
||||
const iz = qw * vz + qx * vy - qy * vx;
|
||||
const iw = -qx * vx - qy * vy - qz * vz;
|
||||
return [
|
||||
ix * qw + iw * -qx + iy * -qz - iz * -qy,
|
||||
iy * qw + iw * -qy + iz * -qx - ix * -qz,
|
||||
iz * qw + iw * -qz + ix * -qy - iy * -qx,
|
||||
];
|
||||
}
|
||||
|
||||
/** 从世界 +Z 沿 -Z 与一个地图局部 OBB 求最高交点。 */
|
||||
function boxSurfaceHeight(box: SurfaceBox, x: number, y: number): number | null {
|
||||
const originZ = 10_000;
|
||||
const quaternion =
|
||||
'quaternion' in box && box.quaternion ? box.quaternion : ([1, 0, 0, 0] as const);
|
||||
const origin = rotateByInverseQuaternion(
|
||||
[x - box.position[0], y - box.position[1], originZ - box.position[2]],
|
||||
quaternion,
|
||||
);
|
||||
const direction = rotateByInverseQuaternion([0, 0, -1], quaternion);
|
||||
const half = [box.size[0] / 2, box.size[1] / 2, box.size[2] / 2];
|
||||
let near = Number.NEGATIVE_INFINITY;
|
||||
let far = Number.POSITIVE_INFINITY;
|
||||
for (let axis = 0; axis < 3; axis += 1) {
|
||||
if (Math.abs(direction[axis]) < 1e-10) {
|
||||
if (origin[axis] < -half[axis] || origin[axis] > half[axis]) return null;
|
||||
continue;
|
||||
}
|
||||
let first = (-half[axis] - origin[axis]) / direction[axis];
|
||||
let second = (half[axis] - origin[axis]) / direction[axis];
|
||||
if (first > second) [first, second] = [second, first];
|
||||
near = Math.max(near, first);
|
||||
far = Math.min(far, second);
|
||||
if (near > far) return null;
|
||||
}
|
||||
const distance = near >= 0 ? near : far;
|
||||
return distance >= 0 && Number.isFinite(distance)
|
||||
? Math.round((originZ - distance) * 1_000_000_000) / 1_000_000_000
|
||||
: null;
|
||||
}
|
||||
|
||||
function heightfieldSurfaceHeight(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
length: number,
|
||||
rowSegments: number,
|
||||
columnSegments: number,
|
||||
heights: readonly number[],
|
||||
): number | null {
|
||||
const column = ((x + width / 2) / width) * columnSegments;
|
||||
const row = ((y + length / 2) / length) * rowSegments;
|
||||
if (column < 0 || column > columnSegments || row < 0 || row > rowSegments) return null;
|
||||
const c0 = Math.min(columnSegments, Math.floor(column));
|
||||
const c1 = Math.min(columnSegments, c0 + 1);
|
||||
const r0 = Math.min(rowSegments, Math.floor(row));
|
||||
const r1 = Math.min(rowSegments, r0 + 1);
|
||||
const tx = column - c0;
|
||||
const ty = row - r0;
|
||||
const at = (r: number, c: number) => heights[r * (columnSegments + 1) + c] ?? 0;
|
||||
const lower = at(r0, c0) * (1 - tx) + at(r0, c1) * tx;
|
||||
const upper = at(r1, c0) * (1 - tx) + at(r1, c1) * tx;
|
||||
return lower * (1 - ty) + upper * ty;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前程序地图草稿在世界 XY 下的最高静态表面。工程地图任意 MJCF 不在浏览器
|
||||
* 侧猜测;它们仍可通过可编辑原语文档参与 MapEditSession 的局部重力落位。
|
||||
*/
|
||||
export function mapSceneSurfaceHeightAt(
|
||||
assets: readonly PlacedMapAsset[],
|
||||
worldX: number,
|
||||
worldY: number,
|
||||
): number | null {
|
||||
let highest: number | null = null;
|
||||
for (const asset of assets) {
|
||||
if (asset.selection.kind !== 'builtin' || asset.selection.config.preset === 'none') continue;
|
||||
const config = normalizePhysicalMapConfig(asset.selection.config);
|
||||
const [x, y] = mapLocalPoint(asset, worldX, worldY);
|
||||
const generated = isSystemTerrainPreset(config.preset)
|
||||
? generateSystemTerrain(config.preset, config)
|
||||
: { boxes: generateBuiltinMapBoxes(config) };
|
||||
for (const box of generated.boxes) {
|
||||
const height = boxSurfaceHeight(box, x, y);
|
||||
if (height !== null && (highest === null || height > highest)) highest = height;
|
||||
}
|
||||
if (generated.heightfield) {
|
||||
const height = heightfieldSurfaceHeight(
|
||||
x,
|
||||
y,
|
||||
generated.heightfield.width,
|
||||
generated.heightfield.length,
|
||||
generated.heightfield.rowSegments,
|
||||
generated.heightfield.columnSegments,
|
||||
generated.heightfield.heights,
|
||||
);
|
||||
if (height !== null && (highest === null || height > highest)) highest = height;
|
||||
}
|
||||
}
|
||||
return highest;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
DEFAULT_PHYSICAL_MAP_CONFIG,
|
||||
createPlacedMapAsset,
|
||||
mapLocalPointToWorld,
|
||||
mapSelectionTransform,
|
||||
mapWorldPointToLocal,
|
||||
updatePlacedMapAsset,
|
||||
} from './types';
|
||||
|
||||
describe('地图场景实例', () => {
|
||||
it('实例 id 独立于源资产并且只更新目标实例参数', () => {
|
||||
const first = createPlacedMapAsset(
|
||||
{ kind: 'builtin', config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'stairs' } },
|
||||
'楼梯',
|
||||
'map-a',
|
||||
);
|
||||
const second = createPlacedMapAsset(
|
||||
{ kind: 'project', descriptorPath: 'maps/warehouse/map.json' },
|
||||
'仓库',
|
||||
'map-b',
|
||||
);
|
||||
const next = updatePlacedMapAsset(
|
||||
[first, second],
|
||||
first.id,
|
||||
{
|
||||
kind: 'builtin',
|
||||
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'stairs', stairCount: 12 },
|
||||
},
|
||||
'楼梯',
|
||||
);
|
||||
expect(next[0].selection).toMatchObject({ kind: 'builtin', config: { stairCount: 12 } });
|
||||
expect(next[1]).toBe(second);
|
||||
expect(next[1].selection).toEqual({
|
||||
kind: 'project',
|
||||
descriptorPath: 'maps/warehouse/map.json',
|
||||
});
|
||||
});
|
||||
|
||||
it('程序地图和工程地图实例共享同一平面变换约定', () => {
|
||||
expect(
|
||||
mapSelectionTransform({
|
||||
kind: 'builtin',
|
||||
config: {
|
||||
...DEFAULT_PHYSICAL_MAP_CONFIG,
|
||||
preset: 'rough',
|
||||
positionX: 2,
|
||||
positionY: -3,
|
||||
yawDeg: 45,
|
||||
},
|
||||
}),
|
||||
).toEqual({ positionX: 2, positionY: -3, yawDeg: 45 });
|
||||
expect(
|
||||
mapSelectionTransform({
|
||||
kind: 'project',
|
||||
descriptorPath: 'maps/warehouse/map.json',
|
||||
positionX: 200,
|
||||
positionY: Number.NaN,
|
||||
yawDeg: -999,
|
||||
}),
|
||||
).toEqual({ positionX: 100, positionY: 0, yawDeg: -180 });
|
||||
|
||||
const selection = {
|
||||
kind: 'project' as const,
|
||||
descriptorPath: 'maps/warehouse/map.json',
|
||||
positionX: 2,
|
||||
positionY: -3,
|
||||
yawDeg: 90,
|
||||
};
|
||||
const world = mapLocalPointToWorld([1, 2, 0.5], selection);
|
||||
expect(world[0]).toBeCloseTo(0);
|
||||
expect(world[1]).toBeCloseTo(-2);
|
||||
expect(mapWorldPointToLocal(world, selection)).toEqual([
|
||||
expect.closeTo(1),
|
||||
expect.closeTo(2),
|
||||
0.5,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ export type SystemTerrainPreset = (typeof SYSTEM_TERRAIN_PRESETS)[number];
|
||||
export type PhysicalMapPreset =
|
||||
'none' | 'flat' | 'slope' | 'stairs' | 'obstacles' | SystemTerrainPreset;
|
||||
|
||||
export function isSystemTerrainPreset(value: PhysicalMapPreset): value is SystemTerrainPreset {
|
||||
export function isSystemTerrainPreset(value: string): value is SystemTerrainPreset {
|
||||
return (SYSTEM_TERRAIN_PRESETS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
@@ -82,11 +82,45 @@ export type MapSelection =
|
||||
| {
|
||||
kind: 'project';
|
||||
descriptorPath: string;
|
||||
/** 工程地图实例位姿;descriptor 内容始终保持源坐标不变。 */
|
||||
positionX?: number;
|
||||
positionY?: number;
|
||||
yawDeg?: number;
|
||||
spawnPointId?: string;
|
||||
robotRootBody?: string;
|
||||
frictionOverride?: number;
|
||||
};
|
||||
|
||||
export type PlacedMapSelection = Exclude<MapSelection, { kind: 'none' }>;
|
||||
|
||||
/** 场景中的地图实例;源地图与实例状态分离,同一源可重复放置。 */
|
||||
export interface PlacedMapAsset {
|
||||
id: string;
|
||||
name: string;
|
||||
selection: PlacedMapSelection;
|
||||
}
|
||||
|
||||
export function createPlacedMapAsset(
|
||||
selection: PlacedMapSelection,
|
||||
name: string,
|
||||
id = `map_${crypto.randomUUID().slice(0, 8)}`,
|
||||
): PlacedMapAsset {
|
||||
return { id, name, selection: structuredClone(selection) };
|
||||
}
|
||||
|
||||
export function updatePlacedMapAsset(
|
||||
assets: readonly PlacedMapAsset[],
|
||||
id: string,
|
||||
selection: PlacedMapSelection,
|
||||
name?: string,
|
||||
): PlacedMapAsset[] {
|
||||
return assets.map((asset) =>
|
||||
asset.id === id
|
||||
? { ...asset, ...(name ? { name } : {}), selection: structuredClone(selection) }
|
||||
: asset,
|
||||
);
|
||||
}
|
||||
|
||||
export interface ResolvedProjectMap {
|
||||
definition: MapDefinition;
|
||||
descriptorPath: string;
|
||||
@@ -102,6 +136,67 @@ export interface VisualMapAsset {
|
||||
data: Uint8Array;
|
||||
castShadow: boolean;
|
||||
receiveShadow: boolean;
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
yawDeg: number;
|
||||
}
|
||||
|
||||
export interface MapInstanceTransform {
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
yawDeg: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_MAP_INSTANCE_TRANSFORM: MapInstanceTransform = {
|
||||
positionX: 0,
|
||||
positionY: 0,
|
||||
yawDeg: 0,
|
||||
};
|
||||
|
||||
export function mapSelectionTransform(selection: PlacedMapSelection): MapInstanceTransform {
|
||||
if (selection.kind === 'builtin')
|
||||
return {
|
||||
positionX: selection.config.positionX,
|
||||
positionY: selection.config.positionY,
|
||||
yawDeg: selection.config.yawDeg,
|
||||
};
|
||||
const finite = (value: number | undefined, minimum: number, maximum: number) =>
|
||||
typeof value === 'number' && Number.isFinite(value)
|
||||
? Math.min(maximum, Math.max(minimum, value))
|
||||
: 0;
|
||||
return {
|
||||
positionX: finite(selection.positionX, -100, 100),
|
||||
positionY: finite(selection.positionY, -100, 100),
|
||||
yawDeg: finite(selection.yawDeg, -180, 180),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapLocalPointToWorld(
|
||||
point: readonly [number, number, number],
|
||||
selection: PlacedMapSelection,
|
||||
): [number, number, number] {
|
||||
const transform = mapSelectionTransform(selection);
|
||||
const yaw = (transform.yawDeg * Math.PI) / 180;
|
||||
const cosine = Math.cos(yaw);
|
||||
const sine = Math.sin(yaw);
|
||||
return [
|
||||
transform.positionX + cosine * point[0] - sine * point[1],
|
||||
transform.positionY + sine * point[0] + cosine * point[1],
|
||||
point[2],
|
||||
];
|
||||
}
|
||||
|
||||
export function mapWorldPointToLocal(
|
||||
point: readonly [number, number, number],
|
||||
selection: PlacedMapSelection,
|
||||
): [number, number, number] {
|
||||
const transform = mapSelectionTransform(selection);
|
||||
const yaw = (-transform.yawDeg * Math.PI) / 180;
|
||||
const cosine = Math.cos(yaw);
|
||||
const sine = Math.sin(yaw);
|
||||
const dx = point[0] - transform.positionX;
|
||||
const dy = point[1] - transform.positionY;
|
||||
return [cosine * dx - sine * dy, sine * dx + cosine * dy, point[2]];
|
||||
}
|
||||
|
||||
export const DEFAULT_PHYSICAL_MAP_CONFIG: PhysicalMapConfig = {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { MainThreadPhysicsAdapter } from './PhysicsAdapter';
|
||||
import type { ProjectFile } from '../project/types';
|
||||
|
||||
interface Disposable {
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
interface AdapterLifecycleState {
|
||||
session: Disposable | null;
|
||||
workspace: Disposable | null;
|
||||
supportFiles: ProjectFile[];
|
||||
retiredSession: Disposable | null;
|
||||
retiredWorkspace: Disposable | null;
|
||||
retiredSupportFiles: ProjectFile[];
|
||||
releaseRetired(): void;
|
||||
rollbackRetired(): void;
|
||||
}
|
||||
|
||||
const lifecycle = (adapter: MainThreadPhysicsAdapter) =>
|
||||
adapter as unknown as AdapterLifecycleState;
|
||||
|
||||
const file = (path: string): ProjectFile => ({
|
||||
path,
|
||||
data: new Uint8Array(),
|
||||
size: 0,
|
||||
source: 'file',
|
||||
mimeType: '',
|
||||
});
|
||||
|
||||
describe('MainThreadPhysicsAdapter 会话事务清理', () => {
|
||||
it('先清空 retired 引用,旧资源释放失败也不会破坏当前会话', () => {
|
||||
const adapter = new MainThreadPhysicsAdapter();
|
||||
const state = lifecycle(adapter);
|
||||
const activeSession = { dispose: vi.fn() };
|
||||
const retiredSession = {
|
||||
dispose: vi.fn(() => {
|
||||
throw new Error('controller cleanup failed');
|
||||
}),
|
||||
};
|
||||
const retiredWorkspace = { dispose: vi.fn() };
|
||||
state.session = activeSession;
|
||||
state.retiredSession = retiredSession;
|
||||
state.retiredWorkspace = retiredWorkspace;
|
||||
state.retiredSupportFiles = [file('old.xml')];
|
||||
|
||||
expect(() => state.releaseRetired()).not.toThrow();
|
||||
expect(state.session).toBe(activeSession);
|
||||
expect(state.retiredSession).toBeNull();
|
||||
expect(state.retiredWorkspace).toBeNull();
|
||||
expect(state.retiredSupportFiles).toEqual([]);
|
||||
expect(retiredSession.dispose).toHaveBeenCalledOnce();
|
||||
expect(retiredWorkspace.dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('先恢复旧引用,候选资源释放失败也不会阻止回滚', () => {
|
||||
const adapter = new MainThreadPhysicsAdapter();
|
||||
const state = lifecycle(adapter);
|
||||
const failedSession = {
|
||||
dispose: vi.fn(() => {
|
||||
throw new Error('candidate cleanup failed');
|
||||
}),
|
||||
};
|
||||
const failedWorkspace = { dispose: vi.fn() };
|
||||
const retiredSession = { dispose: vi.fn() };
|
||||
const retiredWorkspace = { dispose: vi.fn() };
|
||||
const retiredFiles = [file('old.xml')];
|
||||
state.session = failedSession;
|
||||
state.workspace = failedWorkspace;
|
||||
state.supportFiles = [file('new.xml')];
|
||||
state.retiredSession = retiredSession;
|
||||
state.retiredWorkspace = retiredWorkspace;
|
||||
state.retiredSupportFiles = retiredFiles;
|
||||
|
||||
expect(() => state.rollbackRetired()).not.toThrow();
|
||||
expect(state.session).toBe(retiredSession);
|
||||
expect(state.workspace).toBe(retiredWorkspace);
|
||||
expect(state.supportFiles).toBe(retiredFiles);
|
||||
expect(state.retiredSession).toBeNull();
|
||||
expect(state.retiredWorkspace).toBeNull();
|
||||
expect(failedSession.dispose).toHaveBeenCalledOnce();
|
||||
expect(failedWorkspace.dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,8 @@ import type {
|
||||
import { composePhysicalMap } from '../map/physicalMap';
|
||||
import { composeProjectMap } from '../map/MapComposer';
|
||||
import { resolveProjectMap } from '../map/MapLoader';
|
||||
import { DEFAULT_MAP_SELECTION, type MapSelection } from '../map/types';
|
||||
import { composePlacedMapAssets } from '../map/MapStackComposer';
|
||||
import { DEFAULT_MAP_SELECTION, type MapSelection, type PlacedMapAsset } from '../map/types';
|
||||
|
||||
export type UrdfLoadMode = 'mjcf' | 'native';
|
||||
export type { UrdfBaseMode, UrdfEnhancementOptions };
|
||||
@@ -38,7 +39,9 @@ export interface PhysicsLoadOptions {
|
||||
urdfMode?: UrdfLoadMode;
|
||||
baseMode?: UrdfBaseMode;
|
||||
enhancements?: UrdfEnhancementOptions;
|
||||
/** @deprecated 单地图兼容入口;新场景应使用 mapAssets。 */
|
||||
map?: MapSelection;
|
||||
mapAssets?: readonly PlacedMapAsset[];
|
||||
onProgress?: (progress: PhysicsLoadProgress) => void;
|
||||
}
|
||||
|
||||
@@ -126,6 +129,8 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
|
||||
sensorType: 'camera',
|
||||
};
|
||||
const mapSelection = options.map ?? DEFAULT_MAP_SELECTION;
|
||||
const placedMapAssets = options.mapAssets;
|
||||
const hasMaps = placedMapAssets ? placedMapAssets.length > 0 : mapSelection.kind !== 'none';
|
||||
const report = (value: number, label: string) => {
|
||||
if (!this.disposed && generation === this.loadGeneration)
|
||||
options.onProgress?.({ value, label });
|
||||
@@ -191,24 +196,31 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
|
||||
intermediate.dispose();
|
||||
}
|
||||
}
|
||||
if (mapSelection.kind !== 'none') {
|
||||
if (hasMaps) {
|
||||
report(0.72, '组合机器人与物理地图');
|
||||
if (entry?.format === 'urdf' && urdfMode === 'native')
|
||||
throw new Error('原生 URDF 模式暂不支持地图,请切换为转换模式');
|
||||
const slash = modelRelativePath.lastIndexOf('/');
|
||||
const directory = slash >= 0 ? modelRelativePath.slice(0, slash + 1) : '';
|
||||
const mapPath = `${directory}.__mujoco_map_scene_${manifest.id.replace(/[^a-zA-Z0-9_-]/g, '_')}.xml`;
|
||||
if (mapSelection.kind === 'builtin') {
|
||||
const composed = composePhysicalMap(
|
||||
new TextEncoder().encode(workspace.readText(modelRelativePath)),
|
||||
mapSelection.config,
|
||||
const source = new TextEncoder().encode(workspace.readText(modelRelativePath));
|
||||
if (placedMapAssets) {
|
||||
const composed = composePlacedMapAssets(
|
||||
source,
|
||||
mapPath,
|
||||
prepared.manifest,
|
||||
placedMapAssets,
|
||||
);
|
||||
workspace.writeGenerated(mapPath, composed.data);
|
||||
warnings.push(...composed.warnings);
|
||||
} else if (mapSelection.kind === 'builtin') {
|
||||
const composed = composePhysicalMap(source, mapSelection.config);
|
||||
workspace.writeGenerated(mapPath, composed.data);
|
||||
if (composed.summary) warnings.push(composed.summary);
|
||||
} else {
|
||||
} else if (mapSelection.kind === 'project') {
|
||||
const resolvedMap = resolveProjectMap(prepared.manifest, mapSelection.descriptorPath);
|
||||
const composed = composeProjectMap(
|
||||
new TextEncoder().encode(workspace.readText(modelRelativePath)),
|
||||
source,
|
||||
mapPath,
|
||||
prepared.manifest,
|
||||
resolvedMap,
|
||||
@@ -339,21 +351,41 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
|
||||
return this.supportFiles.map((file) => ({ ...file, data: file.data.slice() }));
|
||||
}
|
||||
releaseRetired(): void {
|
||||
this.retiredSession?.dispose();
|
||||
const retiredSession = this.retiredSession;
|
||||
const retiredWorkspace = this.retiredWorkspace;
|
||||
this.retiredSession = null;
|
||||
this.retiredWorkspace?.dispose();
|
||||
this.retiredWorkspace = null;
|
||||
this.retiredSupportFiles = [];
|
||||
try {
|
||||
retiredSession?.dispose();
|
||||
} catch (error) {
|
||||
console.warn('[MuJoCo] 释放旧仿真会话失败', error);
|
||||
}
|
||||
try {
|
||||
retiredWorkspace?.dispose();
|
||||
} catch (error) {
|
||||
console.warn('[MuJoCo] 释放旧内存工作区失败', error);
|
||||
}
|
||||
}
|
||||
rollbackRetired(): void {
|
||||
this.session?.dispose();
|
||||
this.workspace?.dispose();
|
||||
const failedSession = this.session;
|
||||
const failedWorkspace = this.workspace;
|
||||
this.session = this.retiredSession;
|
||||
this.workspace = this.retiredWorkspace;
|
||||
this.supportFiles = this.retiredSupportFiles;
|
||||
this.retiredSession = null;
|
||||
this.retiredWorkspace = null;
|
||||
this.retiredSupportFiles = [];
|
||||
try {
|
||||
failedSession?.dispose();
|
||||
} catch (error) {
|
||||
console.warn('[MuJoCo] 释放失败的候选仿真会话失败', error);
|
||||
}
|
||||
try {
|
||||
failedWorkspace?.dispose();
|
||||
} catch (error) {
|
||||
console.warn('[MuJoCo] 释放失败的候选内存工作区失败', error);
|
||||
}
|
||||
}
|
||||
exportMjcf(): Uint8Array {
|
||||
if (!this.session || !this.workspace) throw new Error('尚未加载可导出的模型');
|
||||
|
||||
@@ -928,11 +928,18 @@ export class SimulationSession {
|
||||
dispose(): void {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
this.removeController();
|
||||
this.removeRLPolicy();
|
||||
this.clearExternalForce();
|
||||
this.perturb.delete();
|
||||
this.data.delete();
|
||||
this.model.delete();
|
||||
const cleanup = (label: string, action: () => void) => {
|
||||
try {
|
||||
action();
|
||||
} catch (error) {
|
||||
console.warn(`[MuJoCo] 释放${label}失败`, error);
|
||||
}
|
||||
};
|
||||
cleanup('Python 控制器', () => this.removeController());
|
||||
cleanup('RL 策略', () => this.removeRLPolicy());
|
||||
cleanup('外力状态', () => this.clearExternalForce());
|
||||
cleanup('扰动对象', () => this.perturb.delete());
|
||||
cleanup('仿真数据', () => this.data.delete());
|
||||
cleanup('仿真模型', () => this.model.delete());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ function createLayer() {
|
||||
const element = document.createElement('canvas');
|
||||
const callbacks = {
|
||||
onSelect: vi.fn(),
|
||||
onPreviewSelect: vi.fn(),
|
||||
onTransform: vi.fn(),
|
||||
onDragging: vi.fn(),
|
||||
};
|
||||
@@ -44,6 +45,68 @@ describe('MapEditorLayer', () => {
|
||||
expect(scene.children).not.toContain(layer.group);
|
||||
});
|
||||
|
||||
it('在工程地图实例坐标系中预览认证资产', () => {
|
||||
const { layer } = createLayer();
|
||||
layer.setDocument(documentValue, { positionX: 2, positionY: -3, yawDeg: 90 });
|
||||
expect(layer.group.position.toArray()).toEqual([2, -3, 0]);
|
||||
expect(layer.group.rotation.z).toBeCloseTo(Math.PI / 2);
|
||||
const object = layer.group.children[0];
|
||||
expect(object.position.toArray()).toEqual(documentValue.objects[0].pose.position);
|
||||
layer.dispose();
|
||||
});
|
||||
|
||||
it('清空激活文档时保留其他待提交认证场景的预览', () => {
|
||||
const { scene, layer } = createLayer();
|
||||
layer.setPreviewInstances([
|
||||
{
|
||||
id: 'certified_instance',
|
||||
document: documentValue,
|
||||
transform: { positionX: 2, positionY: -3, yawDeg: 90 },
|
||||
},
|
||||
]);
|
||||
const previewRoot = layer.previewGroup.children[0];
|
||||
const previewMesh = previewRoot.children[0].children[0] as THREE.Mesh;
|
||||
const geometryDispose = vi.spyOn(previewMesh.geometry, 'dispose');
|
||||
expect(previewRoot.position.toArray()).toEqual([2, -3, 0]);
|
||||
expect(previewRoot.rotation.z).toBeCloseTo(Math.PI / 2);
|
||||
|
||||
layer.setDocument(null);
|
||||
expect(layer.previewGroup.children).toHaveLength(1);
|
||||
layer.setPreviewInstances([]);
|
||||
expect(geometryDispose).toHaveBeenCalledOnce();
|
||||
expect(layer.previewGroup.children).toHaveLength(0);
|
||||
layer.dispose();
|
||||
expect(scene.children).not.toContain(layer.previewGroup);
|
||||
});
|
||||
|
||||
it('可从视口拾取非激活的认证场景预览', () => {
|
||||
const { callbacks, layer } = createLayer();
|
||||
layer.setPreviewInstances([
|
||||
{
|
||||
id: 'certified_instance',
|
||||
document: documentValue,
|
||||
transform: { positionX: 0, positionY: 0, yawDeg: 0 },
|
||||
},
|
||||
]);
|
||||
const bounds = {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
right: 100,
|
||||
bottom: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
expect(
|
||||
layer.handlePointerDown({ button: 0, clientX: 50, clientY: 50 } as PointerEvent, bounds),
|
||||
).toBe(true);
|
||||
expect(callbacks.onPreviewSelect).toHaveBeenCalledWith('certified_instance', 'box_1');
|
||||
expect(callbacks.onSelect).not.toHaveBeenCalled();
|
||||
layer.dispose();
|
||||
});
|
||||
|
||||
it('在视口中拾取编辑对象', () => {
|
||||
const { callbacks, layer } = createLayer();
|
||||
layer.setDocument(documentValue);
|
||||
@@ -58,14 +121,21 @@ describe('MapEditorLayer', () => {
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
expect(
|
||||
layer.handlePointerDown({ button: 0, clientX: 50, clientY: 50 } as PointerEvent, bounds, 1),
|
||||
).toBe(false);
|
||||
expect(callbacks.onSelect).not.toHaveBeenCalled();
|
||||
expect(
|
||||
layer.handlePointerDown({ button: 0, clientX: 50, clientY: 50 } as PointerEvent, bounds),
|
||||
).toBe(true);
|
||||
expect(callbacks.onSelect).toHaveBeenCalledWith('box_1');
|
||||
expect(layer.transform.getHelper().visible).toBe(true);
|
||||
expect(
|
||||
layer.handlePointerDown({ button: 0, clientX: 2, clientY: 2 } as PointerEvent, bounds),
|
||||
).toBe(false);
|
||||
expect(callbacks.onSelect).toHaveBeenCalledTimes(1);
|
||||
expect(callbacks.onSelect).toHaveBeenLastCalledWith(null);
|
||||
expect(callbacks.onSelect).toHaveBeenCalledTimes(2);
|
||||
expect(layer.transform.getHelper().visible).toBe(false);
|
||||
layer.dispose();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,10 +5,16 @@ import type {
|
||||
EditableMapObject,
|
||||
MapEditorTransformMode,
|
||||
} from '../map/editor/types';
|
||||
import type { SpawnPoint } from '../map/types';
|
||||
import type { MapEditorDraftPreviewInstance } from '../map/mapSceneDraft';
|
||||
import {
|
||||
DEFAULT_MAP_INSTANCE_TRANSFORM,
|
||||
type MapInstanceTransform,
|
||||
type SpawnPoint,
|
||||
} from '../map/types';
|
||||
|
||||
export interface MapEditorLayerCallbacks {
|
||||
onSelect(id: string | null): void;
|
||||
onPreviewSelect(mapAssetId: string, objectId: string): void;
|
||||
onTransform(
|
||||
id: string,
|
||||
position: [number, number, number],
|
||||
@@ -32,6 +38,7 @@ function mesh(geometry: THREE.BufferGeometry, object: EditableMapObject): THREE.
|
||||
const result = new THREE.Mesh(geometry, material(object));
|
||||
result.castShadow = true;
|
||||
result.receiveShadow = true;
|
||||
result.renderOrder = 9;
|
||||
return result;
|
||||
}
|
||||
function spawnPreview(spawn: SpawnPoint): THREE.Group {
|
||||
@@ -47,6 +54,8 @@ function spawnPreview(spawn: SpawnPoint): THREE.Group {
|
||||
});
|
||||
const ring = new THREE.Mesh(new THREE.TorusGeometry(0.22, 0.025, 8, 24), markerMaterial);
|
||||
const arrow = new THREE.Mesh(new THREE.ConeGeometry(0.08, 0.32, 12), markerMaterial);
|
||||
ring.renderOrder = 9;
|
||||
arrow.renderOrder = 9;
|
||||
arrow.rotation.z = -Math.PI / 2;
|
||||
arrow.position.x = 0.25;
|
||||
group.add(ring, arrow);
|
||||
@@ -99,13 +108,30 @@ function objectPreview(object: EditableMapObject): THREE.Group {
|
||||
return group;
|
||||
}
|
||||
|
||||
function disposeGroupChildren(group: THREE.Group): void {
|
||||
const geometries = new Set<THREE.BufferGeometry>();
|
||||
const materials = new Set<THREE.Material>();
|
||||
for (const child of group.children)
|
||||
child.traverse((object) => {
|
||||
if (!(object instanceof THREE.Mesh)) return;
|
||||
geometries.add(object.geometry);
|
||||
for (const item of Array.isArray(object.material) ? object.material : [object.material])
|
||||
materials.add(item);
|
||||
});
|
||||
group.clear();
|
||||
for (const geometry of geometries) geometry.dispose();
|
||||
for (const item of materials) item.dispose();
|
||||
}
|
||||
|
||||
export class MapEditorLayer {
|
||||
readonly group = new THREE.Group();
|
||||
readonly previewGroup = new THREE.Group();
|
||||
readonly transform: TransformControls;
|
||||
private readonly helper: THREE.Object3D;
|
||||
private readonly raycaster = new THREE.Raycaster();
|
||||
private readonly pointer = new THREE.Vector2();
|
||||
private readonly objects = new Map<string, THREE.Group>();
|
||||
private readonly previewInstances = new Map<string, THREE.Group>();
|
||||
private selectedId: string | null = null;
|
||||
private documentLoaded = false;
|
||||
|
||||
@@ -115,8 +141,9 @@ export class MapEditorLayer {
|
||||
domElement: HTMLElement,
|
||||
private readonly callbacks: MapEditorLayerCallbacks,
|
||||
) {
|
||||
this.previewGroup.name = '__platform_map_editor_scene_previews__';
|
||||
this.group.name = '__platform_map_editor__';
|
||||
scene.add(this.group);
|
||||
scene.add(this.previewGroup, this.group);
|
||||
this.transform = new TransformControls(camera, domElement);
|
||||
this.transform.setSpace('world');
|
||||
this.transform.setSize(0.8);
|
||||
@@ -134,12 +161,18 @@ export class MapEditorLayer {
|
||||
}
|
||||
|
||||
get enabled(): boolean {
|
||||
return this.documentLoaded;
|
||||
return this.documentLoaded || this.previewInstances.size > 0;
|
||||
}
|
||||
|
||||
setDocument(document: EditableMapDocument | null): void {
|
||||
setDocument(
|
||||
document: EditableMapDocument | null,
|
||||
instanceTransform: MapInstanceTransform = DEFAULT_MAP_INSTANCE_TRANSFORM,
|
||||
): void {
|
||||
const selected = this.selectedId;
|
||||
this.clearObjects();
|
||||
this.group.position.set(instanceTransform.positionX, instanceTransform.positionY, 0);
|
||||
this.group.rotation.set(0, 0, THREE.MathUtils.degToRad(instanceTransform.yawDeg));
|
||||
this.group.updateMatrixWorld(true);
|
||||
this.documentLoaded = Boolean(document);
|
||||
this.transform.enabled = this.documentLoaded;
|
||||
if (!document) {
|
||||
@@ -156,6 +189,27 @@ export class MapEditorLayer {
|
||||
this.selectObject(selected && this.objects.has(selected) ? selected : null, false);
|
||||
}
|
||||
|
||||
/** 绘制非激活、但仍处于统一场景草稿中的认证资产实例。 */
|
||||
setPreviewInstances(instances: readonly MapEditorDraftPreviewInstance[]): void {
|
||||
this.clearPreviewInstances();
|
||||
for (const instance of instances) {
|
||||
const root = new THREE.Group();
|
||||
root.name = `__platform_map_editor_scene_preview_${instance.id}__`;
|
||||
root.userData.mapAssetId = instance.id;
|
||||
root.position.set(instance.transform.positionX, instance.transform.positionY, 0);
|
||||
root.rotation.z = THREE.MathUtils.degToRad(instance.transform.yawDeg);
|
||||
for (const object of instance.document.objects) {
|
||||
if (object.enabled) root.add(objectPreview(object));
|
||||
}
|
||||
for (const spawn of instance.document.spawnPoints) root.add(spawnPreview(spawn));
|
||||
root.traverse((object) => {
|
||||
object.userData.mapEditorMapAssetId = instance.id;
|
||||
});
|
||||
this.previewInstances.set(instance.id, root);
|
||||
this.previewGroup.add(root);
|
||||
}
|
||||
}
|
||||
|
||||
setTransformMode(mode: MapEditorTransformMode): void {
|
||||
this.transform.setMode(mode);
|
||||
const translate = mode === 'translate';
|
||||
@@ -197,18 +251,38 @@ export class MapEditorLayer {
|
||||
if (notify) this.callbacks.onSelect(this.selectedId);
|
||||
}
|
||||
|
||||
/** 命中编辑对象或操纵器时消费左键;空白区域留给 OrbitControls 旋转视角。 */
|
||||
handlePointerDown(event: PointerEvent, bounds: DOMRect): boolean {
|
||||
/** 命中编辑对象或操纵器时消费左键;点击空白区域取消对象选中。 */
|
||||
handlePointerDown(
|
||||
event: PointerEvent,
|
||||
bounds: DOMRect,
|
||||
maximumDistance = Number.POSITIVE_INFINITY,
|
||||
): boolean {
|
||||
if (!this.enabled || event.button !== 0) return false;
|
||||
if (this.transform.axis || this.transform.dragging) return true;
|
||||
this.pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
|
||||
this.pointer.y = -((event.clientY - bounds.top) / bounds.height) * 2 + 1;
|
||||
this.raycaster.setFromCamera(this.pointer, this.camera);
|
||||
const hit = this.raycaster.intersectObjects([...this.objects.values()], true)[0];
|
||||
const id = hit?.object.userData.mapEditorObjectId;
|
||||
if (typeof id !== 'string') return false;
|
||||
this.selectObject(id, true);
|
||||
return true;
|
||||
this.group.updateMatrixWorld(true);
|
||||
this.previewGroup.updateMatrixWorld(true);
|
||||
const hit = this.raycaster
|
||||
.intersectObjects([...this.objects.values(), ...this.previewInstances.values()], true)
|
||||
.find(
|
||||
(candidate) =>
|
||||
typeof candidate.object.userData.mapEditorObjectId === 'string' &&
|
||||
candidate.distance <= maximumDistance + 1e-3,
|
||||
);
|
||||
const objectId = hit?.object.userData.mapEditorObjectId;
|
||||
const mapAssetId = hit?.object.userData.mapEditorMapAssetId;
|
||||
if (typeof objectId === 'string' && typeof mapAssetId === 'string') {
|
||||
this.callbacks.onPreviewSelect(mapAssetId, objectId);
|
||||
return true;
|
||||
}
|
||||
if (typeof objectId === 'string') {
|
||||
this.selectObject(objectId, true);
|
||||
return true;
|
||||
}
|
||||
if (this.selectedId) this.selectObject(null, true);
|
||||
return false;
|
||||
}
|
||||
|
||||
private commitTransform(): void {
|
||||
@@ -233,26 +307,22 @@ export class MapEditorLayer {
|
||||
private clearObjects(): void {
|
||||
this.transform.detach();
|
||||
this.helper.visible = false;
|
||||
const geometries = new Set<THREE.BufferGeometry>(),
|
||||
materials = new Set<THREE.Material>();
|
||||
for (const child of this.group.children)
|
||||
child.traverse((object) => {
|
||||
if (object instanceof THREE.Mesh) {
|
||||
geometries.add(object.geometry);
|
||||
for (const item of Array.isArray(object.material) ? object.material : [object.material])
|
||||
materials.add(item);
|
||||
}
|
||||
});
|
||||
this.group.clear();
|
||||
disposeGroupChildren(this.group);
|
||||
this.objects.clear();
|
||||
for (const geometry of geometries) geometry.dispose();
|
||||
for (const item of materials) item.dispose();
|
||||
}
|
||||
|
||||
private clearPreviewInstances(): void {
|
||||
disposeGroupChildren(this.previewGroup);
|
||||
this.previewInstances.clear();
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.documentLoaded = false;
|
||||
this.selectedId = null;
|
||||
this.clearObjects();
|
||||
this.clearPreviewInstances();
|
||||
this.group.position.set(0, 0, 0);
|
||||
this.group.rotation.set(0, 0, 0);
|
||||
this.transform.enabled = false;
|
||||
this.callbacks.onDragging(false);
|
||||
}
|
||||
@@ -262,6 +332,7 @@ export class MapEditorLayer {
|
||||
this.callbacks.onDragging(false);
|
||||
this.transform.dispose();
|
||||
this.helper.removeFromParent();
|
||||
this.previewGroup.removeFromParent();
|
||||
this.group.removeFromParent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,11 @@ import { texturePixelsToRgba } from './texturePixels';
|
||||
import { DEFAULT_VIEWER_DISPLAY_OPTIONS, type ViewerDisplayOptions } from './displayOptions';
|
||||
import { ViewerVisualizationHelpers } from './ViewerVisualizationHelpers';
|
||||
import { VisualMapLayer } from './VisualMapLayer';
|
||||
import type { VisualMapAsset } from '../map/types';
|
||||
import type { MapInstanceTransform, PlacedMapAsset, VisualMapAsset } from '../map/types';
|
||||
import type { EditableMapDocument, MapEditorTransformMode } from '../map/editor/types';
|
||||
import type { MapEditorDraftPreviewInstance } from '../map/mapSceneDraft';
|
||||
import { MapEditorLayer } from './MapEditorLayer';
|
||||
import { ParametricMapPreviewLayer } from './ParametricMapPreviewLayer';
|
||||
|
||||
export type InteractionMode = 'select' | 'joint' | 'force';
|
||||
export type ViewerTheme = 'light' | 'dark';
|
||||
@@ -36,6 +38,9 @@ interface ViewerCallbacks {
|
||||
onFrame(frame: FrameResult, fps: number, snapshot?: SimulationSnapshot): void;
|
||||
onError(error: Error): void;
|
||||
onMapEditorSelect(id: string | null): void;
|
||||
onMapEditorPreviewSelect(mapAssetId: string, objectId: string): void;
|
||||
onParametricMapSelect(id: string | null): void;
|
||||
onParametricMapTransform(id: string, position: [number, number], yawDeg: number): void;
|
||||
onMapEditorTransform(
|
||||
id: string,
|
||||
position: [number, number, number],
|
||||
@@ -78,6 +83,9 @@ export class MuJoCoViewer {
|
||||
private mjCamera: MjvCamera | null = null;
|
||||
private mjScene: MjvScene | null = null;
|
||||
private frame = 0;
|
||||
private resizeFrame = 0;
|
||||
private resizeWidth = 0;
|
||||
private resizeHeight = 0;
|
||||
private lastFpsAt = performance.now();
|
||||
private fpsFrames = 0;
|
||||
private lastSnapshotAt = 0;
|
||||
@@ -111,6 +119,7 @@ export class MuJoCoViewer {
|
||||
private orientationGizmo: OrientationGizmo;
|
||||
private visualizationHelpers: ViewerVisualizationHelpers;
|
||||
private visualMapLayer: VisualMapLayer;
|
||||
private parametricMapPreviewLayer: ParametricMapPreviewLayer;
|
||||
private mapEditorLayer: MapEditorLayer;
|
||||
private grid: THREE.GridHelper;
|
||||
private hemisphere: THREE.HemisphereLight;
|
||||
@@ -153,8 +162,38 @@ export class MuJoCoViewer {
|
||||
this.scene.add(this.grid);
|
||||
this.visualizationHelpers = new ViewerVisualizationHelpers(this.scene);
|
||||
this.visualMapLayer = new VisualMapLayer(this.scene);
|
||||
this.parametricMapPreviewLayer = new ParametricMapPreviewLayer(
|
||||
this.scene,
|
||||
this.camera,
|
||||
this.renderer.domElement,
|
||||
{
|
||||
onSelect: (id) => {
|
||||
if (id) {
|
||||
this.mapEditorLayer.selectObject(null, true);
|
||||
this.clearSelection();
|
||||
}
|
||||
this.callbacks.onParametricMapSelect(id);
|
||||
},
|
||||
onTransform: (id, position, yawDeg) =>
|
||||
this.callbacks.onParametricMapTransform(id, position, yawDeg),
|
||||
onDragging: (value) => {
|
||||
this.controls.enabled = !value;
|
||||
},
|
||||
},
|
||||
);
|
||||
this.mapEditorLayer = new MapEditorLayer(this.scene, this.camera, this.renderer.domElement, {
|
||||
onSelect: (id) => this.callbacks.onMapEditorSelect(id),
|
||||
onSelect: (id) => {
|
||||
if (id) {
|
||||
this.parametricMapPreviewLayer.selectObject(null);
|
||||
this.clearSelection();
|
||||
}
|
||||
this.callbacks.onMapEditorSelect(id);
|
||||
},
|
||||
onPreviewSelect: (mapAssetId, objectId) => {
|
||||
this.parametricMapPreviewLayer.selectObject(null);
|
||||
this.clearSelection();
|
||||
this.callbacks.onMapEditorPreviewSelect(mapAssetId, objectId);
|
||||
},
|
||||
onTransform: (id, position, quaternion, scale) =>
|
||||
this.callbacks.onMapEditorTransform(id, position, quaternion, scale),
|
||||
onDragging: (value) => {
|
||||
@@ -163,7 +202,10 @@ export class MuJoCoViewer {
|
||||
});
|
||||
this.orientationGizmo = new OrientationGizmo(host);
|
||||
this.orientationGizmo.update(this.camera);
|
||||
this.resizeObserver = new ResizeObserver(() => this.resize());
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
cancelAnimationFrame(this.resizeFrame);
|
||||
this.resizeFrame = requestAnimationFrame(() => this.resize());
|
||||
});
|
||||
this.resizeObserver.observe(host);
|
||||
this.resize();
|
||||
this.renderer.domElement.addEventListener('pointerdown', this.onPointerDown);
|
||||
@@ -202,6 +244,10 @@ export class MuJoCoViewer {
|
||||
}
|
||||
setMode(mode: InteractionMode): void {
|
||||
this.mode = mode;
|
||||
if (mode !== 'select') {
|
||||
this.mapEditorLayer.selectObject(null, true);
|
||||
this.parametricMapPreviewLayer.selectObject(null, true);
|
||||
}
|
||||
this.controls.enabled = true;
|
||||
this.controls.enableRotate = mode === 'select';
|
||||
this.controls.enablePan = true;
|
||||
@@ -218,27 +264,47 @@ export class MuJoCoViewer {
|
||||
this.setDisplayOptions({ ...this.displayOptions, showCollision: value });
|
||||
}
|
||||
async setVisualMap(asset: VisualMapAsset | null): Promise<void> {
|
||||
await this.visualMapLayer.load(null);
|
||||
this.mapHasVisuals = false;
|
||||
this.applyGeomVisibility();
|
||||
if (!asset) return;
|
||||
await this.visualMapLayer.load(asset);
|
||||
await this.setVisualMaps(asset ? [asset] : []);
|
||||
}
|
||||
async setVisualMaps(assets: readonly VisualMapAsset[]): Promise<void> {
|
||||
await this.visualMapLayer.loadMany(assets);
|
||||
this.mapHasVisuals = this.visualMapLayer.loaded;
|
||||
this.visualMapLayer.visible = this.showVisualMap && this.displayOptions.showVisual;
|
||||
this.applyGeomVisibility();
|
||||
}
|
||||
setMapEditorDocument(document: EditableMapDocument | null): void {
|
||||
this.mapEditorLayer.setDocument(document);
|
||||
setVisualMapTransforms(assets: readonly VisualMapAsset[]): void {
|
||||
this.visualMapLayer.setTransforms(assets);
|
||||
}
|
||||
setParametricMapAssets(assets: readonly PlacedMapAsset[], previewIds: readonly string[]): void {
|
||||
this.parametricMapPreviewLayer.setAssets(assets, previewIds);
|
||||
}
|
||||
selectParametricMapAsset(id: string | null): void {
|
||||
this.parametricMapPreviewLayer.selectObject(id);
|
||||
}
|
||||
setMapEditorDocument(
|
||||
document: EditableMapDocument | null,
|
||||
instanceTransform?: MapInstanceTransform,
|
||||
): void {
|
||||
this.mapEditorLayer.setDocument(document, instanceTransform);
|
||||
this.controls.enableRotate = this.mode === 'select';
|
||||
}
|
||||
setMapEditorPreviewInstances(instances: readonly MapEditorDraftPreviewInstance[]): void {
|
||||
this.mapEditorLayer.setPreviewInstances(instances);
|
||||
}
|
||||
selectMapEditorObject(id: string | null): void {
|
||||
if (id) {
|
||||
this.parametricMapPreviewLayer.selectObject(null);
|
||||
this.clearSelection();
|
||||
}
|
||||
this.mapEditorLayer.selectObject(id);
|
||||
}
|
||||
setMapEditorTransformMode(mode: MapEditorTransformMode): void {
|
||||
this.mapEditorLayer.setTransformMode(mode);
|
||||
this.parametricMapPreviewLayer.setTransformMode(mode);
|
||||
}
|
||||
setMapEditorSnapping(translation: number | null, rotationDegrees: number | null): void {
|
||||
this.mapEditorLayer.setSnapping(translation, rotationDegrees);
|
||||
this.parametricMapPreviewLayer.setSnapping(translation, rotationDegrees);
|
||||
}
|
||||
mapPlanePoint(clientX: number, clientY: number): [number, number, number] | null {
|
||||
const rect = this.renderer.domElement.getBoundingClientRect();
|
||||
@@ -411,6 +477,9 @@ export class MuJoCoViewer {
|
||||
private resize(): void {
|
||||
const w = Math.max(1, this.host.clientWidth),
|
||||
h = Math.max(1, this.host.clientHeight);
|
||||
if (w === this.resizeWidth && h === this.resizeHeight) return;
|
||||
this.resizeWidth = w;
|
||||
this.resizeHeight = h;
|
||||
this.renderer.setSize(w, h, false);
|
||||
this.camera.aspect = w / h;
|
||||
this.camera.updateProjectionMatrix();
|
||||
@@ -732,20 +801,34 @@ export class MuJoCoViewer {
|
||||
}
|
||||
private onPointerDown = (event: PointerEvent): void => {
|
||||
if (event.button !== 0) return;
|
||||
if (
|
||||
this.mapEditorLayer.handlePointerDown(event, this.renderer.domElement.getBoundingClientRect())
|
||||
)
|
||||
return;
|
||||
const bounds = this.renderer.domElement.getBoundingClientRect();
|
||||
this.eventPointer(event);
|
||||
this.raycaster.setFromCamera(this.pointer, this.camera);
|
||||
const hit = this.raycaster.intersectObjects(
|
||||
this.meshes.filter((m) => m.visible),
|
||||
false,
|
||||
)[0];
|
||||
if (!hit) return;
|
||||
if (this.mode === 'select') {
|
||||
const dynamicOccluderDistance =
|
||||
hit && Number(hit.object.userData.bodyId) > 0 ? hit.distance : Number.POSITIVE_INFINITY;
|
||||
if (this.mapEditorLayer.handlePointerDown(event, bounds, dynamicOccluderDistance)) return;
|
||||
if (this.parametricMapPreviewLayer.handlePointerDown(event, bounds, dynamicOccluderDistance))
|
||||
return;
|
||||
}
|
||||
if (!hit) {
|
||||
this.clearSelection();
|
||||
return;
|
||||
}
|
||||
const mesh = hit.object as THREE.Mesh;
|
||||
this.select(mesh);
|
||||
const bodyId = Number(mesh.userData.bodyId);
|
||||
const worldPlane =
|
||||
bodyId === 0 &&
|
||||
Number(mesh.userData.geomType) === this.session?.module.mjtGeom.mjGEOM_PLANE.value;
|
||||
if (this.mode === 'select' && worldPlane) {
|
||||
this.clearSelection();
|
||||
return;
|
||||
}
|
||||
this.select(mesh);
|
||||
if (this.mode === 'joint') {
|
||||
const joint = this.session?.snapshot().joints.find((j) => j.bodyId === bodyId && j.editable);
|
||||
if (joint) {
|
||||
@@ -875,6 +958,13 @@ export class MuJoCoViewer {
|
||||
this.arrow = null;
|
||||
}
|
||||
}
|
||||
private clearSelection(): void {
|
||||
const previous = this.selected;
|
||||
if (!previous) return;
|
||||
this.selected = null;
|
||||
this.applyMeshHighlight(previous);
|
||||
this.callbacks.onSelection(null);
|
||||
}
|
||||
private select(mesh: THREE.Mesh): void {
|
||||
const previous = this.selected;
|
||||
this.selected = mesh;
|
||||
@@ -947,6 +1037,7 @@ export class MuJoCoViewer {
|
||||
}
|
||||
dispose(): void {
|
||||
cancelAnimationFrame(this.frame);
|
||||
cancelAnimationFrame(this.resizeFrame);
|
||||
this.releaseModel();
|
||||
this.resizeObserver.disconnect();
|
||||
this.renderer.domElement.removeEventListener('pointerdown', this.onPointerDown);
|
||||
@@ -959,6 +1050,7 @@ export class MuJoCoViewer {
|
||||
this.orientationGizmo.dispose();
|
||||
this.visualizationHelpers.dispose();
|
||||
this.visualMapLayer.dispose();
|
||||
this.parametricMapPreviewLayer.dispose();
|
||||
this.mapEditorLayer.dispose();
|
||||
this.grid.geometry.dispose();
|
||||
const gridMaterials = Array.isArray(this.grid.material)
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import * as THREE from 'three';
|
||||
import {
|
||||
DEFAULT_PHYSICAL_MAP_CONFIG,
|
||||
type PhysicalMapPreset,
|
||||
type PlacedMapAsset,
|
||||
} from '../map/types';
|
||||
import { ParametricMapPreviewLayer } from './ParametricMapPreviewLayer';
|
||||
|
||||
function terrain(
|
||||
id: string,
|
||||
preset: Exclude<PhysicalMapPreset, 'none'>,
|
||||
positionX = 0,
|
||||
positionY = 0,
|
||||
): PlacedMapAsset {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
selection: {
|
||||
kind: 'builtin',
|
||||
config: {
|
||||
...DEFAULT_PHYSICAL_MAP_CONFIG,
|
||||
preset,
|
||||
size: 4,
|
||||
positionX,
|
||||
positionY,
|
||||
yawDeg: 30,
|
||||
terrainHorizontalScale: 0.5,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createLayer() {
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
|
||||
camera.position.set(0, 0, 10);
|
||||
camera.lookAt(0, 0, 0);
|
||||
camera.updateMatrixWorld();
|
||||
camera.updateProjectionMatrix();
|
||||
const callbacks = {
|
||||
onSelect: vi.fn(),
|
||||
onTransform: vi.fn(),
|
||||
onDragging: vi.fn(),
|
||||
};
|
||||
const layer = new ParametricMapPreviewLayer(
|
||||
scene,
|
||||
camera,
|
||||
document.createElement('canvas'),
|
||||
callbacks,
|
||||
);
|
||||
return { scene, callbacks, layer };
|
||||
}
|
||||
|
||||
const bounds = {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
right: 100,
|
||||
bottom: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
|
||||
describe('ParametricMapPreviewLayer', () => {
|
||||
it('无需 MuJoCo 编译即可预览参数地形的落点、旋转和几何', () => {
|
||||
const { scene, layer } = createLayer();
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
layer.setAssets([terrain('wave-a', 'wave', 2.5, -1.5), terrain('boxes', 'discrete_obstacles')]);
|
||||
|
||||
expect(consoleError).not.toHaveBeenCalled();
|
||||
consoleError.mockRestore();
|
||||
expect(layer.group.children).toHaveLength(2);
|
||||
const wave = layer.group.children[0] as THREE.Group;
|
||||
expect(wave.position.toArray()).toEqual([2.5, -1.5, 0]);
|
||||
expect(wave.rotation.z).toBeCloseTo(Math.PI / 6);
|
||||
const surface = wave.children[0] as THREE.Mesh;
|
||||
const positions = surface.geometry.getAttribute('position') as THREE.BufferAttribute;
|
||||
expect(
|
||||
Array.from({ length: positions.count }, (_, index) => positions.getZ(index)),
|
||||
).not.toEqual(Array(positions.count).fill(0));
|
||||
expect(
|
||||
layer.group.children[1].children.some((child) => child instanceof THREE.InstancedMesh),
|
||||
).toBe(true);
|
||||
layer.dispose();
|
||||
expect(scene.children).not.toContain(layer.group);
|
||||
});
|
||||
|
||||
it('旧版内置地图也使用相同预览、拾取和实例变换', () => {
|
||||
const { layer } = createLayer();
|
||||
layer.setAssets([terrain('stairs-a', 'stairs', 1, 2), terrain('flat-a', 'flat')]);
|
||||
expect(layer.group.children).toHaveLength(2);
|
||||
const stairs = layer.group.children[0] as THREE.Group;
|
||||
expect(stairs.position.toArray()).toEqual([1, 2, 0]);
|
||||
expect(stairs.children[0]).toBeInstanceOf(THREE.InstancedMesh);
|
||||
expect((stairs.children[0] as THREE.InstancedMesh).count).toBe(
|
||||
DEFAULT_PHYSICAL_MAP_CONFIG.stairCount,
|
||||
);
|
||||
layer.selectObject('stairs-a');
|
||||
expect(layer.transform.object).toBe(stairs);
|
||||
layer.dispose();
|
||||
});
|
||||
|
||||
it('连续添加时复用未变化地形,不重复生成或释放已有几何', () => {
|
||||
const { layer } = createLayer();
|
||||
const first = terrain('wave-a', 'wave');
|
||||
layer.setAssets([first]);
|
||||
const firstRoot = layer.group.children[0];
|
||||
const mesh = firstRoot.children[0] as THREE.Mesh;
|
||||
const geometryDispose = vi.spyOn(mesh.geometry, 'dispose');
|
||||
const moved = terrain('wave-a', 'wave', 2, -1);
|
||||
layer.setAssets([moved]);
|
||||
expect(layer.group.children[0]).toBe(firstRoot);
|
||||
expect(firstRoot.position.toArray()).toEqual([2, -1, 0]);
|
||||
layer.setAssets([moved, terrain('boxes', 'discrete_obstacles')]);
|
||||
expect(layer.group.children[0]).toBe(firstRoot);
|
||||
expect(geometryDispose).not.toHaveBeenCalled();
|
||||
geometryDispose.mockRestore();
|
||||
layer.dispose();
|
||||
});
|
||||
|
||||
it('点击地形才显示操纵轴,点击空白后取消选中', () => {
|
||||
const { callbacks, layer } = createLayer();
|
||||
layer.setAssets([terrain('wave-a', 'wave')], []);
|
||||
expect(layer.group.children[0].visible).toBe(false);
|
||||
expect(layer.transform.getHelper().visible).toBe(false);
|
||||
expect(
|
||||
layer.handlePointerDown({ button: 0, clientX: 50, clientY: 50 } as PointerEvent, bounds, 1),
|
||||
).toBe(false);
|
||||
expect(callbacks.onSelect).not.toHaveBeenCalled();
|
||||
expect(
|
||||
layer.handlePointerDown({ button: 0, clientX: 50, clientY: 50 } as PointerEvent, bounds),
|
||||
).toBe(true);
|
||||
expect(callbacks.onSelect).toHaveBeenLastCalledWith('wave-a');
|
||||
expect(layer.group.children[0].visible).toBe(true);
|
||||
expect(layer.transform.getHelper().visible).toBe(true);
|
||||
expect(
|
||||
layer.handlePointerDown({ button: 0, clientX: 2, clientY: 2 } as PointerEvent, bounds),
|
||||
).toBe(false);
|
||||
expect(callbacks.onSelect).toHaveBeenLastCalledWith(null);
|
||||
expect(layer.group.children[0].visible).toBe(false);
|
||||
expect(layer.transform.getHelper().visible).toBe(false);
|
||||
layer.dispose();
|
||||
});
|
||||
|
||||
it('提交参数地形的平面移动和绕 Z 旋转', () => {
|
||||
const { callbacks, layer } = createLayer();
|
||||
layer.setAssets([terrain('wave-a', 'wave')], []);
|
||||
layer.selectObject('wave-a');
|
||||
const root = layer.group.children[0];
|
||||
root.position.set(1.2, -2.3, 0);
|
||||
root.rotation.z = Math.PI / 4;
|
||||
layer.transform.dispatchEvent({ type: 'mouseUp', mode: 'translate' });
|
||||
expect(callbacks.onTransform).toHaveBeenCalledWith('wave-a', [1.2, -2.3], 45);
|
||||
layer.setTransformMode('rotate');
|
||||
expect(layer.transform.showX).toBe(false);
|
||||
expect(layer.transform.showY).toBe(false);
|
||||
expect(layer.transform.showZ).toBe(true);
|
||||
layer.dispose();
|
||||
});
|
||||
|
||||
it('替换预览时释放旧几何与材质', () => {
|
||||
const { layer } = createLayer();
|
||||
layer.setAssets([terrain('wave-a', 'wave')]);
|
||||
const mesh = layer.group.children[0].children[0] as THREE.Mesh;
|
||||
const geometryDispose = vi.spyOn(mesh.geometry, 'dispose');
|
||||
const material = mesh.material as THREE.Material;
|
||||
const materialDispose = vi.spyOn(material, 'dispose');
|
||||
layer.setAssets([]);
|
||||
expect(geometryDispose).toHaveBeenCalledOnce();
|
||||
expect(materialDispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
import * as THREE from 'three';
|
||||
import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js';
|
||||
import type { MapEditorTransformMode } from '../map/editor/types';
|
||||
import { generateSystemTerrain, type GeneratedTerrainBox } from '../map/terrainGenerator';
|
||||
import {
|
||||
generateBuiltinMapBoxes,
|
||||
normalizePhysicalMapConfig,
|
||||
type GeneratedPhysicalMapBox,
|
||||
} from '../map/physicalMap';
|
||||
import { isSystemTerrainPreset, type PlacedMapAsset } from '../map/types';
|
||||
|
||||
type PreviewRole = 'terrain' | 'obstacle' | 'hazard';
|
||||
|
||||
const ROLE_COLORS: Record<PreviewRole, number> = {
|
||||
terrain: 0x65a30d,
|
||||
obstacle: 0xd97706,
|
||||
hazard: 0xdc2626,
|
||||
};
|
||||
|
||||
type PreviewBox = GeneratedTerrainBox | GeneratedPhysicalMapBox;
|
||||
|
||||
function boxRole(item: PreviewBox): PreviewRole {
|
||||
if (/obstacle|stone|rail/i.test(item.name)) return 'obstacle';
|
||||
if (/bottom|near|far|left|right|front|back/i.test(item.name)) return 'hazard';
|
||||
return 'terrain';
|
||||
}
|
||||
|
||||
function previewMaterial(role: PreviewRole): THREE.MeshStandardMaterial {
|
||||
const result = new THREE.MeshStandardMaterial({
|
||||
color: ROLE_COLORS[role],
|
||||
emissive: ROLE_COLORS[role],
|
||||
emissiveIntensity: 0.08,
|
||||
roughness: 0.82,
|
||||
metalness: 0,
|
||||
transparent: true,
|
||||
opacity: 0.58,
|
||||
depthWrite: false,
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: -1,
|
||||
});
|
||||
result.userData.previewColor = ROLE_COLORS[role];
|
||||
return result;
|
||||
}
|
||||
|
||||
function boxesPreview(boxes: readonly PreviewBox[]): THREE.InstancedMesh[] {
|
||||
const byRole = new Map<PreviewRole, PreviewBox[]>();
|
||||
for (const item of boxes) {
|
||||
const role = boxRole(item);
|
||||
const values = byRole.get(role) ?? [];
|
||||
values.push(item);
|
||||
byRole.set(role, values);
|
||||
}
|
||||
const result: THREE.InstancedMesh[] = [];
|
||||
for (const [role, values] of byRole) {
|
||||
const geometry = new THREE.BoxGeometry(1, 1, 1);
|
||||
const mesh = new THREE.InstancedMesh(geometry, previewMaterial(role), values.length);
|
||||
mesh.name = `__platform_parametric_map_preview_${role}__`;
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
mesh.renderOrder = 8;
|
||||
const matrix = new THREE.Matrix4();
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
const item = values[index];
|
||||
const quaternion = 'quaternion' in item ? item.quaternion : undefined;
|
||||
matrix.compose(
|
||||
new THREE.Vector3(...item.position),
|
||||
quaternion
|
||||
? new THREE.Quaternion(quaternion[1], quaternion[2], quaternion[3], quaternion[0])
|
||||
: new THREE.Quaternion(),
|
||||
new THREE.Vector3(...item.size),
|
||||
);
|
||||
mesh.setMatrixAt(index, matrix);
|
||||
}
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
mesh.computeBoundingSphere();
|
||||
result.push(mesh);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function terrainGeometrySignature(asset: PlacedMapAsset): string {
|
||||
if (asset.selection.kind !== 'builtin') return '';
|
||||
const config = normalizePhysicalMapConfig(asset.selection.config);
|
||||
return JSON.stringify({
|
||||
preset: config.preset,
|
||||
size: config.size,
|
||||
slopeAngle: config.slopeAngle,
|
||||
stairCount: config.stairCount,
|
||||
obstacleCount: config.obstacleCount,
|
||||
seed: config.seed,
|
||||
terrainDifficulty: config.terrainDifficulty,
|
||||
terrainHorizontalScale: config.terrainHorizontalScale,
|
||||
terrainVerticalScale: config.terrainVerticalScale,
|
||||
});
|
||||
}
|
||||
|
||||
function heightfieldPreview(
|
||||
width: number,
|
||||
length: number,
|
||||
rowSegments: number,
|
||||
columnSegments: number,
|
||||
heights: readonly number[],
|
||||
): THREE.Mesh {
|
||||
const geometry = new THREE.PlaneGeometry(width, length, columnSegments, rowSegments);
|
||||
const positions = geometry.getAttribute('position') as THREE.BufferAttribute;
|
||||
for (let row = 0; row <= rowSegments; row += 1) {
|
||||
const sourceRow = rowSegments - row;
|
||||
for (let column = 0; column <= columnSegments; column += 1) {
|
||||
const vertex = row * (columnSegments + 1) + column;
|
||||
const source = sourceRow * (columnSegments + 1) + column;
|
||||
positions.setZ(vertex, heights[source] ?? 0);
|
||||
}
|
||||
}
|
||||
positions.needsUpdate = true;
|
||||
geometry.computeVertexNormals();
|
||||
geometry.computeBoundingBox();
|
||||
geometry.computeBoundingSphere();
|
||||
const mesh = new THREE.Mesh(geometry, previewMaterial('terrain'));
|
||||
mesh.name = '__platform_parametric_map_preview_heightfield__';
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
mesh.renderOrder = 8;
|
||||
return mesh;
|
||||
}
|
||||
|
||||
export interface ParametricMapPreviewLayerCallbacks {
|
||||
onSelect(id: string | null): void;
|
||||
onTransform(id: string, position: [number, number], yawDeg: number): void;
|
||||
onDragging(value: boolean): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 程序地图的轻量 Three.js 预览与拾取层。已应用地图保留不可见拾取代理;选中或变为草稿时
|
||||
* 才显示半透明几何,因此移动过程不创建 MuJoCo Model,确认后再一次性编译完整地图栈。
|
||||
*/
|
||||
export class ParametricMapPreviewLayer {
|
||||
readonly group = new THREE.Group();
|
||||
readonly transform: TransformControls;
|
||||
private readonly helper: THREE.Object3D;
|
||||
private readonly raycaster = new THREE.Raycaster();
|
||||
private readonly pointer = new THREE.Vector2();
|
||||
private readonly objects = new Map<string, { geometrySignature: string; root: THREE.Group }>();
|
||||
private previewIds = new Set<string>();
|
||||
private selectedId: string | null = null;
|
||||
|
||||
constructor(
|
||||
scene: THREE.Scene,
|
||||
private readonly camera: THREE.Camera,
|
||||
domElement: HTMLElement,
|
||||
private readonly callbacks: ParametricMapPreviewLayerCallbacks,
|
||||
) {
|
||||
this.group.name = '__platform_parametric_map_preview__';
|
||||
scene.add(this.group);
|
||||
this.transform = new TransformControls(camera, domElement);
|
||||
this.transform.setSpace('world');
|
||||
this.transform.setSize(0.8);
|
||||
this.transform.setTranslationSnap(0.1);
|
||||
this.transform.setRotationSnap(THREE.MathUtils.degToRad(5));
|
||||
this.helper = this.transform.getHelper();
|
||||
this.helper.name = '__platform_parametric_map_transform__';
|
||||
this.helper.visible = false;
|
||||
scene.add(this.helper);
|
||||
this.transform.addEventListener('dragging-changed', (event) => {
|
||||
this.callbacks.onDragging(Boolean(event.value));
|
||||
});
|
||||
this.transform.addEventListener('mouseUp', () => this.commitTransform());
|
||||
this.setTransformMode('translate');
|
||||
}
|
||||
|
||||
get enabled(): boolean {
|
||||
return this.objects.size > 0;
|
||||
}
|
||||
|
||||
setAssets(assets: readonly PlacedMapAsset[], previewIds?: readonly string[]): void {
|
||||
this.previewIds = new Set(previewIds ?? assets.map((asset) => asset.id));
|
||||
const desired = new Map(
|
||||
assets.flatMap((asset) => {
|
||||
if (asset.selection.kind !== 'builtin') return [];
|
||||
const config = normalizePhysicalMapConfig(asset.selection.config);
|
||||
return config.preset === 'none' ? [] : [[asset.id, { asset, config }] as const];
|
||||
}),
|
||||
);
|
||||
|
||||
for (const [id, current] of this.objects) {
|
||||
if (desired.has(id)) continue;
|
||||
if (this.transform.object === current.root) this.transform.detach();
|
||||
this.group.remove(current.root);
|
||||
this.disposeObject(current.root);
|
||||
this.objects.delete(id);
|
||||
}
|
||||
|
||||
for (const [id, { asset, config }] of desired) {
|
||||
const geometrySignature = terrainGeometrySignature(asset);
|
||||
const current = this.objects.get(id);
|
||||
if (current?.geometrySignature === geometrySignature) {
|
||||
current.root.position.set(config.positionX, config.positionY, 0);
|
||||
current.root.rotation.z = THREE.MathUtils.degToRad(config.yawDeg);
|
||||
continue;
|
||||
}
|
||||
if (current) {
|
||||
if (this.transform.object === current.root) this.transform.detach();
|
||||
this.group.remove(current.root);
|
||||
this.disposeObject(current.root);
|
||||
this.objects.delete(id);
|
||||
}
|
||||
const terrain = isSystemTerrainPreset(config.preset)
|
||||
? generateSystemTerrain(config.preset, config)
|
||||
: { boxes: generateBuiltinMapBoxes(config) };
|
||||
const root = new THREE.Group();
|
||||
root.name = `__platform_parametric_map_preview_${id}__`;
|
||||
root.userData.mapAssetId = id;
|
||||
root.position.set(config.positionX, config.positionY, 0);
|
||||
root.rotation.z = THREE.MathUtils.degToRad(config.yawDeg);
|
||||
for (const preview of boxesPreview(terrain.boxes)) root.add(preview);
|
||||
if (terrain.heightfield)
|
||||
root.add(
|
||||
heightfieldPreview(
|
||||
terrain.heightfield.width,
|
||||
terrain.heightfield.length,
|
||||
terrain.heightfield.rowSegments,
|
||||
terrain.heightfield.columnSegments,
|
||||
terrain.heightfield.heights,
|
||||
),
|
||||
);
|
||||
root.traverse((object) => {
|
||||
object.userData.parametricMapAssetId = id;
|
||||
});
|
||||
this.objects.set(id, { geometrySignature, root });
|
||||
this.group.add(root);
|
||||
}
|
||||
|
||||
if (this.selectedId && !this.objects.has(this.selectedId)) this.selectedId = null;
|
||||
this.transform.enabled = this.enabled;
|
||||
this.refreshSelection();
|
||||
}
|
||||
|
||||
setTransformMode(mode: MapEditorTransformMode): void {
|
||||
const nextMode = mode === 'rotate' ? 'rotate' : 'translate';
|
||||
this.transform.setMode(nextMode);
|
||||
const translate = nextMode === 'translate';
|
||||
this.transform.showX = translate;
|
||||
this.transform.showY = translate;
|
||||
this.transform.showZ = !translate;
|
||||
this.transform.showXY = translate;
|
||||
this.transform.showYZ = false;
|
||||
this.transform.showXZ = false;
|
||||
this.transform.showE = false;
|
||||
}
|
||||
|
||||
setSnapping(translation: number | null, rotationDegrees: number | null): void {
|
||||
this.transform.setTranslationSnap(translation);
|
||||
this.transform.setRotationSnap(
|
||||
rotationDegrees === null ? null : THREE.MathUtils.degToRad(rotationDegrees),
|
||||
);
|
||||
}
|
||||
|
||||
selectObject(id: string | null, notify = false): void {
|
||||
this.selectedId = id && this.objects.has(id) ? id : null;
|
||||
this.refreshSelection();
|
||||
if (notify) this.callbacks.onSelect(this.selectedId);
|
||||
}
|
||||
|
||||
/** 命中参数地形或操纵器时消费左键;点击空白区域会取消当前选中。 */
|
||||
handlePointerDown(
|
||||
event: PointerEvent,
|
||||
bounds: DOMRect,
|
||||
maximumDistance = Number.POSITIVE_INFINITY,
|
||||
): boolean {
|
||||
if (!this.enabled || event.button !== 0) return false;
|
||||
if (this.transform.axis || this.transform.dragging) return true;
|
||||
this.pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
|
||||
this.pointer.y = -((event.clientY - bounds.top) / bounds.height) * 2 + 1;
|
||||
this.raycaster.setFromCamera(this.pointer, this.camera);
|
||||
this.group.updateMatrixWorld(true);
|
||||
const hit = this.raycaster.intersectObjects(
|
||||
[...this.objects.values()].map(({ root }) => root),
|
||||
true,
|
||||
)[0];
|
||||
const id = hit?.object.userData.parametricMapAssetId;
|
||||
if (typeof id === 'string' && hit.distance <= maximumDistance + 1e-3) {
|
||||
this.selectObject(id, true);
|
||||
return true;
|
||||
}
|
||||
if (this.selectedId) this.selectObject(null, true);
|
||||
return false;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.transform.detach();
|
||||
this.helper.visible = false;
|
||||
this.selectedId = null;
|
||||
this.previewIds.clear();
|
||||
for (const { root } of this.objects.values()) this.disposeObject(root);
|
||||
this.objects.clear();
|
||||
this.group.clear();
|
||||
this.transform.enabled = false;
|
||||
this.callbacks.onDragging(false);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.clear();
|
||||
this.transform.dispose();
|
||||
this.helper.removeFromParent();
|
||||
this.group.removeFromParent();
|
||||
}
|
||||
|
||||
private refreshSelection(): void {
|
||||
const selected = this.selectedId ? this.objects.get(this.selectedId)?.root : undefined;
|
||||
if (this.transform.object !== selected) {
|
||||
this.transform.detach();
|
||||
if (selected) this.transform.attach(selected);
|
||||
}
|
||||
this.helper.visible = Boolean(selected);
|
||||
for (const [id, { root }] of this.objects) {
|
||||
const active = id === this.selectedId;
|
||||
root.visible = active || this.previewIds.has(id);
|
||||
root.traverse((object) => {
|
||||
if (!(object instanceof THREE.Mesh)) return;
|
||||
const materials = Array.isArray(object.material) ? object.material : [object.material];
|
||||
for (const material of materials) {
|
||||
if (!(material instanceof THREE.MeshStandardMaterial)) continue;
|
||||
const baseColor = Number(material.userData.previewColor ?? 0x65a30d);
|
||||
material.emissive.setHex(active ? 0x2563eb : baseColor);
|
||||
material.emissiveIntensity = active ? 0.4 : 0.08;
|
||||
material.opacity = active ? 0.78 : 0.58;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private commitTransform(): void {
|
||||
if (!this.selectedId) return;
|
||||
const root = this.objects.get(this.selectedId)?.root;
|
||||
if (!root) return;
|
||||
this.callbacks.onTransform(
|
||||
this.selectedId,
|
||||
[root.position.x, root.position.y],
|
||||
THREE.MathUtils.radToDeg(root.rotation.z),
|
||||
);
|
||||
}
|
||||
|
||||
private disposeObject(root: THREE.Object3D): void {
|
||||
const geometries = new Set<THREE.BufferGeometry>();
|
||||
const materials = new Set<THREE.Material>();
|
||||
root.traverse((object) => {
|
||||
if (!(object instanceof THREE.Mesh)) return;
|
||||
geometries.add(object.geometry);
|
||||
for (const material of Array.isArray(object.material) ? object.material : [object.material])
|
||||
materials.add(material);
|
||||
});
|
||||
for (const geometry of geometries) geometry.dispose();
|
||||
for (const material of materials) material.dispose();
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,9 @@ function asset(data: Uint8Array): VisualMapAsset {
|
||||
data,
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
positionX: 0,
|
||||
positionY: 0,
|
||||
yawDeg: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,20 +54,66 @@ describe('validateSelfContainedGlb', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('同时加载多个视觉地图实例', async () => {
|
||||
const scene = new THREE.Scene();
|
||||
const layer = new VisualMapLayer(scene);
|
||||
const data = glb({ asset: { version: '2.0' }, scenes: [{}], scene: 0, nodes: [] });
|
||||
await layer.loadMany([
|
||||
{ ...asset(data), id: 'map-a', name: '地图 A' },
|
||||
{
|
||||
...asset(data),
|
||||
id: 'map-b',
|
||||
name: '地图 B',
|
||||
positionX: 2,
|
||||
positionY: -3,
|
||||
yawDeg: 90,
|
||||
},
|
||||
]);
|
||||
expect(layer.group.children.map((child) => child.name)).toEqual([
|
||||
'__platform_visual_map_map-a__',
|
||||
'__platform_visual_map_map-b__',
|
||||
]);
|
||||
expect(layer.group.children[1].position.toArray()).toEqual([2, -3, 0]);
|
||||
expect(layer.group.children[1].rotation.z).toBeCloseTo(Math.PI / 2);
|
||||
layer.setTransforms([
|
||||
{ ...asset(data), id: 'map-b', name: '地图 B', positionX: 4, positionY: 5, yawDeg: -45 },
|
||||
]);
|
||||
expect(layer.group.children[1].position.toArray()).toEqual([4, 5, 0]);
|
||||
expect(layer.group.children[1].rotation.z).toBeCloseTo(-Math.PI / 4);
|
||||
layer.dispose();
|
||||
});
|
||||
|
||||
it('清理共享 Three.js 资源并关闭 ImageBitmap', () => {
|
||||
const scene = new THREE.Scene();
|
||||
const layer = new VisualMapLayer(scene);
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
const lineGeometry = new THREE.BufferGeometry();
|
||||
const pointsGeometry = new THREE.BufferGeometry();
|
||||
const close = vi.fn();
|
||||
const texture = new THREE.Texture({ close } as unknown as TexImageSource);
|
||||
const material = new THREE.MeshStandardMaterial({ map: texture });
|
||||
const lineMaterial = new THREE.LineBasicMaterial();
|
||||
const pointsMaterial = new THREE.PointsMaterial();
|
||||
const geometryDispose = vi.spyOn(geometry, 'dispose');
|
||||
const lineGeometryDispose = vi.spyOn(lineGeometry, 'dispose');
|
||||
const pointsGeometryDispose = vi.spyOn(pointsGeometry, 'dispose');
|
||||
const materialDispose = vi.spyOn(material, 'dispose');
|
||||
const lineMaterialDispose = vi.spyOn(lineMaterial, 'dispose');
|
||||
const pointsMaterialDispose = vi.spyOn(pointsMaterial, 'dispose');
|
||||
const textureDispose = vi.spyOn(texture, 'dispose');
|
||||
layer.group.add(new THREE.Mesh(geometry, material), new THREE.Mesh(geometry, material));
|
||||
layer.group.add(
|
||||
new THREE.Mesh(geometry, material),
|
||||
new THREE.Mesh(geometry, material),
|
||||
new THREE.LineSegments(lineGeometry, lineMaterial),
|
||||
new THREE.Points(pointsGeometry, pointsMaterial),
|
||||
);
|
||||
layer.clear();
|
||||
expect(geometryDispose).toHaveBeenCalledOnce();
|
||||
expect(lineGeometryDispose).toHaveBeenCalledOnce();
|
||||
expect(pointsGeometryDispose).toHaveBeenCalledOnce();
|
||||
expect(materialDispose).toHaveBeenCalledOnce();
|
||||
expect(lineMaterialDispose).toHaveBeenCalledOnce();
|
||||
expect(pointsMaterialDispose).toHaveBeenCalledOnce();
|
||||
expect(textureDispose).toHaveBeenCalledOnce();
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
layer.dispose();
|
||||
|
||||
@@ -62,40 +62,73 @@ export class VisualMapLayer {
|
||||
}
|
||||
|
||||
async load(asset: VisualMapAsset | null): Promise<void> {
|
||||
await this.loadMany(asset ? [asset] : []);
|
||||
}
|
||||
|
||||
setTransforms(assets: readonly VisualMapAsset[]): void {
|
||||
const byId = new Map(assets.map((asset) => [asset.id, asset]));
|
||||
for (const child of this.group.children) {
|
||||
const id = child.userData.visualMapAssetId;
|
||||
const asset = typeof id === 'string' ? byId.get(id) : undefined;
|
||||
if (!asset) continue;
|
||||
child.position.set(asset.positionX, asset.positionY, 0);
|
||||
child.rotation.z = THREE.MathUtils.degToRad(asset.yawDeg);
|
||||
}
|
||||
}
|
||||
|
||||
async loadMany(assets: readonly VisualMapAsset[]): Promise<void> {
|
||||
const generation = ++this.generation;
|
||||
if (!asset) {
|
||||
this.clear();
|
||||
if (!assets.length) {
|
||||
this.clear(false);
|
||||
return;
|
||||
}
|
||||
validateSelfContainedGlb(asset);
|
||||
const gltf = await new GLTFLoader().parseAsync(exactArrayBuffer(asset.data), '');
|
||||
const scenes: THREE.Object3D[] = [];
|
||||
try {
|
||||
for (const asset of assets) {
|
||||
validateSelfContainedGlb(asset);
|
||||
const gltf = await new GLTFLoader().parseAsync(exactArrayBuffer(asset.data), '');
|
||||
if (generation !== this.generation) {
|
||||
this.disposeObjects([...scenes, gltf.scene]);
|
||||
return;
|
||||
}
|
||||
let triangles = 0;
|
||||
gltf.scene.traverse((object) => {
|
||||
if (!(object instanceof THREE.Mesh)) return;
|
||||
triangles +=
|
||||
(object.geometry.index?.count ?? object.geometry.attributes.position?.count ?? 0) / 3;
|
||||
});
|
||||
if (triangles > 3_000_000) {
|
||||
this.disposeObjects([gltf.scene]);
|
||||
throw new Error(
|
||||
`${asset.path} 的 GLB 三角面数量超过 300 万限制:${Math.round(triangles).toLocaleString()}`,
|
||||
);
|
||||
}
|
||||
if (triangles > 1_000_000)
|
||||
console.warn(
|
||||
`[MuJoCo] 视觉地图 ${asset.name} 包含 ${Math.round(triangles).toLocaleString()} 个三角面,可能影响帧率`,
|
||||
);
|
||||
gltf.scene.name = `__platform_visual_map_${asset.id}__`;
|
||||
gltf.scene.userData.visualMapAssetId = asset.id;
|
||||
gltf.scene.position.set(asset.positionX, asset.positionY, 0);
|
||||
gltf.scene.rotation.z = THREE.MathUtils.degToRad(asset.yawDeg);
|
||||
gltf.scene.traverse((object) => {
|
||||
if (!(object instanceof THREE.Mesh)) return;
|
||||
object.castShadow = asset.castShadow;
|
||||
object.receiveShadow = asset.receiveShadow;
|
||||
object.userData.platformVisualMap = true;
|
||||
});
|
||||
scenes.push(gltf.scene);
|
||||
}
|
||||
} catch (error) {
|
||||
this.disposeObjects(scenes);
|
||||
throw error;
|
||||
}
|
||||
if (generation !== this.generation) {
|
||||
this.disposeObjects([gltf.scene]);
|
||||
this.disposeObjects(scenes);
|
||||
return;
|
||||
}
|
||||
let triangles = 0;
|
||||
gltf.scene.traverse((object) => {
|
||||
if (!(object instanceof THREE.Mesh)) return;
|
||||
triangles +=
|
||||
(object.geometry.index?.count ?? object.geometry.attributes.position?.count ?? 0) / 3;
|
||||
});
|
||||
if (triangles > 3_000_000) {
|
||||
this.disposeObjects([gltf.scene]);
|
||||
throw new Error(`GLB 三角面数量超过 300 万限制:${Math.round(triangles).toLocaleString()}`);
|
||||
}
|
||||
if (triangles > 1_000_000)
|
||||
console.warn(
|
||||
`[MuJoCo] 视觉地图包含 ${Math.round(triangles).toLocaleString()} 个三角面,可能影响帧率`,
|
||||
);
|
||||
gltf.scene.name = `__platform_visual_map_${asset.id}__`;
|
||||
gltf.scene.traverse((object) => {
|
||||
if (!(object instanceof THREE.Mesh)) return;
|
||||
object.castShadow = asset.castShadow;
|
||||
object.receiveShadow = asset.receiveShadow;
|
||||
object.userData.platformVisualMap = true;
|
||||
});
|
||||
this.clear(false);
|
||||
this.group.add(gltf.scene);
|
||||
this.group.add(...scenes);
|
||||
}
|
||||
|
||||
clear(invalidate = true): void {
|
||||
@@ -116,27 +149,54 @@ export class VisualMapLayer {
|
||||
const textures = new Set<THREE.Texture>();
|
||||
for (const root of roots)
|
||||
root.traverse((object) => {
|
||||
if (!(object instanceof THREE.Mesh)) return;
|
||||
geometries.add(object.geometry);
|
||||
for (const material of Array.isArray(object.material) ? object.material : [object.material])
|
||||
materials.add(material);
|
||||
const renderable = object as THREE.Object3D & {
|
||||
geometry?: THREE.BufferGeometry;
|
||||
material?: THREE.Material | THREE.Material[];
|
||||
};
|
||||
if (renderable.geometry instanceof THREE.BufferGeometry)
|
||||
geometries.add(renderable.geometry);
|
||||
if (!renderable.material) return;
|
||||
for (const material of Array.isArray(renderable.material)
|
||||
? renderable.material
|
||||
: [renderable.material])
|
||||
if (material instanceof THREE.Material) materials.add(material);
|
||||
});
|
||||
for (const material of materials)
|
||||
for (const value of Object.values(material))
|
||||
if (value instanceof THREE.Texture) textures.add(value);
|
||||
const closedImages = new Set<object>();
|
||||
for (const geometry of geometries) geometry.dispose();
|
||||
for (const geometry of geometries) {
|
||||
try {
|
||||
geometry.dispose();
|
||||
} catch (error) {
|
||||
console.warn('[MuJoCo] 释放视觉地图几何失败', error);
|
||||
}
|
||||
}
|
||||
for (const texture of textures) {
|
||||
const source = texture.source.data;
|
||||
const images = Array.isArray(source) ? source : [source];
|
||||
texture.dispose();
|
||||
try {
|
||||
texture.dispose();
|
||||
} catch (error) {
|
||||
console.warn('[MuJoCo] 释放视觉地图纹理失败', error);
|
||||
}
|
||||
for (const image of images) {
|
||||
if (!image || typeof image !== 'object' || closedImages.has(image)) continue;
|
||||
closedImages.add(image);
|
||||
(image as { close?: () => void }).close?.();
|
||||
try {
|
||||
(image as { close?: () => void }).close?.();
|
||||
} catch (error) {
|
||||
console.warn('[MuJoCo] 关闭视觉地图图像失败', error);
|
||||
}
|
||||
}
|
||||
texture.source.data = null;
|
||||
}
|
||||
for (const material of materials) material.dispose();
|
||||
for (const material of materials) {
|
||||
try {
|
||||
material.dispose();
|
||||
} catch (error) {
|
||||
console.warn('[MuJoCo] 释放视觉地图材质失败', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user