diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 00000000..f9a48601
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,14 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+indent_style = space
+indent_size = 2
+
+[*.py]
+indent_size = 4
+
+[*.md]
+trim_trailing_whitespace = false
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ba76d32d..e4d46194 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -9,15 +9,22 @@ permissions:
jobs:
quality:
- name: TypeScript、Lint、Unit、Build
+ name: TypeScript, lint, unit, build
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
- node-version: 24
+ node-version-file: .nvmrc
cache: npm
+ - uses: actions/setup-python@v6
+ with:
+ python-version: '3.12'
+ cache: pip
+ - run: npm install --global npm@11.17.0
- run: npm ci
+ - run: python -m pip install -r requirements-dev.txt
+ - run: npm run lint:python
- run: npm run check
e2e:
@@ -27,8 +34,10 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
- node-version: 24
+ node-version-file: .nvmrc
cache: npm
+ - run: npm install --global npm@11.17.0
- run: npm ci
+ - run: npx playwright install --with-deps chromium
- run: npm run build
- run: npm run test:e2e
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000..8e74160e
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,46 @@
+name: web-platform-release
+
+on:
+ push:
+ tags:
+ - 'V*'
+
+permissions:
+ contents: write
+
+jobs:
+ release:
+ name: Build and publish release
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v6
+ - uses: actions/setup-node@v6
+ with:
+ node-version-file: .nvmrc
+ cache: npm
+ - uses: actions/setup-python@v6
+ with:
+ python-version: '3.12'
+ cache: pip
+ - run: npm install --global npm@11.17.0
+ - run: npm ci
+ - run: python -m pip install -r requirements-dev.txt
+ - run: npm run lint:python
+ - run: npm run check
+ - run: npx playwright install --with-deps chromium
+ - run: npm run test:e2e
+ - name: Package static site
+ shell: bash
+ run: |
+ archive="mujoco-web-platform-${GITHUB_REF_NAME}.tar.gz"
+ tar -C web-platform-dist -czf "$archive" .
+ sha256sum "$archive" > SHA256SUMS
+ - name: Publish GitHub Release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ gh release create "$GITHUB_REF_NAME" \
+ "mujoco-web-platform-${GITHUB_REF_NAME}.tar.gz" \
+ SHA256SUMS \
+ --generate-notes \
+ --title "$GITHUB_REF_NAME"
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/.npmrc b/.npmrc
new file mode 100644
index 00000000..b6f27f13
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1 @@
+engine-strict=true
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 00000000..60ade1ae
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+24.19.0
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 00000000..944d5227
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,15 @@
+AGENTS.md
+context.md
+plans/
+.git/
+.venv/
+build/
+node_modules/
+web-platform-dist/
+coverage/
+playwright-report/
+test-results/
+.playwright-cli/
+web_platform/fixtures/
+web_platform/public/
+package-lock.json
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 00000000..53270ba2
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,7 @@
+{
+ "singleQuote": true,
+ "semi": true,
+ "trailingComma": "all",
+ "printWidth": 100,
+ "proseWrap": "preserve"
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..0c8b5c47
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,45 @@
+# 更新日志
+
+本项目的重要变更记录在此文件中,版本标签沿用仓库现有的 `V主版本.次版本[.修订版本]` 格式。
+
+## [0.7.1] - 2026-09-01
+
+### 新增
+
+- 增加浏览器内仿真遥测记录模块,可配置记录 Body、采样频率和样本上限。
+- 记录位置、移动速度、机身侧倾/俯仰/偏航角、角速度、累计里程、接触与驱动指标。
+- 增加实时数据与摘要面板,并支持导出稳定列结构的 CSV 和 Schema V1 JSON。
+- 提供 `TelemetrySource`、`registerDataChannel` 及记录生命周期接口,便于扩展业务指标和其他仿真后端。
+- 仿真重置使用数据分段,避免跨重置计算错误速度;达到样本上限时自动停止。
+
+## [0.6.1] - 2026-08-28
+
+### 新增
+
+- 固定并强制使用 Node.js、npm、Prettier 和 Ruff 开发工具版本。
+- 为核心导入、仿真数学、状态管理和训练客户端增加覆盖率门槛。
+- Git 标签触发的自动构建、校验和与 GitHub Release 工作流。
+- 本地训练服务随机 Bearer Token 鉴权、Host 校验和任务历史上限。
+- 训练进程启动/取消竞态回归测试和 SIGTERM 受控退出。
+
+### 变更
+
+- Playwright CI 改用版本固定的 Chromium。
+- TypeScript、TSX、配置和文档统一使用 Prettier 格式化。
+- Python 训练服务统一使用 Ruff 检查和格式化。
+
+## [0.6.0] - 2026-08-28
+
+### 变更
+
+- 将仓库重构为以 `web_platform/` 为核心的 Web 应用仓库。
+- MuJoCo 运行时改为依赖官方 `@mujoco/mujoco` npm 包。
+- 移除原生 C++、Python、MJX、Unity、桌面模拟器、CMake 和上游测试镜像。
+- 将训练桥接服务和 Python 控制器示例提升到仓库根目录。
+
+## [0.5.2] - 2026-08-28
+
+### 变更
+
+- 优化响应式工作区、可访问性、首屏加载、纹理兼容性和视口交互。
+- 增加碰撞体、坐标系、关节轴、质心和惯量辅助可视化。
diff --git a/README.md b/README.md
index 98cf5802..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 训练桥接服务
@@ -17,10 +20,12 @@
## 快速开始
-环境要求:Node.js 24+;仅使用训练桥接服务时需要 Python 3。
+环境要求:Node.js 24(版本见 `.nvmrc`)和 npm 11.17;仅使用训练桥接服务或执行 Python 检查时需要 Python 3.12。
```bash
-npm install
+nvm use
+npm install --global npm@11.17.0
+npm ci
npm run dev
```
@@ -34,10 +39,16 @@ npm run build # 生产构建到 web-platform-dist/
npm run preview # 预览生产构建
npm run typecheck # TypeScript 检查
npm run lint # ESLint
+npm run check:format # Prettier 格式检查
npm test # Vitest 单元测试
-npm run test:e2e # Playwright 浏览器测试
+npm run test:coverage # 核心模块覆盖率检查
+npm run test:e2e # Playwright Chromium 浏览器测试
npm run test:training-server # Python 训练桥接服务测试
-npm run check # 除 E2E 外的完整检查
+npm run check # 除 E2E 和 Ruff 外的完整检查
+
+# 修改 training_server/ 时额外执行
+python3 -m pip install -r requirements-dev.txt
+npm run lint:python
```
## 仓库结构
diff --git a/eslint.config.js b/eslint.config.js
index 9fd38e29..392c87fd 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -3,4 +3,18 @@ import globals from 'globals';
import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
-export default tseslint.config({ignores:['web-platform-dist','node_modules']},js.configs.recommended,...tseslint.configs.recommended,{files:['web_platform/**/*.{ts,tsx}'],languageOptions:{globals:{...globals.browser,...globals.node}},plugins:{'react-hooks':reactHooks,'react-refresh':reactRefresh},rules:{...reactHooks.configs.recommended.rules,'react-refresh/only-export-components':['warn',{allowConstantExport:true}],'@typescript-eslint/no-explicit-any':'off'}});
+export default tseslint.config(
+ { ignores: ['web-platform-dist', 'node_modules'] },
+ js.configs.recommended,
+ ...tseslint.configs.recommended,
+ {
+ files: ['web_platform/**/*.{ts,tsx}'],
+ languageOptions: { globals: { ...globals.browser, ...globals.node } },
+ plugins: { 'react-hooks': reactHooks, 'react-refresh': reactRefresh },
+ rules: {
+ ...reactHooks.configs.recommended.rules,
+ 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
+ '@typescript-eslint/no-explicit-any': 'off',
+ },
+ },
+);
diff --git a/package-lock.json b/package-lock.json
index ae7713c4..0f266f91 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "mujoco-web-platform",
- "version": "0.6.0",
+ "version": "0.7.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mujoco-web-platform",
- "version": "0.6.0",
+ "version": "0.7.1",
"license": "Apache-2.0",
"dependencies": {
"@monaco-editor/react": "^4.7.0",
@@ -30,6 +30,7 @@
"@types/react-dom": "^19.2.4",
"@types/three": "^0.185.4",
"@vitejs/plugin-react": "^6.1.0",
+ "@vitest/coverage-v8": "4.1.11",
"autoprefixer": "^10.5.4",
"eslint": "^10.8.1",
"eslint-plugin-react-hooks": "^7.1.1",
@@ -37,12 +38,17 @@
"globals": "^17.11.0",
"jsdom": "^30.0.1",
"postcss": "^8.5.26",
+ "prettier": "3.9.6",
"tailwindcss": "^3.4.17",
"three": "^0.178.0",
"typescript": "5.8.2",
"typescript-eslint": "^8.67.0",
"vite": "^8.0.16",
"vitest": "^4.1.11"
+ },
+ "engines": {
+ "node": ">=24 <25",
+ "npm": "11.17.0"
}
},
"node_modules/@adobe/css-tools": {
@@ -191,17 +197,6 @@
"node": ">=6.9.0"
}
},
- "node_modules/@babel/generator/node_modules/@jridgewell/trace-mapping": {
- "version": "0.3.31",
- "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
- "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/resolve-uri": "^3.1.0",
- "@jridgewell/sourcemap-codec": "^1.4.14"
- }
- },
"node_modules/@babel/helper-compilation-targets": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
@@ -389,6 +384,16 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
@@ -845,17 +850,6 @@
"@jridgewell/trace-mapping": "^0.3.24"
}
},
- "node_modules/@jridgewell/gen-mapping/node_modules/@jridgewell/trace-mapping": {
- "version": "0.3.31",
- "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
- "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/resolve-uri": "^3.1.0",
- "@jridgewell/sourcemap-codec": "^1.4.14"
- }
- },
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
@@ -867,17 +861,6 @@
"@jridgewell/trace-mapping": "^0.3.24"
}
},
- "node_modules/@jridgewell/remapping/node_modules/@jridgewell/trace-mapping": {
- "version": "0.3.31",
- "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
- "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/resolve-uri": "^3.1.0",
- "@jridgewell/sourcemap-codec": "^1.4.14"
- }
- },
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -893,6 +876,17 @@
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true
},
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
"node_modules/@monaco-editor/loader": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
@@ -1883,6 +1877,37 @@
}
}
},
+ "node_modules/@vitest/coverage-v8": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz",
+ "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@bcoe/v8-coverage": "^1.0.2",
+ "@vitest/utils": "4.1.11",
+ "ast-v8-to-istanbul": "^1.0.0",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-reports": "^3.2.0",
+ "magicast": "^0.5.2",
+ "obug": "^2.1.1",
+ "std-env": "^4.0.0-rc.1",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "4.1.11",
+ "vitest": "4.1.11"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@vitest/expect": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
@@ -2090,6 +2115,25 @@
"node": ">=12"
}
},
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz",
+ "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^10.0.0"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
+ "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/autoprefixer": {
"version": "10.5.4",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz",
@@ -3013,6 +3057,16 @@
"integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==",
"license": "ISC"
},
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
@@ -3056,6 +3110,13 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3161,6 +3222,45 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true
},
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/jiti": {
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
@@ -3645,6 +3745,47 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/magicast": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz",
+ "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/make-dir/node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/marked": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
@@ -4192,6 +4333,22 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/prettier": {
+ "version": "3.9.6",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
+ "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
@@ -4600,6 +4757,19 @@
"node": ">=16 || 14 >=14.17"
}
},
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
diff --git a/package.json b/package.json
index 0af2714e..91c4f3c3 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "mujoco-web-platform",
- "version": "0.6.0",
+ "version": "0.7.1",
"description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台",
"private": true,
"type": "module",
@@ -14,7 +14,12 @@
"test:e2e": "playwright test -c web_platform/playwright.config.ts",
"training-server": "python3 training_server/server.py",
"test:training-server": "python3 -m unittest discover -s training_server/tests",
- "check": "npm run typecheck && npm run lint && npm run test && npm run test:training-server && npm run build"
+ "check": "npm run typecheck && npm run lint && npm run check:format && npm run test:coverage && npm run test:training-server && npm run build",
+ "format": "prettier --write .",
+ "check:format": "prettier --check .",
+ "test:coverage": "vitest run --coverage --config web_platform/vite.config.ts",
+ "lint:python": "python3 -m ruff check training_server",
+ "format:python": "python3 -m ruff format training_server"
},
"license": "Apache-2.0",
"devDependencies": {
@@ -27,6 +32,7 @@
"@types/react-dom": "^19.2.4",
"@types/three": "^0.185.4",
"@vitejs/plugin-react": "^6.1.0",
+ "@vitest/coverage-v8": "4.1.11",
"autoprefixer": "^10.5.4",
"eslint": "^10.8.1",
"eslint-plugin-react-hooks": "^7.1.1",
@@ -34,6 +40,7 @@
"globals": "^17.11.0",
"jsdom": "^30.0.1",
"postcss": "^8.5.26",
+ "prettier": "3.9.6",
"tailwindcss": "^3.4.17",
"three": "^0.178.0",
"typescript": "5.8.2",
@@ -55,5 +62,10 @@
"react": "^19.2.8",
"react-dom": "^19.2.8",
"zustand": "^5.0.15"
- }
+ },
+ "engines": {
+ "node": ">=24 <25",
+ "npm": "11.17.0"
+ },
+ "packageManager": "npm@11.17.0"
}
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..871599fc
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,11 @@
+[tool.ruff]
+target-version = "py312"
+line-length = 100
+extend-exclude = [".venv", "build"]
+
+[tool.ruff.lint]
+select = ["E", "F", "I", "UP", "B", "SIM"]
+
+[tool.ruff.format]
+quote-style = "double"
+indent-style = "space"
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644
index 00000000..68e63057
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1 @@
+ruff==0.16.5
diff --git a/training_server/README.md b/training_server/README.md
index 58099ece..302578da 100644
--- a/training_server/README.md
+++ b/training_server/README.md
@@ -11,11 +11,20 @@ npm run training-server -- \
--trainer-root /path/to/unitree_rl_mjlab \
--trainer-python /path/to/training-env/bin/python
```
+
如:
+
```bash
npm run training-server -- --trainer-root /home/cen/Embodied_Workspace/unitree_rl_mjlab --trainer-python /home/cen/miniconda3/envs/unitree_rl_mjlab/bin/python
```
+服务启动时会在终端显示一个随机访问令牌。将该令牌填入前端“访问令牌”字段后再连接。令牌只保存在当前浏览器标签页的 `sessionStorage` 中。自动化启动时可固定令牌:
+
+```bash
+MUJOCO_TRAINING_TOKEN='至少十六个字符的随机令牌' npm run training-server -- \
+ --trainer-root /path/to/unitree_rl_mjlab
+```
+
也可用 `UNITREE_RL_MJLAB_ROOT` 指定工程目录。默认端口是 `8765`。如果前端不是从 `localhost` 或 `127.0.0.1` 提供,可显式添加来源:
```bash
@@ -24,7 +33,7 @@ python training_server/server.py \
--allow-origin http://192.168.1.10:5173
```
-服务一次只运行一个训练任务。停止服务或在界面点击“停止训练”会向整个训练进程组发送终止信号。训练请求的 W&B 模式默认为 `offline`,保留本地指标但不登录;也可以在界面选择完全禁用或在线模式。
+服务一次只运行一个训练任务,最多保留 20 个任务的内存状态,每个任务最多保留 200 行最近日志。停止服务或在界面点击“停止训练”会同步终止整个训练进程组。训练请求的 W&B 模式默认为 `offline`,保留本地指标但不登录;也可以在界面选择完全禁用或在线模式。所有 API 请求都必须携带启动时生成的 Bearer Token。
## 接口
@@ -39,5 +48,7 @@ python training_server/server.py \
## 测试
```bash
+python3 -m pip install -r requirements-dev.txt
+npm run lint:python
npm run test:training-server
```
diff --git a/training_server/server.py b/training_server/server.py
index 161612c8..0d957a01 100644
--- a/training_server/server.py
+++ b/training_server/server.py
@@ -4,29 +4,32 @@
from __future__ import annotations
import argparse
+import hmac
import json
import os
import re
+import secrets
import shutil
import signal
import subprocess
import sys
import threading
-import time
import uuid
from collections import deque
+from contextlib import suppress
from dataclasses import dataclass, field
-from datetime import datetime, timezone
+from datetime import UTC, datetime
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import unquote, urlsplit
-VERSION = "0.1.0"
+VERSION = "0.2.0"
# 浏览器当前 ONNX 运行时只实现 Go2 的 47→12 部署契约;其他任务须由服务启动参数显式放行。
DEFAULT_TASKS = ("Unitree-Go2-Flat",)
ACTIVE_STATES = {"queued", "running"}
+MAX_JOBS = 20
ANSI_ESCAPE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
ITERATION_PATTERNS = (
re.compile(r"(?:learning\s+)?iteration\D+(\d+)\s*/\s*(\d+)", re.I),
@@ -34,459 +37,585 @@ ITERATION_PATTERNS = (
)
RUN_NAME = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
LOCAL_ORIGIN = re.compile(r"^https?://(?:localhost|127\.0\.0\.1)(?::\d+)?$")
+LOCAL_HOST = re.compile(r"^(?:localhost|127\.0\.0\.1)(?::\d+)?$")
def now_iso() -> str:
- return datetime.now(timezone.utc).isoformat()
+ return datetime.now(UTC).isoformat()
+
+
+def termination_signal_handler(_signum: int, _frame: Any) -> None:
+ """将 SIGTERM 转成受控退出,使 main 的 finally 能清理训练子进程。"""
+ raise KeyboardInterrupt
class ApiError(Exception):
- def __init__(self, status: int, message: str):
- super().__init__(message)
- self.status = status
+ def __init__(self, status: int, message: str):
+ super().__init__(message)
+ self.status = status
@dataclass
class TrainingConfig:
- task_id: str
- num_envs: int
- max_iterations: int
- seed: int
- run_name: str
- device: str
- gpu_ids: list[int]
- wandb_mode: str
+ task_id: str
+ num_envs: int
+ max_iterations: int
+ seed: int
+ run_name: str
+ device: str
+ gpu_ids: list[int]
+ wandb_mode: str
@dataclass
class TrainingJob:
- id: str
- config: TrainingConfig
- state: str = "queued"
- created_at: str = field(default_factory=now_iso)
- started_at: str | None = None
- ended_at: str | None = None
- iteration: int = 0
- message: str = "等待本地训练进程启动"
- logs: deque[str] = field(default_factory=lambda: deque(maxlen=200))
- artifact: Path | None = None
- process: subprocess.Popen[str] | None = None
- cancel_requested: bool = False
+ id: str
+ config: TrainingConfig
+ state: str = "queued"
+ created_at: str = field(default_factory=now_iso)
+ started_at: str | None = None
+ ended_at: str | None = None
+ iteration: int = 0
+ message: str = "等待本地训练进程启动"
+ logs: deque[str] = field(default_factory=lambda: deque(maxlen=200))
+ artifact: Path | None = None
+ process: subprocess.Popen[str] | None = None
+ cancel_requested: bool = False
- def public(self) -> dict[str, Any]:
- progress = min(1.0, max(0.0, self.iteration / self.config.max_iterations))
- if self.state == "succeeded":
- progress = 1.0
- return {
- "id": self.id,
- "state": self.state,
- "taskId": self.config.task_id,
- "createdAt": self.created_at,
- "startedAt": self.started_at,
- "endedAt": self.ended_at,
- "iteration": self.iteration,
- "maxIterations": self.config.max_iterations,
- "progress": progress,
- "message": self.message,
- "logs": list(self.logs),
- "artifactReady": self.artifact is not None and self.artifact.is_file(),
- "artifactName": self.artifact.name if self.artifact else None,
- }
+ def public(self) -> dict[str, Any]:
+ progress = min(1.0, max(0.0, self.iteration / self.config.max_iterations))
+ if self.state == "succeeded":
+ progress = 1.0
+ return {
+ "id": self.id,
+ "state": self.state,
+ "taskId": self.config.task_id,
+ "createdAt": self.created_at,
+ "startedAt": self.started_at,
+ "endedAt": self.ended_at,
+ "iteration": self.iteration,
+ "maxIterations": self.config.max_iterations,
+ "progress": progress,
+ "message": self.message,
+ "logs": list(self.logs),
+ "artifactReady": self.artifact is not None and self.artifact.is_file(),
+ "artifactName": self.artifact.name if self.artifact else None,
+ }
class TrainingManager:
- def __init__(self, trainer_root: Path, python: str, tasks: tuple[str, ...], check_environment: bool = True):
- self.trainer_root = trainer_root.expanduser().resolve()
- self.python = str(Path(python).expanduser()) if os.sep in python else python
- self.tasks = tasks
- self.jobs: dict[str, TrainingJob] = {}
- self.lock = threading.RLock()
- self.check_environment = check_environment
- self._environment_error: str | None | bool = False
+ def __init__(
+ self,
+ trainer_root: Path,
+ python: str,
+ tasks: tuple[str, ...],
+ check_environment: bool = True,
+ ):
+ self.trainer_root = trainer_root.expanduser().resolve()
+ self.python = str(Path(python).expanduser()) if os.sep in python else python
+ self.tasks = tasks
+ self.jobs: dict[str, TrainingJob] = {}
+ self.lock = threading.RLock()
+ self.check_environment = check_environment
+ self._environment_error: str | None | bool = False
- def readiness_error(self) -> str | None:
- if not self.trainer_root.is_dir():
- return f"训练工程目录不存在:{self.trainer_root}"
- if not (self.trainer_root / "scripts" / "train.py").is_file():
- return f"训练入口不存在:{self.trainer_root / 'scripts/train.py'}"
- executable = Path(self.python)
- if not executable.is_file() and shutil.which(self.python) is None:
- return f"Python 解释器不存在:{self.python}"
- if self.check_environment and self._environment_error is False:
- probe = "import importlib.util,sys; missing=[m for m in ('mjlab','torch','tyro') if importlib.util.find_spec(m) is None]; print(','.join(missing)); sys.exit(bool(missing))"
- try:
- result = subprocess.run([self.python, "-c", probe], cwd=self.trainer_root, capture_output=True, text=True, timeout=15, check=False)
- missing = result.stdout.strip()
- self._environment_error = f"训练 Python 缺少依赖:{missing}" if result.returncode else None
- except (OSError, subprocess.TimeoutExpired) as error:
- self._environment_error = f"无法检查训练 Python 环境:{error}"
- return self._environment_error if isinstance(self._environment_error, str) else None
+ def readiness_error(self) -> str | None:
+ if not self.trainer_root.is_dir():
+ return f"训练工程目录不存在:{self.trainer_root}"
+ if not (self.trainer_root / "scripts" / "train.py").is_file():
+ return f"训练入口不存在:{self.trainer_root / 'scripts/train.py'}"
+ executable = Path(self.python)
+ if not executable.is_file() and shutil.which(self.python) is None:
+ return f"Python 解释器不存在:{self.python}"
+ if self.check_environment and self._environment_error is False:
+ probe = (
+ "import importlib.util,sys; "
+ "missing=[m for m in ('mjlab','torch','tyro') "
+ "if importlib.util.find_spec(m) is None]; "
+ "print(','.join(missing)); sys.exit(bool(missing))"
+ )
+ try:
+ result = subprocess.run(
+ [self.python, "-c", probe],
+ cwd=self.trainer_root,
+ capture_output=True,
+ text=True,
+ timeout=15,
+ check=False,
+ )
+ missing = result.stdout.strip()
+ self._environment_error = (
+ f"训练 Python 缺少依赖:{missing}" if result.returncode else None
+ )
+ except (OSError, subprocess.TimeoutExpired) as error:
+ self._environment_error = f"无法检查训练 Python 环境:{error}"
+ return self._environment_error if isinstance(self._environment_error, str) else None
- def active_job_id(self) -> str | None:
- with self.lock:
- return next((job.id for job in self.jobs.values() if job.state in ACTIVE_STATES), None)
+ def active_job_id(self) -> str | None:
+ with self.lock:
+ return next((job.id for job in self.jobs.values() if job.state in ACTIVE_STATES), None)
- def health(self) -> dict[str, Any]:
- error = self.readiness_error()
- return {
- "version": VERSION,
- "ready": error is None,
- "trainerRoot": str(self.trainer_root),
- "python": self.python,
- "tasks": list(self.tasks),
- "activeJobId": self.active_job_id(),
- "error": error,
- }
+ def health(self) -> dict[str, Any]:
+ error = self.readiness_error()
+ return {
+ "version": VERSION,
+ "ready": error is None,
+ "trainerRoot": str(self.trainer_root),
+ "python": self.python,
+ "tasks": list(self.tasks),
+ "activeJobId": self.active_job_id(),
+ "error": error,
+ }
- def parse_config(self, payload: Any) -> TrainingConfig:
- if not isinstance(payload, dict):
- raise ApiError(HTTPStatus.BAD_REQUEST, "请求体必须是 JSON 对象")
- task_id = payload.get("taskId")
- if task_id not in self.tasks:
- raise ApiError(HTTPStatus.BAD_REQUEST, f"不允许的训练任务:{task_id}")
+ def parse_config(self, payload: Any) -> TrainingConfig:
+ if not isinstance(payload, dict):
+ raise ApiError(HTTPStatus.BAD_REQUEST, "请求体必须是 JSON 对象")
+ task_id = payload.get("taskId")
+ if task_id not in self.tasks:
+ raise ApiError(HTTPStatus.BAD_REQUEST, f"不允许的训练任务:{task_id}")
- def integer(name: str, minimum: int, maximum: int) -> int:
- value = payload.get(name)
- if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum:
- raise ApiError(HTTPStatus.BAD_REQUEST, f"{name} 必须在 {minimum}–{maximum} 之间")
- return value
+ def integer(name: str, minimum: int, maximum: int) -> int:
+ value = payload.get(name)
+ if (
+ isinstance(value, bool)
+ or not isinstance(value, int)
+ or not minimum <= value <= maximum
+ ):
+ raise ApiError(HTTPStatus.BAD_REQUEST, f"{name} 必须在 {minimum}–{maximum} 之间")
+ return value
- run_name = payload.get("runName", "web")
- if not isinstance(run_name, str) or not RUN_NAME.fullmatch(run_name):
- raise ApiError(HTTPStatus.BAD_REQUEST, "runName 只能包含字母、数字、点、下划线和连字符,最长 64 字符")
- device = payload.get("device")
- if device not in ("cpu", "gpu"):
- raise ApiError(HTTPStatus.BAD_REQUEST, "device 必须是 cpu 或 gpu")
- raw_gpu_ids = payload.get("gpuIds", [])
- if not isinstance(raw_gpu_ids, list) or any(isinstance(value, bool) or not isinstance(value, int) or value < 0 or value > 255 for value in raw_gpu_ids):
- raise ApiError(HTTPStatus.BAD_REQUEST, "gpuIds 必须是非负整数数组")
- if device == "gpu" and not raw_gpu_ids:
- raise ApiError(HTTPStatus.BAD_REQUEST, "GPU 训练至少需要一个 GPU 编号")
- wandb_mode = payload.get("wandbMode", "offline")
- if wandb_mode not in ("offline", "disabled", "online"):
- raise ApiError(HTTPStatus.BAD_REQUEST, "wandbMode 必须是 offline、disabled 或 online")
- return TrainingConfig(
- task_id=task_id,
- num_envs=integer("numEnvs", 1, 16384),
- max_iterations=integer("maxIterations", 1, 1_000_000),
- seed=integer("seed", 0, 2_147_483_647),
- run_name=run_name,
- device=device,
- gpu_ids=raw_gpu_ids,
- wandb_mode=wandb_mode,
- )
+ run_name = payload.get("runName", "web")
+ if not isinstance(run_name, str) or not RUN_NAME.fullmatch(run_name):
+ raise ApiError(
+ HTTPStatus.BAD_REQUEST,
+ "runName 只能包含字母、数字、点、下划线和连字符,最长 64 字符",
+ )
+ device = payload.get("device")
+ if device not in ("cpu", "gpu"):
+ raise ApiError(HTTPStatus.BAD_REQUEST, "device 必须是 cpu 或 gpu")
+ raw_gpu_ids = payload.get("gpuIds", [])
+ if not isinstance(raw_gpu_ids, list) or any(
+ isinstance(value, bool) or not isinstance(value, int) or value < 0 or value > 255
+ for value in raw_gpu_ids
+ ):
+ raise ApiError(HTTPStatus.BAD_REQUEST, "gpuIds 必须是非负整数数组")
+ if device == "gpu" and not raw_gpu_ids:
+ raise ApiError(HTTPStatus.BAD_REQUEST, "GPU 训练至少需要一个 GPU 编号")
+ wandb_mode = payload.get("wandbMode", "offline")
+ if wandb_mode not in ("offline", "disabled", "online"):
+ raise ApiError(HTTPStatus.BAD_REQUEST, "wandbMode 必须是 offline、disabled 或 online")
+ return TrainingConfig(
+ task_id=task_id,
+ num_envs=integer("numEnvs", 1, 16384),
+ max_iterations=integer("maxIterations", 1, 1_000_000),
+ seed=integer("seed", 0, 2_147_483_647),
+ run_name=run_name,
+ device=device,
+ gpu_ids=raw_gpu_ids,
+ wandb_mode=wandb_mode,
+ )
- def start(self, payload: Any) -> dict[str, Any]:
- error = self.readiness_error()
- if error:
- raise ApiError(HTTPStatus.SERVICE_UNAVAILABLE, error)
- config = self.parse_config(payload)
- with self.lock:
- if self.active_job_id():
- raise ApiError(HTTPStatus.CONFLICT, "已有训练任务正在运行,请等待完成或先停止任务")
- job = TrainingJob(id=uuid.uuid4().hex, config=config)
- self.jobs[job.id] = job
- threading.Thread(target=self._run, args=(job,), name=f"training-{job.id[:8]}", daemon=True).start()
- return job.public()
+ def start(self, payload: Any) -> dict[str, Any]:
+ error = self.readiness_error()
+ if error:
+ raise ApiError(HTTPStatus.SERVICE_UNAVAILABLE, error)
+ config = self.parse_config(payload)
+ with self.lock:
+ if self.active_job_id():
+ raise ApiError(HTTPStatus.CONFLICT, "已有训练任务正在运行,请等待完成或先停止任务")
+ while len(self.jobs) >= MAX_JOBS:
+ completed = next(
+ (job_id for job_id, job in self.jobs.items() if job.state not in ACTIVE_STATES),
+ None,
+ )
+ if completed is None:
+ raise ApiError(HTTPStatus.CONFLICT, "训练任务历史已满,请稍后重试")
+ del self.jobs[completed]
+ job = TrainingJob(id=uuid.uuid4().hex, config=config)
+ self.jobs[job.id] = job
+ threading.Thread(
+ target=self._run, args=(job,), name=f"training-{job.id[:8]}", daemon=True
+ ).start()
+ return job.public()
- def get(self, job_id: str) -> dict[str, Any]:
- with self.lock:
- job = self.jobs.get(job_id)
- if not job:
- raise ApiError(HTTPStatus.NOT_FOUND, "训练任务不存在或服务已重启")
- return job.public()
+ def get(self, job_id: str) -> dict[str, Any]:
+ with self.lock:
+ job = self.jobs.get(job_id)
+ if not job:
+ raise ApiError(HTTPStatus.NOT_FOUND, "训练任务不存在或服务已重启")
+ return job.public()
- def artifact(self, job_id: str) -> Path:
- with self.lock:
- job = self.jobs.get(job_id)
- if not job:
- raise ApiError(HTTPStatus.NOT_FOUND, "训练任务不存在或服务已重启")
- if not job.artifact or not job.artifact.is_file():
- raise ApiError(HTTPStatus.NOT_FOUND, "该训练任务尚未生成 policy.onnx")
- return job.artifact
+ def artifact(self, job_id: str) -> Path:
+ with self.lock:
+ job = self.jobs.get(job_id)
+ if not job:
+ raise ApiError(HTTPStatus.NOT_FOUND, "训练任务不存在或服务已重启")
+ if not job.artifact or not job.artifact.is_file():
+ raise ApiError(HTTPStatus.NOT_FOUND, "该训练任务尚未生成 policy.onnx")
+ return job.artifact
- def cancel(self, job_id: str) -> dict[str, Any]:
- with self.lock:
- job = self.jobs.get(job_id)
- if not job:
- raise ApiError(HTTPStatus.NOT_FOUND, "训练任务不存在或服务已重启")
- if job.state not in ACTIVE_STATES:
- return job.public()
- job.cancel_requested = True
- job.message = "正在停止训练进程"
- process = job.process
- if process and process.poll() is None:
- try:
- os.killpg(process.pid, signal.SIGTERM)
- except ProcessLookupError:
- pass
- threading.Thread(target=self._kill_later, args=(process,), daemon=True).start()
- return self.get(job_id)
+ def cancel(self, job_id: str) -> dict[str, Any]:
+ with self.lock:
+ job = self.jobs.get(job_id)
+ if not job:
+ raise ApiError(HTTPStatus.NOT_FOUND, "训练任务不存在或服务已重启")
+ if job.state not in ACTIVE_STATES:
+ return job.public()
+ job.cancel_requested = True
+ job.message = "正在停止训练进程"
+ process = job.process
+ if process and process.poll() is None:
+ with suppress(ProcessLookupError):
+ os.killpg(process.pid, signal.SIGTERM)
+ threading.Thread(target=self._kill_later, args=(process,), daemon=True).start()
+ return self.get(job_id)
- @staticmethod
- def _kill_later(process: subprocess.Popen[str]) -> None:
- try:
- process.wait(timeout=5)
- except subprocess.TimeoutExpired:
- try:
- os.killpg(process.pid, signal.SIGKILL)
- except ProcessLookupError:
- pass
+ @staticmethod
+ def _kill_later(process: subprocess.Popen[str]) -> None:
+ try:
+ process.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ with suppress(ProcessLookupError):
+ os.killpg(process.pid, signal.SIGKILL)
- def command_for(self, config: TrainingConfig) -> list[str]:
- command = [
- self.python, "-u", "scripts/train.py", config.task_id,
- f"--env.scene.num-envs={config.num_envs}",
- f"--agent.max-iterations={config.max_iterations}",
- f"--agent.seed={config.seed}",
- f"--agent.run-name={config.run_name}",
- ]
- if config.device == "cpu":
- command.extend(("--gpu-ids", "None"))
- else:
- # mjlab.TYRO_FLAGS 对 Union[list[int], Literal["all"], None] 使用 JSON 风格
- # list token;传成多个独立参数会被解析为错误的 Union 分支。
- command.extend(("--gpu-ids", json.dumps(config.gpu_ids, separators=(",", ":"))))
- return command
+ def shutdown(self) -> None:
+ """同步停止活动训练,避免服务退出后遗留子进程。"""
+ active = self.active_job_id()
+ if not active:
+ return
+ self.cancel(active)
+ with self.lock:
+ process = self.jobs[active].process
+ if process and process.poll() is None:
+ try:
+ process.wait(timeout=6)
+ except subprocess.TimeoutExpired:
+ with suppress(ProcessLookupError):
+ os.killpg(process.pid, signal.SIGKILL)
+ process.wait(timeout=2)
- def _update_from_log(self, job: TrainingJob, raw_line: str) -> None:
- line = ANSI_ESCAPE.sub("", raw_line).strip()
- if not line:
- return
- with self.lock:
- job.logs.append(line[-4000:])
- for pattern in ITERATION_PATTERNS:
- match = pattern.search(line)
- if match:
- job.iteration = min(job.config.max_iterations, max(job.iteration, int(match.group(1))))
- break
- job.message = line[-240:]
-
- def _artifact_snapshot(self) -> dict[Path, int]:
- root = self.trainer_root / "logs" / "rsl_rl"
- if not root.is_dir():
- return {}
- return {path: path.stat().st_mtime_ns for path in root.glob("**/policy.onnx") if path.is_file()}
-
- def _find_artifact(self, before: dict[Path, int]) -> Path | None:
- root = self.trainer_root / "logs" / "rsl_rl"
- if not root.is_dir():
- return None
- changed = [path for path in root.glob("**/policy.onnx") if path.is_file() and before.get(path) != path.stat().st_mtime_ns]
- return max(changed, key=lambda path: path.stat().st_mtime_ns) if changed else None
-
- def _run(self, job: TrainingJob) -> None:
- before = self._artifact_snapshot()
- command = self.command_for(job.config)
- with self.lock:
- if job.cancel_requested:
- job.state, job.ended_at, job.message = "cancelled", now_iso(), "训练已取消"
- return
- job.state, job.started_at, job.message = "running", now_iso(), "本地训练进程已启动"
- try:
- environment = os.environ.copy()
- # 默认离线记录,保留本地 W&B 指标但不要求 API Key;只有前端明确选择
- # online 时才允许 wandb 发起登录/联网。
- environment["WANDB_MODE"] = job.config.wandb_mode
- environment["WANDB_SILENT"] = "true"
- process = subprocess.Popen(
- command,
- cwd=self.trainer_root,
- env=environment,
- stdout=subprocess.PIPE,
- stderr=subprocess.STDOUT,
- text=True,
- encoding="utf-8",
- errors="replace",
- bufsize=1,
- start_new_session=True,
- )
- with self.lock:
- job.process = process
- assert process.stdout is not None
- try:
- for line in process.stdout:
- self._update_from_log(job, line)
- finally:
- process.stdout.close()
- return_code = process.wait()
- artifact = self._find_artifact(before)
- with self.lock:
- job.process = None
- job.ended_at = now_iso()
- if job.cancel_requested:
- job.state, job.message = "cancelled", "训练已由用户取消"
- elif return_code != 0:
- job.state, job.message = "failed", f"训练进程退出,返回码 {return_code}"
- elif artifact is None:
- job.state, job.message = "failed", "训练结束,但没有找到本次生成的 policy.onnx"
+ def command_for(self, config: TrainingConfig) -> list[str]:
+ command = [
+ self.python,
+ "-u",
+ "scripts/train.py",
+ config.task_id,
+ f"--env.scene.num-envs={config.num_envs}",
+ f"--agent.max-iterations={config.max_iterations}",
+ f"--agent.seed={config.seed}",
+ f"--agent.run-name={config.run_name}",
+ ]
+ if config.device == "cpu":
+ command.extend(("--gpu-ids", "None"))
else:
- job.state, job.artifact = "succeeded", artifact
- job.iteration = job.config.max_iterations
- job.message = f"训练完成:{artifact.relative_to(self.trainer_root)}"
- except Exception as error: # 服务必须保留错误供前端诊断。
- with self.lock:
- job.process = None
- job.ended_at = now_iso()
- job.state = "cancelled" if job.cancel_requested else "failed"
- job.message = f"启动训练失败:{error}"
- job.logs.append(job.message)
+ # mjlab.TYRO_FLAGS 对 Union[list[int], Literal["all"], None] 使用 JSON 风格
+ # list token;传成多个独立参数会被解析为错误的 Union 分支。
+ command.extend(("--gpu-ids", json.dumps(config.gpu_ids, separators=(",", ":"))))
+ return command
+
+ def _update_from_log(self, job: TrainingJob, raw_line: str) -> None:
+ line = ANSI_ESCAPE.sub("", raw_line).strip()
+ if not line:
+ return
+ with self.lock:
+ job.logs.append(line[-4000:])
+ for pattern in ITERATION_PATTERNS:
+ match = pattern.search(line)
+ if match:
+ job.iteration = min(
+ job.config.max_iterations, max(job.iteration, int(match.group(1)))
+ )
+ break
+ job.message = line[-240:]
+
+ def _artifact_snapshot(self) -> dict[Path, int]:
+ root = self.trainer_root / "logs" / "rsl_rl"
+ if not root.is_dir():
+ return {}
+ return {
+ path: path.stat().st_mtime_ns for path in root.glob("**/policy.onnx") if path.is_file()
+ }
+
+ def _find_artifact(self, before: dict[Path, int]) -> Path | None:
+ root = self.trainer_root / "logs" / "rsl_rl"
+ if not root.is_dir():
+ return None
+ changed = [
+ path
+ for path in root.glob("**/policy.onnx")
+ if path.is_file() and before.get(path) != path.stat().st_mtime_ns
+ ]
+ return max(changed, key=lambda path: path.stat().st_mtime_ns) if changed else None
+
+ def _run(self, job: TrainingJob) -> None:
+ before = self._artifact_snapshot()
+ command = self.command_for(job.config)
+ environment = os.environ.copy()
+ # 默认离线记录,保留本地 W&B 指标但不要求 API Key;只有前端明确选择
+ # online 时才允许 wandb 发起登录/联网。
+ environment["WANDB_MODE"] = job.config.wandb_mode
+ environment["WANDB_SILENT"] = "true"
+ try:
+ # Popen 与 process 登记必须和取消检查处于同一个临界区:cancel() 要么在
+ # 创建前标记取消,要么在创建后取得进程并终止,不能落入二者之间。
+ with self.lock:
+ if job.cancel_requested:
+ job.state, job.ended_at, job.message = "cancelled", now_iso(), "训练已取消"
+ return
+ process = subprocess.Popen(
+ command,
+ cwd=self.trainer_root,
+ env=environment,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ bufsize=1,
+ start_new_session=True,
+ )
+ job.process = process
+ job.state, job.started_at, job.message = (
+ "running",
+ now_iso(),
+ "本地训练进程已启动",
+ )
+ assert process.stdout is not None
+ try:
+ for line in process.stdout:
+ self._update_from_log(job, line)
+ finally:
+ process.stdout.close()
+ return_code = process.wait()
+ artifact = self._find_artifact(before)
+ with self.lock:
+ job.process = None
+ job.ended_at = now_iso()
+ if job.cancel_requested:
+ job.state, job.message = "cancelled", "训练已由用户取消"
+ elif return_code != 0:
+ job.state, job.message = "failed", f"训练进程退出,返回码 {return_code}"
+ elif artifact is None:
+ job.state, job.message = "failed", "训练结束,但没有找到本次生成的 policy.onnx"
+ else:
+ job.state, job.artifact = "succeeded", artifact
+ job.iteration = job.config.max_iterations
+ job.message = f"训练完成:{artifact.relative_to(self.trainer_root)}"
+ except Exception as error: # 服务必须保留错误供前端诊断。
+ with self.lock:
+ job.process = None
+ job.ended_at = now_iso()
+ job.state = "cancelled" if job.cancel_requested else "failed"
+ job.message = f"启动训练失败:{error}"
+ job.logs.append(job.message)
class TrainingRequestHandler(BaseHTTPRequestHandler):
- manager: TrainingManager
- allowed_origins: tuple[str, ...] = ()
- server_version = "MuJoCoLocalTraining/0.1"
+ manager: TrainingManager
+ allowed_origins: tuple[str, ...] = ()
+ access_token = ""
+ server_version = "MuJoCoLocalTraining/0.2"
- def log_message(self, format: str, *args: Any) -> None:
- sys.stderr.write(f"[{self.log_date_time_string()}] {format % args}\n")
+ def log_message(self, format: str, *args: Any) -> None:
+ sys.stderr.write(f"[{self.log_date_time_string()}] {format % args}\n")
- def _origin_allowed(self) -> bool:
- origin = self.headers.get("Origin")
- return origin is None or bool(LOCAL_ORIGIN.fullmatch(origin)) or origin in self.allowed_origins
+ def _origin_allowed(self) -> bool:
+ origin = self.headers.get("Origin")
+ return (
+ origin is None or bool(LOCAL_ORIGIN.fullmatch(origin)) or origin in self.allowed_origins
+ )
- def _cors(self) -> None:
- origin = self.headers.get("Origin")
- if origin and self._origin_allowed():
- self.send_header("Access-Control-Allow-Origin", origin)
- self.send_header("Vary", "Origin")
+ def _host_allowed(self) -> bool:
+ host = self.headers.get("Host", "")
+ return bool(LOCAL_HOST.fullmatch(host))
- def _json(self, status: int, payload: Any) -> None:
- body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
- self.send_response(status)
- self._cors()
- self.send_header("Content-Type", "application/json; charset=utf-8")
- self.send_header("Content-Length", str(len(body)))
- self.send_header("Cache-Control", "no-store")
- self.end_headers()
- self.wfile.write(body)
+ def _authorized(self) -> bool:
+ authorization = self.headers.get("Authorization", "")
+ prefix = "Bearer "
+ return authorization.startswith(prefix) and hmac.compare_digest(
+ authorization[len(prefix) :], self.access_token
+ )
- def _error(self, error: Exception) -> None:
- if isinstance(error, ApiError):
- self._json(error.status, {"error": str(error)})
- else:
- self._json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": f"本地训练服务内部错误:{error}"})
+ def _cors(self) -> None:
+ origin = self.headers.get("Origin")
+ if origin and self._origin_allowed():
+ self.send_header("Access-Control-Allow-Origin", origin)
+ self.send_header("Vary", "Origin")
- def _ensure_origin(self) -> None:
- if not self._origin_allowed():
- raise ApiError(HTTPStatus.FORBIDDEN, "不允许的浏览器来源")
-
- def _payload(self) -> Any:
- try:
- length = int(self.headers.get("Content-Length", "0"))
- except ValueError as error:
- raise ApiError(HTTPStatus.BAD_REQUEST, "Content-Length 无效") from error
- if length <= 0 or length > 32 * 1024:
- raise ApiError(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "训练请求体不能为空且不能超过 32 KiB")
- try:
- return json.loads(self.rfile.read(length))
- except (UnicodeDecodeError, json.JSONDecodeError) as error:
- raise ApiError(HTTPStatus.BAD_REQUEST, "训练请求不是有效 JSON") from error
-
- @staticmethod
- def _route(path: str) -> tuple[str | None, bool]:
- match = re.fullmatch(r"/api/training/jobs/([0-9a-f]{32})(/artifacts/policy\.onnx)?", path)
- return (unquote(match.group(1)), bool(match.group(2))) if match else (None, False)
-
- def do_OPTIONS(self) -> None:
- try:
- self._ensure_origin()
- self.send_response(HTTPStatus.NO_CONTENT)
- self._cors()
- self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
- self.send_header("Access-Control-Allow-Headers", "Content-Type")
- self.send_header("Access-Control-Max-Age", "600")
- self.end_headers()
- except Exception as error:
- self._error(error)
-
- def do_GET(self) -> None:
- try:
- self._ensure_origin()
- path = urlsplit(self.path).path
- if path == "/api/training/health":
- self._json(HTTPStatus.OK, self.manager.health())
- return
- job_id, artifact = self._route(path)
- if not job_id:
- raise ApiError(HTTPStatus.NOT_FOUND, "接口不存在")
- if artifact:
- file_path = self.manager.artifact(job_id)
- size = file_path.stat().st_size
- self.send_response(HTTPStatus.OK)
+ def _json(self, status: int, payload: Any) -> None:
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ self.send_response(status)
self._cors()
- self.send_header("Content-Type", "application/octet-stream")
- self.send_header("Content-Disposition", 'attachment; filename="policy.onnx"')
- self.send_header("Content-Length", str(size))
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
- with file_path.open("rb") as source:
- shutil.copyfileobj(source, self.wfile)
- else:
- self._json(HTTPStatus.OK, self.manager.get(job_id))
- except Exception as error:
- self._error(error)
+ self.wfile.write(body)
- def do_POST(self) -> None:
- try:
- self._ensure_origin()
- if urlsplit(self.path).path != "/api/training/jobs":
- raise ApiError(HTTPStatus.NOT_FOUND, "接口不存在")
- self._json(HTTPStatus.ACCEPTED, self.manager.start(self._payload()))
- except Exception as error:
- self._error(error)
+ def _error(self, error: Exception) -> None:
+ if isinstance(error, ApiError):
+ self._json(error.status, {"error": str(error)})
+ else:
+ self._json(
+ HTTPStatus.INTERNAL_SERVER_ERROR, {"error": f"本地训练服务内部错误:{error}"}
+ )
- def do_DELETE(self) -> None:
- try:
- self._ensure_origin()
- job_id, artifact = self._route(urlsplit(self.path).path)
- if not job_id or artifact:
- raise ApiError(HTTPStatus.NOT_FOUND, "接口不存在")
- self._json(HTTPStatus.ACCEPTED, self.manager.cancel(job_id))
- except Exception as error:
- self._error(error)
+ def _ensure_origin(self) -> None:
+ if not self._host_allowed():
+ raise ApiError(HTTPStatus.FORBIDDEN, "不允许的 Host")
+ if not self._origin_allowed():
+ raise ApiError(HTTPStatus.FORBIDDEN, "不允许的浏览器来源")
+
+ def _ensure_request(self) -> None:
+ self._ensure_origin()
+ if not self._authorized():
+ raise ApiError(HTTPStatus.UNAUTHORIZED, "训练服务访问令牌无效")
+
+ def _payload(self) -> Any:
+ try:
+ length = int(self.headers.get("Content-Length", "0"))
+ except ValueError as error:
+ raise ApiError(HTTPStatus.BAD_REQUEST, "Content-Length 无效") from error
+ if length <= 0 or length > 32 * 1024:
+ raise ApiError(
+ HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "训练请求体不能为空且不能超过 32 KiB"
+ )
+ try:
+ return json.loads(self.rfile.read(length))
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
+ raise ApiError(HTTPStatus.BAD_REQUEST, "训练请求不是有效 JSON") from error
+
+ @staticmethod
+ def _route(path: str) -> tuple[str | None, bool]:
+ match = re.fullmatch(r"/api/training/jobs/([0-9a-f]{32})(/artifacts/policy\.onnx)?", path)
+ return (unquote(match.group(1)), bool(match.group(2))) if match else (None, False)
+
+ def do_OPTIONS(self) -> None:
+ try:
+ self._ensure_origin()
+ self.send_response(HTTPStatus.NO_CONTENT)
+ self._cors()
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
+ self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type")
+ self.send_header("Access-Control-Max-Age", "600")
+ self.end_headers()
+ except Exception as error:
+ self._error(error)
+
+ def do_GET(self) -> None:
+ try:
+ self._ensure_request()
+ path = urlsplit(self.path).path
+ if path == "/api/training/health":
+ self._json(HTTPStatus.OK, self.manager.health())
+ return
+ job_id, artifact = self._route(path)
+ if not job_id:
+ raise ApiError(HTTPStatus.NOT_FOUND, "接口不存在")
+ if artifact:
+ file_path = self.manager.artifact(job_id)
+ size = file_path.stat().st_size
+ self.send_response(HTTPStatus.OK)
+ self._cors()
+ self.send_header("Content-Type", "application/octet-stream")
+ self.send_header("Content-Disposition", 'attachment; filename="policy.onnx"')
+ self.send_header("Content-Length", str(size))
+ self.send_header("Cache-Control", "no-store")
+ self.end_headers()
+ with file_path.open("rb") as source:
+ shutil.copyfileobj(source, self.wfile)
+ else:
+ self._json(HTTPStatus.OK, self.manager.get(job_id))
+ except Exception as error:
+ self._error(error)
+
+ def do_POST(self) -> None:
+ try:
+ self._ensure_request()
+ if urlsplit(self.path).path != "/api/training/jobs":
+ raise ApiError(HTTPStatus.NOT_FOUND, "接口不存在")
+ self._json(HTTPStatus.ACCEPTED, self.manager.start(self._payload()))
+ except Exception as error:
+ self._error(error)
+
+ def do_DELETE(self) -> None:
+ try:
+ self._ensure_request()
+ job_id, artifact = self._route(urlsplit(self.path).path)
+ if not job_id or artifact:
+ raise ApiError(HTTPStatus.NOT_FOUND, "接口不存在")
+ self._json(HTTPStatus.ACCEPTED, self.manager.cancel(job_id))
+ except Exception as error:
+ self._error(error)
def default_trainer_root() -> Path:
- configured = os.environ.get("UNITREE_RL_MJLAB_ROOT")
- if configured:
- return Path(configured)
- repository = Path(__file__).resolve().parents[2]
- return repository.parent.parent / "unitree_rl_mjlab"
+ configured = os.environ.get("UNITREE_RL_MJLAB_ROOT")
+ if configured:
+ return Path(configured)
+ repository = Path(__file__).resolve().parents[2]
+ return repository.parent.parent / "unitree_rl_mjlab"
def parse_args() -> argparse.Namespace:
- parser = argparse.ArgumentParser(description="MuJoCo Web 平台本地强化学习训练服务")
- parser.add_argument("--host", default="127.0.0.1", choices=("127.0.0.1", "localhost"), help="仅允许绑定本机回环地址")
- parser.add_argument("--port", type=int, default=8765)
- parser.add_argument("--trainer-root", type=Path, default=default_trainer_root(), help="unitree_rl_mjlab 工程目录")
- parser.add_argument("--trainer-python", default=sys.executable, help="已安装 mjlab/torch 的 Python 解释器")
- parser.add_argument("--task", action="append", dest="tasks", help="允许前端启动的任务 ID;可重复")
- parser.add_argument("--allow-origin", action="append", default=[], help="额外允许的前端 Origin;可重复")
- return parser.parse_args()
+ parser = argparse.ArgumentParser(description="MuJoCo Web 平台本地强化学习训练服务")
+ parser.add_argument(
+ "--host",
+ default="127.0.0.1",
+ choices=("127.0.0.1", "localhost"),
+ help="仅允许绑定本机回环地址",
+ )
+ parser.add_argument("--port", type=int, default=8765)
+ parser.add_argument(
+ "--trainer-root",
+ type=Path,
+ default=default_trainer_root(),
+ help="unitree_rl_mjlab 工程目录",
+ )
+ parser.add_argument(
+ "--trainer-python", default=sys.executable, help="已安装 mjlab/torch 的 Python 解释器"
+ )
+ parser.add_argument(
+ "--task", action="append", dest="tasks", help="允许前端启动的任务 ID;可重复"
+ )
+ parser.add_argument(
+ "--allow-origin", action="append", default=[], help="额外允许的前端 Origin;可重复"
+ )
+ parser.add_argument(
+ "--token",
+ default=os.environ.get("MUJOCO_TRAINING_TOKEN"),
+ help="访问令牌;默认随机生成,也可通过 MUJOCO_TRAINING_TOKEN 设置",
+ )
+ return parser.parse_args()
def main() -> None:
- args = parse_args()
- manager = TrainingManager(args.trainer_root, args.trainer_python, tuple(args.tasks or DEFAULT_TASKS))
- TrainingRequestHandler.manager = manager
- TrainingRequestHandler.allowed_origins = tuple(args.allow_origin)
- server = ThreadingHTTPServer((args.host, args.port), TrainingRequestHandler)
- print(f"本地训练服务:http://{args.host}:{args.port}")
- print(f"训练工程:{manager.trainer_root}")
- print(f"Python:{manager.python}")
- if manager.readiness_error():
- print(f"警告:{manager.readiness_error()}", file=sys.stderr)
- try:
- server.serve_forever()
- except KeyboardInterrupt:
- print("\n正在停止本地训练服务…")
- finally:
- active = manager.active_job_id()
- if active:
- manager.cancel(active)
- server.server_close()
+ args = parse_args()
+ token = args.token or secrets.token_urlsafe(24)
+ if len(token) < 16:
+ raise SystemExit("训练服务访问令牌至少需要 16 个字符")
+ manager = TrainingManager(
+ args.trainer_root, args.trainer_python, tuple(args.tasks or DEFAULT_TASKS)
+ )
+ TrainingRequestHandler.manager = manager
+ TrainingRequestHandler.allowed_origins = tuple(args.allow_origin)
+ TrainingRequestHandler.access_token = token
+ server = ThreadingHTTPServer((args.host, args.port), TrainingRequestHandler)
+ print(f"本地训练服务:http://{args.host}:{args.port}")
+ print(f"访问令牌:{token}")
+ print(f"训练工程:{manager.trainer_root}")
+ print(f"Python:{manager.python}")
+ if manager.readiness_error():
+ print(f"警告:{manager.readiness_error()}", file=sys.stderr)
+ signal.signal(signal.SIGTERM, termination_signal_handler)
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ print("\n正在停止本地训练服务…")
+ finally:
+ manager.shutdown()
+ server.server_close()
if __name__ == "__main__":
- main()
+ main()
diff --git a/training_server/tests/test_server.py b/training_server/tests/test_server.py
index ef2ad2c1..20800eb1 100644
--- a/training_server/tests/test_server.py
+++ b/training_server/tests/test_server.py
@@ -1,20 +1,30 @@
+import subprocess
import sys
import tempfile
+import threading
import time
import unittest
from pathlib import Path
+from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
-from server import ApiError, TrainingManager # noqa: E402
+from server import ( # noqa: E402
+ MAX_JOBS,
+ ApiError,
+ TrainingJob,
+ TrainingManager,
+ TrainingRequestHandler,
+ termination_signal_handler,
+)
class TrainingManagerTest(unittest.TestCase):
- def setUp(self):
- self.temporary = tempfile.TemporaryDirectory()
- self.root = Path(self.temporary.name)
- (self.root / "scripts").mkdir()
- (self.root / "scripts" / "train.py").write_text(
- """import os, pathlib, time
+ def setUp(self):
+ self.temporary = tempfile.TemporaryDirectory()
+ self.root = Path(self.temporary.name)
+ (self.root / "scripts").mkdir()
+ (self.root / "scripts" / "train.py").write_text(
+ """import os, pathlib, time
print('WANDB_MODE=' + os.environ.get('WANDB_MODE', ''), flush=True)
print('Learning iteration 1 / 2', flush=True)
time.sleep(0.02)
@@ -23,58 +33,162 @@ out=pathlib.Path('logs/rsl_rl/test/run/policy.onnx')
out.parent.mkdir(parents=True, exist_ok=True)
out.write_bytes(b'onnx')
""",
- encoding="utf-8",
- )
- self.manager = TrainingManager(self.root, sys.executable, ("Unitree-Go2-Flat",), check_environment=False)
+ encoding="utf-8",
+ )
+ self.manager = TrainingManager(
+ self.root, sys.executable, ("Unitree-Go2-Flat",), check_environment=False
+ )
- def tearDown(self):
- self.temporary.cleanup()
+ def tearDown(self):
+ self.temporary.cleanup()
- @staticmethod
- def payload(**patch):
- value = {
- "taskId": "Unitree-Go2-Flat",
- "numEnvs": 16,
- "maxIterations": 2,
- "seed": 42,
- "runName": "browser-test",
- "device": "cpu",
- "gpuIds": [],
- "wandbMode": "offline",
- }
- value.update(patch)
- return value
+ @staticmethod
+ def payload(**patch):
+ value = {
+ "taskId": "Unitree-Go2-Flat",
+ "numEnvs": 16,
+ "maxIterations": 2,
+ "seed": 42,
+ "runName": "browser-test",
+ "device": "cpu",
+ "gpuIds": [],
+ "wandbMode": "offline",
+ }
+ value.update(patch)
+ return value
- def test_validates_allowlist_and_limits(self):
- with self.assertRaises(ApiError):
- self.manager.parse_config(self.payload(taskId="shell injection"))
- with self.assertRaises(ApiError):
- self.manager.parse_config(self.payload(numEnvs=0))
- with self.assertRaises(ApiError):
- self.manager.parse_config(self.payload(runName="bad name"))
- with self.assertRaises(ApiError):
- self.manager.parse_config(self.payload(wandbMode="login"))
+ def test_validates_allowlist_and_limits(self):
+ with self.assertRaises(ApiError):
+ self.manager.parse_config(self.payload(taskId="shell injection"))
+ with self.assertRaises(ApiError):
+ self.manager.parse_config(self.payload(numEnvs=0))
+ with self.assertRaises(ApiError):
+ self.manager.parse_config(self.payload(runName="bad name"))
+ with self.assertRaises(ApiError):
+ self.manager.parse_config(self.payload(wandbMode="login"))
- def test_builds_argument_array_without_shell(self):
- config = self.manager.parse_config(self.payload(device="gpu", gpuIds=[0, 2]))
- command = self.manager.command_for(config)
- self.assertEqual(command[:4], [sys.executable, "-u", "scripts/train.py", "Unitree-Go2-Flat"])
- self.assertEqual(command[-2:], ["--gpu-ids", "[0,2]"])
+ def test_builds_argument_array_without_shell(self):
+ config = self.manager.parse_config(self.payload(device="gpu", gpuIds=[0, 2]))
+ command = self.manager.command_for(config)
+ self.assertEqual(
+ command[:4], [sys.executable, "-u", "scripts/train.py", "Unitree-Go2-Flat"]
+ )
+ self.assertEqual(command[-2:], ["--gpu-ids", "[0,2]"])
- def test_runs_job_and_exposes_new_onnx_artifact(self):
- job = self.manager.start(self.payload())
- deadline = time.monotonic() + 5
- while time.monotonic() < deadline:
- job = self.manager.get(job["id"])
- if job["state"] not in ("queued", "running"):
- break
- time.sleep(0.02)
- self.assertEqual(job["state"], "succeeded")
- self.assertEqual(job["iteration"], 2)
- self.assertIn("WANDB_MODE=offline", job["logs"])
- self.assertTrue(job["artifactReady"])
- self.assertEqual(self.manager.artifact(job["id"]).read_bytes(), b"onnx")
+ def test_requires_local_host_origin_and_bearer_token(self):
+ handler = object.__new__(TrainingRequestHandler)
+ handler.access_token = "secret-token-1234"
+ handler.allowed_origins = ()
+ handler.headers = {
+ "Host": "127.0.0.1:8765",
+ "Origin": "http://localhost:5173",
+ "Authorization": "Bearer secret-token-1234",
+ }
+ handler._ensure_request()
+ handler.headers["Authorization"] = "Bearer wrong-token"
+ with self.assertRaises(ApiError) as error:
+ handler._ensure_request()
+ self.assertEqual(error.exception.status, 401)
+ handler.headers["Authorization"] = "Bearer secret-token-1234"
+ handler.headers["Host"] = "attacker.example"
+ with self.assertRaises(ApiError) as error:
+ handler._ensure_request()
+ self.assertEqual(error.exception.status, 403)
+
+ def test_caps_completed_job_history(self):
+ config = self.manager.parse_config(self.payload())
+ for index in range(MAX_JOBS):
+ job_id = f"{index:032x}"
+ self.manager.jobs[job_id] = TrainingJob(
+ id=job_id,
+ config=config,
+ state="succeeded",
+ )
+ with patch("server.threading.Thread") as thread:
+ created = self.manager.start(self.payload())
+ self.assertEqual(len(self.manager.jobs), MAX_JOBS)
+ self.assertNotIn(f"{0:032x}", self.manager.jobs)
+ self.assertIn(created["id"], self.manager.jobs)
+ thread.return_value.start.assert_called_once()
+
+ def test_cancel_waits_until_starting_process_is_registered(self):
+ entered_popen = threading.Event()
+ release_popen = threading.Event()
+ terminated = threading.Event()
+
+ class FakeStdout:
+ def __iter__(self):
+ terminated.wait(2)
+ return iter(())
+
+ def close(self):
+ pass
+
+ class FakeProcess:
+ pid = 1234
+ stdout = FakeStdout()
+
+ @staticmethod
+ def poll():
+ return -15 if terminated.is_set() else None
+
+ @staticmethod
+ def wait(timeout=None):
+ if not terminated.wait(timeout):
+ raise subprocess.TimeoutExpired("fake-training", timeout)
+ return -15
+
+ def create_process(*_args, **_kwargs):
+ entered_popen.set()
+ self.assertTrue(release_popen.wait(2))
+ return FakeProcess()
+
+ config = self.manager.parse_config(self.payload())
+ job = TrainingJob(id="a" * 32, config=config)
+ self.manager.jobs[job.id] = job
+ runner = threading.Thread(target=self.manager._run, args=(job,))
+ cancel_done = threading.Event()
+
+ def cancel():
+ self.manager.cancel(job.id)
+ cancel_done.set()
+
+ with (
+ patch("server.subprocess.Popen", side_effect=create_process),
+ patch("server.os.killpg", side_effect=lambda *_args: terminated.set()) as killpg,
+ ):
+ runner.start()
+ self.assertTrue(entered_popen.wait(2))
+ canceller = threading.Thread(target=cancel)
+ canceller.start()
+ self.assertFalse(cancel_done.wait(0.05))
+ release_popen.set()
+ canceller.join(2)
+ runner.join(2)
+
+ self.assertFalse(runner.is_alive())
+ self.assertFalse(canceller.is_alive())
+ killpg.assert_called_once_with(FakeProcess.pid, 15)
+ self.assertEqual(self.manager.get(job.id)["state"], "cancelled")
+
+ def test_sigterm_enters_controlled_shutdown(self):
+ with self.assertRaises(KeyboardInterrupt):
+ termination_signal_handler(15, None)
+
+ def test_runs_job_and_exposes_new_onnx_artifact(self):
+ job = self.manager.start(self.payload())
+ deadline = time.monotonic() + 5
+ while time.monotonic() < deadline:
+ job = self.manager.get(job["id"])
+ if job["state"] not in ("queued", "running"):
+ break
+ time.sleep(0.02)
+ self.assertEqual(job["state"], "succeeded")
+ self.assertEqual(job["iteration"], 2)
+ self.assertIn("WANDB_MODE=offline", job["logs"])
+ self.assertTrue(job["artifactReady"])
+ self.assertEqual(self.manager.artifact(job["id"]).read_bytes(), b"onnx")
if __name__ == "__main__":
- unittest.main()
+ unittest.main()
diff --git a/web_platform/README.md b/web_platform/README.md
index d6e6d744..01f6c6d9 100644
--- a/web_platform/README.md
+++ b/web_platform/README.md
@@ -12,9 +12,11 @@
- Three.js primitive、mesh、材质/贴图显示与对象选择
- 播放、暂停、单步、重置、0.25×–4× 速度
- actuator 滑杆、hinge/slide 关节拖动、动态 body 外力拖拽
+- 内置平地、坡道、楼梯、可复现随机障碍物及 9 类系统参数化地形,可配置尺寸、摩擦、难度、种子与高度场采样精度
- 导入单文件 `.py` 控制器,通过本地 Pyodide 在 `mj_step` 前按仿真时间同步执行
- 导入 mjlab 导出的 `policy.onnx`,在浏览器本地执行 Go2-W 平衡/速度策略推理
- 从图形界面向本机训练桥接服务发起 mjlab 强化学习训练、查看进度/日志、停止任务并导入训练生成的 ONNX
+- 可配置仿真遥测记录,实时查看速度、机身姿态、位置、驱动力等指标并导出 CSV/JSON
- FPS、物理耗时和主线程步进预算提示
## 开发
@@ -61,6 +63,47 @@ 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”。
+
+## 数据记录
+
+加载模型后打开右侧“数据”标签,可以选择需要跟踪的 Body、采样频率和样本上限。内置通道包括世界系位置/速度、水平与三维速度、机身侧倾/俯仰/偏航角及角速度、累计里程、接触数、控制输入 RMS、驱动力 RMS、绝对驱动功率和广义速度 RMS。仿真重置不会删除已有数据,而是创建新分段,避免跨重置计算出错误速度;切换记录 Body 或采样配置会清空不兼容的旧数据。
+
+记录只保留在当前浏览器会话中,达到样本上限后自动停止,可导出带稳定列名的 CSV 或包含通道元数据、摘要和样本的 Schema V1 JSON。`SimulationSession`/`PhysicsAdapter` 保留 `configureDataRecorder`、`startDataRecording`、`stopDataRecording`、`clearDataRecording`、`exportDataRecording` 接口;还可通过 `registerDataChannel({ key, label, unit, read })` 在开始记录前注册业务自定义标量通道。数据源通过 `TelemetrySource` 抽象与 MuJoCo 解耦,后续可复用于 Worker 或远端仿真。
+
## 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`。
@@ -77,9 +120,9 @@ npm run training-server -- \
--trainer-python /path/to/training-env/bin/python
```
-界面默认连接 `http://127.0.0.1:8765`,可选择服务端允许的任务、并行环境数、训练迭代、随机种子、CPU/GPU、GPU 编号和实验记录方式。W&B 默认为本地离线模式,无需登录或 API Key;也可完全禁用,只有明确选择在线模式时才会联网登录。训练期间页面轮询迭代进度与最近日志,可以停止任务;训练成功后点击“导入策略”,生成的 `policy.onnx` 会进入现有 ONNX 加载流程。
+服务启动时会在终端输出一个随机访问令牌;在界面中填写该令牌后连接。令牌仅保存在当前标签页的 `sessionStorage`。界面默认连接 `http://127.0.0.1:8765`,可选择服务端允许的任务、并行环境数、训练迭代、随机种子、CPU/GPU、GPU 编号和实验记录方式。W&B 默认为本地离线模式,无需登录或 API Key;也可完全禁用,只有明确选择在线模式时才会联网登录。训练期间页面轮询迭代进度与最近日志,可以停止任务;训练成功后点击“导入策略”,生成的 `policy.onnx` 会进入现有 ONNX 加载流程。
-桥接服务只监听本机回环地址、仅接受允许列表中的任务和经过范围校验的参数,不执行前端提供的 Shell 命令;一次只运行一个训练进程。当前任务使用 `unitree_rl_mjlab` 自带的机器人资产与环境配置,**不会自动把浏览器中临时编辑的 MJCF/URDF 作为训练环境**。自定义浏览器模型训练需要先在 mjlab 中注册对应 task。服务配置、接口和安全边界见 [`../training_server/README.md`](../training_server/README.md)。
+桥接服务只监听本机回环地址,并检查 Host、Origin 和 Bearer Token;仅接受允许列表中的任务和经过范围校验的参数,不执行前端提供的 Shell 命令;一次只运行一个训练进程。当前任务使用 `unitree_rl_mjlab` 自带的机器人资产与环境配置,**不会自动把浏览器中临时编辑的 MJCF/URDF 作为训练环境**。自定义浏览器模型训练需要先在 mjlab 中注册对应 task。服务配置、接口和安全边界见 [`../training_server/README.md`](../training_server/README.md)。
## ONNX 强化学习策略
diff --git a/web_platform/e2e/app.spec.ts b/web_platform/e2e/app.spec.ts
index d3e7bded..3c067464 100644
--- a/web_platform/e2e/app.spec.ts
+++ b/web_platform/e2e/app.spec.ts
@@ -1,9 +1,10 @@
-import {expect, test} from '@playwright/test';
-import {readFileSync} from 'node:fs';
-import {fileURLToPath} from 'node:url';
-import {zipSync} from 'fflate';
+import { expect, test } from '@playwright/test';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { zipSync } from 'fflate';
-const fixture = (relative: string) => fileURLToPath(new URL(`../fixtures/${relative}`, import.meta.url));
+const fixture = (relative: string) =>
+ fileURLToPath(new URL(`../fixtures/${relative}`, import.meta.url));
const SIMPLE_MODEL = `
@@ -19,7 +20,47 @@ const SIMPLE_MODEL = `
`;
-const SLIDE_DIRECTION_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 = `
@@ -36,238 +77,670 @@ const LARGE_MODEL = `
`;
-test('显示中文平台骨架并加载单文件模型', async ({page}) => {
+test('显示中文平台骨架并加载单文件模型', async ({ page }) => {
page.on('console', (message) => console.log(`[browser:${message.type()}] ${message.text()}`));
page.on('pageerror', (error) => console.log(`[browser:error] ${error.message}`));
- page.on('requestfailed', (request) => console.log(`[browser:requestfailed] ${request.url()} ${request.failure()?.errorText}`));
- page.on('response', (response) => { if (response.status() >= 400 || response.url().endsWith('.wasm')) console.log(`[browser:response] ${response.status()} ${response.url()} ${response.headers()['content-type'] ?? ''}`); });
+ page.on('requestfailed', (request) =>
+ console.log(`[browser:requestfailed] ${request.url()} ${request.failure()?.errorText}`),
+ );
+ page.on('response', (response) => {
+ if (response.status() >= 400 || response.url().endsWith('.wasm'))
+ console.log(
+ `[browser:response] ${response.status()} ${response.url()} ${response.headers()['content-type'] ?? ''}`,
+ );
+ });
await page.goto('/');
- await page.setViewportSize({width:1024,height:768});
- const resetCameraBox=await page.getByRole('button',{name:'相机复位'}).boundingBox(),playBox=await page.getByRole('button',{name:'▶ 播放'}).boundingBox();
- expect(resetCameraBox&&playBox&&resetCameraBox.x+resetCameraBox.width<=playBox.x).toBeTruthy();
- await page.getByRole('button',{name:'更多工作台操作'}).click();await expect(page.getByRole('menuitem',{name:'工作台设置'})).toBeVisible();await page.keyboard.press('Escape');
- await page.setViewportSize({width:1440,height:900});
- await expect(page.getByRole('heading', {name: 'MuJoCo Web 仿真平台'})).toBeVisible();
+ await page.setViewportSize({ width: 1024, height: 768 });
+ const resetCameraBox = await page.getByRole('button', { name: '相机复位' }).boundingBox(),
+ playBox = await page.getByRole('button', { name: '▶ 播放' }).boundingBox();
+ expect(
+ resetCameraBox && playBox && resetCameraBox.x + resetCameraBox.width <= playBox.x,
+ ).toBeTruthy();
+ await page.getByRole('button', { name: '更多工作台操作' }).click();
+ await expect(page.getByRole('menuitem', { name: '工作台设置' })).toBeVisible();
+ await page.keyboard.press('Escape');
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await expect(page.getByRole('heading', { name: 'MuJoCo Web 仿真平台' })).toBeVisible();
await expect(page.getByRole('main').getByText('拖放模型工程到此处')).toBeVisible();
- await expect(page.getByRole('img',{name:'XYZ 方向指示器'})).toBeVisible();
- await expect(page.getByRole('button',{name:'切换到白天主题'})).toBeVisible();
- await page.getByRole('button',{name:'布局设置'}).click();await expect(page.getByRole('dialog',{name:'布局设置'})).toBeVisible();await page.keyboard.press('Escape');
- await page.getByRole('button',{name:'工作台设置'}).click();await expect(page.getByRole('dialog',{name:'工作台设置'})).toBeVisible();await page.keyboard.press('Escape');
+ await expect(page.getByRole('img', { name: 'XYZ 方向指示器' })).toBeVisible();
+ await expect(page.getByRole('button', { name: '切换到白天主题' })).toBeVisible();
+ await page.getByRole('button', { name: '布局设置' }).click();
+ await expect(page.getByRole('dialog', { name: '布局设置' })).toBeVisible();
+ await page.keyboard.press('Escape');
+ await page.getByRole('button', { name: '工作台设置' }).click();
+ await expect(page.getByRole('dialog', { name: '工作台设置' })).toBeVisible();
+ await page.keyboard.press('Escape');
await page.keyboard.press('Control+k');
- await expect(page.getByRole('dialog',{name:'命令面板'})).toBeVisible();
+ await expect(page.getByRole('dialog', { name: '命令面板' })).toBeVisible();
await page.getByLabel('搜索命令').fill('复位相机');
- await expect(page.getByRole('option',{name:/复位相机/})).toBeVisible();
+ await expect(page.getByRole('option', { name: /复位相机/ })).toBeVisible();
await page.keyboard.press('Escape');
- await page.getByRole('button',{name:'进入全屏'}).click();
- await expect(page.getByRole('button',{name:'退出全屏'})).toBeVisible();
+ await page.getByRole('button', { name: '进入全屏' }).click();
+ await expect(page.getByRole('button', { name: '退出全屏' })).toBeVisible();
await page.keyboard.press('Control+k');
- await expect(page.getByRole('dialog',{name:'命令面板'})).toBeVisible();
+ await expect(page.getByRole('dialog', { name: '命令面板' })).toBeVisible();
await page.keyboard.press('Escape');
- await page.getByRole('button',{name:'退出全屏'}).click();
- await page.getByRole('button',{name:'切换到白天主题'}).click();
+ await page.getByRole('button', { name: '退出全屏' }).click();
+ await page.getByRole('button', { name: '切换到白天主题' }).click();
await expect(page.locator('#root > div')).toHaveClass(/theme-light/);
- await expect(page.getByRole('button',{name:'切换到黑夜主题'})).toBeVisible();
- await page.getByRole('button',{name:'切换到黑夜主题'}).click();
+ await expect(page.getByRole('button', { name: '切换到黑夜主题' })).toBeVisible();
+ await page.getByRole('button', { name: '切换到黑夜主题' }).click();
await expect(page.locator('#root > div')).toHaveClass(/theme-dark/);
- await page.locator('input[type="file"]').first().setInputFiles({
- name: 'model.xml',
- mimeType: 'text/xml',
- buffer: Buffer.from(SIMPLE_MODEL),
- });
+ await page
+ .locator('input[type="file"]')
+ .first()
+ .setInputFiles({
+ name: 'model.xml',
+ mimeType: 'text/xml',
+ buffer: Buffer.from(SIMPLE_MODEL),
+ });
- await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000});
- await page.getByRole('button',{name:'显示设置'}).click();
- const displayDialog=page.getByRole('dialog',{name:'视图显示设置'});await expect(displayDialog).toBeVisible();await expect(displayDialog.getByRole('switch')).toHaveCount(7);
- const centerOfMassSwitch=displayDialog.getByRole('switch',{name:/^质心/});await centerOfMassSwitch.click();await expect(centerOfMassSwitch).toHaveAttribute('aria-checked','true');await page.keyboard.press('Escape');await expect(displayDialog).toBeHidden();
- await page.getByRole('button',{name:'通知中心'}).click();await expect(page.getByRole('dialog',{name:'通知中心'})).toContainText('模型加载完成');await page.getByText('事件日志').click();await expect(page.getByRole('dialog',{name:'诊断与事件日志'})).toBeVisible();await page.keyboard.press('Escape');
- await page.getByRole('button',{name:/FPS .*物理/}).click();
- await expect(page.getByRole('dialog',{name:'性能详情'})).toBeVisible();
+ await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
+ await page.getByRole('button', { name: '显示设置' }).click();
+ const displayDialog = page.getByRole('dialog', { name: '视图显示设置' });
+ await expect(displayDialog).toBeVisible();
+ await expect(displayDialog.getByRole('switch')).toHaveCount(7);
+ const centerOfMassSwitch = displayDialog.getByRole('switch', { name: /^质心/ });
+ await centerOfMassSwitch.click();
+ await expect(centerOfMassSwitch).toHaveAttribute('aria-checked', 'true');
await page.keyboard.press('Escape');
- await page.getByRole('tab',{name:'控制'}).click();
- await page.getByRole('button',{name:'Actuator'}).click();
- await expect(page.getByText('motor',{exact:true})).toBeVisible();
- await expect(page.getByRole('tabpanel',{name:'控制'}).getByText('slide',{exact:true})).toBeVisible();
- await page.getByRole('tab',{name:'模型结构'}).click();
- const structure=page.getByRole('navigation',{name:'模型结构树'});await expect(structure).toBeVisible();await structure.getByRole('treeitem',{name:/hinge/}).hover();
+ await expect(displayDialog).toBeHidden();
+ await page.getByRole('button', { name: '通知中心' }).click();
+ await expect(page.getByRole('dialog', { name: '通知中心' })).toContainText('模型加载完成');
+ await page.getByText('事件日志').click();
+ await expect(page.getByRole('dialog', { name: '诊断与事件日志' })).toBeVisible();
+ await page.keyboard.press('Escape');
+ await page.getByRole('button', { name: /FPS .*物理/ }).click();
+ await expect(page.getByRole('dialog', { name: '性能详情' })).toBeVisible();
+ await page.keyboard.press('Escape');
+ await page.getByRole('tab', { name: '控制' }).click();
+ await page.getByRole('button', { name: 'Actuator' }).click();
+ await expect(page.getByText('motor', { exact: true })).toBeVisible();
+ await expect(
+ page.getByRole('tabpanel', { name: '控制' }).getByText('slide', { exact: true }),
+ ).toBeVisible();
+ await page.getByRole('tab', { name: '模型结构' }).click();
+ const structure = page.getByRole('navigation', { name: '模型结构树' });
+ await expect(structure).toBeVisible();
+ await structure.getByRole('treeitem', { name: /hinge/ }).hover();
await expect(page.getByRole('alert')).toHaveCount(0);
- await expect(page.getByRole('button',{name:'重置关节'})).toBeVisible();
- await page.getByRole('button',{name:'高级'}).click();
+ await expect(page.getByRole('button', { name: '重置关节' })).toBeVisible();
+ await page.getByRole('button', { name: '高级' }).click();
await expect(page.getByText('下限 -1.571 rad')).toBeVisible();
await expect(page.getByText('上限 1.571 rad')).toBeVisible();
- await page.getByRole('button',{name:'rad 弧度制'}).click();
+ await page.getByRole('button', { name: 'rad 弧度制' }).click();
await expect(page.getByText('下限 -90.000°')).toBeVisible();
await expect(page.getByText('上限 90.000°')).toBeVisible();
- await page.getByRole('button',{name:'忽略关节限位'}).click();
- await expect(page.getByRole('button',{name:'忽略关节限位'})).toHaveAttribute('aria-pressed','true');
+ await page.getByRole('button', { name: '忽略关节限位' }).click();
+ await expect(page.getByRole('button', { name: '忽略关节限位' })).toHaveAttribute(
+ 'aria-pressed',
+ 'true',
+ );
await expect(page.getByText('已忽略').first()).toBeVisible();
- await page.getByRole('button',{name:'重置关节'}).click();
- await expect(page.getByRole('button',{name:'▶ 播放'})).toBeVisible();
+ await page.getByRole('button', { name: '重置关节' }).click();
+ await expect(page.getByRole('button', { name: '▶ 播放' })).toBeVisible();
});
-test('窄视口默认保留完整视口并可按需打开侧栏',async({page})=>{
- await page.setViewportSize({width:800,height:700});
+test('窄视口默认保留完整视口并可按需打开侧栏', async ({ page }) => {
+ await page.setViewportSize({ width: 800, height: 700 });
await page.goto('/');
await expect(page.getByRole('main')).toBeInViewport();
- await expect(page.getByRole('button',{name:'显示工程面板'})).toBeVisible();
- await expect(page.getByRole('button',{name:'显示属性面板'})).toBeVisible();
- await page.getByRole('button',{name:'显示属性面板'}).click();
- await expect(page.getByRole('complementary').filter({hasText:'导入模型后显示属性'})).toBeVisible();
+ await expect(page.getByRole('button', { name: '显示工程面板' })).toBeVisible();
+ await expect(page.getByRole('button', { name: '显示属性面板' })).toBeVisible();
+ await page.getByRole('button', { name: '显示属性面板' }).click();
+ await expect(
+ page.getByRole('complementary').filter({ hasText: '导入模型后显示属性' }),
+ ).toBeVisible();
});
-test('工作区布局与视口显示偏好在刷新后保留',async({page})=>{
- await page.setViewportSize({width:1440,height:900});
+test('工作区布局与视口显示偏好在刷新后保留', async ({ page }) => {
+ await page.setViewportSize({ width: 1440, height: 900 });
await page.goto('/');
- await page.getByRole('button',{name:'隐藏工程面板'}).click();
- await page.getByRole('button',{name:'显示设置'}).click();
- await page.getByRole('switch',{name:/^坐标系/}).click();
+ await page.getByRole('button', { name: '隐藏工程面板' }).click();
+ await page.getByRole('button', { name: '显示设置' }).click();
+ await page.getByRole('switch', { name: /^坐标系/ }).click();
await page.reload();
- await expect(page.getByRole('button',{name:'显示工程面板'})).toBeVisible();
- await page.getByRole('button',{name:'显示设置'}).click();
- await expect(page.getByRole('switch',{name:/^坐标系/})).toHaveAttribute('aria-checked','true');
+ await expect(page.getByRole('button', { name: '显示工程面板' })).toBeVisible();
+ await page.getByRole('button', { name: '显示设置' }).click();
+ await expect(page.getByRole('switch', { name: /^坐标系/ })).toHaveAttribute(
+ 'aria-checked',
+ 'true',
+ );
});
-test('转换后的 MJCF 可编辑并重新载入', async ({page}) => {
+test('转换后的 MJCF 可编辑并重新载入', 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('WASM 已加载')).toBeVisible({timeout:30_000});
- await page.getByRole('button',{name:'源代码'}).click();
- const dialog=page.getByRole('dialog',{name:'转换后的 MJCF 编辑器'});await expect(dialog).toBeVisible();
+ await page
+ .locator('input[type="file"]')
+ .first()
+ .setInputFiles({ name: 'model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL) });
+ await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
+ await page.getByRole('button', { name: '源代码' }).click();
+ const dialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
+ await expect(dialog).toBeVisible();
await expect(dialog.getByText('缓存文件 · 可编辑')).toBeVisible();
- await expect(dialog.getByText('转换后的 MJCF',{exact:true})).toBeVisible();
- const editor=dialog.locator('.monaco-editor');await editor.click({position:{x:240,y:120}});
- await page.keyboard.press('Control+a');await page.keyboard.insertText(SIMPLE_MODEL.replace('model="e2e"','model="cached-edit"'));
- await dialog.getByRole('button',{name:'保存并重新载入',exact:true}).click();
- await expect(dialog.getByRole('button',{name:'保存并重新载入',exact:true})).toBeDisabled({timeout:30_000});
- await dialog.getByRole('button',{name:'关闭源代码编辑器'}).click();
- await expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000});
- await expect(page.getByRole('button',{name:'导出 URDF'})).toHaveCount(0);
- await expect(page.getByRole('button',{name:'导出 MJCF'})).toHaveCount(0);
+ await expect(dialog.getByText('转换后的 MJCF', { exact: true })).toBeVisible();
+ const editor = dialog.locator('.monaco-editor');
+ await editor.click({ position: { x: 240, y: 120 } });
+ await page.keyboard.press('Control+a');
+ await page.keyboard.insertText(SIMPLE_MODEL.replace('model="e2e"', 'model="cached-edit"'));
+ await dialog.getByRole('button', { name: '保存并重新载入', exact: true }).click();
+ await expect(dialog.getByRole('button', { name: '保存并重新载入', exact: true })).toBeDisabled({
+ timeout: 30_000,
+ });
+ await dialog.getByRole('button', { name: '关闭源代码编辑器' }).click();
+ await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
+ await expect(page.getByRole('button', { name: '导出 URDF' })).toHaveCount(0);
+ await expect(page.getByRole('button', { name: '导出 MJCF' })).toHaveCount(0);
});
-test('关闭已修改的 MJCF 前要求确认',async({page})=>{
+test('关闭已修改的 MJCF 前要求确认', 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('WASM 已加载')).toBeVisible({timeout:30_000});
- await page.getByRole('button',{name:'源代码'}).click();
- const editorDialog=page.getByRole('dialog',{name:'转换后的 MJCF 编辑器'});
- await editorDialog.locator('.monaco-editor').click({position:{x:240,y:120}});
- await page.keyboard.press('Control+End');await page.keyboard.insertText('\n');
- await editorDialog.getByRole('button',{name:'关闭源代码编辑器'}).click();
- const confirm=page.getByRole('dialog',{name:'放弃未保存的修改?'});
+ await page
+ .locator('input[type="file"]')
+ .first()
+ .setInputFiles({ name: 'model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL) });
+ await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
+ await page.getByRole('button', { name: '源代码' }).click();
+ const editorDialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
+ await editorDialog.locator('.monaco-editor').click({ position: { x: 240, y: 120 } });
+ await page.keyboard.press('Control+End');
+ await page.keyboard.insertText('\n');
+ await editorDialog.getByRole('button', { name: '关闭源代码编辑器' }).click();
+ const confirm = page.getByRole('dialog', { name: '放弃未保存的修改?' });
await expect(confirm).toBeVisible();
- await confirm.getByRole('button',{name:'继续编辑'}).click();
+ await confirm.getByRole('button', { name: '继续编辑' }).click();
await expect(editorDialog).toBeVisible();
- await editorDialog.getByRole('button',{name:'关闭源代码编辑器'}).click();
- await page.getByRole('dialog',{name:'放弃未保存的修改?'}).getByRole('button',{name:'放弃修改'}).click();
+ await editorDialog.getByRole('button', { name: '关闭源代码编辑器' }).click();
+ await page
+ .getByRole('dialog', { name: '放弃未保存的修改?' })
+ .getByRole('button', { name: '放弃修改' })
+ .click();
await expect(editorDialog).toHaveCount(0);
});
-test('加载包含 include、OBJ、STL 与 PNG 的工程', async ({page}) => {
+test('加载包含 include、OBJ、STL 与 PNG 的工程', async ({ page }) => {
await page.goto('/');
- await page.locator('input[type="file"]').first().setInputFiles([
- fixture('mjcf_include/model.xml'),
- fixture('mjcf_include/world.xml'),
- fixture('mjcf_include/triangle.obj'),
- fixture('mjcf_include/triangle.stl'),
- fixture('mjcf_include/checker.png'),
- ]);
- await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000});
+ await page
+ .locator('input[type="file"]')
+ .first()
+ .setInputFiles([
+ fixture('mjcf_include/model.xml'),
+ fixture('mjcf_include/world.xml'),
+ fixture('mjcf_include/triangle.obj'),
+ fixture('mjcf_include/triangle.stl'),
+ fixture('mjcf_include/checker.png'),
+ ]);
+ await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await expect(page.getByText('5 个文件')).toBeVisible();
});
-test('加载引用 OBJ 的 URDF 工程', async ({page}) => {
+test('加载引用 OBJ 的 URDF 工程', async ({ page }) => {
await page.goto('/');
- await page.locator('input[type="file"]').first().setInputFiles([
- fixture('urdf_mesh/robot.urdf'),
- fixture('urdf_mesh/triangle.obj'),
- ]);
- const options=page.getByRole('dialog',{name:'配置 URDF 仿真组件'});
- await expect(options.getByRole('checkbox',{name:/为关节添加驱动器/})).toBeChecked();
- await expect(options.getByRole('checkbox',{name:/添加传感器/})).toBeChecked();
- await options.getByRole('button',{name:'转换并加载'}).click();
- await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000});
+ await page
+ .locator('input[type="file"]')
+ .first()
+ .setInputFiles([fixture('urdf_mesh/robot.urdf'), fixture('urdf_mesh/triangle.obj')]);
+ const options = page.getByRole('dialog', { name: '配置 URDF 仿真组件' });
+ await expect(options.getByRole('checkbox', { name: /为关节添加驱动器/ })).toBeChecked();
+ await expect(options.getByRole('checkbox', { name: /添加传感器/ })).toBeChecked();
+ await options.getByRole('button', { name: '转换并加载' }).click();
+ await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await expect(page.getByText('2 个文件')).toBeVisible();
- await page.getByRole('button',{name:'URDF 处理方式'}).click();
+ await page.getByRole('button', { name: 'URDF 处理方式' }).click();
await expect(page.getByLabel('URDF 处理方式')).toHaveValue('mjcf');
await expect(page.getByLabel('URDF 基座类型')).toHaveValue('floating');
- await page.getByRole('button',{name:'通知中心'}).click();
- const notifications=page.getByRole('dialog',{name:'通知中心'});
+ await page.getByRole('button', { name: '通知中心' }).click();
+ const notifications = page.getByRole('dialog', { name: '通知中心' });
await expect(notifications).toContainText(/模型已加载 · \d+ 项兼容调整/);
await expect(notifications).toContainText(/URDF 已转换为 MJCF(浮动基座),并整体平移/);
await page.keyboard.press('Escape');
await expect(page.getByLabel('显示碰撞几何')).not.toBeChecked();
- await page.getByRole('button',{name:'源代码'}).click();
- const sourceDialog=page.getByRole('dialog',{name:'转换后的 MJCF 编辑器'});await expect(sourceDialog.getByText('缓存文件 · 可编辑')).toBeVisible();
- await sourceDialog.locator('.monaco-editor').click({position:{x:240,y:120}});await page.keyboard.press('Control+End');await page.keyboard.insertText('\n');
- await sourceDialog.getByRole('button',{name:'保存并重新载入',exact:true}).click();await expect(sourceDialog.getByRole('button',{name:'保存并重新载入',exact:true})).toBeDisabled({timeout:30_000});
- await expect(page.getByText('WASM 已加载')).toBeVisible();await sourceDialog.getByRole('button',{name:'关闭源代码编辑器'}).click();
+ await page.getByRole('button', { name: '源代码' }).click();
+ const sourceDialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
+ await expect(sourceDialog.getByText('缓存文件 · 可编辑')).toBeVisible();
+ await sourceDialog.locator('.monaco-editor').click({ position: { x: 240, y: 120 } });
+ await page.keyboard.press('Control+End');
+ await page.keyboard.insertText('\n');
+ await sourceDialog.getByRole('button', { name: '保存并重新载入', exact: true }).click();
+ await expect(
+ sourceDialog.getByRole('button', { name: '保存并重新载入', exact: true }),
+ ).toBeDisabled({ timeout: 30_000 });
+ await expect(page.getByText('WASM 已加载')).toBeVisible();
+ await sourceDialog.getByRole('button', { name: '关闭源代码编辑器' }).click();
});
-test('URDF 自动生成的关节驱动器与摄像头可通过 MuJoCo 编译',async({page})=>{
- const urdf=``;
- await page.goto('/');await page.locator('input[type="file"]').first().setInputFiles({name:'jointed.urdf',mimeType:'application/xml',buffer:Buffer.from(urdf)});
- await page.getByRole('dialog',{name:'配置 URDF 仿真组件'}).getByRole('button',{name:'转换并加载'}).click();
- await expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000});
- await expect(page.getByLabel('摄像头画面')).toBeVisible();await page.getByRole('button',{name:'隐藏画面'}).click();await page.getByRole('button',{name:'显示摄像头画面'}).click();await expect(page.getByLabel('摄像头画面')).toBeVisible();
- await page.getByRole('button',{name:'通知中心'}).click();
- const notifications=page.getByRole('dialog',{name:'通知中心'});
+test('URDF 自动生成的关节驱动器与摄像头可通过 MuJoCo 编译', async ({ page }) => {
+ const urdf = ``;
+ await page.goto('/');
+ await page
+ .locator('input[type="file"]')
+ .first()
+ .setInputFiles({
+ name: 'jointed.urdf',
+ mimeType: 'application/xml',
+ buffer: Buffer.from(urdf),
+ });
+ await page
+ .getByRole('dialog', { name: '配置 URDF 仿真组件' })
+ .getByRole('button', { name: '转换并加载' })
+ .click();
+ await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
+ await expect(page.getByLabel('摄像头画面')).toBeVisible();
+ await page.getByRole('button', { name: '隐藏画面' }).click();
+ await page.getByRole('button', { name: '显示摄像头画面' }).click();
+ await expect(page.getByLabel('摄像头画面')).toBeVisible();
+ await page.getByRole('button', { name: '通知中心' }).click();
+ const notifications = page.getByRole('dialog', { name: '通知中心' });
await expect(notifications).toContainText('已为 1 个 hinge/slide 关节生成 motor 驱动器');
await expect(notifications).toContainText('已将 640×480 摄像头固连到 arm');
- await page.keyboard.press('Escape');await page.getByRole('tab',{name:'控制'}).click();await page.getByRole('button',{name:'Actuator'}).click();
- await expect(page.getByText('shoulder_motor')).toBeVisible();await expect(page.getByText('关节:shoulder')).toBeVisible();await expect(page.getByText('N·m',{exact:true})).toBeVisible();
- await page.getByText('常用参数').click();const kp=page.getByLabel(/kp(MJCF stiffness/),kv=page.getByLabel(/kv(MJCF damping/);await kp.fill('150');await kp.press('Enter');await kv.fill('15');await kv.press('Enter');await expect(kp).toHaveValue('150');await expect(kv).toHaveValue('15');
+ await page.keyboard.press('Escape');
+ await page.getByRole('tab', { name: '控制' }).click();
+ await page.getByRole('button', { name: 'Actuator' }).click();
+ await expect(page.getByText('shoulder_motor')).toBeVisible();
+ await expect(page.getByText('关节:shoulder')).toBeVisible();
+ await expect(page.getByText('N·m', { exact: true })).toBeVisible();
+ await page.getByText('常用参数').click();
+ const kp = page.getByLabel(/kp(MJCF stiffness/),
+ kv = page.getByLabel(/kv(MJCF damping/);
+ await kp.fill('150');
+ await kp.press('Enter');
+ await kv.fill('15');
+ await kv.press('Enter');
+ await expect(kp).toHaveValue('150');
+ await expect(kv).toHaveValue('15');
});
-test('转换后的 MJCF 保存时保留 DAE 转换缓存资源',async({page})=>{
- const zip=zipSync({'robot/urdf/robot.urdf':new Uint8Array(readFileSync(fixture('urdf_dae/robot/urdf/robot.urdf'))),'robot/dae/triangle.dae':new Uint8Array(readFileSync(fixture('urdf_dae/robot/dae/triangle.dae')))});
- await page.goto('/');await page.locator('input[type="file"]').first().setInputFiles({name:'dae.zip',mimeType:'application/zip',buffer:Buffer.from(zip)});await page.getByRole('dialog',{name:'配置 URDF 仿真组件'}).getByRole('button',{name:'转换并加载'}).click();await expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000});
- await page.getByRole('button',{name:'源代码'}).click();const dialog=page.getByRole('dialog',{name:'转换后的 MJCF 编辑器'});await dialog.locator('.monaco-editor').click({position:{x:240,y:120}});await page.keyboard.press('Control+End');await page.keyboard.insertText('\n');await dialog.getByRole('button',{name:'保存并重新载入',exact:true}).click();await expect(dialog.getByRole('button',{name:'保存并重新载入',exact:true})).toBeDisabled({timeout:30_000});await expect(page.getByText('WASM 已加载')).toBeVisible();await expect(page.getByText('模型编译失败')).toHaveCount(0);
+test('转换后的 MJCF 保存时保留 DAE 转换缓存资源', async ({ page }) => {
+ const zip = zipSync({
+ 'robot/urdf/robot.urdf': new Uint8Array(
+ readFileSync(fixture('urdf_dae/robot/urdf/robot.urdf')),
+ ),
+ 'robot/dae/triangle.dae': new Uint8Array(
+ readFileSync(fixture('urdf_dae/robot/dae/triangle.dae')),
+ ),
+ });
+ await page.goto('/');
+ await page
+ .locator('input[type="file"]')
+ .first()
+ .setInputFiles({ name: 'dae.zip', mimeType: 'application/zip', buffer: Buffer.from(zip) });
+ await page
+ .getByRole('dialog', { name: '配置 URDF 仿真组件' })
+ .getByRole('button', { name: '转换并加载' })
+ .click();
+ await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
+ await page.getByRole('button', { name: '源代码' }).click();
+ const dialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
+ await dialog.locator('.monaco-editor').click({ position: { x: 240, y: 120 } });
+ await page.keyboard.press('Control+End');
+ await page.keyboard.insertText('\n');
+ await dialog.getByRole('button', { name: '保存并重新载入', exact: true }).click();
+ await expect(dialog.getByRole('button', { name: '保存并重新载入', exact: true })).toBeDisabled({
+ timeout: 30_000,
+ });
+ await expect(page.getByText('WASM 已加载')).toBeVisible();
+ await expect(page.getByText('模型编译失败')).toHaveCount(0);
});
-test('slide 关节向屏幕轴正方向拖动时 qpos 同向增加',async({page})=>{
- await page.goto('/');await page.locator('input[type="file"]').first().setInputFiles({name:'slide.xml',mimeType:'text/xml',buffer:Buffer.from(SLIDE_DIRECTION_MODEL)});await expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000});
- await page.getByRole('button',{name:'关节拖动'}).click();const canvas=page.locator('main canvas').first(),box=await canvas.boundingBox();expect(box).not.toBeNull();const x=box!.x+box!.width/2,y=box!.y+box!.height/2;await page.mouse.move(x,y);await page.mouse.down();await page.mouse.move(x+70,y,{steps:8});await page.mouse.up();
- await page.getByRole('tab',{name:'控制'}).click();const jointSection=page.getByRole('button',{name:'关节 1'});if(await jointSection.getAttribute('aria-expanded')==='false')await jointSection.click();const output=page.getByText('screen_x').locator('..').locator('output');await expect.poll(async()=>Number.parseFloat(await output.textContent()||'0')).toBeGreaterThan(0);
+test('slide 关节向屏幕轴正方向拖动时 qpos 同向增加', async ({ page }) => {
+ await page.goto('/');
+ await page
+ .locator('input[type="file"]')
+ .first()
+ .setInputFiles({
+ name: 'slide.xml',
+ mimeType: 'text/xml',
+ buffer: Buffer.from(SLIDE_DIRECTION_MODEL),
+ });
+ await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
+ await page.getByRole('button', { name: '关节拖动' }).click();
+ const canvas = page.locator('main canvas').first(),
+ box = await canvas.boundingBox();
+ expect(box).not.toBeNull();
+ const x = box!.x + box!.width / 2,
+ y = box!.y + box!.height / 2;
+ await page.mouse.move(x, y);
+ await page.mouse.down();
+ await page.mouse.move(x + 70, y, { steps: 8 });
+ await page.mouse.up();
+ await page.getByRole('tab', { name: '控制' }).click();
+ const jointSection = page.getByRole('button', { name: '关节 1' });
+ if ((await jointSection.getAttribute('aria-expanded')) === 'false') await jointSection.click();
+ const output = page.getByText('screen_x').locator('..').locator('output');
+ await expect
+ .poll(async () => Number.parseFloat((await output.textContent()) || '0'))
+ .toBeGreaterThan(0);
});
-test('可导入并启用 Python 控制器',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('WASM 已加载')).toBeVisible({timeout:30_000});
- await page.getByRole('tab',{name:'控制'}).click();const python=`NAME = "测试 PD 控制器"\nCONTROL_HZ = 100\ndef init(api):\n return {"joint": api.joint("slide"), "actuator": api.actuator("motor"), "body": api.body("box")}\ndef step(ctx, state):\n assert len(ctx.body_quat(state["body"])) == 4\n assert len(ctx.body_position(state["body"])) == 3\n ctx.set_control(state["actuator"], -ctx.qpos(state["joint"]) - 0.1 * ctx.qvel(state["joint"]))\n`;
- await page.locator('input[accept=".py,text/x-python"]').setInputFiles({name:'balance.py',mimeType:'text/x-python',buffer:Buffer.from(python)});await expect(page.getByText('测试 PD 控制器',{exact:true})).toBeVisible({timeout:30_000});await expect(page.getByText('Python / Pyodide')).toBeVisible();await page.getByRole('button',{name:'启用',exact:true}).click();await expect(page.getByText('运行中')).toBeVisible();
+test('可导入并启用 Python 控制器', 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('WASM 已加载')).toBeVisible({ timeout: 30_000 });
+ await page.getByRole('tab', { name: '控制' }).click();
+ const python = `NAME = "测试 PD 控制器"\nCONTROL_HZ = 100\ndef init(api):\n return {"joint": api.joint("slide"), "actuator": api.actuator("motor"), "body": api.body("box")}\ndef step(ctx, state):\n assert len(ctx.body_quat(state["body"])) == 4\n assert len(ctx.body_position(state["body"])) == 3\n ctx.set_control(state["actuator"], -ctx.qpos(state["joint"]) - 0.1 * ctx.qvel(state["joint"]))\n`;
+ await page
+ .locator('input[accept=".py,text/x-python"]')
+ .setInputFiles({ name: 'balance.py', mimeType: 'text/x-python', buffer: Buffer.from(python) });
+ await expect(page.getByText('测试 PD 控制器', { exact: true })).toBeVisible({ timeout: 30_000 });
+ await expect(page.getByText('Python / Pyodide')).toBeVisible();
+ await page.getByRole('button', { name: '启用', exact: true }).click();
+ await expect(page.getByText('运行中')).toBeVisible();
});
-test('中等规模模型持续步进并可重复加载', async ({page}) => {
+test('中等规模模型持续步进并可重复加载', async ({ page }) => {
await page.goto('/');
const input = page.locator('input[type="file"]').first();
- const modelFile = {name:'large.xml',mimeType:'text/xml',buffer:Buffer.from(LARGE_MODEL)};
+ const modelFile = { name: 'large.xml', mimeType: 'text/xml', buffer: Buffer.from(LARGE_MODEL) };
await input.setInputFiles(modelFile);
- await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000});
- await page.getByRole('button', {name:'▶ 播放'}).click();
+ await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
+ await page.getByRole('button', { name: '▶ 播放' }).click();
await page.waitForTimeout(2_000);
await expect(page.locator('footer')).not.toContainText('时间 0.000 s');
// 播放过程中重置必须同时暂停底层会话,之后仍可正常播放和暂停。
- await page.getByRole('button',{name:'重置',exact:true}).click();
- await expect(page.getByRole('button',{name:'▶ 播放'})).toBeVisible();
+ await page.getByRole('button', { name: '重置', exact: true }).click();
+ await expect(page.getByRole('button', { name: '▶ 播放' })).toBeVisible();
await expect(page.locator('footer')).toContainText('时间 0.000 s');
- await page.getByRole('button',{name:'▶ 播放'}).click();
+ await page.getByRole('button', { name: '▶ 播放' }).click();
await page.waitForTimeout(500);
- await page.getByRole('button',{name:'⏸ 暂停'}).click();
+ await page.getByRole('button', { name: '⏸ 暂停' }).click();
await page.waitForTimeout(200);
- const pausedTime=(await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1];
+ const pausedTime = (await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1];
expect(Number(pausedTime)).toBeGreaterThan(0);
await page.waitForTimeout(500);
expect((await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1]).toBe(pausedTime);
await input.setInputFiles(modelFile);
- await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000});
+ await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole('alert')).toHaveCount(0);
});
-test('无效模型显示中文诊断且保留工程树', async ({page}) => {
+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'));
- await expect(page.getByRole('alert')).toContainText('模型编译失败', {timeout: 30_000});
- await expect(page.getByText('invalid.xml', {exact: false}).first()).toBeVisible();
+ await expect(page.getByRole('alert')).toContainText('模型编译失败', { timeout: 30_000 });
+ await expect(page.getByText('invalid.xml', { exact: false }).first()).toBeVisible();
});
diff --git a/web_platform/index.html b/web_platform/index.html
index 90a9b23a..336f510d 100644
--- a/web_platform/index.html
+++ b/web_platform/index.html
@@ -1 +1,72 @@
-
MuJoCo Web 仿真平台
+
+
+
+
+
+
+
+
+ MuJoCo Web 仿真平台
+
+
+
+
+
+
+
diff --git a/web_platform/playwright.config.ts b/web_platform/playwright.config.ts
index 0f211a7c..3c13e201 100644
--- a/web_platform/playwright.config.ts
+++ b/web_platform/playwright.config.ts
@@ -1,2 +1,11 @@
-import {defineConfig} from '@playwright/test';
-export default defineConfig({testDir:'./e2e', timeout:120_000, use:{baseURL:'http://127.0.0.1:4173',channel:'chrome'}, webServer:{command:'npm run preview --prefix .. -- --host 127.0.0.1',url:'http://127.0.0.1:4173',reuseExistingServer:true}});
+import { defineConfig } from '@playwright/test';
+export default defineConfig({
+ testDir: './e2e',
+ timeout: 120_000,
+ use: { baseURL: 'http://127.0.0.1:4173' },
+ webServer: {
+ command: 'npm run preview --prefix .. -- --host 127.0.0.1',
+ url: 'http://127.0.0.1:4173',
+ reuseExistingServer: true,
+ },
+});
diff --git a/web_platform/postcss.config.cjs b/web_platform/postcss.config.cjs
index ec5e4b82..5ff0f895 100644
--- a/web_platform/postcss.config.cjs
+++ b/web_platform/postcss.config.cjs
@@ -1 +1,3 @@
-module.exports = {plugins: {tailwindcss: {config: './web_platform/tailwind.config.cjs'}, autoprefixer: {}}};
+module.exports = {
+ plugins: { tailwindcss: { config: './web_platform/tailwind.config.cjs' }, autoprefixer: {} },
+};
diff --git a/web_platform/src/app/App.tsx b/web_platform/src/app/App.tsx
index 9df99cce..92116e0e 100644
--- a/web_platform/src/app/App.tsx
+++ b/web_platform/src/app/App.tsx
@@ -1,121 +1,1899 @@
/* Zustand 的 action 引用稳定;初始化 viewer 与导入回调有意只创建一次。 */
/* eslint-disable react-hooks/exhaustive-deps */
-import {lazy,Suspense,useCallback,useEffect,useRef,useState,type ChangeEvent,type DragEvent} from 'react';
-import {Camera,ChevronLeft,ChevronRight,CircleHelp,Code2,Crosshair,Download,Hand,Maximize,MousePointer2,PanelsTopLeft,Pause,Play,RotateCcw,Settings as SettingsIcon,SunMoon} from 'lucide-react';
-import {DEFAULT_IMPORT_LIMITS,type ProjectManifest} from '../project/types';
-import {filesFromDrop,importBrowserFiles,normalizeProjectPath,ProjectImportError} from '../project/importer';
-import {MainThreadPhysicsAdapter,type UrdfBaseMode,type UrdfEnhancementOptions,type UrdfLoadMode} from '../simulation/PhysicsAdapter';
-import type {ActuatorParameters} from '../simulation/SimulationSession';
-import type {ControllerCommand,ControllerStatus} from '../controller/types';
-import type {RLCommand,RLPolicyStatus} from '../rl/types';
-import {MuJoCoViewer,type InteractionMode,type ViewerTheme} from '../viewer/MuJoCoViewer';
-import {DEFAULT_VIEWER_DISPLAY_OPTIONS,type ViewerDisplayOptions} from '../viewer/displayOptions';
-import {useAppStore,type AppDiagnostic} from '../stores/useAppStore';
-import {WorkbenchHeader} from './components/WorkbenchHeader';
-import {ViewerToolDock} from './components/ViewerToolDock';
-import {ProjectSidebar,ModelControlsSidebar} from './components/SidebarPanel';
-import {WorkspaceOverlays,type ImportProgress} from './components/WorkspaceOverlays';
-import {EntrySelectionDialog} from './components/EntrySelectionDialog';
-import {ErrorRecoveryPanel} from './components/ErrorRecoveryPanel';
-import {StatusBar} from './components/StatusBar';
-import {ViewportHUD} from './components/ViewportHUD';
-import {ShortcutHelpDialog} from './components/ShortcutHelpDialog';
-import {CommandPalette,type WorkbenchCommand} from './components/CommandPalette';
-import {NotificationCenter,ToastViewport,type WorkbenchNotification} from './components/NotificationCenter';
-import {SettingsDialog} from './components/SettingsDialog';
-import {dispatchLayoutWidths,LayoutSettingsDialog,type LayoutPreset} from './components/LayoutSettingsDialog';
-import {Button,ConfirmDialog,IconButton} from '../components/ui';
-import {DiagnosticsDrawer} from './components/DiagnosticsDrawer';
-import {ToolbarOverflowMenu} from './components/ToolbarOverflowMenu';
+import {
+ lazy,
+ Suspense,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+ type ChangeEvent,
+ type DragEvent,
+} from 'react';
+import { useShallow } from 'zustand/react/shallow';
+import {
+ Camera,
+ ChevronLeft,
+ ChevronRight,
+ CircleHelp,
+ Code2,
+ Crosshair,
+ Download,
+ Hand,
+ Maximize,
+ MousePointer2,
+ PanelsTopLeft,
+ Pause,
+ Play,
+ RotateCcw,
+ Settings as SettingsIcon,
+ SunMoon,
+} from 'lucide-react';
+import { DEFAULT_IMPORT_LIMITS, type MapEntry, type ProjectManifest } from '../project/types';
+import {
+ filesFromDrop,
+ importBrowserFiles,
+ normalizeProjectPath,
+ ProjectImportError,
+} from '../project/importer';
+import {
+ MainThreadPhysicsAdapter,
+ type UrdfBaseMode,
+ type UrdfEnhancementOptions,
+ type UrdfLoadMode,
+} from '../simulation/PhysicsAdapter';
+import type { ActuatorParameters } from '../simulation/SimulationSession';
+import type { DataRecorderConfig } from '../simulation/DataRecorder';
+import type { ControllerCommand, ControllerStatus } from '../controller/types';
+import type { RLCommand, RLPolicyStatus } from '../rl/types';
+import type { MuJoCoViewer, InteractionMode, ViewerTheme } from '../viewer/MuJoCoViewer';
+import {
+ DEFAULT_VIEWER_DISPLAY_OPTIONS,
+ type ViewerDisplayOptions,
+} from '../viewer/displayOptions';
+import { useAppStore, type AppDiagnostic } from '../stores/useAppStore';
+import { WorkbenchHeader } from './components/WorkbenchHeader';
+import { ViewerToolDock } from './components/ViewerToolDock';
+import { ProjectSidebar, ModelControlsSidebar } from './components/SidebarPanel';
+import { WorkspaceOverlays, type ImportProgress } from './components/WorkspaceOverlays';
+import { EntrySelectionDialog } from './components/EntrySelectionDialog';
+import { ErrorRecoveryPanel } from './components/ErrorRecoveryPanel';
+import { StoreStatusBar } from './components/StatusBar';
+import { ViewportHUD } from './components/ViewportHUD';
+import { ShortcutHelpDialog } from './components/ShortcutHelpDialog';
+import { CommandPalette, type WorkbenchCommand } from './components/CommandPalette';
+import {
+ NotificationCenter,
+ ToastViewport,
+ type WorkbenchNotification,
+} from './components/NotificationCenter';
+import { SettingsDialog } from './components/SettingsDialog';
+import {
+ dispatchLayoutWidths,
+ LayoutSettingsDialog,
+ type LayoutPreset,
+} from './components/LayoutSettingsDialog';
+import { Button, ConfirmDialog, IconButton } from '../components/ui';
+import { DiagnosticsDrawer } from './components/DiagnosticsDrawer';
+import { ToolbarOverflowMenu } from './components/ToolbarOverflowMenu';
-import {UrdfImportOptionsDialog} from './components/UrdfImportOptionsDialog';
-import {downloadBytes,exportedFileName,mergeCachedFiles,readCachedText,upsertCachedMjcf} from '../project/cachedFiles';
+import { UrdfImportOptionsDialog } from './components/UrdfImportOptionsDialog';
+import {
+ downloadBytes,
+ exportedFileName,
+ mergeCachedFiles,
+ readCachedText,
+ upsertCachedMjcf,
+} from '../project/cachedFiles';
+import {
+ DEFAULT_MAP_SELECTION,
+ DEFAULT_PHYSICAL_MAP_CONFIG,
+ type MapSelection,
+ type SystemTerrainPreset,
+} from '../map/types';
+import { discoverMapEntries, resolveProjectMap, visualMapAsset } from '../map/MapLoader';
+import { decodeMapDefinition } from '../map/mapSchema';
+import { decodeEditableMapDocument, encodeEditableMapDocument } from '../map/editor/editorSchema';
+import type {
+ EditableMapDocument,
+ EditableMapObjectType,
+ MapEditorInteractionCallbacks,
+ MapEditorTransformMode,
+ MapObjectPlacementMode,
+} from '../map/editor/types';
+import { isMapObjectPlacementMode } from '../map/editor/types';
+import {
+ isEditableMapObjectType,
+ MAP_ASSET_DRAG_MIME,
+ MAP_ASSET_PLACEMENT_MIME,
+} from '../map/editor/assetCatalog';
+import { compileEditableMapDocument } from '../map/editor/MapDocumentCompiler';
+import { importEditableMapDocument } from '../map/editor/MapDocumentImporter';
+import { resolveProjectAssetPath } from '../map/mapPaths';
-const SourceEditorDialog=lazy(()=>import('./components/SourceEditorDialog').then(module=>({default:module.SourceEditorDialog})));
+const SourceEditorDialog = lazy(() =>
+ import('./components/SourceEditorDialog').then((module) => ({
+ default: module.SourceEditorDialog,
+ })),
+);
-function diagnostic(category:AppDiagnostic['category'],error:unknown,path?:string):AppDiagnostic{const detail=error instanceof Error?error.message:String(error);return {category,summary:`${category}失败`,detail,path,at:Date.now()};}
-function initialTheme():ViewerTheme{try{return localStorage.getItem('mujoco-platform-theme')==='light'?'light':'dark';}catch{return'dark';}}
-function initialSidebarVisibility():{left:boolean;right:boolean}{const width=typeof window==='undefined'?1280:window.innerWidth;if(width<900)return {left:false,right:false};try{const stored=JSON.parse(localStorage.getItem('mujoco-platform-layout')??'null') as {left?:unknown;right?:unknown}|null;if(stored&&typeof stored.left==='boolean'&&typeof stored.right==='boolean')return {left:stored.left,right:stored.right};}catch{/* 使用响应式默认布局 */}return width>=1280?{left:true,right:true}:{left:false,right:true};}
-function initialDisplayOptions():ViewerDisplayOptions{try{const stored=JSON.parse(localStorage.getItem('mujoco-platform-display')??'null') as Partial|null;if(!stored)return {...DEFAULT_VIEWER_DISPLAY_OPTIONS};const next={...DEFAULT_VIEWER_DISPLAY_OPTIONS};for(const key of Object.keys(next) as (keyof ViewerDisplayOptions)[])if(typeof stored[key]==='boolean')next[key]=stored[key];return next;}catch{return {...DEFAULT_VIEWER_DISPLAY_OPTIONS};}}
-function convertedCachePath(entryPath:string):string{const slash=entryPath.lastIndexOf('/');return `${slash>=0?entryPath.slice(0,slash+1):''}.__converted_mjcf_cache__.xml`;}
-function urdfLinkNames(project:ProjectManifest|null,path:string|undefined):string[]{const file=path?project?.files.find(candidate=>candidate.path===path):undefined;if(!file)return[];const document=new DOMParser().parseFromString(new TextDecoder().decode(file.data),'application/xml');return Array.from(document.querySelectorAll('robot > link[name]')).map(link=>link.getAttribute('name')).filter((name):name is string=>Boolean(name));}
-
-export function App(){
- const state=useAppStore();
- const manifest=useRef(null),notificationId=useRef(0),loadInFlight=useRef(false),importInFlight=useRef(false),adapter=useRef(new MainThreadPhysicsAdapter()),root=useRef(null),viewerHost=useRef(null),viewer=useRef(null),urdfEnhancementsRef=useRef({addActuators:true,addSensors:true,sensorType:'camera'});
- const [forceScale,setForceScale]=useState(50),[leftOpen,setLeftOpen]=useState(()=>initialSidebarVisibility().left),[rightOpen,setRightOpen]=useState(()=>initialSidebarVisibility().right),[helpOpen,setHelpOpen]=useState(false),[commandOpen,setCommandOpen]=useState(false),[sourceOpen,setSourceOpen]=useState(false),[generatedMjcf,setGeneratedMjcf]=useState(),[generatedMjcfPath,setGeneratedMjcfPath]=useState(),[pendingUrdfPath,setPendingUrdfPath]=useState(),[pendingUrdfMounts,setPendingUrdfMounts]=useState([]),[removeConfirmOpen,setRemoveConfirmOpen]=useState(false),[fullscreen,setFullscreen]=useState(false),[settingsOpen,setSettingsOpen]=useState(false),[layoutOpen,setLayoutOpen]=useState(false),[diagnosticsOpen,setDiagnosticsOpen]=useState(false),[importProgress,setImportProgress]=useState(),[notifications,setNotifications]=useState([]),[toast,setToast]=useState(),[selectedControllerPath,setSelectedControllerPath]=useState(),[controllerStatus,setControllerStatus]=useState(),[selectedPolicyPath,setSelectedPolicyPath]=useState(),[policyStatus,setPolicyStatus]=useState();
- const [urdfMode,setUrdfMode]=useState('mjcf'),urdfModeRef=useRef('mjcf');
- const [baseMode,setBaseMode]=useState('floating'),baseModeRef=useRef('floating');
- const [displayOptions,setDisplayOptions]=useState(initialDisplayOptions),[showSensorCamera,setShowSensorCamera]=useState(true),[theme,setTheme]=useState(initialTheme),[jointAdvanced,setJointAdvanced]=useState(false),[ignoreJointLimits,setIgnoreJointLimits]=useState(false),[angleUnit,setAngleUnit]=useState<'rad'|'deg'>('rad');
- const showCollision=displayOptions.showCollision,setShowCollision=(value:boolean)=>setDisplayOptions(options=>({...options,showCollision:value}));
- useEffect(()=>{if(!viewerHost.current)return;viewer.current=new MuJoCoViewer(viewerHost.current,{onSelection:state.setSelection,onFrame:(frame,fps,snapshot)=>{const memory=(performance as Performance&{memory?:{usedJSHeapSize:number}}).memory?.usedJSHeapSize;state.setMetrics(fps,frame.stepMs,memory===undefined?undefined:memory/1048576,frame.overBudget);if(snapshot){state.setSnapshot(snapshot);setControllerStatus(snapshot.controller);setPolicyStatus(snapshot.rlPolicy);if(snapshot.controller?.error||snapshot.rlPolicy?.error){adapter.current.setPaused(true);state.setPaused(true);}}},onError:error=>state.setDiagnostic(diagnostic(error.message.includes('控制器')?'仿真':'渲染',error))});return()=>{viewer.current?.dispose();viewer.current=null;adapter.current.dispose();};},[]);
- useEffect(()=>{viewer.current?.setMode(state.mode);},[state.mode]);
- useEffect(()=>{if(viewer.current)viewer.current.forceScale=forceScale;},[forceScale]);
- useEffect(()=>{viewer.current?.setDisplayOptions(displayOptions);try{localStorage.setItem('mujoco-platform-display',JSON.stringify(displayOptions));}catch{/* 当前会话仍可修改 */}},[displayOptions]);
- useEffect(()=>{if(window.innerWidth<900)return;try{localStorage.setItem('mujoco-platform-layout',JSON.stringify({left:leftOpen,right:rightOpen}));}catch{/* 当前会话仍可修改 */}},[leftOpen,rightOpen]);
- useEffect(()=>{viewer.current?.setShowSensorCamera(showSensorCamera);},[showSensorCamera]);
- useEffect(()=>{viewer.current?.setTheme(theme);document.documentElement.style.colorScheme=theme;try{localStorage.setItem('mujoco-platform-theme',theme);}catch{/* 当前会话仍可切换 */}},[theme]);
- useEffect(()=>{const change=()=>setFullscreen(document.fullscreenElement===root.current);document.addEventListener('fullscreenchange',change);return()=>document.removeEventListener('fullscreenchange',change);},[]);
- const loadEntry=useCallback(async(path:string,requestedMode?:UrdfLoadMode)=>{if(!manifest.current||loadInFlight.current)return;loadInFlight.current=true;setIgnoreJointLimits(false);setControllerStatus(undefined);setPolicyStatus(undefined);state.setEntry(path);state.setLoading(true);setImportProgress({label:'初始化 WASM 与编译模型',value:.65});state.setDiagnostic(undefined);setGeneratedMjcf(undefined);setGeneratedMjcfPath(undefined);viewer.current?.attach(null);state.setSnapshot(undefined);state.setSelection(null);try{const snapshot=await adapter.current.load(manifest.current,path,requestedMode??urdfModeRef.current,baseModeRef.current,urdfEnhancementsRef.current);const supportFiles=adapter.current.cachedSupportFiles();if(supportFiles.length&&manifest.current){manifest.current=mergeCachedFiles(manifest.current,supportFiles);state.setProject(manifest.current.name,manifest.current.files.map(file=>({path:file.path,size:file.size})),manifest.current.entries,path);}setImportProgress({label:'创建视口场景',value:.92});adapter.current.setSpeed(useAppStore.getState().speed);state.setSnapshot(snapshot);state.setPaused(true);viewer.current?.attach(adapter.current.session);try{setGeneratedMjcf(new TextDecoder().decode(adapter.current.exportMjcf()));setGeneratedMjcfPath(convertedCachePath(path));}catch(error){console.warn('[MuJoCo] 无法生成源码预览',error);}const notice:WorkbenchNotification={id:++notificationId.current,title:snapshot.warnings.length?`模型已加载 · ${snapshot.warnings.length} 项兼容调整`:'模型加载完成',detail:snapshot.warnings.length?snapshot.warnings.join('\n'):path,tone:snapshot.warnings.length?'warning':'success',at:Date.now()};setNotifications(items=>[notice,...items].slice(0,20));setToast(notice);}catch(error){state.setDiagnostic(diagnostic('模型编译',error,path));const notice:WorkbenchNotification={id:++notificationId.current,title:'模型编译失败',detail:error instanceof Error?error.message:String(error),tone:'danger',at:Date.now()};setNotifications(items=>[notice,...items].slice(0,20));setToast(notice);}finally{loadInFlight.current=false;setImportProgress(undefined);state.setLoading(false);}},[]);
- const requestLoadEntry=useCallback(async(path:string)=>{const entry=manifest.current?.entries.find(candidate=>candidate.path===path);if(entry?.format==='urdf'&&urdfModeRef.current==='mjcf'){setPendingUrdfMounts(urdfLinkNames(manifest.current,path));setPendingUrdfPath(path);return;}await loadEntry(path);},[loadEntry]);
- const confirmUrdfOptions=(options:UrdfEnhancementOptions)=>{const path=pendingUrdfPath;if(!path)return;urdfEnhancementsRef.current=options;setPendingUrdfPath(undefined);setPendingUrdfMounts([]);void loadEntry(path);};
- const skipUrdfOptions=()=>confirmUrdfOptions({addActuators:false,addSensors:false,sensorType:'camera'});
- const ingest=useCallback(async(files:File[],lockOwned=false)=>{if(importInFlight.current&&!lockOwned)return;importInFlight.current=true;state.setLoading(true);setImportProgress({label:'读取工程文件',value:.12});try{const next=await importBrowserFiles(files);setImportProgress({label:'处理模型资源与入口',value:.38});manifest.current=next;setSelectedControllerPath(next.files.find(file=>/\.py$/i.test(file.path))?.path);setSelectedPolicyPath(next.files.find(file=>/\.onnx$/i.test(file.path))?.path);state.setProject(next.name,next.files.map(({path,size})=>({path,size})),next.entries,next.selectedEntry);if(next.selectedEntry)await requestLoadEntry(next.selectedEntry);}catch(error){state.setDiagnostic(diagnostic(error instanceof ProjectImportError&&/ZIP/.test(error.message)?'ZIP':'导入',error,error instanceof ProjectImportError?error.path:undefined));const notice:WorkbenchNotification={id:++notificationId.current,title:'工程导入失败',detail:error instanceof Error?error.message:String(error),tone:'danger',at:Date.now()};setNotifications(items=>[notice,...items].slice(0,20));setToast(notice);}finally{importInFlight.current=false;setImportProgress(undefined);state.setLoading(false);}},[requestLoadEntry]);
- const removeProject=()=>{if(state.projectName)setRemoveConfirmOpen(true);};
- const confirmRemoveProject=()=>{viewer.current?.attach(null);adapter.current.dispose();manifest.current=null;setGeneratedMjcf(undefined);setGeneratedMjcfPath(undefined);setPendingUrdfPath(undefined);setPendingUrdfMounts([]);setSelectedControllerPath(undefined);setControllerStatus(undefined);setSelectedPolicyPath(undefined);setPolicyStatus(undefined);state.clearProject();setRemoveConfirmOpen(false);};
- const changeUrdfMode=(value:UrdfLoadMode)=>{setUrdfMode(value);urdfModeRef.current=value;const entry=state.entries.find(candidate=>candidate.path===state.selectedEntry);if(entry?.format!=='urdf')return;if(value==='mjcf'){setPendingUrdfMounts(urdfLinkNames(manifest.current,entry.path));setPendingUrdfPath(entry.path);}else void loadEntry(entry.path,value);};
- const changeBaseMode=(value:UrdfBaseMode)=>{setBaseMode(value);baseModeRef.current=value;const entry=state.entries.find(candidate=>candidate.path===state.selectedEntry);if(entry?.format==='urdf'&&urdfModeRef.current==='mjcf')void loadEntry(entry.path,'mjcf');};
- const changeFiles=(event:ChangeEvent)=>{void ingest(Array.from(event.target.files??[]));event.target.value='';};
- const drop=(event:DragEvent)=>{event.preventDefault();if(state.loading||importInFlight.current)return;importInFlight.current=true;state.setLoading(true);setImportProgress({label:'读取拖放文件',value:.05});void (async()=>{try{const files=await filesFromDrop(event.dataTransfer.items,event.dataTransfer.files);await ingest(files,true);}catch(error){importInFlight.current=false;setImportProgress(undefined);state.setLoading(false);state.setDiagnostic(diagnostic('导入',error));}})();};
- const togglePause=()=>{const value=!state.paused;state.setPaused(value);adapter.current.setPaused(value);};
- const reset=()=>{adapter.current.setPaused(true);adapter.current.reset();state.setSnapshot(adapter.current.snapshot()??undefined);state.setPaused(true);};
- const singleStep=()=>{adapter.current.singleStep();state.setSnapshot(adapter.current.snapshot()??undefined);};
- const changeSpeed=(value:number)=>{state.setSpeed(value);adapter.current.setSpeed(value);};
- const mode=(value:InteractionMode)=>state.setMode(value);
- const resetJoints=()=>{adapter.current.resetJoints();state.setPaused(true);state.setSnapshot(adapter.current.snapshot()??undefined);};
- const toggleJointLimits=()=>{const next=!ignoreJointLimits;adapter.current.setIgnoreJointLimits(next);setIgnoreJointLimits(next);state.setSnapshot(adapter.current.snapshot()??undefined);};
- const setActuator=(id:number,value:number)=>{adapter.current.setActuator(id,value);state.setSnapshot(adapter.current.snapshot()??undefined);};
- const setActuatorParameters=(id:number,parameters:ActuatorParameters)=>{if(!adapter.current.setActuatorParameters(id,parameters))return;state.setSnapshot(adapter.current.snapshot()??undefined);try{setGeneratedMjcf(new TextDecoder().decode(adapter.current.exportMjcf()));}catch(error){console.warn('[MuJoCo] 无法刷新驱动器参数源码',error);}};
- const setJoint=(id:number,value:number)=>{adapter.current.setJointPosition(id,value);state.setPaused(true);state.setSnapshot(adapter.current.snapshot()??undefined);};
- const loadControllerSource=async(source:string,path:string)=>{state.setLoading(true);setImportProgress({label:'初始化 Python 运行时并加载控制器',value:.5});state.setDiagnostic(undefined);try{const status=await adapter.current.loadPythonController(source,path);setControllerStatus(status);state.setSnapshot(adapter.current.snapshot()??undefined);notify('Python 控制器已加载',`${status.name} · ${status.controlHz} Hz`);}catch(error){state.setDiagnostic(diagnostic('仿真',error,path));}finally{setImportProgress(undefined);state.setLoading(false);}};
- const loadControllerPath=(path:string)=>{const file=manifest.current?.files.find(candidate=>candidate.path===path);if(!file){state.setDiagnostic(diagnostic('仿真',new Error('工程中找不到控制脚本'),path));return;}setSelectedControllerPath(path);void loadControllerSource(new TextDecoder().decode(file.data),path);};
- const importController=(file:File)=>{void (async()=>{try{if(!/\.py$/i.test(file.name))throw new Error('请选择 .py 文件');if(file.size>1024*1024)throw new Error('Python 控制脚本不能超过 1 MiB');const path=normalizeProjectPath(file.name),data=new Uint8Array(await file.arrayBuffer());if(manifest.current){const index=manifest.current.files.findIndex(candidate=>candidate.path===path),files=manifest.current.files.slice(),entry={path,data,size:data.byteLength,source:'file' as const,mimeType:file.type||'text/x-python'};if(index>=0)files[index]=entry;else files.push(entry);manifest.current={...manifest.current,files,totalBytes:files.reduce((total,item)=>total+item.size,0)};state.setProject(manifest.current.name,files.map(({path:filePath,size})=>({path:filePath,size})),manifest.current.entries,manifest.current.selectedEntry);state.setSnapshot(adapter.current.snapshot()??undefined);}setSelectedControllerPath(path);await loadControllerSource(new TextDecoder().decode(data),path);}catch(error){state.setDiagnostic(diagnostic('仿真',error,file.name));}})();};
- const toggleController=(enabled:boolean)=>{adapter.current.setControllerEnabled(enabled);const snapshot=adapter.current.snapshot()??undefined;setControllerStatus(snapshot?.controller);setPolicyStatus(snapshot?.rlPolicy);state.setSnapshot(snapshot);};
- const sendControllerCommand=(command:ControllerCommand)=>{try{adapter.current.sendControllerCommand(command);const snapshot=adapter.current.snapshot()??undefined;setControllerStatus(snapshot?.controller);state.setSnapshot(snapshot);}catch(error){state.setDiagnostic(diagnostic('仿真',error,selectedControllerPath));}};
- const removeController=()=>{adapter.current.removeController();setControllerStatus(undefined);state.setSnapshot(adapter.current.snapshot()??undefined);};
- const loadPolicyBytes=async(data:Uint8Array,path:string)=>{state.setLoading(true);setImportProgress({label:'初始化 ONNX Runtime 并加载策略',value:.55});state.setDiagnostic(undefined);try{const status=await adapter.current.loadRLPolicy(data,path);setPolicyStatus(status);state.setSnapshot(adapter.current.snapshot()??undefined);notify('ONNX 策略已加载',`${status.taskName} · ${status.observationSize} → ${status.actionSize}`);}catch(error){state.setDiagnostic(diagnostic('仿真',error,path));}finally{setImportProgress(undefined);state.setLoading(false);}};
- const loadPolicyPath=(path:string)=>{const file=manifest.current?.files.find(candidate=>candidate.path===path);if(!file){state.setDiagnostic(diagnostic('仿真',new Error('工程中找不到 ONNX 策略'),path));return;}setSelectedPolicyPath(path);void loadPolicyBytes(file.data,path);};
- const importPolicy=(file:File)=>{void (async()=>{try{if(!/\.onnx$/i.test(file.name))throw new Error('请选择 .onnx 文件');if(file.size>64*1024*1024)throw new Error('ONNX 策略不能超过 64 MiB');const path=normalizeProjectPath(file.name),data=new Uint8Array(await file.arrayBuffer());if(manifest.current){const index=manifest.current.files.findIndex(candidate=>candidate.path===path),files=manifest.current.files.slice(),entry={path,data,size:data.byteLength,source:'file' as const,mimeType:file.type||'application/octet-stream'};if(index>=0)files[index]=entry;else files.push(entry);const totalBytes=files.reduce((total,item)=>total+item.size,0);if(totalBytes>DEFAULT_IMPORT_LIMITS.maxTotalBytes)throw new Error('加入 ONNX 后工程总大小超过 512 MiB');manifest.current={...manifest.current,files,totalBytes};state.setProject(manifest.current.name,files.map(({path:filePath,size})=>({path:filePath,size})),manifest.current.entries,manifest.current.selectedEntry);state.setSnapshot(adapter.current.snapshot()??undefined);}setSelectedPolicyPath(path);await loadPolicyBytes(data,path);}catch(error){state.setDiagnostic(diagnostic('仿真',error,file.name));}})();};
- const togglePolicy=(enabled:boolean)=>{adapter.current.setRLPolicyEnabled(enabled);const snapshot=adapter.current.snapshot()??undefined;setPolicyStatus(snapshot?.rlPolicy);setControllerStatus(snapshot?.controller);state.setSnapshot(snapshot);};
- const setPolicyCommand=(command:RLCommand)=>{adapter.current.setRLCommand(command);const snapshot=adapter.current.snapshot()??undefined;setPolicyStatus(snapshot?.rlPolicy);state.setSnapshot(snapshot);};
- const removePolicy=()=>{adapter.current.removeRLPolicy();setPolicyStatus(undefined);state.setSnapshot(adapter.current.snapshot()??undefined);};
- const notify=(title:string,detail:string,tone:WorkbenchNotification['tone']='success')=>{const notice:WorkbenchNotification={id:++notificationId.current,title,detail,tone,at:Date.now()};setNotifications(items=>[notice,...items].slice(0,20));setToast(notice);};
- const saveCachedSource=async(path:string,text:string)=>{if(!manifest.current)return;manifest.current=upsertCachedMjcf(manifest.current,path,text);state.setProject(manifest.current.name,manifest.current.files.map(file=>({path:file.path,size:file.size})),manifest.current.entries,path);notify('转换后的 MJCF 已保存到缓存',path);await loadEntry(path);};
- const exportUrdf=()=>{if(!manifest.current||selectedFormat!=='urdf'||!state.selectedEntry)return;const text=readCachedText(manifest.current,state.selectedEntry);downloadBytes(new TextEncoder().encode(text),exportedFileName(manifest.current.name,'urdf'));notify('URDF 已导出',state.selectedEntry);};
- const exportMjcf=()=>{try{const data=adapter.current.exportMjcf();downloadBytes(data,exportedFileName(manifest.current?.name??'model','xml'));notify('MJCF 已导出','导出内容来自当前已编译模型');}catch(error){state.setDiagnostic(diagnostic('模型编译',error,state.selectedEntry));}};
- const toggleFullscreen=()=>{if(document.fullscreenElement)void document.exitFullscreen().catch(()=>{});else if(root.current)void root.current.requestFullscreen().catch(()=>{});};
- const applyLayoutPreset=(preset:LayoutPreset)=>{if(preset==='viewport'){setLeftOpen(false);setRightOpen(false);dispatchLayoutWidths(288,288);}else if(preset==='project'){setLeftOpen(true);setRightOpen(false);dispatchLayoutWidths(384,288);}else if(preset==='control'){setLeftOpen(false);setRightOpen(true);dispatchLayoutWidths(288,384);}else{setLeftOpen(true);setRightOpen(true);dispatchLayoutWidths(288,288);}};
- useEffect(()=>{const key=(event:KeyboardEvent)=>{if(document.activeElement instanceof HTMLElement&&document.activeElement.closest('[role="dialog"]'))return;if((event.ctrlKey||event.metaKey)&&event.key.toLocaleLowerCase()==='k'){event.preventDefault();setCommandOpen(true);return;}if((event.target as HTMLElement).matches('input,select,button'))return;if(event.code==='Space'){event.preventDefault();togglePause();}if(event.key==='r')reset();if(event.key==='1')mode('select');if(event.key==='2')mode('joint');if(event.key==='3')mode('force');};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);});
- const selectedFormat=state.entries.find(entry=>entry.path===state.selectedEntry)?.format;
- const commands:WorkbenchCommand[]=[
- {id:'play',label:state.paused?'播放仿真':'暂停仿真',group:'仿真',icon:state.paused?:,shortcut:'Space',disabled:!state.snapshot,run:togglePause},
- {id:'reset',label:'重置仿真',group:'仿真',icon:,shortcut:'R',disabled:!state.snapshot,run:reset},
- {id:'select',label:'切换到选择模式',group:'视口',icon:,shortcut:'1',run:()=>mode('select')},
- {id:'joint',label:'切换到关节拖动',group:'视口',icon:,shortcut:'2',run:()=>mode('joint')},
- {id:'force',label:'切换到外力施加',group:'视口',icon:,shortcut:'3',run:()=>mode('force')},
- {id:'camera',label:'复位相机',group:'视口',icon:,run:()=>viewer.current?.resetCamera()},
- {id:'source',label:'查看和修改缓存源代码',group:'工程',icon:,disabled:!generatedMjcf,run:()=>setSourceOpen(true)},
- {id:'export-urdf',label:'导出 URDF 文件',group:'工程',icon:,disabled:selectedFormat!=='urdf',run:exportUrdf},
- {id:'export-mjcf',label:'导出 MJCF 文件',group:'工程',icon:,disabled:!state.snapshot,run:exportMjcf},
- {id:'left',label:leftOpen?'隐藏工程面板':'显示工程面板',group:'布局',icon:leftOpen?:,run:()=>setLeftOpen(value=>!value)},
- {id:'right',label:rightOpen?'隐藏属性面板':'显示属性面板',group:'布局',icon:rightOpen?:,run:()=>setRightOpen(value=>!value)},
- {id:'theme',label:theme==='dark'?'切换到白天主题':'切换到黑夜主题',group:'外观',icon:,run:()=>setTheme(value=>value==='dark'?'light':'dark')},
- {id:'fullscreen',label:fullscreen?'退出全屏':'进入全屏',group:'布局',icon:,run:toggleFullscreen},
- {id:'help',label:'查看快捷键帮助',group:'帮助',icon:,run:()=>setHelpOpen(true)},
- ];
- return event.preventDefault()} onDrop={drop}>
-
setSourceOpen(true)} onTogglePause={togglePause} onStep={singleStep} onReset={reset} onSpeed={changeSpeed} onToggleLeft={()=>setLeftOpen(value=>!value)} onToggleRight={()=>setRightOpen(value=>!value)} onToggleTheme={()=>setTheme(value=>value==='dark'?'light':'dark')} onHelp={()=>setHelpOpen(true)} endActions={<>setNotifications(items=>items.filter(item=>item.id!==id))} onClear={()=>setNotifications([])} onOpenLog={()=>setDiagnosticsOpen(true)}/>setLayoutOpen(true)}>setSettingsOpen(true)}>>} compactMenu={setCommandOpen(true)} onLayout={()=>setLayoutOpen(true)} onSettings={()=>setSettingsOpen(true)} onFullscreen={toggleFullscreen} onHelp={()=>setHelpOpen(true)} onTheme={()=>setTheme(value=>value==='dark'?'light':'dark')}/>} onCommands={()=>setCommandOpen(true)} onToggleFullscreen={toggleFullscreen} center={viewer.current?.resetCamera()}/>}/>
- viewer.current?.highlightJoint(jointId)}/>setToast(undefined)}/>{Boolean(state.snapshot?.model.ncam)&&(showSensorCamera?摄像头
:} onClick={()=>setShowSensorCamera(true)}>显示摄像头画面)}{state.entries.length>1&&!state.selectedEntry&&!pendingUrdfPath&&} {state.diagnostic&&state.setDiagnostic(undefined)} onRetry={state.diagnostic.category==='模型编译'&&state.diagnostic.path?()=>void loadEntry(state.diagnostic!.path!):undefined} onOpenProject={()=>{setLeftOpen(true);state.setDiagnostic(undefined);}}/>}/\.py$/i.test(file.path)).map(file=>file.path)} selectedControllerPath={selectedControllerPath} controllerStatus={controllerStatus} policyPaths={state.files.filter(file=>/\.onnx$/i.test(file.path)).map(file=>file.path)} selectedPolicyPath={selectedPolicyPath} policyStatus={policyStatus} onUrdfMode={changeUrdfMode} onBaseMode={changeBaseMode} onShowCollision={setShowCollision} onResetJoints={resetJoints} onToggleJointLimits={toggleJointLimits} onToggleAdvanced={()=>setJointAdvanced(value=>!value)} onToggleAngleUnit={()=>setAngleUnit(value=>value==='rad'?'deg':'rad')} onActuator={setActuator} onActuatorParameters={setActuatorParameters} onJoint={setJoint} onForceScale={setForceScale} onSelectControllerPath={setSelectedControllerPath} onLoadControllerPath={loadControllerPath} onImportController={importController} onToggleController={toggleController} onControllerCommand={sendControllerCommand} onRemoveController={removeController} onSelectPolicyPath={setSelectedPolicyPath} onLoadPolicyPath={loadPolicyPath} onImportPolicy={importPolicy} onTogglePolicy={togglePolicy} onPolicyCommand={setPolicyCommand} onRemovePolicy={removePolicy}/>
- {pendingUrdfPath&&}{sourceOpen&&generatedMjcf&&generatedMjcfPath&&正在加载源码编辑器… }>setSourceOpen(false)} onSave={saveCachedSource}/>}setHelpOpen(false)}/>setDiagnosticsOpen(false)} onClear={()=>setNotifications([])}/>setSettingsOpen(false)} theme={theme} angleUnit={angleUnit} showCollision={showCollision} jointAdvanced={jointAdvanced} forceScale={forceScale} onTheme={setTheme} onAngleUnit={setAngleUnit} onShowCollision={setShowCollision} onJointAdvanced={setJointAdvanced} onForceScale={setForceScale}/>setLayoutOpen(false)} leftOpen={leftOpen} rightOpen={rightOpen} onLeftOpen={setLeftOpen} onRightOpen={setRightOpen} onPreset={applyLayoutPreset} onReset={()=>applyLayoutPreset('default')}/>setCommandOpen(false)} commands={commands}/>setRemoveConfirmOpen(false)}>确定从当前会话中移除“{state.projectName}”吗?
该操作不会删除本地文件。
- ;
+function diagnostic(
+ category: AppDiagnostic['category'],
+ error: unknown,
+ path?: string,
+): AppDiagnostic {
+ const detail = error instanceof Error ? error.message : String(error);
+ return { category, summary: `${category}失败`, detail, path, at: Date.now() };
+}
+function initialTheme(): ViewerTheme {
+ try {
+ return localStorage.getItem('mujoco-platform-theme') === 'light' ? 'light' : 'dark';
+ } catch {
+ return 'dark';
+ }
+}
+function initialSidebarVisibility(): { left: boolean; right: boolean } {
+ const width = typeof window === 'undefined' ? 1280 : window.innerWidth;
+ if (width < 900) return { left: false, right: false };
+ try {
+ const stored = JSON.parse(localStorage.getItem('mujoco-platform-layout') ?? 'null') as {
+ left?: unknown;
+ right?: unknown;
+ } | null;
+ if (stored && typeof stored.left === 'boolean' && typeof stored.right === 'boolean')
+ return { left: stored.left, right: stored.right };
+ } catch {
+ /* 使用响应式默认布局 */
+ }
+ return width >= 1280 ? { left: true, right: true } : { left: false, right: true };
+}
+function initialDisplayOptions(): ViewerDisplayOptions {
+ try {
+ const stored = JSON.parse(
+ localStorage.getItem('mujoco-platform-display') ?? 'null',
+ ) as Partial | null;
+ if (!stored) return { ...DEFAULT_VIEWER_DISPLAY_OPTIONS };
+ const next = { ...DEFAULT_VIEWER_DISPLAY_OPTIONS };
+ for (const key of Object.keys(next) as (keyof ViewerDisplayOptions)[])
+ if (typeof stored[key] === 'boolean') next[key] = stored[key];
+ return next;
+ } catch {
+ return { ...DEFAULT_VIEWER_DISPLAY_OPTIONS };
+ }
+}
+function convertedCachePath(entryPath: string): string {
+ const slash = entryPath.lastIndexOf('/');
+ return `${slash >= 0 ? entryPath.slice(0, slash + 1) : ''}.__converted_mjcf_cache__.xml`;
+}
+function urdfLinkNames(project: ProjectManifest | null, path: string | undefined): string[] {
+ const file = path ? project?.files.find((candidate) => candidate.path === path) : undefined;
+ if (!file) return [];
+ const document = new DOMParser().parseFromString(
+ new TextDecoder().decode(file.data),
+ 'application/xml',
+ );
+ return Array.from(document.querySelectorAll('robot > link[name]'))
+ .map((link) => link.getAttribute('name'))
+ .filter((name): name is string => Boolean(name));
+}
+
+export function App() {
+ const state = useAppStore(
+ useShallow((value) => ({
+ projectName: value.projectName,
+ files: value.files,
+ entries: value.entries,
+ selectedEntry: value.selectedEntry,
+ loading: value.loading,
+ diagnostic: value.diagnostic,
+ snapshot: value.snapshot,
+ selection: value.selection,
+ paused: value.paused,
+ speed: value.speed,
+ mode: value.mode,
+ clearProject: value.clearProject,
+ setProject: value.setProject,
+ setEntry: value.setEntry,
+ setLoading: value.setLoading,
+ setDiagnostic: value.setDiagnostic,
+ setSnapshot: value.setSnapshot,
+ setSelection: value.setSelection,
+ setPaused: value.setPaused,
+ setSpeed: value.setSpeed,
+ setMode: value.setMode,
+ setMetrics: value.setMetrics,
+ })),
+ );
+ const manifest = useRef(null),
+ notificationId = useRef(0),
+ loadInFlight = useRef(false),
+ importInFlight = useRef(false),
+ adapter = useRef(new MainThreadPhysicsAdapter()),
+ root = useRef(null),
+ viewerHost = useRef(null),
+ viewer = useRef(null),
+ viewerReady = useRef | null>(null),
+ dragDepth = useRef(0),
+ editorInteraction = useRef(null),
+ pendingMapAsset = useRef<{
+ type: EditableMapObjectType;
+ position?: [number, number, number];
+ placementMode: MapObjectPlacementMode;
+ } | null>(null),
+ urdfEnhancementsRef = useRef({
+ addActuators: true,
+ addSensors: true,
+ sensorType: 'camera',
+ });
+ const [forceScale, setForceScale] = useState(50),
+ [leftOpen, setLeftOpen] = useState(() => initialSidebarVisibility().left),
+ [rightOpen, setRightOpen] = useState(() => initialSidebarVisibility().right),
+ [helpOpen, setHelpOpen] = useState(false),
+ [commandOpen, setCommandOpen] = useState(false),
+ [sourceOpen, setSourceOpen] = useState(false),
+ [generatedMjcf, setGeneratedMjcf] = useState(),
+ [generatedMjcfPath, setGeneratedMjcfPath] = useState(),
+ [pendingUrdfPath, setPendingUrdfPath] = useState(),
+ [pendingUrdfMounts, setPendingUrdfMounts] = useState([]),
+ [removeConfirmOpen, setRemoveConfirmOpen] = useState(false),
+ [fullscreen, setFullscreen] = useState(false),
+ [settingsOpen, setSettingsOpen] = useState(false),
+ [layoutOpen, setLayoutOpen] = useState(false),
+ [diagnosticsOpen, setDiagnosticsOpen] = useState(false),
+ [dragActive, setDragActive] = useState(false),
+ [importProgress, setImportProgress] = useState(),
+ [notifications, setNotifications] = useState([]),
+ [toast, setToast] = useState(),
+ [selectedControllerPath, setSelectedControllerPath] = useState(),
+ [controllerStatus, setControllerStatus] = useState(),
+ [selectedPolicyPath, setSelectedPolicyPath] = useState(),
+ [policyStatus, setPolicyStatus] = useState(),
+ [projectMaps, setProjectMaps] = useState([]),
+ [editorDocument, setEditorDocument] = useState(null),
+ [projectSidebarTab, setProjectSidebarTab] = useState<'project' | 'structure' | 'assets'>(
+ 'project',
+ );
+ const [urdfMode, setUrdfMode] = useState('mjcf'),
+ urdfModeRef = useRef('mjcf');
+ const [baseMode, setBaseMode] = useState('floating'),
+ baseModeRef = useRef('floating');
+ const [mapSelection, setMapSelection] = useState(DEFAULT_MAP_SELECTION),
+ mapSelectionRef = useRef(DEFAULT_MAP_SELECTION),
+ [showVisualMap, setShowVisualMap] = useState(true),
+ [showMapCollision, setShowMapCollision] = useState(false);
+ const [displayOptions, setDisplayOptions] = useState(initialDisplayOptions),
+ [showSensorCamera, setShowSensorCamera] = useState(true),
+ [theme, setTheme] = useState(initialTheme),
+ [jointAdvanced, setJointAdvanced] = useState(false),
+ [ignoreJointLimits, setIgnoreJointLimits] = useState(false),
+ [angleUnit, setAngleUnit] = useState<'rad' | 'deg'>('rad');
+ const showCollision = displayOptions.showCollision,
+ setShowCollision = (value: boolean) =>
+ setDisplayOptions((options) => ({ ...options, showCollision: value }));
+ const viewerSettings = useRef({
+ mode: state.mode,
+ forceScale,
+ displayOptions,
+ showVisualMap,
+ showMapCollision,
+ showSensorCamera,
+ theme,
+ });
+ useEffect(() => {
+ viewerSettings.current = {
+ mode: state.mode,
+ forceScale,
+ displayOptions,
+ showVisualMap,
+ showMapCollision,
+ showSensorCamera,
+ theme,
+ };
+ }, [
+ state.mode,
+ forceScale,
+ displayOptions,
+ showVisualMap,
+ showMapCollision,
+ showSensorCamera,
+ theme,
+ ]);
+ useEffect(() => {
+ const host = viewerHost.current;
+ if (!host) return;
+ let active = true;
+ const ready = import('../viewer/MuJoCoViewer')
+ .then(({ MuJoCoViewer: Viewer }) => {
+ if (!active) return null;
+ const next = new Viewer(host, {
+ onSelection: state.setSelection,
+ onFrame: (frame, fps, snapshot) => {
+ const memory = (performance as Performance & { memory?: { usedJSHeapSize: number } })
+ .memory?.usedJSHeapSize;
+ state.setMetrics(
+ fps,
+ frame.stepMs,
+ memory === undefined ? undefined : memory / 1048576,
+ frame.overBudget,
+ );
+ if (snapshot) {
+ state.setSnapshot(snapshot);
+ setControllerStatus(snapshot.controller);
+ setPolicyStatus(snapshot.rlPolicy);
+ if (snapshot.controller?.error || snapshot.rlPolicy?.error) {
+ adapter.current.setPaused(true);
+ state.setPaused(true);
+ }
+ }
+ },
+ onError: (error) => {
+ console.error('[MuJoCo] 视口运行失败', error);
+ state.setDiagnostic(
+ diagnostic(error.message.includes('控制器') ? '仿真' : '渲染', error),
+ );
+ },
+ onMapEditorSelect: (id) => editorInteraction.current?.onSelect(id),
+ onMapEditorTransform: (id, position, quaternion, scale) =>
+ editorInteraction.current?.onTransform({ id, position, quaternion, scale }),
+ });
+ if (!active) {
+ next.dispose();
+ return null;
+ }
+ viewer.current = next;
+ const settings = viewerSettings.current;
+ next.setMode(settings.mode);
+ next.forceScale = settings.forceScale;
+ next.setDisplayOptions(settings.displayOptions);
+ next.setMapDisplay(settings.showVisualMap, settings.showMapCollision);
+ next.setShowSensorCamera(settings.showSensorCamera);
+ next.setTheme(settings.theme);
+ return next;
+ })
+ .catch((error) => {
+ if (active) {
+ console.error('[MuJoCo] 三维视口初始化失败', error);
+ state.setDiagnostic(diagnostic('渲染', error));
+ }
+ return null;
+ });
+ viewerReady.current = ready;
+ return () => {
+ active = false;
+ if (viewerReady.current === ready) viewerReady.current = null;
+ viewer.current?.dispose();
+ viewer.current = null;
+ const retiredAdapter = adapter.current;
+ retiredAdapter.dispose();
+ if (adapter.current === retiredAdapter) adapter.current = new MainThreadPhysicsAdapter();
+ };
+ }, []);
+ useEffect(() => {
+ viewer.current?.setMode(state.mode);
+ }, [state.mode]);
+ useEffect(() => {
+ if (viewer.current) viewer.current.forceScale = forceScale;
+ }, [forceScale]);
+ useEffect(() => {
+ viewer.current?.setDisplayOptions(displayOptions);
+ try {
+ localStorage.setItem('mujoco-platform-display', JSON.stringify(displayOptions));
+ } catch {
+ /* 当前会话仍可修改 */
+ }
+ }, [displayOptions]);
+ useEffect(() => {
+ viewer.current?.setMapDisplay(showVisualMap, showMapCollision);
+ }, [showVisualMap, showMapCollision]);
+ useEffect(() => {
+ if (window.innerWidth < 900) return;
+ try {
+ localStorage.setItem(
+ 'mujoco-platform-layout',
+ JSON.stringify({ left: leftOpen, right: rightOpen }),
+ );
+ } catch {
+ /* 当前会话仍可修改 */
+ }
+ }, [leftOpen, rightOpen]);
+ useEffect(() => {
+ viewer.current?.setShowSensorCamera(showSensorCamera);
+ }, [showSensorCamera]);
+ useEffect(() => {
+ viewer.current?.setTheme(theme);
+ document.documentElement.style.colorScheme = theme;
+ try {
+ localStorage.setItem('mujoco-platform-theme', theme);
+ } catch {
+ /* 当前会话仍可切换 */
+ }
+ }, [theme]);
+ useEffect(() => {
+ const change = () => setFullscreen(document.fullscreenElement === root.current);
+ document.addEventListener('fullscreenchange', change);
+ return () => document.removeEventListener('fullscreenchange', change);
+ }, []);
+ const loadEntry = useCallback(async (path: string, requestedMode?: UrdfLoadMode) => {
+ if (!manifest.current || loadInFlight.current) return false;
+ const previousEntry = useAppStore.getState().selectedEntry;
+ loadInFlight.current = true;
+ setIgnoreJointLimits(false);
+ setControllerStatus(undefined);
+ setPolicyStatus(undefined);
+ state.setEntry(path);
+ state.setLoading(true);
+ setImportProgress({
+ title: '正在准备仿真',
+ label: '初始化三维视口',
+ detail: path,
+ value: 0.4,
+ });
+ state.setDiagnostic(undefined);
+ setGeneratedMjcf(undefined);
+ setGeneratedMjcfPath(undefined);
+ adapter.current.setPaused(true);
+ try {
+ const activeViewer = viewer.current ?? (await viewerReady.current);
+ if (!activeViewer) throw new Error('三维视口尚未就绪,请重试');
+ const snapshot = await adapter.current.load(manifest.current, path, {
+ urdfMode: requestedMode ?? urdfModeRef.current,
+ baseMode: baseModeRef.current,
+ enhancements: urdfEnhancementsRef.current,
+ map: mapSelectionRef.current,
+ onProgress: ({ value, label }) =>
+ setImportProgress({
+ title: '正在准备仿真',
+ label,
+ detail: path,
+ value: 0.4 + value * 0.53,
+ }),
+ });
+ const supportFiles = adapter.current.cachedSupportFiles();
+ setImportProgress({
+ title: '正在准备仿真',
+ label: '创建三维场景',
+ detail: path,
+ value: 0.94,
+ });
+ adapter.current.setSpeed(useAppStore.getState().speed);
+ try {
+ activeViewer.attach(adapter.current.session);
+ } catch (error) {
+ adapter.current.rollbackRetired();
+ activeViewer.attach(adapter.current.session);
+ throw error;
+ }
+ adapter.current.releaseRetired();
+ if (supportFiles.length && manifest.current) {
+ manifest.current = mergeCachedFiles(manifest.current, supportFiles);
+ state.setProject(
+ manifest.current.name,
+ manifest.current.files.map((file) => ({ path: file.path, size: file.size })),
+ manifest.current.entries,
+ path,
+ );
+ }
+ state.setSnapshot(snapshot);
+ state.setSelection(null);
+ state.setPaused(true);
+ setImportProgress({
+ title: '正在准备仿真',
+ label: '加载视觉地图与材质',
+ detail: path,
+ value: 0.97,
+ });
+ await activeViewer.setVisualMap(null);
+ let visualMapWarning: string | undefined;
+ try {
+ const asset = manifest.current
+ ? visualMapAsset(manifest.current, mapSelectionRef.current)
+ : null;
+ await activeViewer.setVisualMap(asset);
+ } catch (error) {
+ visualMapWarning = `视觉地图加载失败:${error instanceof Error ? error.message : String(error)}`;
+ console.warn('[MuJoCo] 视觉地图加载失败', error);
+ }
+ try {
+ setGeneratedMjcf(new TextDecoder().decode(adapter.current.exportMjcf()));
+ setGeneratedMjcfPath(convertedCachePath(path));
+ } catch (error) {
+ console.warn('[MuJoCo] 无法生成源码预览', error);
+ }
+ const notice: WorkbenchNotification = {
+ id: ++notificationId.current,
+ title:
+ snapshot.warnings.length || visualMapWarning
+ ? `模型已加载 · ${snapshot.warnings.length + (visualMapWarning ? 1 : 0)} 项兼容调整`
+ : '模型加载完成',
+ detail:
+ [...snapshot.warnings, ...(visualMapWarning ? [visualMapWarning] : [])].join('\n') ||
+ path,
+ tone: snapshot.warnings.length || visualMapWarning ? 'warning' : 'success',
+ at: Date.now(),
+ };
+ setNotifications((items) => [notice, ...items].slice(0, 20));
+ setToast(notice);
+ return true;
+ } catch (error) {
+ state.setDiagnostic(diagnostic('模型编译', error, path));
+ const notice: WorkbenchNotification = {
+ id: ++notificationId.current,
+ title: '模型编译失败',
+ detail: error instanceof Error ? error.message : String(error),
+ tone: 'danger',
+ at: Date.now(),
+ };
+ setNotifications((items) => [notice, ...items].slice(0, 20));
+ setToast(notice);
+ const retained = adapter.current.snapshot();
+ if (retained && previousEntry) state.setEntry(previousEntry);
+ setControllerStatus(retained?.controller);
+ setPolicyStatus(retained?.rlPolicy);
+ return false;
+ } finally {
+ loadInFlight.current = false;
+ setImportProgress(undefined);
+ state.setLoading(false);
+ }
+ }, []);
+ const requestLoadEntry = useCallback(
+ async (path: string) => {
+ const entry = manifest.current?.entries.find((candidate) => candidate.path === path);
+ if (entry?.format === 'urdf' && urdfModeRef.current === 'mjcf') {
+ setPendingUrdfMounts(urdfLinkNames(manifest.current, path));
+ setPendingUrdfPath(path);
+ return;
+ }
+ await loadEntry(path);
+ },
+ [loadEntry],
+ );
+ const confirmUrdfOptions = (options: UrdfEnhancementOptions) => {
+ const path = pendingUrdfPath;
+ if (!path) return;
+ urdfEnhancementsRef.current = options;
+ setPendingUrdfPath(undefined);
+ setPendingUrdfMounts([]);
+ void loadEntry(path);
+ };
+ const skipUrdfOptions = () =>
+ confirmUrdfOptions({ addActuators: false, addSensors: false, sensorType: 'camera' });
+ const ingest = useCallback(
+ async (files: File[], lockOwned = false) => {
+ if (importInFlight.current && !lockOwned) return;
+ importInFlight.current = true;
+ state.setLoading(true);
+ setImportProgress({
+ title: '正在导入工程',
+ label: '检查文件清单',
+ detail: files.length === 1 ? files[0].name : `${files.length} 个文件`,
+ value: 0.04,
+ });
+ try {
+ const next = await importBrowserFiles(
+ files,
+ DEFAULT_IMPORT_LIMITS,
+ ({ phase, completed, total, path }) => {
+ const ratio = total ? completed / total : 0;
+ const label =
+ phase === 'reading'
+ ? '读取工程文件'
+ : phase === 'extracting'
+ ? '在后台解压工程包'
+ : '索引模型与地图入口';
+ const value =
+ phase === 'reading'
+ ? 0.06 + ratio * 0.2
+ : phase === 'extracting'
+ ? 0.28 + ratio * 0.07
+ : 0.37;
+ setImportProgress({ title: '正在导入工程', label, detail: path, value });
+ },
+ );
+ setImportProgress({
+ title: '正在导入工程',
+ label: '处理模型资源与入口',
+ detail: `${next.files.length} 个文件`,
+ value: 0.39,
+ });
+ manifest.current = next;
+ setProjectMaps(next.maps);
+ setProjectSidebarTab('project');
+ setEditorDocument(null);
+ viewer.current?.setMapEditorDocument(null);
+ mapSelectionRef.current = DEFAULT_MAP_SELECTION;
+ setMapSelection(DEFAULT_MAP_SELECTION);
+ setSelectedControllerPath(next.files.find((file) => /\.py$/i.test(file.path))?.path);
+ setSelectedPolicyPath(next.files.find((file) => /\.onnx$/i.test(file.path))?.path);
+ state.setProject(
+ next.name,
+ next.files.map(({ path, size }) => ({ path, size })),
+ next.entries,
+ next.selectedEntry,
+ );
+ if (next.selectedEntry) await requestLoadEntry(next.selectedEntry);
+ } catch (error) {
+ state.setDiagnostic(
+ diagnostic(
+ error instanceof ProjectImportError && /ZIP/.test(error.message) ? 'ZIP' : '导入',
+ error,
+ error instanceof ProjectImportError ? error.path : undefined,
+ ),
+ );
+ const notice: WorkbenchNotification = {
+ id: ++notificationId.current,
+ title: '工程导入失败',
+ detail: error instanceof Error ? error.message : String(error),
+ tone: 'danger',
+ at: Date.now(),
+ };
+ setNotifications((items) => [notice, ...items].slice(0, 20));
+ setToast(notice);
+ } finally {
+ importInFlight.current = false;
+ setImportProgress(undefined);
+ state.setLoading(false);
+ }
+ },
+ [requestLoadEntry],
+ );
+ const removeProject = () => {
+ if (state.projectName) setRemoveConfirmOpen(true);
+ };
+ const confirmRemoveProject = () => {
+ void viewer.current?.setVisualMap(null);
+ viewer.current?.attach(null);
+ adapter.current.dispose();
+ adapter.current = new MainThreadPhysicsAdapter();
+ manifest.current = null;
+ setGeneratedMjcf(undefined);
+ setGeneratedMjcfPath(undefined);
+ setPendingUrdfPath(undefined);
+ setPendingUrdfMounts([]);
+ setSelectedControllerPath(undefined);
+ setControllerStatus(undefined);
+ setSelectedPolicyPath(undefined);
+ setPolicyStatus(undefined);
+ setProjectMaps([]);
+ setProjectSidebarTab('project');
+ setEditorDocument(null);
+ viewer.current?.setMapEditorDocument(null);
+ mapSelectionRef.current = DEFAULT_MAP_SELECTION;
+ setMapSelection(DEFAULT_MAP_SELECTION);
+ state.clearProject();
+ setRemoveConfirmOpen(false);
+ };
+ const changeUrdfMode = (value: UrdfLoadMode) => {
+ setUrdfMode(value);
+ urdfModeRef.current = value;
+ const entry = state.entries.find((candidate) => candidate.path === state.selectedEntry);
+ if (entry?.format !== 'urdf') return;
+ if (value === 'mjcf') {
+ setPendingUrdfMounts(urdfLinkNames(manifest.current, entry.path));
+ setPendingUrdfPath(entry.path);
+ } else void loadEntry(entry.path, value);
+ };
+ const changeBaseMode = (value: UrdfBaseMode) => {
+ setBaseMode(value);
+ baseModeRef.current = value;
+ const entry = state.entries.find((candidate) => candidate.path === state.selectedEntry);
+ if (entry?.format === 'urdf' && urdfModeRef.current === 'mjcf')
+ void loadEntry(entry.path, 'mjcf');
+ };
+ const previewEditorDocument = useCallback((document: EditableMapDocument | null) => {
+ viewer.current?.setMapEditorDocument(document);
+ if (document) {
+ adapter.current.setPaused(true);
+ state.setPaused(true);
+ }
+ }, []);
+ const bindEditorInteraction = useCallback((callbacks: MapEditorInteractionCallbacks | null) => {
+ editorInteraction.current = callbacks;
+ const pending = pendingMapAsset.current;
+ if (callbacks && pending) {
+ pendingMapAsset.current = null;
+ callbacks.onAddAsset(pending.type, pending.position, pending.placementMode);
+ }
+ }, []);
+ const selectEditorObject = useCallback((id: string | null) => {
+ viewer.current?.selectMapEditorObject(id);
+ }, []);
+ const setEditorTransformMode = useCallback((mode: MapEditorTransformMode) => {
+ viewer.current?.setMapEditorTransformMode(mode);
+ }, []);
+ const setEditorSnapping = useCallback(
+ (translation: number | null, rotationDegrees: number | null) => {
+ viewer.current?.setMapEditorSnapping(translation, rotationDegrees);
+ },
+ [],
+ );
+ const readEditorDocument = (selection: MapSelection): EditableMapDocument | null => {
+ if (selection.kind !== 'project' || !manifest.current) return null;
+ const resolved = resolveProjectMap(manifest.current, selection.descriptorPath);
+ if (!resolved.authoringPath) return null;
+ const file = manifest.current.files.find(
+ (candidate) => candidate.path === resolved.authoringPath,
+ );
+ return file ? decodeEditableMapDocument(file.data) : null;
+ };
+ const createEditableScene = async (
+ type: EditableMapObjectType,
+ position?: [number, number, number],
+ placementMode: MapObjectPlacementMode = 'auto_ground',
+ ): Promise => {
+ const current = manifest.current;
+ const entryPath = state.selectedEntry;
+ const entry = state.entries.find((candidate) => candidate.path === entryPath);
+ if (!current || !entryPath || !entry || loadInFlight.current) return false;
+ if (entry.format === 'urdf' && urdfModeRef.current === 'native') {
+ state.setDiagnostic(
+ diagnostic('模型编译', new Error('原生 URDF 不能创建 MJCF 场景,请切换为转换模式')),
+ );
+ return false;
+ }
+ if (current.files.length + 3 > DEFAULT_IMPORT_LIMITS.maxFiles) {
+ state.setDiagnostic(
+ diagnostic('文件系统', new Error('工程文件数量已达到上限,无法创建场景')),
+ );
+ return false;
+ }
+
+ let index = 1;
+ while (
+ current.maps.some((map) => map.id === `scene_${index}`) ||
+ current.files.some((file) => file.path.startsWith(`maps/scene_${index}/`))
+ )
+ index += 1;
+ const mapId = `scene_${index}`;
+ const directory = `maps/${mapId}`;
+ const descriptorPath = `${directory}/map.json`;
+ const physicsPath = `${directory}/physics/world.xml`;
+ const authoringPath = `${directory}/authoring/map.scene.json`;
+ const document: EditableMapDocument = {
+ schemaVersion: 1,
+ mapId,
+ revision: 0,
+ objects: [],
+ spawnPoints: [],
+ };
+ const definition = {
+ schemaVersion: 2 as const,
+ id: mapId,
+ name: `场景 ${index}`,
+ coordinateSystem: { units: 'm' as const, up: 'Z' as const, forward: '+X' as const },
+ physics: { source: 'physics/world.xml' },
+ authoring: { source: 'authoring/map.scene.json' },
+ spawnPoints: [],
+ };
+ const descriptorData = new TextEncoder().encode(`${JSON.stringify(definition, null, 2)}\n`);
+ const physicsData = compileEditableMapDocument(document);
+ const authoringData = encodeEditableMapDocument(document);
+ const source = current.files.find((file) => file.path === entryPath)?.source ?? 'file';
+ const files = [
+ ...current.files,
+ {
+ path: descriptorPath,
+ data: descriptorData,
+ size: descriptorData.byteLength,
+ source,
+ mimeType: 'application/json',
+ },
+ {
+ path: physicsPath,
+ data: physicsData,
+ size: physicsData.byteLength,
+ source,
+ mimeType: 'application/xml',
+ },
+ {
+ path: authoringPath,
+ data: authoringData,
+ size: authoringData.byteLength,
+ source,
+ mimeType: 'application/json',
+ },
+ ];
+ const totalBytes = files.reduce((total, file) => total + file.size, 0);
+ if (totalBytes > DEFAULT_IMPORT_LIMITS.maxTotalBytes) {
+ state.setDiagnostic(diagnostic('文件系统', new Error('创建场景后工程总大小超过 512 MiB')));
+ return false;
+ }
+ try {
+ const maps = discoverMapEntries(files);
+ const candidate: ProjectManifest = { ...current, files, maps, totalBytes };
+ const selection: MapSelection = { kind: 'project', descriptorPath };
+ pendingMapAsset.current = { type, position, placementMode };
+ manifest.current = candidate;
+ mapSelectionRef.current = selection;
+ setProjectMaps(maps);
+ setEditorDocument(document);
+ setMapSelection(selection);
+ previewEditorDocument(document);
+ state.setProject(
+ candidate.name,
+ candidate.files.map((file) => ({ path: file.path, size: file.size })),
+ candidate.entries,
+ entryPath,
+ );
+ state.setSnapshot(adapter.current.snapshot() ?? undefined);
+ return true;
+ } catch (error) {
+ pendingMapAsset.current = null;
+ state.setDiagnostic(diagnostic('文件系统', error, descriptorPath));
+ return false;
+ }
+ };
+ const addCertifiedMapAsset = async (
+ type: EditableMapObjectType,
+ position?: [number, number, number],
+ placementMode: MapObjectPlacementMode = 'auto_ground',
+ ) => {
+ if (state.loading || loadInFlight.current) return;
+ const interaction = editorInteraction.current;
+ if (interaction) {
+ interaction.onAddAsset(type, position, placementMode);
+ return;
+ }
+ await createEditableScene(type, position, placementMode);
+ };
+ const applyMapSelection = (value: MapSelection) => {
+ const previous = mapSelectionRef.current;
+ const entry = state.entries.find((candidate) => candidate.path === state.selectedEntry);
+ if (!entry) return;
+ if (entry.format === 'urdf' && urdfModeRef.current === 'native' && value.kind !== 'none') {
+ state.setDiagnostic(
+ diagnostic('模型编译', new Error('原生 URDF 不能注入地图,请切换为转换模式'), entry.path),
+ );
+ return;
+ }
+ mapSelectionRef.current = value;
+ setMapSelection(value);
+ void loadEntry(entry.path).then((loaded) => {
+ if (loaded) {
+ setEditorDocument(readEditorDocument(value));
+ viewer.current?.setMapEditorDocument(null);
+ return;
+ }
+ mapSelectionRef.current = previous;
+ setMapSelection(previous);
+ });
+ };
+ const selectTerrainAsset = (preset: SystemTerrainPreset) => {
+ const current = mapSelectionRef.current;
+ applyMapSelection({
+ kind: 'builtin',
+ config: {
+ ...(current.kind === 'builtin' ? current.config : DEFAULT_PHYSICAL_MAP_CONFIG),
+ preset,
+ },
+ });
+ };
+ const applyEditorDocument = async (document: EditableMapDocument): Promise => {
+ const selection = mapSelectionRef.current;
+ const current = manifest.current;
+ const entryPath = state.selectedEntry;
+ if (selection.kind !== 'project' || !current || !entryPath) return false;
+ const resolved = resolveProjectMap(current, selection.descriptorPath);
+ if (!resolved.authoringPath || !resolved.physicsPath) {
+ state.setDiagnostic(
+ diagnostic(
+ '模型编译',
+ new Error('可编辑地图必须同时声明 authoring.source 和 physics.source'),
+ ),
+ );
+ return false;
+ }
+ try {
+ const authoringData = encodeEditableMapDocument(document);
+ const physicsData = compileEditableMapDocument(document);
+ const definition = decodeMapDefinition(
+ current.files.find((file) => file.path === resolved.descriptorPath)!.data,
+ );
+ definition.spawnPoints = document.spawnPoints;
+ const descriptorData = new TextEncoder().encode(`${JSON.stringify(definition, null, 2)}\n`);
+ const replacements = new Map([
+ [resolved.authoringPath, authoringData],
+ [resolved.physicsPath, physicsData],
+ [resolved.descriptorPath, descriptorData],
+ ]);
+ const files = current.files.map((file) => {
+ const data = replacements.get(file.path);
+ return data ? { ...file, data, size: data.byteLength } : file;
+ });
+ const candidate: ProjectManifest = {
+ ...current,
+ files,
+ maps: discoverMapEntries(files),
+ totalBytes: files.reduce((total, file) => total + file.size, 0),
+ };
+ manifest.current = candidate;
+ const loaded = await loadEntry(entryPath);
+ if (!loaded) {
+ manifest.current = current;
+ return false;
+ }
+ const loadedManifest = manifest.current ?? candidate;
+ const maps = discoverMapEntries(loadedManifest.files);
+ const committed = { ...loadedManifest, maps };
+ manifest.current = committed;
+ setProjectMaps(maps);
+ setEditorDocument(document);
+ viewer.current?.setMapEditorDocument(null);
+ state.setProject(
+ committed.name,
+ committed.files.map((file) => ({ path: file.path, size: file.size })),
+ committed.entries,
+ entryPath,
+ );
+ state.setSnapshot(adapter.current.snapshot() ?? undefined);
+ return true;
+ } catch (error) {
+ manifest.current = current;
+ state.setDiagnostic(diagnostic('模型编译', error, resolved.authoringPath));
+ return false;
+ }
+ };
+ const convertSelectedMap = async (): Promise => {
+ const selection = mapSelectionRef.current;
+ const current = manifest.current;
+ const entryPath = state.selectedEntry;
+ if (selection.kind !== 'project' || !current || !entryPath) return false;
+ let diagnosticPath = selection.descriptorPath;
+ try {
+ const resolved = resolveProjectMap(current, selection.descriptorPath);
+ if (resolved.authoringPath) {
+ setEditorDocument(readEditorDocument(selection));
+ return true;
+ }
+ if (!resolved.physicsPath)
+ throw new Error('只有包含 physics.source 的静态 MJCF 地图可以转换');
+ diagnosticPath = resolved.physicsPath;
+ const physicsFile = current.files.find((file) => file.path === resolved.physicsPath);
+ const descriptorFile = current.files.find((file) => file.path === resolved.descriptorPath);
+ if (!physicsFile || !descriptorFile) throw new Error('地图物理层或描述文件不存在');
+ const document = importEditableMapDocument(physicsFile.data, resolved.definition);
+ const authoringReference = 'authoring/map.scene.json';
+ const authoringPath = resolveProjectAssetPath(resolved.descriptorPath, authoringReference);
+ if (current.files.some((file) => file.path === authoringPath))
+ throw new Error(`目标创作层已存在但未被地图引用:${authoringPath}`);
+ if (current.files.length >= DEFAULT_IMPORT_LIMITS.maxFiles)
+ throw new Error('工程文件数量已达到上限,无法创建创作层');
+
+ const definition = {
+ ...resolved.definition,
+ schemaVersion: 2 as const,
+ authoring: { source: authoringReference },
+ spawnPoints: document.spawnPoints,
+ };
+ const descriptorData = new TextEncoder().encode(`${JSON.stringify(definition, null, 2)}\n`);
+ const physicsData = compileEditableMapDocument(document);
+ const authoringData = encodeEditableMapDocument(document);
+ const files = current.files.map((file) => {
+ if (file.path === resolved.descriptorPath)
+ return { ...file, data: descriptorData, size: descriptorData.byteLength };
+ if (file.path === resolved.physicsPath)
+ return { ...file, data: physicsData, size: physicsData.byteLength };
+ return file;
+ });
+ files.push({
+ path: authoringPath,
+ data: authoringData,
+ size: authoringData.byteLength,
+ source: descriptorFile.source,
+ mimeType: 'application/json',
+ });
+ const totalBytes = files.reduce((total, file) => total + file.size, 0);
+ if (totalBytes > DEFAULT_IMPORT_LIMITS.maxTotalBytes)
+ throw new Error('创建创作层后工程总大小超过 512 MiB');
+ const candidate: ProjectManifest = {
+ ...current,
+ files,
+ maps: discoverMapEntries(files),
+ totalBytes,
+ };
+ manifest.current = candidate;
+ const loaded = await loadEntry(entryPath);
+ if (!loaded) {
+ manifest.current = current;
+ return false;
+ }
+ const loadedManifest = manifest.current ?? candidate;
+ const maps = discoverMapEntries(loadedManifest.files);
+ const committed = { ...loadedManifest, maps };
+ manifest.current = committed;
+ setProjectMaps(maps);
+ setEditorDocument(document);
+ state.setProject(
+ committed.name,
+ committed.files.map((file) => ({ path: file.path, size: file.size })),
+ committed.entries,
+ entryPath,
+ );
+ state.setSnapshot(adapter.current.snapshot() ?? undefined);
+ notify('已创建可编辑地图副本', authoringPath);
+ return true;
+ } catch (error) {
+ manifest.current = current;
+ state.setDiagnostic(diagnostic('模型编译', error, diagnosticPath));
+ return false;
+ }
+ };
+ const exportSelectedMap = async () => {
+ const selection = mapSelectionRef.current;
+ if (selection.kind !== 'project' || !manifest.current) return;
+ try {
+ const { exportMapPackage } = await import('../map/editor/MapPackageExporter');
+ const entry = projectMaps.find((map) => map.descriptorPath === selection.descriptorPath);
+ downloadBytes(
+ exportMapPackage(manifest.current, selection.descriptorPath),
+ `${entry?.id ?? 'map'}-map.zip`,
+ 'application/zip',
+ );
+ } catch (error) {
+ state.setDiagnostic(diagnostic('文件系统', error, selection.descriptorPath));
+ }
+ };
+ const changeFiles = (event: ChangeEvent) => {
+ void ingest(Array.from(event.target.files ?? []));
+ event.target.value = '';
+ };
+ const resetDragState = () => {
+ dragDepth.current = 0;
+ setDragActive(false);
+ };
+ const dragEnter = (event: DragEvent) => {
+ if (
+ event.dataTransfer.types.includes('Files') &&
+ !event.dataTransfer.types.includes(MAP_ASSET_DRAG_MIME)
+ ) {
+ dragDepth.current += 1;
+ if (!state.loading) setDragActive(true);
+ }
+ };
+ const dragLeave = (event: DragEvent) => {
+ if (!event.dataTransfer.types.includes('Files')) return;
+ dragDepth.current = Math.max(0, dragDepth.current - 1);
+ if (dragDepth.current === 0) setDragActive(false);
+ };
+ const dragOver = (event: DragEvent) => {
+ event.preventDefault();
+ if (event.dataTransfer.types.includes(MAP_ASSET_DRAG_MIME)) {
+ event.dataTransfer.dropEffect = viewer.current?.mapPlanePoint(event.clientX, event.clientY)
+ ? 'copy'
+ : 'none';
+ return;
+ }
+ if (event.dataTransfer.types.includes('Files'))
+ event.dataTransfer.dropEffect = state.loading ? 'none' : 'copy';
+ };
+ const drop = (event: DragEvent) => {
+ event.preventDefault();
+ resetDragState();
+ const assetType = event.dataTransfer.getData(MAP_ASSET_DRAG_MIME),
+ requestedPlacement = event.dataTransfer.getData(MAP_ASSET_PLACEMENT_MIME),
+ placementMode = isMapObjectPlacementMode(requestedPlacement)
+ ? requestedPlacement
+ : 'auto_ground';
+ if (isEditableMapObjectType(assetType)) {
+ event.stopPropagation();
+ const position = viewer.current?.mapPlanePoint(event.clientX, event.clientY);
+ if (position) void addCertifiedMapAsset(assetType, position, placementMode);
+ return;
+ }
+ if (state.loading || importInFlight.current) return;
+ importInFlight.current = true;
+ state.setLoading(true);
+ setImportProgress({
+ title: '正在导入工程',
+ label: '扫描拖放的文件与文件夹',
+ value: 0.02,
+ });
+ void (async () => {
+ try {
+ const files = await filesFromDrop(event.dataTransfer.items, event.dataTransfer.files);
+ await ingest(files, true);
+ } catch (error) {
+ importInFlight.current = false;
+ setImportProgress(undefined);
+ state.setLoading(false);
+ state.setDiagnostic(diagnostic('导入', error));
+ }
+ })();
+ };
+ const togglePause = () => {
+ const value = !state.paused;
+ state.setPaused(value);
+ adapter.current.setPaused(value);
+ };
+ const reset = () => {
+ adapter.current.setPaused(true);
+ adapter.current.reset();
+ state.setSnapshot(adapter.current.snapshot() ?? undefined);
+ state.setPaused(true);
+ };
+ const singleStep = () => {
+ adapter.current.singleStep();
+ state.setSnapshot(adapter.current.snapshot() ?? undefined);
+ };
+ const changeSpeed = (value: number) => {
+ state.setSpeed(value);
+ adapter.current.setSpeed(value);
+ };
+ const mode = (value: InteractionMode) => state.setMode(value);
+ const resetJoints = () => {
+ adapter.current.resetJoints();
+ state.setPaused(true);
+ state.setSnapshot(adapter.current.snapshot() ?? undefined);
+ };
+ const toggleJointLimits = () => {
+ const next = !ignoreJointLimits;
+ adapter.current.setIgnoreJointLimits(next);
+ setIgnoreJointLimits(next);
+ state.setSnapshot(adapter.current.snapshot() ?? undefined);
+ };
+ const setActuator = (id: number, value: number) => {
+ adapter.current.setActuator(id, value);
+ state.setSnapshot(adapter.current.snapshot() ?? undefined);
+ };
+ const setActuatorParameters = (id: number, parameters: ActuatorParameters) => {
+ if (!adapter.current.setActuatorParameters(id, parameters)) return;
+ state.setSnapshot(adapter.current.snapshot() ?? undefined);
+ try {
+ setGeneratedMjcf(new TextDecoder().decode(adapter.current.exportMjcf()));
+ } catch (error) {
+ console.warn('[MuJoCo] 无法刷新驱动器参数源码', error);
+ }
+ };
+ const setJoint = (id: number, value: number) => {
+ adapter.current.setJointPosition(id, value);
+ state.setPaused(true);
+ state.setSnapshot(adapter.current.snapshot() ?? undefined);
+ };
+ const loadControllerSource = async (source: string, path: string) => {
+ state.setLoading(true);
+ setImportProgress({
+ title: '正在加载控制器',
+ label: '初始化 Python 运行时',
+ detail: path,
+ value: 0.5,
+ });
+ state.setDiagnostic(undefined);
+ try {
+ const status = await adapter.current.loadPythonController(source, path);
+ setControllerStatus(status);
+ state.setSnapshot(adapter.current.snapshot() ?? undefined);
+ notify('Python 控制器已加载', `${status.name} · ${status.controlHz} Hz`);
+ } catch (error) {
+ state.setDiagnostic(diagnostic('仿真', error, path));
+ } finally {
+ setImportProgress(undefined);
+ state.setLoading(false);
+ }
+ };
+ const loadControllerPath = (path: string) => {
+ const file = manifest.current?.files.find((candidate) => candidate.path === path);
+ if (!file) {
+ state.setDiagnostic(diagnostic('仿真', new Error('工程中找不到控制脚本'), path));
+ return;
+ }
+ setSelectedControllerPath(path);
+ void loadControllerSource(new TextDecoder().decode(file.data), path);
+ };
+ const importController = (file: File) => {
+ void (async () => {
+ try {
+ if (!/\.py$/i.test(file.name)) throw new Error('请选择 .py 文件');
+ if (file.size > 1024 * 1024) throw new Error('Python 控制脚本不能超过 1 MiB');
+ const path = normalizeProjectPath(file.name),
+ data = new Uint8Array(await file.arrayBuffer());
+ if (manifest.current) {
+ const index = manifest.current.files.findIndex((candidate) => candidate.path === path),
+ files = manifest.current.files.slice(),
+ entry = {
+ path,
+ data,
+ size: data.byteLength,
+ source: 'file' as const,
+ mimeType: file.type || 'text/x-python',
+ };
+ if (index >= 0) files[index] = entry;
+ else files.push(entry);
+ manifest.current = {
+ ...manifest.current,
+ files,
+ totalBytes: files.reduce((total, item) => total + item.size, 0),
+ };
+ state.setProject(
+ manifest.current.name,
+ files.map(({ path: filePath, size }) => ({ path: filePath, size })),
+ manifest.current.entries,
+ manifest.current.selectedEntry,
+ );
+ state.setSnapshot(adapter.current.snapshot() ?? undefined);
+ }
+ setSelectedControllerPath(path);
+ await loadControllerSource(new TextDecoder().decode(data), path);
+ } catch (error) {
+ state.setDiagnostic(diagnostic('仿真', error, file.name));
+ }
+ })();
+ };
+ const toggleController = (enabled: boolean) => {
+ adapter.current.setControllerEnabled(enabled);
+ const snapshot = adapter.current.snapshot() ?? undefined;
+ setControllerStatus(snapshot?.controller);
+ setPolicyStatus(snapshot?.rlPolicy);
+ state.setSnapshot(snapshot);
+ };
+ const sendControllerCommand = (command: ControllerCommand) => {
+ try {
+ adapter.current.sendControllerCommand(command);
+ const snapshot = adapter.current.snapshot() ?? undefined;
+ setControllerStatus(snapshot?.controller);
+ state.setSnapshot(snapshot);
+ } catch (error) {
+ state.setDiagnostic(diagnostic('仿真', error, selectedControllerPath));
+ }
+ };
+ const removeController = () => {
+ adapter.current.removeController();
+ setControllerStatus(undefined);
+ state.setSnapshot(adapter.current.snapshot() ?? undefined);
+ };
+ const loadPolicyBytes = async (data: Uint8Array, path: string) => {
+ state.setLoading(true);
+ setImportProgress({
+ title: '正在加载强化学习策略',
+ label: '初始化 ONNX Runtime',
+ detail: path,
+ value: 0.55,
+ });
+ state.setDiagnostic(undefined);
+ try {
+ const status = await adapter.current.loadRLPolicy(data, path);
+ setPolicyStatus(status);
+ state.setSnapshot(adapter.current.snapshot() ?? undefined);
+ notify(
+ 'ONNX 策略已加载',
+ `${status.taskName} · ${status.observationSize} → ${status.actionSize}`,
+ );
+ } catch (error) {
+ state.setDiagnostic(diagnostic('仿真', error, path));
+ } finally {
+ setImportProgress(undefined);
+ state.setLoading(false);
+ }
+ };
+ const loadPolicyPath = (path: string) => {
+ const file = manifest.current?.files.find((candidate) => candidate.path === path);
+ if (!file) {
+ state.setDiagnostic(diagnostic('仿真', new Error('工程中找不到 ONNX 策略'), path));
+ return;
+ }
+ setSelectedPolicyPath(path);
+ void loadPolicyBytes(file.data, path);
+ };
+ const importPolicy = (file: File) => {
+ void (async () => {
+ try {
+ if (!/\.onnx$/i.test(file.name)) throw new Error('请选择 .onnx 文件');
+ if (file.size > 64 * 1024 * 1024) throw new Error('ONNX 策略不能超过 64 MiB');
+ const path = normalizeProjectPath(file.name),
+ data = new Uint8Array(await file.arrayBuffer());
+ if (manifest.current) {
+ const index = manifest.current.files.findIndex((candidate) => candidate.path === path),
+ files = manifest.current.files.slice(),
+ entry = {
+ path,
+ data,
+ size: data.byteLength,
+ source: 'file' as const,
+ mimeType: file.type || 'application/octet-stream',
+ };
+ if (index >= 0) files[index] = entry;
+ else files.push(entry);
+ const totalBytes = files.reduce((total, item) => total + item.size, 0);
+ if (totalBytes > DEFAULT_IMPORT_LIMITS.maxTotalBytes)
+ throw new Error('加入 ONNX 后工程总大小超过 512 MiB');
+ manifest.current = { ...manifest.current, files, totalBytes };
+ state.setProject(
+ manifest.current.name,
+ files.map(({ path: filePath, size }) => ({ path: filePath, size })),
+ manifest.current.entries,
+ manifest.current.selectedEntry,
+ );
+ state.setSnapshot(adapter.current.snapshot() ?? undefined);
+ }
+ setSelectedPolicyPath(path);
+ await loadPolicyBytes(data, path);
+ } catch (error) {
+ state.setDiagnostic(diagnostic('仿真', error, file.name));
+ }
+ })();
+ };
+ const togglePolicy = (enabled: boolean) => {
+ adapter.current.setRLPolicyEnabled(enabled);
+ const snapshot = adapter.current.snapshot() ?? undefined;
+ setPolicyStatus(snapshot?.rlPolicy);
+ setControllerStatus(snapshot?.controller);
+ state.setSnapshot(snapshot);
+ };
+ const setPolicyCommand = (command: RLCommand) => {
+ adapter.current.setRLCommand(command);
+ const snapshot = adapter.current.snapshot() ?? undefined;
+ setPolicyStatus(snapshot?.rlPolicy);
+ state.setSnapshot(snapshot);
+ };
+ const removePolicy = () => {
+ adapter.current.removeRLPolicy();
+ setPolicyStatus(undefined);
+ state.setSnapshot(adapter.current.snapshot() ?? undefined);
+ };
+ const configureDataRecorder = (patch: Partial) => {
+ try {
+ adapter.current.configureDataRecorder(patch);
+ state.setSnapshot(adapter.current.snapshot() ?? undefined);
+ } catch (error) {
+ state.setDiagnostic(diagnostic('仿真', error, state.selectedEntry));
+ }
+ };
+ const startDataRecording = () => {
+ adapter.current.startDataRecording();
+ state.setSnapshot(adapter.current.snapshot() ?? undefined);
+ };
+ const stopDataRecording = () => {
+ adapter.current.stopDataRecording();
+ state.setSnapshot(adapter.current.snapshot() ?? undefined);
+ };
+ const clearDataRecording = () => {
+ adapter.current.clearDataRecording();
+ state.setSnapshot(adapter.current.snapshot() ?? undefined);
+ };
+ const exportDataRecording = (format: 'csv' | 'json') => {
+ try {
+ const stem =
+ (manifest.current?.name ?? 'simulation')
+ .replace(/\.(?:zip|xml|urdf)$/i, '')
+ .replace(/[^\p{L}\p{N}._-]+/gu, '_') || 'simulation';
+ downloadBytes(
+ adapter.current.exportDataRecording(format),
+ `${stem}-telemetry.${format}`,
+ format === 'csv' ? 'text/csv' : 'application/json',
+ );
+ notify(`遥测 ${format.toUpperCase()} 已导出`, `${stem}-telemetry.${format}`);
+ } catch (error) {
+ state.setDiagnostic(diagnostic('仿真', error, state.selectedEntry));
+ }
+ };
+ const notify = (
+ title: string,
+ detail: string,
+ tone: WorkbenchNotification['tone'] = 'success',
+ ) => {
+ const notice: WorkbenchNotification = {
+ id: ++notificationId.current,
+ title,
+ detail,
+ tone,
+ at: Date.now(),
+ };
+ setNotifications((items) => [notice, ...items].slice(0, 20));
+ setToast(notice);
+ };
+ const saveCachedSource = async (path: string, text: string) => {
+ if (!manifest.current) return;
+ manifest.current = upsertCachedMjcf(manifest.current, path, text);
+ state.setProject(
+ manifest.current.name,
+ manifest.current.files.map((file) => ({ path: file.path, size: file.size })),
+ manifest.current.entries,
+ path,
+ );
+ notify('转换后的 MJCF 已保存到缓存', path);
+ await loadEntry(path);
+ };
+ const exportUrdf = () => {
+ if (!manifest.current || selectedFormat !== 'urdf' || !state.selectedEntry) return;
+ const text = readCachedText(manifest.current, state.selectedEntry);
+ downloadBytes(new TextEncoder().encode(text), exportedFileName(manifest.current.name, 'urdf'));
+ notify('URDF 已导出', state.selectedEntry);
+ };
+ const exportMjcf = () => {
+ try {
+ const data = adapter.current.exportMjcf();
+ downloadBytes(data, exportedFileName(manifest.current?.name ?? 'model', 'xml'));
+ notify('MJCF 已导出', '导出内容来自当前已编译模型');
+ } catch (error) {
+ state.setDiagnostic(diagnostic('模型编译', error, state.selectedEntry));
+ }
+ };
+ const toggleFullscreen = () => {
+ if (document.fullscreenElement) void document.exitFullscreen().catch(() => {});
+ else if (root.current) void root.current.requestFullscreen().catch(() => {});
+ };
+ const applyLayoutPreset = (preset: LayoutPreset) => {
+ if (preset === 'viewport') {
+ setLeftOpen(false);
+ setRightOpen(false);
+ dispatchLayoutWidths(288, 288);
+ } else if (preset === 'project') {
+ setLeftOpen(true);
+ setRightOpen(false);
+ dispatchLayoutWidths(384, 288);
+ } else if (preset === 'control') {
+ setLeftOpen(false);
+ setRightOpen(true);
+ dispatchLayoutWidths(288, 384);
+ } else {
+ setLeftOpen(true);
+ setRightOpen(true);
+ dispatchLayoutWidths(288, 288);
+ }
+ };
+ useEffect(() => {
+ const key = (event: KeyboardEvent) => {
+ if (
+ document.activeElement instanceof HTMLElement &&
+ document.activeElement.closest('[role="dialog"]')
+ )
+ return;
+ if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === 'k') {
+ event.preventDefault();
+ setCommandOpen(true);
+ return;
+ }
+ if ((event.target as HTMLElement).matches('input,select,button')) return;
+ if (event.code === 'Space') {
+ event.preventDefault();
+ togglePause();
+ }
+ if (event.key === 'r') reset();
+ if (event.key === '1') mode('select');
+ if (event.key === '2') mode('joint');
+ if (event.key === '3') mode('force');
+ };
+ window.addEventListener('keydown', key);
+ return () => window.removeEventListener('keydown', key);
+ });
+ const selectedFormat = state.entries.find((entry) => entry.path === state.selectedEntry)?.format;
+ const commands: WorkbenchCommand[] = [
+ {
+ id: 'play',
+ label: state.paused ? '播放仿真' : '暂停仿真',
+ group: '仿真',
+ icon: state.paused ? : ,
+ shortcut: 'Space',
+ disabled: !state.snapshot,
+ run: togglePause,
+ },
+ {
+ id: 'reset',
+ label: '重置仿真',
+ group: '仿真',
+ icon: ,
+ shortcut: 'R',
+ disabled: !state.snapshot,
+ run: reset,
+ },
+ {
+ id: 'select',
+ label: '切换到选择模式',
+ group: '视口',
+ icon: ,
+ shortcut: '1',
+ run: () => mode('select'),
+ },
+ {
+ id: 'joint',
+ label: '切换到关节拖动',
+ group: '视口',
+ icon: ,
+ shortcut: '2',
+ run: () => mode('joint'),
+ },
+ {
+ id: 'force',
+ label: '切换到外力施加',
+ group: '视口',
+ icon: ,
+ shortcut: '3',
+ run: () => mode('force'),
+ },
+ {
+ id: 'camera',
+ label: '复位相机',
+ group: '视口',
+ icon: ,
+ run: () => viewer.current?.resetCamera(),
+ },
+ {
+ id: 'source',
+ label: '查看和修改缓存源代码',
+ group: '工程',
+ icon: ,
+ disabled: !generatedMjcf,
+ run: () => setSourceOpen(true),
+ },
+ {
+ id: 'export-urdf',
+ label: '导出 URDF 文件',
+ group: '工程',
+ icon: ,
+ disabled: selectedFormat !== 'urdf',
+ run: exportUrdf,
+ },
+ {
+ id: 'export-mjcf',
+ label: '导出 MJCF 文件',
+ group: '工程',
+ icon: ,
+ disabled: !state.snapshot,
+ run: exportMjcf,
+ },
+ {
+ id: 'left',
+ label: leftOpen ? '隐藏工程面板' : '显示工程面板',
+ group: '布局',
+ icon: leftOpen ? : ,
+ run: () => setLeftOpen((value) => !value),
+ },
+ {
+ id: 'right',
+ label: rightOpen ? '隐藏属性面板' : '显示属性面板',
+ group: '布局',
+ icon: rightOpen ? : ,
+ run: () => setRightOpen((value) => !value),
+ },
+ {
+ id: 'theme',
+ label: theme === 'dark' ? '切换到白天主题' : '切换到黑夜主题',
+ group: '外观',
+ icon: ,
+ run: () => setTheme((value) => (value === 'dark' ? 'light' : 'dark')),
+ },
+ {
+ id: 'fullscreen',
+ label: fullscreen ? '退出全屏' : '进入全屏',
+ group: '布局',
+ icon: ,
+ run: toggleFullscreen,
+ },
+ {
+ id: 'help',
+ label: '查看快捷键帮助',
+ group: '帮助',
+ icon: ,
+ run: () => setHelpOpen(true),
+ },
+ ];
+ return (
+
+
setSourceOpen(true)}
+ onTogglePause={togglePause}
+ onStep={singleStep}
+ onReset={reset}
+ onSpeed={changeSpeed}
+ onToggleLeft={() => setLeftOpen((value) => !value)}
+ onToggleRight={() => setRightOpen((value) => !value)}
+ onToggleTheme={() => setTheme((value) => (value === 'dark' ? 'light' : 'dark'))}
+ onHelp={() => setHelpOpen(true)}
+ endActions={
+ <>
+
+ setNotifications((items) => items.filter((item) => item.id !== id))
+ }
+ onClear={() => setNotifications([])}
+ onOpenLog={() => setDiagnosticsOpen(true)}
+ />
+
+ setLayoutOpen(true)}
+ >
+
+
+ setSettingsOpen(true)}
+ >
+
+
+
+ >
+ }
+ compactMenu={
+ setCommandOpen(true)}
+ onLayout={() => setLayoutOpen(true)}
+ onSettings={() => setSettingsOpen(true)}
+ onFullscreen={toggleFullscreen}
+ onHelp={() => setHelpOpen(true)}
+ onTheme={() => setTheme((value) => (value === 'dark' ? 'light' : 'dark'))}
+ />
+ }
+ onCommands={() => setCommandOpen(true)}
+ onToggleFullscreen={toggleFullscreen}
+ center={
+ viewer.current?.resetCamera()}
+ />
+ }
+ />
+
+
viewer.current?.highlightJoint(jointId)}
+ onAddMapAsset={(type, placementMode) =>
+ addCertifiedMapAsset(type, undefined, placementMode)
+ }
+ onSelectTerrain={selectTerrainAsset}
+ onSelectMapObject={(id) => {
+ editorInteraction.current?.onSelect(id);
+ selectEditorObject(id);
+ }}
+ />
+
+
+
+
+ setToast(undefined)} />
+ {Boolean(state.snapshot?.model.ncam) &&
+ (showSensorCamera ? (
+
+
+
+
+ 摄像头
+
+
+
+
+ ) : (
+ }
+ onClick={() => setShowSensorCamera(true)}
+ >
+ 显示摄像头画面
+
+ ))}
+ {state.entries.length > 1 && !state.selectedEntry && !pendingUrdfPath && (
+
+ )}{' '}
+ {state.diagnostic && (
+ state.setDiagnostic(undefined)}
+ onRetry={
+ state.diagnostic.category === '模型编译' && state.diagnostic.path
+ ? () => void loadEntry(state.diagnostic!.path!)
+ : undefined
+ }
+ onOpenProject={() => {
+ setLeftOpen(true);
+ state.setDiagnostic(undefined);
+ }}
+ />
+ )}
+
+ /\.py$/i.test(file.path))
+ .map((file) => file.path)}
+ selectedControllerPath={selectedControllerPath}
+ controllerStatus={controllerStatus}
+ policyPaths={state.files
+ .filter((file) => /\.onnx$/i.test(file.path))
+ .map((file) => file.path)}
+ selectedPolicyPath={selectedPolicyPath}
+ policyStatus={policyStatus}
+ mapSelection={mapSelection}
+ maps={projectMaps}
+ showVisualMap={showVisualMap}
+ showMapCollision={showMapCollision}
+ editorDocument={editorDocument}
+ onUrdfMode={changeUrdfMode}
+ onBaseMode={changeBaseMode}
+ onShowCollision={setShowCollision}
+ onResetJoints={resetJoints}
+ onToggleJointLimits={toggleJointLimits}
+ onToggleAdvanced={() => setJointAdvanced((value) => !value)}
+ onToggleAngleUnit={() => setAngleUnit((value) => (value === 'rad' ? 'deg' : 'rad'))}
+ onActuator={setActuator}
+ onActuatorParameters={setActuatorParameters}
+ onJoint={setJoint}
+ onForceScale={setForceScale}
+ onSelectControllerPath={setSelectedControllerPath}
+ onLoadControllerPath={loadControllerPath}
+ onImportController={importController}
+ onToggleController={toggleController}
+ onControllerCommand={sendControllerCommand}
+ onRemoveController={removeController}
+ onSelectPolicyPath={setSelectedPolicyPath}
+ onLoadPolicyPath={loadPolicyPath}
+ onImportPolicy={importPolicy}
+ onTogglePolicy={togglePolicy}
+ onPolicyCommand={setPolicyCommand}
+ onRemovePolicy={removePolicy}
+ onApplyMap={applyMapSelection}
+ onEditorPreview={previewEditorDocument}
+ onEditorApply={applyEditorDocument}
+ onEditorExport={exportSelectedMap}
+ onEditorConvert={convertSelectedMap}
+ onEditorBindInteraction={bindEditorInteraction}
+ onEditorSelect={selectEditorObject}
+ onEditorTransformMode={setEditorTransformMode}
+ onEditorSnapping={setEditorSnapping}
+ onMapDisplay={(visual, collision) => {
+ setShowVisualMap(visual);
+ setShowMapCollision(collision);
+ }}
+ onMapTabOpen={() => {
+ setLeftOpen(true);
+ setProjectSidebarTab('assets');
+ }}
+ onDataRecorderConfigure={configureDataRecorder}
+ onDataRecordingStart={startDataRecording}
+ onDataRecordingStop={stopDataRecording}
+ onDataRecordingClear={clearDataRecording}
+ onDataRecordingExport={exportDataRecording}
+ />
+
+ {pendingUrdfPath && (
+
+ )}
+ {sourceOpen && generatedMjcf && generatedMjcfPath && (
+
+ 正在加载源码编辑器…
+
+ }
+ >
+ setSourceOpen(false)}
+ onSave={saveCachedSource}
+ />
+
+ )}
+ setHelpOpen(false)} />
+ setDiagnosticsOpen(false)}
+ onClear={() => setNotifications([])}
+ />
+ setSettingsOpen(false)}
+ theme={theme}
+ angleUnit={angleUnit}
+ showCollision={showCollision}
+ jointAdvanced={jointAdvanced}
+ forceScale={forceScale}
+ onTheme={setTheme}
+ onAngleUnit={setAngleUnit}
+ onShowCollision={setShowCollision}
+ onJointAdvanced={setJointAdvanced}
+ onForceScale={setForceScale}
+ />
+ setLayoutOpen(false)}
+ leftOpen={leftOpen}
+ rightOpen={rightOpen}
+ onLeftOpen={setLeftOpen}
+ onRightOpen={setRightOpen}
+ onPreset={applyLayoutPreset}
+ onReset={() => applyLayoutPreset('default')}
+ />
+ setCommandOpen(false)}
+ commands={commands}
+ />
+ setRemoveConfirmOpen(false)}
+ >
+
+ 确定从当前会话中移除“{state.projectName}
+ ”吗?
+
+ 该操作不会删除本地文件。
+
+
+
+ );
}
diff --git a/web_platform/src/app/ErrorBoundary.tsx b/web_platform/src/app/ErrorBoundary.tsx
index cba4e443..5f8646d2 100644
--- a/web_platform/src/app/ErrorBoundary.tsx
+++ b/web_platform/src/app/ErrorBoundary.tsx
@@ -1,3 +1,28 @@
-import {Component,type ErrorInfo,type ReactNode} from 'react';
-import {Button} from '../components/ui';
-export class ErrorBoundary extends Component<{children:ReactNode},{error?:Error}>{state:{error?:Error}={};static getDerivedStateFromError(error:Error){return {error};}componentDidCatch(error:Error,info:ErrorInfo){console.error('React fatal error',error,info);}render(){return this.state.error?界面发生致命错误
{this.state.error.message}:this.props.children;}}
+import { Component, type ErrorInfo, type ReactNode } from 'react';
+import { Button } from '../components/ui';
+export class ErrorBoundary extends Component<{ children: ReactNode }, { error?: Error }> {
+ state: { error?: Error } = {};
+ static getDerivedStateFromError(error: Error) {
+ return { error };
+ }
+ componentDidCatch(error: Error, info: ErrorInfo) {
+ console.error('React fatal error', error, info);
+ }
+ render() {
+ return this.state.error ? (
+
+
+ 界面发生致命错误
+
+ {this.state.error.message}
+
+
+
+
+ ) : (
+ this.props.children
+ );
+ }
+}
diff --git a/web_platform/src/app/components/ActuatorControl.test.tsx b/web_platform/src/app/components/ActuatorControl.test.tsx
index 08cba473..0be3099e 100644
--- a/web_platform/src/app/components/ActuatorControl.test.tsx
+++ b/web_platform/src/app/components/ActuatorControl.test.tsx
@@ -1,39 +1,96 @@
-import {fireEvent,render,screen} from '@testing-library/react';
-import {ActuatorControl} from './SidebarPanel';
-import type {ActuatorInfo} from '../../simulation/SimulationSession';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { ActuatorControl } from './SidebarPanel';
+import type { ActuatorInfo } from '../../simulation/SimulationSession';
-const actuator:ActuatorInfo={id:0,name:'shoulder_motor',value:.5,min:-1,max:1,limited:true,jointId:0,jointName:'shoulder',jointType:3,unit:'N·m',kind:'motor',controlCount:1,gear:2,gain:1,kp:0,kv:0,ctrlLimited:true,ctrlMin:-1,ctrlMax:1,forceLimited:true,forceMin:-20,forceMax:20};
+const actuator: ActuatorInfo = {
+ id: 0,
+ name: 'shoulder_motor',
+ value: 0.5,
+ min: -1,
+ max: 1,
+ limited: true,
+ jointId: 0,
+ jointName: 'shoulder',
+ jointType: 3,
+ unit: 'N·m',
+ kind: 'motor',
+ controlCount: 1,
+ gear: 2,
+ gain: 1,
+ kp: 0,
+ kv: 0,
+ ctrlLimited: true,
+ ctrlMin: -1,
+ ctrlMax: 1,
+ forceLimited: true,
+ forceMin: -20,
+ forceMax: 20,
+};
-describe('ActuatorControl',()=>{
- it('显示对应关节和常用力矩单位',()=>{
- render({}} onParameters={()=>{}}/>);
+describe('ActuatorControl', () => {
+ it('显示对应关节和常用力矩单位', () => {
+ render( {}} onParameters={() => {}} />);
expect(screen.getByText('shoulder_motor')).toBeVisible();
expect(screen.getByText('关节:shoulder')).toBeVisible();
expect(screen.getByText('1.000 N·m')).toBeVisible();
});
- it('内部按 gear 换算输出,但参数面板只开放 kp、kv 等业务参数',()=>{
- const onControl=vi.fn(),onParameters=vi.fn();
- render();
- fireEvent.change(screen.getByRole('slider'),{target:{value:'2'}});
+ it('内部按 gear 换算输出,但参数面板只开放 kp、kv 等业务参数', () => {
+ const onControl = vi.fn(),
+ onParameters = vi.fn();
+ render(
+ ,
+ );
+ fireEvent.change(screen.getByRole('slider'), { target: { value: '2' } });
expect(onControl).toHaveBeenCalledWith(1);
fireEvent.click(screen.getByText('常用参数'));
expect(screen.queryByLabelText('传动比 gear')).not.toBeInTheDocument();
expect(screen.queryByLabelText('固定增益 gain')).not.toBeInTheDocument();
- const kp=screen.getByLabelText(/kp(MJCF stiffness/);fireEvent.change(kp,{target:{value:'3'}});fireEvent.blur(kp);
- expect(onParameters).toHaveBeenCalledWith(expect.objectContaining({kp:3,ctrlLimited:true,forceLimited:true}));
+ const kp = screen.getByLabelText(/kp(MJCF stiffness/);
+ fireEvent.change(kp, { target: { value: '3' } });
+ fireEvent.blur(kp);
+ expect(onParameters).toHaveBeenCalledWith(
+ expect.objectContaining({ kp: 3, ctrlLimited: true, forceLimited: true }),
+ );
});
- it('position 伺服使用角度目标并开放 kp、kv',()=>{
- const onParameters=vi.fn();
- render({}} onParameters={onParameters}/>);
- expect(screen.getByText('90.000 °')).toBeVisible();fireEvent.click(screen.getByText('常用参数'));
- const kp=screen.getByLabelText(/位置增益 kp/);fireEvent.change(kp,{target:{value:'150'}});fireEvent.blur(kp);
- expect(onParameters).toHaveBeenCalledWith(expect.objectContaining({kp:150,kv:10}));
+ it('position 伺服使用角度目标并开放 kp、kv', () => {
+ const onParameters = vi.fn();
+ render(
+ {}}
+ onParameters={onParameters}
+ />,
+ );
+ expect(screen.getByText('90.000 °')).toBeVisible();
+ fireEvent.click(screen.getByText('常用参数'));
+ const kp = screen.getByLabelText(/位置增益 kp/);
+ fireEvent.change(kp, { target: { value: '150' } });
+ fireEvent.blur(kp);
+ expect(onParameters).toHaveBeenCalledWith(expect.objectContaining({ kp: 150, kv: 10 }));
});
- it('非 motor 驱动器保持原始控制单位且不开放通用参数编辑',()=>{
- render({}} onParameters={()=>{}}/>);
+ it('非 motor 驱动器保持原始控制单位且不开放通用参数编辑', () => {
+ render(
+ {}}
+ onParameters={() => {}}
+ />,
+ );
expect(screen.getByText('0.250')).toBeVisible();
expect(screen.queryByText('常用参数')).not.toBeInTheDocument();
expect(screen.getByText(/不是可直接编辑的 motor\/position/)).toBeVisible();
diff --git a/web_platform/src/app/components/CommandPalette.tsx b/web_platform/src/app/components/CommandPalette.tsx
index 6010ffde..e79ada07 100644
--- a/web_platform/src/app/components/CommandPalette.tsx
+++ b/web_platform/src/app/components/CommandPalette.tsx
@@ -1,12 +1,120 @@
-import {useEffect,useId,useMemo,useRef,useState,type ReactNode} from 'react';
-import {Search} from 'lucide-react';
-import {Dialog,EmptySearchState,Kbd} from '../../components/ui';
-export interface WorkbenchCommand{id:string;label:string;group:string;icon?:ReactNode;shortcut?:string;disabled?:boolean;run:()=>void;}
-export function CommandPalette({open,onClose,commands}:{open:boolean;onClose:()=>void;commands:WorkbenchCommand[]}){
- const [query,setQuery]=useState(''),[active,setActive]=useState(0),input=useRef(null),listId=useId();
- const filtered=useMemo(()=>{const needle=query.trim().toLocaleLowerCase();return commands.filter(command=>!needle||`${command.label} ${command.group}`.toLocaleLowerCase().includes(needle));},[commands,query]);
- const enabled=filtered.flatMap((command,index)=>command.disabled?[]:[index]),highlighted=filtered[active]&&!filtered[active].disabled?active:(enabled[0]??-1);
- useEffect(()=>{if(open)requestAnimationFrame(()=>input.current?.focus());},[open]);
- const close=()=>{setQuery('');setActive(0);onClose();},execute=(command?:WorkbenchCommand)=>{if(!command||command.disabled)return;command.run();close();};
- return ;
+import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react';
+import { Search } from 'lucide-react';
+import { Dialog, EmptySearchState, Kbd } from '../../components/ui';
+export interface WorkbenchCommand {
+ id: string;
+ label: string;
+ group: string;
+ icon?: ReactNode;
+ shortcut?: string;
+ disabled?: boolean;
+ run: () => void;
+}
+export function CommandPalette({
+ open,
+ onClose,
+ commands,
+}: {
+ open: boolean;
+ onClose: () => void;
+ commands: WorkbenchCommand[];
+}) {
+ const [query, setQuery] = useState(''),
+ [active, setActive] = useState(0),
+ input = useRef(null),
+ listId = useId();
+ const filtered = useMemo(() => {
+ const needle = query.trim().toLocaleLowerCase();
+ return commands.filter(
+ (command) =>
+ !needle || `${command.label} ${command.group}`.toLocaleLowerCase().includes(needle),
+ );
+ }, [commands, query]);
+ const enabled = filtered.flatMap((command, index) => (command.disabled ? [] : [index])),
+ highlighted = filtered[active] && !filtered[active].disabled ? active : (enabled[0] ?? -1);
+ useEffect(() => {
+ if (open) requestAnimationFrame(() => input.current?.focus());
+ }, [open]);
+ const close = () => {
+ setQuery('');
+ setActive(0);
+ onClose();
+ },
+ execute = (command?: WorkbenchCommand) => {
+ if (!command || command.disabled) return;
+ command.run();
+ close();
+ };
+ return (
+
+ );
}
diff --git a/web_platform/src/app/components/DataRecordingPanel.test.tsx b/web_platform/src/app/components/DataRecordingPanel.test.tsx
new file mode 100644
index 00000000..b500a535
--- /dev/null
+++ b/web_platform/src/app/components/DataRecordingPanel.test.tsx
@@ -0,0 +1,74 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { DataRecordingPanel } from './DataRecordingPanel';
+import type { DataRecorderStatus } from '../../simulation/DataRecorder';
+
+const status: DataRecorderStatus = {
+ recording: false,
+ limitReached: false,
+ sampleCount: 2,
+ segmentCount: 1,
+ config: { bodyId: 1, sampleRateHz: 50, maxSamples: 30_000 },
+ body: { id: 1, name: 'base' },
+ latest: {
+ sequence: 1,
+ segment: 0,
+ simulationTime: 0.02,
+ values: {
+ speed_horizontal: 1.25,
+ speed_3d: 1.3,
+ velocity_x: 1.25,
+ velocity_y: 0,
+ velocity_z: 0.1,
+ pitch: 0.1,
+ roll: -0.05,
+ yaw: 0.2,
+ height: 0.45,
+ position_x: 0.1,
+ position_y: 0,
+ contact_count: 4,
+ control_rms: 2,
+ actuator_force_rms: 3,
+ actuator_power_abs: 8,
+ },
+ },
+ summary: {
+ duration: 0.02,
+ distanceHorizontal: 0.1,
+ maxHorizontalSpeed: 1.25,
+ maxAbsRoll: 0.05,
+ maxAbsPitch: 0.1,
+ minHeight: 0.44,
+ maxHeight: 0.46,
+ },
+};
+
+describe('DataRecordingPanel', () => {
+ it('展示实时遥测并提供配置、记录和导出操作', () => {
+ const configure = vi.fn(),
+ start = vi.fn(),
+ exportData = vi.fn();
+ render(
+ ,
+ );
+ expect(screen.getAllByText('1.250 m/s').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('5.73°').length).toBeGreaterThan(0);
+ fireEvent.change(screen.getByLabelText('记录 Body'), { target: { value: '2' } });
+ expect(configure).toHaveBeenCalledWith({ bodyId: 2 });
+ fireEvent.click(screen.getByRole('button', { name: '开始记录' }));
+ expect(start).toHaveBeenCalledTimes(1);
+ fireEvent.click(screen.getByRole('button', { name: '导出 CSV' }));
+ expect(exportData).toHaveBeenCalledWith('csv');
+ });
+});
diff --git a/web_platform/src/app/components/DataRecordingPanel.tsx b/web_platform/src/app/components/DataRecordingPanel.tsx
new file mode 100644
index 00000000..672c6c8d
--- /dev/null
+++ b/web_platform/src/app/components/DataRecordingPanel.tsx
@@ -0,0 +1,198 @@
+import { Circle, Download, Square, Trash2 } from 'lucide-react';
+import type { BodyInfo } from '../../simulation/SimulationSession';
+import type { DataRecorderConfig, DataRecorderStatus } from '../../simulation/DataRecorder';
+import { Badge, Button, PropertyRow, Select } from '../../components/ui';
+
+const SAMPLE_RATES = [10, 20, 50, 100, 200];
+const radiansToDegrees = 180 / Math.PI;
+
+function number(value: number | undefined, digits = 3): string {
+ return Number.isFinite(value) ? value!.toFixed(digits) : '—';
+}
+
+function degrees(value: number | undefined): string {
+ return Number.isFinite(value) ? `${(value! * radiansToDegrees).toFixed(2)}°` : '—';
+}
+
+export function DataRecordingPanel({
+ status,
+ bodies,
+ onConfigure,
+ onStart,
+ onStop,
+ onClear,
+ onExport,
+}: {
+ status: DataRecorderStatus;
+ bodies: BodyInfo[];
+ onConfigure: (patch: Partial) => void;
+ onStart: () => void;
+ onStop: () => void;
+ onClear: () => void;
+ onExport: (format: 'csv' | 'json') => void;
+}) {
+ const values = status.latest?.values,
+ availableBodies = bodies.filter(
+ (body) => body.id > 0 && !body.name.startsWith('__platform_map_'),
+ );
+ return (
+
+
+
+
+
仿真遥测记录
+
+ 数据仅保存在当前浏览器会话,可导出 CSV 或 JSON。
+
+
+
+ {status.recording ? '记录中' : status.limitReached ? '已达上限' : '已停止'}
+
+
+
+
+
+
+
+
+ {status.recording ? (
+ }
+ onClick={onStop}
+ >
+ 停止记录
+
+ ) : (
+ }
+ onClick={onStart}
+ disabled={status.limitReached}
+ >
+ 开始记录
+
+ )}
+ }
+ onClick={onClear}
+ disabled={!status.sampleCount}
+ >
+ 清空
+
+
+
+
+
+
+ 实时运动状态
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 本次记录摘要
+
+
+
+
+
+
+ }
+ disabled={!status.sampleCount}
+ onClick={() => onExport('csv')}
+ >
+ 导出 CSV
+
+ }
+ disabled={!status.sampleCount}
+ onClick={() => onExport('json')}
+ >
+ 导出 JSON
+
+
+
+
+ 每次仿真重置会创建新分段,避免跨重置计算出错误速度。开发接口支持注册额外标量通道,导出列名保持稳定。
+
+
+ );
+}
diff --git a/web_platform/src/app/components/DiagnosticNotice.tsx b/web_platform/src/app/components/DiagnosticNotice.tsx
index 868cac14..957224a0 100644
--- a/web_platform/src/app/components/DiagnosticNotice.tsx
+++ b/web_platform/src/app/components/DiagnosticNotice.tsx
@@ -1,5 +1,50 @@
-import {useState} from 'react';
-import {ChevronDown,TriangleAlert,X} from 'lucide-react';
-import type {AppDiagnostic} from '../../stores/useAppStore';
-import {IconButton} from '../../components/ui';
-export function DiagnosticNotice({value,onClose}:{value:AppDiagnostic;onClose:()=>void}){const [expanded,setExpanded]=useState(false);return {value.summary}
{value.path&&
路径:{value.path}
}
{expanded&&{value.detail}};}
+import { useState } from 'react';
+import { ChevronDown, TriangleAlert, X } from 'lucide-react';
+import type { AppDiagnostic } from '../../stores/useAppStore';
+import { IconButton } from '../../components/ui';
+export function DiagnosticNotice({
+ value,
+ onClose,
+}: {
+ value: AppDiagnostic;
+ onClose: () => void;
+}) {
+ const [expanded, setExpanded] = useState(false);
+ return (
+
+
+
+
+
+
+
{value.summary}
+ {value.path && (
+
+ 路径:{value.path}
+
+ )}
+
+
+
+
+
+
+ {expanded && (
+
+ {value.detail}
+
+ )}
+
+ );
+}
diff --git a/web_platform/src/app/components/DiagnosticsDrawer.tsx b/web_platform/src/app/components/DiagnosticsDrawer.tsx
index ff65ffbd..1137db8b 100644
--- a/web_platform/src/app/components/DiagnosticsDrawer.tsx
+++ b/web_platform/src/app/components/DiagnosticsDrawer.tsx
@@ -1,6 +1,97 @@
-import {useState} from 'react';
-import {CheckCircle2,Info,TriangleAlert,XCircle} from 'lucide-react';
-import {Button,CopyButton,Dialog,Tabs} from '../../components/ui';
-import type {WorkbenchNotification} from './NotificationCenter';
-type Filter='all'|'warning'|'danger';
-export function DiagnosticsDrawer({open,items,onClose,onClear}:{open:boolean;items:WorkbenchNotification[];onClose:()=>void;onClear:()=>void}){const [filter,setFilter]=useState('all');const content=(value:Filter)=>{const filtered=items.filter(item=>value==='all'||item.tone===value);return {filtered.length?filtered.map(item=>{const Icon=item.tone==='danger'?XCircle:item.tone==='warning'?TriangleAlert:item.tone==='success'?CheckCircle2:Info;return
{item.title}
{item.detail&&
{item.detail}}
{item.detail&&
}
}):
没有符合条件的事件
}
;};return ;}
+import { useState } from 'react';
+import { CheckCircle2, Info, TriangleAlert, XCircle } from 'lucide-react';
+import { Button, CopyButton, Dialog, Tabs } from '../../components/ui';
+import type { WorkbenchNotification } from './NotificationCenter';
+type Filter = 'all' | 'warning' | 'danger';
+export function DiagnosticsDrawer({
+ open,
+ items,
+ onClose,
+ onClear,
+}: {
+ open: boolean;
+ items: WorkbenchNotification[];
+ onClose: () => void;
+ onClear: () => void;
+}) {
+ const [filter, setFilter] = useState('all');
+ const content = (value: Filter) => {
+ const filtered = items.filter((item) => value === 'all' || item.tone === value);
+ return (
+
+ {filtered.length ? (
+ filtered.map((item) => {
+ const Icon =
+ item.tone === 'danger'
+ ? XCircle
+ : item.tone === 'warning'
+ ? TriangleAlert
+ : item.tone === 'success'
+ ? CheckCircle2
+ : Info;
+ return (
+
+
+
+
+
{item.title}
+
+ {item.detail && (
+
+ {item.detail}
+
+ )}
+
+ {item.detail && (
+
+ )}
+
+
+ );
+ })
+ ) : (
+
没有符合条件的事件
+ )}
+
+ );
+ };
+ return (
+
+ );
+}
diff --git a/web_platform/src/app/components/EntrySelectionDialog.test.tsx b/web_platform/src/app/components/EntrySelectionDialog.test.tsx
index c44f65d5..44eb3ce4 100644
--- a/web_platform/src/app/components/EntrySelectionDialog.test.tsx
+++ b/web_platform/src/app/components/EntrySelectionDialog.test.tsx
@@ -1,4 +1,18 @@
-import {render,screen} from '@testing-library/react';
-import {EntrySelectionDialog} from './EntrySelectionDialog';
+import { render, screen } from '@testing-library/react';
+import { EntrySelectionDialog } from './EntrySelectionDialog';
-describe('EntrySelectionDialog',()=>{it('父组件重渲染时不抢走入口按钮焦点,且不暴露无效关闭动作',()=>{const entries=[{path:'a.xml',label:'模型 A'},{path:'b.xml',label:'模型 B'}],select=vi.fn();const {rerender}=render();const entry=screen.getByRole('button',{name:'模型 A'});entry.focus();rerender();expect(entry).toHaveFocus();expect(screen.queryByRole('button',{name:'关闭'})).not.toBeInTheDocument();});});
+describe('EntrySelectionDialog', () => {
+ it('父组件重渲染时不抢走入口按钮焦点,且不暴露无效关闭动作', () => {
+ const entries = [
+ { path: 'a.xml', label: '模型 A' },
+ { path: 'b.xml', label: '模型 B' },
+ ],
+ select = vi.fn();
+ const { rerender } = render();
+ const entry = screen.getByRole('button', { name: '模型 A' });
+ entry.focus();
+ rerender();
+ expect(entry).toHaveFocus();
+ expect(screen.queryByRole('button', { name: '关闭' })).not.toBeInTheDocument();
+ });
+});
diff --git a/web_platform/src/app/components/EntrySelectionDialog.tsx b/web_platform/src/app/components/EntrySelectionDialog.tsx
index af14449f..87ad3e34 100644
--- a/web_platform/src/app/components/EntrySelectionDialog.tsx
+++ b/web_platform/src/app/components/EntrySelectionDialog.tsx
@@ -1,4 +1,28 @@
-import {FileCode2} from 'lucide-react';
-import {Button,Dialog} from '../../components/ui';
-const noop=()=>{};
-export function EntrySelectionDialog({entries,onSelect}:{entries:{path:string;label:string}[];onSelect:(path:string)=>void}){return ;}
+import { FileCode2 } from 'lucide-react';
+import { Button, Dialog } from '../../components/ui';
+const noop = () => {};
+export function EntrySelectionDialog({
+ entries,
+ onSelect,
+}: {
+ entries: { path: string; label: string }[];
+ onSelect: (path: string) => void;
+}) {
+ return (
+
+ );
+}
diff --git a/web_platform/src/app/components/ErrorRecoveryPanel.tsx b/web_platform/src/app/components/ErrorRecoveryPanel.tsx
index bc1c1376..f7aa48c1 100644
--- a/web_platform/src/app/components/ErrorRecoveryPanel.tsx
+++ b/web_platform/src/app/components/ErrorRecoveryPanel.tsx
@@ -1,5 +1,68 @@
-import {useState} from 'react';
-import {ChevronDown,FolderTree,RefreshCw,TriangleAlert,X} from 'lucide-react';
-import type {AppDiagnostic} from '../../stores/useAppStore';
-import {Button,CopyButton,IconButton} from '../../components/ui';
-export function ErrorRecoveryPanel({value,onClose,onRetry,onOpenProject}:{value:AppDiagnostic;onClose:()=>void;onRetry?:()=>void;onOpenProject:()=>void}){const [expanded,setExpanded]=useState(false);return {value.summary}
{value.path&&
路径:{value.path}
}
{onRetry&&}>重试当前入口}}>返回工程树
{expanded&&{value.detail}};}
+import { useState } from 'react';
+import { ChevronDown, FolderTree, RefreshCw, TriangleAlert, X } from 'lucide-react';
+import type { AppDiagnostic } from '../../stores/useAppStore';
+import { Button, CopyButton, IconButton } from '../../components/ui';
+export function ErrorRecoveryPanel({
+ value,
+ onClose,
+ onRetry,
+ onOpenProject,
+}: {
+ value: AppDiagnostic;
+ onClose: () => void;
+ onRetry?: () => void;
+ onOpenProject: () => void;
+}) {
+ const [expanded, setExpanded] = useState(false);
+ return (
+
+
+
+
+
+
+
{value.summary}
+ {value.path &&
路径:{value.path}
}
+
+ {onRetry && (
+ }
+ >
+ 重试当前入口
+
+ )}
+ }>
+ 返回工程树
+
+
+
+
+
+
+
+
+
+ {expanded && (
+
+ {value.detail}
+
+ )}
+
+ );
+}
diff --git a/web_platform/src/app/components/FeedbackComponents.test.tsx b/web_platform/src/app/components/FeedbackComponents.test.tsx
index 01fcf066..32f747f7 100644
--- a/web_platform/src/app/components/FeedbackComponents.test.tsx
+++ b/web_platform/src/app/components/FeedbackComponents.test.tsx
@@ -1,5 +1,57 @@
-import {fireEvent,render,screen} from '@testing-library/react';
-import {DiagnosticNotice} from './DiagnosticNotice';
-import {WorkspaceOverlays} from './WorkspaceOverlays';
-import {StatusBar} from './StatusBar';
-describe('工作台反馈组件',()=>{it('诊断详情可展开并关闭',()=>{const close=vi.fn();render();expect(screen.queryByText('bad xml')).not.toBeInTheDocument();fireEvent.click(screen.getByRole('button',{name:'技术详情'}));expect(screen.getByText('bad xml')).toBeVisible();fireEvent.click(screen.getByRole('button',{name:'关闭错误'}));expect(close).toHaveBeenCalledTimes(1);});it('加载态与空态互斥',()=>{const {rerender}=render();expect(screen.getByText('拖放模型工程到此处')).toBeVisible();rerender();expect(screen.queryByText('拖放模型工程到此处')).not.toBeInTheDocument();expect(screen.getByRole('status')).toBeVisible();});it('展示格式化状态数据',()=>{render();expect(screen.getByText(/时间 1.250 s/)).toBeVisible();expect(screen.getByText(/WASM 已加载/)).toBeVisible();});});
+import { fireEvent, render, screen } from '@testing-library/react';
+import { DiagnosticNotice } from './DiagnosticNotice';
+import { WorkspaceOverlays } from './WorkspaceOverlays';
+import { StatusBar } from './StatusBar';
+describe('工作台反馈组件', () => {
+ it('诊断详情可展开并关闭', () => {
+ const close = vi.fn();
+ render(
+ ,
+ );
+ expect(screen.queryByText('bad xml')).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: '技术详情' }));
+ expect(screen.getByText('bad xml')).toBeVisible();
+ fireEvent.click(screen.getByRole('button', { name: '关闭错误' }));
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+ it('加载态与空态互斥,并展示真实阶段进度', () => {
+ const { rerender } = render();
+ expect(screen.getByText('拖放模型工程到此处')).toBeVisible();
+ rerender(
+ ,
+ );
+ expect(screen.queryByText('拖放模型工程到此处')).not.toBeInTheDocument();
+ expect(screen.getByRole('status')).toHaveTextContent('正在导入工程');
+ expect(screen.getByRole('progressbar', { name: '读取工程文件' })).toHaveAttribute(
+ 'aria-valuenow',
+ '42',
+ );
+ });
+ it('拖入文件时显示明确落点反馈', () => {
+ render();
+ expect(screen.getByText('松开即可导入工程')).toBeVisible();
+ });
+ it('展示格式化状态数据', () => {
+ render();
+ expect(screen.getByText(/时间 1.250 s/)).toBeVisible();
+ expect(screen.getByText(/WASM 已加载/)).toBeVisible();
+ });
+});
diff --git a/web_platform/src/app/components/FifthBatchComponents.test.tsx b/web_platform/src/app/components/FifthBatchComponents.test.tsx
index 6583ee8c..be0cc07a 100644
--- a/web_platform/src/app/components/FifthBatchComponents.test.tsx
+++ b/web_platform/src/app/components/FifthBatchComponents.test.tsx
@@ -1,12 +1,66 @@
-import {fireEvent,render,screen,within} from '@testing-library/react';
-import {DiagnosticsDrawer} from './DiagnosticsDrawer';
-import {ErrorRecoveryPanel} from './ErrorRecoveryPanel';
-import {ToolbarOverflowMenu} from './ToolbarOverflowMenu';
-import {WorkspaceOverlays} from './WorkspaceOverlays';
-const event={id:1,title:'编译失败',detail:'bad xml',tone:'danger' as const,at:0};
-describe('第五批工作台组件',()=>{
- it('事件日志支持分类和清空',()=>{const clear=vi.fn();render({}} onClear={clear}/>);expect(within(screen.getByRole('tabpanel',{name:/全部/})).getByText('bad xml')).toBeVisible();expect(screen.getAllByText('bad xml')).toHaveLength(1);fireEvent.click(screen.getByRole('button',{name:'清空事件'}));expect(clear).toHaveBeenCalled();});
- it('错误恢复面板透传重试与工程树动作',()=>{const retry=vi.fn(),project=vi.fn();render({}} onRetry={retry} onOpenProject={project}/>);fireEvent.click(screen.getByRole('button',{name:'重试当前入口'}));fireEvent.click(screen.getByRole('button',{name:'返回工程树'}));expect(retry).toHaveBeenCalled();expect(project).toHaveBeenCalled();});
- it('导入叠层显示阶段进度',()=>{render(
);expect(screen.getByRole('progressbar',{name:'处理模型资源'})).toHaveAttribute('aria-valuenow','40');});
- it('工具栏更多菜单提供窄桌面动作',()=>{const settings=vi.fn();render({}} onLayout={()=>{}} onSettings={settings} onFullscreen={()=>{}} onHelp={()=>{}} onTheme={()=>{}}/>);fireEvent.click(screen.getByRole('button',{name:'更多工作台操作'}));fireEvent.click(screen.getByRole('menuitem',{name:'工作台设置'}));expect(settings).toHaveBeenCalled();});
+import { fireEvent, render, screen, within } from '@testing-library/react';
+import { DiagnosticsDrawer } from './DiagnosticsDrawer';
+import { ErrorRecoveryPanel } from './ErrorRecoveryPanel';
+import { ToolbarOverflowMenu } from './ToolbarOverflowMenu';
+import { WorkspaceOverlays } from './WorkspaceOverlays';
+const event = { id: 1, title: '编译失败', detail: 'bad xml', tone: 'danger' as const, at: 0 };
+describe('第五批工作台组件', () => {
+ it('事件日志支持分类和清空', () => {
+ const clear = vi.fn();
+ render( {}} onClear={clear} />);
+ expect(
+ within(screen.getByRole('tabpanel', { name: /全部/ })).getByText('bad xml'),
+ ).toBeVisible();
+ expect(screen.getAllByText('bad xml')).toHaveLength(1);
+ fireEvent.click(screen.getByRole('button', { name: '清空事件' }));
+ expect(clear).toHaveBeenCalled();
+ });
+ it('错误恢复面板透传重试与工程树动作', () => {
+ const retry = vi.fn(),
+ project = vi.fn();
+ render(
+ {}}
+ onRetry={retry}
+ onOpenProject={project}
+ />,
+ );
+ fireEvent.click(screen.getByRole('button', { name: '重试当前入口' }));
+ fireEvent.click(screen.getByRole('button', { name: '返回工程树' }));
+ expect(retry).toHaveBeenCalled();
+ expect(project).toHaveBeenCalled();
+ });
+ it('导入叠层显示阶段进度', () => {
+ render(
+
+
+
,
+ );
+ expect(screen.getByRole('progressbar', { name: '处理模型资源' })).toHaveAttribute(
+ 'aria-valuenow',
+ '40',
+ );
+ });
+ it('工具栏更多菜单提供窄桌面动作', () => {
+ const settings = vi.fn();
+ render(
+ {}}
+ onLayout={() => {}}
+ onSettings={settings}
+ onFullscreen={() => {}}
+ onHelp={() => {}}
+ onTheme={() => {}}
+ />,
+ );
+ fireEvent.click(screen.getByRole('button', { name: '更多工作台操作' }));
+ fireEvent.click(screen.getByRole('menuitem', { name: '工作台设置' }));
+ expect(settings).toHaveBeenCalled();
+ });
});
diff --git a/web_platform/src/app/components/FourthBatchComponents.test.tsx b/web_platform/src/app/components/FourthBatchComponents.test.tsx
index 373fe5d8..860e3d5b 100644
--- a/web_platform/src/app/components/FourthBatchComponents.test.tsx
+++ b/web_platform/src/app/components/FourthBatchComponents.test.tsx
@@ -1,13 +1,103 @@
-import {act,fireEvent,render,screen} from '@testing-library/react';
-import {NotificationCenter,ToastViewport,type WorkbenchNotification} from './NotificationCenter';
-import {ProjectBreadcrumb} from './ProjectBreadcrumb';
-import {SettingsDialog} from './SettingsDialog';
-import {LayoutSettingsDialog} from './LayoutSettingsDialog';
-const item:WorkbenchNotification={id:1,title:'模型加载完成',detail:'完成',tone:'success',at:0};
-describe('第四批工作台组件',()=>{
- it('通知中心展示、移除并清空消息',()=>{const dismiss=vi.fn(),clear=vi.fn();render();fireEvent.click(screen.getByRole('button',{name:'通知中心'}));expect(screen.getByRole('dialog',{name:'通知中心'})).toHaveTextContent('模型加载完成');fireEvent.click(screen.getByRole('button',{name:'移除通知:模型加载完成'}));expect(dismiss).toHaveBeenCalledWith(1);fireEvent.click(screen.getByText('清空'));expect(clear).toHaveBeenCalled();});
- it('Toast 自动关闭',()=>{vi.useFakeTimers();const close=vi.fn();render();act(()=>vi.advanceTimersByTime(4000));expect(close).toHaveBeenCalledWith(1);vi.useRealTimers();});
- it('工程面包屑可切换多入口',()=>{const select=vi.fn();render();fireEvent.click(screen.getByRole('button',{name:'切换模型入口'}));fireEvent.click(screen.getByRole('option',{name:/B/}));expect(select).toHaveBeenCalledWith('models/b.xml');});
- it('模型加载期间禁用入口切换',()=>{render({}}/>);expect(screen.getByRole('button',{name:'切换模型入口'})).toBeDisabled();});
- it('设置和布局弹窗透传现有设置动作',()=>{const theme=vi.fn(),preset=vi.fn();render(<>{}} theme="dark" angleUnit="rad" showCollision={false} jointAdvanced={false} forceScale={50} onTheme={theme} onAngleUnit={()=>{}} onShowCollision={()=>{}} onJointAdvanced={()=>{}} onForceScale={()=>{}}/>{}} leftOpen rightOpen onLeftOpen={()=>{}} onRightOpen={()=>{}} onPreset={preset} onReset={()=>{}}/>>);fireEvent.change(screen.getByLabelText('设置主题'),{target:{value:'light'}});expect(theme).toHaveBeenCalledWith('light');});
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import {
+ NotificationCenter,
+ ToastViewport,
+ type WorkbenchNotification,
+} from './NotificationCenter';
+import { ProjectBreadcrumb } from './ProjectBreadcrumb';
+import { SettingsDialog } from './SettingsDialog';
+import { LayoutSettingsDialog } from './LayoutSettingsDialog';
+const item: WorkbenchNotification = {
+ id: 1,
+ title: '模型加载完成',
+ detail: '完成',
+ tone: 'success',
+ at: 0,
+};
+describe('第四批工作台组件', () => {
+ it('通知中心展示、移除并清空消息', () => {
+ const dismiss = vi.fn(),
+ clear = vi.fn();
+ render();
+ fireEvent.click(screen.getByRole('button', { name: '通知中心' }));
+ expect(screen.getByRole('dialog', { name: '通知中心' })).toHaveTextContent('模型加载完成');
+ fireEvent.click(screen.getByRole('button', { name: '移除通知:模型加载完成' }));
+ expect(dismiss).toHaveBeenCalledWith(1);
+ fireEvent.click(screen.getByText('清空'));
+ expect(clear).toHaveBeenCalled();
+ });
+ it('Toast 自动关闭', () => {
+ vi.useFakeTimers();
+ const close = vi.fn();
+ render();
+ act(() => vi.advanceTimersByTime(4000));
+ expect(close).toHaveBeenCalledWith(1);
+ vi.useRealTimers();
+ });
+ it('工程面包屑可切换多入口', () => {
+ const select = vi.fn();
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByRole('button', { name: '切换模型入口' }));
+ fireEvent.click(screen.getByRole('option', { name: /B/ }));
+ expect(select).toHaveBeenCalledWith('models/b.xml');
+ });
+ it('模型加载期间禁用入口切换', () => {
+ render(
+ {}}
+ />,
+ );
+ expect(screen.getByRole('button', { name: '切换模型入口' })).toBeDisabled();
+ });
+ it('设置和布局弹窗透传现有设置动作', () => {
+ const theme = vi.fn(),
+ preset = vi.fn();
+ render(
+ <>
+ {}}
+ theme="dark"
+ angleUnit="rad"
+ showCollision={false}
+ jointAdvanced={false}
+ forceScale={50}
+ onTheme={theme}
+ onAngleUnit={() => {}}
+ onShowCollision={() => {}}
+ onJointAdvanced={() => {}}
+ onForceScale={() => {}}
+ />
+ {}}
+ leftOpen
+ rightOpen
+ onLeftOpen={() => {}}
+ onRightOpen={() => {}}
+ onPreset={preset}
+ onReset={() => {}}
+ />
+ >,
+ );
+ fireEvent.change(screen.getByLabelText('设置主题'), { target: { value: 'light' } });
+ expect(theme).toHaveBeenCalledWith('light');
+ });
});
diff --git a/web_platform/src/app/components/LayoutSettingsDialog.tsx b/web_platform/src/app/components/LayoutSettingsDialog.tsx
index 470624a0..3c8c48fc 100644
--- a/web_platform/src/app/components/LayoutSettingsDialog.tsx
+++ b/web_platform/src/app/components/LayoutSettingsDialog.tsx
@@ -1,7 +1,78 @@
-import {Columns3,Focus,PanelLeft,PanelRight,RotateCcw} from 'lucide-react';
-import {Button,Dialog} from '../../components/ui';
-export type LayoutPreset='default'|'viewport'|'project'|'control';
-const presets=[{value:'default' as const,label:'默认布局',detail:'左右面板均衡显示',icon:Columns3},{value:'viewport' as const,label:'宽视口',detail:'隐藏两侧面板',icon:Focus},{value:'project' as const,label:'工程浏览',detail:'加宽工程面板',icon:PanelLeft},{value:'control' as const,label:'控制调试',detail:'加宽控制面板',icon:PanelRight}];
-export function LayoutSettingsDialog({open,onClose,leftOpen,rightOpen,onLeftOpen,onRightOpen,onPreset,onReset}:{open:boolean;onClose:()=>void;leftOpen:boolean;rightOpen:boolean;onLeftOpen:(value:boolean)=>void;onRightOpen:(value:boolean)=>void;onPreset:(preset:LayoutPreset)=>void;onReset:()=>void}){return ;}
+import { Columns3, Focus, PanelLeft, PanelRight, RotateCcw } from 'lucide-react';
+import { Button, Dialog } from '../../components/ui';
+export type LayoutPreset = 'default' | 'viewport' | 'project' | 'control';
+const presets = [
+ { value: 'default' as const, label: '默认布局', detail: '左右面板均衡显示', icon: Columns3 },
+ { value: 'viewport' as const, label: '宽视口', detail: '隐藏两侧面板', icon: Focus },
+ { value: 'project' as const, label: '工程浏览', detail: '加宽工程面板', icon: PanelLeft },
+ { value: 'control' as const, label: '控制调试', detail: '加宽控制面板', icon: PanelRight },
+];
+export function LayoutSettingsDialog({
+ open,
+ onClose,
+ leftOpen,
+ rightOpen,
+ onLeftOpen,
+ onRightOpen,
+ onPreset,
+ onReset,
+}: {
+ open: boolean;
+ onClose: () => void;
+ leftOpen: boolean;
+ rightOpen: boolean;
+ onLeftOpen: (value: boolean) => void;
+ onRightOpen: (value: boolean) => void;
+ onPreset: (preset: LayoutPreset) => void;
+ onReset: () => void;
+}) {
+ return (
+
+ );
+}
// eslint-disable-next-line react-refresh/only-export-components
-export function dispatchLayoutWidths(left:number,right:number){window.dispatchEvent(new CustomEvent('mujoco-layout-widths',{detail:{left,right}}));}
+export function dispatchLayoutWidths(left: number, right: number) {
+ window.dispatchEvent(new CustomEvent('mujoco-layout-widths', { detail: { left, right } }));
+}
diff --git a/web_platform/src/app/components/LocalTrainingPanel.test.tsx b/web_platform/src/app/components/LocalTrainingPanel.test.tsx
index 462a6529..68076277 100644
--- a/web_platform/src/app/components/LocalTrainingPanel.test.tsx
+++ b/web_platform/src/app/components/LocalTrainingPanel.test.tsx
@@ -1,25 +1,87 @@
-import {fireEvent,render,screen,waitFor} from '@testing-library/react';
-import {beforeEach,describe,expect,it,vi} from 'vitest';
-import {LocalTrainingPanel} from './LocalTrainingPanel';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { LocalTrainingPanel } from './LocalTrainingPanel';
-beforeEach(()=>{localStorage.clear();vi.unstubAllGlobals();});
+beforeEach(() => {
+ localStorage.clear();
+ sessionStorage.clear();
+ vi.unstubAllGlobals();
+});
-describe('LocalTrainingPanel',()=>{
- it('连接本地服务并从图形界面发起训练请求',async()=>{
- const health={version:'0.1.0',ready:true,trainerRoot:'/opt/unitree_rl_mjlab',python:'/env/bin/python',tasks:['Unitree-Go2-Flat']};
- const job={id:'a'.repeat(32),state:'queued',taskId:'Unitree-Go2-Flat',createdAt:'2025-01-01T00:00:00Z',iteration:0,maxIterations:2000,progress:0,message:'等待启动',logs:[],artifactReady:false};
- const fetchMock=vi.fn()
- .mockResolvedValueOnce(new Response(JSON.stringify(health),{status:200,headers:{'Content-Type':'application/json'}}))
- .mockResolvedValueOnce(new Response(JSON.stringify(job),{status:202,headers:{'Content-Type':'application/json'}}));
- vi.stubGlobal('fetch',fetchMock);
- render();
- fireEvent.click(screen.getByRole('button',{name:'连接'}));
+describe('LocalTrainingPanel', () => {
+ it('连接本地服务并从图形界面发起训练请求', async () => {
+ const health = {
+ version: '0.1.0',
+ ready: true,
+ trainerRoot: '/opt/unitree_rl_mjlab',
+ python: '/env/bin/python',
+ tasks: ['Unitree-Go2-Flat'],
+ };
+ const job = {
+ id: 'a'.repeat(32),
+ state: 'queued',
+ taskId: 'Unitree-Go2-Flat',
+ createdAt: '2025-01-01T00:00:00Z',
+ iteration: 0,
+ maxIterations: 2000,
+ progress: 0,
+ message: '等待启动',
+ logs: [],
+ artifactReady: false,
+ };
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify(health), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ )
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify(job), {
+ status: 202,
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ )
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify(health), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ )
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify({ error: '训练任务不存在或服务已重启' }), {
+ status: 404,
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ );
+ vi.stubGlobal('fetch', fetchMock);
+ render();
+ fireEvent.change(screen.getByLabelText('训练服务访问令牌'), {
+ target: { value: 'secret-token' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: '连接' }));
expect(await screen.findByText('/opt/unitree_rl_mjlab')).toBeInTheDocument();
- fireEvent.change(screen.getByLabelText('并行环境'),{target:{value:'32'}});
- fireEvent.click(screen.getByRole('button',{name:'发起本地训练'}));
- await waitFor(()=>expect(fetchMock).toHaveBeenCalledTimes(2));
- const request=fetchMock.mock.calls[1][1] as RequestInit;
- expect(JSON.parse(String(request.body))).toMatchObject({taskId:'Unitree-Go2-Flat',numEnvs:32,device:'gpu',gpuIds:[0],wandbMode:'offline'});
+ fireEvent.change(screen.getByLabelText('并行环境'), { target: { value: '32' } });
+ fireEvent.click(screen.getByRole('button', { name: '发起本地训练' }));
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
+ const request = fetchMock.mock.calls[1][1] as RequestInit;
+ expect(JSON.parse(String(request.body))).toMatchObject({
+ taskId: 'Unitree-Go2-Flat',
+ numEnvs: 32,
+ device: 'gpu',
+ gpuIds: [0],
+ wandbMode: 'offline',
+ });
+ expect(new Headers(request.headers).get('Authorization')).toBe('Bearer secret-token');
expect(await screen.findByText('排队中')).toBeInTheDocument();
+
+ const tokenInput = screen.getByLabelText('训练服务访问令牌');
+ expect(tokenInput).toBeEnabled();
+ fireEvent.change(tokenInput, { target: { value: 'new-secret-token' } });
+ fireEvent.click(screen.getByRole('button', { name: '连接' }));
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(4));
+ expect(await screen.findByRole('button', { name: '发起本地训练' })).toBeInTheDocument();
+ expect(sessionStorage.getItem('mujoco-local-training-token')).toBe('new-secret-token');
});
});
diff --git a/web_platform/src/app/components/LocalTrainingPanel.tsx b/web_platform/src/app/components/LocalTrainingPanel.tsx
index f61be61d..e2732296 100644
--- a/web_platform/src/app/components/LocalTrainingPanel.tsx
+++ b/web_platform/src/app/components/LocalTrainingPanel.tsx
@@ -1,77 +1,435 @@
-import {useEffect,useState,type ReactNode} from 'react';
-import {Download,Link,Play,Server,Square} from 'lucide-react';
-import {Badge,Button,ProgressBar,PropertyRow,Select} from '../../components/ui';
-import {LocalTrainingClient} from '../../training/LocalTrainingClient';
-import type {TrainingDevice,TrainingJob,TrainingServerInfo,WandbMode} from '../../training/types';
+import { useEffect, useState, type ReactNode } from 'react';
+import { Download, Link, Play, Server, Square } from 'lucide-react';
+import { Badge, Button, ProgressBar, PropertyRow, Select } from '../../components/ui';
+import { LocalTrainingClient } from '../../training/LocalTrainingClient';
+import type {
+ TrainingDevice,
+ TrainingJob,
+ TrainingServerInfo,
+ WandbMode,
+} from '../../training/types';
-const ENDPOINT_KEY='mujoco-local-training-endpoint',JOB_KEY='mujoco-local-training-job';
-const DEFAULT_ENDPOINT='http://127.0.0.1:8765';
-const ACTIVE_STATES=new Set(['queued','running']);
-function stored(key:string,fallback=''):string{try{return localStorage.getItem(key)??fallback;}catch{return fallback;}}
-function errorText(error:unknown):string{return error instanceof Error?error.message:String(error);}
-function stateLabel(state:TrainingJob['state']):string{return {queued:'排队中',running:'训练中',succeeded:'已完成',failed:'失败',cancelled:'已取消'}[state];}
-
-export function LocalTrainingPanel({onPolicyReady}:{onPolicyReady(file:File):void}){
- const [endpoint,setEndpoint]=useState(()=>stored(ENDPOINT_KEY,DEFAULT_ENDPOINT));
- const [server,setServer]=useState();
- const [job,setJob]=useState();
- const [busy,setBusy]=useState(false),[error,setError]=useState();
- const [taskId,setTaskId]=useState('Unitree-Go2-Flat'),[numEnvs,setNumEnvs]=useState(4096),[maxIterations,setMaxIterations]=useState(2000),[seed,setSeed]=useState(42),[runName,setRunName]=useState('web'),[device,setDevice]=useState('gpu'),[gpuIds,setGpuIds]=useState('0'),[wandbMode,setWandbMode]=useState('offline');
-
- const connect=async()=>{
- setBusy(true);setError(undefined);
- try{
- const client=new LocalTrainingClient(endpoint),info=await client.health();
- setServer(info);try{localStorage.setItem(ENDPOINT_KEY,client.endpoint);}catch{/* 当前会话仍可连接 */}
- if(info.tasks.length&&!info.tasks.includes(taskId))setTaskId(info.tasks[0]);
- const remembered=info.activeJobId??stored(JOB_KEY);
- if(remembered){try{setJob(await client.job(remembered));}catch{try{localStorage.removeItem(JOB_KEY);}catch{/* ignore */}}}
- if(!info.ready)setError(info.error??'训练服务尚未就绪');
- }catch(value){setServer(undefined);setError(errorText(value));}
- finally{setBusy(false);}
- };
-
- const jobId=job?.id,jobState=job?.state;
- useEffect(()=>{
- if(!jobId||!jobState||!ACTIVE_STATES.has(jobState))return;
- let disposed=false;
- const refresh=async()=>{try{const next=await new LocalTrainingClient(endpoint).job(jobId);if(!disposed)setJob(next);}catch(value){if(!disposed)setError(errorText(value));}};
- const timer=window.setInterval(()=>void refresh(),1500);return()=>{disposed=true;window.clearInterval(timer);};
- },[endpoint,jobId,jobState]);
-
- const start=async()=>{
- setBusy(true);setError(undefined);
- try{
- const ids=device==='gpu'?gpuIds.split(/[\s,]+/).filter(Boolean).map(Number):[];
- if(ids.some(id=>!Number.isInteger(id)||id<0))throw new Error('GPU 编号必须是非负整数');
- const next=await new LocalTrainingClient(endpoint).start({taskId,numEnvs,maxIterations,seed,runName,device,gpuIds:ids,wandbMode});
- setJob(next);try{localStorage.setItem(JOB_KEY,next.id);}catch{/* ignore */}
- }catch(value){setError(errorText(value));}finally{setBusy(false);}
- };
- const cancel=async()=>{if(!job)return;setBusy(true);setError(undefined);try{setJob(await new LocalTrainingClient(endpoint).cancel(job.id));}catch(value){setError(errorText(value));}finally{setBusy(false);}};
- const importResult=async()=>{if(!job)return;setBusy(true);setError(undefined);try{onPolicyReady(await new LocalTrainingClient(endpoint).downloadPolicy(job.id));}catch(value){setError(errorText(value));}finally{setBusy(false);}};
- const active=Boolean(job&&ACTIVE_STATES.has(job.state));
-
- return
-
-
{server?.trainerRoot??'请先启动本地训练服务'}{server?.ready?'可用':'离线'}
- {server?.ready&&!job&&
-
-
setRunName(event.target.value)}/>
-
setGpuIds(event.target.value)}/>
-
-
} disabled={busy} onClick={()=>void start()}>发起本地训练
-
训练使用本地 mjlab 任务资产,不会把浏览器中的模型上传到网络。服务一次只运行一个训练任务。
-
}
- {job&&
-
{job.taskId}{stateLabel(job.state)}
-
- {job.logs.length>0&&
最近日志
{job.logs.slice(-40).join('\n')}}
-
{active?} disabled={busy} onClick={()=>void cancel()}>停止训练:<>} onClick={()=>void importResult()}>导入策略>}
-
}
- {error&&
{error}
}
-
;
+const ENDPOINT_KEY = 'mujoco-local-training-endpoint',
+ JOB_KEY = 'mujoco-local-training-job',
+ TOKEN_KEY = 'mujoco-local-training-token';
+const DEFAULT_ENDPOINT = 'http://127.0.0.1:8765';
+const ACTIVE_STATES = new Set(['queued', 'running']);
+function stored(key: string, fallback = ''): string {
+ try {
+ return localStorage.getItem(key) ?? fallback;
+ } catch {
+ return fallback;
+ }
+}
+function sessionStored(key: string): string {
+ try {
+ return sessionStorage.getItem(key) ?? '';
+ } catch {
+ return '';
+ }
+}
+function errorText(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
+function stateLabel(state: TrainingJob['state']): string {
+ return {
+ queued: '排队中',
+ running: '训练中',
+ succeeded: '已完成',
+ failed: '失败',
+ cancelled: '已取消',
+ }[state];
}
-function Field({label,children}:{label:string;children:ReactNode}){return ;}
-function NumberField({label,value,min,max,onChange}:{label:string;value:number;min:number;max:number;onChange(value:number):void}){return onChange(Number(event.target.value))}/>;}
+export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File): void }) {
+ const [endpoint, setEndpoint] = useState(() => stored(ENDPOINT_KEY, DEFAULT_ENDPOINT));
+ const [token, setToken] = useState(() => sessionStored(TOKEN_KEY));
+ const [server, setServer] = useState();
+ const [job, setJob] = useState();
+ const [busy, setBusy] = useState(false),
+ [error, setError] = useState();
+ const [taskId, setTaskId] = useState('Unitree-Go2-Flat'),
+ [numEnvs, setNumEnvs] = useState(4096),
+ [maxIterations, setMaxIterations] = useState(2000),
+ [seed, setSeed] = useState(42),
+ [runName, setRunName] = useState('web'),
+ [device, setDevice] = useState('gpu'),
+ [gpuIds, setGpuIds] = useState('0'),
+ [wandbMode, setWandbMode] = useState('offline');
+
+ const connect = async () => {
+ setBusy(true);
+ setError(undefined);
+ try {
+ const client = new LocalTrainingClient(endpoint, token),
+ info = await client.health();
+ setServer(info);
+ try {
+ localStorage.setItem(ENDPOINT_KEY, client.endpoint);
+ sessionStorage.setItem(TOKEN_KEY, client.token);
+ } catch {
+ /* 当前会话仍可连接 */
+ }
+ if (info.tasks.length && !info.tasks.includes(taskId)) setTaskId(info.tasks[0]);
+ const remembered = info.activeJobId ?? stored(JOB_KEY);
+ if (remembered) {
+ try {
+ const recovered = await client.job(remembered);
+ setJob(recovered);
+ try {
+ localStorage.setItem(JOB_KEY, recovered.id);
+ } catch {
+ /* ignore */
+ }
+ } catch {
+ setJob(undefined);
+ try {
+ localStorage.removeItem(JOB_KEY);
+ } catch {
+ /* ignore */
+ }
+ }
+ } else {
+ setJob(undefined);
+ }
+ if (!info.ready) setError(info.error ?? '训练服务尚未就绪');
+ } catch (value) {
+ setServer(undefined);
+ setError(errorText(value));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const jobId = job?.id,
+ jobState = job?.state;
+ useEffect(() => {
+ if (!jobId || !jobState || !ACTIVE_STATES.has(jobState)) return;
+ let disposed = false;
+ const refresh = async () => {
+ try {
+ const next = await new LocalTrainingClient(endpoint, token).job(jobId);
+ if (!disposed) setJob(next);
+ } catch (value) {
+ if (!disposed) setError(errorText(value));
+ }
+ };
+ const timer = window.setInterval(() => void refresh(), 1500);
+ return () => {
+ disposed = true;
+ window.clearInterval(timer);
+ };
+ }, [endpoint, jobId, jobState, token]);
+
+ const start = async () => {
+ setBusy(true);
+ setError(undefined);
+ try {
+ const ids =
+ device === 'gpu'
+ ? gpuIds
+ .split(/[\s,]+/)
+ .filter(Boolean)
+ .map(Number)
+ : [];
+ if (ids.some((id) => !Number.isInteger(id) || id < 0))
+ throw new Error('GPU 编号必须是非负整数');
+ const next = await new LocalTrainingClient(endpoint, token).start({
+ taskId,
+ numEnvs,
+ maxIterations,
+ seed,
+ runName,
+ device,
+ gpuIds: ids,
+ wandbMode,
+ });
+ setJob(next);
+ try {
+ localStorage.setItem(JOB_KEY, next.id);
+ } catch {
+ /* ignore */
+ }
+ } catch (value) {
+ setError(errorText(value));
+ } finally {
+ setBusy(false);
+ }
+ };
+ const cancel = async () => {
+ if (!job) return;
+ setBusy(true);
+ setError(undefined);
+ try {
+ setJob(await new LocalTrainingClient(endpoint, token).cancel(job.id));
+ } catch (value) {
+ setError(errorText(value));
+ } finally {
+ setBusy(false);
+ }
+ };
+ const importResult = async () => {
+ if (!job) return;
+ setBusy(true);
+ setError(undefined);
+ try {
+ onPolicyReady(await new LocalTrainingClient(endpoint, token).downloadPolicy(job.id));
+ } catch (value) {
+ setError(errorText(value));
+ } finally {
+ setBusy(false);
+ }
+ };
+ const active = Boolean(job && ACTIVE_STATES.has(job.state));
+
+ return (
+
+
+
+
+
+
+ {server?.trainerRoot ?? '请先启动本地训练服务'}
+
+
+ {server?.ready ? '可用' : '离线'}
+
+
+ {server?.ready && !job && (
+
+
+
+
+
+
+
+
+
+ setRunName(event.target.value)}
+ />
+
+
+
+
+
+
+
+ setGpuIds(event.target.value)}
+ />
+
+
+
+
+
+
}
+ disabled={busy}
+ onClick={() => void start()}
+ >
+ 发起本地训练
+
+
+ 训练使用本地 mjlab
+ 任务资产,不会把浏览器中的模型上传到网络。服务一次只运行一个训练任务。
+
+
+ )}
+ {job && (
+
+
+
+ {job.taskId}
+
+
+ {stateLabel(job.state)}
+
+
+
+
+ {job.logs.length > 0 && (
+
+ 最近日志
+
+ {job.logs.slice(-40).join('\n')}
+
+
+ )}
+
+ {active ? (
+ }
+ disabled={busy}
+ onClick={() => void cancel()}
+ >
+ 停止训练
+
+ ) : (
+ <>
+ }
+ onClick={() => void importResult()}
+ >
+ 导入策略
+
+
+ >
+ )}
+
+
+ )}
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+}
+
+function Field({ label, children }: { label: string; children: ReactNode }) {
+ return (
+
+ );
+}
+function NumberField({
+ label,
+ value,
+ min,
+ max,
+ onChange,
+}: {
+ label: string;
+ value: number;
+ min: number;
+ max: number;
+ onChange(value: number): void;
+}) {
+ return (
+
+ onChange(Number(event.target.value))}
+ />
+
+ );
+}
diff --git a/web_platform/src/app/components/MapAssetLibrary.test.tsx b/web_platform/src/app/components/MapAssetLibrary.test.tsx
new file mode 100644
index 00000000..061834e2
--- /dev/null
+++ b/web_platform/src/app/components/MapAssetLibrary.test.tsx
@@ -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(
+ ,
+ );
+
+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();
+ 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');
+ });
+});
diff --git a/web_platform/src/app/components/MapAssetLibrary.tsx b/web_platform/src/app/components/MapAssetLibrary.tsx
new file mode 100644
index 00000000..576c7502
--- /dev/null
+++ b/web_platform/src/app/components/MapAssetLibrary.tsx
@@ -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 (
+
+ );
+}
+
+function TerrainPreview({ preset }: { preset: SystemTerrainPreset }) {
+ const grid = (
+
+
+
+
+ );
+ let shape;
+ if (preset === 'discrete_obstacles')
+ shape = (
+
+
+
+
+
+ );
+ else if (preset === 'gap')
+ shape = (
+
+
+
+
+ );
+ else if (preset === 'inverted_pyramid_stairs' || preset === 'pit')
+ shape = (
+
+
+
+
+ {preset === 'inverted_pyramid_stairs' && (
+
+ )}
+
+ );
+ else if (preset === 'pyramid_stairs')
+ shape = (
+
+
+
+
+
+
+ );
+ else if (preset === 'rails')
+ shape = (
+
+
+
+
+ );
+ else if (preset === 'rough')
+ shape = (
+
+ );
+ else if (preset === 'stepping_stones')
+ shape = (
+
+
+
+ );
+ else
+ shape = (
+
+
+
+
+
+ );
+ return (
+
+ );
+}
+
+function AddAction({ label }: { label: string }) {
+ return (
+
+ {label}
+
+
+
+
+ );
+}
+
+export function MapAssetLibrary({
+ disabled,
+ terrainSize,
+ document,
+ onAdd,
+ onSelectTerrain,
+ onSelectObject,
+}: {
+ disabled: boolean;
+ terrainSize: number;
+ document?: EditableMapDocument | null;
+ onAdd: (
+ type: EditableMapObjectType,
+ placementMode: MapObjectPlacementMode,
+ ) => void | Promise;
+ onSelectTerrain: (preset: SystemTerrainPreset) => void;
+ onSelectObject?: (id: string) => void;
+}) {
+ const [placementMode, setPlacementMode] = useState('auto_ground');
+ return (
+
+
+
+ 场景结构
+
+ {document?.objects.length ?? 0} 个对象
+
+
+ {document?.objects.length ? (
+
+ {document.objects.map((object) => (
+
+ ))}
+
+ ) : (
+
+ 选择工程地图后,这里显示场景对象层级;可从下方资产库创建场景。
+
+ )}
+
+
+
+
+
+
+ 认证资产
+
+
+ 点击添加,或按住资产拖到画布落位。
+
+
+
+
+
+ {CERTIFIED_MAP_ASSETS.map((asset) => (
+
{
+ 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);
+ }}
+ >
+
+
+
+ {asset.name}
+
+
+ {asset.description}
+
+
+
+
+ ))}
+
+
+
+
+
+ 系统参数化地形
+
+
+ 本地确定性生成;点击“添加”后在右侧“地图”调整尺寸、难度和随机种子。
+
+
+
+ {SYSTEM_TERRAIN_PRESETS.map((preset) => (
+
+
+
+
+
+
+
+
+
+ {PHYSICAL_MAP_PRESET_LABELS[preset]}
+
+
+ {terrainSize.toFixed(2)} × {terrainSize.toFixed(2)} m
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/web_platform/src/app/components/MapEditorPanel.test.tsx b/web_platform/src/app/components/MapEditorPanel.test.tsx
new file mode 100644
index 00000000..b21bca62
--- /dev/null
+++ b/web_platform/src/app/components/MapEditorPanel.test.tsx
@@ -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(
+ {}}
+ />,
+ );
+ 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(
+ {}}
+ 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(
+ {}}
+ 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(
+ {}}
+ onApply={async () => true}
+ onExport={() => {}}
+ />,
+ );
+ expect(screen.getByText(/保持只读/)).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: '创建可编辑副本' }));
+ await waitFor(() => expect(convert).toHaveBeenCalledOnce());
+ });
+});
diff --git a/web_platform/src/app/components/MapEditorPanel.tsx b/web_platform/src/app/components/MapEditorPanel.tsx
new file mode 100644
index 00000000..9c19fb4a
--- /dev/null
+++ b/web_platform/src/app/components/MapEditorPanel.tsx
@@ -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 = {
+ 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 {
+ 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;
+ onExport: () => void;
+ onConvert: () => Promise;
+ 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(),
+ [selectedType, setSelectedType] = useState('box'),
+ [transformMode, setTransformMode] = useState('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 (
+
+
当前地图没有 V3 authoring.source,保持只读。
+
+ 仅由 box、cylinder、capsule 组成且不含资产、材质或碰撞过滤的静态 MJCF
+ 可以安全转换;遇到无法表达的语义会整体拒绝,不会静默丢失内容。
+
+
+
+
+
+ {!canConvert &&
该地图没有 MJCF 物理层,无法转换。
}
+
+ );
+ 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 (
+
+
+
+
+
+
+
+ 在视口点击对象后拖动操纵轴;只允许绕世界 Z 轴旋转,缩放会写入原语尺寸。
+
+
+
+
+
+
+
+ {current.objects.map((object) => (
+
+ ))}
+ {!current.objects.length &&
暂无对象
}
+
+ {active && (
+
+
+
+
+ {active.pose.position.map((value, index) => (
+
+ ))}
+
+
+
+ {Object.entries(active.parameters).map(([key, value]) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ )}
+
+
+
+ 出生点
+
+
+ {current.spawnPoints.map((spawn) => (
+
+ ))}
+ {!current.spawnPoints.length && (
+
暂无出生点
+ )}
+
+
+
+
+
+
+
+
+
+ {session.dirty && (
+
+ 地图草稿尚未应用
+
+ )}
+
+ );
+}
diff --git a/web_platform/src/app/components/NotificationCenter.tsx b/web_platform/src/app/components/NotificationCenter.tsx
index ffb38769..d8b4a3e2 100644
--- a/web_platform/src/app/components/NotificationCenter.tsx
+++ b/web_platform/src/app/components/NotificationCenter.tsx
@@ -1,7 +1,141 @@
-import {useEffect,useRef} from 'react';
-import {Bell,CheckCircle2,Info,Trash2,TriangleAlert,XCircle} from 'lucide-react';
-import {Badge,IconButton,Popover} from '../../components/ui';
-export interface WorkbenchNotification{id:number;title:string;detail?:string;tone:'success'|'warning'|'danger'|'info';at:number;}
-const icons={success:CheckCircle2,warning:TriangleAlert,danger:XCircle,info:Info};
-export function NotificationCenter({items,onDismiss,onClear,onOpenLog}:{items:WorkbenchNotification[];onDismiss:(id:number)=>void;onClear:()=>void;onOpenLog?:()=>void}){return {items.length>0&&}}>{({close})=>{items.length?items.map(item=>{const Icon=icons[item.tone];return
{item.title}
{new Date(item.at).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'})}{item.detail&&
{item.detail}
}
onDismiss(item.id)}>}):
当前没有通知
}
};}
-export function ToastViewport({item,onDismiss}:{item?:WorkbenchNotification;onDismiss:(id:number)=>void}){const dismissRef=useRef(onDismiss);useEffect(()=>{dismissRef.current=onDismiss;},[onDismiss]);useEffect(()=>{if(!item)return;const timer=window.setTimeout(()=>dismissRef.current(item.id),4000);return()=>window.clearTimeout(timer);},[item]);if(!item)return null;const Icon=icons[item.tone];return {item.title}
{item.detail&&
{item.detail}
}
;}
+import { useEffect, useRef } from 'react';
+import { Bell, CheckCircle2, Info, Trash2, TriangleAlert, XCircle } from 'lucide-react';
+import { Badge, IconButton, Popover } from '../../components/ui';
+export interface WorkbenchNotification {
+ id: number;
+ title: string;
+ detail?: string;
+ tone: 'success' | 'warning' | 'danger' | 'info';
+ at: number;
+}
+const icons = { success: CheckCircle2, warning: TriangleAlert, danger: XCircle, info: Info };
+export function NotificationCenter({
+ items,
+ onDismiss,
+ onClear,
+ onOpenLog,
+}: {
+ items: WorkbenchNotification[];
+ onDismiss: (id: number) => void;
+ onClear: () => void;
+ onOpenLog?: () => void;
+}) {
+ return (
+ (
+
+
+ {items.length > 0 && (
+
+ )}
+
+ )}
+ >
+ {({ close }) => (
+
+
+
+ {items.length ? (
+ items.map((item) => {
+ const Icon = icons[item.tone];
+ return (
+
+
+
+
+
{item.title}
+
+ {new Date(item.at).toLocaleTimeString('zh-CN', {
+ hour: '2-digit',
+ minute: '2-digit',
+ })}
+
+
+ {item.detail && (
+
+ {item.detail}
+
+ )}
+
+ onDismiss(item.id)}
+ >
+
+
+
+ );
+ })
+ ) : (
+
当前没有通知
+ )}
+
+
+ )}
+
+ );
+}
+export function ToastViewport({
+ item,
+ onDismiss,
+}: {
+ item?: WorkbenchNotification;
+ onDismiss: (id: number) => void;
+}) {
+ const dismissRef = useRef(onDismiss);
+ useEffect(() => {
+ dismissRef.current = onDismiss;
+ }, [onDismiss]);
+ useEffect(() => {
+ if (!item) return;
+ const timer = window.setTimeout(() => dismissRef.current(item.id), 4000);
+ return () => window.clearTimeout(timer);
+ }, [item]);
+ if (!item) return null;
+ const Icon = icons[item.tone];
+ return (
+
+
+
+
{item.title}
+ {item.detail && (
+
{item.detail}
+ )}
+
+
+ );
+}
diff --git a/web_platform/src/app/components/PerformancePopover.tsx b/web_platform/src/app/components/PerformancePopover.tsx
index f08f172c..916e6603 100644
--- a/web_platform/src/app/components/PerformancePopover.tsx
+++ b/web_platform/src/app/components/PerformancePopover.tsx
@@ -1,3 +1,68 @@
-import {Activity,ChevronUp,Cpu,MemoryStick,TriangleAlert} from 'lucide-react';
-import {Badge,Popover,PropertyRow,Separator} from '../../components/ui';
-export function PerformancePopover({fps,stepMs,memoryMb,overBudget}:{fps:number;stepMs:number;memoryMb?:number;overBudget:boolean}){return }>{()=> 运行性能
{overBudget?'预算超限':'运行正常'}{overBudget?
主线程超出步进预算,平台已限制追帧以保持交互响应。
:
指标来自浏览器运行时,仅用于当前会话诊断。
}
};}
+import { Activity, ChevronUp, Cpu, MemoryStick, TriangleAlert } from 'lucide-react';
+import { Badge, Popover, PropertyRow, Separator } from '../../components/ui';
+export function PerformancePopover({
+ fps,
+ stepMs,
+ memoryMb,
+ overBudget,
+}: {
+ fps: number;
+ stepMs: number;
+ memoryMb?: number;
+ overBudget: boolean;
+}) {
+ return (
+ (
+
+ )}
+ >
+ {() => (
+
+
+
运行性能
+
+ {overBudget ? '预算超限' : '运行正常'}
+
+
+
+
+
+
+ {overBudget ? (
+
+
+ 主线程超出步进预算,平台已限制追帧以保持交互响应。
+
+ ) : (
+
+
+ 指标来自浏览器运行时,仅用于当前会话诊断。
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/web_platform/src/app/components/PhysicalMapPanel.test.tsx b/web_platform/src/app/components/PhysicalMapPanel.test.tsx
new file mode 100644
index 00000000..03d46cc6
--- /dev/null
+++ b/web_platform/src/app/components/PhysicalMapPanel.test.tsx
@@ -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();
+ 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();
+ 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();
+ 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(
+ ,
+ );
+ 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(
+ {}}
+ />,
+ );
+ expect(screen.getByRole('status')).toHaveTextContent('原生 URDF');
+ expect(screen.getByRole('button', { name: '应用并重新编译' })).toBeDisabled();
+ expect(screen.getByLabelText('地图来源')).not.toBeDisabled();
+ });
+});
diff --git a/web_platform/src/app/components/PhysicalMapPanel.tsx b/web_platform/src/app/components/PhysicalMapPanel.tsx
new file mode 100644
index 00000000..92dafdd4
--- /dev/null
+++ b/web_platform/src/app/components/PhysicalMapPanel.tsx
@@ -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 (
+
+ );
+}
+
+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;
+ onEditorExport: () => void;
+ onEditorConvert: () => Promise;
+ 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(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 = (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 (
+
+
+
地图参数
+
+ 从左侧“资产”选择地形或添加场景对象,在这里调整物理参数与编辑属性。
+
+
+
+
+
+ {draft.kind === 'builtin' && (
+ <>
+
+
+
+ 地图变换
+
+
+
+ updateBuiltin('positionX', next)}
+ />
+ updateBuiltin('positionY', next)}
+ />
+ updateBuiltin('yawDeg', next)}
+ />
+
+
+ 调整资产地图在世界坐标中的位置和朝向,应用后同步更新物理碰撞。
+
+
+
+ updateBuiltin('size', next)}
+ />
+ updateBuiltin('friction', next)}
+ />
+ {draft.config.preset === 'slope' && (
+ updateBuiltin('slopeAngle', next)}
+ />
+ )}
+ {draft.config.preset === 'stairs' && (
+ updateBuiltin('stairCount', next)}
+ />
+ )}
+ {draft.config.preset === 'obstacles' && (
+ updateBuiltin('obstacleCount', next)}
+ />
+ )}
+ {(draft.config.preset === 'obstacles' ||
+ isSystemTerrainPreset(draft.config.preset)) && (
+ updateBuiltin('seed', next)}
+ />
+ )}
+ {isSystemTerrainPreset(draft.config.preset) && (
+ updateBuiltin('terrainDifficulty', next)}
+ />
+ )}
+ {(draft.config.preset === 'rough' || draft.config.preset === 'wave') && (
+ <>
+ updateBuiltin('terrainHorizontalScale', next)}
+ />
+ updateBuiltin('terrainVerticalScale', next)}
+ />
+ >
+ )}
+
+ >
+ )}
+
+ {draft.kind === 'project' && projectMap && (
+
+
{projectMap.name}
+
+ {projectMap.descriptorPath}
+
+ {projectMap.spawnPoints.length > 0 && (
+
+ )}
+ {rootBodies.length > 1 && (
+
+ )}
+
+
+ )}
+
+ {draft.kind === 'project' &&
+ draft.descriptorPath === (value.kind === 'project' ? value.descriptorPath : '') && (
+
+ )}
+
+
+
+
+
+
+ {nativeUrdf && normalized.kind !== 'none' && (
+
+ 原生 URDF 不能注入地图。请在“属性”中切换为“转换为 MJCF”。
+
+ )}
+
+
+
+ );
+}
diff --git a/web_platform/src/app/components/ProjectBreadcrumb.tsx b/web_platform/src/app/components/ProjectBreadcrumb.tsx
index 605b1b5d..4973b15d 100644
--- a/web_platform/src/app/components/ProjectBreadcrumb.tsx
+++ b/web_platform/src/app/components/ProjectBreadcrumb.tsx
@@ -1,4 +1,52 @@
-import {ChevronRight,FolderRoot} from 'lucide-react';
-import type {ModelEntry} from '../../project/types';
-import {SearchableCombobox} from '../../components/ui';
-export function ProjectBreadcrumb({projectName,entries,selectedEntry,loading=false,onSelect}:{projectName:string;entries:ModelEntry[];selectedEntry?:string;loading?:boolean;onSelect:(path:string)=>void}){const parts=selectedEntry?.split('/').filter(Boolean)??[];return {projectName}{parts.map((part,index)=>{part})}
{entries.length>1&&
({value:entry.path,label:entry.label,description:entry.path}))}/>
}
;}
+import { ChevronRight, FolderRoot } from 'lucide-react';
+import type { ModelEntry } from '../../project/types';
+import { SearchableCombobox } from '../../components/ui';
+export function ProjectBreadcrumb({
+ projectName,
+ entries,
+ selectedEntry,
+ loading = false,
+ onSelect,
+}: {
+ projectName: string;
+ entries: ModelEntry[];
+ selectedEntry?: string;
+ loading?: boolean;
+ onSelect: (path: string) => void;
+}) {
+ const parts = selectedEntry?.split('/').filter(Boolean) ?? [];
+ return (
+
+
+
+ {projectName}
+ {parts.map((part, index) => (
+
+
+
+ {part}
+
+
+ ))}
+
+ {entries.length > 1 && (
+
+ ({
+ value: entry.path,
+ label: entry.label,
+ description: entry.path,
+ }))}
+ />
+
+ )}
+
+ );
+}
diff --git a/web_platform/src/app/components/PythonControllerPanel.test.tsx b/web_platform/src/app/components/PythonControllerPanel.test.tsx
index dd66fd55..7fa7ccea 100644
--- a/web_platform/src/app/components/PythonControllerPanel.test.tsx
+++ b/web_platform/src/app/components/PythonControllerPanel.test.tsx
@@ -1,23 +1,69 @@
-import {fireEvent,render,screen} from '@testing-library/react';
-import {describe,expect,it,vi} from 'vitest';
-import {PythonControllerPanel} from './PythonControllerPanel';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { PythonControllerPanel } from './PythonControllerPanel';
-const noop=()=>{};
+const noop = () => {};
-describe('PythonControllerPanel',()=>{
- it('向支持 command 的已启用控制器发送基本移动指令',()=>{
- const onCommand=vi.fn();
- render();
- fireEvent.click(screen.getByRole('button',{name:'前进'}));
- fireEvent.click(screen.getByRole('button',{name:'左转'}));
- fireEvent.click(screen.getByRole('button',{name:'起跳'}));
- expect(onCommand.mock.calls).toEqual([['forward'],['turn_left'],['jump']]);
- expect(screen.getByRole('button',{name:'移动停止'})).toHaveAttribute('aria-pressed','true');
+describe('PythonControllerPanel', () => {
+ it('向支持 command 的已启用控制器发送基本移动指令', () => {
+ const onCommand = vi.fn();
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByRole('button', { name: '前进' }));
+ fireEvent.click(screen.getByRole('button', { name: '左转' }));
+ fireEvent.click(screen.getByRole('button', { name: '起跳' }));
+ expect(onCommand.mock.calls).toEqual([['forward'], ['turn_left'], ['jump']]);
+ expect(screen.getByRole('button', { name: '移动停止' })).toHaveAttribute(
+ 'aria-pressed',
+ 'true',
+ );
});
- it('控制器未启用时禁用基本移动按钮',()=>{
- render();
- expect(screen.getByRole('button',{name:'前进'})).toBeDisabled();
- expect(screen.getByRole('button',{name:'起跳'})).toBeDisabled();
+ it('控制器未启用时禁用基本移动按钮', () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole('button', { name: '前进' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: '起跳' })).toBeDisabled();
});
});
diff --git a/web_platform/src/app/components/PythonControllerPanel.tsx b/web_platform/src/app/components/PythonControllerPanel.tsx
index 04571f79..c3c314bd 100644
--- a/web_platform/src/app/components/PythonControllerPanel.tsx
+++ b/web_platform/src/app/components/PythonControllerPanel.tsx
@@ -1,37 +1,184 @@
-import {useRef,type ChangeEvent} from 'react';
-import {ArrowDown,ArrowLeft,ArrowRight,ArrowUp,FileUp,Octagon,Power,RotateCw,Trash2} from 'lucide-react';
-import type {ControllerCommand,ControllerStatus} from '../../controller/types';
-import {Badge,Button,PropertyRow,Select} from '../../components/ui';
+import { useRef, type ChangeEvent } from 'react';
+import {
+ ArrowDown,
+ ArrowLeft,
+ ArrowRight,
+ ArrowUp,
+ FileUp,
+ Octagon,
+ Power,
+ RotateCw,
+ Trash2,
+} from 'lucide-react';
+import type { ControllerCommand, ControllerStatus } from '../../controller/types';
+import { Badge, Button, PropertyRow, Select } from '../../components/ui';
export interface PythonControllerPanelProps {
- paths:string[];
- selectedPath?:string;
- status?:ControllerStatus;
- loading:boolean;
- onSelectPath(path:string):void;
- onLoadPath(path:string):void;
- onImport(file:File):void;
- onToggle(enabled:boolean):void;
- onCommand(command:ControllerCommand):void;
- onRemove():void;
+ paths: string[];
+ selectedPath?: string;
+ status?: ControllerStatus;
+ loading: boolean;
+ onSelectPath(path: string): void;
+ onLoadPath(path: string): void;
+ onImport(file: File): void;
+ onToggle(enabled: boolean): void;
+ onCommand(command: ControllerCommand): void;
+ onRemove(): void;
}
-export function PythonControllerPanel({paths,selectedPath,status,loading,onSelectPath,onLoadPath,onImport,onToggle,onCommand,onRemove}:PythonControllerPanelProps){
- const input=useRef(null);
- const importFile=(event:ChangeEvent)=>{const file=event.target.files?.[0];if(file)onImport(file);event.target.value='';};
- return
-
- {paths.length>0&&
}
-
-
} disabled={loading} onClick={()=>input.current?.click()}>导入 .py
-
} disabled={loading||!selectedPath} onClick={()=>selectedPath&&onLoadPath(selectedPath)}>加载脚本
+export function PythonControllerPanel({
+ paths,
+ selectedPath,
+ status,
+ loading,
+ onSelectPath,
+ onLoadPath,
+ onImport,
+ onToggle,
+ onCommand,
+ onRemove,
+}: PythonControllerPanelProps) {
+ const input = useRef
(null);
+ const importFile = (event: ChangeEvent) => {
+ const file = event.target.files?.[0];
+ if (file) onImport(file);
+ event.target.value = '';
+ };
+ return (
+
+
+ {paths.length > 0 && (
+
+ )}
+
+ }
+ disabled={loading}
+ onClick={() => input.current?.click()}
+ >
+ 导入 .py
+
+ }
+ disabled={loading || !selectedPath}
+ onClick={() => selectedPath && onLoadPath(selectedPath)}
+ >
+ 加载脚本
+
+
+ {status ? (
+
+
+
+ {status.name}
+
+ {status.enabled ? '运行中' : '已停止'}
+
+
+
+
+ {status.error && (
+
+ {status.error}
+
+ )}
+ {status.acceptsCommands && (
+
+
基本移动指令
+
+
+ }
+ onClick={() => onCommand('forward')}
+ >
+ 前进
+
+
+ }
+ onClick={() => onCommand('turn_left')}
+ >
+ 左转
+
+ }
+ onClick={() => onCommand('stop')}
+ >
+ 停止
+
+ }
+ onClick={() => onCommand('turn_right')}
+ >
+ 右转
+
+
+ }
+ onClick={() => onCommand('backward')}
+ >
+ 后退
+
+
+
+
+ )}
+
+ }
+ onClick={() => onToggle(!status.enabled)}
+ >
+ {status.enabled ? '停止' : '启用'}
+
+ } onClick={onRemove}>
+ 卸载
+
+
+
+ ) : (
+
+ 加载可信的单文件 Python 控制器。脚本在每次 mj_step 前按仿真时间同步执行,默认 100 Hz。
+
+ )}
- {status?
-
{status.name}{status.enabled?'运行中':'已停止'}
-
- {status.error&&
{status.error}
}
- {status.acceptsCommands&&
基本移动指令
} onClick={()=>onCommand('forward')}>前进} onClick={()=>onCommand('turn_left')}>左转} onClick={()=>onCommand('stop')}>停止} onClick={()=>onCommand('turn_right')}>右转} onClick={()=>onCommand('backward')}>后退
}
-
} onClick={()=>onToggle(!status.enabled)}>{status.enabled?'停止':'启用'}} onClick={onRemove}>卸载
-
:加载可信的单文件 Python 控制器。脚本在每次 mj_step 前按仿真时间同步执行,默认 100 Hz。
}
- ;
+ );
}
diff --git a/web_platform/src/app/components/RLPolicyPanel.tsx b/web_platform/src/app/components/RLPolicyPanel.tsx
index 391f6899..cb53777f 100644
--- a/web_platform/src/app/components/RLPolicyPanel.tsx
+++ b/web_platform/src/app/components/RLPolicyPanel.tsx
@@ -1,30 +1,190 @@
-import {useRef,type ChangeEvent} from 'react';
-import {BrainCircuit,FileUp,Power,RotateCw,Trash2} from 'lucide-react';
-import type {RLCommand,RLPolicyStatus} from '../../rl/types';
-import {Badge,Button,PropertyRow,Select} from '../../components/ui';
+import { useRef, type ChangeEvent } from 'react';
+import { BrainCircuit, FileUp, Power, RotateCw, Trash2 } from 'lucide-react';
+import type { RLCommand, RLPolicyStatus } from '../../rl/types';
+import { Badge, Button, PropertyRow, Select } from '../../components/ui';
export interface RLPolicyPanelProps {
- paths:string[];selectedPath?:string;status?:RLPolicyStatus;loading:boolean;
- onSelectPath(path:string):void;onLoadPath(path:string):void;onImport(file:File):void;
- onToggle(enabled:boolean):void;onCommand(command:RLCommand):void;onRemove():void;
+ paths: string[];
+ selectedPath?: string;
+ status?: RLPolicyStatus;
+ loading: boolean;
+ onSelectPath(path: string): void;
+ onLoadPath(path: string): void;
+ onImport(file: File): void;
+ onToggle(enabled: boolean): void;
+ onCommand(command: RLCommand): void;
+ onRemove(): void;
}
-export function RLPolicyPanel({paths,selectedPath,status,loading,onSelectPath,onLoadPath,onImport,onToggle,onCommand,onRemove}:RLPolicyPanelProps){
- const input=useRef
(null);
- const importFile=(event:ChangeEvent)=>{const file=event.target.files?.[0];if(file)onImport(file);event.target.value='';};
- const command=status?.command??{linearX:0,linearY:0,angularZ:0};
- return
-
- {paths.length>0&&
}
-
} disabled={loading} onClick={()=>input.current?.click()}>导入 ONNX} disabled={loading||!selectedPath} onClick={()=>selectedPath&&onLoadPath(selectedPath)}>加载策略
- {status?
-
{status.taskName}{status.enabled?'推理中':'已停止'}
-
- 速度指令(机身坐标系)
onCommand({...command,linearX})}/>onCommand({...command,linearY})}/>onCommand({...command,angularZ})}/>
- {status.error&&{status.error}
}
- } disabled={Boolean(status.error)} onClick={()=>onToggle(!status.enabled)}>{status.enabled?'停止':'启用'}} onClick={onRemove}>卸载
- :
加载 mjlab 导出的单输入、单动作输出 policy.onnx。首个内置任务使用 47 维 Go2 actor 观测和 12 维腿部关节位置动作;Go2-W 轮电机保持零力矩。
}
-
;
+export function RLPolicyPanel({
+ paths,
+ selectedPath,
+ status,
+ loading,
+ onSelectPath,
+ onLoadPath,
+ onImport,
+ onToggle,
+ onCommand,
+ onRemove,
+}: RLPolicyPanelProps) {
+ const input = useRef(null);
+ const importFile = (event: ChangeEvent) => {
+ const file = event.target.files?.[0];
+ if (file) onImport(file);
+ event.target.value = '';
+ };
+ const command = status?.command ?? { linearX: 0, linearY: 0, angularZ: 0 };
+ return (
+
+
+ {paths.length > 0 && (
+
+ )}
+
+ }
+ disabled={loading}
+ onClick={() => input.current?.click()}
+ >
+ 导入 ONNX
+
+ }
+ disabled={loading || !selectedPath}
+ onClick={() => selectedPath && onLoadPath(selectedPath)}
+ >
+ 加载策略
+
+
+ {status ? (
+
+
+
+
+ {status.taskName}
+
+ {status.enabled ? '推理中' : '已停止'}
+
+
+
+
+
+
+
速度指令(机身坐标系)
+
onCommand({ ...command, linearX })}
+ />
+ onCommand({ ...command, linearY })}
+ />
+ onCommand({ ...command, angularZ })}
+ />
+
+
+ {status.error && (
+
+ {status.error}
+
+ )}
+
+ }
+ disabled={Boolean(status.error)}
+ onClick={() => onToggle(!status.enabled)}
+ >
+ {status.enabled ? '停止' : '启用'}
+
+ } onClick={onRemove}>
+ 卸载
+
+
+
+ ) : (
+
+ 加载 mjlab 导出的单输入、单动作输出 policy.onnx。首个内置任务使用 47 维 Go2 actor 观测和
+ 12 维腿部关节位置动作;Go2-W 轮电机保持零力矩。
+
+ )}
+
+ );
}
-function CommandInput({label,value,min,max,onChange}:{label:string;value:number;min:number;max:number;onChange(value:number):void}){return ;}
+function CommandInput({
+ label,
+ value,
+ min,
+ max,
+ onChange,
+}: {
+ label: string;
+ value: number;
+ min: number;
+ max: number;
+ onChange(value: number): void;
+}) {
+ return (
+
+ );
+}
diff --git a/web_platform/src/app/components/SecondBatchComponents.test.tsx b/web_platform/src/app/components/SecondBatchComponents.test.tsx
index e1166102..673b522a 100644
--- a/web_platform/src/app/components/SecondBatchComponents.test.tsx
+++ b/web_platform/src/app/components/SecondBatchComponents.test.tsx
@@ -1,15 +1,62 @@
-import {fireEvent,render,screen} from '@testing-library/react';
-import {ShortcutHelpDialog} from './ShortcutHelpDialog';
-import {TreeSearchField} from './TreeSearchField';
-import {ViewportHUD} from './ViewportHUD';
-import {EmptyWorkspace} from './WorkspaceOverlays';
-import {ViewerDisplayPopover} from './ViewerDisplayPopover';
-import {DEFAULT_VIEWER_DISPLAY_OPTIONS} from '../../viewer/displayOptions';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { ShortcutHelpDialog } from './ShortcutHelpDialog';
+import { TreeSearchField } from './TreeSearchField';
+import { ViewportHUD } from './ViewportHUD';
+import { EmptyWorkspace } from './WorkspaceOverlays';
+import { ViewerDisplayPopover } from './ViewerDisplayPopover';
+import { DEFAULT_VIEWER_DISPLAY_OPTIONS } from '../../viewer/displayOptions';
-describe('第二批工作台组件',()=>{
- it('搜索框发送内容并可清除',()=>{const change=vi.fn();const {rerender}=render();fireEvent.change(screen.getByRole('searchbox'),{target:{value:'arm'}});expect(change).toHaveBeenCalledWith('arm');rerender();expect(screen.getByRole('status')).toHaveTextContent('找到 2 个匹配项');fireEvent.click(screen.getByRole('button',{name:'清除搜索'}));expect(change).toHaveBeenLastCalledWith('');});
- it('快捷键帮助展示说明并支持 Escape',()=>{const close=vi.fn();render();expect(screen.getByRole('dialog',{name:'快捷键与视口操作'})).toBeVisible();expect(screen.getByText('播放 / 暂停')).toBeVisible();fireEvent.keyDown(document,{key:'Escape'});expect(close).toHaveBeenCalledTimes(1);});
- it('视口 HUD 复用状态并给出当前模式的鼠标提示',()=>{render();expect(screen.getByLabelText('视口状态')).toHaveTextContent('仿真中');expect(screen.getByLabelText('视口状态')).toHaveTextContent('关节拖动');expect(screen.getByLabelText('视口状态')).toHaveTextContent('arm');expect(screen.getByLabelText('视口操作提示')).toHaveTextContent('左键拖动关节');expect(screen.getByLabelText('视口操作提示')).toHaveTextContent('右键平移');});
- it('空工作区解释导入到仿真的三步流程',()=>{render();expect(screen.getByRole('region',{name:'导入模型工程'})).toBeVisible();expect(screen.getByRole('list',{name:'仿真工作流程'})).toHaveTextContent('导入');expect(screen.getByRole('list',{name:'仿真工作流程'})).toHaveTextContent('检查与配置');expect(screen.getByRole('list',{name:'仿真工作流程'})).toHaveTextContent('运行与调试');expect(screen.getByText('模型与资源仅在当前浏览器会话中处理')).toBeVisible();});
- it('显示浮窗切换碰撞体和结构辅助标记',()=>{const change=vi.fn();render();fireEvent.click(screen.getByRole('button',{name:'显示设置'}));expect(screen.getByRole('dialog',{name:'视图显示设置'})).toBeVisible();expect(screen.getAllByRole('switch')).toHaveLength(7);fireEvent.click(screen.getByRole('switch',{name:/碰撞体/}));expect(change).toHaveBeenCalledWith({...DEFAULT_VIEWER_DISPLAY_OPTIONS,showCollision:true});});
+describe('第二批工作台组件', () => {
+ it('搜索框发送内容并可清除', () => {
+ const change = vi.fn();
+ const { rerender } = render();
+ fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'arm' } });
+ expect(change).toHaveBeenCalledWith('arm');
+ rerender();
+ expect(screen.getByRole('status')).toHaveTextContent('找到 2 个匹配项');
+ fireEvent.click(screen.getByRole('button', { name: '清除搜索' }));
+ expect(change).toHaveBeenLastCalledWith('');
+ });
+ it('快捷键帮助展示说明并支持 Escape', () => {
+ const close = vi.fn();
+ render();
+ expect(screen.getByRole('dialog', { name: '快捷键与视口操作' })).toBeVisible();
+ expect(screen.getByText('播放 / 暂停')).toBeVisible();
+ fireEvent.keyDown(document, { key: 'Escape' });
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+ it('视口 HUD 复用状态并给出当前模式的鼠标提示', () => {
+ render(
+ ,
+ );
+ expect(screen.getByLabelText('视口状态')).toHaveTextContent('仿真中');
+ expect(screen.getByLabelText('视口状态')).toHaveTextContent('关节拖动');
+ expect(screen.getByLabelText('视口状态')).toHaveTextContent('arm');
+ expect(screen.getByLabelText('视口操作提示')).toHaveTextContent('左键拖动关节');
+ expect(screen.getByLabelText('视口操作提示')).toHaveTextContent('右键平移');
+ });
+ it('空工作区解释导入到仿真的三步流程', () => {
+ render();
+ expect(screen.getByRole('region', { name: '导入模型工程' })).toBeVisible();
+ expect(screen.getByRole('list', { name: '仿真工作流程' })).toHaveTextContent('导入');
+ expect(screen.getByRole('list', { name: '仿真工作流程' })).toHaveTextContent('检查与配置');
+ expect(screen.getByRole('list', { name: '仿真工作流程' })).toHaveTextContent('运行与调试');
+ expect(screen.getByText('模型与资源仅在当前浏览器会话中处理')).toBeVisible();
+ });
+ it('显示浮窗切换碰撞体和结构辅助标记', () => {
+ const change = vi.fn();
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByRole('button', { name: '显示设置' }));
+ expect(screen.getByRole('dialog', { name: '视图显示设置' })).toBeVisible();
+ expect(screen.getAllByRole('switch')).toHaveLength(7);
+ fireEvent.click(screen.getByRole('switch', { name: /碰撞体/ }));
+ expect(change).toHaveBeenCalledWith({ ...DEFAULT_VIEWER_DISPLAY_OPTIONS, showCollision: true });
+ });
});
diff --git a/web_platform/src/app/components/SettingsDialog.tsx b/web_platform/src/app/components/SettingsDialog.tsx
index 9318d8e4..5f17b6cf 100644
--- a/web_platform/src/app/components/SettingsDialog.tsx
+++ b/web_platform/src/app/components/SettingsDialog.tsx
@@ -1,3 +1,106 @@
-import {Dialog,PropertyRow,Select} from '../../components/ui';
-export function SettingsDialog({open,onClose,theme,angleUnit,showCollision,jointAdvanced,forceScale,onTheme,onAngleUnit,onShowCollision,onJointAdvanced,onForceScale}:{open:boolean;onClose:()=>void;theme:'light'|'dark';angleUnit:'rad'|'deg';showCollision:boolean;jointAdvanced:boolean;forceScale:number;onTheme:(value:'light'|'dark')=>void;onAngleUnit:(value:'rad'|'deg')=>void;onShowCollision:(value:boolean)=>void;onJointAdvanced:(value:boolean)=>void;onForceScale:(value:number)=>void}){return ;}
-function Check({label,checked,onChange}:{label:string;checked:boolean;onChange:(value:boolean)=>void}){return ;}
+import { Dialog, PropertyRow, Select } from '../../components/ui';
+export function SettingsDialog({
+ open,
+ onClose,
+ theme,
+ angleUnit,
+ showCollision,
+ jointAdvanced,
+ forceScale,
+ onTheme,
+ onAngleUnit,
+ onShowCollision,
+ onJointAdvanced,
+ onForceScale,
+}: {
+ open: boolean;
+ onClose: () => void;
+ theme: 'light' | 'dark';
+ angleUnit: 'rad' | 'deg';
+ showCollision: boolean;
+ jointAdvanced: boolean;
+ forceScale: number;
+ onTheme: (value: 'light' | 'dark') => void;
+ onAngleUnit: (value: 'rad' | 'deg') => void;
+ onShowCollision: (value: boolean) => void;
+ onJointAdvanced: (value: boolean) => void;
+ onForceScale: (value: number) => void;
+}) {
+ return (
+
+ );
+}
+function Check({
+ label,
+ checked,
+ onChange,
+}: {
+ label: string;
+ checked: boolean;
+ onChange: (value: boolean) => void;
+}) {
+ return (
+
+ );
+}
diff --git a/web_platform/src/app/components/ShortcutHelpDialog.tsx b/web_platform/src/app/components/ShortcutHelpDialog.tsx
index fdf5a0a9..f1577a17 100644
--- a/web_platform/src/app/components/ShortcutHelpDialog.tsx
+++ b/web_platform/src/app/components/ShortcutHelpDialog.tsx
@@ -1,3 +1,37 @@
-import {Dialog,Kbd,Separator} from '../../components/ui';
-const shortcuts=[['Space','播放 / 暂停'],['R','重置仿真'],['1','选择模式'],['2','关节拖动'],['3','外力施加']];
-export function ShortcutHelpDialog({open,onClose}:{open:boolean;onClose:()=>void}){return ;}
+import { Dialog, Kbd, Separator } from '../../components/ui';
+const shortcuts = [
+ ['Space', '播放 / 暂停'],
+ ['R', '重置仿真'],
+ ['1', '选择模式'],
+ ['2', '关节拖动'],
+ ['3', '外力施加'],
+];
+export function ShortcutHelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
+ return (
+
+ );
+}
diff --git a/web_platform/src/app/components/SidebarPanel.tsx b/web_platform/src/app/components/SidebarPanel.tsx
index e1a488af..fb3bb20d 100644
--- a/web_platform/src/app/components/SidebarPanel.tsx
+++ b/web_platform/src/app/components/SidebarPanel.tsx
@@ -1,67 +1,913 @@
-import {useState,type ReactNode} from 'react';
-import {Box,FolderTree,Info,Settings2,SlidersHorizontal} from 'lucide-react';
-import type {ModelEntry} from '../../project/types';
-import {countProjectSearchResults,ProjectTree,type ProjectTreeFile} from '../../project/ProjectTree';
-import {countModelStructureSearchResults,ModelStructureTree} from '../../project/ModelStructureTree';
-import type {ActuatorInfo,ActuatorParameters,SimulationSnapshot} from '../../simulation/SimulationSession';
-import type {UrdfBaseMode,UrdfLoadMode} from '../../simulation/PhysicsAdapter';
-import type {ViewerSelection} from '../../viewer/MuJoCoViewer';
-import type {ControllerCommand,ControllerStatus} from '../../controller/types';
-import type {RLCommand,RLPolicyStatus} from '../../rl/types';
-import {Badge,Button,CollapsibleSection,CopyButton,PropertyRow,ResizablePanel,Select,Tabs} from '../../components/ui';
-import {TreeSearchField} from './TreeSearchField';
-import {ProjectBreadcrumb} from './ProjectBreadcrumb';
-import {PythonControllerPanel} from './PythonControllerPanel';
-import {RLPolicyPanel} from './RLPolicyPanel';
-import {LocalTrainingPanel} from './LocalTrainingPanel';
+import { useState, type ReactNode } from 'react';
+import {
+ Box,
+ Database,
+ FolderTree,
+ Info,
+ Map as MapIcon,
+ Settings2,
+ SlidersHorizontal,
+} from 'lucide-react';
+import type { MapEntry, ModelEntry } from '../../project/types';
+import {
+ countProjectSearchResults,
+ ProjectTree,
+ type ProjectTreeFile,
+} from '../../project/ProjectTree';
+import {
+ countModelStructureSearchResults,
+ ModelStructureTree,
+} from '../../project/ModelStructureTree';
+import type {
+ ActuatorInfo,
+ ActuatorParameters,
+ SimulationSnapshot,
+} from '../../simulation/SimulationSession';
+import type { UrdfBaseMode, UrdfLoadMode } from '../../simulation/PhysicsAdapter';
+import type { DataRecorderConfig } from '../../simulation/DataRecorder';
+import type { ViewerSelection } from '../../viewer/MuJoCoViewer';
+import type { ControllerCommand, ControllerStatus } from '../../controller/types';
+import type { RLCommand, RLPolicyStatus } from '../../rl/types';
+import {
+ Badge,
+ Button,
+ CollapsibleSection,
+ CopyButton,
+ PropertyRow,
+ ResizablePanel,
+ Select,
+ Tabs,
+} from '../../components/ui';
+import { TreeSearchField } from './TreeSearchField';
+import { ProjectBreadcrumb } from './ProjectBreadcrumb';
+import { PythonControllerPanel } from './PythonControllerPanel';
+import { RLPolicyPanel } from './RLPolicyPanel';
+import { LocalTrainingPanel } from './LocalTrainingPanel';
+import { DataRecordingPanel } from './DataRecordingPanel';
+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,side,children,visible=true}:{title:string;side:'left'|'right';children:ReactNode;visible?:boolean}){return ;}
-
-export function ProjectSidebar({projectName,files,entries,selectedEntry,snapshot,loading,visible=true,onRemove,onSelectEntry,onJointHover}:{projectName?:string;files:ProjectTreeFile[];entries:ModelEntry[];selectedEntry?:string;snapshot?:SimulationSnapshot;loading:boolean;visible?:boolean;onRemove:()=>void;onSelectEntry:(path:string)=>void;onJointHover:(jointId:number|null)=>void}){const [tab,setTab]=useState<'project'|'structure'>('project'),[fileQuery,setFileQuery]=useState(''),[structureQuery,setStructureQuery]=useState('');const fileMatches=countProjectSearchResults(files,fileQuery),structureMatches=snapshot?countModelStructureSearchResults(snapshot.bodies,snapshot.joints,structureQuery):0;return {projectName?<>{projectName}
{files.length} 个文件
,content:<>>},{value:'structure',label:'模型结构',icon:,disabled:!snapshot,content:snapshot?<>
>:加载模型后显示结构
}]}/>>:导入模型后显示工程资源
};}
-
-interface ModelControlsProps{
- snapshot?:SimulationSnapshot;selection:ViewerSelection|null;selectedFormat?:ModelEntry['format'];loading:boolean;visible?:boolean;
- urdfMode:UrdfLoadMode;baseMode:UrdfBaseMode;showCollision:boolean;ignoreJointLimits:boolean;jointAdvanced:boolean;angleUnit:'rad'|'deg';forceScale:number;
- controllerPaths:string[];selectedControllerPath?:string;controllerStatus?:ControllerStatus;
- policyPaths:string[];selectedPolicyPath?:string;policyStatus?:RLPolicyStatus;
- onUrdfMode:(value:UrdfLoadMode)=>void;onBaseMode:(value:UrdfBaseMode)=>void;onShowCollision:(value:boolean)=>void;
- onResetJoints:()=>void;onToggleJointLimits:()=>void;onToggleAdvanced:()=>void;onToggleAngleUnit:()=>void;
- onActuator:(id:number,value:number)=>void;onActuatorParameters:(id:number,parameters:ActuatorParameters)=>void;onJoint:(id:number,value:number)=>void;onForceScale:(value:number)=>void;
- onSelectControllerPath:(path:string)=>void;onLoadControllerPath:(path:string)=>void;onImportController:(file:File)=>void;onToggleController:(enabled:boolean)=>void;onControllerCommand:(command:ControllerCommand)=>void;onRemoveController:()=>void;
- onSelectPolicyPath:(path:string)=>void;onLoadPolicyPath:(path:string)=>void;onImportPolicy:(file:File)=>void;onTogglePolicy:(enabled:boolean)=>void;onPolicyCommand:(command:RLCommand)=>void;onRemovePolicy:()=>void;
-}
-export function ModelControlsSidebar(props:ModelControlsProps){const [tab,setTab]=useState<'properties'|'controls'>('properties'),s=props.snapshot;if(!s)return 导入模型后显示属性
;
- const properties=<>{s.model.nbody} Body}>
- {props.selectedFormat==='urdf'&&MJCF 模式保留 visual mesh、添加物理地面,并将模型最低点对齐到 z=0。
}
- {props.selection?}/>
}/>
value.toFixed(3)).join(', ')} action={}/> :在视口中单击物体
}>;
- const controls=<>{s.rlPolicy.enabled?'推理':'停止'}:undefined}>{s.controller.enabled?'运行':'停止'}:undefined}>{s.actuators.length}}>{s.actuators.length?s.actuators.map(actuator=>props.onActuator(actuator.id,value)} onParameters={parameters=>props.onActuatorParameters(actuator.id,parameters)}/>):模型没有驱动器
}
- {s.joints.length}}>{s.joints.map(joint=>{const scale=joint.type===3&&props.angleUnit==='deg'?180/Math.PI:1,unit=joint.type===3?(props.angleUnit==='deg'?'°':' rad'):joint.type===2?' m':'';return props.onJoint(joint.id,value/scale)}/>;})}
- 选择“外力施加”,在动态物体上按住拖动,松开即清零。
>;
- return ,content:properties},{value:'controls',label:'控制',icon:,content:controls}]}/>;
+export function SidebarPanel({
+ title,
+ side,
+ children,
+ visible = true,
+}: {
+ title: string;
+ side: 'left' | 'right';
+ children: ReactNode;
+ visible?: boolean;
+}) {
+ return (
+
+
+
+ );
}
-export function ActuatorControl({actuator,onControl,onParameters}:{actuator:ActuatorInfo;onControl:(value:number)=>void;onParameters:(parameters:ActuatorParameters)=>void}){
- const isMotor=actuator.kind==='motor',isPosition=actuator.kind==='position',editable=isMotor||isPosition,baseTargetScale=isPosition&&actuator.jointType===3?180/Math.PI:1,targetScale=isPosition&&Math.abs(actuator.gear)>1e-9?baseTargetScale/actuator.gear:baseTargetScale;
- const clampForce=(value:number)=>actuator.forceLimited?Math.min(actuator.forceMax,Math.max(actuator.forceMin,value)):value,physicalScale=actuator.gear*actuator.gain;
- const forceA=clampForce(actuator.min*actuator.gain)*actuator.gear,forceB=clampForce(actuator.max*actuator.gain)*actuator.gear;
- const targetA=actuator.min*targetScale,targetB=actuator.max*targetScale,outputMin=isMotor?Math.min(forceA,forceB):Math.min(targetA,targetB),outputMax=isMotor?Math.max(forceA,forceB):Math.max(targetA,targetB);
- const outputValue=isMotor?clampForce(actuator.value*actuator.gain)*actuator.gear:actuator.value*targetScale,outputDisabled=(isMotor&&Math.abs(physicalScale)<=1e-9)||(isPosition&&Math.abs(actuator.gear)<=1e-9);
- const outputLabel=isMotor?(actuator.jointType===3?'输出力矩':'输出力'):isPosition?(actuator.jointType===3?'目标角度':'目标位置'):'控制输入';
- const forceUnit=actuator.jointType===3?'N·m':actuator.jointType===2?'N':'',jointForceA=actuator.forceMin*actuator.gear,jointForceB=actuator.forceMax*actuator.gear,jointForceMin=Math.min(jointForceA,jointForceB),jointForceMax=Math.max(jointForceA,jointForceB);
- const update=(patch:Partial)=>onParameters({...actuator,...patch}),controlLabel=isPosition?(actuator.jointType===3?'角度':'位置'):'控制',gearSquared=actuator.gear*actuator.gear;
- return
-
{actuator.name}
{actuator.jointName?`关节:${actuator.jointName}`:'未关联标量关节'}
{actuator.unit||'u'}
- {actuator.controlCount===1?
{if(outputDisabled)return;onControl(isMotor?value/physicalScale:value/targetScale);}}/>:该驱动器包含 {actuator.controlCount} 个控制分量,请在 MJCF 源码或专用控制器中设置。
}
- {actuator.controlCount===1&&!actuator.ctrlLimited&&onControl(value/targetScale)}/> }
- {editable?常用参数
- {isPosition?<>
update({kp:kp/gearSquared})}/>update({kv:kv/gearSquared})}/>>:<>update({kp})}/>update({kv})}/>>}
- update({forceLimited})}/>
- update(actuator.gear>=0?{forceMin:value/actuator.gear}:{forceMax:value/actuator.gear})}/>update(actuator.gear>=0?{forceMax:value/actuator.gear}:{forceMin:value/actuator.gear})}/>
- {isPosition?'position 伺服使用 kp 跟踪目标位置,kv 提供速度阻尼。':'motor 保持力/力矩控制且控制输入不限幅。MJCF 的 motor 没有 kp/kv 属性;这里的 kp、kv 会分别保存为对应 joint 的 stiffness、damping。'} 参数修改会立即作用于当前模型,并可随 MJCF 导出。
- :该驱动器不是可直接编辑的 motor/position 类型,控制值按模型原始单位显示;请在 MJCF 源码中修改专用参数。
}
- ;
+export function ProjectSidebar({
+ projectName,
+ files,
+ entries,
+ selectedEntry,
+ snapshot,
+ loading,
+ visible = true,
+ nativeUrdf,
+ mapSelection,
+ editorDocument,
+ activeTab,
+ onActiveTabChange,
+ onRemove,
+ onSelectEntry,
+ onJointHover,
+ onAddMapAsset,
+ onSelectTerrain,
+ onSelectMapObject,
+}: {
+ projectName?: string;
+ files: ProjectTreeFile[];
+ entries: ModelEntry[];
+ selectedEntry?: string;
+ 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;
+ onSelectTerrain: (preset: SystemTerrainPreset) => void;
+ onSelectMapObject: (id: string) => void;
+}) {
+ const [internalTab, setInternalTab] = useState<'project' | 'structure' | 'assets'>('project'),
+ [fileQuery, setFileQuery] = useState(''),
+ [structureQuery, setStructureQuery] = useState('');
+ const tab = activeTab ?? internalTab,
+ fileMatches = countProjectSearchResults(files, fileQuery),
+ structureMatches = snapshot
+ ? countModelStructureSearchResults(snapshot.bodies, snapshot.joints, structureQuery)
+ : 0;
+ return (
+
+ {projectName ? (
+ <>
+
+
+
+ {projectName}
+
+
{files.length} 个文件
+
+
+
+
+ {
+ setInternalTab(value);
+ onActiveTabChange?.(value);
+ }}
+ items={[
+ {
+ value: 'project',
+ label: '工程',
+ icon: ,
+ content: (
+ <>
+
+
+ >
+ ),
+ },
+ {
+ value: 'structure',
+ label: '模型结构',
+ icon: ,
+ disabled: !snapshot,
+ content: snapshot ? (
+ <>
+
+
+
+
+ >
+ ) : (
+ 加载模型后显示结构
+ ),
+ },
+ {
+ value: 'assets',
+ label: '资产',
+ icon: ,
+ disabled: !snapshot,
+ content: (
+
+ ),
+ },
+ ]}
+ />
+ >
+ ) : (
+ 导入模型后显示工程资源
+ )}
+
+ );
+}
+
+interface ModelControlsProps {
+ snapshot?: SimulationSnapshot;
+ selection: ViewerSelection | null;
+ selectedFormat?: ModelEntry['format'];
+ loading: boolean;
+ visible?: boolean;
+ urdfMode: UrdfLoadMode;
+ baseMode: UrdfBaseMode;
+ showCollision: boolean;
+ ignoreJointLimits: boolean;
+ jointAdvanced: boolean;
+ angleUnit: 'rad' | 'deg';
+ forceScale: number;
+ controllerPaths: string[];
+ selectedControllerPath?: string;
+ controllerStatus?: ControllerStatus;
+ 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;
+ onResetJoints: () => void;
+ onToggleJointLimits: () => void;
+ onToggleAdvanced: () => void;
+ onToggleAngleUnit: () => void;
+ onActuator: (id: number, value: number) => void;
+ onActuatorParameters: (id: number, parameters: ActuatorParameters) => void;
+ onJoint: (id: number, value: number) => void;
+ onForceScale: (value: number) => void;
+ onSelectControllerPath: (path: string) => void;
+ onLoadControllerPath: (path: string) => void;
+ onImportController: (file: File) => void;
+ onToggleController: (enabled: boolean) => void;
+ onControllerCommand: (command: ControllerCommand) => void;
+ onRemoveController: () => void;
+ onSelectPolicyPath: (path: string) => void;
+ onLoadPolicyPath: (path: string) => void;
+ onImportPolicy: (file: File) => void;
+ onTogglePolicy: (enabled: boolean) => void;
+ onPolicyCommand: (command: RLCommand) => void;
+ onRemovePolicy: () => void;
+ onApplyMap: (value: MapSelection) => void;
+ onEditorPreview: (document: EditableMapDocument | null) => void;
+ onEditorApply: (document: EditableMapDocument) => Promise;
+ onEditorExport: () => void;
+ onEditorConvert: () => Promise;
+ 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;
+ onDataRecorderConfigure: (patch: Partial) => void;
+ onDataRecordingStart: () => void;
+ onDataRecordingStop: () => void;
+ onDataRecordingClear: () => void;
+ onDataRecordingExport: (format: 'csv' | 'json') => void;
+}
+export function ModelControlsSidebar(props: ModelControlsProps) {
+ const [tab, setTab] = useState<'properties' | 'controls' | 'data' | 'map'>('properties'),
+ s = props.snapshot;
+ if (!s)
+ return (
+
+ 导入模型后显示属性
+
+ );
+ const properties = (
+ <>
+ {s.model.nbody} Body}>
+
+
+ {props.selectedFormat === 'urdf' && (
+
+
+
+
+ MJCF 模式保留 visual mesh、添加物理地面,并将模型最低点对齐到 z=0。
+
+
+
+ )}
+
+ {props.selection ? (
+
+
}
+ />
+
+ }
+ />
+
value.toFixed(3)).join(', ')}
+ action={}
+ />
+
+ ) : (
+
+
+ 在视口中单击物体
+
+ )}
+
+ >
+ );
+ const controls = (
+ <>
+ {s.rlPolicy.enabled ? '推理' : '停止'} : undefined}
+ >
+
+
+
+
+
+ {s.controller.enabled ? '运行' : '停止'} : undefined}
+ >
+
+
+ {s.actuators.length}}
+ >
+ {s.actuators.length ? (
+ s.actuators.map((actuator) => (
+ props.onActuator(actuator.id, value)}
+ onParameters={(parameters) => props.onActuatorParameters(actuator.id, parameters)}
+ />
+ ))
+ ) : (
+ 模型没有驱动器
+ )}
+
+ {s.joints.length}}>
+
+
+
+
+
+
+ {s.joints.map((joint) => {
+ const scale = joint.type === 3 && props.angleUnit === 'deg' ? 180 / Math.PI : 1,
+ unit =
+ joint.type === 3
+ ? props.angleUnit === 'deg'
+ ? '°'
+ : ' rad'
+ : joint.type === 2
+ ? ' m'
+ : '';
+ return (
+ props.onJoint(joint.id, value / scale)}
+ />
+ );
+ })}
+
+
+
+
+ 选择“外力施加”,在动态物体上按住拖动,松开即清零。
+
+
+ >
+ );
+ return (
+
+ {
+ setTab(value);
+ if (value === 'map') props.onMapTabOpen?.();
+ }}
+ items={[
+ {
+ value: 'properties',
+ label: '属性',
+ icon: ,
+ content: properties,
+ },
+ {
+ value: 'controls',
+ label: '控制',
+ icon: ,
+ content: controls,
+ },
+ {
+ value: 'data',
+ label: '数据',
+ icon: ,
+ content: (
+
+ ),
+ },
+ {
+ value: 'map',
+ label: '地图',
+ icon: ,
+ content: (
+
+ 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}
+ />
+ ),
+ },
+ ]}
+ />
+
+ );
+}
+
+export function ActuatorControl({
+ actuator,
+ onControl,
+ onParameters,
+}: {
+ actuator: ActuatorInfo;
+ onControl: (value: number) => void;
+ onParameters: (parameters: ActuatorParameters) => void;
+}) {
+ const isMotor = actuator.kind === 'motor',
+ isPosition = actuator.kind === 'position',
+ editable = isMotor || isPosition,
+ baseTargetScale = isPosition && actuator.jointType === 3 ? 180 / Math.PI : 1,
+ targetScale =
+ isPosition && Math.abs(actuator.gear) > 1e-9
+ ? baseTargetScale / actuator.gear
+ : baseTargetScale;
+ const clampForce = (value: number) =>
+ actuator.forceLimited
+ ? Math.min(actuator.forceMax, Math.max(actuator.forceMin, value))
+ : value,
+ physicalScale = actuator.gear * actuator.gain;
+ const forceA = clampForce(actuator.min * actuator.gain) * actuator.gear,
+ forceB = clampForce(actuator.max * actuator.gain) * actuator.gear;
+ const targetA = actuator.min * targetScale,
+ targetB = actuator.max * targetScale,
+ outputMin = isMotor ? Math.min(forceA, forceB) : Math.min(targetA, targetB),
+ outputMax = isMotor ? Math.max(forceA, forceB) : Math.max(targetA, targetB);
+ const outputValue = isMotor
+ ? clampForce(actuator.value * actuator.gain) * actuator.gear
+ : actuator.value * targetScale,
+ outputDisabled =
+ (isMotor && Math.abs(physicalScale) <= 1e-9) ||
+ (isPosition && Math.abs(actuator.gear) <= 1e-9);
+ const outputLabel = isMotor
+ ? actuator.jointType === 3
+ ? '输出力矩'
+ : '输出力'
+ : isPosition
+ ? actuator.jointType === 3
+ ? '目标角度'
+ : '目标位置'
+ : '控制输入';
+ const forceUnit = actuator.jointType === 3 ? 'N·m' : actuator.jointType === 2 ? 'N' : '',
+ jointForceA = actuator.forceMin * actuator.gear,
+ jointForceB = actuator.forceMax * actuator.gear,
+ jointForceMin = Math.min(jointForceA, jointForceB),
+ jointForceMax = Math.max(jointForceA, jointForceB);
+ const update = (patch: Partial) => onParameters({ ...actuator, ...patch }),
+ controlLabel = isPosition ? (actuator.jointType === 3 ? '角度' : '位置') : '控制',
+ gearSquared = actuator.gear * actuator.gear;
+ return (
+
+
+
+
+ {actuator.name}
+
+
+ {actuator.jointName ? `关节:${actuator.jointName}` : '未关联标量关节'}
+
+
+
{actuator.unit || 'u'}
+
+ {actuator.controlCount === 1 ? (
+
{
+ if (outputDisabled) return;
+ onControl(isMotor ? value / physicalScale : value / targetScale);
+ }}
+ />
+ ) : (
+
+ 该驱动器包含 {actuator.controlCount} 个控制分量,请在 MJCF 源码或专用控制器中设置。
+
+ )}
+ {actuator.controlCount === 1 && !actuator.ctrlLimited && (
+
+
onControl(value / targetScale)}
+ />
+
+ )}
+ {editable ? (
+
+
+ 常用参数
+
+
+ {isPosition ? (
+ <>
+
update({ kp: kp / gearSquared })}
+ />
+ update({ kv: kv / gearSquared })}
+ />
+ >
+ ) : (
+ <>
+ update({ kp })}
+ />
+ update({ kv })}
+ />
+ >
+ )}
+
+ update({ forceLimited })}
+ />
+
+
+ update(
+ actuator.gear >= 0
+ ? { forceMin: value / actuator.gear }
+ : { forceMax: value / actuator.gear },
+ )
+ }
+ />
+
+ update(
+ actuator.gear >= 0
+ ? { forceMax: value / actuator.gear }
+ : { forceMin: value / actuator.gear },
+ )
+ }
+ />
+
+
+ {isPosition
+ ? 'position 伺服使用 kp 跟踪目标位置,kv 提供速度阻尼。'
+ : 'motor 保持力/力矩控制且控制输入不限幅。MJCF 的 motor 没有 kp/kv 属性;这里的 kp、kv 会分别保存为对应 joint 的 stiffness、damping。'}{' '}
+ 参数修改会立即作用于当前模型,并可随 MJCF 导出。
+
+
+ ) : (
+
+ 该驱动器不是可直接编辑的 motor/position 类型,控制值按模型原始单位显示;请在 MJCF
+ 源码中修改专用参数。
+
+ )}
+
+ );
+}
+function ParameterInput({
+ label,
+ value,
+ onCommit,
+ disabled = false,
+}: {
+ label: string;
+ value: number;
+ onCommit: (value: number) => void;
+ disabled?: boolean;
+}) {
+ return (
+
+ );
+}
+function ParameterToggle({
+ label,
+ checked,
+ onChange,
+}: {
+ label: string;
+ checked: boolean;
+ onChange: (value: boolean) => void;
+}) {
+ return (
+
+ );
+}
+function Check({
+ label,
+ checked,
+ onChange,
+}: {
+ label: string;
+ checked: boolean;
+ onChange: (value: boolean) => void;
+}) {
+ return (
+
+ );
+}
+function ControlSlider({
+ label,
+ value,
+ min,
+ max,
+ onChange,
+ disabled = false,
+ unit = '',
+ advanced = false,
+ limited = false,
+ limitsIgnored = false,
+ limitMin = 0,
+ limitMax = 0,
+}: {
+ label: string;
+ value: number;
+ min: number;
+ max: number;
+ onChange: (value: number) => void;
+ disabled?: boolean;
+ unit?: string;
+ advanced?: boolean;
+ limited?: boolean;
+ limitsIgnored?: boolean;
+ limitMin?: number;
+ limitMax?: number;
+}) {
+ const sane = Number.isFinite(value) ? value : 0,
+ format = (number: number) => `${number.toFixed(3)}${unit}`;
+ return (
+
+ );
}
-function ParameterInput({label,value,onCommit,disabled=false}:{label:string;value:number;onCommit:(value:number)=>void;disabled?:boolean}){return ;}
-function ParameterToggle({label,checked,onChange}:{label:string;checked:boolean;onChange:(value:boolean)=>void}){return ;}
-function Check({label,checked,onChange}:{label:string;checked:boolean;onChange:(value:boolean)=>void}){return ;}
-function ControlSlider({label,value,min,max,onChange,disabled=false,unit='',advanced=false,limited=false,limitsIgnored=false,limitMin=0,limitMax=0}:{label:string;value:number;min:number;max:number;onChange:(value:number)=>void;disabled?:boolean;unit?:string;advanced?:boolean;limited?:boolean;limitsIgnored?:boolean;limitMin?:number;limitMax?:number}){const sane=Number.isFinite(value)?value:0,format=(number:number)=>`${number.toFixed(3)}${unit}`;return ;}
diff --git a/web_platform/src/app/components/SourceEditorDialog.tsx b/web_platform/src/app/components/SourceEditorDialog.tsx
index 33ce5449..4c5e6c62 100644
--- a/web_platform/src/app/components/SourceEditorDialog.tsx
+++ b/web_platform/src/app/components/SourceEditorDialog.tsx
@@ -1,29 +1,248 @@
import './monacoSetup';
import Editor from '@monaco-editor/react';
-import {useCallback,useEffect,useRef,useState,type PointerEvent as ReactPointerEvent} from 'react';
-import {Check,Code2,Copy,Download,Maximize2,Minimize2,Save,X} from 'lucide-react';
-import {downloadBytes} from '../../project/cachedFiles';
-import {Button,ConfirmDialog,IconButton} from '../../components/ui';
+import {
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+ type PointerEvent as ReactPointerEvent,
+} from 'react';
+import { Check, Code2, Copy, Download, Maximize2, Minimize2, Save, X } from 'lucide-react';
+import { downloadBytes } from '../../project/cachedFiles';
+import { Button, ConfirmDialog, IconButton } from '../../components/ui';
-function basename(path:string):string{return path.split('/').at(-1)??path;}
-function contentSize(content:string):string{const bytes=new Blob([content]).size;return bytes<1024?`${bytes} B`:`${(bytes/1024).toFixed(1)} KB`;}
-function xmlProblem(code:string):string|undefined{const document=new DOMParser().parseFromString(code,'application/xml'),error=document.querySelector('parsererror');return error?.textContent?.split('\n')[0]||undefined;}
-
-export function SourceEditorDialog({open,code:sourceCode,filePath,theme,onClose,onSave}:{open:boolean;code:string;filePath:string;theme:'light'|'dark';onClose:()=>void;onSave:(path:string,text:string)=>void|Promise}){
- const [code,setCode]=useState(sourceCode),[savedCode,setSavedCode]=useState(sourceCode),[saving,setSaving]=useState(false),[copied,setCopied]=useState(false),[maximized,setMaximized]=useState(false),[discardOpen,setDiscardOpen]=useState(false),[position,setPosition]=useState(()=>({x:Math.max(24,(window.innerWidth-900)/2),y:Math.max(52,(window.innerHeight-650)/2)}));
- const drag=useRef<{x:number;y:number;left:number;top:number}|null>(null),dialog=useRef(null),previousFocus=useRef(null),dirty=code!==savedCode,problem=xmlProblem(code);
- const requestClose=useCallback(()=>{if(dirty)setDiscardOpen(true);else onClose();},[dirty,onClose]);
- const save=useCallback(async()=>{if(!dirty||problem)return;setSaving(true);try{await onSave(filePath,code);setSavedCode(code);}finally{setSaving(false);}},[code,dirty,filePath,onSave,problem]);
- useEffect(()=>{if(!open)return;previousFocus.current=document.activeElement instanceof HTMLElement?document.activeElement:null;requestAnimationFrame(()=>dialog.current?.focus());return()=>{if(previousFocus.current&&document.contains(previousFocus.current))previousFocus.current.focus();};},[open]);
- useEffect(()=>{const key=(event:KeyboardEvent)=>{if(discardOpen)return;if((event.ctrlKey||event.metaKey)&&event.key.toLowerCase()==='s'&&dirty&&!problem){event.preventDefault();void save();}else if(event.key==='Escape'){event.preventDefault();requestClose();}};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);},[dirty,discardOpen,problem,requestClose,save]);
- const copy=async()=>{await navigator.clipboard.writeText(code);setCopied(true);window.setTimeout(()=>setCopied(false),1500);};
- const download=()=>downloadBytes(new TextEncoder().encode(code),basename(filePath),'application/xml');
- const pointerDown=(event:ReactPointerEvent)=>{if(maximized||event.button!==0||(event.target as HTMLElement).closest('button'))return;drag.current={x:event.clientX,y:event.clientY,left:position.x,top:position.y};event.currentTarget.setPointerCapture(event.pointerId);};
- const pointerMove=(event:ReactPointerEvent)=>{if(!drag.current)return;setPosition({x:Math.min(window.innerWidth-120,Math.max(-780,drag.current.left+event.clientX-drag.current.x)),y:Math.min(window.innerHeight-48,Math.max(0,drag.current.top+event.clientY-drag.current.y))});};
- if(!open)return null;
- return <>
- {drag.current=null;}} onDoubleClick={()=>setMaximized(value=>!value)}>{contentSize(code)}缓存文件 · 可编辑{dirty&&已修改}} disabled={!dirty||saving||Boolean(problem)} onClick={()=>void save()}>{saving?'重新载入中…':'保存并重新载入'}} onClick={download}>下载:} onClick={()=>void copy()}>{copied?'已复制':'复制'}setMaximized(value=>!value)}>{maximized?:}
- setCode(value??'')} options={{automaticLayout:true,minimap:{enabled:false},fontFamily:"'JetBrains Mono','Fira Code',ui-monospace,monospace",fontSize:13,fontLigatures:true,scrollBeyondLastLine:false,wordWrap:'off',stickyScroll:{enabled:false},tabSize:2,formatOnPaste:true,formatOnType:true,lineNumbersMinChars:4,padding:{top:12,bottom:14},renderLineHighlight:'all'}}/>
-
- setDiscardOpen(false)}>当前 MJCF 源码包含未保存的修改。关闭后,这些修改将无法恢复。
>;
+function basename(path: string): string {
+ return path.split('/').at(-1) ?? path;
+}
+function contentSize(content: string): string {
+ const bytes = new Blob([content]).size;
+ return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;
+}
+function xmlProblem(code: string): string | undefined {
+ const document = new DOMParser().parseFromString(code, 'application/xml'),
+ error = document.querySelector('parsererror');
+ return error?.textContent?.split('\n')[0] || undefined;
+}
+
+export function SourceEditorDialog({
+ open,
+ code: sourceCode,
+ filePath,
+ theme,
+ onClose,
+ onSave,
+}: {
+ open: boolean;
+ code: string;
+ filePath: string;
+ theme: 'light' | 'dark';
+ onClose: () => void;
+ onSave: (path: string, text: string) => void | Promise;
+}) {
+ const [code, setCode] = useState(sourceCode),
+ [savedCode, setSavedCode] = useState(sourceCode),
+ [saving, setSaving] = useState(false),
+ [copied, setCopied] = useState(false),
+ [maximized, setMaximized] = useState(false),
+ [discardOpen, setDiscardOpen] = useState(false),
+ [position, setPosition] = useState(() => ({
+ x: Math.max(24, (window.innerWidth - 900) / 2),
+ y: Math.max(52, (window.innerHeight - 650) / 2),
+ }));
+ const drag = useRef<{ x: number; y: number; left: number; top: number } | null>(null),
+ dialog = useRef(null),
+ previousFocus = useRef(null),
+ dirty = code !== savedCode,
+ problem = xmlProblem(code);
+ const requestClose = useCallback(() => {
+ if (dirty) setDiscardOpen(true);
+ else onClose();
+ }, [dirty, onClose]);
+ const save = useCallback(async () => {
+ if (!dirty || problem) return;
+ setSaving(true);
+ try {
+ await onSave(filePath, code);
+ setSavedCode(code);
+ } finally {
+ setSaving(false);
+ }
+ }, [code, dirty, filePath, onSave, problem]);
+ useEffect(() => {
+ if (!open) return;
+ previousFocus.current =
+ document.activeElement instanceof HTMLElement ? document.activeElement : null;
+ requestAnimationFrame(() => dialog.current?.focus());
+ return () => {
+ if (previousFocus.current && document.contains(previousFocus.current))
+ previousFocus.current.focus();
+ };
+ }, [open]);
+ useEffect(() => {
+ const key = (event: KeyboardEvent) => {
+ if (discardOpen) return;
+ if (
+ (event.ctrlKey || event.metaKey) &&
+ event.key.toLowerCase() === 's' &&
+ dirty &&
+ !problem
+ ) {
+ event.preventDefault();
+ void save();
+ } else if (event.key === 'Escape') {
+ event.preventDefault();
+ requestClose();
+ }
+ };
+ window.addEventListener('keydown', key);
+ return () => window.removeEventListener('keydown', key);
+ }, [dirty, discardOpen, problem, requestClose, save]);
+ const copy = async () => {
+ await navigator.clipboard.writeText(code);
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 1500);
+ };
+ const download = () =>
+ downloadBytes(new TextEncoder().encode(code), basename(filePath), 'application/xml');
+ const pointerDown = (event: ReactPointerEvent) => {
+ if (maximized || event.button !== 0 || (event.target as HTMLElement).closest('button')) return;
+ drag.current = { x: event.clientX, y: event.clientY, left: position.x, top: position.y };
+ event.currentTarget.setPointerCapture(event.pointerId);
+ };
+ const pointerMove = (event: ReactPointerEvent) => {
+ if (!drag.current) return;
+ setPosition({
+ x: Math.min(
+ window.innerWidth - 120,
+ Math.max(-780, drag.current.left + event.clientX - drag.current.x),
+ ),
+ y: Math.min(
+ window.innerHeight - 48,
+ Math.max(0, drag.current.top + event.clientY - drag.current.y),
+ ),
+ });
+ };
+ if (!open) return null;
+ return (
+ <>
+
+
+ {
+ drag.current = null;
+ }}
+ onDoubleClick={() => setMaximized((value) => !value)}
+ >
+
+
+
+ 转换后的 MJCF
+
+
+ {filePath}
+
+
+ {contentSize(code)}
+
+ 缓存文件 · 可编辑
+
+ {dirty && (
+
+ 已修改
+
+ )}
+ }
+ disabled={!dirty || saving || Boolean(problem)}
+ onClick={() => void save()}
+ >
+ {saving ? '重新载入中…' : '保存并重新载入'}
+
+ } onClick={download}>
+ 下载
+
+ : }
+ onClick={() => void copy()}
+ >
+ {copied ? '已复制' : '复制'}
+
+ setMaximized((value) => !value)}
+ >
+ {maximized ? : }
+
+
+
+
+
+
+ setCode(value ?? '')}
+ options={{
+ automaticLayout: true,
+ minimap: { enabled: false },
+ fontFamily: "'JetBrains Mono','Fira Code',ui-monospace,monospace",
+ fontSize: 13,
+ fontLigatures: true,
+ scrollBeyondLastLine: false,
+ wordWrap: 'off',
+ stickyScroll: { enabled: false },
+ tabSize: 2,
+ formatOnPaste: true,
+ formatOnType: true,
+ lineNumbersMinChars: 4,
+ padding: { top: 12, bottom: 14 },
+ renderLineHighlight: 'all',
+ }}
+ />
+
+
+
+
+ setDiscardOpen(false)}
+ >
+
+ 当前 MJCF 源码包含未保存的修改。关闭后,这些修改将无法恢复。
+
+
+ >
+ );
}
diff --git a/web_platform/src/app/components/StatusBar.tsx b/web_platform/src/app/components/StatusBar.tsx
index f97658bb..f77ec8a5 100644
--- a/web_platform/src/app/components/StatusBar.tsx
+++ b/web_platform/src/app/components/StatusBar.tsx
@@ -1,7 +1,68 @@
-import type {ReactNode} from 'react';
-import {Box,Clock3,MemoryStick,TriangleAlert} from 'lucide-react';
-import {Kbd} from '../../components/ui';
-import {PerformancePopover} from './PerformancePopover';
-export interface StatusBarProps{time?:number;fps:number;stepMs:number;memoryMb?:number;loaded:boolean;overBudget:boolean;}
-function Item({icon:Icon,children,className=''}:{icon:typeof Clock3;children:ReactNode;className?:string}){return {children};}
-export function StatusBar({time,fps,stepMs,memoryMb,loaded,overBudget}:StatusBarProps){return ;}
+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;
+ fps: number;
+ stepMs: number;
+ memoryMb?: number;
+ loaded: boolean;
+ overBudget: boolean;
+}
+function Item({
+ icon: Icon,
+ children,
+ className = '',
+}: {
+ icon: typeof Clock3;
+ children: ReactNode;
+ className?: string;
+}) {
+ return (
+
+
+ {children}
+
+ );
+}
+export function StatusBar({ time, fps, stepMs, memoryMb, loaded, overBudget }: StatusBarProps) {
+ return (
+
+ );
+}
+
+/** 仅让状态栏订阅高频性能数据,避免带动整个工作台重渲染。 */
+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 ;
+}
diff --git a/web_platform/src/app/components/ThirdBatchComponents.test.tsx b/web_platform/src/app/components/ThirdBatchComponents.test.tsx
index e98856f7..2146c23a 100644
--- a/web_platform/src/app/components/ThirdBatchComponents.test.tsx
+++ b/web_platform/src/app/components/ThirdBatchComponents.test.tsx
@@ -1,8 +1,31 @@
-import {fireEvent,render,screen} from '@testing-library/react';
-import {CommandPalette,type WorkbenchCommand} from './CommandPalette';
-import {PerformancePopover} from './PerformancePopover';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { CommandPalette, type WorkbenchCommand } from './CommandPalette';
+import { PerformancePopover } from './PerformancePopover';
-describe('第三批工作台组件',()=>{
- it('命令面板可搜索并执行现有命令',()=>{const run=vi.fn(),close=vi.fn(),commands:WorkbenchCommand[]=[{id:'reset',label:'重置仿真',group:'仿真',run},{id:'theme',label:'切换主题',group:'外观',run:vi.fn()}];render();const input=screen.getByLabelText('搜索命令');fireEvent.change(input,{target:{value:'重置'}});expect(screen.queryByText('切换主题')).not.toBeInTheDocument();fireEvent.keyDown(input,{key:'Enter'});expect(run).toHaveBeenCalledTimes(1);expect(close).toHaveBeenCalledTimes(1);expect(input).toHaveAttribute('aria-controls');expect(input).toHaveAttribute('aria-activedescendant');});
- it('状态栏性能入口展示已有指标',()=>{render();fireEvent.click(screen.getByRole('button'));expect(screen.getByRole('dialog',{name:'性能详情'})).toHaveTextContent('60 FPS');expect(screen.getByRole('dialog',{name:'性能详情'})).toHaveTextContent('42.5 MiB');fireEvent.keyDown(document,{key:'k',ctrlKey:true});expect(screen.queryByRole('dialog',{name:'性能详情'})).not.toBeInTheDocument();});
+describe('第三批工作台组件', () => {
+ it('命令面板可搜索并执行现有命令', () => {
+ const run = vi.fn(),
+ close = vi.fn(),
+ commands: WorkbenchCommand[] = [
+ { id: 'reset', label: '重置仿真', group: '仿真', run },
+ { id: 'theme', label: '切换主题', group: '外观', run: vi.fn() },
+ ];
+ render();
+ const input = screen.getByLabelText('搜索命令');
+ fireEvent.change(input, { target: { value: '重置' } });
+ expect(screen.queryByText('切换主题')).not.toBeInTheDocument();
+ fireEvent.keyDown(input, { key: 'Enter' });
+ expect(run).toHaveBeenCalledTimes(1);
+ expect(close).toHaveBeenCalledTimes(1);
+ expect(input).toHaveAttribute('aria-controls');
+ expect(input).toHaveAttribute('aria-activedescendant');
+ });
+ it('状态栏性能入口展示已有指标', () => {
+ render();
+ fireEvent.click(screen.getByRole('button'));
+ expect(screen.getByRole('dialog', { name: '性能详情' })).toHaveTextContent('60 FPS');
+ expect(screen.getByRole('dialog', { name: '性能详情' })).toHaveTextContent('42.5 MiB');
+ fireEvent.keyDown(document, { key: 'k', ctrlKey: true });
+ expect(screen.queryByRole('dialog', { name: '性能详情' })).not.toBeInTheDocument();
+ });
});
diff --git a/web_platform/src/app/components/ToolbarOverflowMenu.tsx b/web_platform/src/app/components/ToolbarOverflowMenu.tsx
index 56752379..be14edc1 100644
--- a/web_platform/src/app/components/ToolbarOverflowMenu.tsx
+++ b/web_platform/src/app/components/ToolbarOverflowMenu.tsx
@@ -1,3 +1,72 @@
-import {CircleHelp,Expand,LayoutDashboard,Maximize,Search,Settings,SunMoon} from 'lucide-react';
-import {DropdownMenu} from '../../components/ui';
-export function ToolbarOverflowMenu({fullscreen,onCommands,onLayout,onSettings,onFullscreen,onHelp,onTheme}:{fullscreen:boolean;onCommands:()=>void;onLayout:()=>void;onSettings:()=>void;onFullscreen:()=>void;onHelp:()=>void;onTheme:()=>void}){return ,onSelect:onCommands},{id:'layout',label:'布局设置',icon:,onSelect:onLayout},{id:'settings',label:'工作台设置',icon:,onSelect:onSettings},{id:'fullscreen',label:fullscreen?'退出全屏':'进入全屏',icon:fullscreen?:,onSelect:onFullscreen},{id:'help',label:'快捷键帮助',icon:,onSelect:onHelp},{id:'theme',label:'切换主题',icon:,onSelect:onTheme}]}/>;}
+import {
+ CircleHelp,
+ Expand,
+ LayoutDashboard,
+ Maximize,
+ Search,
+ Settings,
+ SunMoon,
+} from 'lucide-react';
+import { DropdownMenu } from '../../components/ui';
+export function ToolbarOverflowMenu({
+ fullscreen,
+ onCommands,
+ onLayout,
+ onSettings,
+ onFullscreen,
+ onHelp,
+ onTheme,
+}: {
+ fullscreen: boolean;
+ onCommands: () => void;
+ onLayout: () => void;
+ onSettings: () => void;
+ onFullscreen: () => void;
+ onHelp: () => void;
+ onTheme: () => void;
+}) {
+ return (
+ ,
+ onSelect: onCommands,
+ },
+ {
+ id: 'layout',
+ label: '布局设置',
+ icon: ,
+ onSelect: onLayout,
+ },
+ {
+ id: 'settings',
+ label: '工作台设置',
+ icon: ,
+ onSelect: onSettings,
+ },
+ {
+ id: 'fullscreen',
+ label: fullscreen ? '退出全屏' : '进入全屏',
+ icon: fullscreen ? : ,
+ onSelect: onFullscreen,
+ },
+ {
+ id: 'help',
+ label: '快捷键帮助',
+ icon: ,
+ onSelect: onHelp,
+ },
+ {
+ id: 'theme',
+ label: '切换主题',
+ icon: ,
+ onSelect: onTheme,
+ },
+ ]}
+ />
+ );
+}
diff --git a/web_platform/src/app/components/TreeSearchField.tsx b/web_platform/src/app/components/TreeSearchField.tsx
index cb983f15..56d904dc 100644
--- a/web_platform/src/app/components/TreeSearchField.tsx
+++ b/web_platform/src/app/components/TreeSearchField.tsx
@@ -1,3 +1,46 @@
-import {Search,X} from 'lucide-react';
-import {IconButton} from '../../components/ui';
-export function TreeSearchField({value,onChange,resultCount,placeholder='搜索…',label='搜索树'}:{value:string;onChange:(value:string)=>void;resultCount?:number;placeholder?:string;label?:string}){return onChange(event.target.value)} placeholder={placeholder} className="h-8 w-full rounded-md border border-border bg-input pl-7 pr-8 text-xs text-text-primary placeholder:text-text-tertiary focus-visible:ring-2 focus-visible:ring-accent/35"/>{value&&onChange('')}>}
{value&&resultCount!==undefined&&
找到 {resultCount} 个匹配项
}
;}
+import { Search, X } from 'lucide-react';
+import { IconButton } from '../../components/ui';
+export function TreeSearchField({
+ value,
+ onChange,
+ resultCount,
+ placeholder = '搜索…',
+ label = '搜索树',
+}: {
+ value: string;
+ onChange: (value: string) => void;
+ resultCount?: number;
+ placeholder?: string;
+ label?: string;
+}) {
+ return (
+
+
+
+ onChange(event.target.value)}
+ placeholder={placeholder}
+ className="h-8 w-full rounded-md border border-border bg-input pl-7 pr-8 text-xs text-text-primary placeholder:text-text-tertiary focus-visible:ring-2 focus-visible:ring-accent/35"
+ />
+ {value && (
+
+ onChange('')}>
+
+
+
+ )}
+
+ {value && resultCount !== undefined && (
+
+ 找到 {resultCount} 个匹配项
+
+ )}
+
+ );
+}
diff --git a/web_platform/src/app/components/UrdfImportOptionsDialog.test.tsx b/web_platform/src/app/components/UrdfImportOptionsDialog.test.tsx
index 9435aded..542b881e 100644
--- a/web_platform/src/app/components/UrdfImportOptionsDialog.test.tsx
+++ b/web_platform/src/app/components/UrdfImportOptionsDialog.test.tsx
@@ -1,31 +1,52 @@
-import {fireEvent,render,screen} from '@testing-library/react';
-import {UrdfImportOptionsDialog} from './UrdfImportOptionsDialog';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { UrdfImportOptionsDialog } from './UrdfImportOptionsDialog';
-describe('UrdfImportOptionsDialog',()=>{
- it('默认选择关节驱动器和摄像头传感器',()=>{
- const onConfirm=vi.fn();
- render({}}/>);
- expect(screen.getByRole('checkbox',{name:/为关节添加驱动器/})).toBeChecked();
- expect(screen.getByRole('checkbox',{name:/添加传感器/})).toBeChecked();
+describe('UrdfImportOptionsDialog', () => {
+ it('默认选择关节驱动器和摄像头传感器', () => {
+ const onConfirm = vi.fn();
+ render(
+ {}}
+ />,
+ );
+ expect(screen.getByRole('checkbox', { name: /为关节添加驱动器/ })).toBeChecked();
+ expect(screen.getByRole('checkbox', { name: /添加传感器/ })).toBeChecked();
expect(screen.getByLabelText('摄像头固连 Body')).toHaveValue('head_link');
- fireEvent.change(screen.getByLabelText('摄像头位置 X'),{target:{value:'0.2'}});
- fireEvent.click(screen.getByRole('button',{name:'转换并加载'}));
- expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({addActuators:true,addSensors:true,sensorType:'camera',cameraMountBody:'head_link',cameraPosition:[.2,0,.05],cameraDirection:'+X'}));
+ fireEvent.change(screen.getByLabelText('摄像头位置 X'), { target: { value: '0.2' } });
+ fireEvent.click(screen.getByRole('button', { name: '转换并加载' }));
+ expect(onConfirm).toHaveBeenCalledWith(
+ expect.objectContaining({
+ addActuators: true,
+ addSensors: true,
+ sensorType: 'camera',
+ cameraMountBody: 'head_link',
+ cameraPosition: [0.2, 0, 0.05],
+ cameraDirection: '+X',
+ }),
+ );
});
- it('允许分别关闭自动生成项',()=>{
- const onConfirm=vi.fn();
- render({}}/>);
- fireEvent.click(screen.getByRole('checkbox',{name:/为关节添加驱动器/}));
- fireEvent.click(screen.getByRole('checkbox',{name:/添加传感器/}));
- fireEvent.click(screen.getByRole('button',{name:'转换并加载'}));
- expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({addActuators:false,addSensors:false,sensorType:'camera'}));
+ it('允许分别关闭自动生成项', () => {
+ const onConfirm = vi.fn();
+ render(
+ {}} />,
+ );
+ fireEvent.click(screen.getByRole('checkbox', { name: /为关节添加驱动器/ }));
+ fireEvent.click(screen.getByRole('checkbox', { name: /添加传感器/ }));
+ fireEvent.click(screen.getByRole('button', { name: '转换并加载' }));
+ expect(onConfirm).toHaveBeenCalledWith(
+ expect.objectContaining({ addActuators: false, addSensors: false, sensorType: 'camera' }),
+ );
});
- it('可以不添加组件并继续加载',()=>{
- const onSkip=vi.fn();
- render({}} onSkip={onSkip}/>);
- fireEvent.click(screen.getByRole('button',{name:'不添加,直接加载'}));
+ it('可以不添加组件并继续加载', () => {
+ const onSkip = vi.fn();
+ render( {}} onSkip={onSkip} />);
+ fireEvent.click(screen.getByRole('button', { name: '不添加,直接加载' }));
expect(onSkip).toHaveBeenCalledOnce();
});
});
diff --git a/web_platform/src/app/components/UrdfImportOptionsDialog.tsx b/web_platform/src/app/components/UrdfImportOptionsDialog.tsx
index ee41ef25..c67669f2 100644
--- a/web_platform/src/app/components/UrdfImportOptionsDialog.tsx
+++ b/web_platform/src/app/components/UrdfImportOptionsDialog.tsx
@@ -1,26 +1,178 @@
-import {useState,type ReactNode} from 'react';
-import {Camera,Settings2} from 'lucide-react';
-import type {CameraDirection,UrdfEnhancementOptions} from '../../project/urdfToMjcf';
-import {Button,Dialog,Select} from '../../components/ui';
+import { useState, type ReactNode } from 'react';
+import { Camera, Settings2 } from 'lucide-react';
+import type { CameraDirection, UrdfEnhancementOptions } from '../../project/urdfToMjcf';
+import { Button, Dialog, Select } from '../../components/ui';
-function OptionCard({checked,onChange,icon,title,description,children}:{checked:boolean;onChange:(checked:boolean)=>void;icon:ReactNode;title:string;description:string;children?:ReactNode}){
- return ;
+function OptionCard({
+ checked,
+ onChange,
+ icon,
+ title,
+ description,
+ children,
+}: {
+ checked: boolean;
+ onChange: (checked: boolean) => void;
+ icon: ReactNode;
+ title: string;
+ description: string;
+ children?: ReactNode;
+}) {
+ return (
+
+ );
}
-export function UrdfImportOptionsDialog({open,path,mountBodies=[],onConfirm,onSkip}:{open:boolean;path?:string;mountBodies?:string[];onConfirm:(options:UrdfEnhancementOptions)=>void;onSkip:()=>void}){
- const [options,setOptions]=useState(()=>({addActuators:true,addSensors:true,sensorType:'camera',cameraMountBody:mountBodies.find(name=>/(head|camera|sensor|neck|头)/i.test(name))??mountBodies.at(-1),cameraPosition:[.1,0,.05],cameraDirection:'+X'}));
- const setPosition=(axis:number,value:number)=>setOptions(current=>{const position:[number,number,number]=[...(current.cameraPosition??[.1,0,.05])];position[axis]=Number.isFinite(value)?value:0;return {...current,cameraPosition:position};});
- return }>
- 导入 {path} 后,是否自动补充以下仿真组件?稍后重新选择该 URDF 时仍会再次询问。
-
-
setOptions(value=>({...value,addActuators}))} icon={} title="为关节添加驱动器" description="为每个 hinge/slide 关节生成控制输入不限幅的 motor 驱动器;hinge 使用 N·m、slide 使用 N。kp/kv 用于调整对应 MJCF 关节的刚度和阻尼,已有驱动器不会重复添加。"/>
- setOptions(value=>({...value,addSensors}))} icon={} title="添加传感器" description="在浮动基座添加三轴陀螺仪和三轴加速度计(6轴 IMU),并添加一台 640×480 固定摄像头。"/>
- {options.addSensors&&摄像头安装参数
{(['X','Y','Z'] as const).map((axis,index)=>)}
位置和朝向均相对于所选 Body;常见 ROS 头部摄像头使用 +X 朝前、+Z 朝上。
}
-
- 自动组件只写入浏览器内生成的 MJCF,不会修改本地 URDF 文件;使用“原生 URDF”加载模式时不会注入这些组件。
- ;
+export function UrdfImportOptionsDialog({
+ open,
+ path,
+ mountBodies = [],
+ onConfirm,
+ onSkip,
+}: {
+ open: boolean;
+ path?: string;
+ mountBodies?: string[];
+ onConfirm: (options: UrdfEnhancementOptions) => void;
+ onSkip: () => void;
+}) {
+ const [options, setOptions] = useState(() => ({
+ addActuators: true,
+ addSensors: true,
+ sensorType: 'camera',
+ cameraMountBody:
+ mountBodies.find((name) => /(head|camera|sensor|neck|头)/i.test(name)) ?? mountBodies.at(-1),
+ cameraPosition: [0.1, 0, 0.05],
+ cameraDirection: '+X',
+ }));
+ const setPosition = (axis: number, value: number) =>
+ setOptions((current) => {
+ const position: [number, number, number] = [...(current.cameraPosition ?? [0.1, 0, 0.05])];
+ position[axis] = Number.isFinite(value) ? value : 0;
+ return { ...current, cameraPosition: position };
+ });
+ return (
+
+ );
}
diff --git a/web_platform/src/app/components/ViewerDisplayPopover.tsx b/web_platform/src/app/components/ViewerDisplayPopover.tsx
index 3a1a3345..160296de 100644
--- a/web_platform/src/app/components/ViewerDisplayPopover.tsx
+++ b/web_platform/src/app/components/ViewerDisplayPopover.tsx
@@ -1,30 +1,166 @@
-import {Check,Eye,RotateCcw} from 'lucide-react';
-import {IconButton,Popover} from '../../components/ui';
-import {DEFAULT_VIEWER_DISPLAY_OPTIONS,type ViewerDisplayOptions} from '../../viewer/displayOptions';
+import { Check, Eye, RotateCcw } from 'lucide-react';
+import { IconButton, Popover } from '../../components/ui';
+import {
+ DEFAULT_VIEWER_DISPLAY_OPTIONS,
+ type ViewerDisplayOptions,
+} from '../../viewer/displayOptions';
-type DisplayKey=keyof ViewerDisplayOptions;
-interface DisplayItem {key:DisplayKey;label:string;description:string;color:string;}
-const geometryItems:DisplayItem[]=[
- {key:'showVisual',label:'视觉模型',description:'显示模型的外观几何与材质',color:'bg-slate-400'},
- {key:'showCollision',label:'碰撞体',description:'以青色半透明方式叠加碰撞几何',color:'bg-cyan-400'},
+type DisplayKey = keyof ViewerDisplayOptions;
+interface DisplayItem {
+ key: DisplayKey;
+ label: string;
+ description: string;
+ color: string;
+}
+const geometryItems: DisplayItem[] = [
+ {
+ key: 'showVisual',
+ label: '视觉模型',
+ description: '显示模型的外观几何与材质',
+ color: 'bg-slate-400',
+ },
+ {
+ key: 'showCollision',
+ label: '碰撞体',
+ description: '以青色半透明方式叠加碰撞几何',
+ color: 'bg-cyan-400',
+ },
];
-const helperItems:DisplayItem[]=[
- {key:'showFrames',label:'坐标系',description:'显示每个刚体的 RGB 坐标轴',color:'bg-red-400'},
- {key:'showJointAxes',label:'关节轴',description:'显示转动与滑动关节的正轴方向',color:'bg-red-500'},
- {key:'showCenterOfMass',label:'质心',description:'显示各刚体的质量中心',color:'bg-yellow-400'},
- {key:'showInertia',label:'惯量',description:'显示由主惯量计算的等效惯量盒',color:'bg-cyan-300'},
+const helperItems: DisplayItem[] = [
+ {
+ key: 'showFrames',
+ label: '坐标系',
+ description: '显示每个刚体的 RGB 坐标轴',
+ color: 'bg-red-400',
+ },
+ {
+ key: 'showJointAxes',
+ label: '关节轴',
+ description: '显示转动与滑动关节的正轴方向',
+ color: 'bg-red-500',
+ },
+ {
+ key: 'showCenterOfMass',
+ label: '质心',
+ description: '显示各刚体的质量中心',
+ color: 'bg-yellow-400',
+ },
+ {
+ key: 'showInertia',
+ label: '惯量',
+ description: '显示由主惯量计算的等效惯量盒',
+ color: 'bg-cyan-300',
+ },
];
-const sceneItems:DisplayItem[]=[
- {key:'showGrid',label:'地面网格',description:'显示世界坐标系的参考网格',color:'bg-blue-400'},
+const sceneItems: DisplayItem[] = [
+ {
+ key: 'showGrid',
+ label: '地面网格',
+ description: '显示世界坐标系的参考网格',
+ color: 'bg-blue-400',
+ },
];
-function DisplayRows({items,value,onChange}:{items:DisplayItem[];value:ViewerDisplayOptions;onChange:(next:ViewerDisplayOptions)=>void}){
- return {items.map(item=>{const checked=value[item.key];return ;})}
;
+function DisplayRows({
+ items,
+ value,
+ onChange,
+}: {
+ items: DisplayItem[];
+ value: ViewerDisplayOptions;
+ onChange: (next: ViewerDisplayOptions) => void;
+}) {
+ return (
+
+ {items.map((item) => {
+ const checked = value[item.key];
+ return (
+
+ );
+ })}
+
+ );
}
-export function ViewerDisplayPopover({value,onChange}:{value:ViewerDisplayOptions;onChange:(next:ViewerDisplayOptions)=>void}){
- const customized=Object.keys(DEFAULT_VIEWER_DISPLAY_OPTIONS).some(key=>value[key as DisplayKey]!==DEFAULT_VIEWER_DISPLAY_OPTIONS[key as DisplayKey]);
- return }>
- {()=> onChange({...DEFAULT_VIEWER_DISPLAY_OPTIONS})}> }
- ;
+export function ViewerDisplayPopover({
+ value,
+ onChange,
+}: {
+ value: ViewerDisplayOptions;
+ onChange: (next: ViewerDisplayOptions) => void;
+}) {
+ const customized = Object.keys(DEFAULT_VIEWER_DISPLAY_OPTIONS).some(
+ (key) => value[key as DisplayKey] !== DEFAULT_VIEWER_DISPLAY_OPTIONS[key as DisplayKey],
+ );
+ return (
+ (
+
+
+
+ )}
+ >
+ {() => (
+
+
+
+
onChange({ ...DEFAULT_VIEWER_DISPLAY_OPTIONS })}
+ >
+
+
+
+
+
+ 几何
+
+
+
+ 辅助标记
+
+
+
+ 场景
+
+
+
+
+ )}
+
+ );
}
diff --git a/web_platform/src/app/components/ViewerToolDock.tsx b/web_platform/src/app/components/ViewerToolDock.tsx
index d1e912dc..5aca45c2 100644
--- a/web_platform/src/app/components/ViewerToolDock.tsx
+++ b/web_platform/src/app/components/ViewerToolDock.tsx
@@ -1,7 +1,33 @@
-import {Crosshair,Hand,MousePointer2,RotateCcw} from 'lucide-react';
-import type {InteractionMode} from '../../viewer/MuJoCoViewer';
-import type {ViewerDisplayOptions} from '../../viewer/displayOptions';
-import {IconButton,ToolbarToggleGroup,type ToolbarItem} from '../../components/ui';
-import {ViewerDisplayPopover} from './ViewerDisplayPopover';
-const tools:ToolbarItem[]=[{value:'select',label:'选择',icon:MousePointer2},{value:'joint',label:'关节拖动',icon:Hand},{value:'force',label:'外力施加',icon:Crosshair}];
-export function ViewerToolDock({mode,display,onModeChange,onDisplayChange,onResetCamera}:{mode:InteractionMode;display:ViewerDisplayOptions;onModeChange:(mode:InteractionMode)=>void;onDisplayChange:(next:ViewerDisplayOptions)=>void;onResetCamera:()=>void}){return
;}
+import { Crosshair, Hand, MousePointer2, RotateCcw } from 'lucide-react';
+import type { InteractionMode } from '../../viewer/MuJoCoViewer';
+import type { ViewerDisplayOptions } from '../../viewer/displayOptions';
+import { IconButton, ToolbarToggleGroup, type ToolbarItem } from '../../components/ui';
+import { ViewerDisplayPopover } from './ViewerDisplayPopover';
+const tools: ToolbarItem[] = [
+ { value: 'select', label: '选择', icon: MousePointer2 },
+ { value: 'joint', label: '关节拖动', icon: Hand },
+ { value: 'force', label: '外力施加', icon: Crosshair },
+];
+export function ViewerToolDock({
+ mode,
+ display,
+ onModeChange,
+ onDisplayChange,
+ onResetCamera,
+}: {
+ mode: InteractionMode;
+ display: ViewerDisplayOptions;
+ onModeChange: (mode: InteractionMode) => void;
+ onDisplayChange: (next: ViewerDisplayOptions) => void;
+ onResetCamera: () => void;
+}) {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/web_platform/src/app/components/ViewportHUD.tsx b/web_platform/src/app/components/ViewportHUD.tsx
index 4a24b169..22f322a1 100644
--- a/web_platform/src/app/components/ViewportHUD.tsx
+++ b/web_platform/src/app/components/ViewportHUD.tsx
@@ -1,6 +1,72 @@
-import {CirclePause,CirclePlay,Mouse,MousePointer2} from 'lucide-react';
-import type {InteractionMode,ViewerSelection} from '../../viewer/MuJoCoViewer';
-import {Badge,Kbd} from '../../components/ui';
-const labels:Record={select:'选择',joint:'关节拖动',force:'外力施加'};
-const primaryGestures:Record={select:'左键旋转',joint:'左键拖动关节',force:'左键拖动施力'};
-export function ViewportHUD({paused,mode,selection,ready}:{paused:boolean;mode:InteractionMode;selection:ViewerSelection|null;ready:boolean}){if(!ready)return null;return <>{paused?:}{paused?'已暂停':'仿真中'}{labels[mode]}{selection&&{selection.bodyName}}
{primaryGestures[mode]}·右键平移·滚轮缩放{mode!=='select'&&<>·1旋转视角>}
>;}
+import { CirclePause, CirclePlay, Mouse, MousePointer2 } from 'lucide-react';
+import type { InteractionMode, ViewerSelection } from '../../viewer/MuJoCoViewer';
+import { Badge, Kbd } from '../../components/ui';
+const labels: Record = {
+ select: '选择',
+ joint: '关节拖动',
+ force: '外力施加',
+};
+const primaryGestures: Record = {
+ select: '左键旋转',
+ joint: '左键拖动关节',
+ force: '左键拖动施力',
+};
+export function ViewportHUD({
+ paused,
+ mode,
+ selection,
+ ready,
+}: {
+ paused: boolean;
+ mode: InteractionMode;
+ selection: ViewerSelection | null;
+ ready: boolean;
+}) {
+ if (!ready) return null;
+ return (
+ <>
+
+
+ {paused ? : }
+ {paused ? '已暂停' : '仿真中'}
+
+
+
+ {labels[mode]}
+
+ {selection && (
+
+ {selection.bodyName}
+
+ )}
+
+
+
+ {primaryGestures[mode]}
+
+ ·
+
+ 右键平移
+
+ ·
+
+ 滚轮缩放
+ {mode !== 'select' && (
+ <>
+
+ ·
+
+ 1
+ 旋转视角
+ >
+ )}
+
+ >
+ );
+}
diff --git a/web_platform/src/app/components/WorkbenchHeader.test.tsx b/web_platform/src/app/components/WorkbenchHeader.test.tsx
index 4218df1a..06461ffa 100644
--- a/web_platform/src/app/components/WorkbenchHeader.test.tsx
+++ b/web_platform/src/app/components/WorkbenchHeader.test.tsx
@@ -1,4 +1,58 @@
-import {fireEvent,render,screen} from '@testing-library/react';
-import {WorkbenchHeader} from './WorkbenchHeader';
-const fn=()=>{};
-describe('WorkbenchHeader',()=>{it('透传仿真动作且保留可访问名称',()=>{const pause=vi.fn(),step=vi.fn(),reset=vi.fn(),speed=vi.fn();render(工具} onFiles={fn} onFolder={fn} onTogglePause={pause} onStep={step} onReset={reset} onSpeed={speed} onToggleLeft={fn} onToggleRight={fn} onToggleTheme={fn} onHelp={fn} onCommands={fn} onToggleFullscreen={fn}/>);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(pause).toHaveBeenCalledTimes(1);expect(step).toHaveBeenCalledTimes(1);expect(reset).toHaveBeenCalledTimes(1);expect(speed).toHaveBeenCalledWith(2);expect(screen.getByRole('button',{name:'切换到白天主题'})).toBeInTheDocument();expect(screen.getByRole('button',{name:'隐藏工程面板'})).toHaveAttribute('aria-expanded','true');expect(screen.getByRole('button',{name:'打开命令面板'})).toBeInTheDocument();expect(screen.getByRole('button',{name:'进入全屏'})).toBeInTheDocument();});});
+import { fireEvent, render, screen } from '@testing-library/react';
+import { WorkbenchHeader } from './WorkbenchHeader';
+const fn = () => {};
+describe('WorkbenchHeader', () => {
+ it('透传仿真动作且保留可访问名称', () => {
+ const pause = vi.fn(),
+ step = vi.fn(),
+ reset = vi.fn(),
+ speed = vi.fn(),
+ openSource = vi.fn();
+ render(
+ 工具}
+ onFiles={fn}
+ onFolder={fn}
+ onOpenSource={openSource}
+ onTogglePause={pause}
+ onStep={step}
+ onReset={reset}
+ onSpeed={speed}
+ onToggleLeft={fn}
+ onToggleRight={fn}
+ onToggleTheme={fn}
+ onHelp={fn}
+ onCommands={fn}
+ onToggleFullscreen={fn}
+ />,
+ );
+ const sourceButton = screen.getByRole('button', { name: '源代码' });
+ expect(sourceButton).toHaveTextContent('源代码');
+ fireEvent.click(sourceButton);
+ fireEvent.click(screen.getByRole('button', { name: '▶ 播放' }));
+ fireEvent.click(screen.getByRole('button', { name: '单步' }));
+ fireEvent.click(screen.getByRole('button', { name: '重置' }));
+ fireEvent.change(screen.getByLabelText('仿真速度'), { target: { value: '2' } });
+ expect(openSource).toHaveBeenCalledTimes(1);
+ expect(pause).toHaveBeenCalledTimes(1);
+ expect(step).toHaveBeenCalledTimes(1);
+ expect(reset).toHaveBeenCalledTimes(1);
+ expect(speed).toHaveBeenCalledWith(2);
+ expect(screen.getByRole('button', { name: '切换到白天主题' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: '隐藏工程面板' })).toHaveAttribute(
+ 'aria-expanded',
+ 'true',
+ );
+ expect(screen.getByRole('button', { name: '打开命令面板' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: '进入全屏' })).toBeInTheDocument();
+ });
+});
diff --git a/web_platform/src/app/components/WorkbenchHeader.tsx b/web_platform/src/app/components/WorkbenchHeader.tsx
index 2b56ad3d..a4e9c511 100644
--- a/web_platform/src/app/components/WorkbenchHeader.tsx
+++ b/web_platform/src/app/components/WorkbenchHeader.tsx
@@ -1,6 +1,217 @@
-import type {ChangeEvent,ReactNode} from 'react';
-import {CircleHelp,Code2,Expand,FolderOpen,Minimize,PanelLeft,PanelRight,Pause,Play,RotateCcw,Search,StepForward,Sun,Moon,Upload} 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';
-const fileActionLabelClass='hidden sm:inline';
-export function WorkbenchHeader({paused,ready,speed,theme,loading,leftOpen,rightOpen,fullscreen,hasProject,center,endActions,compactMenu,onFiles,onFolder,onOpenSource,onTogglePause,onStep,onReset,onSpeed,onToggleLeft,onToggleRight,onToggleTheme,onHelp,onCommands,onToggleFullscreen}:{paused:boolean;ready:boolean;speed:number;theme:'light'|'dark';loading:boolean;leftOpen:boolean;rightOpen:boolean;fullscreen:boolean;hasProject?:boolean;center:ReactNode;endActions?:ReactNode;compactMenu?:ReactNode;onFiles:(event:ChangeEvent)=>void;onFolder:(event:ChangeEvent)=>void;onOpenSource?:()=>void;onTogglePause:()=>void;onStep:()=>void;onReset:()=>void;onSpeed:(value:number)=>void;onToggleLeft:()=>void;onToggleRight:()=>void;onToggleTheme:()=>void;onHelp:()=>void;onCommands:()=>void;onToggleFullscreen:()=>void}){return ;}
+import type { ChangeEvent, ReactNode } from 'react';
+import {
+ Boxes,
+ CircleHelp,
+ Code2,
+ Expand,
+ FolderOpen,
+ Minimize,
+ PanelLeft,
+ PanelRight,
+ Pause,
+ Play,
+ RotateCcw,
+ Search,
+ StepForward,
+ Sun,
+ Moon,
+ Upload,
+} 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/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,
+ ready,
+ speed,
+ theme,
+ loading,
+ leftOpen,
+ rightOpen,
+ fullscreen,
+ hasProject,
+ center,
+ endActions,
+ compactMenu,
+ onFiles,
+ onFolder,
+ onOpenSource,
+ onTogglePause,
+ onStep,
+ onReset,
+ onSpeed,
+ onToggleLeft,
+ onToggleRight,
+ onToggleTheme,
+ onHelp,
+ onCommands,
+ onToggleFullscreen,
+}: {
+ paused: boolean;
+ ready: boolean;
+ speed: number;
+ theme: 'light' | 'dark';
+ loading: boolean;
+ leftOpen: boolean;
+ rightOpen: boolean;
+ fullscreen: boolean;
+ hasProject?: boolean;
+ center: ReactNode;
+ endActions?: ReactNode;
+ compactMenu?: ReactNode;
+ onFiles: (event: ChangeEvent) => void;
+ onFolder: (event: ChangeEvent) => void;
+ onOpenSource?: () => void;
+ onTogglePause: () => void;
+ onStep: () => void;
+ onReset: () => void;
+ onSpeed: (value: number) => void;
+ onToggleLeft: () => void;
+ onToggleRight: () => void;
+ onToggleTheme: () => void;
+ onHelp: () => void;
+ onCommands: () => void;
+ onToggleFullscreen: () => void;
+}) {
+ return (
+
+ );
+}
diff --git a/web_platform/src/app/components/WorkspaceOverlays.tsx b/web_platform/src/app/components/WorkspaceOverlays.tsx
index 7b4797f3..53fb14f5 100644
--- a/web_platform/src/app/components/WorkspaceOverlays.tsx
+++ b/web_platform/src/app/components/WorkspaceOverlays.tsx
@@ -1,6 +1,206 @@
-import {Box,FolderOpen,LoaderCircle,PlayCircle,Settings2,ShieldCheck,Upload,UploadCloud} from 'lucide-react';
-import {ProgressBar,Skeleton} from '../../components/ui';
-export interface ImportProgress{label: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 拖放模型工程到此处
支持 MJCF/XML、URDF、文件夹和 ZIP
;return 拖放模型工程到此处
支持 MJCF/XML、URDF、文件夹和 ZIP
{workflow.map((item,index)=>0{index+1}{item.label}
{item.detail} )}
模型与资源仅在当前浏览器会话中处理
;}
-export function WorkspaceOverlays({loading,hasSnapshot,progress}:{loading:boolean;hasSnapshot:boolean;progress?:ImportProgress}){return <>{!hasSnapshot&&!loading&&}{loading&&正在加载 MuJoCo 与模型…
{progress?
:
}
}>;}
+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 (
+
+
+
+
+
拖放模型工程到此处
+
支持 MJCF/XML、URDF、文件夹和 ZIP
+
+ );
+
+ return (
+
+
+
+
+
+ Local Simulation Workspace
+
+
+
+
+
+ 拖放模型工程到此处
+
+
+ 本地解析、编译并运行机器人模型,无需上传资源
+
+
+ {['MJCF / XML', 'URDF', 'ZIP', 'OBJ / STL / DAE'].map((format) => (
+
+ {format}
+
+ ))}
+
+
+
+
+
+
+ {workflow.map((item, index) => (
+ -
+
+
+ 0{index + 1}
+
+
+ {item.label}
+
+
+ {item.detail}
+
+
+ ))}
+
+
+
+ 模型与资源仅在当前浏览器会话中处理
+
+
+
+ );
+}
+
+function LoadingCard({ progress, compact }: { progress?: ImportProgress; compact: boolean }) {
+ const title = progress?.title ?? '正在加载 MuJoCo 与模型';
+ return (
+
+
+
+
+
+
+
+
+
+
{title}
+
+ {progress?.detail ?? '首次运行会下载并编译本地 WebAssembly 运行时'}
+
+
+
+ {progress ? (
+
+ ) : (
+
+
+
+
+ )}
+
+
+ );
+}
+
+export function WorkspaceOverlays({
+ loading,
+ hasSnapshot,
+ dragActive = false,
+ progress,
+}: {
+ loading: boolean;
+ hasSnapshot: boolean;
+ dragActive?: boolean;
+ progress?: ImportProgress;
+}) {
+ return (
+ <>
+ {!hasSnapshot && !loading && (
+
+ )}
+ {loading && (
+
+
+
+ )}
+ {dragActive && !loading && (
+
+
+
+
+
+
松开即可导入工程
+
+
+ 文件、文件夹与 ZIP 都可以直接解析
+
+
+
+ )}
+ >
+ );
+}
diff --git a/web_platform/src/app/components/monacoSetup.ts b/web_platform/src/app/components/monacoSetup.ts
index def7ae0d..3217cb93 100644
--- a/web_platform/src/app/components/monacoSetup.ts
+++ b/web_platform/src/app/components/monacoSetup.ts
@@ -1,8 +1,8 @@
-import {loader} from '@monaco-editor/react';
+import { loader } from '@monaco-editor/react';
import * as monaco from 'monaco-editor/editor/editor.api';
import 'monaco-editor/languages/definitions/xml/register';
import EditorWorker from 'monaco-editor/editor/editor.worker?worker';
-type MonacoGlobal=typeof globalThis&{MonacoEnvironment?:{getWorker?:()=>Worker}};
-(globalThis as MonacoGlobal).MonacoEnvironment={getWorker:()=>new EditorWorker()};
-loader.config({monaco});
+type MonacoGlobal = typeof globalThis & { MonacoEnvironment?: { getWorker?: () => Worker } };
+(globalThis as MonacoGlobal).MonacoEnvironment = { getWorker: () => new EditorWorker() };
+loader.config({ monaco });
diff --git a/web_platform/src/components/ui/Badge.tsx b/web_platform/src/components/ui/Badge.tsx
index 422ba818..3be67942 100644
--- a/web_platform/src/components/ui/Badge.tsx
+++ b/web_platform/src/components/ui/Badge.tsx
@@ -1,2 +1,27 @@
-import type {ReactNode} from 'react';
-export function Badge({children,tone='neutral',className='',title}:{children:ReactNode;tone?:'neutral'|'accent'|'success'|'warning';className?:string;title?:string}){const toneClass={neutral:'border-border bg-surface text-text-secondary',accent:'border-success-border bg-accent-soft text-accent',success:'border-success-border bg-success-soft text-success',warning:'border-warning-border bg-warning-soft text-warning'}[tone];return {children};}
+import type { ReactNode } from 'react';
+export function Badge({
+ children,
+ tone = 'neutral',
+ className = '',
+ title,
+}: {
+ children: ReactNode;
+ tone?: 'neutral' | 'accent' | 'success' | 'warning';
+ className?: string;
+ title?: string;
+}) {
+ const toneClass = {
+ neutral: 'border-border bg-surface text-text-secondary',
+ accent: 'border-success-border bg-accent-soft text-accent',
+ success: 'border-success-border bg-success-soft text-success',
+ warning: 'border-warning-border bg-warning-soft text-warning',
+ }[tone];
+ return (
+
+ {children}
+
+ );
+}
diff --git a/web_platform/src/components/ui/Button.tsx b/web_platform/src/components/ui/Button.tsx
index 3665c390..5d4457f9 100644
--- a/web_platform/src/components/ui/Button.tsx
+++ b/web_platform/src/components/ui/Button.tsx
@@ -1,18 +1,44 @@
-import type {ButtonHTMLAttributes,ReactNode} from 'react';
+import type { ButtonHTMLAttributes, ReactNode } from 'react';
-export interface ButtonProps extends ButtonHTMLAttributes{
- variant?:'primary'|'secondary'|'ghost'|'danger';
- size?:'sm'|'md'|'icon';
- icon?:ReactNode;
+export interface ButtonProps extends ButtonHTMLAttributes {
+ variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
+ size?: 'sm' | 'md' | 'icon';
+ icon?: ReactNode;
}
-export function Button({variant='secondary',size='sm',icon,className='',children,type='button',...props}:ButtonProps){
- const variants={
- primary:'border-transparent bg-accent text-white hover:bg-accent-hover',
- secondary:'border-border bg-surface text-text-primary hover:bg-element-hover',
- ghost:'border-transparent bg-transparent text-text-secondary hover:bg-element-hover hover:text-text-primary',
- danger:'border-danger-border bg-danger-soft text-danger hover:bg-danger hover:text-white',
+export function Button({
+ variant = 'secondary',
+ size = 'sm',
+ icon,
+ className = '',
+ children,
+ type = 'button',
+ ...props
+}: ButtonProps) {
+ const variants = {
+ primary: 'border-transparent bg-accent text-white hover:bg-accent-hover',
+ secondary: 'border-border bg-surface text-text-primary hover:bg-element-hover',
+ ghost:
+ 'border-transparent bg-transparent text-text-secondary hover:bg-element-hover hover:text-text-primary',
+ danger: 'border-danger-border bg-danger-soft text-danger hover:bg-danger hover:text-white',
};
- const sizes={sm:'h-7 gap-1.5 rounded-md px-2 text-xs',md:'h-8 gap-2 rounded-md px-3 text-sm',icon:'h-7 w-7 rounded-md p-0'};
- return ;
+ const sizes = {
+ sm: 'h-7 gap-1.5 rounded-md px-2 text-xs',
+ md: 'h-8 gap-2 rounded-md px-3 text-sm',
+ icon: 'h-7 w-7 rounded-md p-0',
+ };
+ return (
+
+ );
}
diff --git a/web_platform/src/components/ui/CollapsibleSection.test.tsx b/web_platform/src/components/ui/CollapsibleSection.test.tsx
index 24bcc192..f39a2f00 100644
--- a/web_platform/src/components/ui/CollapsibleSection.test.tsx
+++ b/web_platform/src/components/ui/CollapsibleSection.test.tsx
@@ -1,3 +1,25 @@
-import {fireEvent,render,screen} from '@testing-library/react';
-import {CollapsibleSection} from './CollapsibleSection';
-describe('CollapsibleSection',()=>{it('遵循默认折叠状态并可展开',()=>{render(内容);const trigger=screen.getByRole('button',{name:'低频设置'});expect(trigger).toHaveAttribute('aria-expanded','false');expect(screen.queryByText('内容')).not.toBeInTheDocument();fireEvent.click(trigger);expect(trigger).toHaveAttribute('aria-expanded','true');expect(screen.getByText('内容')).toBeVisible();});it('forceOpen 时保持内容可见',()=>{render(错误详情);expect(screen.getByText('错误详情')).toBeVisible();});});
+import { fireEvent, render, screen } from '@testing-library/react';
+import { CollapsibleSection } from './CollapsibleSection';
+describe('CollapsibleSection', () => {
+ it('遵循默认折叠状态并可展开', () => {
+ render(
+
+ 内容
+ ,
+ );
+ const trigger = screen.getByRole('button', { name: '低频设置' });
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
+ expect(screen.queryByText('内容')).not.toBeInTheDocument();
+ fireEvent.click(trigger);
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
+ expect(screen.getByText('内容')).toBeVisible();
+ });
+ it('forceOpen 时保持内容可见', () => {
+ render(
+
+ 错误详情
+ ,
+ );
+ expect(screen.getByText('错误详情')).toBeVisible();
+ });
+});
diff --git a/web_platform/src/components/ui/CollapsibleSection.tsx b/web_platform/src/components/ui/CollapsibleSection.tsx
index bfd54ddc..e1b27848 100644
--- a/web_platform/src/components/ui/CollapsibleSection.tsx
+++ b/web_platform/src/components/ui/CollapsibleSection.tsx
@@ -1,11 +1,36 @@
-import {useState,type ReactNode} from 'react';
-import {ChevronRight} from 'lucide-react';
-export function CollapsibleSection({title,children,defaultOpen=true,forceOpen=false,badge}:{title:string;children:ReactNode;defaultOpen?:boolean;forceOpen?:boolean;badge?:ReactNode}){
- const [open,setOpen]=useState(defaultOpen);const expanded=forceOpen||open;
- return
-
- {expanded&&{children}
}
- ;
+import { useState, type ReactNode } from 'react';
+import { ChevronRight } from 'lucide-react';
+export function CollapsibleSection({
+ title,
+ children,
+ defaultOpen = true,
+ forceOpen = false,
+ badge,
+}: {
+ title: string;
+ children: ReactNode;
+ defaultOpen?: boolean;
+ forceOpen?: boolean;
+ badge?: ReactNode;
+}) {
+ const [open, setOpen] = useState(defaultOpen);
+ const expanded = forceOpen || open;
+ return (
+
+
+ {expanded && {children}
}
+
+ );
}
diff --git a/web_platform/src/components/ui/ConfirmDialog.tsx b/web_platform/src/components/ui/ConfirmDialog.tsx
index b2b7c7ab..b7b554c2 100644
--- a/web_platform/src/components/ui/ConfirmDialog.tsx
+++ b/web_platform/src/components/ui/ConfirmDialog.tsx
@@ -1,4 +1,40 @@
-import type {ReactNode} from 'react';
-import {Button} from './Button';
-import {Dialog} from './Dialog';
-export function ConfirmDialog({open,title,children,confirmLabel='确认',cancelLabel='取消',danger=false,onConfirm,onClose}:{open:boolean;title:string;children:ReactNode;confirmLabel?:string;cancelLabel?:string;danger?:boolean;onConfirm:()=>void;onClose:()=>void}){return ;}
+import type { ReactNode } from 'react';
+import { Button } from './Button';
+import { Dialog } from './Dialog';
+export function ConfirmDialog({
+ open,
+ title,
+ children,
+ confirmLabel = '确认',
+ cancelLabel = '取消',
+ danger = false,
+ onConfirm,
+ onClose,
+}: {
+ open: boolean;
+ title: string;
+ children: ReactNode;
+ confirmLabel?: string;
+ cancelLabel?: string;
+ danger?: boolean;
+ onConfirm: () => void;
+ onClose: () => void;
+}) {
+ return (
+
+ );
+}
diff --git a/web_platform/src/components/ui/CopyButton.tsx b/web_platform/src/components/ui/CopyButton.tsx
index 111e05d8..1ea7f1cf 100644
--- a/web_platform/src/components/ui/CopyButton.tsx
+++ b/web_platform/src/components/ui/CopyButton.tsx
@@ -1,4 +1,31 @@
-import {useEffect,useState} from 'react';
-import {Check,Copy} from 'lucide-react';
-import {IconButton} from './IconButton';
-export function CopyButton({value,label='复制'}:{value:string;label?:string}){const [copied,setCopied]=useState(false);useEffect(()=>{if(!copied)return;const timer=window.setTimeout(()=>setCopied(false),1200);return()=>window.clearTimeout(timer);},[copied]);return void (async()=>{try{if(!navigator.clipboard?.writeText)return;await navigator.clipboard.writeText(value);setCopied(true);}catch{setCopied(false);}})()} className="h-5 w-5">{copied?:};}
+import { useEffect, useState } from 'react';
+import { Check, Copy } from 'lucide-react';
+import { IconButton } from './IconButton';
+export function CopyButton({ value, label = '复制' }: { value: string; label?: string }) {
+ const [copied, setCopied] = useState(false);
+ useEffect(() => {
+ if (!copied) return;
+ const timer = window.setTimeout(() => setCopied(false), 1200);
+ return () => window.clearTimeout(timer);
+ }, [copied]);
+ return (
+
+ void (async () => {
+ try {
+ if (!navigator.clipboard?.writeText) return;
+ await navigator.clipboard.writeText(value);
+ setCopied(true);
+ } catch {
+ setCopied(false);
+ }
+ })()
+ }
+ className="h-5 w-5"
+ >
+ {copied ? : }
+
+ );
+}
diff --git a/web_platform/src/components/ui/Dialog.test.tsx b/web_platform/src/components/ui/Dialog.test.tsx
index a1034d70..04c60037 100644
--- a/web_platform/src/components/ui/Dialog.test.tsx
+++ b/web_platform/src/components/ui/Dialog.test.tsx
@@ -1,6 +1,54 @@
-import {fireEvent,render,screen} from '@testing-library/react';
-import {useState} from 'react';
-import {Dialog} from './Dialog';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { useState } from 'react';
+import { Dialog } from './Dialog';
-function Fixture(){const [open,setOpen]=useState(false);return <>>;}
-describe('Dialog',()=>{it('支持 Escape 关闭并恢复触发器焦点',()=>{render();const trigger=screen.getByRole('button',{name:'打开'});trigger.focus();fireEvent.click(trigger);expect(screen.getByRole('dialog')).toBeVisible();fireEvent.keyDown(document,{key:'Escape'});expect(screen.queryByRole('dialog')).not.toBeInTheDocument();expect(trigger).toHaveFocus();});it('将 Tab 焦点限制在弹窗内',()=>{render();fireEvent.click(screen.getByRole('button',{name:'打开'}));const first=screen.getByRole('button',{name:'关闭'}),last=screen.getByRole('button',{name:'最后一个'});last.focus();fireEvent.keyDown(document,{key:'Tab'});expect(first).toHaveFocus();first.focus();fireEvent.keyDown(document,{key:'Tab',shiftKey:true});expect(last).toHaveFocus();});it('全屏时将 Portal 挂载到全屏元素内部',()=>{const host=document.createElement('div');document.body.append(host);Object.defineProperty(document,'fullscreenElement',{configurable:true,value:host});const {unmount}=render();expect(host).toContainElement(screen.getByRole('dialog'));unmount();Object.defineProperty(document,'fullscreenElement',{configurable:true,value:null});host.remove();});});
+function Fixture() {
+ const [open, setOpen] = useState(false);
+ return (
+ <>
+
+
+ >
+ );
+}
+describe('Dialog', () => {
+ it('支持 Escape 关闭并恢复触发器焦点', () => {
+ render();
+ const trigger = screen.getByRole('button', { name: '打开' });
+ trigger.focus();
+ fireEvent.click(trigger);
+ expect(screen.getByRole('dialog')).toBeVisible();
+ fireEvent.keyDown(document, { key: 'Escape' });
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ expect(trigger).toHaveFocus();
+ });
+ it('将 Tab 焦点限制在弹窗内', () => {
+ render();
+ fireEvent.click(screen.getByRole('button', { name: '打开' }));
+ const first = screen.getByRole('button', { name: '关闭' }),
+ last = screen.getByRole('button', { name: '最后一个' });
+ last.focus();
+ fireEvent.keyDown(document, { key: 'Tab' });
+ expect(first).toHaveFocus();
+ first.focus();
+ fireEvent.keyDown(document, { key: 'Tab', shiftKey: true });
+ expect(last).toHaveFocus();
+ });
+ it('全屏时将 Portal 挂载到全屏元素内部', () => {
+ const host = document.createElement('div');
+ document.body.append(host);
+ Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: host });
+ const { unmount } = render(
+ ,
+ );
+ expect(host).toContainElement(screen.getByRole('dialog'));
+ unmount();
+ Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: null });
+ host.remove();
+ });
+});
diff --git a/web_platform/src/components/ui/Dialog.tsx b/web_platform/src/components/ui/Dialog.tsx
index 82c57871..e0b19a57 100644
--- a/web_platform/src/components/ui/Dialog.tsx
+++ b/web_platform/src/components/ui/Dialog.tsx
@@ -1,14 +1,105 @@
-import {useEffect,useId,useRef,type ReactNode} from 'react';
-import {createPortal} from 'react-dom';
-import {X} from 'lucide-react';
-import {IconButton} from './IconButton';
+import { useEffect, useId, useRef, type ReactNode } from 'react';
+import { createPortal } from 'react-dom';
+import { X } from 'lucide-react';
+import { IconButton } from './IconButton';
-const FOCUSABLE='button:not([disabled]),a[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
-export function Dialog({open,onClose,title,children,footer,className='',closable=true}:{open:boolean;onClose:()=>void;title:string;children:ReactNode;footer?:ReactNode;className?:string;closable?:boolean}){
- const ref=useRef(null),previous=useRef(null),onCloseRef=useRef(onClose),titleId=useId();
- useEffect(()=>{onCloseRef.current=onClose;},[onClose]);
- useEffect(()=>{if(!open)return;previous.current=document.activeElement instanceof HTMLElement?document.activeElement:null;ref.current?.focus();const key=(event:KeyboardEvent)=>{if(event.key==='Escape'&&closable){event.preventDefault();onCloseRef.current();return;}if(event.key!=='Tab'||!ref.current)return;const items=Array.from(ref.current.querySelectorAll(FOCUSABLE));if(!items.length){event.preventDefault();ref.current.focus();return;}const first=items[0],last=items.at(-1)!;if(event.shiftKey&&document.activeElement===first){event.preventDefault();last.focus();}else if(!event.shiftKey&&document.activeElement===last){event.preventDefault();first.focus();}};document.addEventListener('keydown',key);return()=>{document.removeEventListener('keydown',key);if(previous.current&&document.contains(previous.current))previous.current.focus();};},[open,closable]);
- if(!open)return null;
- const backdrop={if(closable&&event.target===event.currentTarget)onCloseRef.current();}}/>;
- return createPortal(
{backdrop}
{title}
{closable&&onCloseRef.current()}>}{children}
{footer&&
}
,document.fullscreenElement??document.body);
+const FOCUSABLE =
+ 'button:not([disabled]),a[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
+export function Dialog({
+ open,
+ onClose,
+ title,
+ children,
+ footer,
+ className = '',
+ closable = true,
+}: {
+ open: boolean;
+ onClose: () => void;
+ title: string;
+ children: ReactNode;
+ footer?: ReactNode;
+ className?: string;
+ closable?: boolean;
+}) {
+ const ref = useRef
(null),
+ previous = useRef(null),
+ onCloseRef = useRef(onClose),
+ titleId = useId();
+ useEffect(() => {
+ onCloseRef.current = onClose;
+ }, [onClose]);
+ useEffect(() => {
+ if (!open) return;
+ previous.current =
+ document.activeElement instanceof HTMLElement ? document.activeElement : null;
+ ref.current?.focus();
+ const key = (event: KeyboardEvent) => {
+ if (event.key === 'Escape' && closable) {
+ event.preventDefault();
+ onCloseRef.current();
+ return;
+ }
+ if (event.key !== 'Tab' || !ref.current) return;
+ const items = Array.from(ref.current.querySelectorAll(FOCUSABLE));
+ if (!items.length) {
+ event.preventDefault();
+ ref.current.focus();
+ return;
+ }
+ const first = items[0],
+ last = items.at(-1)!;
+ if (event.shiftKey && document.activeElement === first) {
+ event.preventDefault();
+ last.focus();
+ } else if (!event.shiftKey && document.activeElement === last) {
+ event.preventDefault();
+ first.focus();
+ }
+ };
+ document.addEventListener('keydown', key);
+ return () => {
+ document.removeEventListener('keydown', key);
+ if (previous.current && document.contains(previous.current)) previous.current.focus();
+ };
+ }, [open, closable]);
+ if (!open) return null;
+ const backdrop = (
+ {
+ if (closable && event.target === event.currentTarget) onCloseRef.current();
+ }}
+ />
+ );
+ return createPortal(
+
+ {backdrop}
+
+
+
+ {title}
+
+ {closable && (
+ onCloseRef.current()}>
+
+
+ )}
+
+
{children}
+ {footer && (
+
+ )}
+
+
,
+ document.fullscreenElement ?? document.body,
+ );
}
diff --git a/web_platform/src/components/ui/DropdownMenu.tsx b/web_platform/src/components/ui/DropdownMenu.tsx
index 8c9bd2e2..f756bbba 100644
--- a/web_platform/src/components/ui/DropdownMenu.tsx
+++ b/web_platform/src/components/ui/DropdownMenu.tsx
@@ -1,7 +1,85 @@
-import {useEffect,useRef,type ReactNode} from 'react';
-import {MoreHorizontal} from 'lucide-react';
-import {IconButton} from './IconButton';
-import {Popover} from './Popover';
-export interface DropdownMenuItem{id:string;label:string;icon?:ReactNode;disabled?:boolean;onSelect:()=>void;}
-function MenuContent({items,close}:{items:DropdownMenuItem[];close:()=>void}){const refs=useRef<(HTMLButtonElement|null)[]>([]);useEffect(()=>{requestAnimationFrame(()=>refs.current.find(item=>item&&!item.disabled)?.focus());},[]);return
{const enabled=refs.current.filter((item):item is HTMLButtonElement=>Boolean(item&&!item.disabled)),index=enabled.indexOf(document.activeElement as HTMLButtonElement);if(event.key==='ArrowDown'){event.preventDefault();enabled[(index+1)%enabled.length]?.focus();}else if(event.key==='ArrowUp'){event.preventDefault();enabled[(index-1+enabled.length)%enabled.length]?.focus();}else if(event.key==='Home'){event.preventDefault();enabled[0]?.focus();}else if(event.key==='End'){event.preventDefault();enabled.at(-1)?.focus();}}}>{items.map((item,index)=>)}
;}
-export function DropdownMenu({items,label='更多操作',className=''}:{items:DropdownMenuItem[];label?:string;className?:string}){return
}>{({close})=>};}
+import { useEffect, useRef, type ReactNode } from 'react';
+import { MoreHorizontal } from 'lucide-react';
+import { IconButton } from './IconButton';
+import { Popover } from './Popover';
+export interface DropdownMenuItem {
+ id: string;
+ label: string;
+ icon?: ReactNode;
+ disabled?: boolean;
+ onSelect: () => void;
+}
+function MenuContent({ items, close }: { items: DropdownMenuItem[]; close: () => void }) {
+ const refs = useRef<(HTMLButtonElement | null)[]>([]);
+ useEffect(() => {
+ requestAnimationFrame(() => refs.current.find((item) => item && !item.disabled)?.focus());
+ }, []);
+ return (
+
{
+ const enabled = refs.current.filter((item): item is HTMLButtonElement =>
+ Boolean(item && !item.disabled),
+ ),
+ index = enabled.indexOf(document.activeElement as HTMLButtonElement);
+ if (event.key === 'ArrowDown') {
+ event.preventDefault();
+ enabled[(index + 1) % enabled.length]?.focus();
+ } else if (event.key === 'ArrowUp') {
+ event.preventDefault();
+ enabled[(index - 1 + enabled.length) % enabled.length]?.focus();
+ } else if (event.key === 'Home') {
+ event.preventDefault();
+ enabled[0]?.focus();
+ } else if (event.key === 'End') {
+ event.preventDefault();
+ enabled.at(-1)?.focus();
+ }
+ }}
+ >
+ {items.map((item, index) => (
+
+ ))}
+
+ );
+}
+export function DropdownMenu({
+ items,
+ label = '更多操作',
+ className = '',
+}: {
+ items: DropdownMenuItem[];
+ label?: string;
+ className?: string;
+}) {
+ return (
+
+ (
+
+
+
+ )}
+ >
+ {({ close }) => }
+
+
+ );
+}
diff --git a/web_platform/src/components/ui/EmptySearchState.tsx b/web_platform/src/components/ui/EmptySearchState.tsx
index 0b3ce293..96465df9 100644
--- a/web_platform/src/components/ui/EmptySearchState.tsx
+++ b/web_platform/src/components/ui/EmptySearchState.tsx
@@ -1,2 +1,9 @@
-import {SearchX} from 'lucide-react';
-export function EmptySearchState({label='没有匹配结果'}:{label?:string}){return
{label}
;}
+import { SearchX } from 'lucide-react';
+export function EmptySearchState({ label = '没有匹配结果' }: { label?: string }) {
+ return (
+
+
+ {label}
+
+ );
+}
diff --git a/web_platform/src/components/ui/FifthBatchUi.test.tsx b/web_platform/src/components/ui/FifthBatchUi.test.tsx
index 259cd7c2..d9fb6630 100644
--- a/web_platform/src/components/ui/FifthBatchUi.test.tsx
+++ b/web_platform/src/components/ui/FifthBatchUi.test.tsx
@@ -1,7 +1,48 @@
-import {fireEvent,render,screen,waitFor} from '@testing-library/react';
-import {DropdownMenu,LiveRegion,ProgressBar,SearchableCombobox} from './index';
-describe('第五批基础 UI',()=>{
- it('下拉菜单打开后聚焦菜单项并恢复触发器焦点',async()=>{const run=vi.fn();render(
);const trigger=screen.getByRole('button',{name:'更多操作'});trigger.focus();fireEvent.click(trigger);const item=screen.getByRole('menuitem',{name:'动作 A'});await waitFor(()=>expect(item).toHaveFocus());fireEvent.click(item);expect(run).toHaveBeenCalled();expect(trigger).toHaveFocus();});
- it('进度条和实时区域暴露状态',()=>{render(<>
正在编译模型>);expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow','42');expect(screen.getByRole('status')).toHaveTextContent('正在编译模型');});
- it('可搜索组合框筛选并选择入口',()=>{const change=vi.fn();render(
);fireEvent.click(screen.getByRole('button',{name:'模型入口'}));fireEvent.change(screen.getByRole('combobox',{name:'搜索模型入口'}),{target:{value:'B'}});const input=screen.getByRole('combobox',{name:'搜索模型入口'});expect(input).toHaveAttribute('aria-expanded','true');fireEvent.click(screen.getByRole('option',{name:/模型 B/}));expect(change).toHaveBeenCalledWith('b');});
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { DropdownMenu, LiveRegion, ProgressBar, SearchableCombobox } from './index';
+describe('第五批基础 UI', () => {
+ it('下拉菜单打开后聚焦菜单项并恢复触发器焦点', async () => {
+ const run = vi.fn();
+ render(
);
+ const trigger = screen.getByRole('button', { name: '更多操作' });
+ trigger.focus();
+ fireEvent.click(trigger);
+ const item = screen.getByRole('menuitem', { name: '动作 A' });
+ await waitFor(() => expect(item).toHaveFocus());
+ fireEvent.click(item);
+ expect(run).toHaveBeenCalled();
+ expect(trigger).toHaveFocus();
+ });
+ it('进度条和实时区域暴露状态', () => {
+ render(
+ <>
+
+
正在编译模型
+ >,
+ );
+ expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '42');
+ expect(screen.getByRole('status')).toHaveTextContent('正在编译模型');
+ });
+ it('可搜索组合框筛选并选择入口', () => {
+ const change = vi.fn();
+ render(
+
,
+ );
+ fireEvent.click(screen.getByRole('button', { name: '模型入口' }));
+ fireEvent.change(screen.getByRole('combobox', { name: '搜索模型入口' }), {
+ target: { value: 'B' },
+ });
+ const input = screen.getByRole('combobox', { name: '搜索模型入口' });
+ expect(input).toHaveAttribute('aria-expanded', 'true');
+ fireEvent.click(screen.getByRole('option', { name: /模型 B/ }));
+ expect(change).toHaveBeenCalledWith('b');
+ });
});
diff --git a/web_platform/src/components/ui/IconButton.tsx b/web_platform/src/components/ui/IconButton.tsx
index 27a4490c..1082ecf8 100644
--- a/web_platform/src/components/ui/IconButton.tsx
+++ b/web_platform/src/components/ui/IconButton.tsx
@@ -1,8 +1,24 @@
-import type {ButtonHTMLAttributes} from 'react';
-import {Tooltip} from './Tooltip';
+import type { ButtonHTMLAttributes } from 'react';
+import { Tooltip } from './Tooltip';
-export interface IconButtonProps extends ButtonHTMLAttributes
{active?:boolean;tooltip?:string;}
-export function IconButton({active=false,tooltip,className='',type='button',...props}:IconButtonProps){
- const button=;
- return tooltip?{button}:button;
+export interface IconButtonProps extends ButtonHTMLAttributes {
+ active?: boolean;
+ tooltip?: string;
+}
+export function IconButton({
+ active = false,
+ tooltip,
+ className = '',
+ type = 'button',
+ ...props
+}: IconButtonProps) {
+ const button = (
+
+ );
+ return tooltip ? {button} : button;
}
diff --git a/web_platform/src/components/ui/Kbd.tsx b/web_platform/src/components/ui/Kbd.tsx
index 20180477..34c48622 100644
--- a/web_platform/src/components/ui/Kbd.tsx
+++ b/web_platform/src/components/ui/Kbd.tsx
@@ -1,2 +1,8 @@
-import type {ReactNode} from 'react';
-export function Kbd({children}:{children:ReactNode}){return {children};}
+import type { ReactNode } from 'react';
+export function Kbd({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/web_platform/src/components/ui/LiveRegion.tsx b/web_platform/src/components/ui/LiveRegion.tsx
index 0fd4ee76..00e3a492 100644
--- a/web_platform/src/components/ui/LiveRegion.tsx
+++ b/web_platform/src/components/ui/LiveRegion.tsx
@@ -1,2 +1,19 @@
-import type {ReactNode} from 'react';
-export function LiveRegion({children,assertive=false}:{children:ReactNode;assertive?:boolean}){return {children}
;}
+import type { ReactNode } from 'react';
+export function LiveRegion({
+ children,
+ assertive = false,
+}: {
+ children: ReactNode;
+ assertive?: boolean;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/web_platform/src/components/ui/Popover.tsx b/web_platform/src/components/ui/Popover.tsx
index baf58fde..9592352f 100644
--- a/web_platform/src/components/ui/Popover.tsx
+++ b/web_platform/src/components/ui/Popover.tsx
@@ -1,3 +1,66 @@
/* eslint-disable react-hooks/refs -- refs are read only inside event callbacks passed to render props */
-import {useCallback,useEffect,useRef,useState,type ReactNode} from 'react';
-export function Popover({trigger,children,placement='bottom-right',label}:{trigger:(props:{open:boolean;toggle:()=>void})=>ReactNode;children:(props:{close:(restoreFocus?:boolean)=>void})=>ReactNode;placement?:'bottom-right'|'bottom-left'|'top-left';label:string}){const [open,setOpen]=useState(false),root=useRef(null),previous=useRef(null);const close=useCallback((restoreFocus=true)=>{setOpen(false);if(restoreFocus)previous.current?.focus();},[]);useEffect(()=>{if(!open)return;const pointer=(event:PointerEvent)=>{if(!root.current?.contains(event.target as Node))setOpen(false);},key=(event:KeyboardEvent)=>{if(event.key==='Escape'||((event.ctrlKey||event.metaKey)&&event.key.toLocaleLowerCase()==='k')){setOpen(false);previous.current?.focus();}};document.addEventListener('pointerdown',pointer);document.addEventListener('keydown',key);return()=>{document.removeEventListener('pointerdown',pointer);document.removeEventListener('keydown',key);};},[open]);const position=placement==='top-left'?'bottom-8 left-0':placement==='bottom-left'?'left-0 top-9':'right-0 top-9';return {trigger({open,toggle:()=>{if(!open)previous.current=document.activeElement instanceof HTMLElement?document.activeElement:null;setOpen(value=>!value);}})}{open&&}
;}
+import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
+export function Popover({
+ trigger,
+ children,
+ placement = 'bottom-right',
+ label,
+}: {
+ trigger: (props: { open: boolean; toggle: () => void }) => ReactNode;
+ children: (props: { close: (restoreFocus?: boolean) => void }) => ReactNode;
+ placement?: 'bottom-right' | 'bottom-left' | 'top-left';
+ label: string;
+}) {
+ const [open, setOpen] = useState(false),
+ root = useRef(null),
+ previous = useRef(null);
+ const close = useCallback((restoreFocus = true) => {
+ setOpen(false);
+ if (restoreFocus) previous.current?.focus();
+ }, []);
+ useEffect(() => {
+ if (!open) return;
+ const pointer = (event: PointerEvent) => {
+ if (!root.current?.contains(event.target as Node)) setOpen(false);
+ },
+ key = (event: KeyboardEvent) => {
+ if (
+ event.key === 'Escape' ||
+ ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === 'k')
+ ) {
+ setOpen(false);
+ previous.current?.focus();
+ }
+ };
+ document.addEventListener('pointerdown', pointer);
+ document.addEventListener('keydown', key);
+ return () => {
+ document.removeEventListener('pointerdown', pointer);
+ document.removeEventListener('keydown', key);
+ };
+ }, [open]);
+ const position =
+ placement === 'top-left'
+ ? 'bottom-8 left-0'
+ : placement === 'bottom-left'
+ ? 'left-0 top-9'
+ : 'right-0 top-9';
+ return (
+
+ {trigger({
+ open,
+ toggle: () => {
+ if (!open)
+ previous.current =
+ document.activeElement instanceof HTMLElement ? document.activeElement : null;
+ setOpen((value) => !value);
+ },
+ })}
+ {open && (
+
+ {children({ close })}
+
+ )}
+
+ );
+}
diff --git a/web_platform/src/components/ui/ProgressBar.tsx b/web_platform/src/components/ui/ProgressBar.tsx
index 8f8dfb07..445c8af3 100644
--- a/web_platform/src/components/ui/ProgressBar.tsx
+++ b/web_platform/src/components/ui/ProgressBar.tsx
@@ -1 +1,24 @@
-export function ProgressBar({value,label}:{value:number;label:string}){const percent=Math.round(Math.min(1,Math.max(0,value))*100);return {label}{percent}%
;}
+export function ProgressBar({ value, label }: { value: number; label: string }) {
+ const percent = Math.round(Math.min(1, Math.max(0, value)) * 100);
+ return (
+
+
+ {label}
+ {percent}%
+
+
+
+ );
+}
diff --git a/web_platform/src/components/ui/PropertyRow.tsx b/web_platform/src/components/ui/PropertyRow.tsx
index 74ab8654..2b658781 100644
--- a/web_platform/src/components/ui/PropertyRow.tsx
+++ b/web_platform/src/components/ui/PropertyRow.tsx
@@ -1,2 +1,20 @@
-import type {ReactNode} from 'react';
-export function PropertyRow({label,value,action}:{label:string;value:ReactNode;action?:ReactNode}){return
{label}{value}{action}
;}
+import type { ReactNode } from 'react';
+export function PropertyRow({
+ label,
+ value,
+ action,
+}: {
+ label: string;
+ value: ReactNode;
+ action?: ReactNode;
+}) {
+ return (
+
+ {label}
+
+ {value}
+ {action}
+
+
+ );
+}
diff --git a/web_platform/src/components/ui/ResizablePanel.tsx b/web_platform/src/components/ui/ResizablePanel.tsx
index 49d0b6fb..bf7bd09d 100644
--- a/web_platform/src/components/ui/ResizablePanel.tsx
+++ b/web_platform/src/components/ui/ResizablePanel.tsx
@@ -1,8 +1,127 @@
-import {useEffect,useRef,useState,type PointerEvent as ReactPointerEvent,type ReactNode} from 'react';
-const clamp=(value:number,min:number,max:number)=>Math.min(max,Math.max(min,value));
-const panelMaxWidth=(minWidth:number)=>Math.max(minWidth,Math.min(576,window.innerWidth*.4));
-function storedWidth(key:string,fallback:number,minWidth:number){try{const value=Number(localStorage.getItem(key));return clamp(Number.isFinite(value)&&value>0?value:fallback,minWidth,panelMaxWidth(minWidth));}catch{return clamp(fallback,minWidth,panelMaxWidth(minWidth));}}
-export function ResizablePanel({side,storageKey,visible=true,defaultWidth=288,minWidth=224,children,className=''}:{side:'left'|'right';storageKey:string;visible?:boolean;defaultWidth?:number;minWidth?:number;children:ReactNode;className?:string}){const [width,setWidth]=useState(()=>storedWidth(storageKey,defaultWidth,minWidth)),cleanupRef=useRef<()=>void>(()=>{});const update=(next:number)=>{const value=clamp(next,minWidth,panelMaxWidth(minWidth));setWidth(value);try{localStorage.setItem(storageKey,String(value));}catch{/* 无持久化权限时仍可调整 */}};
- useEffect(()=>{const persist=(next:number)=>{const value=clamp(next,minWidth,panelMaxWidth(minWidth));setWidth(value);try{localStorage.setItem(storageKey,String(value));}catch{/* 忽略 */}},resize=()=>setWidth(value=>clamp(value,minWidth,panelMaxWidth(minWidth))),layout=(event:Event)=>{const widths=(event as CustomEvent<{left:number;right:number}>).detail;persist(widths[side]);};window.addEventListener('resize',resize);window.addEventListener('mujoco-layout-widths',layout);return()=>{window.removeEventListener('resize',resize);window.removeEventListener('mujoco-layout-widths',layout);cleanupRef.current();};},[minWidth,side,storageKey]);
- const start=(event:ReactPointerEvent
)=>{event.preventDefault();cleanupRef.current();const origin=event.clientX,startWidth=width,pointerId=event.pointerId;const move=(moveEvent:PointerEvent)=>{if(moveEvent.pointerId===pointerId)update(startWidth+(moveEvent.clientX-origin)*(side==='left'?1:-1));};const stop=(stopEvent:PointerEvent)=>{if(stopEvent.pointerId!==pointerId)return;cleanup();};const cleanup=()=>{window.removeEventListener('pointermove',move);window.removeEventListener('pointerup',stop);window.removeEventListener('pointercancel',stop);cleanupRef.current=()=>{};};cleanupRef.current=cleanup;window.addEventListener('pointermove',move);window.addEventListener('pointerup',stop);window.addEventListener('pointercancel',stop);};
- return {children}
;}
+import {
+ useEffect,
+ useRef,
+ useState,
+ type PointerEvent as ReactPointerEvent,
+ type ReactNode,
+} from 'react';
+const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
+const panelMaxWidth = (minWidth: number) =>
+ Math.max(minWidth, Math.min(576, window.innerWidth * 0.4));
+function storedWidth(key: string, fallback: number, minWidth: number) {
+ try {
+ const value = Number(localStorage.getItem(key));
+ return clamp(
+ Number.isFinite(value) && value > 0 ? value : fallback,
+ minWidth,
+ panelMaxWidth(minWidth),
+ );
+ } catch {
+ return clamp(fallback, minWidth, panelMaxWidth(minWidth));
+ }
+}
+export function ResizablePanel({
+ side,
+ storageKey,
+ visible = true,
+ defaultWidth = 288,
+ minWidth = 224,
+ children,
+ className = '',
+}: {
+ side: 'left' | 'right';
+ storageKey: string;
+ visible?: boolean;
+ defaultWidth?: number;
+ minWidth?: number;
+ children: ReactNode;
+ className?: string;
+}) {
+ const [width, setWidth] = useState(() => storedWidth(storageKey, defaultWidth, minWidth)),
+ cleanupRef = useRef<() => void>(() => {});
+ const update = (next: number) => {
+ const value = clamp(next, minWidth, panelMaxWidth(minWidth));
+ setWidth(value);
+ try {
+ localStorage.setItem(storageKey, String(value));
+ } catch {
+ /* 无持久化权限时仍可调整 */
+ }
+ };
+ useEffect(() => {
+ const persist = (next: number) => {
+ const value = clamp(next, minWidth, panelMaxWidth(minWidth));
+ setWidth(value);
+ try {
+ localStorage.setItem(storageKey, String(value));
+ } catch {
+ /* 忽略 */
+ }
+ },
+ resize = () => setWidth((value) => clamp(value, minWidth, panelMaxWidth(minWidth))),
+ layout = (event: Event) => {
+ const widths = (event as CustomEvent<{ left: number; right: number }>).detail;
+ persist(widths[side]);
+ };
+ window.addEventListener('resize', resize);
+ window.addEventListener('mujoco-layout-widths', layout);
+ return () => {
+ window.removeEventListener('resize', resize);
+ window.removeEventListener('mujoco-layout-widths', layout);
+ cleanupRef.current();
+ };
+ }, [minWidth, side, storageKey]);
+ const start = (event: ReactPointerEvent) => {
+ event.preventDefault();
+ cleanupRef.current();
+ const origin = event.clientX,
+ startWidth = width,
+ pointerId = event.pointerId;
+ const move = (moveEvent: PointerEvent) => {
+ if (moveEvent.pointerId === pointerId)
+ update(startWidth + (moveEvent.clientX - origin) * (side === 'left' ? 1 : -1));
+ };
+ const stop = (stopEvent: PointerEvent) => {
+ if (stopEvent.pointerId !== pointerId) return;
+ cleanup();
+ };
+ const cleanup = () => {
+ window.removeEventListener('pointermove', move);
+ window.removeEventListener('pointerup', stop);
+ window.removeEventListener('pointercancel', stop);
+ cleanupRef.current = () => {};
+ };
+ cleanupRef.current = cleanup;
+ window.addEventListener('pointermove', move);
+ window.addEventListener('pointerup', stop);
+ window.addEventListener('pointercancel', stop);
+ };
+ return (
+
+ {children}
+
+ );
+}
diff --git a/web_platform/src/components/ui/SearchHighlight.tsx b/web_platform/src/components/ui/SearchHighlight.tsx
index 2325b817..55c68ff9 100644
--- a/web_platform/src/components/ui/SearchHighlight.tsx
+++ b/web_platform/src/components/ui/SearchHighlight.tsx
@@ -1 +1,27 @@
-export function SearchHighlight({text,query}:{text:string;query:string}){const needle=query.trim().toLocaleLowerCase();if(!needle)return <>{text}>;const parts:({text:string;match:boolean})[]=[];let start=0,index=text.toLocaleLowerCase().indexOf(needle);while(index>=0){if(index>start)parts.push({text:text.slice(start,index),match:false});parts.push({text:text.slice(index,index+needle.length),match:true});start=index+needle.length;index=text.toLocaleLowerCase().indexOf(needle,start);}if(start{parts.map((part,i)=>part.match?{part.text}:part.text)}>;}
+export function SearchHighlight({ text, query }: { text: string; query: string }) {
+ const needle = query.trim().toLocaleLowerCase();
+ if (!needle) return <>{text}>;
+ const parts: { text: string; match: boolean }[] = [];
+ let start = 0,
+ index = text.toLocaleLowerCase().indexOf(needle);
+ while (index >= 0) {
+ if (index > start) parts.push({ text: text.slice(start, index), match: false });
+ parts.push({ text: text.slice(index, index + needle.length), match: true });
+ start = index + needle.length;
+ index = text.toLocaleLowerCase().indexOf(needle, start);
+ }
+ if (start < text.length) parts.push({ text: text.slice(start), match: false });
+ return (
+ <>
+ {parts.map((part, i) =>
+ part.match ? (
+
+ {part.text}
+
+ ) : (
+ part.text
+ ),
+ )}
+ >
+ );
+}
diff --git a/web_platform/src/components/ui/SearchableCombobox.tsx b/web_platform/src/components/ui/SearchableCombobox.tsx
index 3336421e..11a4cae3 100644
--- a/web_platform/src/components/ui/SearchableCombobox.tsx
+++ b/web_platform/src/components/ui/SearchableCombobox.tsx
@@ -1,5 +1,120 @@
-import {useId,useMemo,useState} from 'react';
-import {Check,ChevronsUpDown,Search} from 'lucide-react';
-import {Popover} from './Popover';
-export interface ComboboxOption{value:string;label:string;description?:string;}
-export function SearchableCombobox({options,value,onChange,label,disabled=false}:{options:ComboboxOption[];value?:string;onChange:(value:string)=>void;label:string;disabled?:boolean}){const [query,setQuery]=useState(''),[active,setActive]=useState(0),listId=useId(),selected=options.find(option=>option.value===value),filtered=useMemo(()=>{const needle=query.trim().toLocaleLowerCase();return options.filter(option=>!needle||`${option.label} ${option.description??''}`.toLocaleLowerCase().includes(needle));},[options,query]);return }>{({close})=>};}
+import { useId, useMemo, useState } from 'react';
+import { Check, ChevronsUpDown, Search } from 'lucide-react';
+import { Popover } from './Popover';
+export interface ComboboxOption {
+ value: string;
+ label: string;
+ description?: string;
+}
+export function SearchableCombobox({
+ options,
+ value,
+ onChange,
+ label,
+ disabled = false,
+}: {
+ options: ComboboxOption[];
+ value?: string;
+ onChange: (value: string) => void;
+ label: string;
+ disabled?: boolean;
+}) {
+ const [query, setQuery] = useState(''),
+ [active, setActive] = useState(0),
+ listId = useId(),
+ selected = options.find((option) => option.value === value),
+ filtered = useMemo(() => {
+ const needle = query.trim().toLocaleLowerCase();
+ return options.filter(
+ (option) =>
+ !needle ||
+ `${option.label} ${option.description ?? ''}`.toLocaleLowerCase().includes(needle),
+ );
+ }, [options, query]);
+ return (
+ (
+
+ )}
+ >
+ {({ close }) => (
+
+
+
+ {
+ setQuery(event.target.value);
+ setActive(0);
+ }}
+ onKeyDown={(event) => {
+ if (!filtered.length) return;
+ if (event.key === 'ArrowDown') {
+ event.preventDefault();
+ setActive((index) => (index + 1) % filtered.length);
+ } else if (event.key === 'ArrowUp') {
+ event.preventDefault();
+ setActive((index) => (index - 1 + filtered.length) % filtered.length);
+ } else if (event.key === 'Enter') {
+ event.preventDefault();
+ onChange(filtered[active].value);
+ setQuery('');
+ close();
+ } else if (event.key === 'Tab') close(false);
+ }}
+ className="h-8 w-full rounded border border-border bg-input pl-7 pr-2 text-xs"
+ />
+
+
+ {filtered.map((option, index) => (
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/web_platform/src/components/ui/SecondBatchUi.test.tsx b/web_platform/src/components/ui/SecondBatchUi.test.tsx
index c673a5b6..b7c18b56 100644
--- a/web_platform/src/components/ui/SecondBatchUi.test.tsx
+++ b/web_platform/src/components/ui/SecondBatchUi.test.tsx
@@ -1,9 +1,60 @@
-import {useState} from 'react';
-import {act,fireEvent,render,screen} from '@testing-library/react';
-import {Badge,ResizablePanel,Separator,Skeleton,Tabs} from './index';
-function TabHarness(){const [value,setValue]=useState<'a'|'b'>('a');return 甲内容},{value:'b',label:'乙',content:乙内容}]}/>;}
-describe('第二批基础 UI',()=>{
- it('Tabs 保留面板 DOM并支持方向键导航',()=>{render();const first=screen.getByRole('tab',{name:'甲'});expect(first).toHaveAttribute('tabindex','0');fireEvent.keyDown(first,{key:'ArrowRight'});expect(screen.getByRole('tab',{name:'乙'})).toHaveAttribute('aria-selected','true');expect(screen.getByText('乙内容')).toBeVisible();expect(screen.getByText('甲内容').closest('[role="tabpanel"]')).toHaveAttribute('hidden');});
- it('ResizablePanel 支持键盘调整、限制异常持久值并保存宽度',()=>{localStorage.setItem('test-width','9999');render(面板
);const handle=screen.getByRole('separator',{name:'调整工程面板宽度'});expect(Number(handle.getAttribute('aria-valuenow'))).toBeLessThanOrEqual(Number(handle.getAttribute('aria-valuemax')));fireEvent.keyDown(handle,{key:'Home'});expect(handle).toHaveAttribute('aria-valuenow','224');expect(localStorage.getItem('test-width')).toBe('224');act(()=>window.dispatchEvent(new CustomEvent('mujoco-layout-widths',{detail:{left:320,right:360}})));expect(handle).toHaveAttribute('aria-valuenow','320');});
- it('Badge、Separator 和 Skeleton 可渲染',()=>{render(<>状态>);expect(screen.getByText('状态')).toBeVisible();expect(screen.getByRole('separator')).toBeVisible();});
+import { useState } from 'react';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { Badge, ResizablePanel, Separator, Skeleton, Tabs } from './index';
+function TabHarness() {
+ const [value, setValue] = useState<'a' | 'b'>('a');
+ return (
+ 甲内容 },
+ { value: 'b', label: '乙', content: 乙内容 },
+ ]}
+ />
+ );
+}
+describe('第二批基础 UI', () => {
+ it('Tabs 保留面板 DOM并支持方向键导航', () => {
+ render();
+ const first = screen.getByRole('tab', { name: '甲' });
+ expect(first).toHaveAttribute('tabindex', '0');
+ fireEvent.keyDown(first, { key: 'ArrowRight' });
+ expect(screen.getByRole('tab', { name: '乙' })).toHaveAttribute('aria-selected', 'true');
+ expect(screen.getByText('乙内容')).toBeVisible();
+ expect(screen.getByText('甲内容').closest('[role="tabpanel"]')).toHaveAttribute('hidden');
+ });
+ it('ResizablePanel 支持键盘调整、限制异常持久值并保存宽度', () => {
+ localStorage.setItem('test-width', '9999');
+ render(
+
+ 面板
+ ,
+ );
+ const handle = screen.getByRole('separator', { name: '调整工程面板宽度' });
+ expect(Number(handle.getAttribute('aria-valuenow'))).toBeLessThanOrEqual(
+ Number(handle.getAttribute('aria-valuemax')),
+ );
+ fireEvent.keyDown(handle, { key: 'Home' });
+ expect(handle).toHaveAttribute('aria-valuenow', '224');
+ expect(localStorage.getItem('test-width')).toBe('224');
+ act(() =>
+ window.dispatchEvent(
+ new CustomEvent('mujoco-layout-widths', { detail: { left: 320, right: 360 } }),
+ ),
+ );
+ expect(handle).toHaveAttribute('aria-valuenow', '320');
+ });
+ it('Badge、Separator 和 Skeleton 可渲染', () => {
+ render(
+ <>
+ 状态
+
+
+ >,
+ );
+ expect(screen.getByText('状态')).toBeVisible();
+ expect(screen.getByRole('separator')).toBeVisible();
+ });
});
diff --git a/web_platform/src/components/ui/Select.tsx b/web_platform/src/components/ui/Select.tsx
index b3a58dee..092f98d0 100644
--- a/web_platform/src/components/ui/Select.tsx
+++ b/web_platform/src/components/ui/Select.tsx
@@ -1,2 +1,9 @@
-import type {SelectHTMLAttributes} from 'react';
-export function Select({className='',...props}:SelectHTMLAttributes){return ;}
+import type { SelectHTMLAttributes } from 'react';
+export function Select({ className = '', ...props }: SelectHTMLAttributes) {
+ return (
+
+ );
+}
diff --git a/web_platform/src/components/ui/Separator.tsx b/web_platform/src/components/ui/Separator.tsx
index 4c526fe9..ced65523 100644
--- a/web_platform/src/components/ui/Separator.tsx
+++ b/web_platform/src/components/ui/Separator.tsx
@@ -1 +1,15 @@
-export function Separator({orientation='horizontal',className=''}:{orientation?:'horizontal'|'vertical';className?:string}){return ;}
+export function Separator({
+ orientation = 'horizontal',
+ className = '',
+}: {
+ orientation?: 'horizontal' | 'vertical';
+ className?: string;
+}) {
+ return (
+
+ );
+}
diff --git a/web_platform/src/components/ui/Skeleton.tsx b/web_platform/src/components/ui/Skeleton.tsx
index d34ff327..e0523ee6 100644
--- a/web_platform/src/components/ui/Skeleton.tsx
+++ b/web_platform/src/components/ui/Skeleton.tsx
@@ -1 +1,8 @@
-export function Skeleton({className=''}:{className?:string}){return ;}
+export function Skeleton({ className = '' }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/web_platform/src/components/ui/Tabs.tsx b/web_platform/src/components/ui/Tabs.tsx
index cf1742f7..bbe301d2 100644
--- a/web_platform/src/components/ui/Tabs.tsx
+++ b/web_platform/src/components/ui/Tabs.tsx
@@ -1,3 +1,84 @@
-import type {KeyboardEvent,ReactNode} from 'react';
-export interface TabItem{value:T;label:string;icon?:ReactNode;content:ReactNode;disabled?:boolean;}
-export function Tabs({items,value,onValueChange,label,className='',keepMounted=true}:{items:TabItem[];value:T;onValueChange:(value:T)=>void;label:string;className?:string;keepMounted?:boolean}){const active=items.find(item=>item.value===value)??items.find(item=>!item.disabled)??items[0];const navigate=(event:KeyboardEvent)=>{if(!['ArrowLeft','ArrowRight','Home','End'].includes(event.key))return;const enabled=items.filter(item=>!item.disabled);if(!enabled.length)return;const current=enabled.findIndex(item=>item.value===active.value);const next=event.key==='Home'?0:event.key==='End'?enabled.length-1:event.key==='ArrowRight'?(current+1)%enabled.length:(current-1+enabled.length)%enabled.length;event.preventDefault();const item=enabled[next];onValueChange(item.value);requestAnimationFrame(()=>document.getElementById(`${label}-tab-${item.value}`)?.focus());};return {items.map(item=>)}
{(keepMounted?items:[active]).map(item=>
{item.content}
)}
;}
+import type { KeyboardEvent, ReactNode } from 'react';
+export interface TabItem {
+ value: T;
+ label: string;
+ icon?: ReactNode;
+ content: ReactNode;
+ disabled?: boolean;
+}
+export function Tabs({
+ items,
+ value,
+ onValueChange,
+ label,
+ className = '',
+ keepMounted = true,
+}: {
+ items: TabItem[];
+ value: T;
+ onValueChange: (value: T) => void;
+ label: string;
+ className?: string;
+ keepMounted?: boolean;
+}) {
+ const active =
+ items.find((item) => item.value === value) ?? items.find((item) => !item.disabled) ?? items[0];
+ const navigate = (event: KeyboardEvent) => {
+ if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
+ const enabled = items.filter((item) => !item.disabled);
+ if (!enabled.length) return;
+ const current = enabled.findIndex((item) => item.value === active.value);
+ const next =
+ event.key === 'Home'
+ ? 0
+ : event.key === 'End'
+ ? enabled.length - 1
+ : event.key === 'ArrowRight'
+ ? (current + 1) % enabled.length
+ : (current - 1 + enabled.length) % enabled.length;
+ event.preventDefault();
+ const item = enabled[next];
+ onValueChange(item.value);
+ requestAnimationFrame(() => document.getElementById(`${label}-tab-${item.value}`)?.focus());
+ };
+ return (
+
+
+ {items.map((item) => (
+
+ ))}
+
+ {(keepMounted ? items : [active]).map((item) => (
+
+ {item.content}
+
+ ))}
+
+ );
+}
diff --git a/web_platform/src/components/ui/ThirdBatchUi.test.tsx b/web_platform/src/components/ui/ThirdBatchUi.test.tsx
index 5de70bbf..944c0fb9 100644
--- a/web_platform/src/components/ui/ThirdBatchUi.test.tsx
+++ b/web_platform/src/components/ui/ThirdBatchUi.test.tsx
@@ -1,8 +1,38 @@
-import {fireEvent,render,screen,waitFor} from '@testing-library/react';
-import {ConfirmDialog,CopyButton,Kbd,PropertyRow,SearchHighlight} from './index';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { ConfirmDialog, CopyButton, Kbd, PropertyRow, SearchHighlight } from './index';
-describe('第三批基础 UI',()=>{
- it('确认弹窗区分取消和危险确认动作',()=>{const confirm=vi.fn(),close=vi.fn();render(确认内容);fireEvent.click(screen.getByRole('button',{name:'确认'}));expect(confirm).toHaveBeenCalledTimes(1);fireEvent.click(screen.getByRole('button',{name:'取消'}));expect(close).toHaveBeenCalledTimes(1);});
- it('属性行支持复制且搜索词可高亮',async()=>{const writeText=vi.fn().mockResolvedValue(undefined);Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText}});render(<>}/>Ctrl+K>);fireEvent.click(screen.getByRole('button',{name:'复制'}));await waitFor(()=>expect(writeText).toHaveBeenCalledWith('robot'));expect(screen.getByText('arm').tagName).toBe('MARK');expect(screen.getByText('Ctrl+K')).toBeVisible();});
- it('Clipboard API 不可用时复制按钮不会抛错',()=>{Object.defineProperty(navigator,'clipboard',{configurable:true,value:undefined});render();expect(()=>fireEvent.click(screen.getByRole('button',{name:'复制'}))).not.toThrow();});
+describe('第三批基础 UI', () => {
+ it('确认弹窗区分取消和危险确认动作', () => {
+ const confirm = vi.fn(),
+ close = vi.fn();
+ render(
+
+ 确认内容
+ ,
+ );
+ fireEvent.click(screen.getByRole('button', { name: '确认' }));
+ expect(confirm).toHaveBeenCalledTimes(1);
+ fireEvent.click(screen.getByRole('button', { name: '取消' }));
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+ it('属性行支持复制且搜索词可高亮', async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
+ render(
+ <>
+ } />
+
+ Ctrl+K
+ >,
+ );
+ fireEvent.click(screen.getByRole('button', { name: '复制' }));
+ await waitFor(() => expect(writeText).toHaveBeenCalledWith('robot'));
+ expect(screen.getByText('arm').tagName).toBe('MARK');
+ expect(screen.getByText('Ctrl+K')).toBeVisible();
+ });
+ it('Clipboard API 不可用时复制按钮不会抛错', () => {
+ Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined });
+ render();
+ expect(() => fireEvent.click(screen.getByRole('button', { name: '复制' }))).not.toThrow();
+ });
});
diff --git a/web_platform/src/components/ui/ToolbarToggleGroup.tsx b/web_platform/src/components/ui/ToolbarToggleGroup.tsx
index 0c5e1810..33bb0941 100644
--- a/web_platform/src/components/ui/ToolbarToggleGroup.tsx
+++ b/web_platform/src/components/ui/ToolbarToggleGroup.tsx
@@ -1,6 +1,41 @@
-import type {ComponentType} from 'react';
-import {IconButton} from './IconButton';
-export interface ToolbarItem{value:T;label:string;icon:ComponentType<{className?:string}>;}
-export function ToolbarToggleGroup({items,value,onChange,label}:{items:readonly ToolbarItem[];value:T;onChange:(value:T)=>void;label:string}){
- return {items.map(item=>{const Icon=item.icon;return onChange(item.value)}>;})}
;
+import type { ComponentType } from 'react';
+import { IconButton } from './IconButton';
+export interface ToolbarItem {
+ value: T;
+ label: string;
+ icon: ComponentType<{ className?: string }>;
+}
+export function ToolbarToggleGroup({
+ items,
+ value,
+ onChange,
+ label,
+}: {
+ items: readonly ToolbarItem[];
+ value: T;
+ onChange: (value: T) => void;
+ label: string;
+}) {
+ return (
+
+ {items.map((item) => {
+ const Icon = item.icon;
+ return (
+ onChange(item.value)}
+ >
+
+
+ );
+ })}
+
+ );
}
diff --git a/web_platform/src/components/ui/Tooltip.tsx b/web_platform/src/components/ui/Tooltip.tsx
index 0e396d34..cdbb8bcc 100644
--- a/web_platform/src/components/ui/Tooltip.tsx
+++ b/web_platform/src/components/ui/Tooltip.tsx
@@ -1,9 +1,24 @@
-import type {ReactElement,ReactNode} from 'react';
+import type { ReactElement, ReactNode } from 'react';
-export function Tooltip({content,children,side='bottom'}:{content:ReactNode;children:ReactElement;side?:'top'|'bottom'}){
- if(!content)return children;
- return
- {children}
- {content}
- ;
+export function Tooltip({
+ content,
+ children,
+ side = 'bottom',
+}: {
+ content: ReactNode;
+ children: ReactElement;
+ side?: 'top' | 'bottom';
+}) {
+ if (!content) return children;
+ return (
+
+ {children}
+
+ {content}
+
+
+ );
}
diff --git a/web_platform/src/components/ui/VirtualTreeViewport.test.tsx b/web_platform/src/components/ui/VirtualTreeViewport.test.tsx
index c68bef73..d0856040 100644
--- a/web_platform/src/components/ui/VirtualTreeViewport.test.tsx
+++ b/web_platform/src/components/ui/VirtualTreeViewport.test.tsx
@@ -1,3 +1,26 @@
-import {fireEvent,render,screen} from '@testing-library/react';
-import {VirtualTreeViewport} from './VirtualTreeViewport';
-describe('VirtualTreeViewport',()=>{it('只渲染可视窗口并在滚动后更新行',()=>{const items=Array.from({length:1000},(_,id)=>({id,label:`节点 ${id}`}));render(item.id} renderRow={item=>{item.label}
}/>);const tree=screen.getByRole('tree',{name:'大型树'});expect(screen.getByText('节点 0')).toBeVisible();expect(screen.queryByText('节点 500')).not.toBeInTheDocument();Object.defineProperty(tree,'scrollTop',{configurable:true,value:10000});fireEvent.scroll(tree);expect(screen.getByText('节点 500')).toBeVisible();fireEvent.keyDown(tree,{key:'ArrowDown'});expect(tree.getAttribute('aria-activedescendant')).toContain('501');});});
+import { fireEvent, render, screen } from '@testing-library/react';
+import { VirtualTreeViewport } from './VirtualTreeViewport';
+describe('VirtualTreeViewport', () => {
+ it('只渲染可视窗口并在滚动后更新行', () => {
+ const items = Array.from({ length: 1000 }, (_, id) => ({ id, label: `节点 ${id}` }));
+ render(
+ item.id}
+ renderRow={(item) => {item.label}
}
+ />,
+ );
+ const tree = screen.getByRole('tree', { name: '大型树' });
+ expect(screen.getByText('节点 0')).toBeVisible();
+ expect(screen.queryByText('节点 500')).not.toBeInTheDocument();
+ Object.defineProperty(tree, 'scrollTop', { configurable: true, value: 10000 });
+ fireEvent.scroll(tree);
+ expect(screen.getByText('节点 500')).toBeVisible();
+ fireEvent.keyDown(tree, { key: 'ArrowDown' });
+ expect(tree.getAttribute('aria-activedescendant')).toContain('501');
+ });
+});
diff --git a/web_platform/src/components/ui/VirtualTreeViewport.tsx b/web_platform/src/components/ui/VirtualTreeViewport.tsx
index dcffaa55..53392384 100644
--- a/web_platform/src/components/ui/VirtualTreeViewport.tsx
+++ b/web_platform/src/components/ui/VirtualTreeViewport.tsx
@@ -1,2 +1,122 @@
-import {useMemo,useRef,useState,type KeyboardEvent,type ReactNode} from 'react';
-export function VirtualTreeViewport({items,rowHeight=26,height=520,overscan=6,getKey,getLevel=()=>1,isExpandable=()=>false,isExpanded=()=>false,onToggle,onActiveChange,renderRow,label}:{items:T[];rowHeight?:number;height?:number;overscan?:number;getKey:(item:T)=>string|number;getLevel?:(item:T)=>number;isExpandable?:(item:T)=>boolean;isExpanded?:(item:T)=>boolean;onToggle?:(item:T)=>void;onActiveChange?:(item:T)=>void;renderRow:(item:T,index:number)=>ReactNode;label:string}){const root=useRef(null),[scrollTop,setScrollTop]=useState(0),[active,setActive]=useState(0),range=useMemo(()=>{const start=Math.max(0,Math.floor(scrollTop/rowHeight)-overscan),count=Math.ceil(height/rowHeight)+overscan*2;return {start,end:Math.min(items.length,start+count)};},[height,items.length,overscan,rowHeight,scrollTop]),safeActive=Math.min(active,Math.max(0,items.length-1)),activeId=items.length?`${label}-${getKey(items[safeActive])}`:undefined;const activate=(index:number)=>{const next=Math.min(items.length-1,Math.max(0,index));setActive(next);const item=items[next];if(item)onActiveChange?.(item);const viewport=root.current;if(viewport){const top=next*rowHeight;if(topviewport.scrollTop+height)viewport.scrollTop=top+rowHeight-height;}};const key=(event:KeyboardEvent)=>{if(!items.length)return;const item=items[safeActive],level=getLevel(item);if(event.key==='ArrowDown')activate(safeActive+1);else if(event.key==='ArrowUp')activate(safeActive-1);else if(event.key==='Home')activate(0);else if(event.key==='End')activate(items.length-1);else if(event.key==='ArrowRight'&&isExpandable(item)&&!isExpanded(item))onToggle?.(item);else if(event.key==='ArrowLeft'&&isExpandable(item)&&isExpanded(item))onToggle?.(item);else if(event.key==='ArrowLeft'){for(let index=safeActive-1;index>=0;index--)if(getLevel(items[index]){const top=event.currentTarget.scrollTop,first=Math.floor(top/rowHeight),last=first+Math.ceil(height/rowHeight);setScrollTop(top);if(safeActivelast){setActive(first);if(items[first])onActiveChange?.(items[first]);}}}>{items.slice(range.start,range.end).map((item,offset)=>{const index=range.start+offset,expandable=isExpandable(item);return
activate(index)} className={index===safeActive?'bg-accent-soft/60':''} style={{position:'absolute',left:0,right:0,top:index*rowHeight,height:rowHeight}}>{renderRow(item,index)}
;})}
;}
+import { useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
+export function VirtualTreeViewport({
+ items,
+ rowHeight = 26,
+ height = 520,
+ overscan = 6,
+ getKey,
+ getLevel = () => 1,
+ isExpandable = () => false,
+ isExpanded = () => false,
+ onToggle,
+ onActiveChange,
+ renderRow,
+ label,
+}: {
+ items: T[];
+ rowHeight?: number;
+ height?: number;
+ overscan?: number;
+ getKey: (item: T) => string | number;
+ getLevel?: (item: T) => number;
+ isExpandable?: (item: T) => boolean;
+ isExpanded?: (item: T) => boolean;
+ onToggle?: (item: T) => void;
+ onActiveChange?: (item: T) => void;
+ renderRow: (item: T, index: number) => ReactNode;
+ label: string;
+}) {
+ const root = useRef(null),
+ [scrollTop, setScrollTop] = useState(0),
+ [active, setActive] = useState(0),
+ range = useMemo(() => {
+ const start = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan),
+ count = Math.ceil(height / rowHeight) + overscan * 2;
+ return { start, end: Math.min(items.length, start + count) };
+ }, [height, items.length, overscan, rowHeight, scrollTop]),
+ safeActive = Math.min(active, Math.max(0, items.length - 1)),
+ activeId = items.length ? `${label}-${getKey(items[safeActive])}` : undefined;
+ const activate = (index: number) => {
+ const next = Math.min(items.length - 1, Math.max(0, index));
+ setActive(next);
+ const item = items[next];
+ if (item) onActiveChange?.(item);
+ const viewport = root.current;
+ if (viewport) {
+ const top = next * rowHeight;
+ if (top < viewport.scrollTop) viewport.scrollTop = top;
+ else if (top + rowHeight > viewport.scrollTop + height)
+ viewport.scrollTop = top + rowHeight - height;
+ }
+ };
+ const key = (event: KeyboardEvent) => {
+ if (!items.length) return;
+ const item = items[safeActive],
+ level = getLevel(item);
+ if (event.key === 'ArrowDown') activate(safeActive + 1);
+ else if (event.key === 'ArrowUp') activate(safeActive - 1);
+ else if (event.key === 'Home') activate(0);
+ else if (event.key === 'End') activate(items.length - 1);
+ else if (event.key === 'ArrowRight' && isExpandable(item) && !isExpanded(item))
+ onToggle?.(item);
+ else if (event.key === 'ArrowLeft' && isExpandable(item) && isExpanded(item)) onToggle?.(item);
+ else if (event.key === 'ArrowLeft') {
+ for (let index = safeActive - 1; index >= 0; index--)
+ if (getLevel(items[index]) < level) {
+ activate(index);
+ break;
+ }
+ } else if ((event.key === 'Enter' || event.key === ' ') && isExpandable(item)) onToggle?.(item);
+ else return;
+ event.preventDefault();
+ };
+ return (
+ {
+ const top = event.currentTarget.scrollTop,
+ first = Math.floor(top / rowHeight),
+ last = first + Math.ceil(height / rowHeight);
+ setScrollTop(top);
+ if (safeActive < first || safeActive > last) {
+ setActive(first);
+ if (items[first]) onActiveChange?.(items[first]);
+ }
+ }}
+ >
+
+ {items.slice(range.start, range.end).map((item, offset) => {
+ const index = range.start + offset,
+ expandable = isExpandable(item);
+ return (
+
activate(index)}
+ className={index === safeActive ? 'bg-accent-soft/60' : ''}
+ style={{
+ position: 'absolute',
+ left: 0,
+ right: 0,
+ top: index * rowHeight,
+ height: rowHeight,
+ }}
+ >
+ {renderRow(item, index)}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/web_platform/src/controller/PythonControllerRuntime.ts b/web_platform/src/controller/PythonControllerRuntime.ts
index c4e39ac2..dbb79e54 100644
--- a/web_platform/src/controller/PythonControllerRuntime.ts
+++ b/web_platform/src/controller/PythonControllerRuntime.ts
@@ -1,135 +1,197 @@
-import type {PyodideInterface} from 'pyodide';
-import type {PyCallable,PyDict} from 'pyodide/ffi';
-import type {ControllerBindings,ControllerCommand,ControllerStatus} from './types';
+import type { PyodideInterface } from 'pyodide';
+import type { PyCallable, PyDict } from 'pyodide/ffi';
+import type { ControllerBindings, ControllerCommand, ControllerStatus } from './types';
-const DEFAULT_CONTROL_HZ=100;
-const MIN_CONTROL_HZ=1;
-const MAX_CONTROL_HZ=500;
+const DEFAULT_CONTROL_HZ = 100;
+const MIN_CONTROL_HZ = 1;
+const MAX_CONTROL_HZ = 500;
-let pyodidePromise:Promise|undefined;
+let pyodidePromise: Promise | undefined;
-function pyodideIndexUrl():string {
- return new URL('pyodide/',document.baseURI).href;
+function pyodideIndexUrl(): string {
+ return new URL('pyodide/', document.baseURI).href;
}
-export function getPythonRuntime():Promise {
- pyodidePromise??=import('pyodide').then(({loadPyodide})=>loadPyodide({indexURL:pyodideIndexUrl()}));
+export function getPythonRuntime(): Promise {
+ pyodidePromise ??= import('pyodide').then(({ loadPyodide }) =>
+ loadPyodide({ indexURL: pyodideIndexUrl() }),
+ );
return pyodidePromise;
}
-function destroyProxy(value:unknown):void {
- if(value&&typeof value==='object'&&'destroy' in value&&typeof (value as {destroy?:unknown}).destroy==='function'){
- (value as {destroy():void}).destroy();
+function destroyProxy(value: unknown): void {
+ if (
+ value &&
+ typeof value === 'object' &&
+ 'destroy' in value &&
+ typeof (value as { destroy?: unknown }).destroy === 'function'
+ ) {
+ (value as { destroy(): void }).destroy();
}
}
-function errorMessage(error:unknown):string {
- return error instanceof Error?error.message:String(error);
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
}
/** 在主线程同步执行可信的单文件 Python 控制器,保证控制发生在 mj_step 之前。 */
export class PythonControllerRuntime {
- private globals?:PyDict;
- private initFunction?:PyCallable;
- private stepFunction?:PyCallable;
- private resetFunction?:PyCallable;
- private commandFunction?:PyCallable;
- private disposeFunction?:PyCallable;
- private state?:unknown;
- private nextControlTime=0;
- private statusValue:ControllerStatus;
+ private globals?: PyDict;
+ private initFunction?: PyCallable;
+ private stepFunction?: PyCallable;
+ private resetFunction?: PyCallable;
+ private commandFunction?: PyCallable;
+ private disposeFunction?: PyCallable;
+ private state?: unknown;
+ private nextControlTime = 0;
+ private statusValue: ControllerStatus;
- private constructor(private readonly bindings:ControllerBindings,path:string,name:string,controlHz:number){
- this.statusValue={language:'python',path,name,controlHz,loaded:true,enabled:false,acceptsCommands:false,lastStepMs:0};
+ private constructor(
+ private readonly bindings: ControllerBindings,
+ path: string,
+ name: string,
+ controlHz: number,
+ ) {
+ this.statusValue = {
+ language: 'python',
+ path,
+ name,
+ controlHz,
+ loaded: true,
+ enabled: false,
+ acceptsCommands: false,
+ lastStepMs: 0,
+ };
}
- static async load(source:string,path:string,bindings:ControllerBindings):Promise{
- const pyodide=await getPythonRuntime();
- const globals=pyodide.runPython('dict()') as PyDict;
- globals.set('__name__','__mujoco_controller__');
- try{
- await pyodide.runPythonAsync(source,{globals});
- if(!globals.has('step'))throw new Error('Python 控制器必须定义 step(ctx, state)');
- const rawHz=globals.has('CONTROL_HZ')?Number(globals.get('CONTROL_HZ')):DEFAULT_CONTROL_HZ;
- const controlHz=Math.min(MAX_CONTROL_HZ,Math.max(MIN_CONTROL_HZ,Number.isFinite(rawHz)?rawHz:DEFAULT_CONTROL_HZ));
- const name=globals.has('NAME')?String(globals.get('NAME')):path.split('/').at(-1)??path;
- const runtime=new PythonControllerRuntime(bindings,path,name,controlHz);
- runtime.globals=globals;
- runtime.initFunction=globals.has('init')?globals.get('init') as PyCallable:undefined;
- runtime.stepFunction=globals.get('step') as PyCallable;
- runtime.resetFunction=globals.has('reset')?globals.get('reset') as PyCallable:undefined;
- runtime.commandFunction=globals.has('command')?globals.get('command') as PyCallable:undefined;
- runtime.statusValue.acceptsCommands=Boolean(runtime.commandFunction);
- runtime.disposeFunction=globals.has('dispose')?globals.get('dispose') as PyCallable:undefined;
- runtime.state=runtime.initFunction?.(bindings.model);
- if(runtime.state instanceof Promise)throw new Error('控制器函数必须同步执行');
+ static async load(
+ source: string,
+ path: string,
+ bindings: ControllerBindings,
+ ): Promise {
+ const pyodide = await getPythonRuntime();
+ const globals = pyodide.runPython('dict()') as PyDict;
+ globals.set('__name__', '__mujoco_controller__');
+ try {
+ await pyodide.runPythonAsync(source, { globals });
+ if (!globals.has('step')) throw new Error('Python 控制器必须定义 step(ctx, state)');
+ const rawHz = globals.has('CONTROL_HZ')
+ ? Number(globals.get('CONTROL_HZ'))
+ : DEFAULT_CONTROL_HZ;
+ const controlHz = Math.min(
+ MAX_CONTROL_HZ,
+ Math.max(MIN_CONTROL_HZ, Number.isFinite(rawHz) ? rawHz : DEFAULT_CONTROL_HZ),
+ );
+ const name = globals.has('NAME')
+ ? String(globals.get('NAME'))
+ : (path.split('/').at(-1) ?? path);
+ const runtime = new PythonControllerRuntime(bindings, path, name, controlHz);
+ runtime.globals = globals;
+ runtime.initFunction = globals.has('init') ? (globals.get('init') as PyCallable) : undefined;
+ runtime.stepFunction = globals.get('step') as PyCallable;
+ runtime.resetFunction = globals.has('reset')
+ ? (globals.get('reset') as PyCallable)
+ : undefined;
+ runtime.commandFunction = globals.has('command')
+ ? (globals.get('command') as PyCallable)
+ : undefined;
+ runtime.statusValue.acceptsCommands = Boolean(runtime.commandFunction);
+ runtime.disposeFunction = globals.has('dispose')
+ ? (globals.get('dispose') as PyCallable)
+ : undefined;
+ runtime.state = runtime.initFunction?.(bindings.model);
+ if (runtime.state instanceof Promise) throw new Error('控制器函数必须同步执行');
return runtime;
- }catch(error){
+ } catch (error) {
globals.destroy();
- throw new Error(`Python 控制器加载失败(${path}):${errorMessage(error)}`,{cause:error});
+ throw new Error(`Python 控制器加载失败(${path}):${errorMessage(error)}`, { cause: error });
}
}
- status():ControllerStatus{return {...this.statusValue};}
-
- setEnabled(enabled:boolean,currentTime:number):void {
- if(!this.statusValue.loaded)return;
- this.statusValue.enabled=enabled;
- this.statusValue.error=undefined;
- this.nextControlTime=currentTime;
- if(!enabled)this.statusValue.activeCommand=undefined;
+ status(): ControllerStatus {
+ return { ...this.statusValue };
}
- command(command:ControllerCommand):void {
- if(!this.statusValue.enabled)throw new Error('请先启用 Python 控制器');
- if(!this.commandFunction)throw new Error('当前 Python 控制器未定义 command(name, state)');
- try{
- const result=this.commandFunction(command,this.state);
- if(result instanceof Promise)throw new Error('command() 必须是同步函数');
+ setEnabled(enabled: boolean, currentTime: number): void {
+ if (!this.statusValue.loaded) return;
+ this.statusValue.enabled = enabled;
+ this.statusValue.error = undefined;
+ this.nextControlTime = currentTime;
+ if (!enabled) this.statusValue.activeCommand = undefined;
+ }
+
+ command(command: ControllerCommand): void {
+ if (!this.statusValue.enabled) throw new Error('请先启用 Python 控制器');
+ if (!this.commandFunction) throw new Error('当前 Python 控制器未定义 command(name, state)');
+ try {
+ const result = this.commandFunction(command, this.state);
+ if (result instanceof Promise) throw new Error('command() 必须是同步函数');
destroyProxy(result);
- this.statusValue.activeCommand=command==='jump'?'stop':command;
- this.statusValue.error=undefined;
- }catch(error){
- this.statusValue.error=errorMessage(error);
- throw new Error(`Python 控制指令失败:${this.statusValue.error}`,{cause:error});
+ this.statusValue.activeCommand = command === 'jump' ? 'stop' : command;
+ this.statusValue.error = undefined;
+ } catch (error) {
+ this.statusValue.error = errorMessage(error);
+ throw new Error(`Python 控制指令失败:${this.statusValue.error}`, { cause: error });
}
}
- stepIfDue(time:number):void {
- if(!this.statusValue.enabled||!this.stepFunction||time+1e-9需要本地 HTTP 服务器
请运行 npm run dev,不能直接通过 file:// 打开。
';else createRoot(document.getElementById('root')!).render();
+if (location.protocol === 'file:')
+ document.body.innerHTML =
+ '需要本地 HTTP 服务器
请运行 npm run dev,不能直接通过 file:// 打开。
';
+else
+ createRoot(document.getElementById('root')!).render(
+
+
+
+
+ ,
+ );
diff --git a/web_platform/src/map/MapComposer.test.ts b/web_platform/src/map/MapComposer.test.ts
new file mode 100644
index 00000000..0306ce27
--- /dev/null
+++ b/web_platform/src/map/MapComposer.test.ts
@@ -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(`
+
+
+
+
+
+
+`);
+const mapXml = `
+
+
+
+`;
+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(
+ '',
+ );
+ 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(
+ '',
+ );
+ 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(
+ '',
+ );
+ expect(() =>
+ composeProjectMap(robot, 'robot/scene.xml', angleMap, definition, {
+ kind: 'project',
+ descriptorPath: definition.descriptorPath,
+ }),
+ ).toThrow('compiler');
+ const eulerMap = manifest(
+ '',
+ );
+ expect(() =>
+ composeProjectMap(robot, 'robot/scene.xml', eulerMap, definition, {
+ kind: 'project',
+ descriptorPath: definition.descriptorPath,
+ }),
+ ).toThrow('姿态必须使用 quat');
+ const zaxisRobot = encoder.encode(
+ '',
+ );
+ expect(() =>
+ composeProjectMap(zaxisRobot, 'robot/scene.xml', manifest(), definition, {
+ kind: 'project',
+ descriptorPath: definition.descriptorPath,
+ spawnPointId: 'door',
+ }),
+ ).toThrow('zaxis');
+ });
+
+ it('拒绝动态地图和缺失资源', () => {
+ const dynamic = manifest(
+ '',
+ );
+ 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('资源不存在');
+ });
+});
diff --git a/web_platform/src/map/MapComposer.ts b/web_platform/src/map/MapComposer.ts
new file mode 100644
index 00000000..ef867d6a
--- /dev/null
+++ b/web_platform/src/map/MapComposer.ts
@@ -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();
+ 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,
+): 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 视觉层' : ''})`,
+ };
+}
diff --git a/web_platform/src/map/MapLoader.test.ts b/web_platform/src/map/MapLoader.test.ts
new file mode 100644
index 00000000..6777c705
--- /dev/null
+++ b/web_platform/src/map/MapLoader.test.ts
@@ -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', ''),
+ 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 重复');
+ });
+});
diff --git a/web_platform/src/map/MapLoader.ts b/web_platform/src/map/MapLoader.ts
new file mode 100644
index 00000000..5f7337c5
--- /dev/null
+++ b/web_platform/src/map/MapLoader.ts
@@ -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();
+ 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,
+ };
+}
diff --git a/web_platform/src/map/editor/MapDocumentCompiler.ts b/web_platform/src/map/editor/MapDocumentCompiler.ts
new file mode 100644
index 00000000..8c11f087
--- /dev/null
+++ b/web_platform/src/map/editor/MapDocumentCompiler.ts
@@ -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 [
+ ``,
+ ];
+ if (object.type === 'cylinder')
+ return [``];
+ if (object.type === 'capsule')
+ return [``];
+ if (object.type === 'ramp') {
+ const angle = Math.atan2(p.rise, p.length),
+ half = angle / 2;
+ return [
+ ``,
+ ];
+ }
+ return Array.from({ length: p.count }, (_, index) => {
+ const height = p.stepHeight * (index + 1);
+ // 每级使用等深、逐级增高的独立立方柱。旧实现同时增大踏步深度,
+ // 导致最后一级完全包住前面所有级,编译后看起来只是一个方块。
+ const x = p.stepDepth * index;
+ return ``;
+ });
+}
+
+/** 将受约束编辑文档确定性编译成只含静态 geom 的 MJCF。 */
+export function compileEditableMapDocument(input: EditableMapDocument): Uint8Array {
+ const document = parseEditableMapDocument(input);
+ const bodies = document.objects
+ .filter((object) => object.enabled)
+ .map(
+ (object) =>
+ ` \n ${geomXml(object).join('\n ')}\n `,
+ );
+ return new TextEncoder().encode(
+ `\n \n${bodies.join('\n')}\n \n\n`,
+ );
+}
diff --git a/web_platform/src/map/editor/MapDocumentImporter.test.ts b/web_platform/src/map/editor/MapDocumentImporter.test.ts
new file mode 100644
index 00000000..4610c93a
--- /dev/null
+++ b/web_platform/src/map/editor/MapDocumentImporter.test.ts
@@ -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(`
+
+
+
+
+ `),
+ 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', '', '类型 plane'],
+ ['碰撞过滤', '', '属性 contype'],
+ [
+ 'asset',
+ '',
+ '带 asset',
+ ],
+ ])('拒绝不可逆的%s转换', (_label, content, message) => {
+ const xml = content.includes('')
+ ? `${content}`
+ : `${content}`;
+ expect(() => importEditableMapDocument(encode(xml), definition)).toThrow(message);
+ });
+});
diff --git a/web_platform/src/map/editor/MapDocumentImporter.ts b/web_platform/src/map/editor/MapDocumentImporter.ts
new file mode 100644
index 00000000..c390cb38
--- /dev/null
+++ b/web_platform/src/map/editor/MapDocumentImporter.ts
@@ -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, 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 {
+ 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,
+ 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 =
+ 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,
+): 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();
+ 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),
+ });
+}
diff --git a/web_platform/src/map/editor/MapEditSession.ts b/web_platform/src/map/editor/MapEditSession.ts
new file mode 100644
index 00000000..6bbb604b
--- /dev/null
+++ b/web_platform/src/map/editor/MapEditSession.ts
@@ -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>): 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> & { 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>): 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 = [];
+ }
+}
diff --git a/web_platform/src/map/editor/MapPackageExporter.ts b/web_platform/src/map/editor/MapPackageExporter.ts
new file mode 100644
index 00000000..97e5c490
--- /dev/null
+++ b/web_platform/src/map/editor/MapPackageExporter.ts
@@ -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 = {};
+ 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 });
+}
diff --git a/web_platform/src/map/editor/assetCatalog.ts b/web_platform/src/map/editor/assetCatalog.ts
new file mode 100644
index 00000000..6b1d1141
--- /dev/null
+++ b/web_platform/src/map/editor/assetCatalog.ts
@@ -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;
+}
diff --git a/web_platform/src/map/editor/editor.test.ts b/web_platform/src/map/editor/editor.test.ts
new file mode 100644
index 00000000..b60a18c6
--- /dev/null
+++ b/web_platform/src/map/editor/editor.test.ts
@@ -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(' 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',
+ '',
+ ),
+ file('maps/w/physics/meshes/wall.obj', 'v 0 0 0'),
+ file('robot.xml', ''),
+ ];
+ 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',
+ ]);
+ });
+});
diff --git a/web_platform/src/map/editor/editorSchema.ts b/web_platform/src/map/editor/editorSchema.ts
new file mode 100644
index 00000000..42835637
--- /dev/null
+++ b/web_platform/src/map/editor/editorSchema.ts
@@ -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([
+ 'box',
+ 'cylinder',
+ 'capsule',
+ 'ramp',
+ 'stairs',
+]);
+const legacyRoles = new Set(['auto', 'walkable', 'obstacle', 'ignore']);
+const placementModes = new Set(['auto_ground', 'gravity', 'locked']);
+function record(value: unknown, field: string): Record {
+ if (!value || typeof value !== 'object' || Array.isArray(value))
+ throw new MapValidationError(`${field} 必须是对象`);
+ return value as Record;
+}
+function exact(value: Record, 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 = {};
+ 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();
+ 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`);
+}
diff --git a/web_platform/src/map/editor/placement.ts b/web_platform/src/map/editor/placement.ts
new file mode 100644
index 00000000..899cc3cd
--- /dev/null
+++ b/web_platform/src/map/editor/placement.ts
@@ -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)];
+}
diff --git a/web_platform/src/map/editor/types.ts b/web_platform/src/map/editor/types.ts
new file mode 100644
index 00000000..01134e86
--- /dev/null
+++ b/web_platform/src/map/editor/types.ts
@@ -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 = {
+ 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;
+ 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> = {
+ 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,
+ };
+}
diff --git a/web_platform/src/map/mapPaths.test.ts b/web_platform/src/map/mapPaths.test.ts
new file mode 100644
index 00000000..a3906402
--- /dev/null
+++ b/web_platform/src/map/mapPaths.test.ts
@@ -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();
+ });
+});
diff --git a/web_platform/src/map/mapPaths.ts b/web_platform/src/map/mapPaths.ts
new file mode 100644
index 00000000..db0bcb9f
--- /dev/null
+++ b/web_platform/src/map/mapPaths.ts
@@ -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('/')}` || './';
+}
diff --git a/web_platform/src/map/mapSchema.ts b/web_platform/src/map/mapSchema.ts
new file mode 100644
index 00000000..f20e65e0
--- /dev/null
+++ b/web_platform/src/map/mapSchema.ts
@@ -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 {
+ if (!value || typeof value !== 'object' || Array.isArray(value))
+ throw new MapValidationError(`${field} 必须是对象`);
+ return value as Record;
+}
+
+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();
+ 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);
+}
diff --git a/web_platform/src/map/physicalMap.test.ts b/web_platform/src/map/physicalMap.test.ts
new file mode 100644
index 00000000..547712fe
--- /dev/null
+++ b/web_platform/src/map/physicalMap.test.ts
@@ -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 {
+ 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('');
+ const result = composePhysicalMap(source, config({ preset: 'none' }));
+ expect(result.data).toBe(source);
+ expect(result.geomCount).toBe(0);
+ });
+
+ it('复用现有地面并注入楼梯碰撞几何', () => {
+ const source = encoder.encode(
+ '',
+ );
+ 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('');
+ 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('');
+ 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('');
+ 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(
+ '',
+ );
+ 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(
+ '',
+ );
+ 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(''), config({ preset: 'flat' })),
+ ).toThrow('MJCF 缺少 worldbody');
+ });
+});
diff --git a/web_platform/src/map/physicalMap.ts b/web_platform/src/map/physicalMap.ts
new file mode 100644
index 00000000..7b575a0b
--- /dev/null
+++ b/web_platform/src/map/physicalMap.ts
@@ -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 = {
+ 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})`,
+ };
+}
diff --git a/web_platform/src/map/terrainGenerator.test.ts b/web_platform/src/map/terrainGenerator.test.ts
new file mode 100644
index 00000000..f2f66887
--- /dev/null
+++ b/web_platform/src/map/terrainGenerator.test.ts
@@ -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);
+ });
+});
diff --git a/web_platform/src/map/terrainGenerator.ts b/web_platform/src/map/terrainGenerator.ts
new file mode 100644
index 00000000..dde1b8a3
--- /dev/null
+++ b/web_platform/src/map/terrainGenerator.ts
@@ -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,
+ 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,
+ 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 };
+}
diff --git a/web_platform/src/map/types.ts b/web_platform/src/map/types.ts
new file mode 100644
index 00000000..7db6abab
--- /dev/null
+++ b/web_platform/src/map/types.ts
@@ -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 = {
+ none: '不使用地图',
+ flat: '平地',
+ slope: '坡道',
+ stairs: '楼梯',
+ obstacles: '随机障碍物',
+ discrete_obstacles: '离散障碍地形',
+ gap: '沟壑地形',
+ inverted_pyramid_stairs: '倒金字塔阶梯',
+ pit: '深坑地形',
+ pyramid_stairs: '金字塔阶梯',
+ rails: '轨道地形',
+ rough: '随机粗糙地形',
+ stepping_stones: '踏石地形',
+ wave: '波浪地形',
+};
diff --git a/web_platform/src/project/ModelStructureTree.test.tsx b/web_platform/src/project/ModelStructureTree.test.tsx
index 35747146..36344727 100644
--- a/web_platform/src/project/ModelStructureTree.test.tsx
+++ b/web_platform/src/project/ModelStructureTree.test.tsx
@@ -1,24 +1,64 @@
-import {fireEvent,render,screen} from '@testing-library/react';
-import {buildBodyTree,countModelStructureSearchResults,ModelStructureTree} from './ModelStructureTree';
-import type {BodyInfo,JointInfo} from '../simulation/SimulationSession';
+import { fireEvent, render, screen } from '@testing-library/react';
+import {
+ buildBodyTree,
+ countModelStructureSearchResults,
+ ModelStructureTree,
+} from './ModelStructureTree';
+import type { BodyInfo, JointInfo } from '../simulation/SimulationSession';
-const bodies:BodyInfo[]=[{id:0,name:'world',parentId:0},{id:1,name:'base',parentId:0},{id:2,name:'arm',parentId:1}];
-const joints:JointInfo[]=[{id:0,name:'arm_joint',type:3,value:0,min:-1,max:1,limitMin:-1,limitMax:1,limited:true,limitsIgnored:false,editable:true,bodyId:2,axis:[0,0,1]}];
+const bodies: BodyInfo[] = [
+ { id: 0, name: 'world', parentId: 0 },
+ { id: 1, name: 'base', parentId: 0 },
+ { id: 2, name: 'arm', parentId: 1 },
+];
+const joints: JointInfo[] = [
+ {
+ id: 0,
+ name: 'arm_joint',
+ type: 3,
+ value: 0,
+ min: -1,
+ max: 1,
+ limitMin: -1,
+ limitMax: 1,
+ limited: true,
+ limitsIgnored: false,
+ editable: true,
+ bodyId: 2,
+ axis: [0, 0, 1],
+ },
+];
-describe('ModelStructureTree',()=>{
- it('按 body 父子关系构建结构,并将关节放在所属 body 下',()=>{
- const tree=buildBodyTree(bodies,joints);
- expect(tree[0]).toMatchObject({id:1,name:'base'});
- expect(tree[0].children[0]).toMatchObject({id:2,name:'arm'});
+describe('ModelStructureTree', () => {
+ it('按 body 父子关系构建结构,并将关节放在所属 body 下', () => {
+ const tree = buildBodyTree(bodies, joints);
+ expect(tree[0]).toMatchObject({ id: 1, name: 'base' });
+ expect(tree[0].children[0]).toMatchObject({ id: 2, name: 'arm' });
expect(tree[0].children[0].joints[0].name).toBe('arm_joint');
});
- it('鼠标进入和离开关节时通知查看器高亮',()=>{
- const hover=vi.fn();render();expect(screen.getByRole('treeitem',{name:'base'})).toHaveAttribute('aria-expanded','true');const item=screen.getByRole('treeitem',{name:/arm_joint/});
- fireEvent.mouseEnter(item);fireEvent.mouseLeave(item);expect(hover.mock.calls).toEqual([[0],[null]]);
+ it('鼠标进入和离开关节时通知查看器高亮', () => {
+ const hover = vi.fn();
+ render();
+ expect(screen.getByRole('treeitem', { name: 'base' })).toHaveAttribute('aria-expanded', 'true');
+ const item = screen.getByRole('treeitem', { name: /arm_joint/ });
+ fireEvent.mouseEnter(item);
+ fireEvent.mouseLeave(item);
+ expect(hover.mock.calls).toEqual([[0], [null]]);
});
- it('按 Body 或关节名称过滤并保留祖先路径',()=>{
- render({}}/>);expect(screen.getByRole('treeitem',{name:'base'})).toBeVisible();expect(screen.getByRole('treeitem',{name:/arm_joint/})).toBeVisible();expect(countModelStructureSearchResults(bodies,joints,'arm_joint')).toBe(3);expect(countModelStructureSearchResults(bodies,joints,'world')).toBe(0);
+ it('按 Body 或关节名称过滤并保留祖先路径', () => {
+ render(
+ {}}
+ />,
+ );
+ expect(screen.getByRole('treeitem', { name: 'base' })).toBeVisible();
+ expect(screen.getByRole('treeitem', { name: /arm_joint/ })).toBeVisible();
+ expect(countModelStructureSearchResults(bodies, joints, 'arm_joint')).toBe(3);
+ expect(countModelStructureSearchResults(bodies, joints, 'world')).toBe(0);
});
});
diff --git a/web_platform/src/project/ModelStructureTree.tsx b/web_platform/src/project/ModelStructureTree.tsx
index 4de34292..02f6ea02 100644
--- a/web_platform/src/project/ModelStructureTree.tsx
+++ b/web_platform/src/project/ModelStructureTree.tsx
@@ -1,32 +1,257 @@
-import {useState} from 'react';
-import {Box,Disc3} from 'lucide-react';
-import type {BodyInfo,JointInfo} from '../simulation/SimulationSession';
-import {EmptySearchState,SearchHighlight,VirtualTreeViewport} from '../components/ui';
+import { useState } from 'react';
+import { Box, Disc3 } from 'lucide-react';
+import type { BodyInfo, JointInfo } from '../simulation/SimulationSession';
+import { EmptySearchState, SearchHighlight, VirtualTreeViewport } from '../components/ui';
-interface BodyNode extends BodyInfo {children:BodyNode[];joints:JointInfo[];}
+interface BodyNode extends BodyInfo {
+ children: BodyNode[];
+ joints: JointInfo[];
+}
// eslint-disable-next-line react-refresh/only-export-components
-export function buildBodyTree(bodies:BodyInfo[],joints:JointInfo[]):BodyNode[]{
- const nodes=new Map();for(const body of bodies)if(body.id>0)nodes.set(body.id,{...body,children:[],joints:joints.filter(joint=>joint.bodyId===body.id)});
- const roots:BodyNode[]=[];
- for(const node of nodes.values()){const parent=nodes.get(node.parentId);if(parent)parent.children.push(node);else roots.push(node);}
- const sort=(items:BodyNode[])=>{items.sort((a,b)=>a.id-b.id);for(const item of items)sort(item.children);};sort(roots);return roots;
+export function buildBodyTree(bodies: BodyInfo[], joints: JointInfo[]): BodyNode[] {
+ const nodes = new Map();
+ for (const body of bodies)
+ if (body.id > 0)
+ nodes.set(body.id, {
+ ...body,
+ children: [],
+ joints: joints.filter((joint) => joint.bodyId === body.id),
+ });
+ const roots: BodyNode[] = [];
+ for (const node of nodes.values()) {
+ const parent = nodes.get(node.parentId);
+ if (parent) parent.children.push(node);
+ else roots.push(node);
+ }
+ const sort = (items: BodyNode[]) => {
+ items.sort((a, b) => a.id - b.id);
+ for (const item of items) sort(item.children);
+ };
+ sort(roots);
+ return roots;
}
-function BodyBranch({node,depth,onJointHover,searching,query}:{node:BodyNode;depth:number;onJointHover:(jointId:number|null)=>void;searching:boolean;query:string}){
- const hasChildren=node.joints.length>0||node.children.length>0;const [open,setOpen]=useState(depth<2),shownOpen=searching||open;
- return {hasChildren?{if(!searching)setOpen(event.currentTarget.open);}}>{if(searching)event.preventDefault();}} className="flex cursor-pointer select-none items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary hover:bg-element-hover focus-visible:ring-2 focus-visible:ring-accent/30">
{node.joints.map(joint=>- onJointHover(joint.id)} onMouseLeave={()=>onJointHover(null)} onFocus={()=>onJointHover(joint.id)} onBlur={()=>onJointHover(null)} title={`关节:${joint.name}`}>
)}{node.children.map(child=>)}
:
};
+function BodyBranch({
+ node,
+ depth,
+ onJointHover,
+ searching,
+ query,
+}: {
+ node: BodyNode;
+ depth: number;
+ onJointHover: (jointId: number | null) => void;
+ searching: boolean;
+ query: string;
+}) {
+ const hasChildren = node.joints.length > 0 || node.children.length > 0;
+ const [open, setOpen] = useState(depth < 2),
+ shownOpen = searching || open;
+ return (
+
+ {hasChildren ? (
+ {
+ if (!searching) setOpen(event.currentTarget.open);
+ }}
+ >
+ {
+ if (searching) event.preventDefault();
+ }}
+ className="flex cursor-pointer select-none items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary hover:bg-element-hover focus-visible:ring-2 focus-visible:ring-accent/30"
+ >
+
+
+
+
+
+
+ {node.joints.map((joint) => (
+ -
+ onJointHover(joint.id)}
+ onMouseLeave={() => onJointHover(null)}
+ onFocus={() => onJointHover(joint.id)}
+ onBlur={() => onJointHover(null)}
+ title={`关节:${joint.name}`}
+ >
+
+
+
+
+ ))}
+ {node.children.map((child) => (
+
+ ))}
+
+
+ ) : (
+
+
+
+
+ )}
+
+ );
}
-function filterBodies(nodes:BodyNode[],query:string):BodyNode[]{if(!query)return nodes;return nodes.flatMap(node=>{if(node.name.toLocaleLowerCase().includes(query))return [node];const joints=node.joints.filter(joint=>joint.name.toLocaleLowerCase().includes(query)),children=filterBodies(node.children,query);return joints.length||children.length?[{...node,joints,children}]:[];});}
-function countBodyNodes(nodes:BodyNode[]):number{return nodes.reduce((total,node)=>total+1+node.joints.length+countBodyNodes(node.children),0);}
+function filterBodies(nodes: BodyNode[], query: string): BodyNode[] {
+ if (!query) return nodes;
+ return nodes.flatMap((node) => {
+ if (node.name.toLocaleLowerCase().includes(query)) return [node];
+ const joints = node.joints.filter((joint) => joint.name.toLocaleLowerCase().includes(query)),
+ children = filterBodies(node.children, query);
+ return joints.length || children.length ? [{ ...node, joints, children }] : [];
+ });
+}
+function countBodyNodes(nodes: BodyNode[]): number {
+ return nodes.reduce(
+ (total, node) => total + 1 + node.joints.length + countBodyNodes(node.children),
+ 0,
+ );
+}
// eslint-disable-next-line react-refresh/only-export-components
-export function countModelStructureSearchResults(bodies:BodyInfo[],joints:JointInfo[],query:string):number{return countBodyNodes(filterBodies(buildBodyTree(bodies,joints),query.trim().toLocaleLowerCase()));}
-type FlatBodyItem={kind:'body';body:BodyNode;depth:number}|{kind:'joint';joint:JointInfo;depth:number};
-function flattenBodies(nodes:BodyNode[],expanded:Set,searching:boolean,depth=0):FlatBodyItem[]{return nodes.flatMap(body=>[{kind:'body' as const,body,depth},...(searching||expanded.has(body.id)?[...body.joints.map(joint=>({kind:'joint' as const,joint,depth:depth+1})),...flattenBodies(body.children,expanded,searching,depth+1)]:[])]);}
-function initiallyExpanded(nodes:BodyNode[],depth=0):number[]{return nodes.flatMap(body=>[...(depth<2?[body.id]:[]),...initiallyExpanded(body.children,depth+1)]);}
-export function ModelStructureTree({bodies,joints,onJointHover,query=''}:{bodies:BodyInfo[];joints:JointInfo[];onJointHover:(jointId:number|null)=>void;query?:string}){
- const normalized=query.trim().toLocaleLowerCase(),roots=filterBodies(buildBodyTree(bodies,joints),normalized),[virtualExpanded,setVirtualExpanded]=useState(()=>new Set(initiallyExpanded(buildBodyTree(bodies,joints))));
- if(bodies.length+joints.length>500&&roots.length){const searching=Boolean(normalized),flat=flattenBodies(roots,virtualExpanded,searching),toggle=(item:FlatBodyItem)=>{if(item.kind!=='body'||searching)return;setVirtualExpanded(current=>{const next=new Set(current);if(next.has(item.body.id))next.delete(item.body.id);else next.add(item.body.id);return next;});};return ;}
- return ;
+export function countModelStructureSearchResults(
+ bodies: BodyInfo[],
+ joints: JointInfo[],
+ query: string,
+): number {
+ return countBodyNodes(
+ filterBodies(buildBodyTree(bodies, joints), query.trim().toLocaleLowerCase()),
+ );
+}
+type FlatBodyItem =
+ | { kind: 'body'; body: BodyNode; depth: number }
+ | { kind: 'joint'; joint: JointInfo; depth: number };
+function flattenBodies(
+ nodes: BodyNode[],
+ expanded: Set,
+ searching: boolean,
+ depth = 0,
+): FlatBodyItem[] {
+ return nodes.flatMap((body) => [
+ { kind: 'body' as const, body, depth },
+ ...(searching || expanded.has(body.id)
+ ? [
+ ...body.joints.map((joint) => ({ kind: 'joint' as const, joint, depth: depth + 1 })),
+ ...flattenBodies(body.children, expanded, searching, depth + 1),
+ ]
+ : []),
+ ]);
+}
+function initiallyExpanded(nodes: BodyNode[], depth = 0): number[] {
+ return nodes.flatMap((body) => [
+ ...(depth < 2 ? [body.id] : []),
+ ...initiallyExpanded(body.children, depth + 1),
+ ]);
+}
+export function ModelStructureTree({
+ bodies,
+ joints,
+ onJointHover,
+ query = '',
+}: {
+ bodies: BodyInfo[];
+ joints: JointInfo[];
+ onJointHover: (jointId: number | null) => void;
+ query?: string;
+}) {
+ const normalized = query.trim().toLocaleLowerCase(),
+ roots = filterBodies(buildBodyTree(bodies, joints), normalized),
+ [virtualExpanded, setVirtualExpanded] = useState(
+ () => new Set(initiallyExpanded(buildBodyTree(bodies, joints))),
+ );
+ if (bodies.length + joints.length > 500 && roots.length) {
+ const searching = Boolean(normalized),
+ flat = flattenBodies(roots, virtualExpanded, searching),
+ toggle = (item: FlatBodyItem) => {
+ if (item.kind !== 'body' || searching) return;
+ setVirtualExpanded((current) => {
+ const next = new Set(current);
+ if (next.has(item.body.id)) next.delete(item.body.id);
+ else next.add(item.body.id);
+ return next;
+ });
+ };
+ return (
+
+ );
+ }
+ return (
+
+ );
}
diff --git a/web_platform/src/project/ProjectTree.test.tsx b/web_platform/src/project/ProjectTree.test.tsx
index c7882299..2cdec5d2 100644
--- a/web_platform/src/project/ProjectTree.test.tsx
+++ b/web_platform/src/project/ProjectTree.test.tsx
@@ -1,27 +1,44 @@
-import {fireEvent,render,screen,within} from '@testing-library/react';
-import {buildProjectTree,countProjectSearchResults,ProjectTree} from './ProjectTree';
+import { fireEvent, render, screen, within } from '@testing-library/react';
+import { buildProjectTree, countProjectSearchResults, ProjectTree } from './ProjectTree';
-const files=[
- {path:'robot/meshes/arm.obj',size:2048},
- {path:'robot/model.xml',size:512},
- {path:'robot/textures/body.png',size:4096},
- {path:'README.txt',size:10},
+const files = [
+ { path: 'robot/meshes/arm.obj', size: 2048 },
+ { path: 'robot/model.xml', size: 512 },
+ { path: 'robot/textures/body.png', size: 4096 },
+ { path: 'README.txt', size: 10 },
];
-describe('ProjectTree',()=>{
- it('按路径构建多级目录,并将目录排在文件前面',()=>{
- const tree=buildProjectTree(files);
- expect(tree.map(node=>[node.kind,node.name])).toEqual([['directory','robot'],['file','README.txt']]);
- const robot=tree[0];
- expect(robot.children?.map(node=>[node.kind,node.name])).toEqual([
- ['directory','meshes'],['directory','textures'],['file','model.xml'],
+describe('ProjectTree', () => {
+ it('按路径构建多级目录,并将目录排在文件前面', () => {
+ const tree = buildProjectTree(files);
+ expect(tree.map((node) => [node.kind, node.name])).toEqual([
+ ['directory', 'robot'],
+ ['file', 'README.txt'],
]);
- expect(robot.children?.[0].children?.[0]).toMatchObject({kind:'file',name:'arm.obj',path:'robot/meshes/arm.obj'});
+ const robot = tree[0];
+ expect(robot.children?.map((node) => [node.kind, node.name])).toEqual([
+ ['directory', 'meshes'],
+ ['directory', 'textures'],
+ ['file', 'model.xml'],
+ ]);
+ expect(robot.children?.[0].children?.[0]).toMatchObject({
+ kind: 'file',
+ name: 'arm.obj',
+ path: 'robot/meshes/arm.obj',
+ });
});
- it('以可折叠目录显示文件名,而不是平铺完整路径',()=>{
- render();
- const tree=screen.getByRole('navigation',{name:'工程文件树'}),robot=within(tree).getByText('robot'),meshes=within(tree).getByText('meshes');
+ it('以可折叠目录显示文件名,而不是平铺完整路径', () => {
+ render(
+ ,
+ );
+ const tree = screen.getByRole('navigation', { name: '工程文件树' }),
+ robot = within(tree).getByText('robot'),
+ meshes = within(tree).getByText('meshes');
expect(robot.closest('details')).toHaveAttribute('open');
expect(meshes.closest('details')).not.toHaveAttribute('open');
fireEvent.click(meshes);
@@ -30,10 +47,25 @@ describe('ProjectTree',()=>{
expect(within(tree).getByText('urdf')).toBeVisible();
});
- it('搜索时只保留匹配文件及其目录路径',()=>{
- render();
- expect(screen.getByText('robot')).toBeVisible();expect(screen.getByText('meshes')).toBeVisible();expect(screen.getByText('arm.obj')).toBeVisible();expect(screen.queryByText('README.txt')).not.toBeInTheDocument();expect(countProjectSearchResults(files,'meshes')).toBe(3);expect(countProjectSearchResults(files,'robot/meshes')).toBe(0);
+ it('搜索时只保留匹配文件及其目录路径', () => {
+ render();
+ expect(screen.getByText('robot')).toBeVisible();
+ expect(screen.getByText('meshes')).toBeVisible();
+ expect(screen.getByText('arm.obj')).toBeVisible();
+ expect(screen.queryByText('README.txt')).not.toBeInTheDocument();
+ expect(countProjectSearchResults(files, 'meshes')).toBe(3);
+ expect(countProjectSearchResults(files, 'robot/meshes')).toBe(0);
});
- it('大型工程使用可键盘折叠的虚拟树',()=>{const large=Array.from({length:401},(_,index)=>({path:`assets/file-${index}.obj`,size:1}));render();const tree=screen.getByRole('tree',{name:'虚拟化工程文件树'});expect(tree).toHaveAttribute('aria-activedescendant',expect.stringContaining('assets'));fireEvent.keyDown(tree,{key:'ArrowLeft'});expect(screen.queryByText('file-0.obj')).not.toBeInTheDocument();});
+ it('大型工程使用可键盘折叠的虚拟树', () => {
+ const large = Array.from({ length: 401 }, (_, index) => ({
+ path: `assets/file-${index}.obj`,
+ size: 1,
+ }));
+ render();
+ const tree = screen.getByRole('tree', { name: '虚拟化工程文件树' });
+ expect(tree).toHaveAttribute('aria-activedescendant', expect.stringContaining('assets'));
+ fireEvent.keyDown(tree, { key: 'ArrowLeft' });
+ expect(screen.queryByText('file-0.obj')).not.toBeInTheDocument();
+ });
});
diff --git a/web_platform/src/project/ProjectTree.tsx b/web_platform/src/project/ProjectTree.tsx
index 00a8c3cb..6339823d 100644
--- a/web_platform/src/project/ProjectTree.tsx
+++ b/web_platform/src/project/ProjectTree.tsx
@@ -1,76 +1,314 @@
-import {useState} from 'react';
-import {Box,File,FileCode2,Folder,FolderOpen} from 'lucide-react';
-import type {ModelEntry} from './types';
-import {EmptySearchState,SearchHighlight,VirtualTreeViewport} from '../components/ui';
+import { useState } from 'react';
+import { Box, File, FileCode2, Folder, FolderOpen } from 'lucide-react';
+import type { ModelEntry } from './types';
+import { EmptySearchState, SearchHighlight, VirtualTreeViewport } from '../components/ui';
-export interface ProjectTreeFile {path:string;size:number;}
+export interface ProjectTreeFile {
+ path: string;
+ size: number;
+}
export interface ProjectTreeNode {
- name:string;
- path:string;
- kind:'directory'|'file';
- size?:number;
- children?:ProjectTreeNode[];
+ name: string;
+ path: string;
+ kind: 'directory' | 'file';
+ size?: number;
+ children?: ProjectTreeNode[];
}
interface MutableDirectory {
- name:string;
- path:string;
- directories:Map;
- files:ProjectTreeNode[];
+ name: string;
+ path: string;
+ directories: Map;
+ files: ProjectTreeNode[];
}
-function compareNodes(a:ProjectTreeNode,b:ProjectTreeNode):number {
- if(a.kind!==b.kind)return a.kind==='directory'?-1:1;
- return a.name.localeCompare(b.name,'zh-CN',{numeric:true,sensitivity:'base'});
+function compareNodes(a: ProjectTreeNode, b: ProjectTreeNode): number {
+ if (a.kind !== b.kind) return a.kind === 'directory' ? -1 : 1;
+ return a.name.localeCompare(b.name, 'zh-CN', { numeric: true, sensitivity: 'base' });
}
/** 将规范化后的工程路径转换为“目录优先、名称排序”的资源树。 */
// 同文件导出纯函数是为了让资源树的数据转换可独立测试。
// eslint-disable-next-line react-refresh/only-export-components
-export function buildProjectTree(files:ProjectTreeFile[]):ProjectTreeNode[] {
- const root:MutableDirectory={name:'',path:'',directories:new Map(),files:[]};
- for(const file of files){
- const parts=file.path.split('/').filter(Boolean);
- if(!parts.length)continue;
- let parent=root;
- for(const part of parts.slice(0,-1)){
- const path=parent.path?`${parent.path}/${part}`:part;
- let directory=parent.directories.get(part);
- if(!directory){directory={name:part,path,directories:new Map(),files:[]};parent.directories.set(part,directory);}
- parent=directory;
+export function buildProjectTree(files: ProjectTreeFile[]): ProjectTreeNode[] {
+ const root: MutableDirectory = { name: '', path: '', directories: new Map(), files: [] };
+ for (const file of files) {
+ const parts = file.path.split('/').filter(Boolean);
+ if (!parts.length) continue;
+ let parent = root;
+ for (const part of parts.slice(0, -1)) {
+ const path = parent.path ? `${parent.path}/${part}` : part;
+ let directory = parent.directories.get(part);
+ if (!directory) {
+ directory = { name: part, path, directories: new Map(), files: [] };
+ parent.directories.set(part, directory);
+ }
+ parent = directory;
}
- parent.files.push({name:parts.at(-1)!,path:file.path,kind:'file',size:file.size});
+ parent.files.push({ name: parts.at(-1)!, path: file.path, kind: 'file', size: file.size });
}
- const finish=(directory:MutableDirectory):ProjectTreeNode[]=>[
- ...Array.from(directory.directories.values(),child=>({name:child.name,path:child.path,kind:'directory' as const,children:finish(child)})),
- ...directory.files,
- ].sort(compareNodes);
+ const finish = (directory: MutableDirectory): ProjectTreeNode[] =>
+ [
+ ...Array.from(directory.directories.values(), (child) => ({
+ name: child.name,
+ path: child.path,
+ kind: 'directory' as const,
+ children: finish(child),
+ })),
+ ...directory.files,
+ ].sort(compareNodes);
return finish(root);
}
-function formatSize(bytes:number):string {
- if(bytes<1024)return `${bytes} B`;
- if(bytes<1024*1024)return `${(bytes/1024).toFixed(bytes<10*1024?1:0)} KB`;
- return `${(bytes/(1024*1024)).toFixed(1)} MB`;
+function formatSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
-interface TreeNodeProps {entryFormats:Map;selectedEntry?:string;expandedEntry?:string;searching:boolean;query:string;}
-function DirectoryNode({node,entryFormats,selectedEntry,expandedEntry,searching,query}:TreeNodeProps&{node:ProjectTreeNode}){const [open,setOpen]=useState(Boolean(expandedEntry?.startsWith(`${node.path}/`)));const shownOpen=searching||open,FolderIcon=shownOpen?FolderOpen:Folder;return {if(!searching)setOpen(event.currentTarget.open);}}>{if(searching)event.preventDefault();}} className="flex cursor-pointer select-none items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary hover:bg-element-hover">
;}
-function TreeNodes({nodes,entryFormats,selectedEntry,expandedEntry,searching,query}:TreeNodeProps&{nodes:ProjectTreeNode[]}){
- return {nodes.map(node=>{if(node.kind==='directory')return ;const EntryIcon=entryFormats.has(node.path)?FileCode2:node.path.endsWith('.obj')||node.path.endsWith('.stl')||node.path.endsWith('.dae')?Box:File;return - {entryFormats.has(node.path)&&{entryFormats.get(node.path)}}{formatSize(node.size??0)}
;})}
;
+interface TreeNodeProps {
+ entryFormats: Map;
+ selectedEntry?: string;
+ expandedEntry?: string;
+ searching: boolean;
+ query: string;
+}
+function DirectoryNode({
+ node,
+ entryFormats,
+ selectedEntry,
+ expandedEntry,
+ searching,
+ query,
+}: TreeNodeProps & { node: ProjectTreeNode }) {
+ const [open, setOpen] = useState(Boolean(expandedEntry?.startsWith(`${node.path}/`)));
+ const shownOpen = searching || open,
+ FolderIcon = shownOpen ? FolderOpen : Folder;
+ return (
+
+ {
+ if (!searching) setOpen(event.currentTarget.open);
+ }}
+ >
+ {
+ if (searching) event.preventDefault();
+ }}
+ className="flex cursor-pointer select-none items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary hover:bg-element-hover"
+ >
+
+
+
+
+
+
+
+
+ );
+}
+function TreeNodes({
+ nodes,
+ entryFormats,
+ selectedEntry,
+ expandedEntry,
+ searching,
+ query,
+}: TreeNodeProps & { nodes: ProjectTreeNode[] }) {
+ return (
+
+ {nodes.map((node) => {
+ if (node.kind === 'directory')
+ return (
+
+ );
+ const EntryIcon = entryFormats.has(node.path)
+ ? FileCode2
+ : node.path.endsWith('.obj') || node.path.endsWith('.stl') || node.path.endsWith('.dae')
+ ? Box
+ : File;
+ return (
+ -
+
+
+
+
+ {entryFormats.has(node.path) && (
+
+ {entryFormats.get(node.path)}
+
+ )}
+
+ {formatSize(node.size ?? 0)}
+
+
+ );
+ })}
+
+ );
}
-function filterNodes(nodes:ProjectTreeNode[],query:string):ProjectTreeNode[]{if(!query)return nodes;return nodes.flatMap(node=>{if(node.name.toLocaleLowerCase().includes(query))return [node];if(node.kind==='file')return [];const children=filterNodes(node.children??[],query);return children.length?[{...node,children}]:[];});}
-function countNodes(nodes:ProjectTreeNode[]):number{return nodes.reduce((total,node)=>total+1+(node.children?countNodes(node.children):0),0);}
+function filterNodes(nodes: ProjectTreeNode[], query: string): ProjectTreeNode[] {
+ if (!query) return nodes;
+ return nodes.flatMap((node) => {
+ if (node.name.toLocaleLowerCase().includes(query)) return [node];
+ if (node.kind === 'file') return [];
+ const children = filterNodes(node.children ?? [], query);
+ return children.length ? [{ ...node, children }] : [];
+ });
+}
+function countNodes(nodes: ProjectTreeNode[]): number {
+ return nodes.reduce(
+ (total, node) => total + 1 + (node.children ? countNodes(node.children) : 0),
+ 0,
+ );
+}
// eslint-disable-next-line react-refresh/only-export-components
-export function countProjectSearchResults(files:ProjectTreeFile[],query:string):number{return countNodes(filterNodes(buildProjectTree(files),query.trim().toLocaleLowerCase()));}
-interface FlatProjectNode{node:ProjectTreeNode;depth:number;}
-function flattenProjectNodes(nodes:ProjectTreeNode[],expanded:Set,searching:boolean,depth=0):FlatProjectNode[]{return nodes.flatMap(node=>[{node,depth},...(node.kind==='directory'&&(searching||expanded.has(node.path))?flattenProjectNodes(node.children??[],expanded,searching,depth+1):[])]);}
-export function ProjectTree({files,entries,selectedEntry,query=''}:{files:ProjectTreeFile[];entries:ModelEntry[];selectedEntry?:string;query?:string}){
- const normalized=query.trim().toLocaleLowerCase(),nodes=filterNodes(buildProjectTree(files),normalized),[virtualExpanded,setVirtualExpanded]=useState(()=>new Set(buildProjectTree(files).filter(node=>node.kind==='directory').map(node=>node.path)));
- const entryFormats=new Map(entries.map(entry=>[entry.path,entry.format]));
- const expandedEntry=entries.some(entry=>entry.path===selectedEntry&&entry.format==='urdf')?selectedEntry:undefined;
- if(files.length>400&&nodes.length){const searching=Boolean(normalized),flat=flattenProjectNodes(nodes,virtualExpanded,searching),toggle=(item:FlatProjectNode)=>{if(item.node.kind!=='directory'||searching)return;setVirtualExpanded(current=>{const next=new Set(current);if(next.has(item.node.path))next.delete(item.node.path);else next.add(item.node.path);return next;});};return ;}
- return ;
+export function countProjectSearchResults(files: ProjectTreeFile[], query: string): number {
+ return countNodes(filterNodes(buildProjectTree(files), query.trim().toLocaleLowerCase()));
+}
+interface FlatProjectNode {
+ node: ProjectTreeNode;
+ depth: number;
+}
+function flattenProjectNodes(
+ nodes: ProjectTreeNode[],
+ expanded: Set,
+ searching: boolean,
+ depth = 0,
+): FlatProjectNode[] {
+ return nodes.flatMap((node) => [
+ { node, depth },
+ ...(node.kind === 'directory' && (searching || expanded.has(node.path))
+ ? flattenProjectNodes(node.children ?? [], expanded, searching, depth + 1)
+ : []),
+ ]);
+}
+export function ProjectTree({
+ files,
+ entries,
+ selectedEntry,
+ query = '',
+}: {
+ files: ProjectTreeFile[];
+ entries: ModelEntry[];
+ selectedEntry?: string;
+ query?: string;
+}) {
+ const normalized = query.trim().toLocaleLowerCase(),
+ nodes = filterNodes(buildProjectTree(files), normalized),
+ [virtualExpanded, setVirtualExpanded] = useState(
+ () =>
+ new Set(
+ buildProjectTree(files)
+ .filter((node) => node.kind === 'directory')
+ .map((node) => node.path),
+ ),
+ );
+ const entryFormats = new Map(entries.map((entry) => [entry.path, entry.format]));
+ const expandedEntry = entries.some(
+ (entry) => entry.path === selectedEntry && entry.format === 'urdf',
+ )
+ ? selectedEntry
+ : undefined;
+ if (files.length > 400 && nodes.length) {
+ const searching = Boolean(normalized),
+ flat = flattenProjectNodes(nodes, virtualExpanded, searching),
+ toggle = (item: FlatProjectNode) => {
+ if (item.node.kind !== 'directory' || searching) return;
+ setVirtualExpanded((current) => {
+ const next = new Set(current);
+ if (next.has(item.node.path)) next.delete(item.node.path);
+ else next.add(item.node.path);
+ return next;
+ });
+ };
+ return (
+
+ );
+ }
+ return (
+
+ );
}
diff --git a/web_platform/src/project/cachedFiles.test.ts b/web_platform/src/project/cachedFiles.test.ts
index 51b3e0b1..5ddd6a86 100644
--- a/web_platform/src/project/cachedFiles.test.ts
+++ b/web_platform/src/project/cachedFiles.test.ts
@@ -1,13 +1,78 @@
-import {editableSourcePaths,exportedFileName,mergeCachedFiles,readCachedText,updateCachedText,upsertCachedMjcf} from './cachedFiles';
-import type {ProjectManifest} from './types';
+import {
+ editableSourcePaths,
+ exportedFileName,
+ mergeCachedFiles,
+ readCachedText,
+ updateCachedText,
+ upsertCachedMjcf,
+} from './cachedFiles';
+import type { ProjectManifest } from './types';
-const encoder=new TextEncoder();
-function fixture():ProjectManifest{const xml=encoder.encode(''),png=new Uint8Array([1,2]);return {id:'p',name:'测试 工程.zip',files:[{path:'model.xml',data:xml,size:xml.byteLength,source:'zip',mimeType:'text/xml'},{path:'texture.png',data:png,size:png.byteLength,source:'zip',mimeType:'image/png'}],entries:[{path:'model.xml',format:'mjcf',label:'model'}],selectedEntry:'model.xml',totalBytes:xml.byteLength+png.byteLength};}
+const encoder = new TextEncoder();
+function fixture(): ProjectManifest {
+ const xml = encoder.encode(''),
+ png = new Uint8Array([1, 2]);
+ return {
+ id: 'p',
+ name: '测试 工程.zip',
+ files: [
+ { path: 'model.xml', data: xml, size: xml.byteLength, source: 'zip', mimeType: 'text/xml' },
+ {
+ path: 'texture.png',
+ data: png,
+ size: png.byteLength,
+ source: 'zip',
+ mimeType: 'image/png',
+ },
+ ],
+ entries: [{ path: 'model.xml', format: 'mjcf', label: 'model' }],
+ maps: [],
+ selectedEntry: 'model.xml',
+ totalBytes: xml.byteLength + png.byteLength,
+ };
+}
-describe('cached source files',()=>{
- it('只列出可编辑文本并读取缓存',()=>{const manifest=fixture();expect(editableSourcePaths(manifest)).toEqual(['model.xml']);expect(readCachedText(manifest,'model.xml')).toBe('');expect(()=>readCachedText(manifest,'texture.png')).toThrow('二进制');});
- it('以不可变方式更新会话缓存和大小',()=>{const original=fixture(),updated=updateCachedText(original,'model.xml','');expect(readCachedText(updated,'model.xml')).toContain('edited');expect(readCachedText(original,'model.xml')).toBe('');expect(updated.totalBytes).toBe(updated.files.reduce((sum,file)=>sum+file.size,0));});
- it('合并转换生成的支持资源',()=>{const original=fixture(),obj={path:'mesh.mujoco.obj',data:encoder.encode('v 0 0 0'),size:7,source:'file' as const,mimeType:'text/plain'},updated=mergeCachedFiles(original,[obj]);expect(updated.files.map(file=>file.path)).toContain('mesh.mujoco.obj');expect(original.files.map(file=>file.path)).not.toContain('mesh.mujoco.obj');});
- it('创建可重新载入的 MJCF 缓存文件和入口',()=>{const updated=upsertCachedMjcf(fixture(),'.__converted_mjcf_cache__.xml','');expect(readCachedText(updated,'.__converted_mjcf_cache__.xml')).toContain('cached');expect(updated.entries.at(-1)).toMatchObject({path:'.__converted_mjcf_cache__.xml',format:'mjcf'});});
- it('生成安全的导出文件名',()=>{expect(exportedFileName('测试 工程.zip','urdf')).toBe('测试_工程.urdf');expect(exportedFileName('robot.xml','xml')).toBe('robot.xml');});
+describe('cached source files', () => {
+ it('只列出可编辑文本并读取缓存', () => {
+ const manifest = fixture();
+ expect(editableSourcePaths(manifest)).toEqual(['model.xml']);
+ expect(readCachedText(manifest, 'model.xml')).toBe('');
+ expect(() => readCachedText(manifest, 'texture.png')).toThrow('二进制');
+ });
+ it('以不可变方式更新会话缓存和大小', () => {
+ const original = fixture(),
+ updated = updateCachedText(original, 'model.xml', '');
+ expect(readCachedText(updated, 'model.xml')).toContain('edited');
+ expect(readCachedText(original, 'model.xml')).toBe('');
+ expect(updated.totalBytes).toBe(updated.files.reduce((sum, file) => sum + file.size, 0));
+ });
+ it('合并转换生成的支持资源', () => {
+ const original = fixture(),
+ obj = {
+ path: 'mesh.mujoco.obj',
+ data: encoder.encode('v 0 0 0'),
+ size: 7,
+ source: 'file' as const,
+ mimeType: 'text/plain',
+ },
+ updated = mergeCachedFiles(original, [obj]);
+ expect(updated.files.map((file) => file.path)).toContain('mesh.mujoco.obj');
+ expect(original.files.map((file) => file.path)).not.toContain('mesh.mujoco.obj');
+ });
+ it('创建可重新载入的 MJCF 缓存文件和入口', () => {
+ const updated = upsertCachedMjcf(
+ fixture(),
+ '.__converted_mjcf_cache__.xml',
+ '',
+ );
+ expect(readCachedText(updated, '.__converted_mjcf_cache__.xml')).toContain('cached');
+ expect(updated.entries.at(-1)).toMatchObject({
+ path: '.__converted_mjcf_cache__.xml',
+ format: 'mjcf',
+ });
+ });
+ it('生成安全的导出文件名', () => {
+ expect(exportedFileName('测试 工程.zip', 'urdf')).toBe('测试_工程.urdf');
+ expect(exportedFileName('robot.xml', 'xml')).toBe('robot.xml');
+ });
});
diff --git a/web_platform/src/project/cachedFiles.ts b/web_platform/src/project/cachedFiles.ts
index 97de3d3b..9bddd0f9 100644
--- a/web_platform/src/project/cachedFiles.ts
+++ b/web_platform/src/project/cachedFiles.ts
@@ -1,57 +1,105 @@
-import type {ProjectManifest} from './types';
+import type { ProjectManifest } from './types';
-const TEXT_EXTENSIONS=/\.(?:xml|urdf|txt|obj|mtl|csv|json|yaml|yml)$/i;
-const decoder=new TextDecoder('utf-8',{fatal:false});
-const encoder=new TextEncoder();
+const TEXT_EXTENSIONS = /\.(?:xml|urdf|txt|obj|mtl|csv|json|yaml|yml)$/i;
+const decoder = new TextDecoder('utf-8', { fatal: false });
+const encoder = new TextEncoder();
-export function isEditableSource(path:string):boolean{return TEXT_EXTENSIONS.test(path);}
-
-export function editableSourcePaths(manifest:ProjectManifest):string[]{
- return manifest.files.filter(file=>isEditableSource(file.path)).map(file=>file.path).sort((a,b)=>a.localeCompare(b));
+export function isEditableSource(path: string): boolean {
+ return TEXT_EXTENSIONS.test(path);
}
-export function readCachedText(manifest:ProjectManifest,path:string):string{
- const file=manifest.files.find(candidate=>candidate.path===path);
- if(!file)throw new Error(`缓存中找不到文件:${path}`);
- if(!isEditableSource(path))throw new Error(`不支持编辑二进制文件:${path}`);
+export function editableSourcePaths(manifest: ProjectManifest): string[] {
+ return manifest.files
+ .filter((file) => isEditableSource(file.path))
+ .map((file) => file.path)
+ .sort((a, b) => a.localeCompare(b));
+}
+
+export function readCachedText(manifest: ProjectManifest, path: string): string {
+ const file = manifest.files.find((candidate) => candidate.path === path);
+ if (!file) throw new Error(`缓存中找不到文件:${path}`);
+ if (!isEditableSource(path)) throw new Error(`不支持编辑二进制文件:${path}`);
return decoder.decode(file.data);
}
/** 返回只更新浏览器会话内存的新工程清单,不接触用户本地文件系统。 */
-export function mergeCachedFiles(manifest:ProjectManifest,additional:ProjectManifest['files']):ProjectManifest{
- if(!additional.length)return manifest;
- const byPath=new Map(manifest.files.map(file=>[file.path,file]));
- for(const file of additional)byPath.set(file.path,file);
- const files=Array.from(byPath.values());
- return {...manifest,files,totalBytes:files.reduce((total,item)=>total+item.size,0)};
+export function mergeCachedFiles(
+ manifest: ProjectManifest,
+ additional: ProjectManifest['files'],
+): ProjectManifest {
+ if (!additional.length) return manifest;
+ const byPath = new Map(manifest.files.map((file) => [file.path, file]));
+ for (const file of additional) byPath.set(file.path, file);
+ const files = Array.from(byPath.values());
+ return { ...manifest, files, totalBytes: files.reduce((total, item) => total + item.size, 0) };
}
-export function upsertCachedMjcf(manifest:ProjectManifest,path:string,text:string):ProjectManifest{
- const data=encoder.encode(text),index=manifest.files.findIndex(candidate=>candidate.path===path);
- const files=manifest.files.slice();
- const file={path,data,size:data.byteLength,source:'file' as const,mimeType:'application/xml'};
- if(index<0)files.push(file);else files[index]={...files[index],...file};
- const entries=manifest.entries.some(entry=>entry.path===path)?manifest.entries:[...manifest.entries,{path,format:'mjcf' as const,label:`${path} (MJCF 缓存)`}];
- return {...manifest,files,entries,totalBytes:files.reduce((total,item)=>total+item.size,0)};
+export function upsertCachedMjcf(
+ manifest: ProjectManifest,
+ path: string,
+ text: string,
+): ProjectManifest {
+ const data = encoder.encode(text),
+ index = manifest.files.findIndex((candidate) => candidate.path === path);
+ const files = manifest.files.slice();
+ const file = {
+ path,
+ data,
+ size: data.byteLength,
+ source: 'file' as const,
+ mimeType: 'application/xml',
+ };
+ if (index < 0) files.push(file);
+ else files[index] = { ...files[index], ...file };
+ const entries = manifest.entries.some((entry) => entry.path === path)
+ ? manifest.entries
+ : [...manifest.entries, { path, format: 'mjcf' as const, label: `${path} (MJCF 缓存)` }];
+ return {
+ ...manifest,
+ files,
+ entries,
+ totalBytes: files.reduce((total, item) => total + item.size, 0),
+ };
}
-export function updateCachedText(manifest:ProjectManifest,path:string,text:string):ProjectManifest{
- const index=manifest.files.findIndex(candidate=>candidate.path===path);
- if(index<0)throw new Error(`缓存中找不到文件:${path}`);
- if(!isEditableSource(path))throw new Error(`不支持编辑二进制文件:${path}`);
- const data=encoder.encode(text),files=manifest.files.slice();
- files[index]={...files[index],data,size:data.byteLength,mimeType:files[index].mimeType||'text/plain'};
- return {...manifest,files,totalBytes:files.reduce((total,file)=>total+file.size,0)};
+export function updateCachedText(
+ manifest: ProjectManifest,
+ path: string,
+ text: string,
+): ProjectManifest {
+ const index = manifest.files.findIndex((candidate) => candidate.path === path);
+ if (index < 0) throw new Error(`缓存中找不到文件:${path}`);
+ if (!isEditableSource(path)) throw new Error(`不支持编辑二进制文件:${path}`);
+ const data = encoder.encode(text),
+ files = manifest.files.slice();
+ files[index] = {
+ ...files[index],
+ data,
+ size: data.byteLength,
+ mimeType: files[index].mimeType || 'text/plain',
+ };
+ return { ...manifest, files, totalBytes: files.reduce((total, file) => total + file.size, 0) };
}
-export function downloadBytes(data:Uint8Array,fileName:string,mimeType='application/xml'):void{
- const blob=new Blob([data as BlobPart],{type:`${mimeType};charset=utf-8`});
- const url=URL.createObjectURL(blob),anchor=document.createElement('a');
- anchor.href=url;anchor.download=fileName;anchor.style.display='none';document.body.append(anchor);anchor.click();anchor.remove();
- setTimeout(()=>URL.revokeObjectURL(url),0);
+export function downloadBytes(
+ data: Uint8Array,
+ fileName: string,
+ mimeType = 'application/xml',
+): void {
+ const blob = new Blob([data as BlobPart], { type: `${mimeType};charset=utf-8` });
+ const url = URL.createObjectURL(blob),
+ anchor = document.createElement('a');
+ anchor.href = url;
+ anchor.download = fileName;
+ anchor.style.display = 'none';
+ document.body.append(anchor);
+ anchor.click();
+ anchor.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 0);
}
-export function exportedFileName(projectName:string,extension:'urdf'|'xml'):string{
- const stem=projectName.replace(/\.(?:zip|xml|urdf)$/i,'').replace(/[^\p{L}\p{N}._-]+/gu,'_')||'model';
+export function exportedFileName(projectName: string, extension: 'urdf' | 'xml'): string {
+ const stem =
+ projectName.replace(/\.(?:zip|xml|urdf)$/i, '').replace(/[^\p{L}\p{N}._-]+/gu, '_') || 'model';
return `${stem}.${extension}`;
}
diff --git a/web_platform/src/project/daeConverter.ts b/web_platform/src/project/daeConverter.ts
index 0aa5f801..dc359af4 100644
--- a/web_platform/src/project/daeConverter.ts
+++ b/web_platform/src/project/daeConverter.ts
@@ -1,52 +1,56 @@
-import {LoadingManager,type Material,type Mesh,type Texture} from 'three';
-import {OBJExporter} from 'three/addons/exporters/OBJExporter.js';
-import {ColladaLoader} from 'three/addons/loaders/ColladaLoader.js';
+import { LoadingManager, type Material, type Mesh, type Texture } from 'three';
+import { OBJExporter } from 'three/addons/exporters/OBJExporter.js';
+import { ColladaLoader } from 'three/addons/loaders/ColladaLoader.js';
-const TRANSPARENT_PIXEL='data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=';
+const TRANSPARENT_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=';
/**
* 将 Collada 几何转换为 MuJoCo WASM 可读取的 OBJ。
* ColladaLoader 会先统一为 Y-up;额外旋转到 MuJoCo 使用的 Z-up,并烘焙节点变换与单位缩放。
*/
-export function convertDaeToObj(data:Uint8Array,path:string):Uint8Array {
- const manager=new LoadingManager();
+export function convertDaeToObj(data: Uint8Array, path: string): Uint8Array {
+ const manager = new LoadingManager();
// 转换只需要几何。拦截贴图 URL,避免为浏览器内存文件发起无效网络请求。
- manager.setURLModifier(()=>TRANSPARENT_PIXEL);
- const loader=new ColladaLoader(manager);
- const text=new TextDecoder('utf-8').decode(data);
- const xml=new DOMParser().parseFromString(text,'application/xml');
- if(xml.querySelector('parsererror'))throw new Error('Collada XML 格式无效');
- const upAxis=xml.getElementsByTagName('up_axis')[0]?.textContent?.trim().toUpperCase()??'Y_UP';
+ manager.setURLModifier(() => TRANSPARENT_PIXEL);
+ const loader = new ColladaLoader(manager);
+ const text = new TextDecoder('utf-8').decode(data);
+ const xml = new DOMParser().parseFromString(text, 'application/xml');
+ if (xml.querySelector('parsererror')) throw new Error('Collada XML 格式无效');
+ const upAxis =
+ xml.getElementsByTagName('up_axis')[0]?.textContent?.trim().toUpperCase() ?? 'Y_UP';
// 禁用 ColladaLoader 自带的 Z-up → Y-up 旋转,改为直接统一到 MuJoCo 的 Z-up。
- if(upAxis==='Z_UP')xml.getElementsByTagName('up_axis')[0]!.textContent='Y_UP';
- const normalized=new XMLSerializer().serializeToString(xml);
- const result=loader.parse(normalized,path.slice(0,path.lastIndexOf('/')+1));
- if(!result?.scene)throw new Error('Collada 文件无法解析');
- const scene=result.scene;
- if(upAxis==='Y_UP')scene.rotation.x+=Math.PI/2;
- else if(upAxis==='X_UP')scene.rotation.y-=Math.PI/2;
+ if (upAxis === 'Z_UP') xml.getElementsByTagName('up_axis')[0]!.textContent = 'Y_UP';
+ const normalized = new XMLSerializer().serializeToString(xml);
+ const result = loader.parse(normalized, path.slice(0, path.lastIndexOf('/') + 1));
+ if (!result?.scene) throw new Error('Collada 文件无法解析');
+ const scene = result.scene;
+ if (upAxis === 'Y_UP') scene.rotation.x += Math.PI / 2;
+ else if (upAxis === 'X_UP') scene.rotation.y -= Math.PI / 2;
scene.updateMatrixWorld(true);
- let meshCount=0;
- scene.traverse(object=>{
- const mesh=object as Mesh;
- if(!mesh.isMesh)return;
- meshCount+=1;
- const materials=Array.isArray(mesh.material)?mesh.material:[mesh.material];
- for(const material of materials)if(material)material.name='';
+ let meshCount = 0;
+ scene.traverse((object) => {
+ const mesh = object as Mesh;
+ if (!mesh.isMesh) return;
+ meshCount += 1;
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
+ for (const material of materials) if (material) material.name = '';
});
- if(!meshCount)throw new Error('Collada 文件不包含可转换的三角网格');
+ if (!meshCount) throw new Error('Collada 文件不包含可转换的三角网格');
try {
- const output=new OBJExporter().parse(scene);
- if(!/^v\s/m.test(output)||!/^f\s/m.test(output))throw new Error('Collada 文件未生成有效三角面');
+ const output = new OBJExporter().parse(scene);
+ if (!/^v\s/m.test(output) || !/^f\s/m.test(output))
+ throw new Error('Collada 文件未生成有效三角面');
return new TextEncoder().encode(output);
} finally {
- scene.traverse(object=>{
- const mesh=object as Mesh;
- if(!mesh.isMesh)return;
+ scene.traverse((object) => {
+ const mesh = object as Mesh;
+ if (!mesh.isMesh) return;
mesh.geometry?.dispose();
- const materials:Material[]=Array.isArray(mesh.material)?mesh.material:[mesh.material];
- for(const material of materials){
- for(const value of Object.values(material))if(value&&typeof value==='object'&&(value as Texture).isTexture)(value as Texture).dispose();
+ const materials: Material[] = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
+ for (const material of materials) {
+ for (const value of Object.values(material))
+ if (value && typeof value === 'object' && (value as Texture).isTexture)
+ (value as Texture).dispose();
material.dispose();
}
});
diff --git a/web_platform/src/project/importer.test.ts b/web_platform/src/project/importer.test.ts
index 20f387d6..bf8d8285 100644
--- a/web_platform/src/project/importer.test.ts
+++ b/web_platform/src/project/importer.test.ts
@@ -1,21 +1,197 @@
-import {zipSync} from 'fflate';
-import {choosePreferredEntry,discoverEntries,importBrowserFiles,normalizeProjectPath,prepareProjectForMujoco,ProjectImportError} from './importer';
-import type {ProjectFile} from './types';
-const encode=(s:string)=>new TextEncoder().encode(s);
-const projectFile=(path:string,text:string):ProjectFile=>({path,data:encode(text),size:encode(text).length,source:'file',mimeType:'text/xml'});
-const TRIANGLE_DAE=`
+import { zipSync } from 'fflate';
+import {
+ choosePreferredEntry,
+ discoverEntries,
+ importBrowserFiles,
+ normalizeProjectPath,
+ prepareProjectForMujoco,
+ ProjectImportError,
+} from './importer';
+import type { ProjectFile } from './types';
+const encode = (s: string) => new TextEncoder().encode(s);
+const projectFile = (path: string, text: string): ProjectFile => ({
+ path,
+ data: encode(text),
+ size: encode(text).length,
+ source: 'file',
+ mimeType: 'text/xml',
+});
+const TRIANGLE_DAE = `
Z_UP1 1 1 1
0 0 0 1 0 0 0 1 00 1 2
`;
-describe('project importer',()=>{
- it('拒绝路径穿越与绝对路径',()=>{expect(()=>normalizeProjectPath('../model.xml')).toThrow(ProjectImportError);expect(()=>normalizeProjectPath('/model.xml')).toThrow(ProjectImportError);expect(normalizeProjectPath('robot\\mesh\\a.obj')).toBe('robot/mesh/a.obj');});
- it('识别 MJCF 与 URDF 并执行入口优先级',()=>{const entries=discoverEntries([projectFile('other.xml',''),projectFile('model.xml',''),projectFile('robot.urdf','')]);expect(entries).toHaveLength(3);expect(choosePreferredEntry(entries)).toBe('model.xml');});
- it('解压 ZIP 并保留二进制数据',async()=>{const zipped=zipSync({'robot/model.urdf':encode(''),'robot/mesh.obj':encode('v 0 0 0')});const file=new File([zipped],'robot.zip',{type:'application/zip'});const result=await importBrowserFiles([file]);expect(result.files.map(f=>f.path)).toContain('robot/mesh.obj');expect(result.selectedEntry).toBe('robot/model.urdf');});
- it('拒绝 ZIP 路径穿越',async()=>{const zipped=zipSync({'../model.xml':encode('')});await expect(importBrowserFiles([new File([zipped],'bad.zip')])).rejects.toThrow('路径包含越界片段');});
- it('拒绝同名路径',async()=>{const a=new File([''],'model.xml');const b=new File([''],'model.xml');await expect(importBrowserFiles([a,b])).rejects.toThrow('同名路径');});
- it('拒绝超过限制的文件',async()=>{const file=new File([''],'model.xml');await expect(importBrowserFiles([file],{maxFiles:1,maxFileBytes:2,maxTotalBytes:2,maxZipBytes:2})).rejects.toThrow('单文件超过限制');});
- it('规范化 MuJoCo 不接受的重复 material 和 ROS package URI',()=>{const urdf=projectFile('go2w_description/urdf/robot.urdf','');const mesh:ProjectFile={path:'go2w_description/meshes/base.obj',data:new Uint8Array([1]),size:1,source:'directory',mimeType:''};const manifest={id:'go2w',name:'go2w',files:[urdf,mesh],entries:[{path:urdf.path,format:'urdf' as const,label:'robot'}],selectedEntry:urdf.path,totalBytes:urdf.size+1};const prepared=prepareProjectForMujoco(manifest,urdf.path);const text=new TextDecoder().decode(prepared.manifest.files[0].data);expect((text.match(/{const urdf=projectFile('robot/robot.urdf','');const dae=projectFile('robot/meshes/triangle.dae',TRIANGLE_DAE);const manifest={id:'dae',name:'dae',files:[urdf,dae],entries:[{path:urdf.path,format:'urdf' as const,label:'robot'}],selectedEntry:urdf.path,totalBytes:urdf.size+dae.size};const prepared=prepareProjectForMujoco(manifest,urdf.path);const text=new TextDecoder().decode(prepared.manifest.files.find(file=>file.path===urdf.path)!.data);expect(text).not.toContain('.dae');expect(text.match(/meshes\/triangle\.mujoco\.obj/g)).toHaveLength(2);const obj=prepared.manifest.files.find(file=>file.path==='robot/meshes/triangle.mujoco.obj');expect(new TextDecoder().decode(obj!.data)).toMatch(/^f\s/m);expect(prepared.warnings.join(' ')).toContain('1 个 DAE 文件转换为 OBJ');});
- it('DAE 缺失或转换失败时安全降级',()=>{const urdf=projectFile('robot.urdf','');const manifest={id:'dae',name:'dae',files:[urdf],entries:[{path:urdf.path,format:'urdf' as const,label:'robot'}],selectedEntry:urdf.path,totalBytes:urdf.size};const prepared=prepareProjectForMujoco(manifest,urdf.path);const text=new TextDecoder().decode(prepared.manifest.files[0].data);expect(text).not.toContain('');expect(text).toContain('');expect(text).toContain('{const zipped=zipSync({'model.xml':encode(`${' '.repeat(4096)}`)});const file=new File([zipped],'large.zip');await expect(importBrowserFiles([file],{maxFiles:2,maxFileBytes:128,maxTotalBytes:256,maxZipBytes:4096})).rejects.toThrow('单文件超过限制');});
+describe('project importer', () => {
+ it('拒绝路径穿越与绝对路径', () => {
+ expect(() => normalizeProjectPath('../model.xml')).toThrow(ProjectImportError);
+ expect(() => normalizeProjectPath('/model.xml')).toThrow(ProjectImportError);
+ expect(normalizeProjectPath('robot\\mesh\\a.obj')).toBe('robot/mesh/a.obj');
+ });
+ it('识别 MJCF 与 URDF 并执行入口优先级', () => {
+ const entries = discoverEntries([
+ projectFile('other.xml', ''),
+ projectFile('model.xml', ''),
+ projectFile('robot.urdf', ''),
+ ]);
+ expect(entries).toHaveLength(3);
+ expect(choosePreferredEntry(entries)).toBe('model.xml');
+ });
+ it('异步解压 ZIP、保留二进制数据并报告阶段进度', async () => {
+ const zipped = zipSync({
+ 'robot/model.urdf': encode(''),
+ 'robot/mesh.obj': encode('v 0 0 0'),
+ });
+ const file = new File([zipped], 'robot.zip', { type: 'application/zip' });
+ 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([''], 'model.xml'),
+ new File([mapJson], 'map.json'),
+ new File([''], '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('') });
+ await expect(importBrowserFiles([new File([zipped], 'bad.zip')])).rejects.toThrow(
+ '路径包含越界片段',
+ );
+ });
+ it('拒绝同名路径', async () => {
+ const a = new File([''], 'model.xml');
+ const b = new File([''], 'model.xml');
+ await expect(importBrowserFiles([a, b])).rejects.toThrow('同名路径');
+ });
+ it('读取文件内容前根据元数据拒绝超过限制的输入', async () => {
+ const file = new File([''], 'model.xml'),
+ read = vi.spyOn(file, 'arrayBuffer');
+ await expect(
+ importBrowserFiles([file], {
+ maxFiles: 1,
+ maxFileBytes: 2,
+ maxTotalBytes: 2,
+ maxZipBytes: 2,
+ }),
+ ).rejects.toThrow('单文件超过限制');
+ expect(read).not.toHaveBeenCalled();
+ });
+ it('按完成文件数报告普通工程读取进度', async () => {
+ const updates: Array<{ phase: string; completed: number; total: number }> = [];
+ const result = await importBrowserFiles(
+ [new File([''], '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',
+ '',
+ );
+ const mesh: ProjectFile = {
+ path: 'go2w_description/meshes/base.obj',
+ data: new Uint8Array([1]),
+ size: 1,
+ source: 'directory',
+ mimeType: '',
+ };
+ const manifest = {
+ id: 'go2w',
+ 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 = await prepareProjectForMujoco(manifest, urdf.path);
+ const text = new TextDecoder().decode(prepared.manifest.files[0].data);
+ expect(text.match(/ {
+ const urdf = projectFile(
+ 'robot/robot.urdf',
+ '',
+ );
+ const dae = projectFile('robot/meshes/triangle.dae', TRIANGLE_DAE);
+ const manifest = {
+ id: 'dae',
+ 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 = await prepareProjectForMujoco(manifest, urdf.path);
+ const text = new TextDecoder().decode(
+ prepared.manifest.files.find((file) => file.path === urdf.path)!.data,
+ );
+ expect(text).not.toContain('.dae');
+ expect(text.match(/meshes\/triangle\.mujoco\.obj/g)).toHaveLength(2);
+ const obj = prepared.manifest.files.find(
+ (file) => file.path === 'robot/meshes/triangle.mujoco.obj',
+ );
+ expect(new TextDecoder().decode(obj!.data)).toMatch(/^f\s/m);
+ expect(prepared.warnings.join(' ')).toContain('1 个 DAE 文件转换为 OBJ');
+ });
+ it('DAE 缺失或转换失败时安全降级', async () => {
+ const urdf = projectFile(
+ 'robot.urdf',
+ '',
+ );
+ const manifest = {
+ id: 'dae',
+ name: 'dae',
+ files: [urdf],
+ entries: [{ path: urdf.path, format: 'urdf' as const, label: 'robot' }],
+ maps: [],
+ selectedEntry: urdf.path,
+ totalBytes: urdf.size,
+ };
+ const prepared = await prepareProjectForMujoco(manifest, urdf.path);
+ const text = new TextDecoder().decode(prepared.manifest.files[0].data);
+ expect(text).not.toContain('');
+ expect(text).toContain('');
+ expect(text).toContain(' {
+ const zipped = zipSync({ 'model.xml': encode(`${' '.repeat(4096)}`) });
+ const file = new File([zipped], 'large.zip');
+ await expect(
+ importBrowserFiles([file], {
+ maxFiles: 2,
+ maxFileBytes: 128,
+ maxTotalBytes: 256,
+ maxZipBytes: 4096,
+ }),
+ ).rejects.toThrow('单文件超过限制');
+ });
});
diff --git a/web_platform/src/project/importer.ts b/web_platform/src/project/importer.ts
index f54085bc..09bdd6a3 100644
--- a/web_platform/src/project/importer.ts
+++ b/web_platform/src/project/importer.ts
@@ -1,24 +1,51 @@
-import {unzipSync} from 'fflate';
-import {DEFAULT_IMPORT_LIMITS, type ImportLimits, type ModelEntry, type ProjectFile, type ProjectManifest} from './types';
-import {convertDaeToObj} from './daeConverter';
+import {
+ DEFAULT_IMPORT_LIMITS,
+ type ImportLimits,
+ type ModelEntry,
+ type ProjectFile,
+ type ProjectManifest,
+} from './types';
+import { discoverMapEntries } from '../map/MapLoader';
-const decoder = new TextDecoder('utf-8', {fatal: false});
+const decoder = new TextDecoder('utf-8', { fatal: false });
+
+export interface ProjectImportProgress {
+ phase: 'reading' | 'extracting' | 'indexing';
+ completed: number;
+ total: number;
+ path?: string;
+}
+
+export type ProjectImportProgressCallback = (progress: ProjectImportProgress) => void;
export class ProjectImportError extends Error {
- constructor(message: string, readonly path?: string) { super(message); this.name = 'ProjectImportError'; }
+ constructor(
+ message: string,
+ readonly path?: string,
+ ) {
+ super(message);
+ this.name = 'ProjectImportError';
+ }
}
export function normalizeProjectPath(input: string): string {
const path = input.replaceAll('\\', '/').replace(/^\.\//, '');
- if (!path || path.startsWith('/') || path.includes('\0') || /^[A-Za-z]:/.test(path)) throw new ProjectImportError('不允许绝对路径或空路径', input);
+ if (!path || path.startsWith('/') || path.includes('\0') || /^[A-Za-z]:/.test(path))
+ throw new ProjectImportError('不允许绝对路径或空路径', input);
const parts = path.split('/').filter((part) => part !== '' && part !== '.');
- if (!parts.length || parts.some((part) => part === '..')) throw new ProjectImportError('路径包含越界片段', input);
+ if (!parts.length || parts.some((part) => part === '..'))
+ throw new ProjectImportError('路径包含越界片段', input);
return parts.join('/');
}
function checkEncryptedZip(data: Uint8Array): void {
for (let i = 0; i + 8 < data.length; i++) {
- if (data[i] === 0x50 && data[i + 1] === 0x4b && (data[i + 2] === 0x03 || data[i + 2] === 0x01) && (data[i + 3] === 0x04 || data[i + 3] === 0x02)) {
+ if (
+ data[i] === 0x50 &&
+ data[i + 1] === 0x4b &&
+ (data[i + 2] === 0x03 || data[i + 2] === 0x01) &&
+ (data[i + 3] === 0x04 || data[i + 3] === 0x02)
+ ) {
const flags = data[i + 6] | (data[i + 7] << 8);
if ((flags & 1) !== 0) throw new ProjectImportError('不支持加密 ZIP');
}
@@ -26,24 +53,35 @@ function checkEncryptedZip(data: Uint8Array): void {
}
function enforceLimits(files: ProjectFile[], limits: ImportLimits): void {
- if (files.length > limits.maxFiles) throw new ProjectImportError(`文件数量超过限制(${limits.maxFiles})`);
+ if (files.length > limits.maxFiles)
+ throw new ProjectImportError(`文件数量超过限制(${limits.maxFiles})`);
let total = 0;
const seen = new Set();
for (const file of files) {
if (seen.has(file.path)) throw new ProjectImportError('工程中存在同名路径', file.path);
seen.add(file.path);
- if (file.size > limits.maxFileBytes) throw new ProjectImportError(`单文件超过限制(${limits.maxFileBytes} 字节)`, file.path);
+ if (file.size > limits.maxFileBytes)
+ throw new ProjectImportError(`单文件超过限制(${limits.maxFileBytes} 字节)`, file.path);
total += file.size;
- if (total > limits.maxTotalBytes) throw new ProjectImportError(`工程总大小超过限制(${limits.maxTotalBytes} 字节)`);
+ if (total > limits.maxTotalBytes)
+ throw new ProjectImportError(`工程总大小超过限制(${limits.maxTotalBytes} 字节)`);
}
}
export function discoverEntries(files: ProjectFile[]): ModelEntry[] {
return files.flatMap((file): ModelEntry[] => {
if (!/\.(xml|urdf)$/i.test(file.path)) return [];
- const head = decoder.decode(file.data.subarray(0, Math.min(file.data.length, 256 * 1024))).replace(/^\uFEFF/, '');
- const format = /)/i.test(head) ? 'urdf' : /)/i.test(head) ? 'mjcf' : undefined;
- return format ? [{path: file.path, format, label: `${file.path} (${format.toUpperCase()})`}] : [];
+ const head = decoder
+ .decode(file.data.subarray(0, Math.min(file.data.length, 256 * 1024)))
+ .replace(/^\uFEFF/, '');
+ const format = /)/i.test(head)
+ ? 'urdf'
+ : /)/i.test(head)
+ ? 'mjcf'
+ : undefined;
+ return format
+ ? [{ path: file.path, format, label: `${file.path} (${format.toUpperCase()})` }]
+ : [];
});
}
@@ -55,55 +93,80 @@ export interface PreparedProject {
function relativeProjectPath(fromFile: string, toFile: string): string {
const from = fromFile.split('/').slice(0, -1);
const to = toFile.split('/');
- while (from.length && to.length && from[0] === to[0]) { from.shift(); to.shift(); }
+ while (from.length && to.length && from[0] === to[0]) {
+ from.shift();
+ to.shift();
+ }
return `${'../'.repeat(from.length)}${to.join('/')}` || './';
}
-function resolveProjectReference(fromFile:string,reference:string):string|undefined {
- if(/^[a-z][a-z\d+.-]*:/i.test(reference))return;
- let decoded:string;
- try{decoded=decodeURIComponent(reference.split(/[?#]/,1)[0]);}catch{return;}
- const parts=fromFile.split('/').slice(0,-1);
- for(const part of decoded.replaceAll('\\','/').split('/')){
- if(!part||part==='.')continue;
- if(part==='..'){if(!parts.length)return;parts.pop();}
- else parts.push(part);
+function resolveProjectReference(fromFile: string, reference: string): string | undefined {
+ if (/^[a-z][a-z\d+.-]*:/i.test(reference)) return;
+ let decoded: string;
+ try {
+ decoded = decodeURIComponent(reference.split(/[?#]/, 1)[0]);
+ } catch {
+ return;
+ }
+ const parts = fromFile.split('/').slice(0, -1);
+ for (const part of decoded.replaceAll('\\', '/').split('/')) {
+ if (!part || part === '.') continue;
+ if (part === '..') {
+ if (!parts.length) return;
+ parts.pop();
+ } else parts.push(part);
}
return parts.join('/');
}
-function generatedObjPath(daePath:string,occupied:Set):string {
- const base=daePath.replace(/\.dae$/i,'');
- let candidate=`${base}.mujoco.obj`;
- for(let index=2;occupied.has(candidate);index+=1)candidate=`${base}.mujoco-${index}.obj`;
+function generatedObjPath(daePath: string, occupied: Set): string {
+ const base = daePath.replace(/\.dae$/i, '');
+ let candidate = `${base}.mujoco.obj`;
+ for (let index = 2; occupied.has(candidate); index += 1)
+ candidate = `${base}.mujoco-${index}.obj`;
occupied.add(candidate);
return candidate;
}
/** Normalizes common ROS URDF constructs that MuJoCo's stricter parser rejects. */
-export function prepareProjectForMujoco(manifest: ProjectManifest, entryPath: string): PreparedProject {
+export async function prepareProjectForMujoco(
+ manifest: ProjectManifest,
+ entryPath: string,
+): Promise {
const entry = manifest.entries.find((candidate) => candidate.path === entryPath);
- if (entry?.format !== 'urdf') return {manifest, warnings: []};
+ if (entry?.format !== 'urdf') return { manifest, warnings: [] };
const source = manifest.files.find((file) => file.path === entryPath);
- if (!source) return {manifest, warnings: []};
+ if (!source) return { manifest, warnings: [] };
const document = new DOMParser().parseFromString(decoder.decode(source.data), 'application/xml');
- if (document.querySelector('parsererror')) return {manifest, warnings: []};
+ if (document.querySelector('parsererror')) return { manifest, warnings: [] };
const warnings: string[] = [];
- const robot=document.documentElement;
- let mujoco=Array.from(robot.children).find(child=>child.tagName==='mujoco');
- if(!mujoco){mujoco=document.createElement('mujoco');robot.prepend(mujoco);}
- let compiler=Array.from(mujoco.children).find(child=>child.tagName==='compiler');
- if(!compiler){compiler=document.createElement('compiler');mujoco.append(compiler);}
- compiler.setAttribute('discardvisual','false');
- compiler.setAttribute('fusestatic','false');
+ const robot = document.documentElement;
+ let mujoco = Array.from(robot.children).find((child) => child.tagName === 'mujoco');
+ if (!mujoco) {
+ mujoco = document.createElement('mujoco');
+ robot.prepend(mujoco);
+ }
+ let compiler = Array.from(mujoco.children).find((child) => child.tagName === 'compiler');
+ if (!compiler) {
+ compiler = document.createElement('compiler');
+ mujoco.append(compiler);
+ }
+ compiler.setAttribute('discardvisual', 'false');
+ compiler.setAttribute('fusestatic', 'false');
let removedMaterials = 0;
for (const visual of Array.from(document.querySelectorAll('visual'))) {
const materials = Array.from(visual.children).filter((child) => child.tagName === 'material');
- for (const duplicate of materials.slice(1)) { duplicate.remove(); removedMaterials += 1; }
+ for (const duplicate of materials.slice(1)) {
+ duplicate.remove();
+ removedMaterials += 1;
+ }
}
- if (removedMaterials) warnings.push(`为兼容 MuJoCo,已移除 visual 中 ${removedMaterials} 个重复 material(保留第一个)`);
+ if (removedMaterials)
+ warnings.push(
+ `为兼容 MuJoCo,已移除 visual 中 ${removedMaterials} 个重复 material(保留第一个)`,
+ );
const paths = manifest.files.map((file) => file.path);
let rewrittenUris = 0;
@@ -112,112 +175,303 @@ export function prepareProjectForMujoco(manifest: ProjectManifest, entryPath: st
const value = element.getAttribute('filename');
if (!value?.startsWith('package://')) continue;
const packagePath = normalizeProjectPath(value.slice('package://'.length));
- const target = paths.find((path) => path === packagePath) ?? paths.find((path) => path.endsWith(`/${packagePath}`));
- if (!target) { unresolved.push(value); continue; }
+ const target =
+ paths.find((path) => path === packagePath) ??
+ paths.find((path) => path.endsWith(`/${packagePath}`));
+ if (!target) {
+ unresolved.push(value);
+ continue;
+ }
element.setAttribute('filename', relativeProjectPath(entryPath, target));
rewrittenUris += 1;
}
- if (rewrittenUris) warnings.push(`已将 ${rewrittenUris} 个 package:// 资源地址改写为工程内相对路径`);
+ if (rewrittenUris)
+ warnings.push(`已将 ${rewrittenUris} 个 package:// 资源地址改写为工程内相对路径`);
if (unresolved.length) warnings.push(`有 ${unresolved.length} 个 package:// 资源未在工程中找到`);
- const occupied=new Set(manifest.files.map(file=>file.path));
- const converted=new Map();
- let convertedDaeReferences=0;
- let removedDaeVisuals=0;
- let daeCollisionFallbacks=0;
- for(const mesh of Array.from(document.querySelectorAll('mesh[filename]'))){
- 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 文件');
- let objFile=converted.get(daePath);
- if(!objFile){
- const data=convertDaeToObj(daeFile.data,daePath);
- if(data.byteLength>DEFAULT_IMPORT_LIMITS.maxFileBytes)throw new Error('转换后的 OBJ 超过单文件大小限制');
- objFile={path:generatedObjPath(daePath,occupied),data,size:data.byteLength,source:daeFile.source,mimeType:'text/plain'};
- converted.set(daePath,objFile);
+ const occupied = new Set(manifest.files.map((file) => file.path));
+ const converted = new Map();
+ 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 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);
+ if (data.byteLength > DEFAULT_IMPORT_LIMITS.maxFileBytes)
+ throw new Error('转换后的 OBJ 超过单文件大小限制');
+ objFile = {
+ path: generatedObjPath(daePath, occupied),
+ data,
+ size: data.byteLength,
+ source: daeFile.source,
+ mimeType: 'text/plain',
+ };
+ converted.set(daePath, objFile);
}
- mesh.setAttribute('filename',relativeProjectPath(entryPath,objFile.path));
- convertedDaeReferences+=1;
- }catch(error){
- console.warn(`[MuJoCo] DAE 转换失败:${filename}`,error);
- const visual=mesh.closest('visual');
- if(visual){visual.remove();removedDaeVisuals+=1;}
- else if(mesh.closest('collision')){
- const sphere=document.createElement('sphere');sphere.setAttribute('radius','0.05');mesh.replaceWith(sphere);daeCollisionFallbacks+=1;
+ mesh.setAttribute('filename', relativeProjectPath(entryPath, objFile.path));
+ convertedDaeReferences += 1;
+ } catch (error) {
+ console.warn(`[MuJoCo] DAE 转换失败:${filename}`, error);
+ const visual = mesh.closest('visual');
+ if (visual) {
+ visual.remove();
+ removedDaeVisuals += 1;
+ } else if (mesh.closest('collision')) {
+ const sphere = document.createElement('sphere');
+ sphere.setAttribute('radius', '0.05');
+ mesh.replaceWith(sphere);
+ daeCollisionFallbacks += 1;
}
}
}
- if(convertedDaeReferences)warnings.push(`已将 ${converted.size} 个 DAE 文件转换为 OBJ,供 ${convertedDaeReferences} 个 visual/collision 使用`);
- if(removedDaeVisuals)warnings.push(`${removedDaeVisuals} 个 DAE visual 转换失败,已移除并使用其他 collision 几何显示/仿真`);
- if(daeCollisionFallbacks)warnings.push(`${daeCollisionFallbacks} 个 DAE collision 转换失败,已替换为半径 0.05 m 的占位球体;碰撞精度会降低`);
+ if (convertedDaeReferences)
+ warnings.push(
+ `已将 ${converted.size} 个 DAE 文件转换为 OBJ,供 ${convertedDaeReferences} 个 visual/collision 使用`,
+ );
+ if (removedDaeVisuals)
+ warnings.push(
+ `${removedDaeVisuals} 个 DAE visual 转换失败,已移除并使用其他 collision 几何显示/仿真`,
+ );
+ if (daeCollisionFallbacks)
+ warnings.push(
+ `${daeCollisionFallbacks} 个 DAE collision 转换失败,已替换为半径 0.05 m 的占位球体;碰撞精度会降低`,
+ );
- const xml=new TextEncoder().encode(new XMLSerializer().serializeToString(document));
- const replacement:ProjectFile={...source,data:xml,size:xml.byteLength};
- const generated=Array.from(converted.values());
- const files=[...manifest.files.map(file=>file===source?replacement:file),...generated];
- return {manifest:{...manifest,files,totalBytes:files.reduce((total,file)=>total+file.size,0)},warnings};
+ const xml = new TextEncoder().encode(new XMLSerializer().serializeToString(document));
+ const replacement: ProjectFile = { ...source, data: xml, size: xml.byteLength };
+ const generated = Array.from(converted.values());
+ const files = [
+ ...manifest.files.map((file) => (file === source ? replacement : file)),
+ ...generated,
+ ];
+ return {
+ manifest: {
+ ...manifest,
+ files,
+ totalBytes: files.reduce((total, file) => total + file.size, 0),
+ },
+ warnings,
+ };
}
export function choosePreferredEntry(entries: ModelEntry[]): string | undefined {
if (entries.length === 1) return entries[0].path;
- const rootPreferred = entries.find((e) => !e.path.includes('/') && /^(model|scene)\.xml$/i.test(e.path));
+ const rootPreferred = entries.find(
+ (e) => !e.path.includes('/') && /^(model|scene)\.xml$/i.test(e.path),
+ );
if (rootPreferred) return rootPreferred.path;
const urdfs = entries.filter((e) => e.format === 'urdf');
return urdfs.length === 1 ? urdfs[0].path : undefined;
}
function manifest(name: string, files: ProjectFile[]): ProjectManifest {
- const entries = discoverEntries(files);
- if (!entries.length) throw new ProjectImportError('未发现包含 或 根元素的 XML/URDF 入口');
- return {id: `${Date.now().toString(36)}-${crypto.randomUUID().slice(0, 8)}`, name, files, entries, selectedEntry: choosePreferredEntry(entries), totalBytes: files.reduce((n, f) => n + f.size, 0)};
+ 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('未发现包含 或 根元素的 XML/URDF 入口');
+ return {
+ id: `${Date.now().toString(36)}-${crypto.randomUUID().slice(0, 8)}`,
+ name,
+ files,
+ entries,
+ maps,
+ selectedEntry: choosePreferredEntry(entries),
+ totalBytes: files.reduce((n, f) => n + f.size, 0),
+ };
}
-export async function importBrowserFiles(input: File[], limits: ImportLimits = DEFAULT_IMPORT_LIMITS): Promise {
+export async function importBrowserFiles(
+ input: File[],
+ limits: ImportLimits = DEFAULT_IMPORT_LIMITS,
+ onProgress?: ProjectImportProgressCallback,
+): Promise {
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} 字节)`);
- const bytes = new Uint8Array(await input[0].arrayBuffer()); checkEncryptedZip(bytes);
+ 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;
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>((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(`ZIP 解压失败:${error instanceof Error ? error.message : String(error)}`);
+ throw new ProjectImportError(
+ `ZIP 解压失败:${error instanceof Error ? error.message : String(error)}`,
+ );
}
- const files = Object.entries(unpacked).filter(([path]) => !path.endsWith('/')).map(([path, data]): ProjectFile => ({path: normalizeProjectPath(path), data, size: data.byteLength, source: 'zip', mimeType: ''}));
- enforceLimits(files, limits); return manifest(input[0].name.replace(/\.zip$/i, ''), files);
+ const files = Object.entries(unpacked)
+ .filter(([path]) => !path.endsWith('/'))
+ .map(([path, data]): ProjectFile => ({
+ path: normalizeProjectPath(path),
+ data,
+ size: data.byteLength,
+ source: 'zip',
+ 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 => {
- const relative = (file as File & {webkitRelativePath?: string}).webkitRelativePath || file.name;
- const data = new Uint8Array(await file.arrayBuffer());
- return {path: normalizeProjectPath(relative), data, size: data.byteLength, source: relative === file.name ? 'file' : 'directory', mimeType: file.type};
- }));
- enforceLimits(files, limits); return manifest(files[0].path.split('/')[0] || '工程', files);
+
+ 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