From 3b4ced6437289b04d91fd9ba1fa945c4136b4158 Mon Sep 17 00:00:00 2001
From: cen617-code <1057290604@qq.com>
Date: Tue, 1 Sep 2026 14:23:49 +0800
Subject: [PATCH] =?UTF-8?q?feat(web-platform):=20release=20V0.7=20?=
=?UTF-8?q?=E5=9C=B0=E5=9B=BE=E6=A8=A1=E5=9D=97?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitignore | 1 +
.prettierignore | 1 +
README.md | 3 +
package-lock.json | 4 +-
package.json | 2 +-
web_platform/README.md | 36 +
web_platform/e2e/app.spec.ts | 318 +++++++
web_platform/index.html | 62 +-
web_platform/src/app/App.tsx | 804 ++++++++++++++++--
.../components/FeedbackComponents.test.tsx | 25 +-
.../app/components/MapAssetLibrary.test.tsx | 51 ++
.../src/app/components/MapAssetLibrary.tsx | 298 +++++++
.../app/components/MapEditorPanel.test.tsx | 106 +++
.../src/app/components/MapEditorPanel.tsx | 597 +++++++++++++
.../app/components/PhysicalMapPanel.test.tsx | 118 +++
.../src/app/components/PhysicalMapPanel.tsx | 474 +++++++++++
.../src/app/components/SidebarPanel.tsx | 137 ++-
web_platform/src/app/components/StatusBar.tsx | 17 +
.../app/components/WorkbenchHeader.test.tsx | 9 +-
.../src/app/components/WorkbenchHeader.tsx | 28 +-
.../src/app/components/WorkspaceOverlays.tsx | 195 +++--
web_platform/src/map/MapComposer.test.ts | 174 ++++
web_platform/src/map/MapComposer.ts | 263 ++++++
web_platform/src/map/MapLoader.test.ts | 118 +++
web_platform/src/map/MapLoader.ts | 103 +++
.../src/map/editor/MapDocumentCompiler.ts | 54 ++
.../map/editor/MapDocumentImporter.test.ts | 65 ++
.../src/map/editor/MapDocumentImporter.ts | 230 +++++
web_platform/src/map/editor/MapEditSession.ts | 151 ++++
.../src/map/editor/MapPackageExporter.ts | 64 ++
web_platform/src/map/editor/assetCatalog.ts | 58 ++
web_platform/src/map/editor/editor.test.ts | 162 ++++
web_platform/src/map/editor/editorSchema.ts | 188 ++++
web_platform/src/map/editor/placement.ts | 81 ++
web_platform/src/map/editor/types.ts | 88 ++
web_platform/src/map/mapPaths.test.ts | 23 +
web_platform/src/map/mapPaths.ts | 44 +
web_platform/src/map/mapSchema.ts | 138 +++
web_platform/src/map/physicalMap.test.ts | 156 ++++
web_platform/src/map/physicalMap.ts | 295 +++++++
web_platform/src/map/terrainGenerator.test.ts | 63 ++
web_platform/src/map/terrainGenerator.ts | 288 +++++++
web_platform/src/map/types.ts | 140 +++
web_platform/src/project/cachedFiles.test.ts | 1 +
web_platform/src/project/importer.test.ts | 60 +-
web_platform/src/project/importer.ts | 146 +++-
web_platform/src/project/types.ts | 12 +
web_platform/src/project/workspace.test.ts | 1 +
web_platform/src/simulation/PhysicsAdapter.ts | 151 +++-
web_platform/src/simulation/geometry.test.ts | 27 +-
web_platform/src/simulation/geometry.ts | 54 ++
web_platform/src/styles.css | 102 ++-
.../src/viewer/MapEditorLayer.test.ts | 110 +++
web_platform/src/viewer/MapEditorLayer.ts | 267 ++++++
web_platform/src/viewer/MuJoCoViewer.ts | 148 +++-
.../src/viewer/VisualMapLayer.test.ts | 72 ++
web_platform/src/viewer/VisualMapLayer.ts | 142 ++++
web_platform/vite.config.ts | 31 +-
58 files changed, 7287 insertions(+), 269 deletions(-)
create mode 100644 web_platform/src/app/components/MapAssetLibrary.test.tsx
create mode 100644 web_platform/src/app/components/MapAssetLibrary.tsx
create mode 100644 web_platform/src/app/components/MapEditorPanel.test.tsx
create mode 100644 web_platform/src/app/components/MapEditorPanel.tsx
create mode 100644 web_platform/src/app/components/PhysicalMapPanel.test.tsx
create mode 100644 web_platform/src/app/components/PhysicalMapPanel.tsx
create mode 100644 web_platform/src/map/MapComposer.test.ts
create mode 100644 web_platform/src/map/MapComposer.ts
create mode 100644 web_platform/src/map/MapLoader.test.ts
create mode 100644 web_platform/src/map/MapLoader.ts
create mode 100644 web_platform/src/map/editor/MapDocumentCompiler.ts
create mode 100644 web_platform/src/map/editor/MapDocumentImporter.test.ts
create mode 100644 web_platform/src/map/editor/MapDocumentImporter.ts
create mode 100644 web_platform/src/map/editor/MapEditSession.ts
create mode 100644 web_platform/src/map/editor/MapPackageExporter.ts
create mode 100644 web_platform/src/map/editor/assetCatalog.ts
create mode 100644 web_platform/src/map/editor/editor.test.ts
create mode 100644 web_platform/src/map/editor/editorSchema.ts
create mode 100644 web_platform/src/map/editor/placement.ts
create mode 100644 web_platform/src/map/editor/types.ts
create mode 100644 web_platform/src/map/mapPaths.test.ts
create mode 100644 web_platform/src/map/mapPaths.ts
create mode 100644 web_platform/src/map/mapSchema.ts
create mode 100644 web_platform/src/map/physicalMap.test.ts
create mode 100644 web_platform/src/map/physicalMap.ts
create mode 100644 web_platform/src/map/terrainGenerator.test.ts
create mode 100644 web_platform/src/map/terrainGenerator.ts
create mode 100644 web_platform/src/map/types.ts
create mode 100644 web_platform/src/viewer/MapEditorLayer.test.ts
create mode 100644 web_platform/src/viewer/MapEditorLayer.ts
create mode 100644 web_platform/src/viewer/VisualMapLayer.test.ts
create mode 100644 web_platform/src/viewer/VisualMapLayer.ts
diff --git a/.gitignore b/.gitignore
index 7cdc71b1..dc57c6d5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,7 @@ playwright-report/
web_platform/test-results/
web_platform/playwright-report/
web_platform/node_modules/.vite/
+.playwright-cli/
*.tsbuildinfo
# Python
diff --git a/.prettierignore b/.prettierignore
index 23de50ee..944d5227 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -9,6 +9,7 @@ web-platform-dist/
coverage/
playwright-report/
test-results/
+.playwright-cli/
web_platform/fixtures/
web_platform/public/
package-lock.json
diff --git a/README.md b/README.md
index c92040c1..f7467dee 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,9 @@
- ROS `package://`、常见 URDF 兼容转换及 DAE 降级处理
- Three.js 模型、碰撞体、坐标系、关节轴、质心和惯量可视化
- 播放、暂停、单步、重置、变速、关节拖动与外力交互
+- 内置平地、坡道、楼梯、随机障碍物及 9 类系统参数化地形(粗糙/波浪、金字塔阶梯、深坑、沟壑等)
+- 工程地图包:静态 MJCF/OBJ/STL/高度场碰撞层、GLB 视觉层和机器人出生点
+- V3 地图创作层:认证资产库支持点击添加、拖到画布落位和首个资产自动创建场景,并可通过表单与视口操纵器继续编辑、事务式应用及导出地图 ZIP
- 浏览器内 Python 控制器(Pyodide)
- ONNX 强化学习策略推理(ONNX Runtime Web)
- 可选的本机 mjlab 训练桥接服务
diff --git a/package-lock.json b/package-lock.json
index 7cb572fb..644be042 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "mujoco-web-platform",
- "version": "0.6.1",
+ "version": "0.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mujoco-web-platform",
- "version": "0.6.1",
+ "version": "0.7.0",
"license": "Apache-2.0",
"dependencies": {
"@monaco-editor/react": "^4.7.0",
diff --git a/package.json b/package.json
index 898d3ecf..249bf2af 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "mujoco-web-platform",
- "version": "0.6.1",
+ "version": "0.7.0",
"description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台",
"private": true,
"type": "module",
diff --git a/web_platform/README.md b/web_platform/README.md
index bdec43b2..d54d719a 100644
--- a/web_platform/README.md
+++ b/web_platform/README.md
@@ -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`。
diff --git a/web_platform/e2e/app.spec.ts b/web_platform/e2e/app.spec.ts
index 7565d976..3c067464 100644
--- a/web_platform/e2e/app.spec.ts
+++ b/web_platform/e2e/app.spec.ts
@@ -22,6 +22,46 @@ const SIMPLE_MODEL = `
const SLIDE_DIRECTION_MODEL = ``;
+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 = `
@@ -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(
+ '',
+ ),
+ '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(
+ '',
+ ),
+ });
+ 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(''),
+ '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(
+ '',
+ ),
+ });
+ 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'));
diff --git a/web_platform/index.html b/web_platform/index.html
index 83215cb0..336f510d 100644
--- a/web_platform/index.html
+++ b/web_platform/index.html
@@ -3,12 +3,70 @@
-
+
+
MuJoCo Web 仿真平台
+
-
+