feat(web-platform): release V0.7 地图模块
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-01 14:23:49 +08:00
parent 60d3a6d68c
commit 3b4ced6437
58 changed files with 7287 additions and 269 deletions
+1
View File
@@ -7,6 +7,7 @@ playwright-report/
web_platform/test-results/ web_platform/test-results/
web_platform/playwright-report/ web_platform/playwright-report/
web_platform/node_modules/.vite/ web_platform/node_modules/.vite/
.playwright-cli/
*.tsbuildinfo *.tsbuildinfo
# Python # Python
+1
View File
@@ -9,6 +9,7 @@ web-platform-dist/
coverage/ coverage/
playwright-report/ playwright-report/
test-results/ test-results/
.playwright-cli/
web_platform/fixtures/ web_platform/fixtures/
web_platform/public/ web_platform/public/
package-lock.json package-lock.json
+3
View File
@@ -10,6 +10,9 @@
- ROS `package://`、常见 URDF 兼容转换及 DAE 降级处理 - ROS `package://`、常见 URDF 兼容转换及 DAE 降级处理
- Three.js 模型、碰撞体、坐标系、关节轴、质心和惯量可视化 - Three.js 模型、碰撞体、坐标系、关节轴、质心和惯量可视化
- 播放、暂停、单步、重置、变速、关节拖动与外力交互 - 播放、暂停、单步、重置、变速、关节拖动与外力交互
- 内置平地、坡道、楼梯、随机障碍物及 9 类系统参数化地形(粗糙/波浪、金字塔阶梯、深坑、沟壑等)
- 工程地图包:静态 MJCF/OBJ/STL/高度场碰撞层、GLB 视觉层和机器人出生点
- V3 地图创作层:认证资产库支持点击添加、拖到画布落位和首个资产自动创建场景,并可通过表单与视口操纵器继续编辑、事务式应用及导出地图 ZIP
- 浏览器内 Python 控制器(Pyodide - 浏览器内 Python 控制器(Pyodide
- ONNX 强化学习策略推理(ONNX Runtime Web - ONNX 强化学习策略推理(ONNX Runtime Web
- 可选的本机 mjlab 训练桥接服务 - 可选的本机 mjlab 训练桥接服务
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mujoco-web-platform", "name": "mujoco-web-platform",
"version": "0.6.1", "version": "0.7.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mujoco-web-platform", "name": "mujoco-web-platform",
"version": "0.6.1", "version": "0.7.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mujoco-web-platform", "name": "mujoco-web-platform",
"version": "0.6.1", "version": "0.7.0",
"description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台", "description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台",
"private": true, "private": true,
"type": "module", "type": "module",
+36
View File
@@ -12,6 +12,7 @@
- Three.js primitive、mesh、材质/贴图显示与对象选择 - Three.js primitive、mesh、材质/贴图显示与对象选择
- 播放、暂停、单步、重置、0.25×–4× 速度 - 播放、暂停、单步、重置、0.25×–4× 速度
- actuator 滑杆、hinge/slide 关节拖动、动态 body 外力拖拽 - actuator 滑杆、hinge/slide 关节拖动、动态 body 外力拖拽
- 内置平地、坡道、楼梯、可复现随机障碍物及 9 类系统参数化地形,可配置尺寸、摩擦、难度、种子与高度场采样精度
- 导入单文件 `.py` 控制器,通过本地 Pyodide 在 `mj_step` 前按仿真时间同步执行 - 导入单文件 `.py` 控制器,通过本地 Pyodide 在 `mj_step` 前按仿真时间同步执行
- 导入 mjlab 导出的 `policy.onnx`,在浏览器本地执行 Go2-W 平衡/速度策略推理 - 导入 mjlab 导出的 `policy.onnx`,在浏览器本地执行 Go2-W 平衡/速度策略推理
- 从图形界面向本机训练桥接服务发起 mjlab 强化学习训练、查看进度/日志、停止任务并导入训练生成的 ONNX - 从图形界面向本机训练桥接服务发起 mjlab 强化学习训练、查看进度/日志、停止任务并导入训练生成的 ONNX
@@ -61,6 +62,41 @@ python3 -m http.server 8080 --directory web-platform-dist
- 默认限制:2000 个文件、单文件 128 MiB、总解压大小 512 MiB、ZIP 文件 128 MiB。 - 默认限制:2000 个文件、单文件 128 MiB、总解压大小 512 MiB、ZIP 文件 128 MiB。
- 文件夹或 ZIP 中的 `.py` 会显示在“控制 → Python 控制器”;也可以在加载模型后单独导入不超过 1 MiB 的 `.py` - 文件夹或 ZIP 中的 `.py` 会显示在“控制 → Python 控制器”;也可以在加载模型后单独导入不超过 1 MiB 的 `.py`
## 地图模块
模型加载后打开右侧“地图”标签,可以选择平地、坡道、楼梯、随机障碍物、系统参数化地形或工程内地图包。参数化地形包括离散障碍、沟壑、倒金字塔阶梯、深坑、金字塔阶梯、轨道、随机粗糙、踏石和波浪地形;相同参数与随机种子会确定性生成相同碰撞层。点击“应用并重新编译”后,平台会在当前入口同目录生成临时组合 MJCF;原始工程文件不会被修改。模型编译失败时保留上一个可用仿真会话。
工程地图由 `map.json`、静态 MJCF 碰撞层和可选的自包含 GLB 视觉层组成:
```text
maps/warehouse/
├── map.json
├── physics/world.xml
├── physics/meshes/*.obj
├── visuals/scene.glb
└── authoring/map.scene.json # V3 可选创作层
```
最小描述示例:
```json
{
"schemaVersion": 1,
"id": "warehouse",
"name": "仓库",
"coordinateSystem": { "units": "m", "up": "Z", "forward": "+X" },
"physics": { "source": "physics/world.xml" },
"visual": { "source": "visuals/scene.glb" },
"spawnPoints": [{ "id": "main", "name": "主入口", "position": [0, 0, 0.35], "yawDeg": 0 }]
}
```
物理地图仅允许静态 `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 描述和确定性物理层;任何不可逆语义都会导致整体拒绝,不会静默丢失内容。
物理地图超过 2000 个 geom 会产生性能警告,超过 10000 个会被拒绝;GLB 超过 100 万三角面会警告,超过 300 万会被拒绝。当前原生 URDF 模式不支持地图,请切换到“转换为 MJCF”。
## Python 控制器 ## Python 控制器
Python 控制器是可信的单文件脚本,必须同步定义 `step(ctx, state)`;可选定义 `NAME``CONTROL_HZ`(限制为 1500 Hz)、`init(api)``command(name, state)``reset(state)``dispose(state)``init` 可用 `api.joint(name)``api.actuator(name)``api.sensor(name)``api.body(name)` 预解析 ID`step` 可用 `ctx.qpos(id)``ctx.qvel(id)``ctx.sensor(id)``ctx.body_quat(id)``ctx.body_position(id)` 读取状态,并用 `ctx.set_control(id, value)` 写入经过有限值检查和 actuator 限幅的控制量。定义 `command` 后,界面会显示停止、前进、后退、左转、右转和起跳按钮,并分别传入 `stop``forward``backward``turn_left``turn_right``jump`。所有回调都必须同步;异常会自动停止控制器或显示诊断,运行期异常还会暂停仿真并清零 `ctrl` Python 控制器是可信的单文件脚本,必须同步定义 `step(ctx, state)`;可选定义 `NAME``CONTROL_HZ`(限制为 1500 Hz)、`init(api)``command(name, state)``reset(state)``dispose(state)``init` 可用 `api.joint(name)``api.actuator(name)``api.sensor(name)``api.body(name)` 预解析 ID`step` 可用 `ctx.qpos(id)``ctx.qvel(id)``ctx.sensor(id)``ctx.body_quat(id)``ctx.body_position(id)` 读取状态,并用 `ctx.set_control(id, value)` 写入经过有限值检查和 actuator 限幅的控制量。定义 `command` 后,界面会显示停止、前进、后退、左转、右转和起跳按钮,并分别传入 `stop``forward``backward``turn_left``turn_right``jump`。所有回调都必须同步;异常会自动停止控制器或显示诊断,运行期异常还会暂停仿真并清零 `ctrl`
+318
View File
@@ -22,6 +22,46 @@ const SIMPLE_MODEL = `
const SLIDE_DIRECTION_MODEL = `<mujoco model="drag-direction"><worldbody><body name="slider" pos="0 0 1"><joint name="screen_x" type="slide" axis="1 0 0" range="-2 2"/><geom type="box" size=".25 .25 .25" mass="1"/></body></worldbody></mujoco>`; const SLIDE_DIRECTION_MODEL = `<mujoco model="drag-direction"><worldbody><body name="slider" pos="0 0 1"><joint name="screen_x" type="slide" axis="1 0 0" range="-2 2"/><geom type="box" size=".25 .25 .25" mass="1"/></body></worldbody></mujoco>`;
function minimalGlb(): Buffer {
const json = Buffer.from(
JSON.stringify({
asset: { version: '2.0' },
scene: 0,
scenes: [{ nodes: [0] }],
nodes: [{ mesh: 0 }],
meshes: [{ primitives: [{ attributes: { POSITION: 0 } }] }],
buffers: [{ byteLength: 36 }],
bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: 36, target: 34962 }],
accessors: [
{
bufferView: 0,
componentType: 5126,
count: 3,
type: 'VEC3',
min: [0, 0, 0],
max: [1, 1, 0],
},
],
}),
);
const jsonPadding = (4 - (json.length % 4)) % 4;
const jsonChunk = Buffer.concat([json, Buffer.alloc(jsonPadding, 0x20)]);
const positions = Buffer.from(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]).buffer);
const totalLength = 12 + 8 + jsonChunk.length + 8 + positions.length;
const glb = Buffer.alloc(totalLength);
glb.writeUInt32LE(0x46546c67, 0);
glb.writeUInt32LE(2, 4);
glb.writeUInt32LE(totalLength, 8);
glb.writeUInt32LE(jsonChunk.length, 12);
glb.writeUInt32LE(0x4e4f534a, 16);
jsonChunk.copy(glb, 20);
const binOffset = 20 + jsonChunk.length;
glb.writeUInt32LE(positions.length, binOffset);
glb.writeUInt32LE(0x004e4942, binOffset + 4);
positions.copy(glb, binOffset + 8);
return glb;
}
const LARGE_MODEL = ` const LARGE_MODEL = `
<mujoco model="soak"> <mujoco model="soak">
<option timestep=".005"/> <option timestep=".005"/>
@@ -420,6 +460,284 @@ test('中等规模模型持续步进并可重复加载', async ({ page }) => {
await expect(page.getByRole('alert')).toHaveCount(0); await expect(page.getByRole('alert')).toHaveCount(0);
}); });
test('认证资产可点击创建场景并拖到画布落位', async ({ page }) => {
await page.goto('/');
await page
.locator('input[type="file"]')
.first()
.setInputFiles({
name: '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 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.getByLabel('地图对象列表').getByText('基础方盒 · 方盒')).toBeVisible();
await page.locator('[data-map-asset="ramp"]').dragTo(page.locator('main canvas'));
await expect(page.getByLabel('地图对象列表').getByText('标准坡道 · 坡道')).toBeVisible();
await expect(page.getByText('地图草稿尚未应用')).toBeVisible();
await page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒').click();
await page.getByLabel('对象放置方式').selectOption('locked');
await expect(page.getByLabel('对象位置X')).toBeDisabled();
await page.getByLabel('对象放置方式').selectOption('auto_ground');
await expect(page.getByLabel('对象位置X')).toBeEnabled();
const gizmoLine = page.getByRole('img', { name: 'XYZ 方向指示器' }).locator('line').first();
const beforeRotation = await gizmoLine.getAttribute('x2');
const canvasBox = await page.locator('main canvas').first().boundingBox();
expect(canvasBox).not.toBeNull();
await page.mouse.move(canvasBox!.x + 24, canvasBox!.y + 24);
await page.mouse.down({ button: 'left' });
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);
});
test('应用内置 MJCF 楼梯物理地图', async ({ page }) => {
await page.goto('/');
await page.setViewportSize({ width: 1440, height: 900 });
await page
.locator('input[type="file"]')
.first()
.setInputFiles({
name: 'map-model.xml',
mimeType: 'text/xml',
buffer: Buffer.from(SIMPLE_MODEL),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
await page.getByLabel('地图来源').selectOption('builtin');
await page.getByLabel('物理地图预设').selectOption('stairs');
await page.getByLabel('台阶数量').fill('6');
await page.getByRole('button', { name: '应用并重新编译' }).click();
await expect(page.getByText(/已加载楼梯物理地图/)).toBeVisible({ timeout: 30_000 });
await expect(page.getByText('WASM 已加载')).toBeVisible();
});
test('依次应用全部系统参数化地形', async ({ page }) => {
const terrains = [
['discrete_obstacles', '离散障碍地形'],
['gap', '沟壑地形'],
['inverted_pyramid_stairs', '倒金字塔阶梯'],
['pit', '深坑地形'],
['pyramid_stairs', '金字塔阶梯'],
['rails', '轨道地形'],
['rough', '随机粗糙地形'],
['stepping_stones', '踏石地形'],
['wave', '波浪地形'],
] as const;
await page.goto('/');
await page.setViewportSize({ width: 1440, height: 900 });
await page
.locator('input[type="file"]')
.first()
.setInputFiles({
name: 'terrain-model.xml',
mimeType: 'text/xml',
buffer: Buffer.from(SIMPLE_MODEL),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
await page.getByLabel('地图来源').selectOption('builtin');
for (const [preset, label] of terrains) {
await page.getByLabel('物理地图预设').selectOption(preset);
await page.getByLabel('地形边长(m').fill('6');
if (preset === 'rough' || preset === 'wave')
await page.getByLabel('水平采样间距(m').fill('0.25');
await page.getByRole('button', { name: '应用并重新编译' }).click();
await expect(page.getByText(new RegExp(`已加载${label}物理地图`))).toBeVisible({
timeout: 30_000,
});
}
await expect(page.getByText('WASM 已加载')).toBeVisible();
});
test('导入并应用分层工程地图包', async ({ page }) => {
const mapJson = JSON.stringify({
schemaVersion: 1,
id: 'test-room',
name: '测试场景',
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
physics: { source: 'physics/world.xml' },
visual: { source: 'visuals/scene.glb' },
spawnPoints: [{ id: 'start', name: '起点', position: [2, 0, 0.5], yawDeg: 0 }],
});
const project = zipSync({
'model.xml': Buffer.from(SIMPLE_MODEL),
'maps/test/map.json': Buffer.from(mapJson),
'maps/test/physics/world.xml': Buffer.from(
'<mujoco><worldbody><geom name="floor" type="plane" size="8 8 .1"/><geom name="wall" type="box" pos="3 0 .5" size=".1 2 .5"/></worldbody></mujoco>',
),
'maps/test/visuals/scene.glb': minimalGlb(),
});
await page.goto('/');
await page.setViewportSize({ width: 1440, height: 900 });
await page
.locator('input[type="file"]')
.first()
.setInputFiles({
name: 'map-project.zip',
mimeType: 'application/zip',
buffer: Buffer.from(project),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
await page.getByLabel('地图来源').selectOption({ label: '测试场景' });
await expect(page.getByLabel('地图出生点')).toHaveValue('start');
await page.getByRole('button', { name: '应用并重新编译' }).click();
await expect(page.getByText(/已加载工程地图“测试场景”/)).toBeVisible({ timeout: 30_000 });
await expect(page.getByText(/视觉地图加载失败/)).toHaveCount(0);
});
test('将受支持的只读物理地图转换为可编辑副本', async ({ page }) => {
const mapJson = JSON.stringify({
schemaVersion: 1,
id: 'legacy-room',
name: '旧版基础场景',
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
physics: { source: 'physics/world.xml' },
spawnPoints: [{ id: 'start', name: '入口', position: [0, 0, 0], yawDeg: 0 }],
});
const project = zipSync({
'model.xml': Buffer.from(SIMPLE_MODEL),
'maps/legacy/map.json': Buffer.from(mapJson),
'maps/legacy/physics/world.xml': Buffer.from(
'<mujoco><worldbody><geom name="floor" type="box" pos="0 0 -.1" size="5 5 .1"/><body name="wall" pos="2 0 .5"><geom type="box" size=".1 1 .5"/></body></worldbody></mujoco>',
),
});
await page.goto('/');
await page.setViewportSize({ width: 1440, height: 900 });
await page
.locator('input[type="file"]')
.first()
.setInputFiles({
name: 'legacy-map.zip',
mimeType: 'application/zip',
buffer: Buffer.from(project),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
await page.getByLabel('地图来源').selectOption({ label: '旧版基础场景' });
await page.getByRole('button', { name: '应用并重新编译' }).click();
const editor = page.getByText('V3 地图编辑器').locator('..');
await expect(editor.getByText(/保持只读/)).toBeVisible({ timeout: 30_000 });
await editor.getByRole('button', { name: '创建可编辑副本' }).click();
await expect(page.getByText('已创建可编辑地图副本')).toBeVisible({ timeout: 30_000 });
await expect(editor.getByRole('button', { name: '移动工具 W' })).toBeVisible();
await expect(editor.getByRole('button', { name: /floor · 方盒/ })).toBeVisible();
await expect(editor.getByRole('button', { name: /wall · 方盒/ })).toBeVisible();
});
test('编辑 V3 地图对象并事务式应用', async ({ page }) => {
const authoring = JSON.stringify({
schemaVersion: 1,
mapId: 'editable-room',
revision: 0,
objects: [],
spawnPoints: [],
});
const mapJson = JSON.stringify({
schemaVersion: 2,
id: 'editable-room',
name: '可编辑场景',
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
physics: { source: 'physics/world.xml' },
authoring: { source: 'authoring/map.scene.json' },
spawnPoints: [],
});
const project = zipSync({
'model.xml': Buffer.from(SIMPLE_MODEL),
'maps/edit/map.json': Buffer.from(mapJson),
'maps/edit/physics/world.xml': Buffer.from('<mujoco><worldbody/></mujoco>'),
'maps/edit/authoring/map.scene.json': Buffer.from(authoring),
});
await page.goto('/');
await page.setViewportSize({ width: 1440, height: 900 });
await page
.locator('input[type="file"]')
.first()
.setInputFiles({
name: 'editable-map.zip',
mimeType: 'application/zip',
buffer: Buffer.from(project),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
await page.getByLabel('地图来源').selectOption({ label: '可编辑场景' });
await page.getByRole('button', { name: '应用并重新编译' }).click();
await expect(page.getByText('V3 地图编辑器')).toBeVisible({ timeout: 30_000 });
const editor = page.getByText('V3 地图编辑器').locator('..');
await expect(editor.getByRole('button', { name: '移动工具 W' })).toHaveAttribute(
'aria-pressed',
'true',
);
await page.keyboard.press('e');
await expect(editor.getByRole('button', { name: '旋转工具 E' })).toHaveAttribute(
'aria-pressed',
'true',
);
await page.keyboard.press('s');
await expect(editor.getByRole('button', { name: '缩放工具 S' })).toHaveAttribute(
'aria-pressed',
'true',
);
await editor.getByRole('button', { name: '新增', exact: true }).click();
await editor.getByLabel('对象位置X').fill('2');
await editor.getByRole('button', { name: '应用并重新编译' }).click();
await expect(editor.getByRole('button', { name: /box · 方盒/ })).toBeVisible({ timeout: 30_000 });
await expect(editor.getByText('地图草稿尚未应用')).toHaveCount(0);
await expect(page.getByText('WASM 已加载')).toBeVisible();
});
test('工程地图编译失败时保留上一仿真会话', async ({ page }) => {
const mapJson = JSON.stringify({
schemaVersion: 1,
id: 'invalid-map',
name: '动态错误地图',
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
physics: { source: 'world.xml' },
spawnPoints: [],
});
const project = zipSync({
'model.xml': Buffer.from(SIMPLE_MODEL),
'maps/invalid/map.json': Buffer.from(mapJson),
'maps/invalid/world.xml': Buffer.from(
'<mujoco><worldbody><body><joint/><geom type="box" size="1 1 1"/></body></worldbody></mujoco>',
),
});
await page.goto('/');
await page.setViewportSize({ width: 1440, height: 900 });
await page
.locator('input[type="file"]')
.first()
.setInputFiles({
name: 'invalid-map.zip',
mimeType: 'application/zip',
buffer: Buffer.from(project),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
await page.getByLabel('地图来源').selectOption({ label: '动态错误地图' });
await page.getByRole('button', { name: '应用并重新编译' }).click();
await expect(page.getByRole('alert')).toContainText('模型编译失败', { timeout: 30_000 });
await expect(page.getByLabel('地图来源')).toHaveValue('none');
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');
});
test('无效模型显示中文诊断且保留工程树', async ({ page }) => { test('无效模型显示中文诊断且保留工程树', async ({ page }) => {
await page.goto('/'); await page.goto('/');
await page.locator('input[type="file"]').first().setInputFiles(fixture('invalid.xml')); await page.locator('input[type="file"]').first().setInputFiles(fixture('invalid.xml'));
+60 -2
View File
@@ -3,12 +3,70 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" /> <meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="theme-color" content="#101827" /> <meta name="theme-color" content="#09111e" />
<meta
name="description"
content="基于 MuJoCo WebAssembly 的本地机器人仿真、控制与地图编辑工作台"
/>
<link rel="icon" href="data:," /> <link rel="icon" href="data:," />
<title>MuJoCo Web 仿真平台</title> <title>MuJoCo Web 仿真平台</title>
<style>
html,
body,
#root {
height: 100%;
margin: 0;
}
body {
background: #09111e;
}
.boot-screen {
display: grid;
height: 100%;
place-items: center;
color: #f1f5f9;
font:
13px Inter,
system-ui,
sans-serif;
}
.boot-mark {
display: grid;
width: 42px;
height: 42px;
margin: 0 auto 14px;
place-items: center;
border: 1px solid #2b604f;
border-radius: 13px;
background: #123b31;
color: #38d39f;
font-weight: 800;
box-shadow: 0 16px 48px rgb(0 0 0 / 35%);
animation: boot-pulse 1.4s ease-in-out infinite;
}
.boot-caption {
color: #8fa0b5;
font-size: 11px;
letter-spacing: 0.04em;
text-align: center;
}
@keyframes boot-pulse {
50% {
transform: translateY(-2px);
box-shadow: 0 18px 54px rgb(56 211 159 / 15%);
}
}
</style>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root">
<div class="boot-screen" role="status" aria-label="正在启动仿真工作台">
<div>
<div class="boot-mark">M</div>
<div class="boot-caption">正在启动本地仿真工作台…</div>
</div>
</div>
</div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>
File diff suppressed because it is too large Load Diff
@@ -23,12 +23,31 @@ describe('工作台反馈组件', () => {
fireEvent.click(screen.getByRole('button', { name: '关闭错误' })); fireEvent.click(screen.getByRole('button', { name: '关闭错误' }));
expect(close).toHaveBeenCalledTimes(1); expect(close).toHaveBeenCalledTimes(1);
}); });
it('加载态与空态互斥', () => { it('加载态与空态互斥,并展示真实阶段进度', () => {
const { rerender } = render(<WorkspaceOverlays loading={false} hasSnapshot={false} />); const { rerender } = render(<WorkspaceOverlays loading={false} hasSnapshot={false} />);
expect(screen.getByText('拖放模型工程到此处')).toBeVisible(); expect(screen.getByText('拖放模型工程到此处')).toBeVisible();
rerender(<WorkspaceOverlays loading hasSnapshot={false} />); rerender(
<WorkspaceOverlays
loading
hasSnapshot={false}
progress={{
title: '正在导入工程',
label: '读取工程文件',
detail: 'robot.zip',
value: 0.42,
}}
/>,
);
expect(screen.queryByText('拖放模型工程到此处')).not.toBeInTheDocument(); expect(screen.queryByText('拖放模型工程到此处')).not.toBeInTheDocument();
expect(screen.getByRole('status')).toBeVisible(); expect(screen.getByRole('status')).toHaveTextContent('正在导入工程');
expect(screen.getByRole('progressbar', { name: '读取工程文件' })).toHaveAttribute(
'aria-valuenow',
'42',
);
});
it('拖入文件时显示明确落点反馈', () => {
render(<WorkspaceOverlays loading={false} hasSnapshot={false} dragActive />);
expect(screen.getByText('松开即可导入工程')).toBeVisible();
}); });
it('展示格式化状态数据', () => { it('展示格式化状态数据', () => {
render(<StatusBar time={1.25} fps={60} stepMs={0.5} memoryMb={10} loaded overBudget={false} />); render(<StatusBar time={1.25} fps={60} stepMs={0.5} memoryMb={10} loaded overBudget={false} />);
@@ -0,0 +1,51 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { MAP_ASSET_DRAG_MIME, MAP_ASSET_PLACEMENT_MIME } from '../../map/editor/assetCatalog';
import { MapAssetLibrary } from './MapAssetLibrary';
const renderLibrary = (
onAdd: (type: string, placementMode: string) => void = () => {},
onSelectTerrain: (preset: string) => void = () => {},
) =>
render(
<MapAssetLibrary
disabled={false}
terrainSize={8}
onAdd={onAdd}
onSelectTerrain={onSelectTerrain}
/>,
);
describe('MapAssetLibrary', () => {
it('按所选放置方式添加认证资产', () => {
const onAdd = vi.fn();
renderLibrary(onAdd);
fireEvent.change(screen.getByLabelText('新增资产放置方式'), {
target: { value: 'gravity' },
});
fireEvent.click(screen.getByRole('button', { name: '添加基础方盒' }));
expect(onAdd).toHaveBeenCalledWith('box', 'gravity');
});
it('拖动资产时写入类型和放置方式', () => {
const values = new Map<string, string>();
const dataTransfer = {
effectAllowed: 'none',
setData: (type: string, value: string) => values.set(type, value),
} as unknown as DataTransfer;
renderLibrary();
fireEvent.change(screen.getByLabelText('新增资产放置方式'), {
target: { value: 'locked' },
});
fireEvent.dragStart(document.querySelector('[data-map-asset="ramp"]')!, { dataTransfer });
expect(values.get(MAP_ASSET_DRAG_MIME)).toBe('ramp');
expect(values.get(MAP_ASSET_PLACEMENT_MIME)).toBe('locked');
});
it('以示意图卡片选择系统参数化地形', () => {
const onSelectTerrain = vi.fn();
renderLibrary(undefined, onSelectTerrain);
expect(screen.getAllByText('8.00 × 8.00 m')).toHaveLength(9);
fireEvent.click(screen.getByRole('button', { name: '添加随机粗糙地形' }));
expect(onSelectTerrain).toHaveBeenCalledWith('rough');
});
});
@@ -0,0 +1,298 @@
import { useState } from 'react';
import { BadgeCheck, CheckCircle2, Mountain, Plus } from 'lucide-react';
import { Button, Select } from '../../components/ui';
import {
CERTIFIED_MAP_ASSETS,
MAP_ASSET_DRAG_MIME,
MAP_ASSET_PLACEMENT_MIME,
} from '../../map/editor/assetCatalog';
import {
MAP_OBJECT_PLACEMENT_LABELS,
type EditableMapDocument,
type EditableMapObjectType,
type MapObjectPlacementMode,
} from '../../map/editor/types';
import {
PHYSICAL_MAP_PRESET_LABELS,
SYSTEM_TERRAIN_PRESETS,
type SystemTerrainPreset,
} from '../../map/types';
function AssetPreview({ type, color }: { type: EditableMapObjectType; color: string }) {
const shape =
type === 'cylinder'
? 'h-9 w-9 rounded-full'
: type === 'capsule'
? 'h-10 w-6 rounded-full'
: type === 'ramp'
? 'h-0 w-0 border-b-[34px] border-l-[48px] border-l-transparent'
: type === 'stairs'
? 'h-9 w-11 [clip-path:polygon(0_100%,0_66%,34%_66%,34%_33%,67%_33%,67%_0,100%_0,100%_100%)]'
: 'h-9 w-9 rounded-sm';
return (
<div className="flex h-16 items-center justify-center rounded bg-black/10" aria-hidden="true">
<div
className={`${shape} shadow-[0_6px_14px_rgba(0,0,0,0.22)]`}
style={type === 'ramp' ? { borderBottomColor: color } : { backgroundColor: color }}
/>
</div>
);
}
function TerrainPreview({ preset }: { preset: SystemTerrainPreset }) {
const grid = (
<g stroke="currentColor" strokeWidth="0.45" opacity="0.18">
<path d="M14 50 60 23l46 27-46 27z" />
<path d="m26 57 46-27m-34 34 46-27m-58 6 46 27m-34-34 46 27" />
</g>
);
let shape;
if (preset === 'discrete_obstacles')
shape = (
<g fill="currentColor" fillOpacity="0.16" stroke="currentColor">
<path d="m31 48 9-5 9 5-9 5zM40 53V39l9-5v14M40 39l-9-5v14" />
<path d="m55 39 8-5 8 5-8 5zM63 44V27l8-5v17M63 27l-8-5v17" />
<path d="m77 51 10-6 10 6-10 6zM87 57V38l10-6v19M87 38l-10-6v19" />
</g>
);
else if (preset === 'gap')
shape = (
<g fill="currentColor" fillOpacity="0.12" stroke="currentColor">
<path d="m21 48 31-18 15 9-31 18zM69 40l15 9 17-10-15-9z" />
<path d="m36 57 31-18v8L36 65zM69 40l17-10v8L69 48z" fillOpacity="0.24" />
</g>
);
else if (preset === 'inverted_pyramid_stairs' || preset === 'pit')
shape = (
<g fill="currentColor" fillOpacity="0.08" stroke="currentColor">
<path d="m22 47 38-22 38 22-38 22z" />
<path d="m32 47 28-16 28 16-28 16z" />
<path d="m43 47 17-10 17 10-17 10z" fillOpacity="0.2" />
{preset === 'inverted_pyramid_stairs' && (
<path d="M32 47v7m11-7v10m17-10v16m17-16v10m11-10v7" />
)}
</g>
);
else if (preset === 'pyramid_stairs')
shape = (
<g fill="currentColor" stroke="currentColor" strokeLinejoin="round">
<path d="m25 52 12 7 52-30-12-7z" fillOpacity="0.1" />
<path d="m37 45 12 7 40-23-12-7z" fillOpacity="0.17" />
<path d="m49 38 12 7 28-16-12-7z" fillOpacity="0.24" />
<path d="m61 31 12 7 16-9-12-7z" fillOpacity="0.32" />
</g>
);
else if (preset === 'rails')
shape = (
<g fill="none" stroke="currentColor" strokeLinecap="round">
<path d="m26 58 53-31m15 40 12-7" strokeWidth="2.3" opacity="0.25" />
<path d="m26 58 53-31m15 40 12-7M33 58V45m14 5V37m30 12V27m20 39V53" strokeWidth="1.2" />
</g>
);
else if (preset === 'rough')
shape = (
<path
d="m20 54 10-18 9 9 10-22 11 17 10-13 10 20 10-8 10 15-40 18z"
fill="currentColor"
fillOpacity="0.16"
stroke="currentColor"
strokeLinejoin="round"
/>
);
else if (preset === 'stepping_stones')
shape = (
<g fill="currentColor" fillOpacity="0.2" stroke="currentColor">
<path d="m27 52 10-6 10 6-10 6zM46 39l10-6 10 6-10 6zM66 54l10-6 10 6-10 6zM83 36l10-6 10 6-10 6z" />
</g>
);
else
shape = (
<g fill="none" stroke="currentColor" strokeLinecap="round">
<path d="M19 48c10-17 20-17 30 0s20 17 30 0 20-17 29-2" strokeWidth="5" opacity="0.16" />
<path d="M19 48c10-17 20-17 30 0s20 17 30 0 20-17 29-2" strokeWidth="1.4" />
<path d="M25 59c10-17 20-17 30 0s20 17 30 0 15-13 23-7" opacity="0.55" />
</g>
);
return (
<svg viewBox="0 0 120 80" className="h-[76px] w-full text-accent" aria-hidden="true">
{grid}
{shape}
</svg>
);
}
function AddAction({ label }: { label: string }) {
return (
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-accent">
{label}
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-accent text-white">
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
</span>
</span>
);
}
export function MapAssetLibrary({
disabled,
terrainSize,
document,
onAdd,
onSelectTerrain,
onSelectObject,
}: {
disabled: boolean;
terrainSize: number;
document?: EditableMapDocument | null;
onAdd: (
type: EditableMapObjectType,
placementMode: MapObjectPlacementMode,
) => void | Promise<void>;
onSelectTerrain: (preset: SystemTerrainPreset) => void;
onSelectObject?: (id: string) => void;
}) {
const [placementMode, setPlacementMode] = useState<MapObjectPlacementMode>('auto_ground');
return (
<section aria-label="认证资产" className="space-y-3 p-3">
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-2.5">
<div className="flex items-center justify-between text-xs font-medium text-text-primary">
<span></span>
<span className="technical-value text-[10px] text-text-tertiary">
{document?.objects.length ?? 0}
</span>
</div>
{document?.objects.length ? (
<div className="mt-2 max-h-36 space-y-0.5 overflow-auto panel-scroll">
{document.objects.map((object) => (
<button
key={object.id}
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[11px] text-text-secondary hover:bg-element-hover hover:text-text-primary"
onClick={() => onSelectObject?.(object.id)}
>
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent" />
<span className="min-w-0 flex-1 truncate">{object.name}</span>
<span className="text-[9px] text-text-tertiary">{object.type}</span>
</button>
))}
</div>
) : (
<p className="mt-2 text-[10px] leading-relaxed text-text-tertiary">
</p>
)}
</div>
<div className="flex items-end justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-1.5 text-xs font-medium text-text-primary">
<BadgeCheck className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
</div>
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
</p>
</div>
<label className="w-36 shrink-0 text-[10px] text-text-secondary">
<Select
aria-label="新增资产放置方式"
className="mt-1 w-full"
value={placementMode}
disabled={disabled}
onChange={(event) => setPlacementMode(event.target.value as MapObjectPlacementMode)}
>
{(Object.keys(MAP_OBJECT_PLACEMENT_LABELS) as MapObjectPlacementMode[]).map((mode) => (
<option key={mode} value={mode}>
{MAP_OBJECT_PLACEMENT_LABELS[mode]}
</option>
))}
</Select>
</label>
</div>
<div className="grid grid-cols-2 gap-2">
{CERTIFIED_MAP_ASSETS.map((asset) => (
<article
key={asset.type}
draggable={!disabled}
data-map-asset={asset.type}
className={`overflow-hidden rounded-lg border border-border-subtle bg-panel-muted shadow-sm transition-colors ${disabled ? 'opacity-50' : 'cursor-grab hover:border-accent active:cursor-grabbing'}`}
onDragStart={(event) => {
if (disabled) {
event.preventDefault();
return;
}
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.setData(MAP_ASSET_DRAG_MIME, asset.type);
event.dataTransfer.setData(MAP_ASSET_PLACEMENT_MIME, placementMode);
event.dataTransfer.setData('text/plain', asset.name);
}}
>
<div className="p-2 pb-0">
<AssetPreview type={asset.type} color={asset.color} />
</div>
<div className="border-t border-border-subtle p-2">
<div className="truncate text-[11px] font-semibold text-text-primary">
{asset.name}
</div>
<div className="mt-0.5 truncate text-[9px] text-text-tertiary">
{asset.description}
</div>
<Button
className="mt-2 w-full"
disabled={disabled}
aria-label={`添加${asset.name}`}
onClick={() => void onAdd(asset.type, placementMode)}
>
<Plus className="mr-1 h-3 w-3" aria-hidden="true" />
</Button>
</div>
</article>
))}
</div>
<div className="border-t border-border-subtle pt-3">
<div className="flex items-center gap-1.5 text-xs font-medium text-text-primary">
<Mountain className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
</div>
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
</p>
</div>
<div className="grid grid-cols-2 gap-2">
{SYSTEM_TERRAIN_PRESETS.map((preset) => (
<article
key={preset}
data-system-terrain={preset}
className={`group relative overflow-hidden rounded-lg border border-border-subtle bg-panel-muted shadow-sm transition-colors ${disabled ? 'opacity-50' : 'hover:border-accent'}`}
>
<div className="absolute right-2 top-2 z-10 rounded-full bg-panel-bg/90 p-0.5 text-success">
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
</div>
<div className="px-1 pt-1">
<TerrainPreview preset={preset} />
</div>
<div className="border-t border-border-subtle px-2 pb-2 pt-1.5">
<div className="truncate text-[11px] font-semibold text-text-primary">
{PHYSICAL_MAP_PRESET_LABELS[preset]}
</div>
<div className="mt-0.5 text-[9px] text-text-tertiary">
{terrainSize.toFixed(2)} × {terrainSize.toFixed(2)} m
</div>
<button
type="button"
className="mt-1 flex w-full justify-end rounded outline-none focus-visible:ring-2 focus-visible:ring-accent/30"
disabled={disabled}
aria-label={`添加${PHYSICAL_MAP_PRESET_LABELS[preset]}`}
onClick={() => onSelectTerrain(preset)}
>
<AddAction label="添加" />
</button>
</div>
</article>
))}
</div>
</section>
);
}
@@ -0,0 +1,106 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { EditableMapDocument } from '../../map/editor/types';
import { MapEditorPanel } from './MapEditorPanel';
const interactionProps = {
onConvert: async () => true,
canConvert: true,
onBindInteraction: () => {},
onSelectPreview: () => {},
onTransformMode: () => {},
onSnapping: () => {},
};
const document: EditableMapDocument = {
schemaVersion: 1,
mapId: 'map',
revision: 0,
objects: [],
spawnPoints: [],
};
describe('MapEditorPanel', () => {
it('新增并编辑对象后应用递增 revision', async () => {
const apply = vi.fn(async (documentValue: EditableMapDocument) => Boolean(documentValue)),
preview = vi.fn();
render(
<MapEditorPanel
{...interactionProps}
document={document}
loading={false}
onPreview={preview}
onApply={apply}
onExport={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '新增' }));
fireEvent.change(screen.getByLabelText('对象位置X'), { target: { value: '2' } });
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
await waitFor(() => expect(apply).toHaveBeenCalled());
expect(apply.mock.calls[0][0]).toEqual(
expect.objectContaining({
revision: 1,
objects: [
expect.objectContaining({ pose: expect.objectContaining({ position: [2, 0, 0.5] }) }),
],
}),
);
});
it('锁定放置方式后禁用位姿编辑', () => {
render(
<MapEditorPanel
{...interactionProps}
document={document}
loading={false}
onPreview={() => {}}
onApply={async () => true}
onExport={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '新增' }));
fireEvent.change(screen.getByLabelText('对象放置方式'), { target: { value: 'locked' } });
expect(screen.getByLabelText('对象位置X')).toBeDisabled();
expect(screen.getByLabelText('对象绕Z旋转')).toBeDisabled();
expect(screen.getByRole('button', { name: '对齐地面' })).toBeDisabled();
});
it('编辑出生点并包含在应用文档中', async () => {
const apply = vi.fn(async (value: EditableMapDocument) => Boolean(value));
render(
<MapEditorPanel
{...interactionProps}
document={document}
loading={false}
onPreview={() => {}}
onApply={apply}
onExport={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '新增出生点' }));
const name = screen.getByLabelText(/出生点名称/);
fireEvent.change(name, { target: { value: '装卸区' } });
const yaw = screen.getByLabelText(/出生点.*朝向/);
fireEvent.change(yaw, { target: { value: '90' } });
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
await waitFor(() => expect(apply).toHaveBeenCalled());
expect(apply.mock.calls[0][0].spawnPoints[0]).toMatchObject({ name: '装卸区', yawDeg: 90 });
});
it('没有 authoring 文档时可请求创建可编辑副本', async () => {
const convert = vi.fn(async () => true);
render(
<MapEditorPanel
{...interactionProps}
onConvert={convert}
document={null}
loading={false}
onPreview={() => {}}
onApply={async () => true}
onExport={() => {}}
/>,
);
expect(screen.getByText(/保持只读/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '创建可编辑副本' }));
await waitFor(() => expect(convert).toHaveBeenCalledOnce());
});
});
@@ -0,0 +1,597 @@
import { useEffect, useMemo, useState } from 'react';
import { Button, Select } from '../../components/ui';
import { MapEditSession } from '../../map/editor/MapEditSession';
import {
editableObjectGroundHeight,
MAP_OBJECT_PLACEMENT_LABELS,
type EditableMapDocument,
type EditableMapObject,
type EditableMapObjectType,
type MapEditorInteractionCallbacks,
type MapEditorTransformMode,
type MapObjectPlacementMode,
} from '../../map/editor/types';
const labels: Record<EditableMapObjectType, string> = {
box: '方盒',
cylinder: '圆柱',
capsule: '胶囊',
ramp: '坡道',
stairs: '楼梯',
};
function yawDegrees(object: EditableMapObject): number {
const [w, x, y, z] = object.pose.quaternion;
return (Math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z)) * 180) / Math.PI;
}
function scaledParameters(
object: EditableMapObject,
scale: [number, number, number],
): Record<string, number> {
const [sx, sy, sz] = scale.map((value) => Math.max(0.001, Math.abs(value))) as [
number,
number,
number,
];
const p = object.parameters;
if (object.type === 'box')
return { sizeX: p.sizeX * sx, sizeY: p.sizeY * sy, sizeZ: p.sizeZ * sz };
if (object.type === 'cylinder')
return { radius: p.radius * Math.max(sx, sy), height: p.height * sz };
if (object.type === 'capsule')
return { radius: p.radius * Math.max(sx, sy), length: p.length * sz };
if (object.type === 'ramp')
return {
length: p.length * sx,
width: p.width * sy,
rise: p.rise * sz,
thickness: p.thickness * sz,
};
return {
stepDepth: p.stepDepth * sx,
width: p.width * sy,
stepHeight: p.stepHeight * sz,
count: p.count,
};
}
export function MapEditorPanel({
document,
loading,
onPreview,
onApply,
onExport,
onConvert,
canConvert,
onBindInteraction,
onSelectPreview,
onTransformMode,
onSnapping,
}: {
document: EditableMapDocument | null;
loading: boolean;
onPreview: (document: EditableMapDocument | null) => void;
onApply: (document: EditableMapDocument) => Promise<boolean>;
onExport: () => void;
onConvert: () => Promise<boolean>;
canConvert: boolean;
onBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
onSelectPreview: (id: string | null) => void;
onTransformMode: (mode: MapEditorTransformMode) => void;
onSnapping: (translation: number | null, rotationDegrees: number | null) => void;
}) {
const session = useMemo(() => (document ? new MapEditSession(document) : null), [document]);
const [, render] = useState(0),
[selected, setSelected] = useState<string>(),
[selectedType, setSelectedType] = useState<EditableMapObjectType>('box'),
[transformMode, setTransformMode] = useState<MapEditorTransformMode>('translate'),
[snapping, setSnapping] = useState(true),
[converting, setConverting] = useState(false);
const refresh = () => {
render((value) => value + 1);
onPreview(session?.document ?? null);
};
useEffect(() => {
onPreview(session?.document ?? null);
return () => onPreview(null);
}, [session, onPreview]);
useEffect(() => {
if (!session) {
onBindInteraction(null);
return;
}
const callbacks: MapEditorInteractionCallbacks = {
onSelect: (id) => setSelected(id ?? undefined),
onTransform: ({ id, position, quaternion, scale }) => {
const object = session.document.objects.find((item) => item.id === id);
if (!object) return;
session.update(id, {
pose: { position, quaternion },
parameters: scaledParameters(object, scale),
});
setSelected(id);
render((value) => value + 1);
onPreview(session.document);
},
onAddAsset: (type, position, placementMode) => {
const object = session.addAsset(type, position, placementMode);
setSelected(object.id);
render((value) => value + 1);
onPreview(session.document);
onSelectPreview(object.id);
},
};
onBindInteraction(callbacks);
return () => onBindInteraction(null);
}, [session, onBindInteraction, onPreview, onSelectPreview]);
useEffect(() => {
onTransformMode(transformMode);
onSnapping(snapping ? 0.1 : null, snapping ? 5 : null);
}, [transformMode, snapping, onTransformMode, onSnapping]);
useEffect(() => {
if (!session) return;
const keydown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (target?.matches('input, textarea, select') || target?.isContentEditable || event.altKey)
return;
const key = event.key.toLowerCase();
if ((event.ctrlKey || event.metaKey) && key === 'z') {
event.preventDefault();
if (event.shiftKey) session.redo();
else session.undo();
if (selected && !session.document.objects.some((object) => object.id === selected)) {
setSelected(undefined);
onSelectPreview(null);
}
render((value) => value + 1);
onPreview(session.document);
} else if ((event.ctrlKey || event.metaKey) && key === 'y') {
event.preventDefault();
session.redo();
render((value) => value + 1);
onPreview(session.document);
} else if (!event.ctrlKey && !event.metaKey && key === 'w') setTransformMode('translate');
else if (!event.ctrlKey && !event.metaKey && key === 'e') setTransformMode('rotate');
else if (!event.ctrlKey && !event.metaKey && key === 's') setTransformMode('scale');
else if (event.key === 'Escape') {
setSelected(undefined);
onSelectPreview(null);
} else if ((event.key === 'Delete' || event.key === 'Backspace') && selected) {
event.preventDefault();
session.remove(selected);
setSelected(undefined);
render((value) => value + 1);
onPreview(session.document);
onSelectPreview(null);
}
};
window.addEventListener('keydown', keydown);
return () => window.removeEventListener('keydown', keydown);
}, [session, selected, onPreview, onSelectPreview]);
if (!session)
return (
<div className="space-y-2 rounded border border-border-subtle p-3 text-xs text-text-tertiary">
<p> V3 authoring.source</p>
<p className="text-[10px] leading-relaxed">
boxcylindercapsule MJCF
</p>
<div className="flex flex-wrap gap-2">
<Button
variant="primary"
disabled={loading || converting || !canConvert}
onClick={() => {
setConverting(true);
void onConvert().finally(() => setConverting(false));
}}
>
{converting ? '正在转换…' : '创建可编辑副本'}
</Button>
<Button disabled={loading || converting} onClick={onExport}>
ZIP
</Button>
</div>
{!canConvert && <p className="text-warning"> MJCF </p>}
</div>
);
const current = session.document;
const active = current.objects.find((object) => object.id === selected);
const poseLocked = active?.placementMode === 'locked';
const updatePosition = (index: number, value: number) => {
if (!active || !Number.isFinite(value)) return;
const position = [...active.pose.position] as [number, number, number];
position[index] = value;
session.update(active.id, { pose: { ...active.pose, position } });
refresh();
};
const updateParameter = (key: string, value: number) => {
if (!active || !Number.isFinite(value) || value <= 0) return;
session.update(active.id, {
parameters: { ...active.parameters, [key]: key === 'count' ? Math.round(value) : value },
});
refresh();
};
const updateSpawnPosition = (
id: string,
position: [number, number, number],
index: number,
value: number,
) => {
if (!Number.isFinite(value)) return;
const next = [...position] as [number, number, number];
next[index] = value;
session.updateSpawn(id, { position: next });
refresh();
};
return (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-2 rounded border border-border-subtle p-2">
<Button
variant={transformMode === 'translate' ? 'primary' : 'secondary'}
aria-pressed={transformMode === 'translate'}
disabled={loading}
onClick={() => setTransformMode('translate')}
>
W
</Button>
<Button
variant={transformMode === 'rotate' ? 'primary' : 'secondary'}
aria-pressed={transformMode === 'rotate'}
disabled={loading}
onClick={() => setTransformMode('rotate')}
>
E
</Button>
<Button
variant={transformMode === 'scale' ? 'primary' : 'secondary'}
aria-pressed={transformMode === 'scale'}
disabled={loading}
onClick={() => setTransformMode('scale')}
>
S
</Button>
<label className="col-span-3 flex items-center justify-between text-[11px] text-text-secondary">
<span> 0.1 m / 5°</span>
<input
aria-label="地图编辑吸附"
type="checkbox"
checked={snapping}
disabled={loading}
onChange={(event) => setSnapping(event.target.checked)}
/>
</label>
<p className="col-span-3 text-[10px] text-text-tertiary">
Z
</p>
</div>
<div className="flex gap-2">
<Select
aria-label="新增地图对象类型"
className="min-w-0 flex-1"
value={selectedType}
disabled={loading}
onChange={(event) => setSelectedType(event.target.value as EditableMapObjectType)}
>
{(Object.keys(labels) as EditableMapObjectType[]).map((type) => (
<option key={type} value={type}>
{labels[type]}
</option>
))}
</Select>
<Button
disabled={loading}
onClick={() => {
const object = session.add(selectedType);
setSelected(object.id);
refresh();
onSelectPreview(object.id);
}}
>
</Button>
</div>
<div className="max-h-36 space-y-1 overflow-auto" aria-label="地图对象列表">
{current.objects.map((object) => (
<button
key={object.id}
type="button"
className={`w-full rounded border px-2 py-1 text-left text-xs ${selected === object.id ? 'border-accent bg-accent/10' : 'border-border-subtle'}`}
onClick={() => {
setSelected(object.id);
onSelectPreview(object.id);
}}
>
{object.name} · {labels[object.type]}
{object.placementMode === 'locked' ? ' · 已锁定' : ''}
</button>
))}
{!current.objects.length && <div className="text-xs text-text-tertiary"></div>}
</div>
{active && (
<div className="space-y-2 rounded border border-border-subtle p-2">
<label className="block text-[11px] text-text-secondary">
<input
aria-label="对象名称"
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
value={active.name}
onChange={(event) => {
if (!event.target.value.trim()) return;
session.update(active.id, { name: event.target.value });
refresh();
}}
/>
</label>
<label className="block text-[11px] text-text-secondary">
<Select
aria-label="对象放置方式"
className="mt-1 w-full"
value={active.placementMode}
disabled={loading}
onChange={(event) => {
session.update(active.id, {
placementMode: event.target.value as MapObjectPlacementMode,
});
refresh();
}}
>
{(Object.keys(MAP_OBJECT_PLACEMENT_LABELS) as MapObjectPlacementMode[]).map(
(mode) => (
<option key={mode} value={mode}>
{MAP_OBJECT_PLACEMENT_LABELS[mode]}
</option>
),
)}
</Select>
<span className="mt-1 block text-[9px] leading-3.5 text-text-tertiary">
{active.placementMode === 'auto_ground'
? '始终将对象底部对齐 z=0。'
: active.placementMode === 'gravity'
? '沿世界 -Z 落到 XY 范围内最高的静态承载面。'
: '保持当前位姿,并禁用表单和视口变换。'}
</span>
</label>
<div className="grid grid-cols-3 gap-2">
{active.pose.position.map((value, index) => (
<label key={index} className="text-[11px] text-text-secondary">
{['X', 'Y', 'Z'][index]}
<input
aria-label={`对象位置${['X', 'Y', 'Z'][index]}`}
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
type="number"
step="0.1"
value={value}
disabled={loading || poseLocked || index === 2}
onChange={(event) => updatePosition(index, Number(event.target.value))}
/>
</label>
))}
</div>
<label className="block text-[11px] text-text-secondary">
Z °
<input
aria-label="对象绕Z旋转"
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
type="number"
step="1"
value={Number(yawDegrees(active).toFixed(4))}
disabled={loading || poseLocked}
onChange={(event) => {
const value = Number(event.target.value);
if (!Number.isFinite(value)) return;
const half = (value * Math.PI) / 360;
session.update(active.id, {
pose: { ...active.pose, quaternion: [Math.cos(half), 0, 0, Math.sin(half)] },
});
refresh();
}}
/>
</label>
<div className="grid grid-cols-2 gap-2">
{Object.entries(active.parameters).map(([key, value]) => (
<label key={key} className="text-[11px] text-text-secondary">
{key}
<input
aria-label={`对象参数${key}`}
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
type="number"
min="0.001"
step={key === 'count' ? 1 : 0.1}
value={value}
onChange={(event) => updateParameter(key, Number(event.target.value))}
/>
</label>
))}
</div>
<label className="flex items-center justify-between text-[11px] text-text-secondary">
<span></span>
<input
aria-label="启用地图对象"
type="checkbox"
checked={active.enabled}
onChange={(event) => {
session.update(active.id, { enabled: event.target.checked });
refresh();
}}
/>
</label>
<div className="flex flex-wrap gap-2">
<Button
disabled={loading || poseLocked}
onClick={() => {
session.update(active.id, {
pose: {
...active.pose,
position: [
active.pose.position[0],
active.pose.position[1],
editableObjectGroundHeight(active),
],
},
});
refresh();
}}
>
</Button>
<Button
disabled={loading}
onClick={() => {
const copy = session.duplicate(active.id);
setSelected(copy.id);
refresh();
onSelectPreview(copy.id);
}}
>
</Button>
<Button
variant="danger"
disabled={loading}
onClick={() => {
session.remove(active.id);
setSelected(undefined);
refresh();
onSelectPreview(null);
}}
>
</Button>
</div>
</div>
)}
<div className="space-y-2 rounded border border-border-subtle p-2">
<div className="flex items-center justify-between text-xs font-medium text-text-primary">
<span></span>
<Button
disabled={loading}
onClick={() => {
session.addSpawn();
refresh();
}}
>
</Button>
</div>
{current.spawnPoints.map((spawn) => (
<div key={spawn.id} className="space-y-2 rounded bg-panel-muted p-2">
<input
aria-label={`出生点名称 ${spawn.id}`}
className="h-7 w-full rounded border border-border-strong bg-input px-1 text-xs"
value={spawn.name}
onChange={(event) => {
if (!event.target.value.trim()) return;
session.updateSpawn(spawn.id, { name: event.target.value });
refresh();
}}
/>
<div className="grid grid-cols-4 gap-1">
{spawn.position.map((value, index) => (
<label key={index} className="text-[10px] text-text-secondary">
{['X', 'Y', 'Z'][index]}
<input
aria-label={`出生点${spawn.id}位置${['X', 'Y', 'Z'][index]}`}
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
type="number"
step="0.1"
value={value}
onChange={(event) =>
updateSpawnPosition(
spawn.id,
spawn.position,
index,
Number(event.target.value),
)
}
/>
</label>
))}
<label className="text-[10px] text-text-secondary">
Yaw°
<input
aria-label={`出生点${spawn.id}朝向`}
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
type="number"
step="1"
value={spawn.yawDeg}
onChange={(event) => {
const value = Number(event.target.value);
if (!Number.isFinite(value)) return;
session.updateSpawn(spawn.id, { yawDeg: value });
refresh();
}}
/>
</label>
</div>
<Button
variant="danger"
disabled={loading}
onClick={() => {
session.removeSpawn(spawn.id);
refresh();
}}
>
</Button>
</div>
))}
{!current.spawnPoints.length && (
<div className="text-[11px] text-text-tertiary"></div>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<Button
disabled={loading || !session.canUndo}
onClick={() => {
session.undo();
refresh();
}}
>
</Button>
<Button
disabled={loading || !session.canRedo}
onClick={() => {
session.redo();
refresh();
}}
>
</Button>
<Button
disabled={loading || !session.dirty}
onClick={() => {
session.discard();
setSelected(undefined);
refresh();
onSelectPreview(null);
}}
>
稿
</Button>
<Button disabled={loading} onClick={onExport}>
ZIP
</Button>
</div>
<Button
className="w-full"
disabled={loading || !session.dirty}
onClick={async () => {
const latest = session.document;
const candidate = { ...latest, revision: latest.revision + 1 };
if (await onApply(candidate)) onPreview(null);
else onPreview(session.document);
}}
>
</Button>
{session.dirty && (
<div role="status" className="text-xs text-warning">
稿
</div>
)}
</div>
);
}
@@ -0,0 +1,118 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { DEFAULT_PHYSICAL_MAP_CONFIG } from '../../map/types';
import { PhysicalMapPanel } from './PhysicalMapPanel';
const common = {
maps: [],
rootBodies: ['robot'],
loading: false,
nativeUrdf: false,
showVisualMap: true,
showMapCollision: false,
editorDocument: null,
onEditorPreview: () => {},
onEditorApply: async () => true,
onEditorExport: () => {},
onEditorConvert: async () => true,
onEditorBindInteraction: () => {},
onEditorSelect: () => {},
onEditorTransformMode: () => {},
onEditorSnapping: () => {},
onMapDisplay: () => {},
};
describe('PhysicalMapPanel', () => {
it('选择楼梯参数后请求重新编译', () => {
const onApply = vi.fn();
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={onApply} />);
fireEvent.change(screen.getByLabelText('地图来源'), { target: { value: 'builtin' } });
fireEvent.change(screen.getByLabelText('物理地图预设'), { target: { value: 'stairs' } });
fireEvent.change(screen.getByLabelText('台阶数量'), { target: { value: '6' } });
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
expect(onApply).toHaveBeenCalledWith({
kind: 'builtin',
config: expect.objectContaining({ preset: 'stairs', stairCount: 6 }),
});
});
it('在右侧参数面板调整系统地形并提交', () => {
const onApply = vi.fn();
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={onApply} />);
fireEvent.change(screen.getByLabelText('地图来源'), { target: { value: 'builtin' } });
fireEvent.change(screen.getByLabelText('物理地图预设'), { target: { value: 'rough' } });
fireEvent.change(screen.getByLabelText('地形难度(01'), { target: { value: '0.8' } });
fireEvent.change(screen.getByLabelText('随机种子'), { target: { value: '9' } });
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
expect(onApply).toHaveBeenCalledWith({
kind: 'builtin',
config: expect.objectContaining({
preset: 'rough',
terrainDifficulty: 0.8,
seed: 9,
}),
});
});
it('可以移动并旋转资产地图', () => {
const onApply = vi.fn();
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={onApply} />);
fireEvent.change(screen.getByLabelText('地图来源'), { target: { value: 'builtin' } });
fireEvent.change(screen.getByLabelText('位置 Xm'), { target: { value: '2.5' } });
fireEvent.change(screen.getByLabelText('位置 Ym'), { target: { value: '-1.5' } });
fireEvent.change(screen.getByLabelText('旋转 Z(°)'), { target: { value: '45' } });
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
expect(onApply).toHaveBeenCalledWith({
kind: 'builtin',
config: expect.objectContaining({ positionX: 2.5, positionY: -1.5, yawDeg: 45 }),
});
});
it('显示工程地图、出生点和根 Body', () => {
const onApply = vi.fn();
render(
<PhysicalMapPanel
{...common}
value={{ kind: 'none' }}
maps={[
{
descriptorPath: 'maps/warehouse/map.json',
schemaVersion: 1 as const,
id: 'warehouse',
name: '仓库',
spawnPoints: [{ id: 'door', name: '入口' }],
},
]}
rootBodies={['robot', 'payload']}
onApply={onApply}
/>,
);
fireEvent.change(screen.getByLabelText('地图来源'), {
target: { value: 'project:maps/warehouse/map.json' },
});
expect(screen.getByLabelText('地图出生点')).toHaveValue('door');
fireEvent.change(screen.getByLabelText('机器人根 Body'), { target: { value: 'robot' } });
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
expect(onApply).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'project',
descriptorPath: 'maps/warehouse/map.json',
spawnPointId: 'door',
robotRootBody: 'robot',
}),
);
});
it('原生 URDF 模式只允许移除地图', () => {
render(
<PhysicalMapPanel
{...common}
value={{ kind: 'builtin', config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'flat' } }}
nativeUrdf
onApply={() => {}}
/>,
);
expect(screen.getByRole('status')).toHaveTextContent('原生 URDF');
expect(screen.getByRole('button', { name: '应用并重新编译' })).toBeDisabled();
expect(screen.getByLabelText('地图来源')).not.toBeDisabled();
});
});
@@ -0,0 +1,474 @@
import { useState } from 'react';
import { Button, Select } from '../../components/ui';
import type { MapEntry } from '../../project/types';
import {
DEFAULT_PHYSICAL_MAP_CONFIG,
PHYSICAL_MAP_PRESET_LABELS,
isSystemTerrainPreset,
type MapSelection,
type PhysicalMapConfig,
type PhysicalMapPreset,
} from '../../map/types';
import { normalizePhysicalMapConfig } from '../../map/physicalMap';
import type {
EditableMapDocument,
MapEditorInteractionCallbacks,
MapEditorTransformMode,
} from '../../map/editor/types';
import { MapEditorPanel } from './MapEditorPanel';
function NumberField({
label,
value,
min,
max,
step = 1,
disabled,
onChange,
}: {
label: string;
value: number;
min: number;
max: number;
step?: number;
disabled: boolean;
onChange: (value: number) => void;
}) {
return (
<label className="block text-xs text-text-secondary">
<span className="mb-1 block">{label}</span>
<input
type="number"
className="h-8 w-full rounded border border-border-strong bg-input px-2 text-xs text-text-primary outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 disabled:cursor-not-allowed disabled:opacity-50"
value={value}
min={min}
max={max}
step={step}
disabled={disabled}
onChange={(event) => onChange(Number(event.target.value))}
/>
</label>
);
}
function normalizeSelection(selection: MapSelection): MapSelection {
if (selection.kind === 'project') {
const friction = selection.frictionOverride;
return {
...selection,
frictionOverride:
friction === undefined || !Number.isFinite(friction)
? undefined
: Math.min(5, Math.max(0.05, friction)),
};
}
if (selection.kind !== 'builtin') return selection;
const config = normalizePhysicalMapConfig(selection.config);
return config.preset === 'none' ? { kind: 'none' } : { kind: 'builtin', config };
}
export function PhysicalMapPanel({
value,
maps,
rootBodies,
loading,
nativeUrdf,
showVisualMap,
showMapCollision,
editorDocument,
onEditorPreview,
onEditorApply,
onEditorExport,
onEditorConvert,
onEditorBindInteraction,
onEditorSelect,
onEditorTransformMode,
onEditorSnapping,
onMapDisplay,
onApply,
}: {
value: MapSelection;
maps: MapEntry[];
rootBodies: string[];
loading: boolean;
nativeUrdf: boolean;
showVisualMap: boolean;
showMapCollision: boolean;
editorDocument: EditableMapDocument | null;
onEditorPreview: (document: EditableMapDocument | null) => void;
onEditorApply: (document: EditableMapDocument) => Promise<boolean>;
onEditorExport: () => void;
onEditorConvert: () => Promise<boolean>;
onEditorBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
onEditorSelect: (id: string | null) => void;
onEditorTransformMode: (mode: MapEditorTransformMode) => void;
onEditorSnapping: (translation: number | null, rotationDegrees: number | null) => void;
onMapDisplay: (visual: boolean, collision: boolean) => void;
onApply: (value: MapSelection) => void;
}) {
const [draft, setDraft] = useState<MapSelection>(value);
const normalized = normalizeSelection(draft);
const changed = JSON.stringify(normalized) !== JSON.stringify(value);
const compileDisabled = loading || (nativeUrdf && normalized.kind !== 'none');
const sourceValue =
draft.kind === 'none'
? 'none'
: draft.kind === 'builtin'
? 'builtin'
: `project:${draft.descriptorPath}`;
const projectMap =
draft.kind === 'project'
? maps.find((entry) => entry.descriptorPath === draft.descriptorPath)
: undefined;
const updateBuiltin = <K extends keyof PhysicalMapConfig>(key: K, next: PhysicalMapConfig[K]) => {
if (draft.kind !== 'builtin') return;
setDraft({ kind: 'builtin', config: { ...draft.config, [key]: next } });
};
const changeSource = (source: string) => {
if (source === 'none') setDraft({ kind: 'none' });
else if (source === 'builtin')
setDraft({
kind: 'builtin',
config: {
...DEFAULT_PHYSICAL_MAP_CONFIG,
preset: value.kind === 'builtin' ? value.config.preset : 'flat',
},
});
else {
const descriptorPath = source.slice('project:'.length);
const map = maps.find((entry) => entry.descriptorPath === descriptorPath);
setDraft({
kind: 'project',
descriptorPath,
spawnPointId: map?.spawnPoints[0]?.id,
});
}
};
return (
<div className="space-y-4 p-3">
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-3">
<div className="text-xs font-semibold text-text-primary"></div>
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
</p>
</div>
<label className="block text-xs text-text-secondary">
<span className="mb-1 block"></span>
<Select
aria-label="地图来源"
className="w-full"
value={sourceValue}
disabled={loading}
onChange={(event) => changeSource(event.target.value)}
>
<option value="none">使</option>
<option value="builtin"></option>
{maps.map((map) => (
<option key={map.descriptorPath} value={`project:${map.descriptorPath}`}>
{map.name}
</option>
))}
</Select>
</label>
{draft.kind === 'builtin' && (
<>
<label className="block text-xs text-text-secondary">
<span className="mb-1 block"></span>
<Select
aria-label="物理地图预设"
className="w-full"
value={draft.config.preset}
disabled={loading}
onChange={(event) => updateBuiltin('preset', event.target.value as PhysicalMapPreset)}
>
{(Object.keys(PHYSICAL_MAP_PRESET_LABELS) as PhysicalMapPreset[])
.filter((preset) => preset !== 'none')
.map((preset) => (
<option key={preset} value={preset}>
{PHYSICAL_MAP_PRESET_LABELS[preset]}
</option>
))}
</Select>
</label>
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-2.5">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-text-primary"></span>
<Button
disabled={
loading ||
(draft.config.positionX === 0 &&
draft.config.positionY === 0 &&
draft.config.yawDeg === 0)
}
onClick={() =>
setDraft({
kind: 'builtin',
config: { ...draft.config, positionX: 0, positionY: 0, yawDeg: 0 },
})
}
>
</Button>
</div>
<div className="grid grid-cols-3 gap-2">
<NumberField
label="位置 Xm"
value={draft.config.positionX}
min={-100}
max={100}
step={0.1}
disabled={loading}
onChange={(next) => updateBuiltin('positionX', next)}
/>
<NumberField
label="位置 Ym"
value={draft.config.positionY}
min={-100}
max={100}
step={0.1}
disabled={loading}
onChange={(next) => updateBuiltin('positionY', next)}
/>
<NumberField
label="旋转 Z(°)"
value={draft.config.yawDeg}
min={-180}
max={180}
step={1}
disabled={loading}
onChange={(next) => updateBuiltin('yawDeg', next)}
/>
</div>
<p className="mt-2 text-[10px] leading-relaxed text-text-tertiary">
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<NumberField
label={isSystemTerrainPreset(draft.config.preset) ? '地形边长(m' : '地图半径(m'}
value={draft.config.size}
min={4}
max={isSystemTerrainPreset(draft.config.preset) ? 30 : 100}
disabled={loading}
onChange={(next) => updateBuiltin('size', next)}
/>
<NumberField
label="摩擦系数"
value={draft.config.friction}
min={0.05}
max={5}
step={0.05}
disabled={loading}
onChange={(next) => updateBuiltin('friction', next)}
/>
{draft.config.preset === 'slope' && (
<NumberField
label="坡度(°)"
value={draft.config.slopeAngle}
min={5}
max={30}
disabled={loading}
onChange={(next) => updateBuiltin('slopeAngle', next)}
/>
)}
{draft.config.preset === 'stairs' && (
<NumberField
label="台阶数量"
value={draft.config.stairCount}
min={2}
max={20}
disabled={loading}
onChange={(next) => updateBuiltin('stairCount', next)}
/>
)}
{draft.config.preset === 'obstacles' && (
<NumberField
label="障碍物数量"
value={draft.config.obstacleCount}
min={1}
max={30}
disabled={loading}
onChange={(next) => updateBuiltin('obstacleCount', next)}
/>
)}
{(draft.config.preset === 'obstacles' ||
isSystemTerrainPreset(draft.config.preset)) && (
<NumberField
label="随机种子"
value={draft.config.seed}
min={0}
max={2_147_483_647}
disabled={loading}
onChange={(next) => updateBuiltin('seed', next)}
/>
)}
{isSystemTerrainPreset(draft.config.preset) && (
<NumberField
label="地形难度(01"
value={draft.config.terrainDifficulty}
min={0}
max={1}
step={0.05}
disabled={loading}
onChange={(next) => updateBuiltin('terrainDifficulty', next)}
/>
)}
{(draft.config.preset === 'rough' || draft.config.preset === 'wave') && (
<>
<NumberField
label="水平采样间距(m"
value={draft.config.terrainHorizontalScale}
min={0.03}
max={1}
step={0.01}
disabled={loading}
onChange={(next) => updateBuiltin('terrainHorizontalScale', next)}
/>
<NumberField
label="垂直量化(m"
value={draft.config.terrainVerticalScale}
min={0.001}
max={0.1}
step={0.001}
disabled={loading}
onChange={(next) => updateBuiltin('terrainVerticalScale', next)}
/>
</>
)}
</div>
</>
)}
{draft.kind === 'project' && projectMap && (
<div className="space-y-3 rounded border border-border-subtle bg-panel-muted p-3">
<div className="text-xs font-medium text-text-primary">{projectMap.name}</div>
<div className="break-all text-[11px] text-text-tertiary">
{projectMap.descriptorPath}
</div>
{projectMap.spawnPoints.length > 0 && (
<label className="block text-xs text-text-secondary">
<span className="mb-1 block"></span>
<Select
aria-label="地图出生点"
className="w-full"
value={draft.spawnPointId ?? ''}
disabled={loading}
onChange={(event) =>
setDraft({ ...draft, spawnPointId: event.target.value || undefined })
}
>
<option value=""></option>
{projectMap.spawnPoints.map((spawn) => (
<option key={spawn.id} value={spawn.id}>
{spawn.name}
</option>
))}
</Select>
</label>
)}
{rootBodies.length > 1 && (
<label className="block text-xs text-text-secondary">
<span className="mb-1 block"> Body</span>
<Select
aria-label="机器人根 Body"
className="w-full"
value={draft.robotRootBody ?? ''}
disabled={loading}
onChange={(event) =>
setDraft({ ...draft, robotRootBody: event.target.value || undefined })
}
>
<option value=""></option>
{rootBodies.map((body) => (
<option key={body} value={body}>
{body}
</option>
))}
</Select>
</label>
)}
<label className="block text-xs text-text-secondary">
<span className="mb-1 block">使</span>
<input
aria-label="地图摩擦系数覆盖"
type="number"
min={0.05}
max={5}
step={0.05}
value={draft.frictionOverride ?? ''}
disabled={loading}
className="h-8 w-full rounded border border-border-strong bg-input px-2 text-xs text-text-primary"
onChange={(event) =>
setDraft({
...draft,
frictionOverride: event.target.value ? Number(event.target.value) : undefined,
})
}
/>
</label>
</div>
)}
{draft.kind === 'project' &&
draft.descriptorPath === (value.kind === 'project' ? value.descriptorPath : '') && (
<div className="space-y-2 rounded border border-border-subtle p-3">
<div className="text-xs font-medium text-text-primary">V3 </div>
<MapEditorPanel
document={editorDocument}
loading={loading}
onPreview={onEditorPreview}
onApply={onEditorApply}
onExport={onEditorExport}
onConvert={onEditorConvert}
canConvert={Boolean(projectMap?.physicsPath)}
onBindInteraction={onEditorBindInteraction}
onSelectPreview={onEditorSelect}
onTransformMode={onEditorTransformMode}
onSnapping={onEditorSnapping}
/>
</div>
)}
<div className="space-y-2 rounded border border-border-subtle p-3 text-xs text-text-secondary">
<label className="flex items-center justify-between gap-3">
<span></span>
<input
type="checkbox"
checked={showVisualMap}
onChange={(event) => onMapDisplay(event.target.checked, showMapCollision)}
/>
</label>
<label className="flex items-center justify-between gap-3">
<span></span>
<input
type="checkbox"
checked={showMapCollision}
onChange={(event) => onMapDisplay(showVisualMap, event.target.checked)}
/>
</label>
</div>
{nativeUrdf && normalized.kind !== 'none' && (
<p
role="status"
className="rounded border border-warning/30 bg-warning/10 p-2 text-xs text-warning"
>
URDF MJCF
</p>
)}
<Button
variant="primary"
className="w-full"
disabled={compileDisabled || !changed}
onClick={() => onApply(normalized)}
>
</Button>
</div>
);
}
+125 -12
View File
@@ -1,6 +1,6 @@
import { useState, type ReactNode } from 'react'; import { useState, type ReactNode } from 'react';
import { Box, FolderTree, Info, Settings2, SlidersHorizontal } from 'lucide-react'; import { Box, FolderTree, Info, Map as MapIcon, Settings2, SlidersHorizontal } from 'lucide-react';
import type { ModelEntry } from '../../project/types'; import type { MapEntry, ModelEntry } from '../../project/types';
import { import {
countProjectSearchResults, countProjectSearchResults,
ProjectTree, ProjectTree,
@@ -34,6 +34,20 @@ import { ProjectBreadcrumb } from './ProjectBreadcrumb';
import { PythonControllerPanel } from './PythonControllerPanel'; import { PythonControllerPanel } from './PythonControllerPanel';
import { RLPolicyPanel } from './RLPolicyPanel'; import { RLPolicyPanel } from './RLPolicyPanel';
import { LocalTrainingPanel } from './LocalTrainingPanel'; import { LocalTrainingPanel } from './LocalTrainingPanel';
import { PhysicalMapPanel } from './PhysicalMapPanel';
import { MapAssetLibrary } from './MapAssetLibrary';
import {
DEFAULT_PHYSICAL_MAP_CONFIG,
type MapSelection,
type SystemTerrainPreset,
} from '../../map/types';
import type {
EditableMapDocument,
EditableMapObjectType,
MapEditorInteractionCallbacks,
MapEditorTransformMode,
MapObjectPlacementMode,
} from '../../map/editor/types';
export function SidebarPanel({ export function SidebarPanel({
title, title,
@@ -51,8 +65,10 @@ export function SidebarPanel({
<aside <aside
className={`flex h-full w-full min-w-0 flex-col overflow-hidden bg-panel ${side === 'left' ? 'border-r' : 'border-l'} border-border`} className={`flex h-full w-full min-w-0 flex-col overflow-hidden bg-panel ${side === 'left' ? 'border-r' : 'border-l'} border-border`}
> >
<h2 className="flex h-10 shrink-0 items-center gap-2 border-b border-border bg-panel px-3 text-sm font-semibold text-text-primary"> <h2 className="flex h-10 shrink-0 items-center gap-2 border-b border-border bg-panel/95 px-3 text-sm font-semibold tracking-tight text-text-primary">
<Settings2 aria-hidden="true" className="h-4 w-4 text-accent" /> <span className="grid h-6 w-6 place-items-center rounded-md bg-accent-soft text-accent">
<Settings2 aria-hidden="true" className="h-3.5 w-3.5" />
</span>
{title} {title}
</h2> </h2>
{children} {children}
@@ -69,9 +85,17 @@ export function ProjectSidebar({
snapshot, snapshot,
loading, loading,
visible = true, visible = true,
nativeUrdf,
mapSelection,
editorDocument,
activeTab,
onActiveTabChange,
onRemove, onRemove,
onSelectEntry, onSelectEntry,
onJointHover, onJointHover,
onAddMapAsset,
onSelectTerrain,
onSelectMapObject,
}: { }: {
projectName?: string; projectName?: string;
files: ProjectTreeFile[]; files: ProjectTreeFile[];
@@ -80,19 +104,31 @@ export function ProjectSidebar({
snapshot?: SimulationSnapshot; snapshot?: SimulationSnapshot;
loading: boolean; loading: boolean;
visible?: boolean; visible?: boolean;
nativeUrdf: boolean;
mapSelection: MapSelection;
editorDocument: EditableMapDocument | null;
activeTab?: 'project' | 'structure' | 'assets';
onActiveTabChange?: (value: 'project' | 'structure' | 'assets') => void;
onRemove: () => void; onRemove: () => void;
onSelectEntry: (path: string) => void; onSelectEntry: (path: string) => void;
onJointHover: (jointId: number | null) => void; onJointHover: (jointId: number | null) => void;
onAddMapAsset: (
type: EditableMapObjectType,
placementMode: MapObjectPlacementMode,
) => void | Promise<void>;
onSelectTerrain: (preset: SystemTerrainPreset) => void;
onSelectMapObject: (id: string) => void;
}) { }) {
const [tab, setTab] = useState<'project' | 'structure'>('project'), const [internalTab, setInternalTab] = useState<'project' | 'structure' | 'assets'>('project'),
[fileQuery, setFileQuery] = useState(''), [fileQuery, setFileQuery] = useState(''),
[structureQuery, setStructureQuery] = useState(''); [structureQuery, setStructureQuery] = useState('');
const fileMatches = countProjectSearchResults(files, fileQuery), const tab = activeTab ?? internalTab,
fileMatches = countProjectSearchResults(files, fileQuery),
structureMatches = snapshot structureMatches = snapshot
? countModelStructureSearchResults(snapshot.bodies, snapshot.joints, structureQuery) ? countModelStructureSearchResults(snapshot.bodies, snapshot.joints, structureQuery)
: 0; : 0;
return ( return (
<SidebarPanel title="工程资源" side="left" visible={visible}> <SidebarPanel title="资产与结构" side="left" visible={visible}>
{projectName ? ( {projectName ? (
<> <>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2.5"> <div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2.5">
@@ -116,7 +152,10 @@ export function ProjectSidebar({
<Tabs <Tabs
label="工程侧栏" label="工程侧栏"
value={tab} value={tab}
onValueChange={setTab} onValueChange={(value) => {
setInternalTab(value);
onActiveTabChange?.(value);
}}
items={[ items={[
{ {
value: 'project', value: 'project',
@@ -170,6 +209,26 @@ export function ProjectSidebar({
<p className="p-4 text-center text-xs text-text-tertiary"></p> <p className="p-4 text-center text-xs text-text-tertiary"></p>
), ),
}, },
{
value: 'assets',
label: '资产',
icon: <MapIcon className="h-3.5 w-3.5" />,
disabled: !snapshot,
content: (
<MapAssetLibrary
disabled={loading || nativeUrdf}
terrainSize={
mapSelection.kind === 'builtin'
? mapSelection.config.size
: DEFAULT_PHYSICAL_MAP_CONFIG.size
}
document={editorDocument}
onAdd={onAddMapAsset}
onSelectTerrain={onSelectTerrain}
onSelectObject={onSelectMapObject}
/>
),
},
]} ]}
/> />
</> </>
@@ -199,6 +258,11 @@ interface ModelControlsProps {
policyPaths: string[]; policyPaths: string[];
selectedPolicyPath?: string; selectedPolicyPath?: string;
policyStatus?: RLPolicyStatus; policyStatus?: RLPolicyStatus;
mapSelection: MapSelection;
maps: MapEntry[];
showVisualMap: boolean;
showMapCollision: boolean;
editorDocument: EditableMapDocument | null;
onUrdfMode: (value: UrdfLoadMode) => void; onUrdfMode: (value: UrdfLoadMode) => void;
onBaseMode: (value: UrdfBaseMode) => void; onBaseMode: (value: UrdfBaseMode) => void;
onShowCollision: (value: boolean) => void; onShowCollision: (value: boolean) => void;
@@ -222,13 +286,24 @@ interface ModelControlsProps {
onTogglePolicy: (enabled: boolean) => void; onTogglePolicy: (enabled: boolean) => void;
onPolicyCommand: (command: RLCommand) => void; onPolicyCommand: (command: RLCommand) => void;
onRemovePolicy: () => void; onRemovePolicy: () => void;
onApplyMap: (value: MapSelection) => void;
onEditorPreview: (document: EditableMapDocument | null) => void;
onEditorApply: (document: EditableMapDocument) => Promise<boolean>;
onEditorExport: () => void;
onEditorConvert: () => Promise<boolean>;
onEditorBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
onEditorSelect: (id: string | null) => void;
onEditorTransformMode: (mode: MapEditorTransformMode) => void;
onEditorSnapping: (translation: number | null, rotationDegrees: number | null) => void;
onMapDisplay: (visual: boolean, collision: boolean) => void;
onMapTabOpen?: () => void;
} }
export function ModelControlsSidebar(props: ModelControlsProps) { export function ModelControlsSidebar(props: ModelControlsProps) {
const [tab, setTab] = useState<'properties' | 'controls'>('properties'), const [tab, setTab] = useState<'properties' | 'controls' | 'map'>('properties'),
s = props.snapshot; s = props.snapshot;
if (!s) if (!s)
return ( return (
<SidebarPanel title="模型与控制" side="right" visible={props.visible}> <SidebarPanel title="属性与参数" side="right" visible={props.visible}>
<div className="p-4 text-sm text-text-tertiary"></div> <div className="p-4 text-sm text-text-tertiary"></div>
</SidebarPanel> </SidebarPanel>
); );
@@ -439,11 +514,14 @@ export function ModelControlsSidebar(props: ModelControlsProps) {
</> </>
); );
return ( return (
<SidebarPanel title="模型与控制" side="right" visible={props.visible}> <SidebarPanel title="属性与参数" side="right" visible={props.visible}>
<Tabs <Tabs
label="模型控制侧栏" label="模型控制侧栏"
value={tab} value={tab}
onValueChange={setTab} onValueChange={(value) => {
setTab(value);
if (value === 'map') props.onMapTabOpen?.();
}}
items={[ items={[
{ {
value: 'properties', value: 'properties',
@@ -457,6 +535,41 @@ export function ModelControlsSidebar(props: ModelControlsProps) {
icon: <SlidersHorizontal className="h-3.5 w-3.5" />, icon: <SlidersHorizontal className="h-3.5 w-3.5" />,
content: controls, content: controls,
}, },
{
value: 'map',
label: '地图',
icon: <MapIcon className="h-3.5 w-3.5" />,
content: (
<PhysicalMapPanel
key={JSON.stringify(props.mapSelection)}
value={props.mapSelection}
maps={props.maps}
rootBodies={s.bodies
.filter(
(body) =>
body.id !== 0 &&
body.parentId === 0 &&
!body.name.startsWith('__platform_map_'),
)
.map((body) => body.name)}
loading={props.loading}
nativeUrdf={props.selectedFormat === 'urdf' && props.urdfMode === 'native'}
showVisualMap={props.showVisualMap}
showMapCollision={props.showMapCollision}
editorDocument={props.editorDocument}
onEditorPreview={props.onEditorPreview}
onEditorApply={props.onEditorApply}
onEditorExport={props.onEditorExport}
onEditorConvert={props.onEditorConvert}
onEditorBindInteraction={props.onEditorBindInteraction}
onEditorSelect={props.onEditorSelect}
onEditorTransformMode={props.onEditorTransformMode}
onEditorSnapping={props.onEditorSnapping}
onMapDisplay={props.onMapDisplay}
onApply={props.onApplyMap}
/>
),
},
]} ]}
/> />
</SidebarPanel> </SidebarPanel>
@@ -1,6 +1,8 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Box, Clock3, MemoryStick, TriangleAlert } from 'lucide-react'; import { Box, Clock3, MemoryStick, TriangleAlert } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { Kbd } from '../../components/ui'; import { Kbd } from '../../components/ui';
import { useAppStore } from '../../stores/useAppStore';
import { PerformancePopover } from './PerformancePopover'; import { PerformancePopover } from './PerformancePopover';
export interface StatusBarProps { export interface StatusBarProps {
time?: number; time?: number;
@@ -49,3 +51,18 @@ export function StatusBar({ time, fps, stepMs, memoryMb, loaded, overBudget }: S
</footer> </footer>
); );
} }
/** 仅让状态栏订阅高频性能数据,避免带动整个工作台重渲染。 */
export function StoreStatusBar() {
const metrics = useAppStore(
useShallow((state) => ({
time: state.snapshot?.time,
fps: state.fps,
stepMs: state.stepMs,
memoryMb: state.memoryMb,
loaded: Boolean(state.snapshot),
overBudget: state.overBudget,
})),
);
return <StatusBar {...metrics} />;
}
@@ -6,7 +6,8 @@ describe('WorkbenchHeader', () => {
const pause = vi.fn(), const pause = vi.fn(),
step = vi.fn(), step = vi.fn(),
reset = vi.fn(), reset = vi.fn(),
speed = vi.fn(); speed = vi.fn(),
openSource = vi.fn();
render( render(
<WorkbenchHeader <WorkbenchHeader
paused paused
@@ -14,12 +15,14 @@ describe('WorkbenchHeader', () => {
speed={1} speed={1}
theme="dark" theme="dark"
loading={false} loading={false}
hasProject
leftOpen leftOpen
rightOpen rightOpen
fullscreen={false} fullscreen={false}
center={<span></span>} center={<span></span>}
onFiles={fn} onFiles={fn}
onFolder={fn} onFolder={fn}
onOpenSource={openSource}
onTogglePause={pause} onTogglePause={pause}
onStep={step} onStep={step}
onReset={reset} onReset={reset}
@@ -32,10 +35,14 @@ describe('WorkbenchHeader', () => {
onToggleFullscreen={fn} onToggleFullscreen={fn}
/>, />,
); );
const sourceButton = screen.getByRole('button', { name: '源代码' });
expect(sourceButton).toHaveTextContent('源代码');
fireEvent.click(sourceButton);
fireEvent.click(screen.getByRole('button', { name: '▶ 播放' })); fireEvent.click(screen.getByRole('button', { name: '▶ 播放' }));
fireEvent.click(screen.getByRole('button', { name: '单步' })); fireEvent.click(screen.getByRole('button', { name: '单步' }));
fireEvent.click(screen.getByRole('button', { name: '重置' })); fireEvent.click(screen.getByRole('button', { name: '重置' }));
fireEvent.change(screen.getByLabelText('仿真速度'), { target: { value: '2' } }); fireEvent.change(screen.getByLabelText('仿真速度'), { target: { value: '2' } });
expect(openSource).toHaveBeenCalledTimes(1);
expect(pause).toHaveBeenCalledTimes(1); expect(pause).toHaveBeenCalledTimes(1);
expect(step).toHaveBeenCalledTimes(1); expect(step).toHaveBeenCalledTimes(1);
expect(reset).toHaveBeenCalledTimes(1); expect(reset).toHaveBeenCalledTimes(1);
@@ -1,5 +1,6 @@
import type { ChangeEvent, ReactNode } from 'react'; import type { ChangeEvent, ReactNode } from 'react';
import { import {
Boxes,
CircleHelp, CircleHelp,
Code2, Code2,
Expand, Expand,
@@ -18,7 +19,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { Button, IconButton, Select } from '../../components/ui'; import { Button, IconButton, Select } from '../../components/ui';
const fileActionClass = const fileActionClass =
'inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md border border-border bg-surface px-2 text-xs font-medium text-text-primary transition-colors hover:bg-element-hover focus-within:ring-2 focus-within:ring-accent/30'; 'inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md border border-border bg-surface/80 px-2 text-xs font-medium text-text-primary shadow-sm transition-[background-color,border-color,transform] hover:-translate-y-px hover:border-border-strong hover:bg-element-hover focus-within:ring-2 focus-within:ring-accent/30';
const fileActionLabelClass = 'hidden sm:inline'; const fileActionLabelClass = 'hidden sm:inline';
export function WorkbenchHeader({ export function WorkbenchHeader({
paused, paused,
@@ -74,11 +75,16 @@ export function WorkbenchHeader({
onToggleFullscreen: () => void; onToggleFullscreen: () => void;
}) { }) {
return ( return (
<header className="relative z-40 grid h-10 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(max-content,1fr)] items-center gap-2 border-b border-border bg-panel px-2.5"> <header className="relative z-40 grid h-10 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(max-content,1fr)] items-center gap-2 border-b border-border bg-panel/95 px-2.5 shadow-[0_1px_0_rgba(255,255,255,0.02)] backdrop-blur-md">
<div className="flex min-w-0 items-center gap-1"> <div className="flex min-w-0 items-center gap-1">
<h1 className="mr-2 hidden truncate border-r border-border pr-3 text-sm font-semibold text-text-primary xl:block"> <div className="mr-2 hidden items-center gap-2 border-r border-border pr-3 xl:flex">
MuJoCo Web 仿 <span className="grid h-7 w-7 place-items-center rounded-lg border border-accent/25 bg-accent-soft text-accent shadow-sm">
</h1> <Boxes aria-hidden="true" className="h-3.5 w-3.5" />
</span>
<h1 className="truncate text-sm font-semibold tracking-tight text-text-primary">
MuJoCo Web 仿
</h1>
</div>
<label <label
aria-disabled={loading} aria-disabled={loading}
className={`${fileActionClass} ${loading ? 'pointer-events-none opacity-40' : ''}`} className={`${fileActionClass} ${loading ? 'pointer-events-none opacity-40' : ''}`}
@@ -113,14 +119,16 @@ export function WorkbenchHeader({
onChange={onFolder} onChange={onFolder}
/> />
</label> </label>
<IconButton <button
tooltip="查看和修改缓存源代码" type="button"
aria-label="源代码" className={`${fileActionClass} disabled:cursor-not-allowed disabled:opacity-40`}
title="查看和修改缓存源代码"
disabled={!hasProject || loading} disabled={!hasProject || loading}
onClick={onOpenSource} onClick={onOpenSource}
> >
<Code2 className="h-4 w-4" /> <Code2 className="h-3.5 w-3.5" />
</IconButton> <span className={fileActionLabelClass}></span>
</button>
</div> </div>
<div className="flex items-center justify-center">{center}</div> <div className="flex items-center justify-center">{center}</div>
<div className="flex min-w-0 items-center justify-end gap-0.5"> <div className="flex min-w-0 items-center justify-end gap-0.5">
@@ -1,23 +1,31 @@
import { import {
Box, Box,
CheckCircle2,
FileArchive,
FolderOpen, FolderOpen,
LoaderCircle, LoaderCircle,
PlayCircle, PlayCircle,
Settings2, Settings2,
ShieldCheck, ShieldCheck,
Sparkles,
Upload, Upload,
UploadCloud, UploadCloud,
} from 'lucide-react'; } from 'lucide-react';
import { ProgressBar, Skeleton } from '../../components/ui'; import { ProgressBar, Skeleton } from '../../components/ui';
export interface ImportProgress { export interface ImportProgress {
title?: string;
label: string; label: string;
detail?: string;
value: number; value: number;
} }
const workflow = [ const workflow = [
{ label: '导入', detail: 'URDF、MJCF 或工程包', icon: UploadCloud }, { label: '导入', detail: 'URDF、MJCF 或工程包', icon: UploadCloud },
{ label: '检查与配置', detail: '结构、驱动器与传感器', icon: Settings2 }, { label: '检查与配置', detail: '结构、驱动器与传感器', icon: Settings2 },
{ label: '运行与调试', detail: '控制、策略与物理状态', icon: PlayCircle }, { label: '运行与调试', detail: '控制、策略与物理状态', icon: PlayCircle },
]; ];
export function EmptyWorkspace({ compact = false }: { compact?: boolean }) { export function EmptyWorkspace({ compact = false }: { compact?: boolean }) {
if (compact) if (compact)
return ( return (
@@ -29,65 +37,131 @@ export function EmptyWorkspace({ compact = false }: { compact?: boolean }) {
<p className="mt-1 text-xs text-text-tertiary"> MJCF/XMLURDF ZIP</p> <p className="mt-1 text-xs text-text-tertiary"> MJCF/XMLURDF ZIP</p>
</div> </div>
); );
return ( return (
<section <section
aria-label="导入模型工程" aria-label="导入模型工程"
className="w-[min(560px,calc(100vw-32px))] rounded-2xl border border-border-strong bg-panel/90 p-6 text-center shadow-2xl backdrop-blur-md" className="workspace-welcome relative w-[min(600px,calc(100vw-32px))] overflow-hidden rounded-[22px] border border-border-strong bg-panel/90 px-6 py-6 text-center shadow-2xl backdrop-blur-md sm:px-7"
> >
<span className="mx-auto mb-3 grid h-11 w-11 place-items-center rounded-xl bg-accent-soft text-accent"> <div aria-hidden="true" className="welcome-glow" />
<UploadCloud className="h-5 w-5" /> <div className="relative">
</span> <div className="mb-4 flex items-center justify-center gap-2 text-[10px] font-semibold uppercase tracking-[0.18em] text-accent">
<h2 className="text-base font-semibold text-text-primary"></h2> <Sparkles className="h-3.5 w-3.5" />
<p className="mt-1 text-xs text-text-tertiary"> MJCF/XMLURDF ZIP</p> Local Simulation Workspace
<div className="mt-4 flex items-center justify-center gap-2"> </div>
<label <span className="mx-auto mb-4 grid h-14 w-14 place-items-center rounded-2xl border border-accent/25 bg-accent-soft text-accent shadow-[0_12px_32px_rgba(53,199,146,0.16)]">
htmlFor="mujoco-project-files" <UploadCloud className="h-6 w-6" />
className="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 text-xs font-medium text-white transition-colors hover:bg-accent-hover focus-within:ring-2 focus-within:ring-accent/40" </span>
<h2 className="text-lg font-semibold tracking-tight text-text-primary">
</h2>
<p className="mt-1.5 text-xs leading-5 text-text-tertiary">
</p>
<div
className="mt-4 flex flex-wrap items-center justify-center gap-1.5"
aria-label="支持格式"
> >
<Upload className="h-3.5 w-3.5" /> {['MJCF / XML', 'URDF', 'ZIP', 'OBJ / STL / DAE'].map((format) => (
<span
</label> key={format}
<label className="rounded-full border border-border bg-surface/80 px-2.5 py-1 text-[10px] font-medium text-text-secondary"
htmlFor="mujoco-project-folder" >
className="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-border bg-surface px-3 text-xs font-medium text-text-primary transition-colors hover:bg-element-hover focus-within:ring-2 focus-within:ring-accent/40" {format}
>
<FolderOpen className="h-3.5 w-3.5" />
</label>
</div>
<ol
aria-label="仿真工作流程"
className="mt-5 hidden grid-cols-3 gap-2 border-t border-border pt-4 sm:grid"
>
{workflow.map((item, index) => (
<li key={item.label} className="rounded-lg bg-surface px-3 py-2.5 text-left">
<div className="flex items-center gap-2">
<span className="technical-value text-[10px] font-semibold text-accent">
0{index + 1}
</span>
<item.icon aria-hidden="true" className="h-3.5 w-3.5 text-text-secondary" />
<span className="text-xs font-medium text-text-primary">{item.label}</span>
</div>
<span className="mt-1 block text-[10px] leading-4 text-text-tertiary">
{item.detail}
</span> </span>
</li> ))}
))} </div>
</ol> <div className="mt-5 flex items-center justify-center gap-2">
<p className="mt-3 flex items-center justify-center gap-1.5 text-[10px] text-text-tertiary"> <label
<ShieldCheck aria-hidden="true" className="h-3 w-3 text-success" /> htmlFor="mujoco-project-files"
className="inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3.5 text-xs font-semibold text-white shadow-[0_8px_22px_rgba(22,131,95,0.22)] transition-[background-color,transform,box-shadow] hover:-translate-y-0.5 hover:bg-accent-hover hover:shadow-[0_10px_26px_rgba(22,131,95,0.28)] focus-within:ring-2 focus-within:ring-accent/40"
</p> >
<Upload className="h-3.5 w-3.5" />
</label>
<label
htmlFor="mujoco-project-folder"
className="inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-lg border border-border bg-surface px-3.5 text-xs font-semibold text-text-primary shadow-sm transition-[background-color,transform] hover:-translate-y-0.5 hover:bg-element-hover focus-within:ring-2 focus-within:ring-accent/40"
>
<FolderOpen className="h-3.5 w-3.5" />
</label>
</div>
<ol
aria-label="仿真工作流程"
className="mt-6 hidden grid-cols-3 gap-2 border-t border-border/80 pt-4 sm:grid"
>
{workflow.map((item, index) => (
<li
key={item.label}
className="rounded-xl border border-border/70 bg-surface/70 px-3 py-3 text-left"
>
<div className="flex items-center gap-2">
<span className="technical-value text-[10px] font-semibold text-accent">
0{index + 1}
</span>
<item.icon aria-hidden="true" className="h-3.5 w-3.5 text-text-secondary" />
<span className="text-xs font-semibold text-text-primary">{item.label}</span>
</div>
<span className="mt-1 block text-[10px] leading-4 text-text-tertiary">
{item.detail}
</span>
</li>
))}
</ol>
<p className="mt-4 flex items-center justify-center gap-1.5 text-[10px] text-text-tertiary">
<ShieldCheck aria-hidden="true" className="h-3.5 w-3.5 text-success" />
</p>
</div>
</section> </section>
); );
} }
function LoadingCard({ progress, compact }: { progress?: ImportProgress; compact: boolean }) {
const title = progress?.title ?? '正在加载 MuJoCo 与模型';
return (
<div
className={`${compact ? 'w-[min(360px,calc(100vw-32px))]' : 'w-[min(400px,calc(100vw-32px))]'} overflow-hidden rounded-2xl border border-border-strong bg-panel/95 shadow-2xl backdrop-blur-md`}
>
<div className="h-0.5 bg-gradient-to-r from-transparent via-accent to-transparent opacity-90" />
<div className={compact ? 'px-4 py-3.5' : 'px-5 py-5'}>
<div className="flex items-start gap-3">
<span className="relative mt-0.5 grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-accent-soft text-accent">
<LoaderCircle aria-hidden="true" className="h-5 w-5 animate-spin" />
<span className="absolute inset-0 animate-ping rounded-xl border border-accent/20" />
</span>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-text-primary">{title}</div>
<div className="mt-0.5 truncate text-[11px] text-text-tertiary">
{progress?.detail ?? '首次运行会下载并编译本地 WebAssembly 运行时'}
</div>
</div>
</div>
{progress ? (
<div className="mt-4">
<ProgressBar value={progress.value} label={progress.label} />
</div>
) : (
<div className="mt-4 space-y-2">
<Skeleton className="h-2.5 w-full" />
<Skeleton className="h-2.5 w-4/5" />
</div>
)}
</div>
</div>
);
}
export function WorkspaceOverlays({ export function WorkspaceOverlays({
loading, loading,
hasSnapshot, hasSnapshot,
dragActive = false,
progress, progress,
}: { }: {
loading: boolean; loading: boolean;
hasSnapshot: boolean; hasSnapshot: boolean;
dragActive?: boolean;
progress?: ImportProgress; progress?: ImportProgress;
}) { }) {
return ( return (
@@ -104,23 +178,26 @@ export function WorkspaceOverlays({
role="status" role="status"
aria-live="polite" aria-live="polite"
aria-label={progress?.label ?? '正在加载 MuJoCo 与模型'} aria-label={progress?.label ?? '正在加载 MuJoCo 与模型'}
className="absolute inset-0 z-20 grid place-items-center bg-app/75 backdrop-blur-sm" className={
hasSnapshot
? 'pointer-events-none absolute right-4 top-4 z-30'
: 'absolute inset-0 z-30 grid place-items-center bg-app/70 p-4 backdrop-blur-[3px]'
}
> >
<div className="w-80 rounded-xl border border-border bg-panel px-5 py-4 text-sm font-medium text-text-primary shadow-xl"> <LoadingCard progress={progress} compact={hasSnapshot} />
<div className="flex items-center gap-3"> </div>
<LoaderCircle aria-hidden="true" className="h-5 w-5 animate-spin text-accent" /> )}
MuJoCo {dragActive && !loading && (
</div> <div className="pointer-events-none absolute inset-3 z-40 grid place-items-center rounded-2xl border-2 border-dashed border-accent bg-accent-soft/20 shadow-[inset_0_0_80px_rgba(53,199,146,0.08)] backdrop-blur-[2px]">
{progress ? ( <div className="rounded-2xl border border-accent/30 bg-panel/95 px-7 py-5 text-center shadow-2xl">
<div className="mt-4"> <span className="mx-auto mb-3 grid h-11 w-11 place-items-center rounded-xl bg-accent-soft text-accent">
<ProgressBar value={progress.value} label={progress.label} /> <FileArchive className="h-5 w-5" />
</div> </span>
) : ( <p className="text-sm font-semibold text-text-primary"></p>
<div className="mt-4 space-y-2"> <p className="mt-1 flex items-center justify-center gap-1.5 text-[11px] text-text-tertiary">
<Skeleton className="h-2.5 w-full" /> <CheckCircle2 className="h-3 w-3 text-success" />
<Skeleton className="h-2.5 w-4/5" /> ZIP
</div> </p>
)}
</div> </div>
</div> </div>
)} )}
+174
View File
@@ -0,0 +1,174 @@
import { composeProjectMap } from './MapComposer';
import type { ProjectFile, ProjectManifest } from '../project/types';
import type { ResolvedProjectMap } from './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>
<compiler meshdir="robot_meshes"/>
<worldbody>
<geom name="__platform_ground__" type="plane" size="2 2 .1"/>
<body name="robot" pos="1 0 0"><freejoint/><geom type="box" size=".1 .1 .1"/></body>
</worldbody>
</mujoco>`);
const mapXml = `<mujoco>
<compiler meshdir="meshes"/>
<asset><mesh name="wall" file="wall.obj"/></asset>
<worldbody><body name="building"><geom name="wall_geom" type="mesh" mesh="wall"/></body></worldbody>
</mujoco>`;
const definition: ResolvedProjectMap = {
descriptorPath: 'maps/warehouse/map.json',
physicsPath: 'maps/warehouse/physics/world.xml',
visualPath: 'maps/warehouse/visuals/scene.glb',
definition: {
schemaVersion: 1,
id: 'warehouse',
name: '仓库',
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
physics: { source: 'physics/world.xml' },
visual: { source: 'visuals/scene.glb' },
spawnPoints: [{ id: 'door', name: '入口', position: [10, 20, 0.5], yawDeg: 90 }],
},
};
function manifest(mapSource = mapXml): ProjectManifest {
const files = [
file('robot/model.xml', decoder.decode(robot)),
file('maps/warehouse/physics/world.xml', mapSource),
file('maps/warehouse/physics/meshes/wall.obj', 'v 0 0 0'),
];
return {
id: 'test',
name: 'test',
files,
entries: [{ path: 'robot/model.xml', format: 'mjcf', label: 'robot' }],
maps: [],
totalBytes: files.reduce((sum, item) => sum + item.size, 0),
};
}
describe('composeProjectMap', () => {
it('合并静态地图、重写资源、命名空间并应用出生点', () => {
const result = composeProjectMap(robot, 'robot/.__scene.xml', manifest(), definition, {
kind: 'project',
descriptorPath: definition.descriptorPath,
spawnPointId: 'door',
frictionOverride: 0.7,
});
const document = new DOMParser().parseFromString(
decoder.decode(result.data),
'application/xml',
);
expect(document.querySelector('[name="__platform_ground__"]')).not.toBeNull();
const mesh = document.querySelector('asset mesh');
expect(mesh?.getAttribute('name')).toBe('__platform_map_warehouse_wall');
expect(mesh?.getAttribute('file')).toBe('../../maps/warehouse/physics/meshes/wall.obj');
const mapGeom = document.querySelector('[name="__platform_map_warehouse_wall_geom"]');
expect(mapGeom?.getAttribute('mesh')).toBe('__platform_map_warehouse_wall');
expect(mapGeom?.getAttribute('group')).toBe('2');
expect(mapGeom?.getAttribute('friction')).toBe('0.7 0.005 0.0001');
const position = document
.querySelector('body[name="robot"]')!
.getAttribute('pos')!
.split(/\s+/)
.map(Number);
expect(position[0]).toBeCloseTo(10);
expect(position[1]).toBeCloseTo(21);
expect(position[2]).toBeCloseTo(0.5);
expect(result.warnings.join(' ')).toContain('入口');
});
it('地图明确提供地面时替换平台基础地面', () => {
const floorMap = manifest(
'<mujoco><worldbody><geom name="floor" type="box" pos="0 0 -.1" size="5 5 .1"/></worldbody></mujoco>',
);
const result = composeProjectMap(robot, 'robot/scene.xml', floorMap, definition, {
kind: 'project',
descriptorPath: definition.descriptorPath,
});
const document = new DOMParser().parseFromString(
decoder.decode(result.data),
'application/xml',
);
expect(document.querySelector('[name="__platform_ground__"]')).toBeNull();
expect(document.querySelector('[name="__platform_map_warehouse_floor"]')).not.toBeNull();
});
it('重写 cube texture 的多文件属性', () => {
const cubeMap = manifest(
'<mujoco><compiler texturedir="textures"/><asset><texture name="sky" type="cube" fileup="up.png"/></asset><worldbody/></mujoco>',
);
cubeMap.files.push(file('maps/warehouse/physics/textures/up.png', 'png'));
const result = composeProjectMap(robot, 'robot/scene.xml', cubeMap, definition, {
kind: 'project',
descriptorPath: definition.descriptorPath,
});
const document = new DOMParser().parseFromString(
decoder.decode(result.data),
'application/xml',
);
expect(document.querySelector('asset texture')?.getAttribute('fileup')).toBe(
'../maps/warehouse/physics/textures/up.png',
);
});
it('拒绝依赖 compiler 角度语义的地图姿态和根 Body zaxis', () => {
const angleMap = manifest(
'<mujoco><compiler angle="radian"/><worldbody><geom type="box" size="1 1 1"/></worldbody></mujoco>',
);
expect(() =>
composeProjectMap(robot, 'robot/scene.xml', angleMap, definition, {
kind: 'project',
descriptorPath: definition.descriptorPath,
}),
).toThrow('compiler');
const eulerMap = manifest(
'<mujoco><worldbody><geom type="box" size="1 1 1" euler="0 0 90"/></worldbody></mujoco>',
);
expect(() =>
composeProjectMap(robot, 'robot/scene.xml', eulerMap, definition, {
kind: 'project',
descriptorPath: definition.descriptorPath,
}),
).toThrow('姿态必须使用 quat');
const zaxisRobot = encoder.encode(
'<mujoco><worldbody><body name="robot" zaxis="0 0 1"><freejoint/><geom type="box" size="1 1 1"/></body></worldbody></mujoco>',
);
expect(() =>
composeProjectMap(zaxisRobot, 'robot/scene.xml', manifest(), definition, {
kind: 'project',
descriptorPath: definition.descriptorPath,
spawnPointId: 'door',
}),
).toThrow('zaxis');
});
it('拒绝动态地图和缺失资源', () => {
const dynamic = manifest(
'<mujoco><worldbody><body><joint/><geom type="box" size="1 1 1"/></body></worldbody></mujoco>',
);
expect(() =>
composeProjectMap(robot, 'robot/scene.xml', dynamic, definition, {
kind: 'project',
descriptorPath: definition.descriptorPath,
}),
).toThrow('静态场景');
const missing = manifest();
missing.files = missing.files.filter((item) => !item.path.endsWith('wall.obj'));
expect(() =>
composeProjectMap(robot, 'robot/scene.xml', missing, definition, {
kind: 'project',
descriptorPath: definition.descriptorPath,
}),
).toThrow('资源不存在');
});
});
+263
View File
@@ -0,0 +1,263 @@
import type { ProjectManifest } from '../project/types';
import { resolveProjectAssetPath, relativeAssetPath } from './mapPaths';
import type { MapSelection, ResolvedProjectMap, SpawnPoint } from './types';
const decoder = new TextDecoder('utf-8');
const encoder = new TextEncoder();
const MAP_PREFIX = '__platform_map_';
const REFERENCE_ATTRIBUTES = ['mesh', 'material', 'hfield', 'texture'] as const;
const FILE_ATTRIBUTES = [
'file',
'fileup',
'filedown',
'fileleft',
'fileright',
'filefront',
'fileback',
] as const;
export interface ProjectMapComposition {
data: Uint8Array;
geomCount: number;
warnings: string[];
summary: string;
}
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 无法解析`);
if (document.documentElement.tagName !== 'mujoco')
throw new Error(`${label} 根元素必须是 mujoco`);
return document;
}
function numbers(value: string | null, fallback: number[]): number[] {
if (!value) return fallback;
const parsed = value.trim().split(/\s+/).map(Number);
return parsed.every(Number.isFinite) ? parsed : fallback;
}
function multiplyQuaternion(a: number[], b: number[]): [number, number, number, number] {
return [
a[0] * b[0] - a[1] * b[1] - a[2] * b[2] - a[3] * b[3],
a[0] * b[1] + a[1] * b[0] + a[2] * b[3] - a[3] * b[2],
a[0] * b[2] - a[1] * b[3] + a[2] * b[0] + a[3] * b[1],
a[0] * b[3] + a[1] * b[2] - a[2] * b[1] + a[3] * b[0],
];
}
function applySpawnPoint(
document: Document,
spawn: SpawnPoint | undefined,
requestedBody: string | undefined,
warnings: string[],
): void {
if (!spawn) return;
const worldbody = document.querySelector('mujoco > worldbody');
if (!worldbody) throw new Error('机器人 MJCF 缺少 worldbody');
const roots = Array.from(worldbody.children).filter((element) => element.tagName === 'body');
let root = requestedBody
? roots.find((body) => body.getAttribute('name') === requestedBody)
: undefined;
if (requestedBody && !root) throw new Error(`找不到机器人根 Body${requestedBody}`);
if (!root) {
const dynamic = roots.filter((body) => body.querySelector('joint, freejoint'));
if (dynamic.length === 1) root = dynamic[0];
else if (roots.length === 1) root = roots[0];
}
if (!root) {
warnings.push('无法唯一确定机器人根 Body,未应用地图出生点');
return;
}
if (
root.hasAttribute('euler') ||
root.hasAttribute('axisangle') ||
root.hasAttribute('xyaxes') ||
root.hasAttribute('zaxis')
)
throw new Error(
'出生点暂不支持带 euler、axisangle、xyaxes 或 zaxis 的机器人根 Body,请改用 quat',
);
const yaw = (spawn.yawDeg * Math.PI) / 180;
const cosine = Math.cos(yaw);
const sine = Math.sin(yaw);
const position = numbers(root.getAttribute('pos'), [0, 0, 0]);
root.setAttribute(
'pos',
[
spawn.position[0] + cosine * position[0] - sine * position[1],
spawn.position[1] + sine * position[0] + cosine * position[1],
spawn.position[2] + position[2],
].join(' '),
);
const yawQuaternion = [Math.cos(yaw / 2), 0, 0, Math.sin(yaw / 2)];
const currentQuaternion = numbers(root.getAttribute('quat'), [1, 0, 0, 0]);
root.setAttribute('quat', multiplyQuaternion(yawQuaternion, currentQuaternion).join(' '));
warnings.push(`机器人已放置到出生点“${spawn.name}`);
}
function mapAssetBase(mapDocument: Document, asset: Element): string {
const compiler = mapDocument.querySelector('mujoco > compiler');
const assetDirectory = compiler?.getAttribute('assetdir') ?? '';
const specificDirectory =
asset.tagName === 'mesh'
? (compiler?.getAttribute('meshdir') ?? '')
: asset.tagName === 'texture'
? (compiler?.getAttribute('texturedir') ?? '')
: '';
return specificDirectory || assetDirectory;
}
function validateMapStructure(document: Document): void {
const allowedSections = new Set(['compiler', 'asset', 'worldbody']);
for (const section of Array.from(document.documentElement.children)) {
if (!allowedSections.has(section.tagName))
throw new Error(`物理地图暂不支持 mujoco/${section.tagName}`);
}
const compiler = document.querySelector('mujoco > compiler');
const allowedCompilerAttributes = new Set(['assetdir', 'meshdir', 'texturedir']);
for (const attribute of Array.from(compiler?.attributes ?? [])) {
if (!allowedCompilerAttributes.has(attribute.name))
throw new Error(`物理地图 compiler 暂不支持属性 ${attribute.name}`);
}
if (document.querySelector('include')) throw new Error('物理地图暂不支持 include');
if (document.querySelector('[euler], [axisangle], [xyaxes], [zaxis]'))
throw new Error('物理地图姿态必须使用 quat,不能依赖 compiler 的角度语义');
if (document.querySelector('joint, freejoint, body[mocap="true"]'))
throw new Error('物理地图必须是静态场景,不能包含 joint 或 mocap body');
if (document.querySelector('default, [class], [childclass]'))
throw new Error('物理地图 V2 暂不支持 default class');
const allowedAssets = new Set(['mesh', 'hfield', 'texture', 'material']);
for (const asset of Array.from(document.querySelectorAll('mujoco > asset > *'))) {
if (!allowedAssets.has(asset.tagName))
throw new Error(`物理地图暂不支持 asset/${asset.tagName}`);
}
}
function mapProvidesGround(document: Document): boolean {
for (const geom of Array.from(document.querySelectorAll('worldbody geom'))) {
const type = geom.getAttribute('type') ?? 'sphere';
if (type === 'plane' || type === 'hfield') return true;
const name = geom.getAttribute('name') ?? '';
if (/(^|[_-])(ground|floor|terrain)([_-]|$)/i.test(name)) return true;
}
return false;
}
function namespaceMap(document: Document, mapId: string): void {
const prefix = `${MAP_PREFIX}${mapId.replace(/[^a-zA-Z0-9_-]/g, '_')}_`;
const names = new Map<string, string>();
for (const element of Array.from(document.querySelectorAll('[name]'))) {
const original = element.getAttribute('name');
if (!original) continue;
const renamed = `${prefix}${original}`;
names.set(original, renamed);
element.setAttribute('name', renamed);
}
for (const element of Array.from(document.querySelectorAll('*'))) {
for (const attribute of REFERENCE_ATTRIBUTES) {
const value = element.getAttribute(attribute);
const renamed = value ? names.get(value) : undefined;
if (renamed) element.setAttribute(attribute, renamed);
}
}
let unnamedGeom = 0;
for (const geom of Array.from(document.querySelectorAll('worldbody geom'))) {
if (!geom.hasAttribute('name')) geom.setAttribute('name', `${prefix}geom_${++unnamedGeom}`);
geom.setAttribute('group', '2');
}
}
function rebaseAssets(
document: Document,
destinationDocument: Document,
manifest: ProjectManifest,
mapSourcePath: string,
generatedScenePath: string,
): void {
const files = new Set(manifest.files.map((file) => file.path));
for (const asset of Array.from(document.querySelectorAll('mujoco > asset > *'))) {
for (const attribute of FILE_ATTRIBUTES) {
const reference = asset.getAttribute(attribute);
if (!reference) continue;
const directory = mapAssetBase(document, asset);
const target = resolveProjectAssetPath(
mapSourcePath,
directory ? `${directory}/${reference}` : reference,
);
if (!files.has(target)) throw new Error(`物理地图资源不存在:${target}`);
const destinationDirectory = mapAssetBase(destinationDocument, asset);
const referenceBase = destinationDirectory
? resolveProjectAssetPath(generatedScenePath, `${destinationDirectory}/.__asset__`)
: generatedScenePath;
asset.setAttribute(attribute, relativeAssetPath(referenceBase, target));
}
}
}
/** 合并工程地图与最终机器人 MJCF;地图只允许静态 worldbody 和基础 asset。 */
export function composeProjectMap(
robotSource: Uint8Array,
generatedScenePath: string,
manifest: ProjectManifest,
resolvedMap: ResolvedProjectMap,
selection: Extract<MapSelection, { kind: 'project' }>,
): 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)
throw new Error(`地图中不存在出生点:${selection.spawnPointId}`);
applySpawnPoint(robot, spawn, selection.robotRootBody, warnings);
let geomCount = 0;
if (resolvedMap.physicsPath) {
const mapFile = manifest.files.find((file) => file.path === resolvedMap.physicsPath);
if (!mapFile) throw new Error(`物理地图文件不存在:${resolvedMap.physicsPath}`);
const map = xml(mapFile.data, '物理地图');
validateMapStructure(map);
const replacesGround = mapProvidesGround(map);
rebaseAssets(map, robot, manifest, resolvedMap.physicsPath, generatedScenePath);
namespaceMap(map, 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')))
geom.setAttribute('friction', `${friction} 0.005 0.0001`);
}
// 编辑地图(例如只包含楼梯或障碍物)是叠加层,不应让 URDF 转换时
// 生成的基础地面失效;只有地图明确提供 floor/ground/terrain 时才替换它。
if (replacesGround) robotWorldbody.querySelector('[name="__platform_ground__"]')?.remove();
const sourceAsset = map.querySelector('mujoco > asset');
if (sourceAsset?.children.length) {
let destinationAsset = robot.querySelector('mujoco > asset');
if (!destinationAsset) {
destinationAsset = robot.createElement('asset');
robot.documentElement.prepend(destinationAsset);
}
for (const asset of Array.from(sourceAsset.children))
destinationAsset.append(robot.importNode(asset, true));
}
const mapWorldbody = map.querySelector('mujoco > worldbody');
if (!mapWorldbody) throw new Error('物理地图缺少 worldbody');
geomCount = mapWorldbody.querySelectorAll('geom').length;
if (geomCount > 10_000) throw new Error(`物理地图包含 ${geomCount} 个 geom,超过 10000 限制`);
if (geomCount > 2_000) warnings.push(`物理地图包含 ${geomCount} 个 geom,可能影响仿真性能`);
for (const child of Array.from(mapWorldbody.children))
robotWorldbody.append(robot.importNode(child, true));
}
return {
data: encoder.encode(new XMLSerializer().serializeToString(robot)),
geomCount,
warnings,
summary: `已加载工程地图“${resolvedMap.definition.name}”(${geomCount} 个物理几何${resolvedMap.visualPath ? ',含 GLB 视觉层' : ''}`,
};
}
+118
View File
@@ -0,0 +1,118 @@
import { discoverMapEntries, resolveProjectMap } from './MapLoader';
import type { ProjectFile, ProjectManifest } from '../project/types';
const encode = (value: string) => new TextEncoder().encode(value);
const file = (path: string, value = ''): ProjectFile => ({
path,
data: encode(value),
size: encode(value).byteLength,
source: 'directory',
mimeType: '',
});
const descriptor = JSON.stringify({
schemaVersion: 1,
id: 'warehouse',
name: '仓库',
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
physics: { source: 'physics/world.xml' },
visual: { source: 'visuals/scene.glb' },
spawnPoints: [{ id: 'door', name: '入口', position: [1, 2, 0.3], yawDeg: 90 }],
});
describe('MapLoader', () => {
it('发现 map.json 并解析工程内资源', () => {
const files = [
file('maps/warehouse/map.json', descriptor),
file('maps/warehouse/physics/world.xml', '<mujoco><worldbody/></mujoco>'),
file('maps/warehouse/visuals/scene.glb'),
];
expect(discoverMapEntries(files)).toEqual([
{
descriptorPath: 'maps/warehouse/map.json',
schemaVersion: 1,
id: 'warehouse',
name: '仓库',
physicsPath: 'maps/warehouse/physics/world.xml',
visualPath: 'maps/warehouse/visuals/scene.glb',
spawnPoints: [{ id: 'door', name: '入口' }],
},
]);
const manifest: ProjectManifest = {
id: 'test',
name: 'test',
files,
entries: [],
maps: discoverMapEntries(files),
totalBytes: files.reduce((sum, item) => sum + item.size, 0),
};
expect(resolveProjectMap(manifest, 'maps/warehouse/map.json')).toMatchObject({
physicsPath: 'maps/warehouse/physics/world.xml',
visualPath: 'maps/warehouse/visuals/scene.glb',
});
});
it('发现 schema V2 authoring 创作层', () => {
const editable = JSON.stringify({
...JSON.parse(descriptor),
schemaVersion: 2,
authoring: { source: 'authoring/map.scene.json' },
});
const files = [
file('maps/warehouse/map.json', editable),
file('maps/warehouse/physics/world.xml'),
file('maps/warehouse/visuals/scene.glb'),
file(
'maps/warehouse/authoring/map.scene.json',
JSON.stringify({
schemaVersion: 1,
mapId: 'warehouse',
revision: 0,
objects: [],
spawnPoints: [],
}),
),
];
expect(discoverMapEntries(files)[0]).toMatchObject({
schemaVersion: 2,
authoringPath: 'maps/warehouse/authoring/map.scene.json',
});
});
it('拒绝没有物理产物的 authoring 地图', () => {
const invalid = JSON.stringify({
schemaVersion: 2,
id: 'editable',
name: 'editable',
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
visual: { source: 'scene.glb' },
authoring: { source: 'map.scene.json' },
spawnPoints: [],
});
expect(() => discoverMapEntries([file('map.json', invalid)])).toThrow('physics.source');
});
it('拒绝坐标约定错误、缺失资源和重复地图 id', () => {
const wrongCoordinates = descriptor.replace('"Z"', '"Y"');
expect(() => discoverMapEntries([file('map.json', wrongCoordinates)])).toThrow(
'coordinateSystem',
);
expect(() => discoverMapEntries([file('map.json', descriptor)])).toThrow('资源不存在');
const physics = file('physics/world.xml');
const visual = file('visuals/scene.glb');
expect(() =>
discoverMapEntries([
file(
'a/map.json',
descriptor.replaceAll('physics/', '../physics/').replaceAll('visuals/', '../visuals/'),
),
file(
'b/map.json',
descriptor.replaceAll('physics/', '../physics/').replaceAll('visuals/', '../visuals/'),
),
physics,
visual,
]),
).toThrow('地图 id 重复');
});
});
+103
View File
@@ -0,0 +1,103 @@
import type { MapEntry, ProjectFile, ProjectManifest } from '../project/types';
import { decodeMapDefinition } from './mapSchema';
import { resolveProjectAssetPath } from './mapPaths';
import type { MapSelection, ResolvedProjectMap, VisualMapAsset } from './types';
import { decodeEditableMapDocument } from './editor/editorSchema';
function projectFile(manifest: ProjectManifest, path: string): ProjectFile {
const file = manifest.files.find((candidate) => candidate.path === path);
if (!file) throw new Error(`地图引用的资源不存在:${path}`);
return file;
}
export function discoverMapEntries(files: ProjectFile[]): MapEntry[] {
const paths = new Set(files.map((file) => file.path));
const filesByPath = new Map(files.map((file) => [file.path, file]));
const ids = new Set<string>();
return files
.filter((file) => /(^|\/)map\.json$/i.test(file.path))
.map((file) => {
const definition = decodeMapDefinition(file.data);
if (ids.has(definition.id)) throw new Error(`地图 id 重复:${definition.id}`);
ids.add(definition.id);
const physicsPath = definition.physics
? resolveProjectAssetPath(file.path, definition.physics.source)
: undefined;
const visualPath = definition.visual
? resolveProjectAssetPath(file.path, definition.visual.source)
: undefined;
const authoringPath = definition.authoring
? resolveProjectAssetPath(file.path, definition.authoring.source)
: undefined;
for (const resolved of [physicsPath, visualPath, authoringPath]) {
if (resolved && !paths.has(resolved))
throw new Error(`地图 ${definition.name} 引用的资源不存在:${resolved}`);
}
if (authoringPath) {
const authoring = filesByPath.get(authoringPath);
if (!authoring) throw new Error(`地图 ${definition.name} 的创作层不存在`);
const editable = decodeEditableMapDocument(authoring.data);
if (editable.mapId !== definition.id)
throw new Error(`地图 ${definition.name} 的创作层 mapId 必须为 ${definition.id}`);
}
return {
descriptorPath: file.path,
schemaVersion: definition.schemaVersion,
id: definition.id,
name: definition.name,
physicsPath,
visualPath,
...(authoringPath ? { authoringPath } : {}),
spawnPoints: definition.spawnPoints.map(({ id, name }) => ({ id, name })),
};
});
}
export function resolveProjectMap(
manifest: ProjectManifest,
descriptorPath: string,
): ResolvedProjectMap {
const descriptor = projectFile(manifest, descriptorPath);
const definition = decodeMapDefinition(descriptor.data);
const physicsPath = definition.physics
? resolveProjectAssetPath(descriptorPath, definition.physics.source)
: undefined;
const visualPath = definition.visual
? resolveProjectAssetPath(descriptorPath, definition.visual.source)
: undefined;
const authoringPath = definition.authoring
? resolveProjectAssetPath(descriptorPath, definition.authoring.source)
: undefined;
if (physicsPath) projectFile(manifest, physicsPath);
if (visualPath) projectFile(manifest, visualPath);
if (authoringPath) {
const editable = decodeEditableMapDocument(projectFile(manifest, authoringPath).data);
if (editable.mapId !== definition.id)
throw new Error(`地图 ${definition.name} 的创作层 mapId 必须为 ${definition.id}`);
}
return {
definition,
descriptorPath,
physicsPath,
visualPath,
authoringPath,
};
}
export function visualMapAsset(
manifest: ProjectManifest,
selection: MapSelection,
): VisualMapAsset | null {
if (selection.kind !== 'project') return null;
const resolved = resolveProjectMap(manifest, selection.descriptorPath);
if (!resolved.visualPath || !resolved.definition.visual) return null;
const visual = projectFile(manifest, resolved.visualPath);
return {
id: resolved.definition.id,
name: resolved.definition.name,
path: resolved.visualPath,
data: visual.data,
castShadow: resolved.definition.visual.castShadow ?? true,
receiveShadow: resolved.definition.visual.receiveShadow ?? true,
};
}
@@ -0,0 +1,54 @@
import { parseEditableMapDocument } from './editorSchema';
import type { EditableMapDocument, EditableMapObject } from './types';
const format = (value: number) => Number(value.toFixed(8)).toString();
const vector = (values: number[]) => values.map(format).join(' ');
const escape = (value: string) =>
value
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
function common(object: EditableMapObject, suffix = ''): string {
return `name="edit_${escape(object.id)}${suffix}" group="2" friction="${vector(object.friction)}" rgba="${vector(object.rgba)}"`;
}
function geomXml(object: EditableMapObject): string[] {
const p = object.parameters;
if (object.type === 'box')
return [
`<geom ${common(object)} type="box" size="${vector([p.sizeX / 2, p.sizeY / 2, p.sizeZ / 2])}"/>`,
];
if (object.type === 'cylinder')
return [`<geom ${common(object)} type="cylinder" size="${vector([p.radius, p.height / 2])}"/>`];
if (object.type === 'capsule')
return [`<geom ${common(object)} type="capsule" size="${vector([p.radius, p.length / 2])}"/>`];
if (object.type === 'ramp') {
const angle = Math.atan2(p.rise, p.length),
half = angle / 2;
return [
`<geom ${common(object)} type="box" size="${vector([Math.hypot(p.length, p.rise) / 2, p.width / 2, p.thickness / 2])}" pos="0 0 ${format(p.rise / 2)}" quat="${vector([Math.cos(half), 0, -Math.sin(half), 0])}"/>`,
];
}
return Array.from({ length: p.count }, (_, index) => {
const height = p.stepHeight * (index + 1);
// 每级使用等深、逐级增高的独立立方柱。旧实现同时增大踏步深度,
// 导致最后一级完全包住前面所有级,编译后看起来只是一个方块。
const x = p.stepDepth * index;
return `<geom ${common(object, `_step_${index + 1}`)} type="box" size="${vector([p.stepDepth / 2, p.width / 2, height / 2])}" pos="${vector([x, 0, height / 2])}"/>`;
});
}
/** 将受约束编辑文档确定性编译成只含静态 geom 的 MJCF。 */
export function compileEditableMapDocument(input: EditableMapDocument): Uint8Array {
const document = parseEditableMapDocument(input);
const bodies = document.objects
.filter((object) => object.enabled)
.map(
(object) =>
` <body name="edit_body_${escape(object.id)}" pos="${vector(object.pose.position)}" quat="${vector(object.pose.quaternion)}">\n ${geomXml(object).join('\n ')}\n </body>`,
);
return new TextEncoder().encode(
`<mujoco model="${escape(document.mapId)}_editable_map">\n <worldbody>\n${bodies.join('\n')}\n </worldbody>\n</mujoco>\n`,
);
}
@@ -0,0 +1,65 @@
import { compileEditableMapDocument } from './MapDocumentCompiler';
import { importEditableMapDocument } from './MapDocumentImporter';
const encode = (value: string) => new TextEncoder().encode(value);
const definition = {
id: 'warehouse',
spawnPoints: [
{ id: 'start', name: '入口', position: [1, 2, 0] as [number, number, number], yawDeg: 90 },
],
};
describe('MapDocumentImporter', () => {
it('转换嵌套静态基础 geom 并合成世界姿态', () => {
const document = importEditableMapDocument(
encode(`<mujoco model="map"><worldbody>
<body name="zone" pos="1 0 0" quat="0.70710678 0 0 0.70710678">
<geom name="wall" type="box" size="1 2 0.5" pos="1 0 0" friction="0.8 0.01 0.001" rgba="1 0 0 1"/>
<body name="post" pos="0 2 0"><geom type="cylinder" size="0.2 1"/></body>
</body>
</worldbody></mujoco>`),
definition,
);
expect(document).toMatchObject({
schemaVersion: 1,
mapId: 'warehouse',
revision: 0,
spawnPoints: definition.spawnPoints,
});
expect(document.objects).toHaveLength(2);
expect(document.objects[0]).toMatchObject({
id: 'wall',
name: 'wall',
type: 'box',
parameters: { sizeX: 2, sizeY: 4, sizeZ: 1 },
friction: [0.8, 0.01, 0.001],
rgba: [1, 0, 0, 1],
placementMode: 'locked',
});
expect(document.objects[0].pose.position[0]).toBeCloseTo(1);
expect(document.objects[0].pose.position[1]).toBeCloseTo(1);
expect(document.objects[1]).toMatchObject({
id: 'post',
type: 'cylinder',
parameters: { radius: 0.2, height: 2 },
});
expect(new TextDecoder().decode(compileEditableMapDocument(document))).toContain(
'name="edit_wall"',
);
});
it.each([
['plane', '<geom type="plane" size="5 5 0.1"/>', '类型 plane'],
['碰撞过滤', '<geom type="box" size="1 1 1" contype="0"/>', '属性 contype'],
[
'asset',
'<asset><mesh name="m" file="m.obj"/></asset><worldbody><geom type="box" size="1 1 1"/></worldbody>',
'带 asset',
],
])('拒绝不可逆的%s转换', (_label, content, message) => {
const xml = content.includes('<worldbody>')
? `<mujoco>${content}</mujoco>`
: `<mujoco><worldbody>${content}</worldbody></mujoco>`;
expect(() => importEditableMapDocument(encode(xml), definition)).toThrow(message);
});
});
@@ -0,0 +1,230 @@
import type { MapDefinition } from '../types';
import { MapValidationError } from '../mapSchema';
import type { EditableMapDocument, EditableMapObject, EditableMapObjectType } from './types';
import { parseEditableMapDocument } from './editorSchema';
const decoder = new TextDecoder('utf-8', { fatal: true });
type Vector3 = [number, number, number];
type Quaternion = [number, number, number, number];
function parseXml(data: Uint8Array): Document {
let text: string;
try {
text = decoder.decode(data);
} catch {
throw new MapValidationError('物理地图必须是有效的 UTF-8 XML');
}
const document = new DOMParser().parseFromString(text, 'application/xml');
if (document.querySelector('parsererror')) throw new MapValidationError('物理地图 XML 无法解析');
if (document.documentElement.tagName !== 'mujoco')
throw new MapValidationError('物理地图根元素必须是 mujoco');
return document;
}
function vector(value: string | null, length: number, fallback: number[], field: string): number[] {
if (value === null) return fallback.slice();
const result = value.trim().split(/\s+/).map(Number);
if (result.length !== length || result.some((item) => !Number.isFinite(item)))
throw new MapValidationError(`${field} 必须包含 ${length} 个有限数字`);
return result;
}
function normalizeQuaternion(value: number[], field: string): Quaternion {
const norm = Math.hypot(...value);
if (norm < 1e-8) throw new MapValidationError(`${field} 不能是零四元数`);
return value.map((item) => item / norm) as Quaternion;
}
function multiply(a: Quaternion, b: Quaternion): Quaternion {
return [
a[0] * b[0] - a[1] * b[1] - a[2] * b[2] - a[3] * b[3],
a[0] * b[1] + a[1] * b[0] + a[2] * b[3] - a[3] * b[2],
a[0] * b[2] - a[1] * b[3] + a[2] * b[0] + a[3] * b[1],
a[0] * b[3] + a[1] * b[2] - a[2] * b[1] + a[3] * b[0],
];
}
function rotate(value: Vector3, quaternion: Quaternion): Vector3 {
const [w, x, y, z] = quaternion;
const uv: Vector3 = [
y * value[2] - z * value[1],
z * value[0] - x * value[2],
x * value[1] - y * value[0],
];
const uuv: Vector3 = [y * uv[2] - z * uv[1], z * uv[0] - x * uv[2], x * uv[1] - y * uv[0]];
return [
value[0] + 2 * (w * uv[0] + uuv[0]),
value[1] + 2 * (w * uv[1] + uuv[1]),
value[2] + 2 * (w * uv[2] + uuv[2]),
];
}
function compose(
parentPosition: Vector3,
parentQuaternion: Quaternion,
localPosition: Vector3,
localQuaternion: Quaternion,
): { position: Vector3; quaternion: Quaternion } {
const translated = rotate(localPosition, parentQuaternion);
return {
position: [
parentPosition[0] + translated[0],
parentPosition[1] + translated[1],
parentPosition[2] + translated[2],
],
quaternion: normalizeQuaternion(multiply(parentQuaternion, localQuaternion), '组合姿态'),
};
}
function assertAttributes(element: Element, allowed: Set<string>, field: string): void {
for (const attribute of Array.from(element.attributes))
if (!allowed.has(attribute.name))
throw new MapValidationError(`${field} 包含不可逆转换的属性 ${attribute.name}`);
}
function uniqueId(raw: string, used: Set<string>): string {
let base = raw
.replace(/^edit_(?:body_)?/, '')
.replace(/[^A-Za-z0-9_-]+/g, '_')
.replace(/^[_-]+/, '');
if (!base || !/^[A-Za-z0-9]/.test(base)) base = `object_${used.size + 1}`;
let value = base;
for (let suffix = 2; used.has(value); suffix += 1) value = `${base}_${suffix}`;
used.add(value);
return value;
}
function importedObject(
geom: Element,
bodyName: string | null,
parentPosition: Vector3,
parentQuaternion: Quaternion,
usedIds: Set<string>,
index: number,
): EditableMapObject {
assertAttributes(
geom,
new Set(['name', 'type', 'size', 'pos', 'quat', 'friction', 'rgba', 'group']),
`geom[${index}]`,
);
const sourceType = geom.getAttribute('type') ?? 'sphere';
if (!['box', 'cylinder', 'capsule'].includes(sourceType))
throw new MapValidationError(
`geom[${index}] 类型 ${sourceType} 无法无损转换;只支持 box、cylinder 和 capsule`,
);
const type = sourceType as EditableMapObjectType;
const sizeLength = type === 'box' ? 3 : 2;
if (!geom.hasAttribute('size')) throw new MapValidationError(`geom[${index}].size 不能为空`);
const size = vector(geom.getAttribute('size'), sizeLength, [], `geom[${index}].size`);
if (size.some((item) => item <= 0))
throw new MapValidationError(`geom[${index}].size 必须大于 0`);
const localPosition = vector(
geom.getAttribute('pos'),
3,
[0, 0, 0],
`geom[${index}].pos`,
) as Vector3;
const localQuaternion = normalizeQuaternion(
vector(geom.getAttribute('quat'), 4, [1, 0, 0, 0], `geom[${index}].quat`),
`geom[${index}].quat`,
);
const pose = compose(parentPosition, parentQuaternion, localPosition, localQuaternion);
const sourceName = geom.getAttribute('name') ?? bodyName ?? `${type}_${index}`;
const id = uniqueId(sourceName, usedIds);
const parameters: Record<string, number> =
type === 'box'
? { sizeX: size[0] * 2, sizeY: size[1] * 2, sizeZ: size[2] * 2 }
: type === 'cylinder'
? { radius: size[0], height: size[1] * 2 }
: { radius: size[0], length: size[1] * 2 };
return {
id,
name: sourceName,
type,
pose,
parameters,
friction: vector(
geom.getAttribute('friction'),
3,
[1, 0.005, 0.0001],
`geom[${index}].friction`,
) as [number, number, number],
rgba: vector(geom.getAttribute('rgba'), 4, [0.5, 0.5, 0.5, 1], `geom[${index}].rgba`) as [
number,
number,
number,
number,
],
// 转换必须先保持源 MJCF 的精确世界位姿;用户可显式改为自动贴地或重力落位。
placementMode: 'locked',
enabled: true,
};
}
/**
* 将严格受限的静态 MJCF 转换为创作层。遇到网格、平面、材质、碰撞过滤或其他
* 无法由编辑 Schema 表达的语义时整体拒绝,绝不静默丢弃内容。
*/
export function importEditableMapDocument(
physicsData: Uint8Array,
definition: Pick<MapDefinition, 'id' | 'spawnPoints'>,
): EditableMapDocument {
const document = parseXml(physicsData);
const allowedSections = new Set(['compiler', 'asset', 'worldbody']);
for (const section of Array.from(document.documentElement.children))
if (!allowedSections.has(section.tagName))
throw new MapValidationError(`mujoco/${section.tagName} 无法转换为受约束创作层`);
const compiler = document.querySelector('mujoco > compiler');
if (compiler && (compiler.attributes.length || compiler.children.length))
throw new MapValidationError('带 compiler 配置的物理地图无法确认无损转换');
if (document.querySelector('mujoco > asset > *'))
throw new MapValidationError('带 asset 的物理地图无法无损转换为受约束创作层');
if (document.querySelector('[euler], [axisangle], [xyaxes], [zaxis], [fromto]'))
throw new MapValidationError('转换只接受使用 pos 和 quat 表达的姿态');
const worldbody = document.querySelector('mujoco > worldbody');
if (!worldbody) throw new MapValidationError('物理地图缺少 worldbody');
const objects: EditableMapObject[] = [];
const usedIds = new Set<string>();
let geomIndex = 0;
const visit = (parent: Element, position: Vector3, quaternion: Quaternion): void => {
for (const child of Array.from(parent.children)) {
if (child.tagName === 'geom') {
objects.push(
importedObject(
child,
parent.tagName === 'body' ? parent.getAttribute('name') : null,
position,
quaternion,
usedIds,
++geomIndex,
),
);
continue;
}
if (child.tagName !== 'body')
throw new MapValidationError(`worldbody/${child.tagName} 无法转换为受约束创作层`);
assertAttributes(
child,
new Set(['name', 'pos', 'quat']),
`body ${child.getAttribute('name') ?? ''}`,
);
const localPosition = vector(child.getAttribute('pos'), 3, [0, 0, 0], 'body.pos') as Vector3;
const localQuaternion = normalizeQuaternion(
vector(child.getAttribute('quat'), 4, [1, 0, 0, 0], 'body.quat'),
'body.quat',
);
const world = compose(position, quaternion, localPosition, localQuaternion);
visit(child, world.position, world.quaternion);
}
};
visit(worldbody, [0, 0, 0], [1, 0, 0, 0]);
if (!objects.length) throw new MapValidationError('物理地图没有可转换的 geom');
return parseEditableMapDocument({
schemaVersion: 1,
mapId: definition.id,
revision: 0,
objects,
spawnPoints: structuredClone(definition.spawnPoints),
});
}
@@ -0,0 +1,151 @@
import { parseEditableMapDocument } from './editorSchema';
import type { SpawnPoint } from '../types';
import { createPlacedMapAsset } from './assetCatalog';
import {
createEditableObject,
type EditableMapDocument,
type EditableMapObject,
type EditableMapObjectType,
type MapObjectPlacementMode,
} from './types';
import { applyObjectPlacement } from './placement';
interface HistoryEntry {
before: EditableMapDocument;
after: EditableMapDocument;
}
const clone = (value: EditableMapDocument): EditableMapDocument => structuredClone(value);
export class MapEditSession {
private current: EditableMapDocument;
private saved: EditableMapDocument;
private undoStack: HistoryEntry[] = [];
private redoStack: HistoryEntry[] = [];
selectedId?: string;
constructor(document: EditableMapDocument) {
this.current = clone(parseEditableMapDocument(document));
this.saved = clone(this.current);
}
get document(): EditableMapDocument {
return clone(this.current);
}
get dirty(): boolean {
return JSON.stringify(this.current) !== JSON.stringify(this.saved);
}
get canUndo(): boolean {
return this.undoStack.length > 0;
}
get canRedo(): boolean {
return this.redoStack.length > 0;
}
private commit(mutator: (draft: EditableMapDocument) => void): void {
const before = clone(this.current),
after = clone(this.current);
mutator(after);
this.current = parseEditableMapDocument(after);
if (JSON.stringify(before) === JSON.stringify(this.current)) return;
this.undoStack.push({ before, after: clone(this.current) });
if (this.undoStack.length > 200) this.undoStack.shift();
this.redoStack = [];
}
add(type: EditableMapObjectType): EditableMapObject {
const object = createEditableObject(type);
applyObjectPlacement(object, this.current.objects);
this.commit((draft) => draft.objects.push(object));
this.selectedId = object.id;
return structuredClone(object);
}
addAsset(
type: EditableMapObjectType,
position?: [number, number, number],
placementMode: MapObjectPlacementMode = 'auto_ground',
): EditableMapObject {
const object = createPlacedMapAsset(type, this.current.objects.length, position, placementMode);
applyObjectPlacement(object, this.current.objects);
this.commit((draft) => draft.objects.push(object));
this.selectedId = object.id;
return structuredClone(object);
}
duplicate(id: string): EditableMapObject {
const source = this.current.objects.find((object) => object.id === id);
if (!source) throw new Error(`找不到编辑对象:${id}`);
const copy = clone({
...this.current,
objects: [source],
}).objects[0];
copy.id = `${copy.type}_${crypto.randomUUID().slice(0, 8)}`;
copy.name = `${copy.name} 副本`;
copy.pose.position = [
copy.pose.position[0] + 0.2,
copy.pose.position[1] + 0.2,
copy.pose.position[2],
];
applyObjectPlacement(copy, this.current.objects);
this.commit((draft) => draft.objects.push(copy));
this.selectedId = copy.id;
return structuredClone(copy);
}
update(id: string, patch: Partial<Omit<EditableMapObject, 'id' | 'type'>>): void {
this.commit((draft) => {
const object = draft.objects.find((candidate) => candidate.id === id);
if (!object) throw new Error(`找不到编辑对象:${id}`);
const next = structuredClone(patch);
if (object.placementMode === 'locked' && next.placementMode === undefined) delete next.pose;
Object.assign(object, next);
applyObjectPlacement(object, draft.objects);
});
}
remove(id: string): void {
this.commit((draft) => {
draft.objects = draft.objects.filter((object) => object.id !== id);
});
if (this.selectedId === id) this.selectedId = undefined;
}
addSpawn(spawn?: Partial<Omit<SpawnPoint, 'id'>> & { id?: string }): SpawnPoint {
const id = spawn?.id ?? `spawn_${crypto.randomUUID().slice(0, 8)}`;
const point: SpawnPoint = {
id,
name: spawn?.name ?? '出生点',
position: spawn?.position ?? [0, 0, 0],
yawDeg: spawn?.yawDeg ?? 0,
};
this.commit((draft) => draft.spawnPoints.push(point));
return structuredClone(point);
}
updateSpawn(id: string, patch: Partial<Omit<SpawnPoint, 'id'>>): void {
this.commit((draft) => {
const spawn = draft.spawnPoints.find((candidate) => candidate.id === id);
if (!spawn) throw new Error(`找不到出生点:${id}`);
Object.assign(spawn, structuredClone(patch));
});
}
removeSpawn(id: string): void {
this.commit((draft) => {
draft.spawnPoints = draft.spawnPoints.filter((spawn) => spawn.id !== id);
});
}
undo(): void {
const entry = this.undoStack.pop();
if (!entry) return;
this.current = clone(entry.before);
this.redoStack.push(entry);
}
redo(): void {
const entry = this.redoStack.pop();
if (!entry) return;
this.current = clone(entry.after);
this.undoStack.push(entry);
}
discard(): void {
this.current = clone(this.saved);
this.undoStack = [];
this.redoStack = [];
this.selectedId = undefined;
}
markSaved(): void {
this.saved = clone(this.current);
this.undoStack = [];
this.redoStack = [];
}
}
@@ -0,0 +1,64 @@
import { zipSync } from 'fflate';
import { normalizeProjectPath } from '../../project/importer';
import type { ProjectManifest } from '../../project/types';
import { resolveProjectMap } from '../MapLoader';
import { resolveProjectAssetPath } from '../mapPaths';
const fileAttributes = [
'file',
'fileup',
'filedown',
'fileleft',
'fileright',
'filefront',
'fileback',
];
/** 导出描述文件及其显式物理/视觉/编辑依赖,ZIP 内保留工程相对路径。 */
export function exportMapPackage(manifest: ProjectManifest, descriptorPath: string): Uint8Array {
const resolved = resolveProjectMap(manifest, descriptorPath);
const selected = new Set(
[descriptorPath, resolved.physicsPath, resolved.visualPath, resolved.authoringPath].filter(
(path): path is string => Boolean(path),
),
);
if (resolved.physicsPath) {
const source = manifest.files.find((file) => file.path === resolved.physicsPath);
if (!source) throw new Error(`物理地图文件不存在:${resolved.physicsPath}`);
const document = new DOMParser().parseFromString(
new TextDecoder().decode(source.data),
'application/xml',
);
if (document.querySelector('parsererror')) throw new Error('物理地图 XML 无法解析,不能导出');
const compiler = document.querySelector('mujoco > compiler');
for (const asset of Array.from(document.querySelectorAll('mujoco > asset > *'))) {
const directory =
asset.tagName === 'mesh'
? (compiler?.getAttribute('meshdir') ?? compiler?.getAttribute('assetdir') ?? '')
: asset.tagName === 'texture'
? (compiler?.getAttribute('texturedir') ?? compiler?.getAttribute('assetdir') ?? '')
: (compiler?.getAttribute('assetdir') ?? '');
for (const attribute of fileAttributes) {
const reference = asset.getAttribute(attribute);
if (reference)
selected.add(
resolveProjectAssetPath(
resolved.physicsPath,
directory ? `${directory}/${reference}` : reference,
),
);
}
}
}
const archive: Record<string, Uint8Array> = {};
for (const path of selected) {
const normalized = normalizeProjectPath(path);
const file = manifest.files.find((candidate) => candidate.path === normalized);
if (!file) throw new Error(`地图导出依赖不存在:${normalized}`);
archive[normalized] =
normalized === descriptorPath
? new TextEncoder().encode(`${JSON.stringify(resolved.definition, null, 2)}\n`)
: file.data;
}
return zipSync(archive, { level: 6 });
}
@@ -0,0 +1,58 @@
import {
createEditableObject,
editableObjectGroundHeight,
type EditableMapObject,
type EditableMapObjectType,
type MapObjectPlacementMode,
} 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 interface CertifiedMapAsset {
type: EditableMapObjectType;
name: string;
description: string;
color: string;
}
export const CERTIFIED_MAP_ASSETS: readonly CertifiedMapAsset[] = [
{ type: 'box', name: '基础方盒', description: '平台、墙体和规则障碍物', color: '#60a5fa' },
{ type: 'cylinder', name: '基础圆柱', description: '立柱和圆形障碍物', color: '#34d399' },
{ type: 'capsule', name: '基础胶囊', description: '圆滑静态障碍物', color: '#a78bfa' },
{ type: 'ramp', name: '标准坡道', description: '可调整长宽和抬升高度', color: '#f59e0b' },
{ type: 'stairs', name: '标准楼梯', description: '可调整踏步尺寸和数量', color: '#f87171' },
] as const;
export function isEditableMapObjectType(value: string): value is EditableMapObjectType {
return CERTIFIED_MAP_ASSETS.some((asset) => asset.type === value);
}
export function defaultAssetPosition(
object: EditableMapObject,
objectCount: number,
): [number, number, number] {
const column = objectCount % 4;
const row = Math.floor(objectCount / 4);
return [column * 1.25, -row * 1.25, editableObjectGroundHeight(object)];
}
export function createPlacedMapAsset(
type: EditableMapObjectType,
objectCount: number,
droppedPosition?: [number, number, number],
placementMode: MapObjectPlacementMode = 'auto_ground',
): EditableMapObject {
const object = createEditableObject(type);
const fallback = defaultAssetPosition(object, objectCount);
object.pose.position = droppedPosition
? [
Math.round(droppedPosition[0] * 10) / 10,
Math.round(droppedPosition[1] * 10) / 10,
fallback[2],
]
: fallback;
object.name = CERTIFIED_MAP_ASSETS.find((asset) => asset.type === type)?.name ?? object.name;
object.placementMode = placementMode;
return object;
}
+162
View File
@@ -0,0 +1,162 @@
import { unzipSync } from 'fflate';
import type { ProjectFile, ProjectManifest } from '../../project/types';
import { MapEditSession } from './MapEditSession';
import { compileEditableMapDocument } from './MapDocumentCompiler';
import { exportMapPackage } from './MapPackageExporter';
import { parseEditableMapDocument } from './editorSchema';
import { createEditableObject, type EditableMapDocument } from './types';
const encoder = new TextEncoder(),
decoder = new TextDecoder();
function document(): EditableMapDocument {
return { schemaVersion: 1, mapId: 'warehouse', revision: 0, objects: [], spawnPoints: [] };
}
function file(path: string, text: string): ProjectFile {
const data = encoder.encode(text);
return { path, data, size: data.byteLength, source: 'zip', mimeType: '' };
}
describe('地图 V3 编辑核心', () => {
it('严格校验编辑文档并归一化四元数', () => {
const value = document();
value.objects.push({
...createEditableObject('box', 'box_1'),
pose: { position: [0, 0, 0], quaternion: [2, 0, 0, 0] },
});
expect(parseEditableMapDocument(value).objects[0].pose.quaternion).toEqual([1, 0, 0, 0]);
expect(() => parseEditableMapDocument({ ...value, unexpected: true })).toThrow('未知字段');
expect(() =>
parseEditableMapDocument({
...value,
objects: [{ ...value.objects[0], parameters: { sizeX: 1 } }],
}),
).toThrow();
expect(() => parseEditableMapDocument({ ...value, revision: 0.5 })).toThrow('安全整数');
const legacy = parseEditableMapDocument({
...value,
objects: [{ ...value.objects[0], navigationRole: 'obstacle' }],
});
expect(legacy.objects[0]).not.toHaveProperty('navigationRole');
});
it('确定性生成五类静态 MJCF 和稳定名称', () => {
const value = document();
for (const type of ['box', 'cylinder', 'capsule', 'ramp', 'stairs'] as const)
value.objects.push(createEditableObject(type, `${type}_1`));
const first = decoder.decode(compileEditableMapDocument(value));
expect(decoder.decode(compileEditableMapDocument(value))).toBe(first);
expect(first).not.toContain('<joint');
expect(first).toContain('name="edit_box_1" group="2"');
expect(first).toContain('edit_stairs_1_step_5');
expect(first).toContain('quat=');
const compiled = new DOMParser().parseFromString(first, 'application/xml');
const steps = Array.from(compiled.querySelectorAll('[name^="edit_stairs_1_step_"]'));
expect(steps).toHaveLength(5);
expect(steps.map((step) => step.getAttribute('size'))).toEqual([
'0.15 0.5 0.075',
'0.15 0.5 0.15',
'0.15 0.5 0.225',
'0.15 0.5 0.3',
'0.15 0.5 0.375',
]);
expect(steps.map((step) => step.getAttribute('pos'))).toEqual([
'0 0 0.075',
'0.3 0 0.15',
'0.6 0 0.225',
'0.9 0 0.3',
'1.2 0 0.375',
]);
});
it('维护增删改、撤销重做和 dirty', () => {
const session = new MapEditSession(document());
const object = session.add('box');
expect(session.dirty).toBe(true);
session.update(object.id, { name: '墙' });
expect(session.document.objects[0].name).toBe('墙');
const copy = session.duplicate(object.id);
expect(copy).toMatchObject({ name: '墙 副本', pose: { position: [0.2, 0.2, 0.5] } });
expect(session.document.objects).toHaveLength(2);
session.undo();
expect(session.document.objects).toHaveLength(1);
session.undo();
expect(session.document.objects[0].name).toBe('box');
session.redo();
expect(session.document.objects[0].name).toBe('墙');
session.remove(object.id);
expect(session.document.objects).toHaveLength(0);
const spawn = session.addSpawn({ name: '入口', position: [1, 2, 0], yawDeg: 90 });
session.updateSpawn(spawn.id, { yawDeg: 180 });
expect(session.document.spawnPoints[0]).toMatchObject({ name: '入口', yawDeg: 180 });
session.removeSpawn(spawn.id);
expect(session.document.spawnPoints).toHaveLength(0);
session.undo();
expect(session.document.spawnPoints).toHaveLength(1);
session.discard();
expect(session.dirty).toBe(false);
});
it('将认证资产按画布落点加入草稿并自动对齐地面', () => {
const session = new MapEditSession(document());
const object = session.addAsset('box', [1.26, -2.34, 0]);
expect(object).toMatchObject({
name: '基础方盒',
placementMode: 'auto_ground',
pose: { position: [1.3, -2.3, 0.5] },
});
});
it('支持重力落位到最高承载面并锁定位姿', () => {
const session = new MapEditSession(document());
const support = session.addAsset('box', [0, 0, 0]);
session.update(support.id, { parameters: { sizeX: 2, sizeY: 2, sizeZ: 1 } });
const settled = session.addAsset('box', [0, 0, 3], 'gravity');
expect(settled.pose.position[2]).toBe(1.5);
session.update(settled.id, { placementMode: 'locked' });
session.update(settled.id, {
pose: { ...settled.pose, position: [5, 5, 5] },
});
expect(session.document.objects.find((item) => item.id === settled.id)?.pose.position).toEqual([
0, 0, 1.5,
]);
});
it('导出地图描述、编辑文件、物理层及显式资产', () => {
const mapJson = JSON.stringify({
schemaVersion: 2,
id: 'warehouse',
name: '仓库',
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
physics: { source: 'physics/world.xml' },
authoring: { source: 'authoring/map.scene.json' },
spawnPoints: [],
});
const files = [
file('maps/w/map.json', mapJson),
file('maps/w/authoring/map.scene.json', JSON.stringify(document())),
file(
'maps/w/physics/world.xml',
'<mujoco><compiler meshdir="meshes"/><asset><mesh name="wall" file="wall.obj"/></asset><worldbody/></mujoco>',
),
file('maps/w/physics/meshes/wall.obj', 'v 0 0 0'),
file('robot.xml', '<mujoco><worldbody/></mujoco>'),
];
const manifest: ProjectManifest = {
id: 'p',
name: 'p',
files,
entries: [{ path: 'robot.xml', format: 'mjcf', label: 'robot.xml' }],
maps: [],
totalBytes: 0,
};
const archive = unzipSync(exportMapPackage(manifest, 'maps/w/map.json'));
expect(Object.keys(archive).sort()).toEqual([
'maps/w/authoring/map.scene.json',
'maps/w/map.json',
'maps/w/physics/meshes/wall.obj',
'maps/w/physics/world.xml',
]);
});
});
+188
View File
@@ -0,0 +1,188 @@
import { MapValidationError } from '../mapSchema';
import type { SpawnPoint } from '../types';
import {
EDITABLE_OBJECT_DEFAULTS,
type EditableMapDocument,
type EditableMapObject,
type EditableMapObjectType,
type MapObjectPlacementMode,
} from './types';
const objectTypes = new Set<EditableMapObjectType>([
'box',
'cylinder',
'capsule',
'ramp',
'stairs',
]);
const legacyRoles = new Set(['auto', 'walkable', 'obstacle', 'ignore']);
const placementModes = new Set<MapObjectPlacementMode>(['auto_ground', 'gravity', 'locked']);
function record(value: unknown, field: string): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new MapValidationError(`${field} 必须是对象`);
return value as Record<string, unknown>;
}
function exact(value: Record<string, unknown>, allowed: string[], field: string): void {
const unknown = Object.keys(value).find((key) => !allowed.includes(key));
if (unknown) throw new MapValidationError(`${field} 包含未知字段 ${unknown}`);
}
function text(value: unknown, field: string): string {
if (typeof value !== 'string' || !value.trim())
throw new MapValidationError(`${field} 必须是非空字符串`);
return value.trim();
}
function id(value: unknown, field: string): string {
const result = text(value, field);
if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(result))
throw new MapValidationError(`${field} 格式无效`);
return result;
}
function number(value: unknown, field: string, min = -1e6, max = 1e6): number {
if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max)
throw new MapValidationError(`${field} 必须是 ${min}${max} 的有限数字`);
return value;
}
function tuple(value: unknown, length: number, field: string, min = -1e6, max = 1e6): number[] {
if (!Array.isArray(value) || value.length !== length)
throw new MapValidationError(`${field} 长度必须为 ${length}`);
return value.map((item, index) => number(item, `${field}[${index}]`, min, max));
}
function parseSpawn(value: unknown, index: number): SpawnPoint {
const source = record(value, `spawnPoints[${index}]`);
exact(source, ['id', 'name', 'position', 'yawDeg'], `spawnPoints[${index}]`);
return {
id: id(source.id, `spawnPoints[${index}].id`),
name: text(source.name ?? source.id, `spawnPoints[${index}].name`),
position: tuple(source.position, 3, `spawnPoints[${index}].position`) as [
number,
number,
number,
],
yawDeg: number(source.yawDeg ?? 0, `spawnPoints[${index}].yawDeg`, -36000, 36000),
};
}
function parseObject(value: unknown, index: number): EditableMapObject {
const field = `objects[${index}]`,
source = record(value, field);
exact(
source,
[
'id',
'name',
'type',
'pose',
'parameters',
'friction',
'rgba',
'navigationRole',
'placementMode',
'enabled',
],
field,
);
if (!objectTypes.has(source.type as EditableMapObjectType))
throw new MapValidationError(`${field}.type 不受支持`);
const type = source.type as EditableMapObjectType;
const pose = record(source.pose, `${field}.pose`);
exact(pose, ['position', 'quaternion'], `${field}.pose`);
const parameterSource = record(source.parameters, `${field}.parameters`);
const required = Object.keys(EDITABLE_OBJECT_DEFAULTS[type]);
exact(parameterSource, required, `${field}.parameters`);
const parameters: Record<string, number> = {};
for (const key of required) {
const integer = type === 'stairs' && key === 'count';
const parsed = number(
parameterSource[key],
`${field}.parameters.${key}`,
integer ? 1 : 0.001,
integer ? 100 : 1000,
);
if (integer && !Number.isInteger(parsed))
throw new MapValidationError(`${field}.parameters.count 必须是整数`);
parameters[key] = parsed;
}
const quaternion = tuple(pose.quaternion, 4, `${field}.pose.quaternion`) as [
number,
number,
number,
number,
];
const norm = Math.hypot(...quaternion);
if (norm < 1e-8) throw new MapValidationError(`${field}.pose.quaternion 不能为零四元数`);
// 兼容早期创作文档,读取后不再写回这个已停用字段。
if (source.navigationRole !== undefined && !legacyRoles.has(String(source.navigationRole)))
throw new MapValidationError(`${field}.navigationRole 不受支持`);
const placementMode = source.placementMode ?? 'auto_ground';
if (!placementModes.has(placementMode as MapObjectPlacementMode))
throw new MapValidationError(`${field}.placementMode 不受支持`);
if (typeof source.enabled !== 'boolean')
throw new MapValidationError(`${field}.enabled 必须是布尔值`);
return {
id: id(source.id, `${field}.id`),
name: text(source.name, `${field}.name`),
type,
pose: {
position: tuple(pose.position, 3, `${field}.pose.position`) as [number, number, number],
quaternion: quaternion.map((v) => v / norm) as [number, number, number, number],
},
parameters,
friction: tuple(source.friction, 3, `${field}.friction`, 0, 10) as [number, number, number],
rgba: tuple(source.rgba, 4, `${field}.rgba`, 0, 1) as [number, number, number, number],
placementMode: placementMode as MapObjectPlacementMode,
enabled: source.enabled,
};
}
export function parseEditableMapDocument(value: unknown): EditableMapDocument {
const source = record(value, 'map.scene.json');
exact(source, ['schemaVersion', 'mapId', 'revision', 'objects', 'spawnPoints'], 'map.scene.json');
if (source.schemaVersion !== 1)
throw new MapValidationError('map.scene.json 仅支持 schemaVersion: 1');
if (!Array.isArray(source.objects) || !Array.isArray(source.spawnPoints))
throw new MapValidationError('objects 和 spawnPoints 必须是数组');
if (source.objects.length > 2_000) throw new MapValidationError('objects 不能超过 2000 个');
if (source.spawnPoints.length > 500) throw new MapValidationError('spawnPoints 不能超过 500 个');
const objects = source.objects.map(parseObject),
spawnPoints = source.spawnPoints.map(parseSpawn);
const geomCount = objects.reduce(
(total, item) =>
total + (item.enabled ? (item.type === 'stairs' ? item.parameters.count : 1) : 0),
0,
);
if (geomCount > 10_000) throw new MapValidationError('编辑地图生成的 geom 不能超过 10000 个');
for (const [label, values] of [
['对象', objects],
['出生点', spawnPoints],
] as const) {
const ids = new Set<string>();
for (const item of values) {
if (ids.has(item.id)) throw new MapValidationError(`${label} id 重复:${item.id}`);
ids.add(item.id);
}
}
const revision = number(source.revision, 'revision', 0, Number.MAX_SAFE_INTEGER);
if (!Number.isSafeInteger(revision)) throw new MapValidationError('revision 必须是安全整数');
return {
schemaVersion: 1,
mapId: id(source.mapId, 'mapId'),
revision,
objects,
spawnPoints,
};
}
export function decodeEditableMapDocument(data: Uint8Array): EditableMapDocument {
try {
return parseEditableMapDocument(
JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(data)),
);
} catch (error) {
if (error instanceof MapValidationError) throw error;
throw new MapValidationError(
`map.scene.json 无法解析:${error instanceof Error ? error.message : String(error)}`,
);
}
}
export function encodeEditableMapDocument(document: EditableMapDocument): Uint8Array {
const validated = parseEditableMapDocument(document);
return new TextEncoder().encode(`${JSON.stringify(validated, null, 2)}\n`);
}
+81
View File
@@ -0,0 +1,81 @@
import { editableObjectGroundHeight, type EditableMapObject } from './types';
interface Footprint {
centerX: number;
centerY: number;
halfX: number;
halfY: number;
}
function localFootprint(object: EditableMapObject): [number, number] {
const parameters = object.parameters;
if (object.type === 'box') return [parameters.sizeX / 2, parameters.sizeY / 2];
if (object.type === 'cylinder' || object.type === 'capsule')
return [parameters.radius, parameters.radius];
if (object.type === 'ramp') return [parameters.length / 2, parameters.width / 2];
return [(parameters.stepDepth * parameters.count) / 2, parameters.width / 2];
}
function footprint(object: EditableMapObject): Footprint {
const [halfWidth, halfDepth] = localFootprint(object),
[w, x, y, z] = object.pose.quaternion,
yaw = Math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z)),
cosine = Math.abs(Math.cos(yaw)),
sine = Math.abs(Math.sin(yaw));
return {
centerX: object.pose.position[0],
centerY: object.pose.position[1],
halfX: cosine * halfWidth + sine * halfDepth,
halfY: sine * halfWidth + cosine * halfDepth,
};
}
function overlaps(a: Footprint, b: Footprint): boolean {
return (
Math.abs(a.centerX - b.centerX) < a.halfX + b.halfX - 1e-6 &&
Math.abs(a.centerY - b.centerY) < a.halfY + b.halfY - 1e-6
);
}
function objectTop(object: EditableMapObject): number {
const parameters = object.parameters,
z = object.pose.position[2];
if (object.type === 'box') return z + parameters.sizeZ / 2;
if (object.type === 'cylinder') return z + parameters.height / 2;
if (object.type === 'capsule') return z + parameters.length / 2 + parameters.radius;
if (object.type === 'ramp') return z + parameters.rise + parameters.thickness / 2;
return z + parameters.stepHeight * parameters.count;
}
/**
* 沿世界 -Z 执行确定性的重力落位:保持对象直立,在 XY 投影重叠的最高静态
* 承载面上停止。地图最终仍编译为静态 geom,不把临时落位动力学写入 MJCF。
*/
export function gravitySettledPosition(
object: EditableMapObject,
objects: EditableMapObject[],
): [number, number, number] {
const target = footprint(object);
let supportTop = 0;
for (const candidate of objects) {
if (candidate.id === object.id || !candidate.enabled || !overlaps(target, footprint(candidate)))
continue;
supportTop = Math.max(supportTop, objectTop(candidate));
}
return [
object.pose.position[0],
object.pose.position[1],
supportTop + editableObjectGroundHeight(object),
];
}
export function applyObjectPlacement(
object: EditableMapObject,
objects: EditableMapObject[],
): void {
if (object.placementMode === 'locked') return;
object.pose.position =
object.placementMode === 'gravity'
? gravitySettledPosition(object, objects)
: [object.pose.position[0], object.pose.position[1], editableObjectGroundHeight(object)];
}
+88
View File
@@ -0,0 +1,88 @@
import type { SpawnPoint } from '../types';
export type EditableMapObjectType = 'box' | 'cylinder' | 'capsule' | 'ramp' | 'stairs';
export type MapEditorTransformMode = 'translate' | 'rotate' | 'scale';
export type MapObjectPlacementMode = 'auto_ground' | 'gravity' | 'locked';
export const MAP_OBJECT_PLACEMENT_LABELS: Record<MapObjectPlacementMode, string> = {
auto_ground: '自动贴地',
gravity: '自动落位(重力)',
locked: '锁定',
};
export function isMapObjectPlacementMode(value: string): value is MapObjectPlacementMode {
return value === 'auto_ground' || value === 'gravity' || value === 'locked';
}
export interface MapEditorTransform {
id: string;
position: [number, number, number];
quaternion: [number, number, number, number];
scale: [number, number, number];
}
export interface MapEditorInteractionCallbacks {
onSelect(id: string | null): void;
onTransform(transform: MapEditorTransform): void;
onAddAsset(
type: EditableMapObjectType,
position?: [number, number, number],
placementMode?: MapObjectPlacementMode,
): void;
}
export interface EditableMapObject {
id: string;
name: string;
type: EditableMapObjectType;
pose: {
position: [number, number, number];
quaternion: [number, number, number, number];
};
parameters: Record<string, number>;
friction: [number, number, number];
rgba: [number, number, number, number];
placementMode: MapObjectPlacementMode;
enabled: boolean;
}
export interface EditableMapDocument {
schemaVersion: 1;
mapId: string;
revision: number;
objects: EditableMapObject[];
spawnPoints: SpawnPoint[];
}
export const EDITABLE_OBJECT_DEFAULTS: Record<EditableMapObjectType, Record<string, number>> = {
box: { sizeX: 1, sizeY: 1, sizeZ: 1 },
cylinder: { radius: 0.5, height: 1 },
capsule: { radius: 0.25, length: 1 },
ramp: { length: 2, width: 1, rise: 0.5, thickness: 0.1 },
stairs: { width: 1, stepDepth: 0.3, stepHeight: 0.15, count: 5 },
};
export function editableObjectGroundHeight(object: EditableMapObject): number {
const parameters = object.parameters;
if (object.type === 'box') return parameters.sizeZ / 2;
if (object.type === 'cylinder') return parameters.height / 2;
if (object.type === 'capsule') return parameters.length / 2 + parameters.radius;
return 0;
}
export function createEditableObject(
type: EditableMapObjectType,
id = `${type}_${crypto.randomUUID().slice(0, 8)}`,
): EditableMapObject {
return {
id,
name: type,
type,
pose: { position: [0, 0, 0], quaternion: [1, 0, 0, 0] },
parameters: { ...EDITABLE_OBJECT_DEFAULTS[type] },
friction: [1, 0.005, 0.0001],
rgba: [0.55, 0.6, 0.68, 1],
placementMode: 'auto_ground',
enabled: true,
};
}
+23
View File
@@ -0,0 +1,23 @@
import { resolveProjectAssetPath } from './mapPaths';
describe('resolveProjectAssetPath', () => {
it('解析工程内相对路径', () => {
expect(resolveProjectAssetPath('maps/a/map.json', 'physics/world.xml')).toBe(
'maps/a/physics/world.xml',
);
expect(resolveProjectAssetPath('maps/a/map.json', '../shared/world.xml')).toBe(
'maps/shared/world.xml',
);
});
it('在解码和斜杠归一化后拒绝绝对路径与协议', () => {
for (const reference of [
'%2Fsecret.xml',
'https%3A%2F%2Fexample.com%2Fa.xml',
'\\\\server\\share.xml',
'C%3A%5Cmap.xml',
'../../../outside.xml',
])
expect(() => resolveProjectAssetPath('maps/a/map.json', reference)).toThrow();
});
});
+44
View File
@@ -0,0 +1,44 @@
function segments(path: string): string[] {
return path
.replaceAll('\\', '/')
.split('/')
.filter((part) => part !== '' && part !== '.');
}
/** 解析工程内相对引用,禁止协议、绝对路径和越过工程根目录。 */
export function resolveProjectAssetPath(fromFile: string, reference: string): string {
let decoded: string;
try {
decoded = decodeURIComponent(reference.split(/[?#]/, 1)[0]).replaceAll('\\', '/');
} catch {
throw new Error(`地图资源路径包含无效编码:${reference}`);
}
if (
!decoded ||
decoded.startsWith('/') ||
/^[a-z][a-z\d+.-]*:/i.test(decoded) ||
/^[A-Za-z]:/.test(decoded) ||
decoded.includes('\0')
)
throw new Error(`地图资源必须使用工程内相对路径:${reference || '(空路径)'}`);
const result = segments(fromFile).slice(0, -1);
for (const part of segments(decoded)) {
if (part === '..') {
if (!result.length) throw new Error(`地图资源路径越过工程根目录:${reference}`);
result.pop();
} else result.push(part);
}
if (!result.length) throw new Error(`地图资源路径无效:${reference}`);
return result.join('/');
}
export function relativeAssetPath(fromFile: string, targetFile: string): string {
const from = segments(fromFile).slice(0, -1);
const target = segments(targetFile);
while (from.length && target.length && from[0] === target[0]) {
from.shift();
target.shift();
}
return `${'../'.repeat(from.length)}${target.join('/')}` || './';
}
+138
View File
@@ -0,0 +1,138 @@
import type { MapDefinition, SpawnPoint } from './types';
export class MapValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'MapValidationError';
}
}
function object(value: unknown, field: string): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new MapValidationError(`${field} 必须是对象`);
return value as Record<string, unknown>;
}
function text(value: unknown, field: string): string {
if (typeof value !== 'string' || !value.trim())
throw new MapValidationError(`${field} 必须是非空字符串`);
return value.trim();
}
function identifier(value: unknown, field: string): string {
const result = text(value, field);
if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(result))
throw new MapValidationError(`${field} 只能包含英文字母、数字、下划线和连字符`);
return result;
}
function optionalBoolean(value: unknown, field: string, fallback: boolean): boolean {
if (value === undefined) return fallback;
if (typeof value !== 'boolean') throw new MapValidationError(`${field} 必须是布尔值`);
return value;
}
function finite(value: unknown, field: string): number {
if (typeof value !== 'number' || !Number.isFinite(value))
throw new MapValidationError(`${field} 必须是有限数字`);
return value;
}
function vector3(value: unknown, field: string): [number, number, number] {
if (!Array.isArray(value) || value.length !== 3)
throw new MapValidationError(`${field} 必须包含 3 个数字`);
return [
finite(value[0], `${field}[0]`),
finite(value[1], `${field}[1]`),
finite(value[2], `${field}[2]`),
];
}
function spawnPoint(value: unknown, index: number): SpawnPoint {
const source = object(value, `spawnPoints[${index}]`);
return {
id: identifier(source.id, `spawnPoints[${index}].id`),
name: text(source.name ?? source.id, `spawnPoints[${index}].name`),
position: vector3(source.position, `spawnPoints[${index}].position`),
yawDeg: finite(source.yawDeg ?? 0, `spawnPoints[${index}].yawDeg`),
};
}
export function parseMapDefinition(value: unknown): MapDefinition {
const source = object(value, 'map.json');
if (source.schemaVersion !== 1 && source.schemaVersion !== 2)
throw new MapValidationError('仅支持 schemaVersion: 1 或 2');
const coordinates = object(source.coordinateSystem, 'coordinateSystem');
if (coordinates.units !== 'm' || coordinates.up !== 'Z' || coordinates.forward !== '+X')
throw new MapValidationError('coordinateSystem 必须为 units=m、up=Z、forward=+X');
const physicsSource = source.physics
? text(object(source.physics, 'physics').source, 'physics.source')
: undefined;
const visualObject = source.visual ? object(source.visual, 'visual') : undefined;
const visualSource = visualObject ? text(visualObject.source, 'visual.source') : undefined;
const authoringSource = source.authoring
? text(object(source.authoring, 'authoring').source, 'authoring.source')
: undefined;
if (authoringSource && source.schemaVersion !== 2)
throw new MapValidationError('authoring 仅支持 schemaVersion: 2');
if (authoringSource && !physicsSource)
throw new MapValidationError('可编辑地图必须同时声明 physics.source');
if (!physicsSource && !visualSource)
throw new MapValidationError('physics.source 和 visual.source 至少需要一个');
if (physicsSource && !/\.xml$/i.test(physicsSource))
throw new MapValidationError('physics.source 必须是 XML 文件');
if (visualSource && !/\.glb$/i.test(visualSource))
throw new MapValidationError('visual.source 必须是自包含 GLB 文件');
if (authoringSource && !/\.scene\.json$/i.test(authoringSource))
throw new MapValidationError('authoring.source 必须是 .scene.json 文件');
const spawnValues = source.spawnPoints ?? [];
if (!Array.isArray(spawnValues)) throw new MapValidationError('spawnPoints 必须是数组');
const spawnPoints = spawnValues.map(spawnPoint);
const spawnIds = new Set<string>();
for (const spawn of spawnPoints) {
if (spawnIds.has(spawn.id)) throw new MapValidationError(`出生点 id 重复:${spawn.id}`);
spawnIds.add(spawn.id);
}
let bounds: MapDefinition['bounds'];
if (source.bounds !== undefined) {
const value = object(source.bounds, 'bounds');
const minimum = vector3(value.min, 'bounds.min');
const maximum = vector3(value.max, 'bounds.max');
if (minimum.some((component, index) => component >= maximum[index]))
throw new MapValidationError('bounds.min 必须在每个轴上小于 bounds.max');
bounds = { min: minimum, max: maximum };
}
return {
schemaVersion: source.schemaVersion,
id: identifier(source.id, 'id'),
name: text(source.name, 'name'),
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
physics: physicsSource ? { source: physicsSource } : undefined,
visual: visualSource
? {
source: visualSource,
castShadow: optionalBoolean(visualObject?.castShadow, 'visual.castShadow', true),
receiveShadow: optionalBoolean(visualObject?.receiveShadow, 'visual.receiveShadow', true),
}
: undefined,
authoring: authoringSource ? { source: authoringSource } : undefined,
spawnPoints,
bounds,
};
}
export function decodeMapDefinition(data: Uint8Array): MapDefinition {
let value: unknown;
try {
value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(data));
} catch (error) {
throw new MapValidationError(
`map.json 无法解析:${error instanceof Error ? error.message : String(error)}`,
);
}
return parseMapDefinition(value);
}
+156
View File
@@ -0,0 +1,156 @@
import { composePhysicalMap, normalizePhysicalMapConfig } from './physicalMap';
import { DEFAULT_PHYSICAL_MAP_CONFIG, type PhysicalMapConfig } from './types';
const encoder = new TextEncoder();
const decoder = new TextDecoder();
function config(overrides: Partial<PhysicalMapConfig>): PhysicalMapConfig {
return { ...DEFAULT_PHYSICAL_MAP_CONFIG, ...overrides };
}
function documentOf(data: Uint8Array): Document {
return new DOMParser().parseFromString(decoder.decode(data), 'application/xml');
}
describe('composePhysicalMap', () => {
it('none 不改写源 MJCF', () => {
const source = encoder.encode('<mujoco><worldbody/></mujoco>');
const result = composePhysicalMap(source, config({ preset: 'none' }));
expect(result.data).toBe(source);
expect(result.geomCount).toBe(0);
});
it('复用现有地面并注入楼梯碰撞几何', () => {
const source = encoder.encode(
'<mujoco><worldbody><geom name="__platform_ground__" type="plane" group="5"/></worldbody></mujoco>',
);
const result = composePhysicalMap(
source,
config({ preset: 'stairs', stairCount: 4, friction: 0.8 }),
);
const document = documentOf(result.data);
expect(document.querySelectorAll('geom[type="plane"]')).toHaveLength(1);
const ground = document.querySelector('[name="__platform_ground__"]');
expect(ground?.getAttribute('group')).toBe('2');
expect(ground?.getAttribute('friction')).toBe('0.8 0.005 0.0001');
expect(document.querySelectorAll('[name^="__platform_map_stair_"]')).toHaveLength(4);
expect(result.geomCount).toBe(4);
});
it('资产地图可通过根节点移动和旋转', () => {
const source = encoder.encode('<mujoco><worldbody/></mujoco>');
const result = composePhysicalMap(
source,
config({ preset: 'stairs', positionX: 2.5, positionY: -1.25, yawDeg: 90 }),
);
const root = documentOf(result.data).querySelector('[name="__platform_map_root__"]');
expect(root?.tagName).toBe('body');
expect(root?.getAttribute('pos')).toBe('2.5 -1.25 0');
const quaternion = root?.getAttribute('quat')?.split(/\s+/).map(Number) ?? [];
expect(quaternion[0]).toBeCloseTo(Math.SQRT1_2);
expect(quaternion[3]).toBeCloseTo(Math.SQRT1_2);
expect(root?.querySelectorAll('[name^="__platform_map_stair_"]')).toHaveLength(8);
});
it('坡道使用与 compiler 角度单位无关的四元数', () => {
const source = encoder.encode('<mujoco><compiler angle="radian"/><worldbody/></mujoco>');
const result = composePhysicalMap(source, config({ preset: 'slope', slopeAngle: 15 }));
const slope = documentOf(result.data).querySelector('[name="__platform_map_slope__"]');
expect(slope?.hasAttribute('quat')).toBe(true);
expect(slope?.hasAttribute('euler')).toBe(false);
});
it('相同种子生成确定的障碍物并避开出生区域', () => {
const source = encoder.encode('<mujoco><worldbody/></mujoco>');
const requested = config({ preset: 'obstacles', obstacleCount: 6, seed: 42 });
const first = composePhysicalMap(source, requested);
const second = composePhysicalMap(source, requested);
expect(decoder.decode(first.data)).toBe(decoder.decode(second.data));
const obstacles = Array.from(
documentOf(first.data).querySelectorAll('[name^="__platform_map_obstacle_"]'),
);
expect(obstacles).toHaveLength(6);
for (const obstacle of obstacles) {
const [x, y] = obstacle.getAttribute('pos')!.split(/\s+/).map(Number);
expect(Math.hypot(x, y)).toBeGreaterThanOrEqual(1.8);
}
});
it('系统粗糙地形生成内联高度场并替换原平面', () => {
const source = encoder.encode(
'<mujoco><worldbody><geom name="__platform_ground__" type="plane"/></worldbody></mujoco>',
);
const result = composePhysicalMap(
source,
config({
preset: 'rough',
size: 8,
seed: 7,
terrainHorizontalScale: 0.5,
terrainVerticalScale: 0.01,
}),
);
const document = documentOf(result.data),
hfield = document.querySelector('asset hfield'),
geom = document.querySelector('worldbody geom[type="hfield"]');
expect(document.querySelector('worldbody geom[type="plane"]')).toBeNull();
expect(hfield?.getAttribute('nrow')).toBe('17');
expect(hfield?.getAttribute('ncol')).toBe('17');
expect(hfield?.getAttribute('elevation')?.split(/\s+/)).toHaveLength(17 * 17);
expect(geom?.getAttribute('group')).toBe('2');
expect(result.geomCount).toBe(1);
});
it('深坑地形移除原平面,避免平面覆盖坑口', () => {
const source = encoder.encode(
'<mujoco><worldbody><geom name="floor" type="plane" pos="0 0 0"/></worldbody></mujoco>',
);
const document = documentOf(composePhysicalMap(source, config({ preset: 'pit' })).data);
expect(document.querySelector('geom[type="plane"]')).toBeNull();
const bottom = document.querySelector('[name="__platform_map_pit-bottom__"]');
expect(Number(bottom?.getAttribute('pos')?.split(/\s+/)[2])).toBeLessThan(-0.2);
});
it('规范化越界参数并拒绝无效 MJCF', () => {
expect(
normalizePhysicalMapConfig(
config({
size: 1,
friction: 99,
positionX: 999,
positionY: -999,
yawDeg: 999,
slopeAngle: Number.NaN,
stairCount: 100,
}),
),
).toMatchObject({
size: 4,
friction: 5,
positionX: 100,
positionY: -100,
yawDeg: 180,
slopeAngle: 12,
stairCount: 20,
});
expect(
normalizePhysicalMapConfig(
config({
preset: 'rough',
size: 100,
terrainDifficulty: 2,
terrainHorizontalScale: 0,
terrainVerticalScale: 2,
}),
),
).toMatchObject({
size: 30,
terrainDifficulty: 1,
terrainHorizontalScale: 0.03,
terrainVerticalScale: 0.1,
});
expect(() =>
composePhysicalMap(encoder.encode('<robot/>'), config({ preset: 'flat' })),
).toThrow('MJCF 缺少 worldbody');
});
});
+295
View File
@@ -0,0 +1,295 @@
import {
DEFAULT_PHYSICAL_MAP_CONFIG,
PHYSICAL_MAP_PRESET_LABELS,
isSystemTerrainPreset,
type PhysicalMapConfig,
} from './types';
import { generateSystemTerrain, type GeneratedTerrainHeightfield } from './terrainGenerator';
const decoder = new TextDecoder('utf-8');
const encoder = new TextEncoder();
const MAP_NAME_PREFIX = '__platform_map_';
export interface PhysicalMapComposition {
data: Uint8Array;
config: PhysicalMapConfig;
geomCount: number;
summary?: string;
}
function clamp(value: number, minimum: number, maximum: number, fallback: number): number {
return Number.isFinite(value) ? Math.min(maximum, Math.max(minimum, value)) : fallback;
}
export function normalizePhysicalMapConfig(config: PhysicalMapConfig): PhysicalMapConfig {
const systemTerrain = isSystemTerrainPreset(config.preset);
return {
preset: config.preset,
size: clamp(config.size, 4, systemTerrain ? 30 : 100, DEFAULT_PHYSICAL_MAP_CONFIG.size),
friction: clamp(config.friction, 0.05, 5, DEFAULT_PHYSICAL_MAP_CONFIG.friction),
positionX: clamp(config.positionX, -100, 100, DEFAULT_PHYSICAL_MAP_CONFIG.positionX),
positionY: clamp(config.positionY, -100, 100, DEFAULT_PHYSICAL_MAP_CONFIG.positionY),
yawDeg: clamp(config.yawDeg, -180, 180, DEFAULT_PHYSICAL_MAP_CONFIG.yawDeg),
slopeAngle: clamp(config.slopeAngle, 5, 30, DEFAULT_PHYSICAL_MAP_CONFIG.slopeAngle),
stairCount: Math.round(clamp(config.stairCount, 2, 20, DEFAULT_PHYSICAL_MAP_CONFIG.stairCount)),
obstacleCount: Math.round(
clamp(config.obstacleCount, 1, 30, DEFAULT_PHYSICAL_MAP_CONFIG.obstacleCount),
),
seed: Math.round(clamp(config.seed, 0, 2_147_483_647, DEFAULT_PHYSICAL_MAP_CONFIG.seed)),
terrainDifficulty: clamp(
config.terrainDifficulty,
0,
1,
DEFAULT_PHYSICAL_MAP_CONFIG.terrainDifficulty,
),
terrainHorizontalScale: clamp(
config.terrainHorizontalScale,
0.03,
1,
DEFAULT_PHYSICAL_MAP_CONFIG.terrainHorizontalScale,
),
terrainVerticalScale: clamp(
config.terrainVerticalScale,
0.001,
0.1,
DEFAULT_PHYSICAL_MAP_CONFIG.terrainVerticalScale,
),
};
}
function parseNumbers(value: string | null): number[] {
return (value ?? '').trim().split(/\s+/).filter(Boolean).map(Number);
}
function isGroundPlane(element: Element): boolean {
if (element.tagName !== 'geom' || (element.getAttribute('type') ?? 'sphere') !== 'plane')
return false;
const position = parseNumbers(element.getAttribute('pos'));
return Math.abs(position[2] ?? 0) < 1e-6;
}
function setCommonGeomAttributes(
geom: Element,
name: string,
config: PhysicalMapConfig,
rgba: string,
): void {
geom.setAttribute('name', `${MAP_NAME_PREFIX}${name}__`);
geom.setAttribute('friction', `${config.friction} 0.005 0.0001`);
geom.setAttribute('group', '2');
geom.setAttribute('rgba', rgba);
geom.setAttribute('condim', '3');
}
function addBox(
document: Document,
worldbody: Element,
config: PhysicalMapConfig,
name: string,
position: [number, number, number],
halfSize: [number, number, number],
rgba: string,
quaternion?: [number, number, number, number],
): void {
const geom = document.createElement('geom');
setCommonGeomAttributes(geom, name, config, rgba);
geom.setAttribute('type', 'box');
geom.setAttribute('pos', position.join(' '));
geom.setAttribute('size', halfSize.join(' '));
if (quaternion) geom.setAttribute('quat', quaternion.join(' '));
worldbody.append(geom);
}
function addHeightfield(
document: Document,
worldbody: Element,
config: PhysicalMapConfig,
heightfield: GeneratedTerrainHeightfield,
): void {
let asset = document.querySelector('mujoco > asset');
if (!asset) {
asset = document.createElement('asset');
const sceneWorldbody = document.querySelector('mujoco > worldbody');
if (!sceneWorldbody) throw new Error('地图合成失败:MJCF 缺少 worldbody');
document.documentElement.insertBefore(asset, sceneWorldbody);
}
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, '_')}__`;
const source = document.createElement('hfield');
source.setAttribute('name', name);
source.setAttribute('nrow', String(heightfield.rowSegments + 1));
source.setAttribute('ncol', String(heightfield.columnSegments + 1));
source.setAttribute('size', `${heightfield.width / 2} ${heightfield.length / 2} ${range} 0.01`);
source.setAttribute(
'elevation',
heightfield.heights.map((value) => (value - minimum) / range).join(' '),
);
asset.append(source);
const geom = document.createElement('geom');
setCommonGeomAttributes(geom, heightfield.name, config, '0.32 0.42 0.28 1');
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 {
const root = document.createElement('body');
const halfYaw = (config.yawDeg * Math.PI) / 360;
root.setAttribute('name', `${MAP_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);
return root;
}
function seededRandom(seed: number): () => number {
let state = seed | 0 || 0x6d2b79f5;
return () => {
state = Math.imul(state ^ (state >>> 15), state | 1);
state ^= state + Math.imul(state ^ (state >>> 7), state | 61);
return ((state ^ (state >>> 14)) >>> 0) / 4_294_967_296;
};
}
function ensureGround(document: Document, worldbody: Element, config: PhysicalMapConfig): number {
const existing = Array.from(worldbody.children).find(isGroundPlane);
if (existing) {
existing.setAttribute('friction', `${config.friction} 0.005 0.0001`);
if (existing.getAttribute('name') === '__platform_ground__')
existing.setAttribute('group', '2');
if (!existing.hasAttribute('rgba')) existing.setAttribute('rgba', '0.18 0.24 0.2 1');
return 0;
}
const ground = document.createElement('geom');
setCommonGeomAttributes(ground, 'ground', config, '0.18 0.24 0.2 1');
ground.setAttribute('type', 'plane');
ground.setAttribute('size', `${config.size} ${config.size} 0.1`);
ground.setAttribute('pos', '0 0 0');
worldbody.prepend(ground);
return 1;
}
/** 将内置静态物理地图注入一份 MJCF,不修改调用方传入的源文件。 */
export function composePhysicalMap(
source: Uint8Array,
requestedConfig: PhysicalMapConfig,
): PhysicalMapComposition {
const config = normalizePhysicalMapConfig(requestedConfig);
if (config.preset === 'none') return { data: source, config, geomCount: 0 };
const document = new DOMParser().parseFromString(decoder.decode(source), 'application/xml');
if (document.querySelector('parsererror')) throw new Error('地图合成失败:MJCF XML 无法解析');
const worldbody = document.querySelector('mujoco > worldbody');
if (!worldbody) throw new Error('地图合成失败:MJCF 缺少 worldbody');
for (const generated of Array.from(document.querySelectorAll(`[name^="${MAP_NAME_PREFIX}"]`)))
generated.remove();
for (const emptyAsset of Array.from(document.querySelectorAll('mujoco > asset:empty')))
emptyAsset.remove();
let geomCount: number;
if (isSystemTerrainPreset(config.preset)) {
for (const child of Array.from(worldbody.children)) if (isGroundPlane(child)) child.remove();
const mapRoot = createMapRoot(document, worldbody, config);
const terrain = generateSystemTerrain(config.preset, config);
const colors: Record<string, string> = {
obstacle: '0.48 0.34 0.2 1',
hazard: '0.38 0.24 0.22 1',
terrain: '0.32 0.42 0.28 1',
};
for (const item of terrain.boxes) {
const role = /obstacle/.test(item.name)
? 'obstacle'
: /bottom|near|far|left|right|front|back/.test(item.name)
? 'hazard'
: 'terrain';
addBox(
document,
mapRoot,
config,
item.name,
item.position,
[item.size[0] / 2, item.size[1] / 2, item.size[2] / 2],
colors[role],
);
}
if (terrain.heightfield) addHeightfield(document, mapRoot, config, terrain.heightfield);
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',
);
}
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)],
);
}
geomCount += config.obstacleCount;
}
return {
data: encoder.encode(new XMLSerializer().serializeToString(document)),
config,
geomCount,
summary: `已加载${PHYSICAL_MAP_PRESET_LABELS[config.preset]}物理地图(${geomCount} 个地图几何,摩擦系数 ${config.friction}`,
};
}
@@ -0,0 +1,63 @@
import { DEFAULT_PHYSICAL_MAP_CONFIG, SYSTEM_TERRAIN_PRESETS } from './types';
import { generateSystemTerrain } from './terrainGenerator';
const config = {
...DEFAULT_PHYSICAL_MAP_CONFIG,
size: 8,
seed: 42,
terrainDifficulty: 0.7,
terrainHorizontalScale: 0.25,
terrainVerticalScale: 0.01,
};
describe('系统参数化地形生成器', () => {
it.each(SYSTEM_TERRAIN_PRESETS)('%s 确定性生成有效几何', (preset) => {
const first = generateSystemTerrain(preset, { ...config, preset });
const second = generateSystemTerrain(preset, { ...config, preset });
expect(first).toEqual(second);
expect(first.boxes.length + (first.heightfield ? 1 : 0)).toBeGreaterThan(0);
for (const item of first.boxes) {
expect(item.size.every((value) => Number.isFinite(value) && value > 0)).toBe(true);
expect(item.position.every(Number.isFinite)).toBe(true);
}
});
it('粗糙与波浪地形生成连续高度场', () => {
for (const preset of ['rough', 'wave'] as const) {
const terrain = generateSystemTerrain(preset, { ...config, preset });
expect(terrain.boxes).toHaveLength(0);
expect(terrain.heightfield).toMatchObject({
width: 8,
length: 8,
rowSegments: 32,
columnSegments: 32,
});
expect(terrain.heightfield?.heights).toHaveLength(33 * 33);
expect(new Set(terrain.heightfield?.heights).size).toBeGreaterThan(10);
}
});
it('深坑和沟壑具有低于通行面的底部', () => {
const pit = generateSystemTerrain('pit', { ...config, preset: 'pit' }),
gap = generateSystemTerrain('gap', { ...config, preset: 'gap' });
expect(pit.boxes.find((item) => item.name === 'pit-bottom')?.position[2]).toBeLessThan(-0.2);
expect(gap.boxes.find((item) => item.name === 'gap-bottom')?.position[2]).toBeLessThan(-0.2);
});
it('金字塔与倒金字塔阶梯朝相反方向变化', () => {
const pyramid = generateSystemTerrain('pyramid_stairs', {
...config,
preset: 'pyramid_stairs',
}),
inverted = generateSystemTerrain('inverted_pyramid_stairs', {
...config,
preset: 'inverted_pyramid_stairs',
});
expect(
Math.max(...pyramid.boxes.map((item) => item.position[2] + item.size[2] / 2)),
).toBeGreaterThan(0.5);
expect(
Math.min(...inverted.boxes.map((item) => item.position[2] - item.size[2] / 2)),
).toBeLessThan(-0.5);
});
});
+288
View File
@@ -0,0 +1,288 @@
import type { PhysicalMapConfig, SystemTerrainPreset } from './types';
export interface GeneratedTerrainBox {
name: string;
size: [number, number, number];
position: [number, number, number];
}
export interface GeneratedTerrainHeightfield {
name: string;
width: number;
length: number;
rowSegments: number;
columnSegments: number;
heights: number[];
}
export interface GeneratedSystemTerrain {
boxes: GeneratedTerrainBox[];
heightfield?: GeneratedTerrainHeightfield;
}
function seededRandom(seed: string): () => number {
let state = 2_166_136_261;
for (const character of seed) state = Math.imul(state ^ character.charCodeAt(0), 16_777_619);
return () => {
state += 1_831_565_813;
let value = state;
value = Math.imul(value ^ (value >>> 15), value | 1);
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296;
};
}
function box(
name: string,
size: [number, number, number],
position: [number, number, number],
): GeneratedTerrainBox {
return { name, size, position };
}
function generateHeightfield(
preset: Extract<SystemTerrainPreset, 'rough' | 'wave'>,
config: PhysicalMapConfig,
random: () => number,
): GeneratedSystemTerrain {
const width = config.size,
length = config.size,
difficulty = config.terrainDifficulty,
amplitude = 0.3 * (0.15 + 0.85 * difficulty),
wavelength = 2,
octaves = 4,
phaseX = random() * Math.PI * 2,
phaseY = random() * Math.PI * 2,
segments = Math.max(
8,
Math.min(128, Math.ceil(Math.max(width, length) / config.terrainHorizontalScale)),
),
quantize = (value: number) =>
Math.round(value / config.terrainVerticalScale) * config.terrainVerticalScale,
heightAt = (x: number, y: number): number => {
if (preset === 'wave')
return quantize(
(amplitude *
(Math.sin((2 * Math.PI * x) / wavelength + phaseX) +
Math.sin((2 * Math.PI * y) / (wavelength * 1.37) - phaseX))) /
2,
);
let value = 0,
weight = 1,
totalWeight = 0;
for (let octave = 0; octave < octaves; octave += 1) {
const frequency = 2 ** octave;
value +=
weight *
Math.sin(x * frequency * 1.37 + phaseX * (octave + 1)) *
Math.cos(y * frequency * 1.71 - phaseY * (octave + 1));
totalWeight += weight;
weight *= 0.5;
}
return quantize((amplitude * value) / totalWeight);
},
heights: number[] = [];
for (let row = 0; row <= segments; row += 1) {
const y = -length / 2 + (length * row) / segments;
for (let column = 0; column <= segments; column += 1) {
const x = -width / 2 + (width * column) / segments;
heights.push(heightAt(x, y));
}
}
return {
boxes: [],
heightfield: {
name: `${preset}-surface`,
width,
length,
rowSegments: segments,
columnSegments: segments,
heights,
},
};
}
function generatePyramidStairs(
preset: Extract<SystemTerrainPreset, 'pyramid_stairs' | 'inverted_pyramid_stairs'>,
config: PhysicalMapConfig,
): GeneratedSystemTerrain {
const width = config.size,
length = config.size,
stepWidth = 0.35,
stepHeight = 0.14 * (0.25 + 0.75 * config.terrainDifficulty),
platformSize = 1.5,
levelCount = Math.max(
1,
Math.floor((Math.min(width, length) - platformSize) / (2 * stepWidth)),
),
boxes: GeneratedTerrainBox[] = [];
if (preset === 'inverted_pyramid_stairs') {
const bottom = -levelCount * stepHeight - 0.08;
for (let level = 0; level < levelCount; level += 1) {
const outerWidth = width - 2 * level * stepWidth,
outerLength = length - 2 * level * stepWidth,
innerWidth = Math.max(0, outerWidth - 2 * stepWidth),
innerLength = Math.max(0, outerLength - 2 * stepWidth),
height = -level * stepHeight - bottom,
z = bottom + height / 2,
sideWidth = (outerWidth - innerWidth) / 2,
sideLength = (outerLength - innerLength) / 2;
const ring: GeneratedTerrainBox[] = [
box(
`ring-${level}-left`,
[sideWidth, outerLength, height],
[-(innerWidth + sideWidth) / 2, 0, z],
),
box(
`ring-${level}-right`,
[sideWidth, outerLength, height],
[(innerWidth + sideWidth) / 2, 0, z],
),
box(
`ring-${level}-front`,
[innerWidth, sideLength, height],
[0, -(innerLength + sideLength) / 2, z],
),
box(
`ring-${level}-back`,
[innerWidth, sideLength, height],
[0, (innerLength + sideLength) / 2, z],
),
];
boxes.push(...ring.filter((item) => item.size[0] > 0.001 && item.size[1] > 0.001));
}
const centerWidth = Math.max(0.05, width - 2 * levelCount * stepWidth),
centerLength = Math.max(0.05, length - 2 * levelCount * stepWidth),
centerHeight = -levelCount * stepHeight - bottom;
boxes.push(
box(
'center-bottom',
[centerWidth, centerLength, centerHeight],
[0, 0, bottom + centerHeight / 2],
),
);
} else {
for (let level = 0; level <= levelCount; level += 1) {
const levelWidth = width - 2 * level * stepWidth,
levelLength = length - 2 * level * stepWidth;
if (levelWidth <= 0 || levelLength <= 0) break;
const top = (level + 1) * stepHeight,
bottom = -0.08,
height = top - bottom;
boxes.push(
box(`level-${level}`, [levelWidth, levelLength, height], [0, 0, bottom + height / 2]),
);
}
}
return { boxes };
}
/**
* URDF Studio botworld-terrain-generator 1.1
* MuJoCo
*/
export function generateSystemTerrain(
preset: SystemTerrainPreset,
config: PhysicalMapConfig,
): GeneratedSystemTerrain {
const random = seededRandom(`${preset}:1.1.0:${config.seed}`),
difficulty = config.terrainDifficulty,
width = config.size,
length = config.size;
if (preset === 'rough' || preset === 'wave') return generateHeightfield(preset, config, random);
if (preset === 'pyramid_stairs' || preset === 'inverted_pyramid_stairs')
return generatePyramidStairs(preset, config);
const boxes: GeneratedTerrainBox[] = [];
if (preset === 'discrete_obstacles') {
const obstacleSize = 0.6,
minimumHeight = 0.08,
maximumHeight = 0.6,
density = 0.35 * (0.35 + 0.65 * difficulty),
spacing = Math.max(obstacleSize * 1.4, 0.2),
columns = Math.max(1, Math.floor(width / spacing)),
rows = Math.max(1, Math.floor(length / spacing));
boxes.push(box('ground', [width, length, 0.06], [0, 0, -0.03]));
for (let row = 0; row < rows; row += 1)
for (let column = 0; column < columns; column += 1) {
if (random() > density || (row < 2 && column < 2)) continue;
const sizeX = obstacleSize * (0.55 + 0.8 * random()),
sizeY = obstacleSize * (0.55 + 0.8 * random()),
height =
minimumHeight + (maximumHeight - minimumHeight) * random() * (0.25 + 0.75 * difficulty);
boxes.push(
box(
`obstacle-${row}-${column}`,
[sizeX, sizeY, height],
[
-width / 2 + spacing * (column + 0.5) + (random() - 0.5) * spacing * 0.25,
-length / 2 + spacing * (row + 0.5) + (random() - 0.5) * spacing * 0.25,
height / 2,
],
),
);
}
} else if (preset === 'stepping_stones') {
const stoneSize = 0.45,
gap = 0.22,
spacing = stoneSize + gap * (0.5 + 0.5 * difficulty),
rows = Math.max(1, Math.floor(length / spacing)),
columns = Math.max(1, Math.floor(width / spacing)),
baseHeight = 0.12,
heightJitter = 0.08 * difficulty,
missingRatio = 0.12 * difficulty;
for (let row = 0; row < rows; row += 1)
for (let column = 0; column < columns; column += 1) {
if (random() < missingRatio) continue;
const height = Math.max(0.03, baseHeight + (random() * 2 - 1) * heightJitter);
boxes.push(
box(
`stone-${row}-${column}`,
[stoneSize, stoneSize, height + 0.04],
[
(column - (columns - 1) / 2) * spacing,
(row - (rows - 1) / 2) * spacing,
height / 2 - 0.02,
],
),
);
}
} else if (preset === 'rails') {
const railWidth = 0.12,
railHeight = 0.18 * (0.5 + 0.5 * difficulty),
spacing = 0.8 * (1 - 0.25 * difficulty);
boxes.push(box('ground', [width, length, 0.06], [0, 0, -0.03]));
for (let x = -width / 2 + spacing; x < width / 2; x += spacing)
boxes.push(box(`rail-x-${x}`, [railWidth, length, railHeight], [x, 0, railHeight / 2]));
if (difficulty >= 0.55)
for (let y = -length / 2 + spacing; y < length / 2; y += spacing * 2)
boxes.push(box(`rail-y-${y}`, [width, railWidth, railHeight], [0, y, railHeight / 2]));
} else if (preset === 'pit' || preset === 'gap') {
const depth = (preset === 'pit' ? 0.8 : 1) * (0.25 + 0.75 * difficulty),
thickness = 0.1;
if (preset === 'pit') {
const pitWidth = Math.min(2, width * 0.6),
pitLength = Math.min(2, length * 0.6),
sideWidth = (width - pitWidth) / 2,
sideLength = (length - pitLength) / 2;
boxes.push(
box('left', [sideWidth, length, thickness], [-(pitWidth + sideWidth) / 2, 0, -0.05]),
box('right', [sideWidth, length, thickness], [(pitWidth + sideWidth) / 2, 0, -0.05]),
box('front', [pitWidth, sideLength, thickness], [0, -(pitLength + sideLength) / 2, -0.05]),
box('back', [pitWidth, sideLength, thickness], [0, (pitLength + sideLength) / 2, -0.05]),
box('pit-bottom', [pitWidth, pitLength, thickness], [0, 0, -depth - 0.05]),
);
} else {
const gapWidth = Math.min(0.7, length * 0.5),
sideLength = (length - gapWidth) / 2,
center = (gapWidth + sideLength) / 2;
boxes.push(
box('near', [width, sideLength, thickness], [0, -center, -0.05]),
box('far', [width, sideLength, thickness], [0, center, -0.05]),
box('gap-bottom', [width, gapWidth, thickness], [0, 0, -depth - 0.05]),
);
}
}
if (!boxes.length) throw new Error(`系统地形 ${preset} 未生成几何`);
return { boxes };
}
+140
View File
@@ -0,0 +1,140 @@
export const SYSTEM_TERRAIN_PRESETS = [
'discrete_obstacles',
'gap',
'inverted_pyramid_stairs',
'pit',
'pyramid_stairs',
'rails',
'rough',
'stepping_stones',
'wave',
] as const;
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 {
return (SYSTEM_TERRAIN_PRESETS as readonly string[]).includes(value);
}
export interface PhysicalMapConfig {
preset: PhysicalMapPreset;
size: number;
friction: number;
positionX: number;
positionY: number;
yawDeg: number;
slopeAngle: number;
stairCount: number;
obstacleCount: number;
seed: number;
terrainDifficulty: number;
terrainHorizontalScale: number;
terrainVerticalScale: number;
}
export interface MapCoordinateSystem {
units: 'm';
up: 'Z';
forward: '+X';
}
export interface MapPhysicsDefinition {
source: string;
}
export interface MapVisualDefinition {
source: string;
castShadow?: boolean;
receiveShadow?: boolean;
}
export interface MapAuthoringDefinition {
source: string;
}
export interface SpawnPoint {
id: string;
name: string;
position: [number, number, number];
yawDeg: number;
}
export interface MapDefinition {
schemaVersion: 1 | 2;
id: string;
name: string;
coordinateSystem: MapCoordinateSystem;
physics?: MapPhysicsDefinition;
visual?: MapVisualDefinition;
authoring?: MapAuthoringDefinition;
spawnPoints: SpawnPoint[];
bounds?: {
min: [number, number, number];
max: [number, number, number];
};
}
export type MapSelection =
| { kind: 'none' }
| { kind: 'builtin'; config: PhysicalMapConfig }
| {
kind: 'project';
descriptorPath: string;
spawnPointId?: string;
robotRootBody?: string;
frictionOverride?: number;
};
export interface ResolvedProjectMap {
definition: MapDefinition;
descriptorPath: string;
physicsPath?: string;
visualPath?: string;
authoringPath?: string;
}
export interface VisualMapAsset {
id: string;
name: string;
path: string;
data: Uint8Array;
castShadow: boolean;
receiveShadow: boolean;
}
export const DEFAULT_PHYSICAL_MAP_CONFIG: PhysicalMapConfig = {
preset: 'none',
size: 5,
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,
};
export const DEFAULT_MAP_SELECTION: MapSelection = { kind: 'none' };
export const PHYSICAL_MAP_PRESET_LABELS: Record<PhysicalMapPreset, string> = {
none: '不使用地图',
flat: '平地',
slope: '坡道',
stairs: '楼梯',
obstacles: '随机障碍物',
discrete_obstacles: '离散障碍地形',
gap: '沟壑地形',
inverted_pyramid_stairs: '倒金字塔阶梯',
pit: '深坑地形',
pyramid_stairs: '金字塔阶梯',
rails: '轨道地形',
rough: '随机粗糙地形',
stepping_stones: '踏石地形',
wave: '波浪地形',
};
@@ -26,6 +26,7 @@ function fixture(): ProjectManifest {
}, },
], ],
entries: [{ path: 'model.xml', format: 'mjcf', label: 'model' }], entries: [{ path: 'model.xml', format: 'mjcf', label: 'model' }],
maps: [],
selectedEntry: 'model.xml', selectedEntry: 'model.xml',
totalBytes: xml.byteLength + png.byteLength, totalBytes: xml.byteLength + png.byteLength,
}; };
+50 -10
View File
@@ -35,15 +35,39 @@ describe('project importer', () => {
expect(entries).toHaveLength(3); expect(entries).toHaveLength(3);
expect(choosePreferredEntry(entries)).toBe('model.xml'); expect(choosePreferredEntry(entries)).toBe('model.xml');
}); });
it('解压 ZIP保留二进制数据', async () => { it('异步解压 ZIP保留二进制数据并报告阶段进度', async () => {
const zipped = zipSync({ const zipped = zipSync({
'robot/model.urdf': encode('<robot name="r"/>'), 'robot/model.urdf': encode('<robot name="r"/>'),
'robot/mesh.obj': encode('v 0 0 0'), 'robot/mesh.obj': encode('v 0 0 0'),
}); });
const file = new File([zipped], 'robot.zip', { type: 'application/zip' }); const file = new File([zipped], 'robot.zip', { type: 'application/zip' });
const result = await importBrowserFiles([file]); const phases: string[] = [];
const result = await importBrowserFiles([file], undefined, (progress) =>
phases.push(`${progress.phase}:${progress.completed}`),
);
expect(result.files.map((f) => f.path)).toContain('robot/mesh.obj'); expect(result.files.map((f) => f.path)).toContain('robot/mesh.obj');
expect(result.selectedEntry).toBe('robot/model.urdf'); expect(result.selectedEntry).toBe('robot/model.urdf');
expect(phases).toContain('extracting:0');
expect(phases.at(-1)).toBe('indexing:1');
});
it('发现工程地图且不会将 map.json 当作模型入口', async () => {
const mapJson = JSON.stringify({
schemaVersion: 1,
id: 'room',
name: '房间',
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
physics: { source: 'world.xml' },
spawnPoints: [],
});
const result = await importBrowserFiles([
new File(['<mujoco><worldbody/></mujoco>'], 'model.xml'),
new File([mapJson], 'map.json'),
new File(['<mujoco><worldbody/></mujoco>'], 'world.xml'),
]);
expect(result.maps).toEqual([
expect.objectContaining({ descriptorPath: 'map.json', id: 'room', name: '房间' }),
]);
expect(result.entries.map((entry) => entry.path)).toEqual(['model.xml']);
}); });
it('拒绝 ZIP 路径穿越', async () => { it('拒绝 ZIP 路径穿越', async () => {
const zipped = zipSync({ '../model.xml': encode('<mujoco/>') }); const zipped = zipSync({ '../model.xml': encode('<mujoco/>') });
@@ -56,8 +80,9 @@ describe('project importer', () => {
const b = new File(['<mujoco/>'], 'model.xml'); const b = new File(['<mujoco/>'], 'model.xml');
await expect(importBrowserFiles([a, b])).rejects.toThrow('同名路径'); await expect(importBrowserFiles([a, b])).rejects.toThrow('同名路径');
}); });
it('拒绝超过限制的文件', async () => { it('读取文件内容前根据元数据拒绝超过限制的输入', async () => {
const file = new File(['<mujoco/>'], 'model.xml'); const file = new File(['<mujoco/>'], 'model.xml'),
read = vi.spyOn(file, 'arrayBuffer');
await expect( await expect(
importBrowserFiles([file], { importBrowserFiles([file], {
maxFiles: 1, maxFiles: 1,
@@ -66,8 +91,20 @@ describe('project importer', () => {
maxZipBytes: 2, maxZipBytes: 2,
}), }),
).rejects.toThrow('单文件超过限制'); ).rejects.toThrow('单文件超过限制');
expect(read).not.toHaveBeenCalled();
}); });
it('规范化 MuJoCo 不接受的重复 material 和 ROS package URI', () => { it('按完成文件数报告普通工程读取进度', async () => {
const updates: Array<{ phase: string; completed: number; total: number }> = [];
const result = await importBrowserFiles(
[new File(['<mujoco/>'], 'model.xml'), new File(['v 0 0 0'], 'mesh.obj')],
undefined,
({ phase, completed, total }) => updates.push({ phase, completed, total }),
);
expect(result.files).toHaveLength(2);
expect(updates).toContainEqual({ phase: 'reading', completed: 2, total: 2 });
expect(updates.at(-1)).toEqual({ phase: 'indexing', completed: 1, total: 1 });
});
it('规范化 MuJoCo 不接受的重复 material 和 ROS package URI', async () => {
const urdf = projectFile( const urdf = projectFile(
'go2w_description/urdf/robot.urdf', 'go2w_description/urdf/robot.urdf',
'<robot><link name="base"><visual><geometry><mesh filename="package://go2w_description/meshes/base.obj"/></geometry><material name="a"/><material name="b"/></visual></link></robot>', '<robot><link name="base"><visual><geometry><mesh filename="package://go2w_description/meshes/base.obj"/></geometry><material name="a"/><material name="b"/></visual></link></robot>',
@@ -84,10 +121,11 @@ describe('project importer', () => {
name: 'go2w', name: 'go2w',
files: [urdf, mesh], files: [urdf, mesh],
entries: [{ path: urdf.path, format: 'urdf' as const, label: 'robot' }], entries: [{ path: urdf.path, format: 'urdf' as const, label: 'robot' }],
maps: [],
selectedEntry: urdf.path, selectedEntry: urdf.path,
totalBytes: urdf.size + 1, totalBytes: urdf.size + 1,
}; };
const prepared = prepareProjectForMujoco(manifest, urdf.path); const prepared = await prepareProjectForMujoco(manifest, urdf.path);
const text = new TextDecoder().decode(prepared.manifest.files[0].data); const text = new TextDecoder().decode(prepared.manifest.files[0].data);
expect(text.match(/<material/g) ?? []).toHaveLength(1); expect(text.match(/<material/g) ?? []).toHaveLength(1);
expect(text).toContain('filename="../meshes/base.obj"'); expect(text).toContain('filename="../meshes/base.obj"');
@@ -95,7 +133,7 @@ describe('project importer', () => {
expect(text).toContain('fusestatic="false"'); expect(text).toContain('fusestatic="false"');
expect(prepared.warnings).toHaveLength(2); expect(prepared.warnings).toHaveLength(2);
}); });
it('将 DAE mesh 转换为 MuJoCo 可读取的 OBJ,并复用于 visual/collision', () => { it('将 DAE mesh 转换为 MuJoCo 可读取的 OBJ,并复用于 visual/collision', async () => {
const urdf = projectFile( const urdf = projectFile(
'robot/robot.urdf', 'robot/robot.urdf',
'<robot><link name="base"><visual><geometry><mesh filename="meshes/triangle.dae"/></geometry></visual><collision><geometry><mesh filename="meshes/triangle.dae"/></geometry></collision></link></robot>', '<robot><link name="base"><visual><geometry><mesh filename="meshes/triangle.dae"/></geometry></visual><collision><geometry><mesh filename="meshes/triangle.dae"/></geometry></collision></link></robot>',
@@ -106,10 +144,11 @@ describe('project importer', () => {
name: 'dae', name: 'dae',
files: [urdf, dae], files: [urdf, dae],
entries: [{ path: urdf.path, format: 'urdf' as const, label: 'robot' }], entries: [{ path: urdf.path, format: 'urdf' as const, label: 'robot' }],
maps: [],
selectedEntry: urdf.path, selectedEntry: urdf.path,
totalBytes: urdf.size + dae.size, totalBytes: urdf.size + dae.size,
}; };
const prepared = prepareProjectForMujoco(manifest, urdf.path); const prepared = await prepareProjectForMujoco(manifest, urdf.path);
const text = new TextDecoder().decode( const text = new TextDecoder().decode(
prepared.manifest.files.find((file) => file.path === urdf.path)!.data, prepared.manifest.files.find((file) => file.path === urdf.path)!.data,
); );
@@ -121,7 +160,7 @@ describe('project importer', () => {
expect(new TextDecoder().decode(obj!.data)).toMatch(/^f\s/m); expect(new TextDecoder().decode(obj!.data)).toMatch(/^f\s/m);
expect(prepared.warnings.join(' ')).toContain('1 个 DAE 文件转换为 OBJ'); expect(prepared.warnings.join(' ')).toContain('1 个 DAE 文件转换为 OBJ');
}); });
it('DAE 缺失或转换失败时安全降级', () => { it('DAE 缺失或转换失败时安全降级', async () => {
const urdf = projectFile( const urdf = projectFile(
'robot.urdf', 'robot.urdf',
'<robot><link name="base"><visual><geometry><mesh filename="visual.dae"/></geometry></visual><collision><geometry><mesh filename="collision.dae"/></geometry></collision></link></robot>', '<robot><link name="base"><visual><geometry><mesh filename="visual.dae"/></geometry></visual><collision><geometry><mesh filename="collision.dae"/></geometry></collision></link></robot>',
@@ -131,10 +170,11 @@ describe('project importer', () => {
name: 'dae', name: 'dae',
files: [urdf], files: [urdf],
entries: [{ path: urdf.path, format: 'urdf' as const, label: 'robot' }], entries: [{ path: urdf.path, format: 'urdf' as const, label: 'robot' }],
maps: [],
selectedEntry: urdf.path, selectedEntry: urdf.path,
totalBytes: urdf.size, totalBytes: urdf.size,
}; };
const prepared = prepareProjectForMujoco(manifest, urdf.path); const prepared = await prepareProjectForMujoco(manifest, urdf.path);
const text = new TextDecoder().decode(prepared.manifest.files[0].data); const text = new TextDecoder().decode(prepared.manifest.files[0].data);
expect(text).not.toContain('<visual>'); expect(text).not.toContain('<visual>');
expect(text).toContain('<collision>'); expect(text).toContain('<collision>');
+113 -33
View File
@@ -1,4 +1,3 @@
import { unzipSync } from 'fflate';
import { import {
DEFAULT_IMPORT_LIMITS, DEFAULT_IMPORT_LIMITS,
type ImportLimits, type ImportLimits,
@@ -6,10 +5,19 @@ import {
type ProjectFile, type ProjectFile,
type ProjectManifest, type ProjectManifest,
} from './types'; } from './types';
import { convertDaeToObj } from './daeConverter'; import { discoverMapEntries } from '../map/MapLoader';
const decoder = new TextDecoder('utf-8', { fatal: false }); const decoder = new TextDecoder('utf-8', { fatal: false });
export interface ProjectImportProgress {
phase: 'reading' | 'extracting' | 'indexing';
completed: number;
total: number;
path?: string;
}
export type ProjectImportProgressCallback = (progress: ProjectImportProgress) => void;
export class ProjectImportError extends Error { export class ProjectImportError extends Error {
constructor( constructor(
message: string, message: string,
@@ -121,10 +129,10 @@ function generatedObjPath(daePath: string, occupied: Set<string>): string {
} }
/** Normalizes common ROS URDF constructs that MuJoCo's stricter parser rejects. */ /** Normalizes common ROS URDF constructs that MuJoCo's stricter parser rejects. */
export function prepareProjectForMujoco( export async function prepareProjectForMujoco(
manifest: ProjectManifest, manifest: ProjectManifest,
entryPath: string, entryPath: string,
): PreparedProject { ): Promise<PreparedProject> {
const entry = manifest.entries.find((candidate) => candidate.path === entryPath); const entry = manifest.entries.find((candidate) => candidate.path === entryPath);
if (entry?.format !== 'urdf') return { manifest, warnings: [] }; if (entry?.format !== 'urdf') return { manifest, warnings: [] };
const source = manifest.files.find((file) => file.path === entryPath); const source = manifest.files.find((file) => file.path === entryPath);
@@ -183,16 +191,31 @@ export function prepareProjectForMujoco(
const occupied = new Set(manifest.files.map((file) => file.path)); const occupied = new Set(manifest.files.map((file) => file.path));
const converted = new Map<string, ProjectFile>(); const converted = new Map<string, ProjectFile>();
const daeMeshes = Array.from(document.querySelectorAll('mesh[filename]')).filter((mesh) =>
mesh.getAttribute('filename')?.toLowerCase().split(/[?#]/)[0].endsWith('.dae'),
);
let convertDaeToObj: typeof import('./daeConverter').convertDaeToObj | undefined;
let converterLoadError: unknown;
if (daeMeshes.length)
try {
({ convertDaeToObj } = await import('./daeConverter'));
} catch (error) {
converterLoadError = error;
}
let convertedDaeReferences = 0; let convertedDaeReferences = 0;
let removedDaeVisuals = 0; let removedDaeVisuals = 0;
let daeCollisionFallbacks = 0; let daeCollisionFallbacks = 0;
for (const mesh of Array.from(document.querySelectorAll('mesh[filename]'))) { for (const mesh of daeMeshes) {
const filename = mesh.getAttribute('filename'); const filename = mesh.getAttribute('filename');
if (!filename?.toLowerCase().split(/[?#]/)[0].endsWith('.dae')) continue; if (!filename?.toLowerCase().split(/[?#]/)[0].endsWith('.dae')) continue;
const daePath = resolveProjectReference(entryPath, filename); const daePath = resolveProjectReference(entryPath, filename);
const daeFile = daePath ? manifest.files.find((file) => file.path === daePath) : undefined; const daeFile = daePath ? manifest.files.find((file) => file.path === daePath) : undefined;
try { try {
if (!daeFile || !daePath) throw new Error('工程中找不到 DAE 文件'); if (!daeFile || !daePath) throw new Error('工程中找不到 DAE 文件');
if (!convertDaeToObj)
throw new Error(
`DAE 转换器加载失败:${converterLoadError instanceof Error ? converterLoadError.message : String(converterLoadError)}`,
);
let objFile = converted.get(daePath); let objFile = converted.get(daePath);
if (!objFile) { if (!objFile) {
const data = convertDaeToObj(daeFile.data, daePath); const data = convertDaeToObj(daeFile.data, daePath);
@@ -264,7 +287,17 @@ export function choosePreferredEntry(entries: ModelEntry[]): string | undefined
} }
function manifest(name: string, files: ProjectFile[]): ProjectManifest { function manifest(name: string, files: ProjectFile[]): ProjectManifest {
const entries = discoverEntries(files); let maps;
try {
maps = discoverMapEntries(files);
} catch (error) {
throw new ProjectImportError(
`地图描述无效:${error instanceof Error ? error.message : String(error)}`,
files.find((file) => /(^|\/)map\.json$/i.test(file.path))?.path,
);
}
const physicsPaths = new Set(maps.map((map) => map.physicsPath).filter(Boolean));
const entries = discoverEntries(files).filter((entry) => !physicsPaths.has(entry.path));
if (!entries.length) if (!entries.length)
throw new ProjectImportError('未发现包含 <mujoco> 或 <robot> 根元素的 XML/URDF 入口'); throw new ProjectImportError('未发现包含 <mujoco> 或 <robot> 根元素的 XML/URDF 入口');
return { return {
@@ -272,6 +305,7 @@ function manifest(name: string, files: ProjectFile[]): ProjectManifest {
name, name,
files, files,
entries, entries,
maps,
selectedEntry: choosePreferredEntry(entries), selectedEntry: choosePreferredEntry(entries),
totalBytes: files.reduce((n, f) => n + f.size, 0), totalBytes: files.reduce((n, f) => n + f.size, 0),
}; };
@@ -280,37 +314,53 @@ function manifest(name: string, files: ProjectFile[]): ProjectManifest {
export async function importBrowserFiles( export async function importBrowserFiles(
input: File[], input: File[],
limits: ImportLimits = DEFAULT_IMPORT_LIMITS, limits: ImportLimits = DEFAULT_IMPORT_LIMITS,
onProgress?: ProjectImportProgressCallback,
): Promise<ProjectManifest> { ): Promise<ProjectManifest> {
if (!input.length) throw new ProjectImportError('未选择文件'); if (!input.length) throw new ProjectImportError('未选择文件');
if (input.length === 1 && /\.zip$/i.test(input[0].name)) { if (input.length === 1 && /\.zip$/i.test(input[0].name)) {
if (input[0].size > limits.maxZipBytes) if (input[0].size > limits.maxZipBytes)
throw new ProjectImportError(`ZIP 超过限制(${limits.maxZipBytes} 字节)`); throw new ProjectImportError(`ZIP 超过限制(${limits.maxZipBytes} 字节)`);
onProgress?.({ phase: 'reading', completed: 0, total: 1, path: input[0].name });
const bytes = new Uint8Array(await input[0].arrayBuffer()); const bytes = new Uint8Array(await input[0].arrayBuffer());
onProgress?.({ phase: 'reading', completed: 1, total: 1, path: input[0].name });
checkEncryptedZip(bytes); checkEncryptedZip(bytes);
let unpacked: Record<string, Uint8Array>; let unpacked: Record<string, Uint8Array>;
try { try {
let fileCount = 0; let fileCount = 0;
let expandedBytes = 0; let expandedBytes = 0;
unpacked = unzipSync(bytes, { onProgress?.({ phase: 'extracting', completed: 0, total: 1 });
filter: (entry) => { const { unzip } = await import('fflate');
if (entry.name.endsWith('/')) return false; unpacked = await new Promise<Record<string, Uint8Array>>((resolve, reject) => {
normalizeProjectPath(entry.name); try {
fileCount += 1; unzip(
expandedBytes += entry.originalSize; bytes,
if (fileCount > limits.maxFiles) {
throw new ProjectImportError(`文件数量超过限制(${limits.maxFiles}`); filter: (entry) => {
if (entry.originalSize > limits.maxFileBytes) if (entry.name.endsWith('/')) return false;
throw new ProjectImportError( normalizeProjectPath(entry.name);
`单文件超过限制(${limits.maxFileBytes} 字节)`, fileCount += 1;
entry.name, expandedBytes += entry.originalSize;
); if (fileCount > limits.maxFiles)
if (expandedBytes > limits.maxTotalBytes) throw new ProjectImportError(`文件数量超过限制(${limits.maxFiles}`);
throw new ProjectImportError( if (entry.originalSize > limits.maxFileBytes)
`ZIP 解压后总大小超过限制(${limits.maxTotalBytes} 字节)`, throw new ProjectImportError(
); `单文件超过限制(${limits.maxFileBytes} 字节)`,
return true; entry.name,
}, );
if (expandedBytes > limits.maxTotalBytes)
throw new ProjectImportError(
`ZIP 解压后总大小超过限制(${limits.maxTotalBytes} 字节)`,
);
return true;
},
},
(error, data) => (error ? reject(error) : resolve(data)),
);
} catch (error) {
reject(error);
}
}); });
onProgress?.({ phase: 'extracting', completed: 1, total: 1 });
} catch (error) { } catch (error) {
if (error instanceof ProjectImportError) throw error; if (error instanceof ProjectImportError) throw error;
throw new ProjectImportError( throw new ProjectImportError(
@@ -327,23 +377,53 @@ export async function importBrowserFiles(
mimeType: '', mimeType: '',
})); }));
enforceLimits(files, limits); enforceLimits(files, limits);
onProgress?.({ phase: 'indexing', completed: 1, total: 1 });
return manifest(input[0].name.replace(/\.zip$/i, ''), files); return manifest(input[0].name.replace(/\.zip$/i, ''), files);
} }
const files = await Promise.all(
input.map(async (file): Promise<ProjectFile> => { if (input.length > limits.maxFiles)
const relative = throw new ProjectImportError(`文件数量超过限制(${limits.maxFiles}`);
(file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name; const sources = input.map((file) => {
const relative =
(file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
return { file, relative, path: normalizeProjectPath(relative) };
});
let totalBytes = 0;
const seen = new Set<string>();
for (const { file, path } of sources) {
if (seen.has(path)) throw new ProjectImportError('工程中存在同名路径', path);
seen.add(path);
if (file.size > limits.maxFileBytes)
throw new ProjectImportError(`单文件超过限制(${limits.maxFileBytes} 字节)`, path);
totalBytes += file.size;
if (totalBytes > limits.maxTotalBytes)
throw new ProjectImportError(`工程总大小超过限制(${limits.maxTotalBytes} 字节)`);
}
const files = new Array<ProjectFile>(sources.length);
let cursor = 0;
let completed = 0;
onProgress?.({ phase: 'reading', completed, total: sources.length });
const readNext = async () => {
for (;;) {
const index = cursor++;
if (index >= sources.length) return;
const { file, relative, path } = sources[index];
const data = new Uint8Array(await file.arrayBuffer()); const data = new Uint8Array(await file.arrayBuffer());
return { files[index] = {
path: normalizeProjectPath(relative), path,
data, data,
size: data.byteLength, size: data.byteLength,
source: relative === file.name ? 'file' : 'directory', source: relative === file.name ? 'file' : 'directory',
mimeType: file.type, mimeType: file.type,
}; };
}), completed += 1;
); onProgress?.({ phase: 'reading', completed, total: sources.length, path });
}
};
await Promise.all(Array.from({ length: Math.min(4, sources.length) }, () => readNext()));
enforceLimits(files, limits); enforceLimits(files, limits);
onProgress?.({ phase: 'indexing', completed: 1, total: 1 });
return manifest(files[0].path.split('/')[0] || '工程', files); return manifest(files[0].path.split('/')[0] || '工程', files);
} }
+12
View File
@@ -14,11 +14,23 @@ export interface ModelEntry {
label: string; label: string;
} }
export interface MapEntry {
descriptorPath: string;
schemaVersion: 1 | 2;
id: string;
name: string;
physicsPath?: string;
visualPath?: string;
authoringPath?: string;
spawnPoints: Array<{ id: string; name: string }>;
}
export interface ProjectManifest { export interface ProjectManifest {
id: string; id: string;
name: string; name: string;
files: ProjectFile[]; files: ProjectFile[];
entries: ModelEntry[]; entries: ModelEntry[];
maps: MapEntry[];
selectedEntry?: string; selectedEntry?: string;
totalBytes: number; totalBytes: number;
} }
@@ -15,6 +15,7 @@ it('按相对路径挂载并逆序清理 MEMFS', () => {
id: 'safe', id: 'safe',
name: 'x', name: 'x',
entries: [], entries: [],
maps: [],
files: [{ path: 'a/b/model.xml', data, size: 1, source: 'file', mimeType: '' }], files: [{ path: 'a/b/model.xml', data, size: 1, source: 'file', mimeType: '' }],
totalBytes: 1, totalBytes: 1,
}; };
+127 -24
View File
@@ -1,4 +1,4 @@
import loadMujoco, { type MainModule } from '@mujoco/mujoco'; import type { MainModule } from '@mujoco/mujoco';
import type { ProjectFile, ProjectManifest } from '../project/types'; import type { ProjectFile, ProjectManifest } from '../project/types';
import { prepareProjectForMujoco } from '../project/importer'; import { prepareProjectForMujoco } from '../project/importer';
import { import {
@@ -16,17 +16,32 @@ import {
} from './SimulationSession'; } from './SimulationSession';
import type { ControllerCommand, ControllerStatus } from '../controller/types'; import type { ControllerCommand, ControllerStatus } from '../controller/types';
import type { RLCommand, RLPolicyStatus } from '../rl/types'; import type { RLCommand, RLPolicyStatus } from '../rl/types';
import { composePhysicalMap } from '../map/physicalMap';
import { composeProjectMap } from '../map/MapComposer';
import { resolveProjectMap } from '../map/MapLoader';
import { DEFAULT_MAP_SELECTION, type MapSelection } from '../map/types';
export type UrdfLoadMode = 'mjcf' | 'native'; export type UrdfLoadMode = 'mjcf' | 'native';
export type { UrdfBaseMode, UrdfEnhancementOptions }; export type { UrdfBaseMode, UrdfEnhancementOptions };
export interface PhysicsLoadProgress {
value: number;
label: string;
}
export interface PhysicsLoadOptions {
urdfMode?: UrdfLoadMode;
baseMode?: UrdfBaseMode;
enhancements?: UrdfEnhancementOptions;
map?: MapSelection;
onProgress?: (progress: PhysicsLoadProgress) => void;
}
export interface PhysicsAdapter { export interface PhysicsAdapter {
load( load(
manifest: ProjectManifest, manifest: ProjectManifest,
entryPath: string, entryPath: string,
urdfMode?: UrdfLoadMode, options?: PhysicsLoadOptions,
baseMode?: UrdfBaseMode,
enhancements?: UrdfEnhancementOptions,
): Promise<SimulationSnapshot>; ): Promise<SimulationSnapshot>;
advance(now: number): FrameResult; advance(now: number): FrameResult;
snapshot(): SimulationSnapshot | null; snapshot(): SimulationSnapshot | null;
@@ -50,6 +65,8 @@ export interface PhysicsAdapter {
setRLCommand(command: RLCommand): void; setRLCommand(command: RLCommand): void;
removeRLPolicy(): void; removeRLPolicy(): void;
cachedSupportFiles(): ProjectFile[]; cachedSupportFiles(): ProjectFile[];
releaseRetired(): void;
rollbackRetired(): void;
exportMjcf(): Uint8Array; exportMjcf(): Uint8Array;
dispose(): void; dispose(): void;
} }
@@ -58,10 +75,17 @@ let modulePromise: Promise<MainModule> | undefined;
export function getMujocoModule(): Promise<MainModule> { export function getMujocoModule(): Promise<MainModule> {
if (!modulePromise) { if (!modulePromise) {
console.info('[MuJoCo] 开始初始化单线程 WASM'); console.info('[MuJoCo] 开始初始化单线程 WASM');
modulePromise = loadMujoco().then((module) => { modulePromise = import('@mujoco/mujoco')
console.info('[MuJoCo] WASM 初始化完成'); .then(({ default: loadMujoco }) => loadMujoco())
return module; .then((module) => {
}); console.info('[MuJoCo] WASM 初始化完成');
return module;
})
.catch((error) => {
// 初始化失败后允许用户重试,而不是永久复用已 rejected 的 Promise。
modulePromise = undefined;
throw error;
});
} }
return modulePromise; return modulePromise;
} }
@@ -70,32 +94,51 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
session: SimulationSession | null = null; session: SimulationSession | null = null;
workspace: MemfsWorkspace | null = null; workspace: MemfsWorkspace | null = null;
private supportFiles: ProjectFile[] = []; private supportFiles: ProjectFile[] = [];
private loadGeneration = 0;
private disposed = false;
private retiredSession: SimulationSession | null = null;
private retiredWorkspace: MemfsWorkspace | null = null;
private retiredSupportFiles: ProjectFile[] = [];
async load( async load(
manifest: ProjectManifest, manifest: ProjectManifest,
entryPath: string, entryPath: string,
urdfMode: UrdfLoadMode = 'mjcf', options: PhysicsLoadOptions = {},
baseMode: UrdfBaseMode = 'floating', ): Promise<SimulationSnapshot> {
enhancements: UrdfEnhancementOptions = { if (this.disposed) throw new Error('物理适配器已释放');
const generation = ++this.loadGeneration;
const urdfMode = options.urdfMode ?? 'mjcf';
const baseMode = options.baseMode ?? 'floating';
const enhancements = options.enhancements ?? {
addActuators: false, addActuators: false,
addSensors: false, addSensors: false,
sensorType: 'camera', sensorType: 'camera',
}, };
): Promise<SimulationSnapshot> { const mapSelection = options.map ?? DEFAULT_MAP_SELECTION;
this.releaseCurrent(); const report = (value: number, label: string) => {
if (!this.disposed && generation === this.loadGeneration)
options.onProgress?.({ value, label });
};
report(0.08, '初始化 MuJoCo WebAssembly');
const module = await getMujocoModule(); const module = await getMujocoModule();
const workspace = new MemfsWorkspace(module, manifest.id); if (this.disposed || generation !== this.loadGeneration) throw new Error('模型加载已取消');
const prepared = prepareProjectForMujoco(manifest, entryPath); report(0.32, '校验并准备模型资源');
this.supportFiles = prepared.manifest.files.filter( const workspace = new MemfsWorkspace(module, `${manifest.id}_stage_${generation}`);
const prepared = await prepareProjectForMujoco(manifest, entryPath);
const supportFiles = prepared.manifest.files.filter(
(file) => !manifest.files.some((original) => original.path === file.path), (file) => !manifest.files.some((original) => original.path === file.path),
); );
let nextSession: SimulationSession | null = null;
try { try {
report(0.46, '写入浏览器内存文件系统');
console.info('[MuJoCo] 写入 MEMFS', prepared.manifest.files.length); console.info('[MuJoCo] 写入 MEMFS', prepared.manifest.files.length);
workspace.mount(prepared.manifest); workspace.mount(prepared.manifest);
const entry = prepared.manifest.entries.find((candidate) => candidate.path === entryPath); const entry = prepared.manifest.entries.find((candidate) => candidate.path === entryPath);
let modelPath = workspace.path(entryPath); let modelRelativePath = entryPath,
modelPath = workspace.path(modelRelativePath);
const warnings = [...prepared.warnings]; const warnings = [...prepared.warnings];
if (entry?.format === 'urdf' && urdfMode === 'mjcf') { if (entry?.format === 'urdf' && urdfMode === 'mjcf') {
report(0.58, '转换并增强 URDF 模型');
console.info('[MuJoCo] 编译 URDF 中间模型', entryPath); console.info('[MuJoCo] 编译 URDF 中间模型', entryPath);
const intermediate = new SimulationSession(module, modelPath); const intermediate = new SimulationSession(module, modelPath);
try { try {
@@ -112,7 +155,8 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
); );
const enhanced = enhanceConvertedMjcf(grounded, enhancements); const enhanced = enhanceConvertedMjcf(grounded, enhancements);
workspace.writeGenerated(convertedPath, enhanced.data); workspace.writeGenerated(convertedPath, enhanced.data);
modelPath = workspace.path(convertedPath); modelRelativePath = convertedPath;
modelPath = workspace.path(modelRelativePath);
warnings.push( warnings.push(
`URDF 已转换为 MJCF${baseMode === 'floating' ? '浮动基座' : '固定基座'}),并整体平移 ${(-minimumZ).toFixed(4)} m,使最低点接触 z=0 地面`, `URDF 已转换为 MJCF${baseMode === 'floating' ? '浮动基座' : '固定基座'}),并整体平移 ${(-minimumZ).toFixed(4)} m,使最低点接触 z=0 地面`,
); );
@@ -136,20 +180,59 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
intermediate.dispose(); intermediate.dispose();
} }
} }
if (mapSelection.kind !== 'none') {
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,
);
workspace.writeGenerated(mapPath, composed.data);
if (composed.summary) warnings.push(composed.summary);
} else {
const resolvedMap = resolveProjectMap(prepared.manifest, mapSelection.descriptorPath);
const composed = composeProjectMap(
new TextEncoder().encode(workspace.readText(modelRelativePath)),
mapPath,
prepared.manifest,
resolvedMap,
mapSelection,
);
workspace.writeGenerated(mapPath, composed.data);
warnings.push(...composed.warnings, composed.summary);
}
modelRelativePath = mapPath;
modelPath = workspace.path(modelRelativePath);
}
report(0.84, '编译模型与物理数据');
console.info('[MuJoCo] 编译模型', modelPath); console.info('[MuJoCo] 编译模型', modelPath);
const session = new SimulationSession(module, modelPath, warnings); nextSession = new SimulationSession(module, modelPath, warnings);
if (entry?.format === 'urdf' && urdfMode === 'native') { if (entry?.format === 'urdf' && urdfMode === 'native') {
const offset = session.alignLowestPointToGround(); const offset = nextSession.alignLowestPointToGround();
warnings.push(`原生 URDF 已整体平移 ${offset.toFixed(4)} m,使最低点位于 z=0`); warnings.push(`原生 URDF 已整体平移 ${offset.toFixed(4)} m,使最低点位于 z=0`);
} }
if (warnings.length) console.info('[MuJoCo] URDF 兼容处理', warnings); if (warnings.length) console.info('[MuJoCo] 兼容与地图处理', warnings);
console.info('[MuJoCo] 模型编译完成'); console.info('[MuJoCo] 模型编译完成');
report(0.94, '生成初始仿真状态');
const snapshot = nextSession.snapshot();
if (this.disposed || generation !== this.loadGeneration) throw new Error('模型加载已取消');
this.releaseRetired();
this.retiredSession = this.session;
this.retiredWorkspace = this.workspace;
this.retiredSupportFiles = this.supportFiles;
this.session = nextSession;
this.workspace = workspace; this.workspace = workspace;
this.session = session; this.supportFiles = supportFiles;
const snapshot = session.snapshot(); nextSession = null;
console.info('[MuJoCo] 状态快照完成'); console.info('[MuJoCo] 状态快照完成');
return snapshot; return snapshot;
} catch (error) { } catch (error) {
nextSession?.dispose();
workspace.dispose(); workspace.dispose();
throw new Error( throw new Error(
`模型编译失败(${entryPath}):${error instanceof Error ? error.message : String(error)}`, `模型编译失败(${entryPath}):${error instanceof Error ? error.message : String(error)}`,
@@ -225,6 +308,23 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
cachedSupportFiles(): ProjectFile[] { cachedSupportFiles(): ProjectFile[] {
return this.supportFiles.map((file) => ({ ...file, data: file.data.slice() })); return this.supportFiles.map((file) => ({ ...file, data: file.data.slice() }));
} }
releaseRetired(): void {
this.retiredSession?.dispose();
this.retiredSession = null;
this.retiredWorkspace?.dispose();
this.retiredWorkspace = null;
this.retiredSupportFiles = [];
}
rollbackRetired(): void {
this.session?.dispose();
this.workspace?.dispose();
this.session = this.retiredSession;
this.workspace = this.retiredWorkspace;
this.supportFiles = this.retiredSupportFiles;
this.retiredSession = null;
this.retiredWorkspace = null;
this.retiredSupportFiles = [];
}
exportMjcf(): Uint8Array { exportMjcf(): Uint8Array {
if (!this.session || !this.workspace) throw new Error('尚未加载可导出的模型'); if (!this.session || !this.workspace) throw new Error('尚未加载可导出的模型');
const relative = '.__platform_export__.xml'; const relative = '.__platform_export__.xml';
@@ -260,6 +360,7 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
return output; return output;
} }
private releaseCurrent(): void { private releaseCurrent(): void {
this.releaseRetired();
this.session?.dispose(); this.session?.dispose();
this.session = null; this.session = null;
this.workspace?.dispose(); this.workspace?.dispose();
@@ -267,6 +368,8 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
this.supportFiles = []; this.supportFiles = [];
} }
dispose(): void { dispose(): void {
this.disposed = true;
this.loadGeneration += 1;
this.releaseCurrent(); this.releaseCurrent();
} }
} }
+25 -2
View File
@@ -1,6 +1,6 @@
import { meshIdFromSceneDataId } from './geometry'; import { heightfieldGeometryData, meshIdFromSceneDataId } from './geometry';
describe('meshIdFromSceneDataId', () => { describe('MuJoCo 几何辅助', () => {
it('解析 mjvGeom 的完整 mesh/凸包编码', () => { it('解析 mjvGeom 的完整 mesh/凸包编码', () => {
expect(meshIdFromSceneDataId(0)).toBe(0); expect(meshIdFromSceneDataId(0)).toBe(0);
expect(meshIdFromSceneDataId(1)).toBe(0); expect(meshIdFromSceneDataId(1)).toBe(0);
@@ -8,4 +8,27 @@ describe('meshIdFromSceneDataId', () => {
expect(meshIdFromSceneDataId(15)).toBe(7); expect(meshIdFromSceneDataId(15)).toBe(7);
expect(meshIdFromSceneDataId(-1)).toBe(-1); expect(meshIdFromSceneDataId(-1)).toBe(-1);
}); });
it('把 MuJoCo 高度场转换为带正确高度和三角面的网格', () => {
const result = heightfieldGeometryData(
{
hfield_adr: [0],
hfield_data: [0, 0.25, 0.5, 1],
hfield_ncol: [2],
hfield_nrow: [2],
hfield_size: [2, 3, 0.8, 0.01],
},
0,
);
expect(Array.from(result.positions).filter((_, index) => index % 3 !== 2)).toEqual([
-2, -3, 2, -3, -2, 3, 2, 3,
]);
expect([2, 5, 8, 11].map((index) => result.positions[index])).toEqual([
expect.closeTo(0),
expect.closeTo(0.2),
expect.closeTo(0.4),
expect.closeTo(0.8),
]);
expect(Array.from(result.indices)).toEqual([0, 1, 2, 1, 3, 2]);
});
}); });
+54
View File
@@ -5,3 +5,57 @@
export function meshIdFromSceneDataId(dataId: number): number { export function meshIdFromSceneDataId(dataId: number): number {
return dataId < 0 ? -1 : Math.floor(dataId / 2); return dataId < 0 ? -1 : Math.floor(dataId / 2);
} }
export interface HeightfieldModelArrays {
hfield_adr: ArrayLike<number>;
hfield_data: ArrayLike<number>;
hfield_ncol: ArrayLike<number>;
hfield_nrow: ArrayLike<number>;
hfield_size: ArrayLike<number>;
}
/** 将 MuJoCo 归一化高度场数据转换为以 geom 原点为基准的 Three.js 网格数据。 */
export function heightfieldGeometryData(
model: HeightfieldModelArrays,
id: number,
): { positions: Float32Array; indices: Uint32Array } {
const rows = Number(model.hfield_nrow[id]),
columns = Number(model.hfield_ncol[id]),
address = Number(model.hfield_adr[id]),
halfWidth = Number(model.hfield_size[id * 4]),
halfLength = Number(model.hfield_size[id * 4 + 1]),
heightScale = Number(model.hfield_size[id * 4 + 2]);
if (
id < 0 ||
rows < 2 ||
columns < 2 ||
address < 0 ||
!Number.isFinite(halfWidth) ||
!Number.isFinite(halfLength) ||
!Number.isFinite(heightScale)
)
return { positions: new Float32Array(), indices: new Uint32Array() };
const positions = new Float32Array(rows * columns * 3);
for (let row = 0; row < rows; row += 1)
for (let column = 0; column < columns; column += 1) {
const vertex = row * columns + column,
offset = vertex * 3;
positions[offset] = -halfWidth + (2 * halfWidth * column) / (columns - 1);
positions[offset + 1] = -halfLength + (2 * halfLength * row) / (rows - 1);
positions[offset + 2] = Number(model.hfield_data[address + vertex]) * heightScale;
}
const indices = new Uint32Array((rows - 1) * (columns - 1) * 6);
let offset = 0;
for (let row = 0; row < rows - 1; row += 1)
for (let column = 0; column < columns - 1; column += 1) {
const first = row * columns + column,
right = first + 1,
next = first + columns,
diagonal = next + 1;
indices.set([first, right, next, right, diagonal, next], offset);
offset += 6;
}
return { positions, indices };
}
+74 -28
View File
@@ -3,21 +3,21 @@
@tailwind utilities; @tailwind utilities;
:root { :root {
--ui-bg: #eef2f7; --ui-bg: #edf2f7;
--ui-panel: #fbfcfe; --ui-panel: #fbfcfe;
--ui-surface: #f7f9fc; --ui-surface: #f5f8fc;
--ui-surface-elevated: #fff; --ui-surface-elevated: #fff;
--ui-input: #fff; --ui-input: #fff;
--ui-hover: #e9eef5; --ui-hover: #e8eef5;
--ui-active: #dfe7f1; --ui-active: #dce6f0;
--ui-border: #d9e1eb; --ui-border: #d6e0ea;
--ui-border-strong: #b8c4d2; --ui-border-strong: #aebdcd;
--ui-text-primary: #122033; --ui-text-primary: #102033;
--ui-text-secondary: #3d4d61; --ui-text-secondary: #3a4d62;
--ui-text-tertiary: #5f6f82; --ui-text-tertiary: #68798c;
--ui-accent: #16835f; --ui-accent: #138462;
--ui-accent-hover: #116b4d; --ui-accent-hover: #0d6d50;
--ui-accent-soft: #dff4ec; --ui-accent-soft: #ddf5ec;
--ui-danger: #c53b45; --ui-danger: #c53b45;
--ui-danger-soft: #fff0f1; --ui-danger-soft: #fff0f1;
--ui-danger-border: #f1b9bd; --ui-danger-border: #f1b9bd;
@@ -32,21 +32,21 @@
color-scheme: light; color-scheme: light;
} }
.theme-dark { .theme-dark {
--ui-bg: #0e141d; --ui-bg: #09111e;
--ui-panel: #171f2b; --ui-panel: #121c2a;
--ui-surface: #1d2735; --ui-surface: #182536;
--ui-surface-elevated: #263243; --ui-surface-elevated: #213148;
--ui-input: #121a25; --ui-input: #0d1725;
--ui-hover: #283548; --ui-hover: #223349;
--ui-active: #324258; --ui-active: #2b4059;
--ui-border: #2c394b; --ui-border: #26374b;
--ui-border-strong: #43536a; --ui-border-strong: #40566f;
--ui-text-primary: #edf2f7; --ui-text-primary: #f1f5f9;
--ui-text-secondary: #c8d2df; --ui-text-secondary: #c7d3e0;
--ui-text-tertiary: #8f9caf; --ui-text-tertiary: #8fa0b5;
--ui-accent: #35c792; --ui-accent: #38d39f;
--ui-accent-hover: #2eae80; --ui-accent-hover: #2db789;
--ui-accent-soft: #163b33; --ui-accent-soft: #123b31;
--ui-danger: #ff7a83; --ui-danger: #ff7a83;
--ui-danger-soft: #401f26; --ui-danger-soft: #401f26;
--ui-danger-border: #71333c; --ui-danger-border: #71333c;
@@ -71,7 +71,16 @@
overflow: hidden; overflow: hidden;
background: var(--ui-bg); background: var(--ui-bg);
color: var(--ui-text-primary); color: var(--ui-text-primary);
font-family: Inter, 'Noto Sans SC', system-ui, sans-serif; font-family: Inter, 'Noto Sans SC', 'Microsoft YaHei UI', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
canvas {
display: block;
}
::selection {
background: color-mix(in srgb, var(--ui-accent) 28%, transparent);
color: var(--ui-text-primary);
} }
button, button,
input, input,
@@ -97,6 +106,43 @@
.technical-value { .technical-value {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.viewport-shell {
background:
radial-gradient(
circle at 50% 18%,
color-mix(in srgb, var(--ui-accent) 8%, transparent),
transparent 34%
),
linear-gradient(145deg, var(--ui-surface), var(--ui-bg));
}
.workspace-welcome {
animation: workspace-enter 360ms cubic-bezier(0.22, 1, 0.36, 1) both;
box-shadow:
0 28px 80px rgb(2 8 23 / 28%),
inset 0 1px 0 rgb(255 255 255 / 5%);
}
.welcome-glow {
position: absolute;
top: -8rem;
left: 50%;
width: 22rem;
height: 15rem;
border-radius: 999px;
background: color-mix(in srgb, var(--ui-accent) 18%, transparent);
filter: blur(52px);
pointer-events: none;
transform: translateX(-50%);
}
}
@keyframes workspace-enter {
from {
opacity: 0;
transform: translateY(10px) scale(0.985);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
height: 6px; height: 6px;
@@ -0,0 +1,110 @@
import * as THREE from 'three';
import { createEditableObject, type EditableMapDocument } from '../map/editor/types';
import { MapEditorLayer } from './MapEditorLayer';
const documentValue: EditableMapDocument = {
schemaVersion: 1,
mapId: 'map',
revision: 0,
objects: [createEditableObject('box', 'box_1')],
spawnPoints: [{ id: 'start', name: '起点', position: [1, 2, 0], yawDeg: 90 }],
};
function createLayer() {
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.set(0, 0, 5);
camera.lookAt(0, 0, 0);
camera.updateMatrixWorld();
camera.updateProjectionMatrix();
const element = document.createElement('canvas');
const callbacks = {
onSelect: vi.fn(),
onTransform: vi.fn(),
onDragging: vi.fn(),
};
return { scene, callbacks, layer: new MapEditorLayer(scene, camera, element, callbacks) };
}
describe('MapEditorLayer', () => {
it('更新预览时释放旧几何和材质', () => {
const { scene, layer } = createLayer();
layer.setDocument(documentValue);
expect(layer.group.children).toHaveLength(2);
expect(layer.group.children[1].name).toBe('__platform_map_editor_spawn_start');
const preview = layer.group.children[0].children[0] as THREE.Mesh;
const geometryDispose = vi.spyOn(preview.geometry, 'dispose');
const material = preview.material as THREE.Material;
const materialDispose = vi.spyOn(material, 'dispose');
layer.setDocument(null);
expect(geometryDispose).toHaveBeenCalledOnce();
expect(materialDispose).toHaveBeenCalledOnce();
expect(layer.group.children).toHaveLength(0);
layer.dispose();
expect(scene.children).not.toContain(layer.group);
});
it('在视口中拾取编辑对象', () => {
const { callbacks, layer } = createLayer();
layer.setDocument(documentValue);
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.onSelect).toHaveBeenCalledWith('box_1');
expect(
layer.handlePointerDown({ button: 0, clientX: 2, clientY: 2 } as PointerEvent, bounds),
).toBe(false);
expect(callbacks.onSelect).toHaveBeenCalledTimes(1);
layer.dispose();
});
it('锁定对象可选中但不挂载变换操纵器', () => {
const { layer } = createLayer();
layer.setDocument({
...documentValue,
objects: [{ ...documentValue.objects[0], placementMode: 'locked' }],
});
layer.selectObject('box_1');
expect(layer.transform.object).toBeUndefined();
expect(layer.transform.getHelper().visible).toBe(false);
layer.dispose();
});
it('提交操纵器变换并保持 W/E 模式约束', () => {
const { callbacks, layer } = createLayer();
layer.setDocument(documentValue);
layer.selectObject('box_1');
layer.setTransformMode('scale');
expect(layer.transform.mode).toBe('scale');
expect(layer.transform.showX).toBe(true);
expect(layer.transform.showY).toBe(true);
layer.setTransformMode('rotate');
expect(layer.transform.mode).toBe('rotate');
expect(layer.transform.showX).toBe(false);
expect(layer.transform.showY).toBe(false);
expect(layer.transform.showZ).toBe(true);
const object = layer.group.children[0];
object.position.set(2, 3, 4);
object.quaternion.setFromAxisAngle(new THREE.Vector3(0, 0, 1), Math.PI / 2);
object.scale.set(2, 3, 4);
layer.transform.dispatchEvent({ type: 'mouseUp', mode: 'rotate' });
expect(callbacks.onTransform).toHaveBeenCalledWith(
'box_1',
[2, 3, 4],
expect.arrayContaining([expect.any(Number), 0, 0, expect.any(Number)]),
[2, 3, 4],
);
layer.dispose();
});
});
+267
View File
@@ -0,0 +1,267 @@
import * as THREE from 'three';
import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js';
import type {
EditableMapDocument,
EditableMapObject,
MapEditorTransformMode,
} from '../map/editor/types';
import type { SpawnPoint } from '../map/types';
export interface MapEditorLayerCallbacks {
onSelect(id: string | null): void;
onTransform(
id: string,
position: [number, number, number],
quaternion: [number, number, number, number],
scale: [number, number, number],
): void;
onDragging(value: boolean): void;
}
function material(object: EditableMapObject): THREE.MeshStandardMaterial {
const [r, g, b, a] = object.rgba;
return new THREE.MeshStandardMaterial({
color: new THREE.Color(r, g, b),
emissive: new THREE.Color(0x000000),
opacity: Math.min(a, 0.65),
transparent: true,
depthWrite: false,
});
}
function mesh(geometry: THREE.BufferGeometry, object: EditableMapObject): THREE.Mesh {
const result = new THREE.Mesh(geometry, material(object));
result.castShadow = true;
result.receiveShadow = true;
return result;
}
function spawnPreview(spawn: SpawnPoint): THREE.Group {
const group = new THREE.Group();
group.name = `__platform_map_editor_spawn_${spawn.id}`;
group.position.set(spawn.position[0], spawn.position[1], spawn.position[2] + 0.03);
group.rotation.z = (spawn.yawDeg * Math.PI) / 180;
const markerMaterial = new THREE.MeshStandardMaterial({
color: 0x22c55e,
emissive: 0x14532d,
transparent: true,
opacity: 0.9,
});
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);
arrow.rotation.z = -Math.PI / 2;
arrow.position.x = 0.25;
group.add(ring, arrow);
return group;
}
function objectPreview(object: EditableMapObject): THREE.Group {
const group = new THREE.Group(),
p = object.parameters;
group.name = `__platform_map_editor_${object.id}`;
group.userData.mapEditorObjectId = object.id;
group.userData.mapEditorLocked = object.placementMode === 'locked';
group.position.fromArray(object.pose.position);
group.quaternion.set(
object.pose.quaternion[1],
object.pose.quaternion[2],
object.pose.quaternion[3],
object.pose.quaternion[0],
);
if (object.type === 'box')
group.add(mesh(new THREE.BoxGeometry(p.sizeX, p.sizeY, p.sizeZ), object));
else if (object.type === 'cylinder')
group.add(
mesh(
new THREE.CylinderGeometry(p.radius, p.radius, p.height, 24).rotateX(Math.PI / 2),
object,
),
);
else if (object.type === 'capsule')
group.add(
mesh(new THREE.CapsuleGeometry(p.radius, p.length, 8, 16).rotateX(Math.PI / 2), object),
);
else if (object.type === 'ramp') {
const item = mesh(
new THREE.BoxGeometry(Math.hypot(p.length, p.rise), p.width, p.thickness),
object,
);
item.position.z = p.rise / 2;
item.rotation.y = -Math.atan2(p.rise, p.length);
group.add(item);
} else
for (let index = 0; index < p.count; index += 1) {
const height = p.stepHeight * (index + 1);
const item = mesh(new THREE.BoxGeometry(p.stepDepth, p.width, height), object);
item.position.set(p.stepDepth * index, 0, height / 2);
group.add(item);
}
group.traverse((child) => {
child.userData.mapEditorObjectId = object.id;
});
return group;
}
export class MapEditorLayer {
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, THREE.Group>();
private selectedId: string | null = null;
private documentLoaded = false;
constructor(
private readonly scene: THREE.Scene,
private readonly camera: THREE.Camera,
domElement: HTMLElement,
private readonly callbacks: MapEditorLayerCallbacks,
) {
this.group.name = '__platform_map_editor__';
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_map_editor_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.documentLoaded;
}
setDocument(document: EditableMapDocument | null): void {
const selected = this.selectedId;
this.clearObjects();
this.documentLoaded = Boolean(document);
this.transform.enabled = this.documentLoaded;
if (!document) {
this.selectObject(null, false);
return;
}
for (const object of document.objects) {
if (!object.enabled) continue;
const preview = objectPreview(object);
this.objects.set(object.id, preview);
this.group.add(preview);
}
for (const spawn of document.spawnPoints) this.group.add(spawnPreview(spawn));
this.selectObject(selected && this.objects.has(selected) ? selected : null, false);
}
setTransformMode(mode: MapEditorTransformMode): void {
this.transform.setMode(mode);
const translate = mode === 'translate';
const scale = mode === 'scale';
this.transform.showX = translate || scale;
this.transform.showY = translate || scale;
this.transform.showZ = translate || scale || mode === 'rotate';
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.transform.detach();
for (const [objectId, root] of this.objects)
root.traverse((child) => {
if (!(child instanceof THREE.Mesh)) return;
const materials = Array.isArray(child.material) ? child.material : [child.material];
for (const item of materials) {
if (!(item instanceof THREE.MeshStandardMaterial)) continue;
item.emissive.setHex(objectId === this.selectedId ? 0x1d4ed8 : 0x000000);
item.emissiveIntensity = objectId === this.selectedId ? 0.45 : 1;
item.opacity = objectId === this.selectedId ? 0.85 : 0.65;
}
});
const selected = this.selectedId ? this.objects.get(this.selectedId) : undefined;
const editable = Boolean(selected && !selected.userData.mapEditorLocked);
if (selected && editable) this.transform.attach(selected);
this.helper.visible = editable;
if (notify) this.callbacks.onSelect(this.selectedId);
}
/** 命中编辑对象或操纵器时消费左键;空白区域留给 OrbitControls 旋转视角。 */
handlePointerDown(event: PointerEvent, bounds: DOMRect): 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;
}
private commitTransform(): void {
if (!this.selectedId) return;
const object = this.objects.get(this.selectedId);
if (!object) return;
const position: [number, number, number] = [
object.position.x,
object.position.y,
object.position.z,
];
const quaternion: [number, number, number, number] = [
object.quaternion.w,
object.quaternion.x,
object.quaternion.y,
object.quaternion.z,
];
const scale: [number, number, number] = [object.scale.x, object.scale.y, object.scale.z];
this.callbacks.onTransform(this.selectedId, position, quaternion, scale);
}
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();
this.objects.clear();
for (const geometry of geometries) geometry.dispose();
for (const item of materials) item.dispose();
}
clear(): void {
this.documentLoaded = false;
this.selectedId = null;
this.clearObjects();
this.transform.enabled = false;
this.callbacks.onDragging(false);
}
dispose(): void {
this.clear();
this.callbacks.onDragging(false);
this.transform.dispose();
this.helper.removeFromParent();
this.group.removeFromParent();
}
}
+135 -13
View File
@@ -6,7 +6,7 @@ import type {
SimulationSession, SimulationSession,
SimulationSnapshot, SimulationSnapshot,
} from '../simulation/SimulationSession'; } from '../simulation/SimulationSession';
import { meshIdFromSceneDataId } from '../simulation/geometry'; import { heightfieldGeometryData, meshIdFromSceneDataId } from '../simulation/geometry';
import { OrientationGizmo } from './OrientationGizmo'; import { OrientationGizmo } from './OrientationGizmo';
import { import {
closestRayAxisParameter, closestRayAxisParameter,
@@ -17,6 +17,10 @@ import {
import { texturePixelsToRgba } from './texturePixels'; import { texturePixelsToRgba } from './texturePixels';
import { DEFAULT_VIEWER_DISPLAY_OPTIONS, type ViewerDisplayOptions } from './displayOptions'; import { DEFAULT_VIEWER_DISPLAY_OPTIONS, type ViewerDisplayOptions } from './displayOptions';
import { ViewerVisualizationHelpers } from './ViewerVisualizationHelpers'; import { ViewerVisualizationHelpers } from './ViewerVisualizationHelpers';
import { VisualMapLayer } from './VisualMapLayer';
import type { VisualMapAsset } from '../map/types';
import type { EditableMapDocument, MapEditorTransformMode } from '../map/editor/types';
import { MapEditorLayer } from './MapEditorLayer';
export type InteractionMode = 'select' | 'joint' | 'force'; export type InteractionMode = 'select' | 'joint' | 'force';
export type ViewerTheme = 'light' | 'dark'; export type ViewerTheme = 'light' | 'dark';
@@ -31,6 +35,13 @@ interface ViewerCallbacks {
onSelection(selection: ViewerSelection | null): void; onSelection(selection: ViewerSelection | null): void;
onFrame(frame: FrameResult, fps: number, snapshot?: SimulationSnapshot): void; onFrame(frame: FrameResult, fps: number, snapshot?: SimulationSnapshot): void;
onError(error: Error): void; onError(error: Error): void;
onMapEditorSelect(id: string | null): void;
onMapEditorTransform(
id: string,
position: [number, number, number],
quaternion: [number, number, number, number],
scale: [number, number, number],
): void;
} }
class CapsuleGeometry extends THREE.BufferGeometry { class CapsuleGeometry extends THREE.BufferGeometry {
@@ -56,6 +67,9 @@ export class MuJoCoViewer {
forceScale = 50; forceScale = 50;
private displayOptions: ViewerDisplayOptions = { ...DEFAULT_VIEWER_DISPLAY_OPTIONS }; private displayOptions: ViewerDisplayOptions = { ...DEFAULT_VIEWER_DISPLAY_OPTIONS };
private modelHasVisuals = false; private modelHasVisuals = false;
private mapHasVisuals = false;
private showVisualMap = true;
private showMapCollision = false;
private showSensorCamera = true; private showSensorCamera = true;
private sensorCameraId = -1; private sensorCameraId = -1;
private modelExtent = 2; private modelExtent = 2;
@@ -67,6 +81,8 @@ export class MuJoCoViewer {
private lastFpsAt = performance.now(); private lastFpsAt = performance.now();
private fpsFrames = 0; private fpsFrames = 0;
private lastSnapshotAt = 0; private lastSnapshotAt = 0;
private snapshotDirty = false;
private runtimeErrorReported = false;
private meshes: THREE.Mesh[] = []; private meshes: THREE.Mesh[] = [];
private geometries = new Map<string, THREE.BufferGeometry>(); private geometries = new Map<string, THREE.BufferGeometry>();
private textures = new Map<number, THREE.DataTexture>(); private textures = new Map<number, THREE.DataTexture>();
@@ -94,6 +110,8 @@ export class MuJoCoViewer {
private resizeObserver: ResizeObserver; private resizeObserver: ResizeObserver;
private orientationGizmo: OrientationGizmo; private orientationGizmo: OrientationGizmo;
private visualizationHelpers: ViewerVisualizationHelpers; private visualizationHelpers: ViewerVisualizationHelpers;
private visualMapLayer: VisualMapLayer;
private mapEditorLayer: MapEditorLayer;
private grid: THREE.GridHelper; private grid: THREE.GridHelper;
private hemisphere: THREE.HemisphereLight; private hemisphere: THREE.HemisphereLight;
private themeTransition?: { private themeTransition?: {
@@ -108,8 +126,14 @@ export class MuJoCoViewer {
private readonly host: HTMLElement, private readonly host: HTMLElement,
private readonly callbacks: ViewerCallbacks, private readonly callbacks: ViewerCallbacks,
) { ) {
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); this.renderer = new THREE.WebGLRenderer({
this.renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); antialias: true,
alpha: false,
stencil: false,
powerPreference: 'high-performance',
});
// 高 DPI 屏幕限制内部像素比,显著降低双视口与阴影的 GPU 填充压力。
this.renderer.setPixelRatio(Math.min(devicePixelRatio, 1.75));
this.renderer.shadowMap.enabled = true; this.renderer.shadowMap.enabled = true;
this.renderer.outputColorSpace = THREE.SRGBColorSpace; this.renderer.outputColorSpace = THREE.SRGBColorSpace;
host.append(this.renderer.domElement); host.append(this.renderer.domElement);
@@ -128,6 +152,15 @@ export class MuJoCoViewer {
this.grid.visible = this.displayOptions.showGrid; this.grid.visible = this.displayOptions.showGrid;
this.scene.add(this.grid); this.scene.add(this.grid);
this.visualizationHelpers = new ViewerVisualizationHelpers(this.scene); this.visualizationHelpers = new ViewerVisualizationHelpers(this.scene);
this.visualMapLayer = new VisualMapLayer(this.scene);
this.mapEditorLayer = new MapEditorLayer(this.scene, this.camera, this.renderer.domElement, {
onSelect: (id) => this.callbacks.onMapEditorSelect(id),
onTransform: (id, position, quaternion, scale) =>
this.callbacks.onMapEditorTransform(id, position, quaternion, scale),
onDragging: (value) => {
this.controls.enabled = !value;
},
});
this.orientationGizmo = new OrientationGizmo(host); this.orientationGizmo = new OrientationGizmo(host);
this.orientationGizmo.update(this.camera); this.orientationGizmo.update(this.camera);
this.resizeObserver = new ResizeObserver(() => this.resize()); this.resizeObserver = new ResizeObserver(() => this.resize());
@@ -144,6 +177,8 @@ export class MuJoCoViewer {
attach(session: SimulationSession | null): void { attach(session: SimulationSession | null): void {
this.releaseModel(); this.releaseModel();
this.runtimeErrorReported = false;
this.snapshotDirty = false;
this.session = session; this.session = session;
if (!session) return; if (!session) return;
this.option = new session.module.MjvOption(); this.option = new session.module.MjvOption();
@@ -182,6 +217,57 @@ export class MuJoCoViewer {
setShowCollision(value: boolean): void { setShowCollision(value: boolean): void {
this.setDisplayOptions({ ...this.displayOptions, showCollision: value }); 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);
this.mapHasVisuals = this.visualMapLayer.loaded;
this.visualMapLayer.visible = this.showVisualMap && this.displayOptions.showVisual;
this.applyGeomVisibility();
}
setMapEditorDocument(document: EditableMapDocument | null): void {
this.mapEditorLayer.setDocument(document);
this.controls.enableRotate = this.mode === 'select';
}
selectMapEditorObject(id: string | null): void {
this.mapEditorLayer.selectObject(id);
}
setMapEditorTransformMode(mode: MapEditorTransformMode): void {
this.mapEditorLayer.setTransformMode(mode);
}
setMapEditorSnapping(translation: number | null, rotationDegrees: number | null): void {
this.mapEditorLayer.setSnapping(translation, rotationDegrees);
}
mapPlanePoint(clientX: number, clientY: number): [number, number, number] | null {
const rect = this.renderer.domElement.getBoundingClientRect();
if (
rect.width <= 0 ||
rect.height <= 0 ||
clientX < rect.left ||
clientX > rect.right ||
clientY < rect.top ||
clientY > rect.bottom
)
return null;
this.pointer.set(
((clientX - rect.left) / rect.width) * 2 - 1,
-((clientY - rect.top) / rect.height) * 2 + 1,
);
this.raycaster.setFromCamera(this.pointer, this.camera);
const point = this.raycaster.ray.intersectPlane(
new THREE.Plane(new THREE.Vector3(0, 0, 1), 0),
new THREE.Vector3(),
);
return point ? [point.x, point.y, 0] : null;
}
setMapDisplay(showVisual: boolean, showCollision: boolean): void {
this.showVisualMap = showVisual;
this.showMapCollision = showCollision;
this.visualMapLayer.visible = showVisual && this.displayOptions.showVisual;
this.applyGeomVisibility();
}
setShowSensorCamera(value: boolean): void { setShowSensorCamera(value: boolean): void {
this.showSensorCamera = value; this.showSensorCamera = value;
} }
@@ -228,11 +314,18 @@ export class MuJoCoViewer {
private applyGeomVisibility(): void { private applyGeomVisibility(): void {
if (!this.option || !this.session) return; if (!this.option || !this.session) return;
this.modelHasVisuals = false; this.modelHasVisuals = false;
for (let index = 0; index < this.session.model.ngeom; index += 1) for (let index = 0; index < this.session.model.ngeom; index += 1) {
if (Number(this.session.model.geom_group[index]) === 1) { if (Number(this.session.model.geom_group[index]) !== 1) continue;
this.modelHasVisuals = true; const geom = this.session.model.geom(index);
break; try {
if (!geom.name.startsWith('__platform_map_') && geom.name !== '__platform_ground__') {
this.modelHasVisuals = true;
break;
}
} finally {
geom.delete();
} }
}
this.option.geomgroup[0] = this.modelHasVisuals this.option.geomgroup[0] = this.modelHasVisuals
? this.displayOptions.showCollision ? this.displayOptions.showCollision
? 1 ? 1
@@ -241,7 +334,10 @@ export class MuJoCoViewer {
? 1 ? 1
: 0; : 0;
this.option.geomgroup[1] = this.displayOptions.showVisual ? 1 : 0; this.option.geomgroup[1] = this.displayOptions.showVisual ? 1 : 0;
this.option.geomgroup[2] =
this.showMapCollision || (!this.mapHasVisuals && this.showVisualMap) ? 1 : 0;
this.option.geomgroup[5] = 0; this.option.geomgroup[5] = 0;
this.visualMapLayer.visible = this.showVisualMap && this.displayOptions.showVisual;
} }
resetCamera(): void { resetCamera(): void {
if (this.session) this.fitCamera(this.session); if (this.session) this.fitCamera(this.session);
@@ -342,13 +438,16 @@ export class MuJoCoViewer {
this.lastFpsAt = now; this.lastFpsAt = now;
} }
const snapshot = const snapshot =
this.session && now - this.lastSnapshotAt > 150 this.session && (result.steps > 0 || this.snapshotDirty) && now - this.lastSnapshotAt > 200
? ((this.lastSnapshotAt = now), this.session.snapshot()) ? ((this.lastSnapshotAt = now), (this.snapshotDirty = false), this.session.snapshot())
: undefined; : undefined;
/* 避免每个 RAF 都触发 Zustand/React 全树重渲染。 */ if (snapshot || fps > 0) // 暂停时仅在交互真正修改状态后刷新,不再固定轮询完整模型快照。
this.callbacks.onFrame(result, fps, snapshot); if (snapshot || fps > 0) this.callbacks.onFrame(result, fps, snapshot);
} catch (error) { } catch (error) {
this.callbacks.onError(error instanceof Error ? error : new Error(String(error))); if (!this.runtimeErrorReported) {
this.runtimeErrorReported = true;
this.callbacks.onError(error instanceof Error ? error : new Error(String(error)));
}
} }
this.frame = requestAnimationFrame(this.animate); this.frame = requestAnimationFrame(this.animate);
}; };
@@ -458,6 +557,8 @@ export class MuJoCoViewer {
const m = this.session!.module, const m = this.session!.module,
t = g.type, t = g.type,
s = g.size; s = g.size;
if (t === m.mjtGeom.mjGEOM_HFIELD.value && g.dataid >= 0)
return this.heightfieldGeometry(g.dataid);
if (t === m.mjtGeom.mjGEOM_PLANE.value) if (t === m.mjtGeom.mjGEOM_PLANE.value)
return new THREE.PlaneGeometry(2 * (s[0] || 1e3), 2 * (s[1] || 1e3)); return new THREE.PlaneGeometry(2 * (s[0] || 1e3), 2 * (s[1] || 1e3));
if (t === m.mjtGeom.mjGEOM_SPHERE.value) return new THREE.SphereGeometry(s[0], 24, 16); if (t === m.mjtGeom.mjGEOM_SPHERE.value) return new THREE.SphereGeometry(s[0], 24, 16);
@@ -478,6 +579,15 @@ export class MuJoCoViewer {
return this.meshGeometry(meshIdFromSceneDataId(g.dataid)); return this.meshGeometry(meshIdFromSceneDataId(g.dataid));
return new THREE.BufferGeometry(); return new THREE.BufferGeometry();
} }
private heightfieldGeometry(id: number): THREE.BufferGeometry {
const data = heightfieldGeometryData(this.session!.model, id),
geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(data.positions, 3));
geometry.setIndex(new THREE.BufferAttribute(data.indices, 1));
geometry.computeVertexNormals();
geometry.computeBoundingSphere();
return geometry;
}
private meshGeometry(id: number): THREE.BufferGeometry { private meshGeometry(id: number): THREE.BufferGeometry {
const m = this.session!.model; const m = this.session!.model;
const va = Number(m.mesh_vertadr[id]), const va = Number(m.mesh_vertadr[id]),
@@ -533,7 +643,12 @@ export class MuJoCoViewer {
private createMesh(g: MjvGeom, key: string): THREE.Mesh { private createMesh(g: MjvGeom, key: string): THREE.Mesh {
const m = this.session!.module, const m = this.session!.module,
isMesh = g.type === m.mjtGeom.mjGEOM_MESH.value && g.dataid >= 0, isMesh = g.type === m.mjtGeom.mjGEOM_MESH.value && g.dataid >= 0,
sharedKey = isMesh ? `mesh:${meshIdFromSceneDataId(g.dataid)}` : undefined; isHeightfield = g.type === m.mjtGeom.mjGEOM_HFIELD.value && g.dataid >= 0,
sharedKey = isMesh
? `mesh:${meshIdFromSceneDataId(g.dataid)}`
: isHeightfield
? `hfield:${g.dataid}`
: undefined;
let geometry = sharedKey ? this.geometries.get(sharedKey) : undefined; let geometry = sharedKey ? this.geometries.get(sharedKey) : undefined;
if (!geometry) { if (!geometry) {
geometry = this.primitive(g); geometry = this.primitive(g);
@@ -617,6 +732,10 @@ export class MuJoCoViewer {
} }
private onPointerDown = (event: PointerEvent): void => { private onPointerDown = (event: PointerEvent): void => {
if (event.button !== 0) return; if (event.button !== 0) return;
if (
this.mapEditorLayer.handlePointerDown(event, this.renderer.domElement.getBoundingClientRect())
)
return;
this.eventPointer(event); this.eventPointer(event);
this.raycaster.setFromCamera(this.pointer, this.camera); this.raycaster.setFromCamera(this.pointer, this.camera);
const hit = this.raycaster.intersectObjects( const hit = this.raycaster.intersectObjects(
@@ -722,6 +841,7 @@ export class MuJoCoViewer {
value += resolveHingeDragDelta(worldDelta, tangentDelta, planeFacing); value += resolveHingeDragDelta(worldDelta, tangentDelta, planeFacing);
} }
if (this.session.setJointPosition(this.dragJointId, value)) { if (this.session.setJointPosition(this.dragJointId, value)) {
this.snapshotDirty = true;
this.dragJointValue = value; this.dragJointValue = value;
this.dragStart.copy(this.pointer); this.dragStart.copy(this.pointer);
if (currentPlaneVector && currentPlaneVector.lengthSq() > 1e-12) if (currentPlaneVector && currentPlaneVector.lengthSq() > 1e-12)
@@ -838,6 +958,8 @@ export class MuJoCoViewer {
this.controls.dispose(); this.controls.dispose();
this.orientationGizmo.dispose(); this.orientationGizmo.dispose();
this.visualizationHelpers.dispose(); this.visualizationHelpers.dispose();
this.visualMapLayer.dispose();
this.mapEditorLayer.dispose();
this.grid.geometry.dispose(); this.grid.geometry.dispose();
const gridMaterials = Array.isArray(this.grid.material) const gridMaterials = Array.isArray(this.grid.material)
? this.grid.material ? this.grid.material
@@ -0,0 +1,72 @@
import * as THREE from 'three';
import { VisualMapLayer, validateSelfContainedGlb } from './VisualMapLayer';
import type { VisualMapAsset } from '../map/types';
function glb(jsonValue: unknown, declaredLengthOffset = 0): Uint8Array {
const json = new TextEncoder().encode(JSON.stringify(jsonValue));
const paddedLength = Math.ceil(json.byteLength / 4) * 4;
const data = new Uint8Array(20 + paddedLength);
data.fill(0x20, 20);
data.set(json, 20);
const view = new DataView(data.buffer);
view.setUint32(0, 0x46546c67, true);
view.setUint32(4, 2, true);
view.setUint32(8, data.byteLength + declaredLengthOffset, true);
view.setUint32(12, paddedLength, true);
view.setUint32(16, 0x4e4f534a, true);
return data;
}
function asset(data: Uint8Array): VisualMapAsset {
return {
id: 'test',
name: 'test',
path: 'scene.glb',
data,
castShadow: true,
receiveShadow: true,
};
}
describe('validateSelfContainedGlb', () => {
it('允许内嵌 data URI 和无 URI 的 GLB buffer', () => {
expect(() =>
validateSelfContainedGlb(
asset(glb({ asset: { version: '2.0' }, buffers: [{}], images: [{ uri: 'DATA:abc' }] })),
),
).not.toThrow();
});
it('拒绝空 URI、外部 URI 和错误总长度', () => {
expect(() =>
validateSelfContainedGlb(asset(glb({ asset: { version: '2.0' }, buffers: [{ uri: '' }] }))),
).toThrow('外部资源 URI');
expect(() =>
validateSelfContainedGlb(
asset(glb({ asset: { version: '2.0' }, images: [{ uri: 'texture.png' }] })),
),
).toThrow('外部资源 URI');
expect(() => validateSelfContainedGlb(asset(glb({ asset: { version: '2.0' } }, 4)))).toThrow(
'总长度',
);
});
it('清理共享 Three.js 资源并关闭 ImageBitmap', () => {
const scene = new THREE.Scene();
const layer = new VisualMapLayer(scene);
const geometry = 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 geometryDispose = vi.spyOn(geometry, 'dispose');
const materialDispose = vi.spyOn(material, 'dispose');
const textureDispose = vi.spyOn(texture, 'dispose');
layer.group.add(new THREE.Mesh(geometry, material), new THREE.Mesh(geometry, material));
layer.clear();
expect(geometryDispose).toHaveBeenCalledOnce();
expect(materialDispose).toHaveBeenCalledOnce();
expect(textureDispose).toHaveBeenCalledOnce();
expect(close).toHaveBeenCalledOnce();
layer.dispose();
});
});
+142
View File
@@ -0,0 +1,142 @@
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import type { VisualMapAsset } from '../map/types';
function exactArrayBuffer(data: Uint8Array): ArrayBuffer {
return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer;
}
export function validateSelfContainedGlb(asset: VisualMapAsset): void {
const data = asset.data;
if (data.byteLength < 20) throw new Error(`${asset.path} 不是有效的 GLB 文件`);
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
if (view.getUint32(0, true) !== 0x46546c67 || view.getUint32(4, true) !== 2)
throw new Error(`${asset.path} 必须是 GLB 2.0`);
if (view.getUint32(8, true) !== data.byteLength)
throw new Error(`${asset.path} 的 GLB 总长度字段与实际文件不一致`);
if (view.getUint32(12, true) + 20 > data.byteLength || view.getUint32(16, true) !== 0x4e4f534a)
throw new Error(`${asset.path} 缺少有效 JSON Chunk`);
for (let offset = 12; offset < data.byteLength;) {
if (offset + 8 > data.byteLength) throw new Error(`${asset.path} 包含截断的 GLB Chunk`);
const chunkLength = view.getUint32(offset, true);
offset += 8 + chunkLength;
if (offset > data.byteLength) throw new Error(`${asset.path} 包含越界的 GLB Chunk`);
}
let json: { buffers?: Array<{ uri?: string }>; images?: Array<{ uri?: string }> };
try {
const length = view.getUint32(12, true);
const text = new TextDecoder().decode(data.subarray(20, 20 + length)).replace(/\0+$/g, '');
json = JSON.parse(text) as typeof json;
} catch (error) {
throw new Error(
`GLB JSON 无法解析:${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
for (const resource of [...(json.buffers ?? []), ...(json.images ?? [])]) {
if (!Object.hasOwn(resource, 'uri')) continue;
if (
typeof resource.uri !== 'string' ||
!resource.uri.trim() ||
!resource.uri.toLowerCase().startsWith('data:')
)
throw new Error(`GLB 包含外部资源 URI,V2 仅支持自包含文件:${resource.uri ?? ''}`);
}
}
export class VisualMapLayer {
readonly group = new THREE.Group();
private generation = 0;
constructor(scene: THREE.Scene) {
this.group.name = '__platform_visual_map__';
scene.add(this.group);
}
get loaded(): boolean {
return this.group.children.length > 0;
}
set visible(value: boolean) {
this.group.visible = value;
}
async load(asset: VisualMapAsset | null): Promise<void> {
const generation = ++this.generation;
if (!asset) {
this.clear();
return;
}
validateSelfContainedGlb(asset);
const gltf = await new GLTFLoader().parseAsync(exactArrayBuffer(asset.data), '');
if (generation !== this.generation) {
this.disposeObjects([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(`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);
}
clear(invalidate = true): void {
if (invalidate) this.generation += 1;
const children = [...this.group.children];
for (const child of children) this.group.remove(child);
this.disposeObjects(children);
}
dispose(): void {
this.clear();
this.group.removeFromParent();
}
private disposeObjects(roots: THREE.Object3D[]): void {
const geometries = new Set<THREE.BufferGeometry>();
const materials = new Set<THREE.Material>();
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);
});
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 texture of textures) {
const source = texture.source.data;
const images = Array.isArray(source) ? source : [source];
texture.dispose();
for (const image of images) {
if (!image || typeof image !== 'object' || closedImages.has(image)) continue;
closedImages.add(image);
(image as { close?: () => void }).close?.();
}
texture.source.data = null;
}
for (const material of materials) material.dispose();
}
}
+28 -3
View File
@@ -16,6 +16,27 @@ const pyodideDirectory = resolve(
'../node_modules/pyodide', '../node_modules/pyodide',
); );
/** 生产预览中长期缓存带内容哈希的资源,同时允许入口文件及时更新。 */
function previewCacheHeaders(): Plugin {
return {
name: 'preview-cache-headers',
configurePreviewServer(server) {
server.middlewares.use((request, response, next) => {
const path = request.url?.split(/[?#]/, 1)[0] ?? '';
if (path.startsWith('/assets/'))
response.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
else if (path.startsWith('/pyodide/'))
response.setHeader(
'Cache-Control',
'public, max-age=86400, stale-while-revalidate=604800',
);
else response.setHeader('Cache-Control', 'no-cache');
next();
});
},
};
}
/** 让开发服务器与生产构建都从本地 npm 包提供 Pyodide,保持平台可离线部署。 */ /** 让开发服务器与生产构建都从本地 npm 包提供 Pyodide,保持平台可离线部署。 */
function localPyodideAssets(): Plugin { function localPyodideAssets(): Plugin {
let isBuild = false; let isBuild = false;
@@ -59,12 +80,16 @@ function localPyodideAssets(): Plugin {
export default defineConfig({ export default defineConfig({
root: 'web_platform', root: 'web_platform',
base: './', base: './',
plugins: [react(), localPyodideAssets()], plugins: [react(), localPyodideAssets(), previewCacheHeaders()],
publicDir: 'public', publicDir: 'public',
build: { outDir: '../web-platform-dist', emptyOutDir: true, target: 'es2022' }, build: {
outDir: '../web-platform-dist',
emptyOutDir: true,
target: 'es2022',
modulePreload: { polyfill: false },
},
worker: { format: 'es' }, worker: { format: 'es' },
server: { open: true, fs: { allow: ['..'] } }, server: { open: true, fs: { allow: ['..'] } },
preview: { headers: { 'Cache-Control': 'no-store' } },
test: { test: {
globals: true, globals: true,
environment: 'jsdom', environment: 'jsdom',