feat(web-platform): release V0.8.1 常规地图优化
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled

This commit is contained in:
2026-09-03 13:18:35 +08:00
parent deead17a9a
commit a9b07e0abf
49 changed files with 4987 additions and 569 deletions
+2 -2
View File
@@ -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”。
+256 -8
View File
@@ -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('位置 Xm').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('位置 Xm').fill('3');
await page.getByLabel('位置 Ym').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('地形难度(01').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 }) => {
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}
/>
),
+227 -3
View File
@@ -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);
});
});
+230 -27
View File
@@ -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" />
+33
View File
@@ -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>',
+60 -9
View File
@@ -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')))
+71 -3
View File
@@ -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
+151 -53
View File
@@ -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);
}}
>
+42 -1
View File
@@ -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),
+20 -1
View File
@@ -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();
});
});
+68
View File
@@ -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>
);
}
+118 -11
View File
@@ -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('地形难度(01'), { 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('位置 Xm')).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('位置 Xm'), { target: { value: '2.5' } });
fireEvent.change(screen.getByLabelText('位置 Ym'), { 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('位置 Xm'), { target: { value: '2.5' } });
fireEvent.change(screen.getByLabelText('位置 Ym'), { 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 模式只允许移除地图', () => {
+176 -15
View File
@@ -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="位置 Xm"
value={draft.positionX ?? 0}
min={-100}
max={100}
step={0.1}
disabled={loading}
onChange={(positionX) => replaceProject({ ...draft, positionX })}
/>
<NumberField
label="位置 Ym"
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),
};
}
+20 -7
View File
@@ -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 {
+45 -2
View File
@@ -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]);
+6 -3
View File
@@ -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)];
}
+2 -1
View File
@@ -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;
}
+124
View File
@@ -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);
});
});
+121
View File
@@ -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 });
}
+30
View File
@@ -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>',
+145 -73
View File
@@ -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 {
+56
View File
@@ -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);
});
});
+133
View File
@@ -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;
}
+78
View File
@@ -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,
]);
});
});
+96 -1
View File
@@ -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();
});
});
+44 -12
View File
@@ -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());
}
}
+71 -1
View File
@@ -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();
});
+95 -24
View File
@@ -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();
}
}
+108 -16
View File
@@ -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();
}
}
+50 -1
View File
@@ -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();
+95 -35
View File
@@ -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);
}
}
}
}