feat(web-platform): release V0.7 地图模块
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
- Three.js primitive、mesh、材质/贴图显示与对象选择
|
||||
- 播放、暂停、单步、重置、0.25×–4× 速度
|
||||
- actuator 滑杆、hinge/slide 关节拖动、动态 body 外力拖拽
|
||||
- 内置平地、坡道、楼梯、可复现随机障碍物及 9 类系统参数化地形,可配置尺寸、摩擦、难度、种子与高度场采样精度
|
||||
- 导入单文件 `.py` 控制器,通过本地 Pyodide 在 `mj_step` 前按仿真时间同步执行
|
||||
- 导入 mjlab 导出的 `policy.onnx`,在浏览器本地执行 Go2-W 平衡/速度策略推理
|
||||
- 从图形界面向本机训练桥接服务发起 mjlab 强化学习训练、查看进度/日志、停止任务并导入训练生成的 ONNX
|
||||
@@ -61,6 +62,41 @@ python3 -m http.server 8080 --directory web-platform-dist
|
||||
- 默认限制:2000 个文件、单文件 128 MiB、总解压大小 512 MiB、ZIP 文件 128 MiB。
|
||||
- 文件夹或 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 控制器是可信的单文件脚本,必须同步定义 `step(ctx, state)`;可选定义 `NAME`、`CONTROL_HZ`(限制为 1–500 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`。
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
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 = `
|
||||
<mujoco model="soak">
|
||||
<option timestep=".005"/>
|
||||
@@ -420,6 +460,284 @@ test('中等规模模型持续步进并可重复加载', async ({ page }) => {
|
||||
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 }) => {
|
||||
await page.goto('/');
|
||||
await page.locator('input[type="file"]').first().setInputFiles(fixture('invalid.xml'));
|
||||
|
||||
+60
-2
@@ -3,12 +3,70 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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:," />
|
||||
<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>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+738
-66
File diff suppressed because it is too large
Load Diff
@@ -23,12 +23,31 @@ describe('工作台反馈组件', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭错误' }));
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('加载态与空态互斥', () => {
|
||||
it('加载态与空态互斥,并展示真实阶段进度', () => {
|
||||
const { rerender } = render(<WorkspaceOverlays loading={false} hasSnapshot={false} />);
|
||||
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.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('展示格式化状态数据', () => {
|
||||
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">
|
||||
仅由 box、cylinder、capsule 组成且不含资产、材质或碰撞过滤的静态 MJCF
|
||||
可以安全转换;遇到无法表达的语义会整体拒绝,不会静默丢失内容。
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={loading || converting || !canConvert}
|
||||
onClick={() => {
|
||||
setConverting(true);
|
||||
void onConvert().finally(() => setConverting(false));
|
||||
}}
|
||||
>
|
||||
{converting ? '正在转换…' : '创建可编辑副本'}
|
||||
</Button>
|
||||
<Button disabled={loading || converting} onClick={onExport}>
|
||||
导出原地图 ZIP
|
||||
</Button>
|
||||
</div>
|
||||
{!canConvert && <p className="text-warning">该地图没有 MJCF 物理层,无法转换。</p>}
|
||||
</div>
|
||||
);
|
||||
const current = session.document;
|
||||
const active = current.objects.find((object) => object.id === selected);
|
||||
const poseLocked = active?.placementMode === 'locked';
|
||||
const updatePosition = (index: number, value: number) => {
|
||||
if (!active || !Number.isFinite(value)) return;
|
||||
const position = [...active.pose.position] as [number, number, number];
|
||||
position[index] = value;
|
||||
session.update(active.id, { pose: { ...active.pose, position } });
|
||||
refresh();
|
||||
};
|
||||
const updateParameter = (key: string, value: number) => {
|
||||
if (!active || !Number.isFinite(value) || value <= 0) return;
|
||||
session.update(active.id, {
|
||||
parameters: { ...active.parameters, [key]: key === 'count' ? Math.round(value) : value },
|
||||
});
|
||||
refresh();
|
||||
};
|
||||
const updateSpawnPosition = (
|
||||
id: string,
|
||||
position: [number, number, number],
|
||||
index: number,
|
||||
value: number,
|
||||
) => {
|
||||
if (!Number.isFinite(value)) return;
|
||||
const next = [...position] as [number, number, number];
|
||||
next[index] = value;
|
||||
session.updateSpawn(id, { position: next });
|
||||
refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-2 rounded border border-border-subtle p-2">
|
||||
<Button
|
||||
variant={transformMode === 'translate' ? 'primary' : 'secondary'}
|
||||
aria-pressed={transformMode === 'translate'}
|
||||
disabled={loading}
|
||||
onClick={() => setTransformMode('translate')}
|
||||
>
|
||||
移动工具 W
|
||||
</Button>
|
||||
<Button
|
||||
variant={transformMode === 'rotate' ? 'primary' : 'secondary'}
|
||||
aria-pressed={transformMode === 'rotate'}
|
||||
disabled={loading}
|
||||
onClick={() => setTransformMode('rotate')}
|
||||
>
|
||||
旋转工具 E
|
||||
</Button>
|
||||
<Button
|
||||
variant={transformMode === 'scale' ? 'primary' : 'secondary'}
|
||||
aria-pressed={transformMode === 'scale'}
|
||||
disabled={loading}
|
||||
onClick={() => setTransformMode('scale')}
|
||||
>
|
||||
缩放工具 S
|
||||
</Button>
|
||||
<label className="col-span-3 flex items-center justify-between text-[11px] text-text-secondary">
|
||||
<span>吸附(移动 0.1 m / 旋转 5°)</span>
|
||||
<input
|
||||
aria-label="地图编辑吸附"
|
||||
type="checkbox"
|
||||
checked={snapping}
|
||||
disabled={loading}
|
||||
onChange={(event) => setSnapping(event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
<p className="col-span-3 text-[10px] text-text-tertiary">
|
||||
在视口点击对象后拖动操纵轴;只允许绕世界 Z 轴旋转,缩放会写入原语尺寸。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
aria-label="新增地图对象类型"
|
||||
className="min-w-0 flex-1"
|
||||
value={selectedType}
|
||||
disabled={loading}
|
||||
onChange={(event) => setSelectedType(event.target.value as EditableMapObjectType)}
|
||||
>
|
||||
{(Object.keys(labels) as EditableMapObjectType[]).map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{labels[type]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
const object = session.add(selectedType);
|
||||
setSelected(object.id);
|
||||
refresh();
|
||||
onSelectPreview(object.id);
|
||||
}}
|
||||
>
|
||||
新增
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-36 space-y-1 overflow-auto" aria-label="地图对象列表">
|
||||
{current.objects.map((object) => (
|
||||
<button
|
||||
key={object.id}
|
||||
type="button"
|
||||
className={`w-full rounded border px-2 py-1 text-left text-xs ${selected === object.id ? 'border-accent bg-accent/10' : 'border-border-subtle'}`}
|
||||
onClick={() => {
|
||||
setSelected(object.id);
|
||||
onSelectPreview(object.id);
|
||||
}}
|
||||
>
|
||||
{object.name} · {labels[object.type]}
|
||||
{object.placementMode === 'locked' ? ' · 已锁定' : ''}
|
||||
</button>
|
||||
))}
|
||||
{!current.objects.length && <div className="text-xs text-text-tertiary">暂无对象</div>}
|
||||
</div>
|
||||
{active && (
|
||||
<div className="space-y-2 rounded border border-border-subtle p-2">
|
||||
<label className="block text-[11px] text-text-secondary">
|
||||
名称
|
||||
<input
|
||||
aria-label="对象名称"
|
||||
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
||||
value={active.name}
|
||||
onChange={(event) => {
|
||||
if (!event.target.value.trim()) return;
|
||||
session.update(active.id, { name: event.target.value });
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-[11px] text-text-secondary">
|
||||
放置方式
|
||||
<Select
|
||||
aria-label="对象放置方式"
|
||||
className="mt-1 w-full"
|
||||
value={active.placementMode}
|
||||
disabled={loading}
|
||||
onChange={(event) => {
|
||||
session.update(active.id, {
|
||||
placementMode: event.target.value as MapObjectPlacementMode,
|
||||
});
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
{(Object.keys(MAP_OBJECT_PLACEMENT_LABELS) as MapObjectPlacementMode[]).map(
|
||||
(mode) => (
|
||||
<option key={mode} value={mode}>
|
||||
{MAP_OBJECT_PLACEMENT_LABELS[mode]}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</Select>
|
||||
<span className="mt-1 block text-[9px] leading-3.5 text-text-tertiary">
|
||||
{active.placementMode === 'auto_ground'
|
||||
? '始终将对象底部对齐 z=0。'
|
||||
: active.placementMode === 'gravity'
|
||||
? '沿世界 -Z 落到 XY 范围内最高的静态承载面。'
|
||||
: '保持当前位姿,并禁用表单和视口变换。'}
|
||||
</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{active.pose.position.map((value, index) => (
|
||||
<label key={index} className="text-[11px] text-text-secondary">
|
||||
{['X', 'Y', 'Z'][index]}
|
||||
<input
|
||||
aria-label={`对象位置${['X', 'Y', 'Z'][index]}`}
|
||||
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={value}
|
||||
disabled={loading || poseLocked || index === 2}
|
||||
onChange={(event) => updatePosition(index, Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label className="block text-[11px] text-text-secondary">
|
||||
绕 Z 旋转(°)
|
||||
<input
|
||||
aria-label="对象绕Z旋转"
|
||||
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
||||
type="number"
|
||||
step="1"
|
||||
value={Number(yawDegrees(active).toFixed(4))}
|
||||
disabled={loading || poseLocked}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
if (!Number.isFinite(value)) return;
|
||||
const half = (value * Math.PI) / 360;
|
||||
session.update(active.id, {
|
||||
pose: { ...active.pose, quaternion: [Math.cos(half), 0, 0, Math.sin(half)] },
|
||||
});
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Object.entries(active.parameters).map(([key, value]) => (
|
||||
<label key={key} className="text-[11px] text-text-secondary">
|
||||
{key}
|
||||
<input
|
||||
aria-label={`对象参数${key}`}
|
||||
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
||||
type="number"
|
||||
min="0.001"
|
||||
step={key === 'count' ? 1 : 0.1}
|
||||
value={value}
|
||||
onChange={(event) => updateParameter(key, Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex items-center justify-between text-[11px] text-text-secondary">
|
||||
<span>启用对象</span>
|
||||
<input
|
||||
aria-label="启用地图对象"
|
||||
type="checkbox"
|
||||
checked={active.enabled}
|
||||
onChange={(event) => {
|
||||
session.update(active.id, { enabled: event.target.checked });
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
disabled={loading || poseLocked}
|
||||
onClick={() => {
|
||||
session.update(active.id, {
|
||||
pose: {
|
||||
...active.pose,
|
||||
position: [
|
||||
active.pose.position[0],
|
||||
active.pose.position[1],
|
||||
editableObjectGroundHeight(active),
|
||||
],
|
||||
},
|
||||
});
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
对齐地面
|
||||
</Button>
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
const copy = session.duplicate(active.id);
|
||||
setSelected(copy.id);
|
||||
refresh();
|
||||
onSelectPreview(copy.id);
|
||||
}}
|
||||
>
|
||||
复制对象
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
session.remove(active.id);
|
||||
setSelected(undefined);
|
||||
refresh();
|
||||
onSelectPreview(null);
|
||||
}}
|
||||
>
|
||||
删除对象
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 rounded border border-border-subtle p-2">
|
||||
<div className="flex items-center justify-between text-xs font-medium text-text-primary">
|
||||
<span>出生点</span>
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
session.addSpawn();
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
新增出生点
|
||||
</Button>
|
||||
</div>
|
||||
{current.spawnPoints.map((spawn) => (
|
||||
<div key={spawn.id} className="space-y-2 rounded bg-panel-muted p-2">
|
||||
<input
|
||||
aria-label={`出生点名称 ${spawn.id}`}
|
||||
className="h-7 w-full rounded border border-border-strong bg-input px-1 text-xs"
|
||||
value={spawn.name}
|
||||
onChange={(event) => {
|
||||
if (!event.target.value.trim()) return;
|
||||
session.updateSpawn(spawn.id, { name: event.target.value });
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{spawn.position.map((value, index) => (
|
||||
<label key={index} className="text-[10px] text-text-secondary">
|
||||
{['X', 'Y', 'Z'][index]}
|
||||
<input
|
||||
aria-label={`出生点${spawn.id}位置${['X', 'Y', 'Z'][index]}`}
|
||||
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={value}
|
||||
onChange={(event) =>
|
||||
updateSpawnPosition(
|
||||
spawn.id,
|
||||
spawn.position,
|
||||
index,
|
||||
Number(event.target.value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<label className="text-[10px] text-text-secondary">
|
||||
Yaw°
|
||||
<input
|
||||
aria-label={`出生点${spawn.id}朝向`}
|
||||
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
||||
type="number"
|
||||
step="1"
|
||||
value={spawn.yawDeg}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
if (!Number.isFinite(value)) return;
|
||||
session.updateSpawn(spawn.id, { yawDeg: value });
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
session.removeSpawn(spawn.id);
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
删除出生点
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{!current.spawnPoints.length && (
|
||||
<div className="text-[11px] text-text-tertiary">暂无出生点</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
disabled={loading || !session.canUndo}
|
||||
onClick={() => {
|
||||
session.undo();
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
撤销
|
||||
</Button>
|
||||
<Button
|
||||
disabled={loading || !session.canRedo}
|
||||
onClick={() => {
|
||||
session.redo();
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
重做
|
||||
</Button>
|
||||
<Button
|
||||
disabled={loading || !session.dirty}
|
||||
onClick={() => {
|
||||
session.discard();
|
||||
setSelected(undefined);
|
||||
refresh();
|
||||
onSelectPreview(null);
|
||||
}}
|
||||
>
|
||||
放弃草稿
|
||||
</Button>
|
||||
<Button disabled={loading} onClick={onExport}>
|
||||
导出地图 ZIP
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={loading || !session.dirty}
|
||||
onClick={async () => {
|
||||
const latest = session.document;
|
||||
const candidate = { ...latest, revision: latest.revision + 1 };
|
||||
if (await onApply(candidate)) onPreview(null);
|
||||
else onPreview(session.document);
|
||||
}}
|
||||
>
|
||||
应用并重新编译
|
||||
</Button>
|
||||
{session.dirty && (
|
||||
<div role="status" className="text-xs text-warning">
|
||||
地图草稿尚未应用
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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('地形难度(0–1)'), { target: { value: '0.8' } });
|
||||
fireEvent.change(screen.getByLabelText('随机种子'), { target: { value: '9' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
|
||||
expect(onApply).toHaveBeenCalledWith({
|
||||
kind: 'builtin',
|
||||
config: expect.objectContaining({
|
||||
preset: 'rough',
|
||||
terrainDifficulty: 0.8,
|
||||
seed: 9,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('可以移动并旋转资产地图', () => {
|
||||
const onApply = vi.fn();
|
||||
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={onApply} />);
|
||||
fireEvent.change(screen.getByLabelText('地图来源'), { target: { value: 'builtin' } });
|
||||
fireEvent.change(screen.getByLabelText('位置 X(m)'), { target: { value: '2.5' } });
|
||||
fireEvent.change(screen.getByLabelText('位置 Y(m)'), { target: { value: '-1.5' } });
|
||||
fireEvent.change(screen.getByLabelText('旋转 Z(°)'), { target: { value: '45' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
|
||||
expect(onApply).toHaveBeenCalledWith({
|
||||
kind: 'builtin',
|
||||
config: expect.objectContaining({ positionX: 2.5, positionY: -1.5, yawDeg: 45 }),
|
||||
});
|
||||
});
|
||||
|
||||
it('显示工程地图、出生点和根 Body', () => {
|
||||
const onApply = vi.fn();
|
||||
render(
|
||||
<PhysicalMapPanel
|
||||
{...common}
|
||||
value={{ kind: 'none' }}
|
||||
maps={[
|
||||
{
|
||||
descriptorPath: 'maps/warehouse/map.json',
|
||||
schemaVersion: 1 as const,
|
||||
id: 'warehouse',
|
||||
name: '仓库',
|
||||
spawnPoints: [{ id: 'door', name: '入口' }],
|
||||
},
|
||||
]}
|
||||
rootBodies={['robot', 'payload']}
|
||||
onApply={onApply}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText('地图来源'), {
|
||||
target: { value: 'project:maps/warehouse/map.json' },
|
||||
});
|
||||
expect(screen.getByLabelText('地图出生点')).toHaveValue('door');
|
||||
fireEvent.change(screen.getByLabelText('机器人根 Body'), { target: { value: 'robot' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
|
||||
expect(onApply).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'project',
|
||||
descriptorPath: 'maps/warehouse/map.json',
|
||||
spawnPointId: 'door',
|
||||
robotRootBody: 'robot',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('原生 URDF 模式只允许移除地图', () => {
|
||||
render(
|
||||
<PhysicalMapPanel
|
||||
{...common}
|
||||
value={{ kind: 'builtin', config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'flat' } }}
|
||||
nativeUrdf
|
||||
onApply={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('status')).toHaveTextContent('原生 URDF');
|
||||
expect(screen.getByRole('button', { name: '应用并重新编译' })).toBeDisabled();
|
||||
expect(screen.getByLabelText('地图来源')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -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="位置 X(m)"
|
||||
value={draft.config.positionX}
|
||||
min={-100}
|
||||
max={100}
|
||||
step={0.1}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('positionX', next)}
|
||||
/>
|
||||
<NumberField
|
||||
label="位置 Y(m)"
|
||||
value={draft.config.positionY}
|
||||
min={-100}
|
||||
max={100}
|
||||
step={0.1}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('positionY', next)}
|
||||
/>
|
||||
<NumberField
|
||||
label="旋转 Z(°)"
|
||||
value={draft.config.yawDeg}
|
||||
min={-180}
|
||||
max={180}
|
||||
step={1}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('yawDeg', next)}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] leading-relaxed text-text-tertiary">
|
||||
调整资产地图在世界坐标中的位置和朝向,应用后同步更新物理碰撞。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<NumberField
|
||||
label={isSystemTerrainPreset(draft.config.preset) ? '地形边长(m)' : '地图半径(m)'}
|
||||
value={draft.config.size}
|
||||
min={4}
|
||||
max={isSystemTerrainPreset(draft.config.preset) ? 30 : 100}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('size', next)}
|
||||
/>
|
||||
<NumberField
|
||||
label="摩擦系数"
|
||||
value={draft.config.friction}
|
||||
min={0.05}
|
||||
max={5}
|
||||
step={0.05}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('friction', next)}
|
||||
/>
|
||||
{draft.config.preset === 'slope' && (
|
||||
<NumberField
|
||||
label="坡度(°)"
|
||||
value={draft.config.slopeAngle}
|
||||
min={5}
|
||||
max={30}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('slopeAngle', next)}
|
||||
/>
|
||||
)}
|
||||
{draft.config.preset === 'stairs' && (
|
||||
<NumberField
|
||||
label="台阶数量"
|
||||
value={draft.config.stairCount}
|
||||
min={2}
|
||||
max={20}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('stairCount', next)}
|
||||
/>
|
||||
)}
|
||||
{draft.config.preset === 'obstacles' && (
|
||||
<NumberField
|
||||
label="障碍物数量"
|
||||
value={draft.config.obstacleCount}
|
||||
min={1}
|
||||
max={30}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('obstacleCount', next)}
|
||||
/>
|
||||
)}
|
||||
{(draft.config.preset === 'obstacles' ||
|
||||
isSystemTerrainPreset(draft.config.preset)) && (
|
||||
<NumberField
|
||||
label="随机种子"
|
||||
value={draft.config.seed}
|
||||
min={0}
|
||||
max={2_147_483_647}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('seed', next)}
|
||||
/>
|
||||
)}
|
||||
{isSystemTerrainPreset(draft.config.preset) && (
|
||||
<NumberField
|
||||
label="地形难度(0–1)"
|
||||
value={draft.config.terrainDifficulty}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('terrainDifficulty', next)}
|
||||
/>
|
||||
)}
|
||||
{(draft.config.preset === 'rough' || draft.config.preset === 'wave') && (
|
||||
<>
|
||||
<NumberField
|
||||
label="水平采样间距(m)"
|
||||
value={draft.config.terrainHorizontalScale}
|
||||
min={0.03}
|
||||
max={1}
|
||||
step={0.01}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('terrainHorizontalScale', next)}
|
||||
/>
|
||||
<NumberField
|
||||
label="垂直量化(m)"
|
||||
value={draft.config.terrainVerticalScale}
|
||||
min={0.001}
|
||||
max={0.1}
|
||||
step={0.001}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('terrainVerticalScale', next)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{draft.kind === 'project' && projectMap && (
|
||||
<div className="space-y-3 rounded border border-border-subtle bg-panel-muted p-3">
|
||||
<div className="text-xs font-medium text-text-primary">{projectMap.name}</div>
|
||||
<div className="break-all text-[11px] text-text-tertiary">
|
||||
{projectMap.descriptorPath}
|
||||
</div>
|
||||
{projectMap.spawnPoints.length > 0 && (
|
||||
<label className="block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">出生点</span>
|
||||
<Select
|
||||
aria-label="地图出生点"
|
||||
className="w-full"
|
||||
value={draft.spawnPointId ?? ''}
|
||||
disabled={loading}
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, spawnPointId: event.target.value || undefined })
|
||||
}
|
||||
>
|
||||
<option value="">不改变机器人位置</option>
|
||||
{projectMap.spawnPoints.map((spawn) => (
|
||||
<option key={spawn.id} value={spawn.id}>
|
||||
{spawn.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
)}
|
||||
{rootBodies.length > 1 && (
|
||||
<label className="block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">机器人根 Body</span>
|
||||
<Select
|
||||
aria-label="机器人根 Body"
|
||||
className="w-full"
|
||||
value={draft.robotRootBody ?? ''}
|
||||
disabled={loading}
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, robotRootBody: event.target.value || undefined })
|
||||
}
|
||||
>
|
||||
<option value="">自动识别</option>
|
||||
{rootBodies.map((body) => (
|
||||
<option key={body} value={body}>
|
||||
{body}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
)}
|
||||
<label className="block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">覆盖摩擦系数(留空则使用地图值)</span>
|
||||
<input
|
||||
aria-label="地图摩擦系数覆盖"
|
||||
type="number"
|
||||
min={0.05}
|
||||
max={5}
|
||||
step={0.05}
|
||||
value={draft.frictionOverride ?? ''}
|
||||
disabled={loading}
|
||||
className="h-8 w-full rounded border border-border-strong bg-input px-2 text-xs text-text-primary"
|
||||
onChange={(event) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
frictionOverride: event.target.value ? Number(event.target.value) : undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{draft.kind === 'project' &&
|
||||
draft.descriptorPath === (value.kind === 'project' ? value.descriptorPath : '') && (
|
||||
<div className="space-y-2 rounded border border-border-subtle p-3">
|
||||
<div className="text-xs font-medium text-text-primary">V3 地图编辑器</div>
|
||||
<MapEditorPanel
|
||||
document={editorDocument}
|
||||
loading={loading}
|
||||
onPreview={onEditorPreview}
|
||||
onApply={onEditorApply}
|
||||
onExport={onEditorExport}
|
||||
onConvert={onEditorConvert}
|
||||
canConvert={Boolean(projectMap?.physicsPath)}
|
||||
onBindInteraction={onEditorBindInteraction}
|
||||
onSelectPreview={onEditorSelect}
|
||||
onTransformMode={onEditorTransformMode}
|
||||
onSnapping={onEditorSnapping}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 rounded border border-border-subtle p-3 text-xs text-text-secondary">
|
||||
<label className="flex items-center justify-between gap-3">
|
||||
<span>显示视觉地图</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showVisualMap}
|
||||
onChange={(event) => onMapDisplay(event.target.checked, showMapCollision)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between gap-3">
|
||||
<span>显示地图碰撞层</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showMapCollision}
|
||||
onChange={(event) => onMapDisplay(showVisualMap, event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{nativeUrdf && normalized.kind !== 'none' && (
|
||||
<p
|
||||
role="status"
|
||||
className="rounded border border-warning/30 bg-warning/10 p-2 text-xs text-warning"
|
||||
>
|
||||
原生 URDF 不能注入地图。请在“属性”中切换为“转换为 MJCF”。
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
disabled={compileDisabled || !changed}
|
||||
onClick={() => onApply(normalized)}
|
||||
>
|
||||
应用并重新编译
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Box, FolderTree, Info, Settings2, SlidersHorizontal } from 'lucide-react';
|
||||
import type { ModelEntry } from '../../project/types';
|
||||
import { Box, FolderTree, Info, Map as MapIcon, Settings2, SlidersHorizontal } from 'lucide-react';
|
||||
import type { MapEntry, ModelEntry } from '../../project/types';
|
||||
import {
|
||||
countProjectSearchResults,
|
||||
ProjectTree,
|
||||
@@ -34,6 +34,20 @@ import { ProjectBreadcrumb } from './ProjectBreadcrumb';
|
||||
import { PythonControllerPanel } from './PythonControllerPanel';
|
||||
import { RLPolicyPanel } from './RLPolicyPanel';
|
||||
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({
|
||||
title,
|
||||
@@ -51,8 +65,10 @@ export function SidebarPanel({
|
||||
<aside
|
||||
className={`flex h-full w-full min-w-0 flex-col overflow-hidden bg-panel ${side === 'left' ? 'border-r' : 'border-l'} border-border`}
|
||||
>
|
||||
<h2 className="flex h-10 shrink-0 items-center gap-2 border-b border-border bg-panel px-3 text-sm font-semibold text-text-primary">
|
||||
<Settings2 aria-hidden="true" className="h-4 w-4 text-accent" />
|
||||
<h2 className="flex h-10 shrink-0 items-center gap-2 border-b border-border bg-panel/95 px-3 text-sm font-semibold tracking-tight text-text-primary">
|
||||
<span className="grid h-6 w-6 place-items-center rounded-md bg-accent-soft text-accent">
|
||||
<Settings2 aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
{title}
|
||||
</h2>
|
||||
{children}
|
||||
@@ -69,9 +85,17 @@ export function ProjectSidebar({
|
||||
snapshot,
|
||||
loading,
|
||||
visible = true,
|
||||
nativeUrdf,
|
||||
mapSelection,
|
||||
editorDocument,
|
||||
activeTab,
|
||||
onActiveTabChange,
|
||||
onRemove,
|
||||
onSelectEntry,
|
||||
onJointHover,
|
||||
onAddMapAsset,
|
||||
onSelectTerrain,
|
||||
onSelectMapObject,
|
||||
}: {
|
||||
projectName?: string;
|
||||
files: ProjectTreeFile[];
|
||||
@@ -80,19 +104,31 @@ export function ProjectSidebar({
|
||||
snapshot?: SimulationSnapshot;
|
||||
loading: boolean;
|
||||
visible?: boolean;
|
||||
nativeUrdf: boolean;
|
||||
mapSelection: MapSelection;
|
||||
editorDocument: EditableMapDocument | null;
|
||||
activeTab?: 'project' | 'structure' | 'assets';
|
||||
onActiveTabChange?: (value: 'project' | 'structure' | 'assets') => void;
|
||||
onRemove: () => void;
|
||||
onSelectEntry: (path: string) => void;
|
||||
onJointHover: (jointId: number | null) => void;
|
||||
onAddMapAsset: (
|
||||
type: EditableMapObjectType,
|
||||
placementMode: MapObjectPlacementMode,
|
||||
) => void | Promise<void>;
|
||||
onSelectTerrain: (preset: SystemTerrainPreset) => void;
|
||||
onSelectMapObject: (id: string) => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<'project' | 'structure'>('project'),
|
||||
const [internalTab, setInternalTab] = useState<'project' | 'structure' | 'assets'>('project'),
|
||||
[fileQuery, setFileQuery] = useState(''),
|
||||
[structureQuery, setStructureQuery] = useState('');
|
||||
const fileMatches = countProjectSearchResults(files, fileQuery),
|
||||
const tab = activeTab ?? internalTab,
|
||||
fileMatches = countProjectSearchResults(files, fileQuery),
|
||||
structureMatches = snapshot
|
||||
? countModelStructureSearchResults(snapshot.bodies, snapshot.joints, structureQuery)
|
||||
: 0;
|
||||
return (
|
||||
<SidebarPanel title="工程资源" side="left" visible={visible}>
|
||||
<SidebarPanel title="资产与结构" side="left" visible={visible}>
|
||||
{projectName ? (
|
||||
<>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2.5">
|
||||
@@ -116,7 +152,10 @@ export function ProjectSidebar({
|
||||
<Tabs
|
||||
label="工程侧栏"
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
onValueChange={(value) => {
|
||||
setInternalTab(value);
|
||||
onActiveTabChange?.(value);
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
value: 'project',
|
||||
@@ -170,6 +209,26 @@ export function ProjectSidebar({
|
||||
<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[];
|
||||
selectedPolicyPath?: string;
|
||||
policyStatus?: RLPolicyStatus;
|
||||
mapSelection: MapSelection;
|
||||
maps: MapEntry[];
|
||||
showVisualMap: boolean;
|
||||
showMapCollision: boolean;
|
||||
editorDocument: EditableMapDocument | null;
|
||||
onUrdfMode: (value: UrdfLoadMode) => void;
|
||||
onBaseMode: (value: UrdfBaseMode) => void;
|
||||
onShowCollision: (value: boolean) => void;
|
||||
@@ -222,13 +286,24 @@ interface ModelControlsProps {
|
||||
onTogglePolicy: (enabled: boolean) => void;
|
||||
onPolicyCommand: (command: RLCommand) => void;
|
||||
onRemovePolicy: () => void;
|
||||
onApplyMap: (value: MapSelection) => void;
|
||||
onEditorPreview: (document: EditableMapDocument | null) => void;
|
||||
onEditorApply: (document: EditableMapDocument) => Promise<boolean>;
|
||||
onEditorExport: () => void;
|
||||
onEditorConvert: () => Promise<boolean>;
|
||||
onEditorBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
|
||||
onEditorSelect: (id: string | null) => void;
|
||||
onEditorTransformMode: (mode: MapEditorTransformMode) => void;
|
||||
onEditorSnapping: (translation: number | null, rotationDegrees: number | null) => void;
|
||||
onMapDisplay: (visual: boolean, collision: boolean) => void;
|
||||
onMapTabOpen?: () => void;
|
||||
}
|
||||
export function ModelControlsSidebar(props: ModelControlsProps) {
|
||||
const [tab, setTab] = useState<'properties' | 'controls'>('properties'),
|
||||
const [tab, setTab] = useState<'properties' | 'controls' | 'map'>('properties'),
|
||||
s = props.snapshot;
|
||||
if (!s)
|
||||
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>
|
||||
</SidebarPanel>
|
||||
);
|
||||
@@ -439,11 +514,14 @@ export function ModelControlsSidebar(props: ModelControlsProps) {
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<SidebarPanel title="模型与控制" side="right" visible={props.visible}>
|
||||
<SidebarPanel title="属性与参数" side="right" visible={props.visible}>
|
||||
<Tabs
|
||||
label="模型控制侧栏"
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
onValueChange={(value) => {
|
||||
setTab(value);
|
||||
if (value === 'map') props.onMapTabOpen?.();
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
value: 'properties',
|
||||
@@ -457,6 +535,41 @@ export function ModelControlsSidebar(props: ModelControlsProps) {
|
||||
icon: <SlidersHorizontal className="h-3.5 w-3.5" />,
|
||||
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>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Box, Clock3, MemoryStick, TriangleAlert } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { Kbd } from '../../components/ui';
|
||||
import { useAppStore } from '../../stores/useAppStore';
|
||||
import { PerformancePopover } from './PerformancePopover';
|
||||
export interface StatusBarProps {
|
||||
time?: number;
|
||||
@@ -49,3 +51,18 @@ export function StatusBar({ time, fps, stepMs, memoryMb, loaded, overBudget }: S
|
||||
</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(),
|
||||
step = vi.fn(),
|
||||
reset = vi.fn(),
|
||||
speed = vi.fn();
|
||||
speed = vi.fn(),
|
||||
openSource = vi.fn();
|
||||
render(
|
||||
<WorkbenchHeader
|
||||
paused
|
||||
@@ -14,12 +15,14 @@ describe('WorkbenchHeader', () => {
|
||||
speed={1}
|
||||
theme="dark"
|
||||
loading={false}
|
||||
hasProject
|
||||
leftOpen
|
||||
rightOpen
|
||||
fullscreen={false}
|
||||
center={<span>工具</span>}
|
||||
onFiles={fn}
|
||||
onFolder={fn}
|
||||
onOpenSource={openSource}
|
||||
onTogglePause={pause}
|
||||
onStep={step}
|
||||
onReset={reset}
|
||||
@@ -32,10 +35,14 @@ describe('WorkbenchHeader', () => {
|
||||
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.change(screen.getByLabelText('仿真速度'), { target: { value: '2' } });
|
||||
expect(openSource).toHaveBeenCalledTimes(1);
|
||||
expect(pause).toHaveBeenCalledTimes(1);
|
||||
expect(step).toHaveBeenCalledTimes(1);
|
||||
expect(reset).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ChangeEvent, ReactNode } from 'react';
|
||||
import {
|
||||
Boxes,
|
||||
CircleHelp,
|
||||
Code2,
|
||||
Expand,
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Button, IconButton, Select } from '../../components/ui';
|
||||
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';
|
||||
export function WorkbenchHeader({
|
||||
paused,
|
||||
@@ -74,11 +75,16 @@ export function WorkbenchHeader({
|
||||
onToggleFullscreen: () => void;
|
||||
}) {
|
||||
return (
|
||||
<header className="relative z-40 grid h-10 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(max-content,1fr)] items-center gap-2 border-b border-border bg-panel 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">
|
||||
<h1 className="mr-2 hidden truncate border-r border-border pr-3 text-sm font-semibold text-text-primary xl:block">
|
||||
MuJoCo Web 仿真平台
|
||||
</h1>
|
||||
<div className="mr-2 hidden items-center gap-2 border-r border-border pr-3 xl:flex">
|
||||
<span className="grid h-7 w-7 place-items-center rounded-lg border border-accent/25 bg-accent-soft text-accent shadow-sm">
|
||||
<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
|
||||
aria-disabled={loading}
|
||||
className={`${fileActionClass} ${loading ? 'pointer-events-none opacity-40' : ''}`}
|
||||
@@ -113,14 +119,16 @@ export function WorkbenchHeader({
|
||||
onChange={onFolder}
|
||||
/>
|
||||
</label>
|
||||
<IconButton
|
||||
tooltip="查看和修改缓存源代码"
|
||||
aria-label="源代码"
|
||||
<button
|
||||
type="button"
|
||||
className={`${fileActionClass} disabled:cursor-not-allowed disabled:opacity-40`}
|
||||
title="查看和修改缓存源代码"
|
||||
disabled={!hasProject || loading}
|
||||
onClick={onOpenSource}
|
||||
>
|
||||
<Code2 className="h-4 w-4" />
|
||||
</IconButton>
|
||||
<Code2 className="h-3.5 w-3.5" />
|
||||
<span className={fileActionLabelClass}>源代码</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">{center}</div>
|
||||
<div className="flex min-w-0 items-center justify-end gap-0.5">
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
import {
|
||||
Box,
|
||||
CheckCircle2,
|
||||
FileArchive,
|
||||
FolderOpen,
|
||||
LoaderCircle,
|
||||
PlayCircle,
|
||||
Settings2,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Upload,
|
||||
UploadCloud,
|
||||
} from 'lucide-react';
|
||||
import { ProgressBar, Skeleton } from '../../components/ui';
|
||||
|
||||
export interface ImportProgress {
|
||||
title?: string;
|
||||
label: string;
|
||||
detail?: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const workflow = [
|
||||
{ label: '导入', detail: 'URDF、MJCF 或工程包', icon: UploadCloud },
|
||||
{ label: '检查与配置', detail: '结构、驱动器与传感器', icon: Settings2 },
|
||||
{ label: '运行与调试', detail: '控制、策略与物理状态', icon: PlayCircle },
|
||||
];
|
||||
|
||||
export function EmptyWorkspace({ compact = false }: { compact?: boolean }) {
|
||||
if (compact)
|
||||
return (
|
||||
@@ -29,65 +37,131 @@ export function EmptyWorkspace({ compact = false }: { compact?: boolean }) {
|
||||
<p className="mt-1 text-xs text-text-tertiary">支持 MJCF/XML、URDF、文件夹和 ZIP</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<section
|
||||
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">
|
||||
<UploadCloud className="h-5 w-5" />
|
||||
</span>
|
||||
<h2 className="text-base font-semibold text-text-primary">拖放模型工程到此处</h2>
|
||||
<p className="mt-1 text-xs text-text-tertiary">支持 MJCF/XML、URDF、文件夹和 ZIP</p>
|
||||
<div className="mt-4 flex items-center justify-center gap-2">
|
||||
<label
|
||||
htmlFor="mujoco-project-files"
|
||||
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"
|
||||
<div aria-hidden="true" className="welcome-glow" />
|
||||
<div className="relative">
|
||||
<div className="mb-4 flex items-center justify-center gap-2 text-[10px] font-semibold uppercase tracking-[0.18em] text-accent">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Local Simulation Workspace
|
||||
</div>
|
||||
<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)]">
|
||||
<UploadCloud className="h-6 w-6" />
|
||||
</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" />
|
||||
选择文件
|
||||
</label>
|
||||
<label
|
||||
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"
|
||||
>
|
||||
<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}
|
||||
{['MJCF / XML', 'URDF', 'ZIP', 'OBJ / STL / DAE'].map((format) => (
|
||||
<span
|
||||
key={format}
|
||||
className="rounded-full border border-border bg-surface/80 px-2.5 py-1 text-[10px] font-medium text-text-secondary"
|
||||
>
|
||||
{format}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="mt-3 flex items-center justify-center gap-1.5 text-[10px] text-text-tertiary">
|
||||
<ShieldCheck aria-hidden="true" className="h-3 w-3 text-success" />
|
||||
模型与资源仅在当前浏览器会话中处理
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-5 flex items-center justify-center gap-2">
|
||||
<label
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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({
|
||||
loading,
|
||||
hasSnapshot,
|
||||
dragActive = false,
|
||||
progress,
|
||||
}: {
|
||||
loading: boolean;
|
||||
hasSnapshot: boolean;
|
||||
dragActive?: boolean;
|
||||
progress?: ImportProgress;
|
||||
}) {
|
||||
return (
|
||||
@@ -104,23 +178,26 @@ export function WorkspaceOverlays({
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
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">
|
||||
<div className="flex items-center gap-3">
|
||||
<LoaderCircle aria-hidden="true" className="h-5 w-5 animate-spin text-accent" />
|
||||
正在加载 MuJoCo 与模型…
|
||||
</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>
|
||||
)}
|
||||
<LoadingCard progress={progress} compact={hasSnapshot} />
|
||||
</div>
|
||||
)}
|
||||
{dragActive && !loading && (
|
||||
<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]">
|
||||
<div className="rounded-2xl border border-accent/30 bg-panel/95 px-7 py-5 text-center shadow-2xl">
|
||||
<span className="mx-auto mb-3 grid h-11 w-11 place-items-center rounded-xl bg-accent-soft text-accent">
|
||||
<FileArchive className="h-5 w-5" />
|
||||
</span>
|
||||
<p className="text-sm font-semibold text-text-primary">松开即可导入工程</p>
|
||||
<p className="mt-1 flex items-center justify-center gap-1.5 text-[11px] text-text-tertiary">
|
||||
<CheckCircle2 className="h-3 w-3 text-success" />
|
||||
文件、文件夹与 ZIP 都可以直接解析
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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('资源不存在');
|
||||
});
|
||||
});
|
||||
@@ -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 视觉层' : ''})`,
|
||||
};
|
||||
}
|
||||
@@ -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 重复');
|
||||
});
|
||||
});
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>');
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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('/')}` || './';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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' }],
|
||||
maps: [],
|
||||
selectedEntry: 'model.xml',
|
||||
totalBytes: xml.byteLength + png.byteLength,
|
||||
};
|
||||
|
||||
@@ -35,15 +35,39 @@ describe('project importer', () => {
|
||||
expect(entries).toHaveLength(3);
|
||||
expect(choosePreferredEntry(entries)).toBe('model.xml');
|
||||
});
|
||||
it('解压 ZIP 并保留二进制数据', async () => {
|
||||
it('异步解压 ZIP、保留二进制数据并报告阶段进度', async () => {
|
||||
const zipped = zipSync({
|
||||
'robot/model.urdf': encode('<robot name="r"/>'),
|
||||
'robot/mesh.obj': encode('v 0 0 0'),
|
||||
});
|
||||
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.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 () => {
|
||||
const zipped = zipSync({ '../model.xml': encode('<mujoco/>') });
|
||||
@@ -56,8 +80,9 @@ describe('project importer', () => {
|
||||
const b = new File(['<mujoco/>'], 'model.xml');
|
||||
await expect(importBrowserFiles([a, b])).rejects.toThrow('同名路径');
|
||||
});
|
||||
it('拒绝超过限制的文件', async () => {
|
||||
const file = new File(['<mujoco/>'], 'model.xml');
|
||||
it('读取文件内容前根据元数据拒绝超过限制的输入', async () => {
|
||||
const file = new File(['<mujoco/>'], 'model.xml'),
|
||||
read = vi.spyOn(file, 'arrayBuffer');
|
||||
await expect(
|
||||
importBrowserFiles([file], {
|
||||
maxFiles: 1,
|
||||
@@ -66,8 +91,20 @@ describe('project importer', () => {
|
||||
maxZipBytes: 2,
|
||||
}),
|
||||
).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(
|
||||
'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>',
|
||||
@@ -84,10 +121,11 @@ describe('project importer', () => {
|
||||
name: 'go2w',
|
||||
files: [urdf, mesh],
|
||||
entries: [{ path: urdf.path, format: 'urdf' as const, label: 'robot' }],
|
||||
maps: [],
|
||||
selectedEntry: urdf.path,
|
||||
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);
|
||||
expect(text.match(/<material/g) ?? []).toHaveLength(1);
|
||||
expect(text).toContain('filename="../meshes/base.obj"');
|
||||
@@ -95,7 +133,7 @@ describe('project importer', () => {
|
||||
expect(text).toContain('fusestatic="false"');
|
||||
expect(prepared.warnings).toHaveLength(2);
|
||||
});
|
||||
it('将 DAE mesh 转换为 MuJoCo 可读取的 OBJ,并复用于 visual/collision', () => {
|
||||
it('将 DAE mesh 转换为 MuJoCo 可读取的 OBJ,并复用于 visual/collision', async () => {
|
||||
const urdf = projectFile(
|
||||
'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>',
|
||||
@@ -106,10 +144,11 @@ describe('project importer', () => {
|
||||
name: 'dae',
|
||||
files: [urdf, dae],
|
||||
entries: [{ path: urdf.path, format: 'urdf' as const, label: 'robot' }],
|
||||
maps: [],
|
||||
selectedEntry: urdf.path,
|
||||
totalBytes: urdf.size + dae.size,
|
||||
};
|
||||
const prepared = prepareProjectForMujoco(manifest, urdf.path);
|
||||
const prepared = await prepareProjectForMujoco(manifest, urdf.path);
|
||||
const text = new TextDecoder().decode(
|
||||
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(prepared.warnings.join(' ')).toContain('1 个 DAE 文件转换为 OBJ');
|
||||
});
|
||||
it('DAE 缺失或转换失败时安全降级', () => {
|
||||
it('DAE 缺失或转换失败时安全降级', async () => {
|
||||
const urdf = projectFile(
|
||||
'robot.urdf',
|
||||
'<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',
|
||||
files: [urdf],
|
||||
entries: [{ path: urdf.path, format: 'urdf' as const, label: 'robot' }],
|
||||
maps: [],
|
||||
selectedEntry: urdf.path,
|
||||
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);
|
||||
expect(text).not.toContain('<visual>');
|
||||
expect(text).toContain('<collision>');
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { unzipSync } from 'fflate';
|
||||
import {
|
||||
DEFAULT_IMPORT_LIMITS,
|
||||
type ImportLimits,
|
||||
@@ -6,10 +5,19 @@ import {
|
||||
type ProjectFile,
|
||||
type ProjectManifest,
|
||||
} from './types';
|
||||
import { convertDaeToObj } from './daeConverter';
|
||||
import { discoverMapEntries } from '../map/MapLoader';
|
||||
|
||||
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 {
|
||||
constructor(
|
||||
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. */
|
||||
export function prepareProjectForMujoco(
|
||||
export async function prepareProjectForMujoco(
|
||||
manifest: ProjectManifest,
|
||||
entryPath: string,
|
||||
): PreparedProject {
|
||||
): Promise<PreparedProject> {
|
||||
const entry = manifest.entries.find((candidate) => candidate.path === entryPath);
|
||||
if (entry?.format !== 'urdf') return { manifest, warnings: [] };
|
||||
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 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 removedDaeVisuals = 0;
|
||||
let daeCollisionFallbacks = 0;
|
||||
for (const mesh of Array.from(document.querySelectorAll('mesh[filename]'))) {
|
||||
for (const mesh of daeMeshes) {
|
||||
const filename = mesh.getAttribute('filename');
|
||||
if (!filename?.toLowerCase().split(/[?#]/)[0].endsWith('.dae')) continue;
|
||||
const daePath = resolveProjectReference(entryPath, filename);
|
||||
const daeFile = daePath ? manifest.files.find((file) => file.path === daePath) : undefined;
|
||||
try {
|
||||
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);
|
||||
if (!objFile) {
|
||||
const data = convertDaeToObj(daeFile.data, daePath);
|
||||
@@ -264,7 +287,17 @@ export function choosePreferredEntry(entries: ModelEntry[]): string | undefined
|
||||
}
|
||||
|
||||
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)
|
||||
throw new ProjectImportError('未发现包含 <mujoco> 或 <robot> 根元素的 XML/URDF 入口');
|
||||
return {
|
||||
@@ -272,6 +305,7 @@ function manifest(name: string, files: ProjectFile[]): ProjectManifest {
|
||||
name,
|
||||
files,
|
||||
entries,
|
||||
maps,
|
||||
selectedEntry: choosePreferredEntry(entries),
|
||||
totalBytes: files.reduce((n, f) => n + f.size, 0),
|
||||
};
|
||||
@@ -280,37 +314,53 @@ function manifest(name: string, files: ProjectFile[]): ProjectManifest {
|
||||
export async function importBrowserFiles(
|
||||
input: File[],
|
||||
limits: ImportLimits = DEFAULT_IMPORT_LIMITS,
|
||||
onProgress?: ProjectImportProgressCallback,
|
||||
): Promise<ProjectManifest> {
|
||||
if (!input.length) throw new ProjectImportError('未选择文件');
|
||||
if (input.length === 1 && /\.zip$/i.test(input[0].name)) {
|
||||
if (input[0].size > 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());
|
||||
onProgress?.({ phase: 'reading', completed: 1, total: 1, path: input[0].name });
|
||||
checkEncryptedZip(bytes);
|
||||
let unpacked: Record<string, Uint8Array>;
|
||||
try {
|
||||
let fileCount = 0;
|
||||
let expandedBytes = 0;
|
||||
unpacked = unzipSync(bytes, {
|
||||
filter: (entry) => {
|
||||
if (entry.name.endsWith('/')) return false;
|
||||
normalizeProjectPath(entry.name);
|
||||
fileCount += 1;
|
||||
expandedBytes += entry.originalSize;
|
||||
if (fileCount > limits.maxFiles)
|
||||
throw new ProjectImportError(`文件数量超过限制(${limits.maxFiles})`);
|
||||
if (entry.originalSize > limits.maxFileBytes)
|
||||
throw new ProjectImportError(
|
||||
`单文件超过限制(${limits.maxFileBytes} 字节)`,
|
||||
entry.name,
|
||||
);
|
||||
if (expandedBytes > limits.maxTotalBytes)
|
||||
throw new ProjectImportError(
|
||||
`ZIP 解压后总大小超过限制(${limits.maxTotalBytes} 字节)`,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
onProgress?.({ phase: 'extracting', completed: 0, total: 1 });
|
||||
const { unzip } = await import('fflate');
|
||||
unpacked = await new Promise<Record<string, Uint8Array>>((resolve, reject) => {
|
||||
try {
|
||||
unzip(
|
||||
bytes,
|
||||
{
|
||||
filter: (entry) => {
|
||||
if (entry.name.endsWith('/')) return false;
|
||||
normalizeProjectPath(entry.name);
|
||||
fileCount += 1;
|
||||
expandedBytes += entry.originalSize;
|
||||
if (fileCount > limits.maxFiles)
|
||||
throw new ProjectImportError(`文件数量超过限制(${limits.maxFiles})`);
|
||||
if (entry.originalSize > limits.maxFileBytes)
|
||||
throw new ProjectImportError(
|
||||
`单文件超过限制(${limits.maxFileBytes} 字节)`,
|
||||
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) {
|
||||
if (error instanceof ProjectImportError) throw error;
|
||||
throw new ProjectImportError(
|
||||
@@ -327,23 +377,53 @@ export async function importBrowserFiles(
|
||||
mimeType: '',
|
||||
}));
|
||||
enforceLimits(files, limits);
|
||||
onProgress?.({ phase: 'indexing', completed: 1, total: 1 });
|
||||
return manifest(input[0].name.replace(/\.zip$/i, ''), files);
|
||||
}
|
||||
const files = await Promise.all(
|
||||
input.map(async (file): Promise<ProjectFile> => {
|
||||
const relative =
|
||||
(file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
|
||||
|
||||
if (input.length > limits.maxFiles)
|
||||
throw new ProjectImportError(`文件数量超过限制(${limits.maxFiles})`);
|
||||
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());
|
||||
return {
|
||||
path: normalizeProjectPath(relative),
|
||||
files[index] = {
|
||||
path,
|
||||
data,
|
||||
size: data.byteLength,
|
||||
source: relative === file.name ? 'file' : 'directory',
|
||||
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);
|
||||
onProgress?.({ phase: 'indexing', completed: 1, total: 1 });
|
||||
return manifest(files[0].path.split('/')[0] || '工程', files);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,23 @@ export interface ModelEntry {
|
||||
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 {
|
||||
id: string;
|
||||
name: string;
|
||||
files: ProjectFile[];
|
||||
entries: ModelEntry[];
|
||||
maps: MapEntry[];
|
||||
selectedEntry?: string;
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ it('按相对路径挂载并逆序清理 MEMFS', () => {
|
||||
id: 'safe',
|
||||
name: 'x',
|
||||
entries: [],
|
||||
maps: [],
|
||||
files: [{ path: 'a/b/model.xml', data, size: 1, source: 'file', mimeType: '' }],
|
||||
totalBytes: 1,
|
||||
};
|
||||
|
||||
@@ -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 { prepareProjectForMujoco } from '../project/importer';
|
||||
import {
|
||||
@@ -16,17 +16,32 @@ import {
|
||||
} from './SimulationSession';
|
||||
import type { ControllerCommand, ControllerStatus } from '../controller/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 { 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 {
|
||||
load(
|
||||
manifest: ProjectManifest,
|
||||
entryPath: string,
|
||||
urdfMode?: UrdfLoadMode,
|
||||
baseMode?: UrdfBaseMode,
|
||||
enhancements?: UrdfEnhancementOptions,
|
||||
options?: PhysicsLoadOptions,
|
||||
): Promise<SimulationSnapshot>;
|
||||
advance(now: number): FrameResult;
|
||||
snapshot(): SimulationSnapshot | null;
|
||||
@@ -50,6 +65,8 @@ export interface PhysicsAdapter {
|
||||
setRLCommand(command: RLCommand): void;
|
||||
removeRLPolicy(): void;
|
||||
cachedSupportFiles(): ProjectFile[];
|
||||
releaseRetired(): void;
|
||||
rollbackRetired(): void;
|
||||
exportMjcf(): Uint8Array;
|
||||
dispose(): void;
|
||||
}
|
||||
@@ -58,10 +75,17 @@ let modulePromise: Promise<MainModule> | undefined;
|
||||
export function getMujocoModule(): Promise<MainModule> {
|
||||
if (!modulePromise) {
|
||||
console.info('[MuJoCo] 开始初始化单线程 WASM');
|
||||
modulePromise = loadMujoco().then((module) => {
|
||||
console.info('[MuJoCo] WASM 初始化完成');
|
||||
return module;
|
||||
});
|
||||
modulePromise = import('@mujoco/mujoco')
|
||||
.then(({ default: loadMujoco }) => loadMujoco())
|
||||
.then((module) => {
|
||||
console.info('[MuJoCo] WASM 初始化完成');
|
||||
return module;
|
||||
})
|
||||
.catch((error) => {
|
||||
// 初始化失败后允许用户重试,而不是永久复用已 rejected 的 Promise。
|
||||
modulePromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return modulePromise;
|
||||
}
|
||||
@@ -70,32 +94,51 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
|
||||
session: SimulationSession | null = null;
|
||||
workspace: MemfsWorkspace | null = null;
|
||||
private supportFiles: ProjectFile[] = [];
|
||||
private loadGeneration = 0;
|
||||
private disposed = false;
|
||||
private retiredSession: SimulationSession | null = null;
|
||||
private retiredWorkspace: MemfsWorkspace | null = null;
|
||||
private retiredSupportFiles: ProjectFile[] = [];
|
||||
|
||||
async load(
|
||||
manifest: ProjectManifest,
|
||||
entryPath: string,
|
||||
urdfMode: UrdfLoadMode = 'mjcf',
|
||||
baseMode: UrdfBaseMode = 'floating',
|
||||
enhancements: UrdfEnhancementOptions = {
|
||||
options: PhysicsLoadOptions = {},
|
||||
): Promise<SimulationSnapshot> {
|
||||
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,
|
||||
addSensors: false,
|
||||
sensorType: 'camera',
|
||||
},
|
||||
): Promise<SimulationSnapshot> {
|
||||
this.releaseCurrent();
|
||||
};
|
||||
const mapSelection = options.map ?? DEFAULT_MAP_SELECTION;
|
||||
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 workspace = new MemfsWorkspace(module, manifest.id);
|
||||
const prepared = prepareProjectForMujoco(manifest, entryPath);
|
||||
this.supportFiles = prepared.manifest.files.filter(
|
||||
if (this.disposed || generation !== this.loadGeneration) throw new Error('模型加载已取消');
|
||||
report(0.32, '校验并准备模型资源');
|
||||
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),
|
||||
);
|
||||
let nextSession: SimulationSession | null = null;
|
||||
try {
|
||||
report(0.46, '写入浏览器内存文件系统');
|
||||
console.info('[MuJoCo] 写入 MEMFS', prepared.manifest.files.length);
|
||||
workspace.mount(prepared.manifest);
|
||||
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];
|
||||
if (entry?.format === 'urdf' && urdfMode === 'mjcf') {
|
||||
report(0.58, '转换并增强 URDF 模型');
|
||||
console.info('[MuJoCo] 编译 URDF 中间模型', entryPath);
|
||||
const intermediate = new SimulationSession(module, modelPath);
|
||||
try {
|
||||
@@ -112,7 +155,8 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
|
||||
);
|
||||
const enhanced = enhanceConvertedMjcf(grounded, enhancements);
|
||||
workspace.writeGenerated(convertedPath, enhanced.data);
|
||||
modelPath = workspace.path(convertedPath);
|
||||
modelRelativePath = convertedPath;
|
||||
modelPath = workspace.path(modelRelativePath);
|
||||
warnings.push(
|
||||
`URDF 已转换为 MJCF(${baseMode === 'floating' ? '浮动基座' : '固定基座'}),并整体平移 ${(-minimumZ).toFixed(4)} m,使最低点接触 z=0 地面`,
|
||||
);
|
||||
@@ -136,20 +180,59 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
|
||||
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);
|
||||
const session = new SimulationSession(module, modelPath, warnings);
|
||||
nextSession = new SimulationSession(module, modelPath, warnings);
|
||||
if (entry?.format === 'urdf' && urdfMode === 'native') {
|
||||
const offset = session.alignLowestPointToGround();
|
||||
const offset = nextSession.alignLowestPointToGround();
|
||||
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] 模型编译完成');
|
||||
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.session = session;
|
||||
const snapshot = session.snapshot();
|
||||
this.supportFiles = supportFiles;
|
||||
nextSession = null;
|
||||
console.info('[MuJoCo] 状态快照完成');
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
nextSession?.dispose();
|
||||
workspace.dispose();
|
||||
throw new Error(
|
||||
`模型编译失败(${entryPath}):${error instanceof Error ? error.message : String(error)}`,
|
||||
@@ -225,6 +308,23 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
|
||||
cachedSupportFiles(): ProjectFile[] {
|
||||
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 {
|
||||
if (!this.session || !this.workspace) throw new Error('尚未加载可导出的模型');
|
||||
const relative = '.__platform_export__.xml';
|
||||
@@ -260,6 +360,7 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
|
||||
return output;
|
||||
}
|
||||
private releaseCurrent(): void {
|
||||
this.releaseRetired();
|
||||
this.session?.dispose();
|
||||
this.session = null;
|
||||
this.workspace?.dispose();
|
||||
@@ -267,6 +368,8 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
|
||||
this.supportFiles = [];
|
||||
}
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.loadGeneration += 1;
|
||||
this.releaseCurrent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { meshIdFromSceneDataId } from './geometry';
|
||||
import { heightfieldGeometryData, meshIdFromSceneDataId } from './geometry';
|
||||
|
||||
describe('meshIdFromSceneDataId', () => {
|
||||
describe('MuJoCo 几何辅助', () => {
|
||||
it('解析 mjvGeom 的完整 mesh/凸包编码', () => {
|
||||
expect(meshIdFromSceneDataId(0)).toBe(0);
|
||||
expect(meshIdFromSceneDataId(1)).toBe(0);
|
||||
@@ -8,4 +8,27 @@ describe('meshIdFromSceneDataId', () => {
|
||||
expect(meshIdFromSceneDataId(15)).toBe(7);
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,3 +5,57 @@
|
||||
export function meshIdFromSceneDataId(dataId: number): number {
|
||||
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
@@ -3,21 +3,21 @@
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--ui-bg: #eef2f7;
|
||||
--ui-bg: #edf2f7;
|
||||
--ui-panel: #fbfcfe;
|
||||
--ui-surface: #f7f9fc;
|
||||
--ui-surface: #f5f8fc;
|
||||
--ui-surface-elevated: #fff;
|
||||
--ui-input: #fff;
|
||||
--ui-hover: #e9eef5;
|
||||
--ui-active: #dfe7f1;
|
||||
--ui-border: #d9e1eb;
|
||||
--ui-border-strong: #b8c4d2;
|
||||
--ui-text-primary: #122033;
|
||||
--ui-text-secondary: #3d4d61;
|
||||
--ui-text-tertiary: #5f6f82;
|
||||
--ui-accent: #16835f;
|
||||
--ui-accent-hover: #116b4d;
|
||||
--ui-accent-soft: #dff4ec;
|
||||
--ui-hover: #e8eef5;
|
||||
--ui-active: #dce6f0;
|
||||
--ui-border: #d6e0ea;
|
||||
--ui-border-strong: #aebdcd;
|
||||
--ui-text-primary: #102033;
|
||||
--ui-text-secondary: #3a4d62;
|
||||
--ui-text-tertiary: #68798c;
|
||||
--ui-accent: #138462;
|
||||
--ui-accent-hover: #0d6d50;
|
||||
--ui-accent-soft: #ddf5ec;
|
||||
--ui-danger: #c53b45;
|
||||
--ui-danger-soft: #fff0f1;
|
||||
--ui-danger-border: #f1b9bd;
|
||||
@@ -32,21 +32,21 @@
|
||||
color-scheme: light;
|
||||
}
|
||||
.theme-dark {
|
||||
--ui-bg: #0e141d;
|
||||
--ui-panel: #171f2b;
|
||||
--ui-surface: #1d2735;
|
||||
--ui-surface-elevated: #263243;
|
||||
--ui-input: #121a25;
|
||||
--ui-hover: #283548;
|
||||
--ui-active: #324258;
|
||||
--ui-border: #2c394b;
|
||||
--ui-border-strong: #43536a;
|
||||
--ui-text-primary: #edf2f7;
|
||||
--ui-text-secondary: #c8d2df;
|
||||
--ui-text-tertiary: #8f9caf;
|
||||
--ui-accent: #35c792;
|
||||
--ui-accent-hover: #2eae80;
|
||||
--ui-accent-soft: #163b33;
|
||||
--ui-bg: #09111e;
|
||||
--ui-panel: #121c2a;
|
||||
--ui-surface: #182536;
|
||||
--ui-surface-elevated: #213148;
|
||||
--ui-input: #0d1725;
|
||||
--ui-hover: #223349;
|
||||
--ui-active: #2b4059;
|
||||
--ui-border: #26374b;
|
||||
--ui-border-strong: #40566f;
|
||||
--ui-text-primary: #f1f5f9;
|
||||
--ui-text-secondary: #c7d3e0;
|
||||
--ui-text-tertiary: #8fa0b5;
|
||||
--ui-accent: #38d39f;
|
||||
--ui-accent-hover: #2db789;
|
||||
--ui-accent-soft: #123b31;
|
||||
--ui-danger: #ff7a83;
|
||||
--ui-danger-soft: #401f26;
|
||||
--ui-danger-border: #71333c;
|
||||
@@ -71,7 +71,16 @@
|
||||
overflow: hidden;
|
||||
background: var(--ui-bg);
|
||||
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,
|
||||
input,
|
||||
@@ -97,6 +106,43 @@
|
||||
.technical-value {
|
||||
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 {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
SimulationSession,
|
||||
SimulationSnapshot,
|
||||
} from '../simulation/SimulationSession';
|
||||
import { meshIdFromSceneDataId } from '../simulation/geometry';
|
||||
import { heightfieldGeometryData, meshIdFromSceneDataId } from '../simulation/geometry';
|
||||
import { OrientationGizmo } from './OrientationGizmo';
|
||||
import {
|
||||
closestRayAxisParameter,
|
||||
@@ -17,6 +17,10 @@ import {
|
||||
import { texturePixelsToRgba } from './texturePixels';
|
||||
import { DEFAULT_VIEWER_DISPLAY_OPTIONS, type ViewerDisplayOptions } from './displayOptions';
|
||||
import { ViewerVisualizationHelpers } from './ViewerVisualizationHelpers';
|
||||
import { VisualMapLayer } from './VisualMapLayer';
|
||||
import type { VisualMapAsset } from '../map/types';
|
||||
import type { EditableMapDocument, MapEditorTransformMode } from '../map/editor/types';
|
||||
import { MapEditorLayer } from './MapEditorLayer';
|
||||
|
||||
export type InteractionMode = 'select' | 'joint' | 'force';
|
||||
export type ViewerTheme = 'light' | 'dark';
|
||||
@@ -31,6 +35,13 @@ interface ViewerCallbacks {
|
||||
onSelection(selection: ViewerSelection | null): void;
|
||||
onFrame(frame: FrameResult, fps: number, snapshot?: SimulationSnapshot): 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 {
|
||||
@@ -56,6 +67,9 @@ export class MuJoCoViewer {
|
||||
forceScale = 50;
|
||||
private displayOptions: ViewerDisplayOptions = { ...DEFAULT_VIEWER_DISPLAY_OPTIONS };
|
||||
private modelHasVisuals = false;
|
||||
private mapHasVisuals = false;
|
||||
private showVisualMap = true;
|
||||
private showMapCollision = false;
|
||||
private showSensorCamera = true;
|
||||
private sensorCameraId = -1;
|
||||
private modelExtent = 2;
|
||||
@@ -67,6 +81,8 @@ export class MuJoCoViewer {
|
||||
private lastFpsAt = performance.now();
|
||||
private fpsFrames = 0;
|
||||
private lastSnapshotAt = 0;
|
||||
private snapshotDirty = false;
|
||||
private runtimeErrorReported = false;
|
||||
private meshes: THREE.Mesh[] = [];
|
||||
private geometries = new Map<string, THREE.BufferGeometry>();
|
||||
private textures = new Map<number, THREE.DataTexture>();
|
||||
@@ -94,6 +110,8 @@ export class MuJoCoViewer {
|
||||
private resizeObserver: ResizeObserver;
|
||||
private orientationGizmo: OrientationGizmo;
|
||||
private visualizationHelpers: ViewerVisualizationHelpers;
|
||||
private visualMapLayer: VisualMapLayer;
|
||||
private mapEditorLayer: MapEditorLayer;
|
||||
private grid: THREE.GridHelper;
|
||||
private hemisphere: THREE.HemisphereLight;
|
||||
private themeTransition?: {
|
||||
@@ -108,8 +126,14 @@ export class MuJoCoViewer {
|
||||
private readonly host: HTMLElement,
|
||||
private readonly callbacks: ViewerCallbacks,
|
||||
) {
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
|
||||
this.renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||
this.renderer = new THREE.WebGLRenderer({
|
||||
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.outputColorSpace = THREE.SRGBColorSpace;
|
||||
host.append(this.renderer.domElement);
|
||||
@@ -128,6 +152,15 @@ export class MuJoCoViewer {
|
||||
this.grid.visible = this.displayOptions.showGrid;
|
||||
this.scene.add(this.grid);
|
||||
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.update(this.camera);
|
||||
this.resizeObserver = new ResizeObserver(() => this.resize());
|
||||
@@ -144,6 +177,8 @@ export class MuJoCoViewer {
|
||||
|
||||
attach(session: SimulationSession | null): void {
|
||||
this.releaseModel();
|
||||
this.runtimeErrorReported = false;
|
||||
this.snapshotDirty = false;
|
||||
this.session = session;
|
||||
if (!session) return;
|
||||
this.option = new session.module.MjvOption();
|
||||
@@ -182,6 +217,57 @@ export class MuJoCoViewer {
|
||||
setShowCollision(value: boolean): void {
|
||||
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 {
|
||||
this.showSensorCamera = value;
|
||||
}
|
||||
@@ -228,11 +314,18 @@ export class MuJoCoViewer {
|
||||
private applyGeomVisibility(): void {
|
||||
if (!this.option || !this.session) return;
|
||||
this.modelHasVisuals = false;
|
||||
for (let index = 0; index < this.session.model.ngeom; index += 1)
|
||||
if (Number(this.session.model.geom_group[index]) === 1) {
|
||||
this.modelHasVisuals = true;
|
||||
break;
|
||||
for (let index = 0; index < this.session.model.ngeom; index += 1) {
|
||||
if (Number(this.session.model.geom_group[index]) !== 1) continue;
|
||||
const geom = this.session.model.geom(index);
|
||||
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.displayOptions.showCollision
|
||||
? 1
|
||||
@@ -241,7 +334,10 @@ export class MuJoCoViewer {
|
||||
? 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.visualMapLayer.visible = this.showVisualMap && this.displayOptions.showVisual;
|
||||
}
|
||||
resetCamera(): void {
|
||||
if (this.session) this.fitCamera(this.session);
|
||||
@@ -342,13 +438,16 @@ export class MuJoCoViewer {
|
||||
this.lastFpsAt = now;
|
||||
}
|
||||
const snapshot =
|
||||
this.session && now - this.lastSnapshotAt > 150
|
||||
? ((this.lastSnapshotAt = now), this.session.snapshot())
|
||||
this.session && (result.steps > 0 || this.snapshotDirty) && now - this.lastSnapshotAt > 200
|
||||
? ((this.lastSnapshotAt = now), (this.snapshotDirty = false), this.session.snapshot())
|
||||
: 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) {
|
||||
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);
|
||||
};
|
||||
@@ -458,6 +557,8 @@ export class MuJoCoViewer {
|
||||
const m = this.session!.module,
|
||||
t = g.type,
|
||||
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)
|
||||
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);
|
||||
@@ -478,6 +579,15 @@ export class MuJoCoViewer {
|
||||
return this.meshGeometry(meshIdFromSceneDataId(g.dataid));
|
||||
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 {
|
||||
const m = this.session!.model;
|
||||
const va = Number(m.mesh_vertadr[id]),
|
||||
@@ -533,7 +643,12 @@ export class MuJoCoViewer {
|
||||
private createMesh(g: MjvGeom, key: string): THREE.Mesh {
|
||||
const m = this.session!.module,
|
||||
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;
|
||||
if (!geometry) {
|
||||
geometry = this.primitive(g);
|
||||
@@ -617,6 +732,10 @@ export class MuJoCoViewer {
|
||||
}
|
||||
private onPointerDown = (event: PointerEvent): void => {
|
||||
if (event.button !== 0) return;
|
||||
if (
|
||||
this.mapEditorLayer.handlePointerDown(event, this.renderer.domElement.getBoundingClientRect())
|
||||
)
|
||||
return;
|
||||
this.eventPointer(event);
|
||||
this.raycaster.setFromCamera(this.pointer, this.camera);
|
||||
const hit = this.raycaster.intersectObjects(
|
||||
@@ -722,6 +841,7 @@ export class MuJoCoViewer {
|
||||
value += resolveHingeDragDelta(worldDelta, tangentDelta, planeFacing);
|
||||
}
|
||||
if (this.session.setJointPosition(this.dragJointId, value)) {
|
||||
this.snapshotDirty = true;
|
||||
this.dragJointValue = value;
|
||||
this.dragStart.copy(this.pointer);
|
||||
if (currentPlaneVector && currentPlaneVector.lengthSq() > 1e-12)
|
||||
@@ -838,6 +958,8 @@ export class MuJoCoViewer {
|
||||
this.controls.dispose();
|
||||
this.orientationGizmo.dispose();
|
||||
this.visualizationHelpers.dispose();
|
||||
this.visualMapLayer.dispose();
|
||||
this.mapEditorLayer.dispose();
|
||||
this.grid.geometry.dispose();
|
||||
const gridMaterials = Array.isArray(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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,27 @@ const pyodideDirectory = resolve(
|
||||
'../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,保持平台可离线部署。 */
|
||||
function localPyodideAssets(): Plugin {
|
||||
let isBuild = false;
|
||||
@@ -59,12 +80,16 @@ function localPyodideAssets(): Plugin {
|
||||
export default defineConfig({
|
||||
root: 'web_platform',
|
||||
base: './',
|
||||
plugins: [react(), localPyodideAssets()],
|
||||
plugins: [react(), localPyodideAssets(), previewCacheHeaders()],
|
||||
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' },
|
||||
server: { open: true, fs: { allow: ['..'] } },
|
||||
preview: { headers: { 'Cache-Control': 'no-store' } },
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
|
||||
Reference in New Issue
Block a user