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 仿真平台 + + + +
+
+
+
M
+
正在启动本地仿真工作台…
+
+
+
+ + + 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?
摄像头
:)}{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 ? ( +
+
+ + + 摄像头 + + +
+
+ ) : ( + + ))} + {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
=0?`${listId}-${filtered[highlighted].id}`:undefined} value={query} onChange={event=>{setQuery(event.target.value);setActive(0);}} onKeyDown={event=>{if(!enabled.length)return;const current=Math.max(0,enabled.indexOf(highlighted));if(event.key==='ArrowDown'){event.preventDefault();setActive(enabled[(current+1)%enabled.length]);}else if(event.key==='ArrowUp'){event.preventDefault();setActive(enabled[(current-1+enabled.length)%enabled.length]);}else if(event.key==='Enter'){event.preventDefault();execute(filtered[highlighted]);}}} placeholder="输入命令名称…" className="h-11 w-full bg-input pl-11 pr-4 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none"/>
{filtered.length?filtered.map((command,index)=>):}
; +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 ( + +
+ + = 0 ? `${listId}-${filtered[highlighted].id}` : undefined + } + value={query} + onChange={(event) => { + setQuery(event.target.value); + setActive(0); + }} + onKeyDown={(event) => { + if (!enabled.length) return; + const current = Math.max(0, enabled.indexOf(highlighted)); + if (event.key === 'ArrowDown') { + event.preventDefault(); + setActive(enabled[(current + 1) % enabled.length]); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + setActive(enabled[(current - 1 + enabled.length) % enabled.length]); + } else if (event.key === 'Enter') { + event.preventDefault(); + execute(filtered[highlighted]); + } + }} + placeholder="输入命令名称…" + className="h-11 w-full bg-input pl-11 pr-4 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none" + /> +
+
+ {filtered.length ? ( + filtered.map((command, index) => ( + + )) + ) : ( + + )} +
+
+ ); } 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 ? ( + + ) : ( + + )} + +
+
+ + + +
+
+ +
+

实时运动状态

+ + + + + + + + + + + + +
+ +
+

本次记录摘要

+ + + + + +
+ + +
+
+

+ 每次仿真重置会创建新分段,避免跨重置计算出错误速度。开发接口支持注册额外标量通道,导出列名保持稳定。 +

+
+ ); +} 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 }>item.tone==='warning').length}`,content:content('warning')},{value:'danger',label:`错误 ${items.filter(item=>item.tone==='danger').length}`,content:content('danger')}]}/>;} +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 ( + + + + } + > + item.tone === 'warning').length}`, + content: content('warning'), + }, + { + value: 'danger', + label: `错误 ${items.filter((item) => item.tone === 'danger').length}`, + content: content('danger'), + }, + ]} + /> + + ); +} 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 0} onClose={noop} closable={false} title="选择模型入口">

工程包含多个可加载模型,请选择一个。

{entries.map(entry=>)}
;} +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 ( + 0} onClose={noop} closable={false} title="选择模型入口"> +

工程包含多个可加载模型,请选择一个。

+
+ {entries.map((entry) => ( + + ))} +
+
+ ); +} 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

布局预设

{presets.map(item=>)}
;} +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 ( + +
+ + +
+

布局预设

+
+ {presets.map((item) => ( + + ))} +
+ +
+ ); +} // 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)}/>
- - -

训练使用本地 mjlab 任务资产,不会把浏览器中的模型上传到网络。服务一次只运行一个训练任务。

-
} - {job&&
-
{job.taskId}{stateLabel(job.state)}
-
- {job.logs.length>0&&
最近日志
{job.logs.slice(-40).join('\n')}
} -
{active?:<>}
-
} - {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)} + /> + +
+ + + + +

+ 训练使用本地 mjlab + 任务资产,不会把浏览器中的模型上传到网络。服务一次只运行一个训练任务。 +

+
+ )} + {job && ( +
+
+ + {job.taskId} + + + {stateLabel(job.state)} + +
+ +
+ + +
+ {job.logs.length > 0 && ( +
+ 最近日志 +
+                {job.logs.slice(-40).join('\n')}
+              
+
+ )} +
+ {active ? ( + + ) : ( + <> + + + + )} +
+
+ )} + {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 ( + + } + > +

+ 导入 {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”加载模式时不会注入这些组件。 +

+ + ); } 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}}
;} +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} + + )} +
+
+
+ + ); +} 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

MuJoCo Web 仿真平台

{center}
{endActions}{compactMenu}{fullscreen?:}{theme==='dark'?:}
;} +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 ( +
+
+
+ + +

+ MuJoCo Web 仿真平台 +

+
+ + + +
+
{center}
+
+ + + + + + + + + + + + + + + + {endActions} + {compactMenu} + + + + + {fullscreen ? : } + + + + + + {theme === 'dark' ? : } + +
+
+ ); +} 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)=>
  1. 0{index+1}
    {item.detail}
  2. )}

;} -export function WorkspaceOverlays({loading,hasSnapshot,progress}:{loading:boolean;hasSnapshot:boolean;progress?:ImportProgress}){return <>{!hasSnapshot&&!loading&&
}{loading&&
{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 ( +
+
+ ); +} + +function LoadingCard({ progress, compact }: { progress?: ImportProgress; compact: boolean }) { + const title = progress?.title ?? '正在加载 MuJoCo 与模型'; + return ( +
+
+
+
+ +
+ {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
}>{children};} +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 ( + + + + + } + > + {children} + + ); +} 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 <>setOpen(false)} title="入口选择">;} -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({}} title="全屏弹窗">内容);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 ( + <> + + setOpen(false)} title="入口选择"> + + + + + ); +} +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( + {}} title="全屏弹窗"> + 内容 + , + ); + 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=;} +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
    • ;})}
    ; +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 ( +
    • +
    • + ); + })} +
    + ); } -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 0

    0 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(); + for (const { file, path } of sources) { + if (seen.has(path)) throw new ProjectImportError('工程中存在同名路径', path); + seen.add(path); + if (file.size > limits.maxFileBytes) + throw new ProjectImportError(`单文件超过限制(${limits.maxFileBytes} 字节)`, path); + totalBytes += file.size; + if (totalBytes > limits.maxTotalBytes) + throw new ProjectImportError(`工程总大小超过限制(${limits.maxTotalBytes} 字节)`); + } + + const files = new Array(sources.length); + let cursor = 0; + let completed = 0; + onProgress?.({ phase: 'reading', completed, total: sources.length }); + const readNext = async () => { + for (;;) { + const index = cursor++; + if (index >= sources.length) return; + const { file, relative, path } = sources[index]; + const data = new Uint8Array(await file.arrayBuffer()); + files[index] = { + path, + data, + size: data.byteLength, + source: relative === file.name ? 'file' : 'directory', + mimeType: file.type, + }; + completed += 1; + onProgress?.({ phase: 'reading', completed, total: sources.length, path }); + } + }; + await Promise.all(Array.from({ length: Math.min(4, sources.length) }, () => readNext())); + enforceLimits(files, limits); + onProgress?.({ phase: 'indexing', completed: 1, total: 1 }); + return manifest(files[0].path.split('/')[0] || '工程', files); } -interface LegacyEntry {isFile: boolean; isDirectory: boolean; name: string; file(cb: (file: File) => void, err: (e: DOMException) => void): void; createReader(): {readEntries(cb: (entries: LegacyEntry[]) => void, err: (e: DOMException) => void): void};} +interface LegacyEntry { + isFile: boolean; + isDirectory: boolean; + name: string; + file(cb: (file: File) => void, err: (e: DOMException) => void): void; + createReader(): { + readEntries(cb: (entries: LegacyEntry[]) => void, err: (e: DOMException) => void): void; + }; +} async function readEntry(entry: LegacyEntry, prefix = ''): Promise { - if (entry.isFile) return [await new Promise((resolve, reject) => entry.file((file) => {Object.defineProperty(file, 'webkitRelativePath', {value: `${prefix}${file.name}`}); resolve(file);}, reject))]; - const reader = entry.createReader(); const children: LegacyEntry[] = []; - for (;;) { const batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject)); if (!batch.length) break; children.push(...batch); } - return (await Promise.all(children.map((child) => readEntry(child, `${prefix}${entry.name}/`)))).flat(); + if (entry.isFile) + return [ + await new Promise((resolve, reject) => + entry.file((file) => { + Object.defineProperty(file, 'webkitRelativePath', { value: `${prefix}${file.name}` }); + resolve(file); + }, reject), + ), + ]; + const reader = entry.createReader(); + const children: LegacyEntry[] = []; + for (;;) { + const batch = await new Promise((resolve, reject) => + reader.readEntries(resolve, reject), + ); + if (!batch.length) break; + children.push(...batch); + } + return ( + await Promise.all(children.map((child) => readEntry(child, `${prefix}${entry.name}/`))) + ).flat(); } -export async function filesFromDrop(items: DataTransferItemList, fallback: FileList): Promise { - const entries = Array.from(items).map((item) => (item as unknown as {webkitGetAsEntry?: () => LegacyEntry | null}).webkitGetAsEntry?.() ?? null).filter((entry): entry is LegacyEntry => entry !== null); - return entries.length ? (await Promise.all(entries.map((entry) => readEntry(entry)))).flat() : Array.from(fallback); +export async function filesFromDrop( + items: DataTransferItemList, + fallback: FileList, +): Promise { + const entries = Array.from(items) + .map( + (item) => + (item as unknown as { webkitGetAsEntry?: () => LegacyEntry | null }).webkitGetAsEntry?.() ?? + null, + ) + .filter((entry): entry is LegacyEntry => entry !== null); + return entries.length + ? (await Promise.all(entries.map((entry) => readEntry(entry)))).flat() + : Array.from(fallback); } diff --git a/web_platform/src/project/types.ts b/web_platform/src/project/types.ts index e1fb3961..c905aa6a 100644 --- a/web_platform/src/project/types.ts +++ b/web_platform/src/project/types.ts @@ -14,11 +14,23 @@ export interface ModelEntry { label: string; } +export interface MapEntry { + descriptorPath: string; + schemaVersion: 1 | 2; + id: string; + name: string; + physicsPath?: string; + visualPath?: string; + authoringPath?: string; + spawnPoints: Array<{ id: string; name: string }>; +} + export interface ProjectManifest { id: string; name: string; files: ProjectFile[]; entries: ModelEntry[]; + maps: MapEntry[]; selectedEntry?: string; totalBytes: number; } diff --git a/web_platform/src/project/urdfToMjcf.test.ts b/web_platform/src/project/urdfToMjcf.test.ts index 6950e05e..8560a159 100644 --- a/web_platform/src/project/urdfToMjcf.test.ts +++ b/web_platform/src/project/urdfToMjcf.test.ts @@ -1,73 +1,165 @@ -import {enhanceConvertedMjcf,groundConvertedMjcf} from './urdfToMjcf'; +import { enhanceConvertedMjcf, groundConvertedMjcf } from './urdfToMjcf'; -const encode=(value:string)=>new TextEncoder().encode(value); -const decode=(value:Uint8Array)=>new TextDecoder().decode(value); +const encode = (value: string) => new TextEncoder().encode(value); +const decode = (value: Uint8Array) => new TextDecoder().decode(value); -describe('groundConvertedMjcf',()=>{ - it('抬升所有根 body,并在 z=0 添加地面',()=>{ - const result=decode(groundConvertedMjcf(encode(''),-0.4,'fixed')); - const document=new DOMParser().parseFromString(result,'application/xml'); +describe('groundConvertedMjcf', () => { + it('抬升所有根 body,并在 z=0 添加地面', () => { + const result = decode( + groundConvertedMjcf( + encode( + '', + ), + -0.4, + 'fixed', + ), + ); + const document = new DOMParser().parseFromString(result, 'application/xml'); expect(document.querySelector('body[name="robot"]')?.getAttribute('pos')).toBe('1 2 0.5'); - expect(document.querySelector('geom[name="__platform_ground__"]')).toMatchObject({tagName:'geom'}); - expect(document.querySelector('geom[name="__platform_ground__"]')?.getAttribute('group')).toBe('5'); + expect(document.querySelector('geom[name="__platform_ground__"]')).toMatchObject({ + tagName: 'geom', + }); + expect(document.querySelector('geom[name="__platform_ground__"]')?.getAttribute('group')).toBe( + '5', + ); expect(document.querySelector('freejoint')).toBeNull(); }); - it('为浮动基座的每个世界根 body 添加 freejoint',()=>{ - const result=decode(groundConvertedMjcf(encode(''),-1,'floating')); - const document=new DOMParser().parseFromString(result,'application/xml'); - expect(document.querySelector('body[name="robot"] > freejoint')?.getAttribute('name')).toBe('__platform_base_freejoint__'); + it('为浮动基座的每个世界根 body 添加 freejoint', () => { + const result = decode( + groundConvertedMjcf( + encode( + '', + ), + -1, + 'floating', + ), + ); + const document = new DOMParser().parseFromString(result, 'application/xml'); + expect(document.querySelector('body[name="robot"] > freejoint')?.getAttribute('name')).toBe( + '__platform_base_freejoint__', + ); expect(document.querySelector('body[name="robot"]')?.getAttribute('pos')).toBe('0 0 1'); }); }); -describe('enhanceConvertedMjcf',()=>{ - it('为可驱动关节补充 motor,并跳过已有驱动器和 ball joint',()=>{ - const source=''; - const result=enhanceConvertedMjcf(encode(source),{addActuators:true,addSensors:false,sensorType:'camera'}); - const document=new DOMParser().parseFromString(decode(result.data),'application/xml'); +describe('enhanceConvertedMjcf', () => { + it('为可驱动关节补充 motor,并跳过已有驱动器和 ball joint', () => { + const source = + ''; + const result = enhanceConvertedMjcf(encode(source), { + addActuators: true, + addSensors: false, + sensorType: 'camera', + }); + const document = new DOMParser().parseFromString(decode(result.data), 'application/xml'); expect(result.actuatorCount).toBe(1); - expect(document.querySelector('motor[joint="slider"]')?.getAttribute('ctrllimited')).toBe('false'); + expect(document.querySelector('motor[joint="slider"]')?.getAttribute('ctrllimited')).toBe( + 'false', + ); expect(document.querySelector('motor[joint="slider"]')?.hasAttribute('ctrlrange')).toBe(false); - expect(document.querySelector('motor[joint="slider"]')?.getAttribute('name')).toBe('slider_motor'); - expect(document.querySelector('motor[joint="slider"]')?.getAttribute('forcerange')).toBe('-100 100'); + expect(document.querySelector('motor[joint="slider"]')?.getAttribute('name')).toBe( + 'slider_motor', + ); + expect(document.querySelector('motor[joint="slider"]')?.getAttribute('forcerange')).toBe( + '-100 100', + ); expect(document.querySelector('joint[name="slider"]')?.getAttribute('stiffness')).toBe('0'); expect(document.querySelector('joint[name="slider"]')?.getAttribute('damping')).toBe('0'); expect(document.querySelectorAll('[joint="shoulder"]')).toHaveLength(1); - expect(document.querySelectorAll('[jointinparent="parent_driven"], [joint="parent_driven"]')).toHaveLength(1); + expect( + document.querySelectorAll('[jointinparent="parent_driven"], [joint="parent_driven"]'), + ).toHaveLength(1); expect(document.querySelector('[joint="ball"]')).toBeNull(); }); - it('识别 Go2-W 并补齐官方 MuJoCo 稳定性参数',()=>{ - const prefixes=['FL','FR','RL','RR'],parts=['hip','thigh','calf']; - const joints=[...prefixes.flatMap(prefix=>parts.map(part=>``)),...prefixes.map(prefix=>``)].join(''); - const result=enhanceConvertedMjcf(encode(`${joints}`),{addActuators:true,addSensors:false,sensorType:'camera'}),document=new DOMParser().parseFromString(decode(result.data),'application/xml'); - expect(result.unitreeGo2wTuned).toBe(true);expect(document.querySelector('option')?.getAttribute('cone')).toBe('elliptic');expect(document.querySelector('joint[name="FL_thigh_joint"]')?.getAttribute('armature')).toBe('0.01');expect(document.querySelector('joint[name="FL_thigh_joint"]')?.getAttribute('damping')).toBe('0.1');expect(document.querySelector('motor[joint="FL_calf_joint"]')?.getAttribute('forcerange')).toBe('-45.43 45.43');expect(document.querySelector('motor[joint="FL_foot_joint"]')?.getAttribute('forcerange')).toBe('-15 15');expect(document.querySelector('body[name="FL_foot"] geom')?.getAttribute('condim')).toBe('6'); + it('识别 Go2-W 并补齐官方 MuJoCo 稳定性参数', () => { + const prefixes = ['FL', 'FR', 'RL', 'RR'], + parts = ['hip', 'thigh', 'calf']; + const joints = [ + ...prefixes.flatMap((prefix) => + parts.map((part) => ``), + ), + ...prefixes.map( + (prefix) => + ``, + ), + ].join(''); + const result = enhanceConvertedMjcf( + encode(`${joints}`), + { addActuators: true, addSensors: false, sensorType: 'camera' }, + ), + document = new DOMParser().parseFromString(decode(result.data), 'application/xml'); + expect(result.unitreeGo2wTuned).toBe(true); + expect(document.querySelector('option')?.getAttribute('cone')).toBe('elliptic'); + expect(document.querySelector('joint[name="FL_thigh_joint"]')?.getAttribute('armature')).toBe( + '0.01', + ); + expect(document.querySelector('joint[name="FL_thigh_joint"]')?.getAttribute('damping')).toBe( + '0.1', + ); + expect(document.querySelector('motor[joint="FL_calf_joint"]')?.getAttribute('forcerange')).toBe( + '-45.43 45.43', + ); + expect(document.querySelector('motor[joint="FL_foot_joint"]')?.getAttribute('forcerange')).toBe( + '-15 15', + ); + expect(document.querySelector('body[name="FL_foot"] geom')?.getAttribute('condim')).toBe('6'); }); - it('将可调摄像头固连到指定机器人 body',()=>{ - const result=enhanceConvertedMjcf(encode(''),{addActuators:false,addSensors:true,sensorType:'camera',cameraMountBody:'head',cameraPosition:[.2,0,.1],cameraDirection:'+X'}); - const document=new DOMParser().parseFromString(decode(result.data),'application/xml'); - const camera=document.querySelector('body[name="head"] > camera'); + it('将可调摄像头固连到指定机器人 body', () => { + const result = enhanceConvertedMjcf( + encode( + '', + ), + { + addActuators: false, + addSensors: true, + sensorType: 'camera', + cameraMountBody: 'head', + cameraPosition: [0.2, 0, 0.1], + cameraDirection: '+X', + }, + ); + const document = new DOMParser().parseFromString(decode(result.data), 'application/xml'); + const camera = document.querySelector('body[name="head"] > camera'); expect(result.cameraAdded).toBe(true); expect(result.imuAdded).toBe(true); - expect(document.querySelector('body[name="base"] > site[name="imu"]')).toMatchObject({tagName:'site'}); - expect(document.querySelector('sensor > gyro[name="imu_gyro"]')?.getAttribute('site')).toBe('imu'); - expect(document.querySelector('sensor > accelerometer[name="imu_acc"]')?.getAttribute('site')).toBe('imu'); + expect(document.querySelector('body[name="base"] > site[name="imu"]')).toMatchObject({ + tagName: 'site', + }); + expect(document.querySelector('sensor > gyro[name="imu_gyro"]')?.getAttribute('site')).toBe( + 'imu', + ); + expect( + document.querySelector('sensor > accelerometer[name="imu_acc"]')?.getAttribute('site'), + ).toBe('imu'); expect(camera?.getAttribute('mode')).toBe('fixed'); expect(camera?.getAttribute('pos')).toBe('0.2 0 0.1'); expect(camera?.getAttribute('xyaxes')).toBe('0 -1 0 0 0 1'); expect(camera?.getAttribute('resolution')).toBe('640 480'); - const repeated=enhanceConvertedMjcf(result.data,{addActuators:false,addSensors:true,sensorType:'camera'}); - const repeatedDocument=new DOMParser().parseFromString(decode(repeated.data),'application/xml'); + const repeated = enhanceConvertedMjcf(result.data, { + addActuators: false, + addSensors: true, + sensorType: 'camera', + }); + const repeatedDocument = new DOMParser().parseFromString( + decode(repeated.data), + 'application/xml', + ); expect(repeatedDocument.querySelectorAll('camera')).toHaveLength(1); expect(repeatedDocument.querySelectorAll('sensor > gyro')).toHaveLength(1); expect(repeatedDocument.querySelectorAll('sensor > accelerometer')).toHaveLength(1); }); - it('关闭选项时不修改 actuator 和 camera',()=>{ - const result=enhanceConvertedMjcf(encode(''),{addActuators:false,addSensors:false,sensorType:'camera'}); - const document=new DOMParser().parseFromString(decode(result.data),'application/xml'); + it('关闭选项时不修改 actuator 和 camera', () => { + const result = enhanceConvertedMjcf( + encode( + '', + ), + { addActuators: false, addSensors: false, sensorType: 'camera' }, + ); + const document = new DOMParser().parseFromString(decode(result.data), 'application/xml'); expect(document.querySelector('actuator')).toBeNull(); expect(document.querySelector('camera')).toBeNull(); }); diff --git a/web_platform/src/project/urdfToMjcf.ts b/web_platform/src/project/urdfToMjcf.ts index df81a021..76a0caf9 100644 --- a/web_platform/src/project/urdfToMjcf.ts +++ b/web_platform/src/project/urdfToMjcf.ts @@ -1,101 +1,275 @@ -const decoder=new TextDecoder('utf-8'); -const encoder=new TextEncoder(); +const decoder = new TextDecoder('utf-8'); +const encoder = new TextEncoder(); -function numbers(value:string|undefined,count:number):number[]{ - const parsed=(value??'').trim().split(/\s+/).filter(Boolean).map(Number); - return Array.from({length:count},(_,index)=>Number.isFinite(parsed[index])?parsed[index]:0); +function numbers(value: string | undefined, count: number): number[] { + const parsed = (value ?? '').trim().split(/\s+/).filter(Boolean).map(Number); + return Array.from({ length: count }, (_, index) => + Number.isFinite(parsed[index]) ? parsed[index] : 0, + ); } -export type UrdfBaseMode='floating'|'fixed'; -export type CameraDirection='+X'|'-X'|'+Y'|'-Y'|'+Z'|'-Z'; -export interface UrdfEnhancementOptions {addActuators:boolean;addSensors:boolean;sensorType:'camera';cameraMountBody?:string;cameraPosition?:[number,number,number];cameraDirection?:CameraDirection;} -export interface UrdfEnhancementResult {data:Uint8Array;actuatorCount:number;cameraAdded:boolean;imuAdded:boolean;unitreeGo2wTuned:boolean;} - -function uniqueName(document:Document,selector:string,base:string):string { - const occupied=new Set(Array.from(document.querySelectorAll(selector)).map(element=>element.getAttribute('name')).filter(Boolean)); - if(!occupied.has(base))return base; - for(let index=2;;index+=1)if(!occupied.has(`${base}_${index}`))return `${base}_${index}`; +export type UrdfBaseMode = 'floating' | 'fixed'; +export type CameraDirection = '+X' | '-X' | '+Y' | '-Y' | '+Z' | '-Z'; +export interface UrdfEnhancementOptions { + addActuators: boolean; + addSensors: boolean; + sensorType: 'camera'; + cameraMountBody?: string; + cameraPosition?: [number, number, number]; + cameraDirection?: CameraDirection; +} +export interface UrdfEnhancementResult { + data: Uint8Array; + actuatorCount: number; + cameraAdded: boolean; + imuAdded: boolean; + unitreeGo2wTuned: boolean; } -function tuneUnitreeGo2w(document:Document):boolean { - const prefixes=['FL','FR','RL','RR'],legParts=['hip','thigh','calf']; - const expected=[...prefixes.flatMap(prefix=>legParts.map(part=>`${prefix}_${part}_joint`)),...prefixes.map(prefix=>`${prefix}_foot_joint`)]; - const joints=new Map(Array.from(document.querySelectorAll('worldbody joint[name]')).map(joint=>[joint.getAttribute('name')!,joint])); - if(!expected.every(name=>joints.has(name)))return false; - const root=document.querySelector('mujoco');if(!root)return false; - let option=Array.from(root.children).find(element=>element.tagName==='option');if(!option){option=document.createElement('option');root.prepend(option);} - option.setAttribute('cone','elliptic');option.setAttribute('impratio','100'); - for(const name of expected){const joint=joints.get(name)!;joint.setAttribute('armature','0.01');joint.setAttribute('damping','0.1');joint.setAttribute('frictionloss','0.2');} - for(const motor of Array.from(document.querySelectorAll('actuator > *[joint]'))){const jointName=motor.getAttribute('joint')??'';if(!expected.includes(jointName))continue;const limit=jointName.includes('_calf_')?45.43:jointName.endsWith('_foot_joint')?15:23.7;motor.setAttribute('forcelimited','true');motor.setAttribute('forcerange',`${-limit} ${limit}`);} - for(const geom of Array.from(document.querySelectorAll('worldbody body geom'))){if(geom.getAttribute('contype')==='0'||geom.getAttribute('group')==='1')continue;geom.setAttribute('friction','0.4 0.005 0.0001');geom.setAttribute('margin','0.001');geom.setAttribute('condim','1');const body=geom.closest('body'),name=body?.getAttribute('name')??'';if(/_(?:foot|wheel)(?:_link)?$/i.test(name)){geom.setAttribute('friction','0.8 0.02 0.01');geom.setAttribute('condim','6');geom.setAttribute('priority','1');}} +function uniqueName(document: Document, selector: string, base: string): string { + const occupied = new Set( + Array.from(document.querySelectorAll(selector)) + .map((element) => element.getAttribute('name')) + .filter(Boolean), + ); + if (!occupied.has(base)) return base; + for (let index = 2; ; index += 1) + if (!occupied.has(`${base}_${index}`)) return `${base}_${index}`; +} + +function tuneUnitreeGo2w(document: Document): boolean { + const prefixes = ['FL', 'FR', 'RL', 'RR'], + legParts = ['hip', 'thigh', 'calf']; + const expected = [ + ...prefixes.flatMap((prefix) => legParts.map((part) => `${prefix}_${part}_joint`)), + ...prefixes.map((prefix) => `${prefix}_foot_joint`), + ]; + const joints = new Map( + Array.from(document.querySelectorAll('worldbody joint[name]')).map((joint) => [ + joint.getAttribute('name')!, + joint, + ]), + ); + if (!expected.every((name) => joints.has(name))) return false; + const root = document.querySelector('mujoco'); + if (!root) return false; + let option = Array.from(root.children).find((element) => element.tagName === 'option'); + if (!option) { + option = document.createElement('option'); + root.prepend(option); + } + option.setAttribute('cone', 'elliptic'); + option.setAttribute('impratio', '100'); + for (const name of expected) { + const joint = joints.get(name)!; + joint.setAttribute('armature', '0.01'); + joint.setAttribute('damping', '0.1'); + joint.setAttribute('frictionloss', '0.2'); + } + for (const motor of Array.from(document.querySelectorAll('actuator > *[joint]'))) { + const jointName = motor.getAttribute('joint') ?? ''; + if (!expected.includes(jointName)) continue; + const limit = jointName.includes('_calf_') + ? 45.43 + : jointName.endsWith('_foot_joint') + ? 15 + : 23.7; + motor.setAttribute('forcelimited', 'true'); + motor.setAttribute('forcerange', `${-limit} ${limit}`); + } + for (const geom of Array.from(document.querySelectorAll('worldbody body geom'))) { + if (geom.getAttribute('contype') === '0' || geom.getAttribute('group') === '1') continue; + geom.setAttribute('friction', '0.4 0.005 0.0001'); + geom.setAttribute('margin', '0.001'); + geom.setAttribute('condim', '1'); + const body = geom.closest('body'), + name = body?.getAttribute('name') ?? ''; + if (/_(?:foot|wheel)(?:_link)?$/i.test(name)) { + geom.setAttribute('friction', '0.8 0.02 0.01'); + geom.setAttribute('condim', '6'); + geom.setAttribute('priority', '1'); + } + } return true; } /** 为转换后的 MJCF 按需补充可直接控制的关节电机和一台自动取景相机。 */ -export function enhanceConvertedMjcf(data:Uint8Array,options:UrdfEnhancementOptions):UrdfEnhancementResult { - const document=new DOMParser().parseFromString(decoder.decode(data),'application/xml'); - if(document.querySelector('parsererror'))throw new Error('MuJoCo 导出的 MJCF XML 无法解析'); - const root=document.querySelector('mujoco'),worldbody=document.querySelector('mujoco > worldbody'); - if(!root||!worldbody)throw new Error('MuJoCo 导出的 MJCF 缺少 worldbody'); - let actuatorCount=0; - if(options.addActuators){ - let actuator=Array.from(root.children).find(element=>element.tagName==='actuator'); - if(!actuator){actuator=document.createElement('actuator');root.append(actuator);} - const driven=new Set(Array.from(actuator.children).flatMap(element=>[element.getAttribute('joint'),element.getAttribute('jointinparent')]).filter(Boolean)); - for(const joint of Array.from(worldbody.querySelectorAll('joint'))){ - const type=joint.getAttribute('type')??'hinge'; - if(type!=='hinge'&&type!=='slide')continue; - let jointName=joint.getAttribute('name'); - if(!jointName){jointName=uniqueName(document,'joint[name]','__platform_joint__');joint.setAttribute('name',jointName);} - if(driven.has(jointName))continue; - const motor=document.createElement('motor'); - motor.setAttribute('name',uniqueName(document,'actuator > *[name]',`${jointName}_motor`)); - motor.setAttribute('joint',jointName);motor.setAttribute('ctrllimited','false');motor.setAttribute('forcelimited','true');motor.setAttribute('forcerange','-100 100'); - if(!joint.hasAttribute('stiffness'))joint.setAttribute('stiffness','0');if(!joint.hasAttribute('damping'))joint.setAttribute('damping','0'); - actuator.append(motor);driven.add(jointName);actuatorCount+=1; +export function enhanceConvertedMjcf( + data: Uint8Array, + options: UrdfEnhancementOptions, +): UrdfEnhancementResult { + const document = new DOMParser().parseFromString(decoder.decode(data), 'application/xml'); + if (document.querySelector('parsererror')) throw new Error('MuJoCo 导出的 MJCF XML 无法解析'); + const root = document.querySelector('mujoco'), + worldbody = document.querySelector('mujoco > worldbody'); + if (!root || !worldbody) throw new Error('MuJoCo 导出的 MJCF 缺少 worldbody'); + let actuatorCount = 0; + if (options.addActuators) { + let actuator = Array.from(root.children).find((element) => element.tagName === 'actuator'); + if (!actuator) { + actuator = document.createElement('actuator'); + root.append(actuator); } - if(!actuator.children.length)actuator.remove(); + const driven = new Set( + Array.from(actuator.children) + .flatMap((element) => [ + element.getAttribute('joint'), + element.getAttribute('jointinparent'), + ]) + .filter(Boolean), + ); + for (const joint of Array.from(worldbody.querySelectorAll('joint'))) { + const type = joint.getAttribute('type') ?? 'hinge'; + if (type !== 'hinge' && type !== 'slide') continue; + let jointName = joint.getAttribute('name'); + if (!jointName) { + jointName = uniqueName(document, 'joint[name]', '__platform_joint__'); + joint.setAttribute('name', jointName); + } + if (driven.has(jointName)) continue; + const motor = document.createElement('motor'); + motor.setAttribute('name', uniqueName(document, 'actuator > *[name]', `${jointName}_motor`)); + motor.setAttribute('joint', jointName); + motor.setAttribute('ctrllimited', 'false'); + motor.setAttribute('forcelimited', 'true'); + motor.setAttribute('forcerange', '-100 100'); + if (!joint.hasAttribute('stiffness')) joint.setAttribute('stiffness', '0'); + if (!joint.hasAttribute('damping')) joint.setAttribute('damping', '0'); + actuator.append(motor); + driven.add(jointName); + actuatorCount += 1; + } + if (!actuator.children.length) actuator.remove(); } - const unitreeGo2wTuned=tuneUnitreeGo2w(document); - let imuAdded=false; - if(options.addSensors&&!document.querySelector('sensor > gyro[name="imu_gyro"], sensor > gyro[name="__platform_imu_gyro__"]')){ - const bodies=Array.from(worldbody.querySelectorAll('body')),mount=bodies.find(body=>/^(?:base|base_link|trunk)$/i.test(body.getAttribute('name')??''))??Array.from(worldbody.children).find(element=>element.tagName==='body'); - if(mount){ - const site=document.createElement('site'),siteName=uniqueName(document,'site[name]','imu');site.setAttribute('name',siteName);site.setAttribute('pos','0 0 0');site.setAttribute('size','0.005');site.setAttribute('rgba','0.2 0.8 1 0.5');mount.append(site); - let sensor=Array.from(root.children).find(element=>element.tagName==='sensor');if(!sensor){sensor=document.createElement('sensor');root.append(sensor);} - const gyro=document.createElement('gyro');gyro.setAttribute('name',uniqueName(document,'sensor > *[name]','imu_gyro'));gyro.setAttribute('site',siteName);gyro.setAttribute('noise','0');sensor.append(gyro); - const accelerometer=document.createElement('accelerometer');accelerometer.setAttribute('name',uniqueName(document,'sensor > *[name]','imu_acc'));accelerometer.setAttribute('site',siteName);accelerometer.setAttribute('noise','0');sensor.append(accelerometer);imuAdded=true; + const unitreeGo2wTuned = tuneUnitreeGo2w(document); + let imuAdded = false; + if ( + options.addSensors && + !document.querySelector( + 'sensor > gyro[name="imu_gyro"], sensor > gyro[name="__platform_imu_gyro__"]', + ) + ) { + const bodies = Array.from(worldbody.querySelectorAll('body')), + mount = + bodies.find((body) => + /^(?:base|base_link|trunk)$/i.test(body.getAttribute('name') ?? ''), + ) ?? Array.from(worldbody.children).find((element) => element.tagName === 'body'); + if (mount) { + const site = document.createElement('site'), + siteName = uniqueName(document, 'site[name]', 'imu'); + site.setAttribute('name', siteName); + site.setAttribute('pos', '0 0 0'); + site.setAttribute('size', '0.005'); + site.setAttribute('rgba', '0.2 0.8 1 0.5'); + mount.append(site); + let sensor = Array.from(root.children).find((element) => element.tagName === 'sensor'); + if (!sensor) { + sensor = document.createElement('sensor'); + root.append(sensor); + } + const gyro = document.createElement('gyro'); + gyro.setAttribute('name', uniqueName(document, 'sensor > *[name]', 'imu_gyro')); + gyro.setAttribute('site', siteName); + gyro.setAttribute('noise', '0'); + sensor.append(gyro); + const accelerometer = document.createElement('accelerometer'); + accelerometer.setAttribute('name', uniqueName(document, 'sensor > *[name]', 'imu_acc')); + accelerometer.setAttribute('site', siteName); + accelerometer.setAttribute('noise', '0'); + sensor.append(accelerometer); + imuAdded = true; } } - let cameraAdded=false; - if(options.addSensors&&options.sensorType==='camera'&&!document.querySelector('camera[name^="__platform_camera__"]')){ - const bodies=Array.from(worldbody.querySelectorAll('body')),preferred=options.cameraMountBody?bodies.find(body=>body.getAttribute('name')===options.cameraMountBody):undefined; - const mount=preferred??bodies.find(body=>/(head|camera|sensor|neck|头)/i.test(body.getAttribute('name')??''))??bodies.at(-1); - if(mount){ - const directions:Record={'+X':'0 -1 0 0 0 1','-X':'0 1 0 0 0 1','+Y':'1 0 0 0 0 1','-Y':'-1 0 0 0 0 1','+Z':'0 1 0 1 0 0','-Z':'0 -1 0 1 0 0'},position=options.cameraPosition??[.1,0,.05],direction=options.cameraDirection??'+X'; - const camera=document.createElement('camera');camera.setAttribute('name',uniqueName(document,'camera[name]','__platform_camera__'));camera.setAttribute('mode','fixed'); - camera.setAttribute('pos',position.map(value=>Number.isFinite(value)?value:0).join(' '));camera.setAttribute('xyaxes',directions[direction]);camera.setAttribute('fovy','45');camera.setAttribute('resolution','640 480');mount.append(camera);cameraAdded=true; + let cameraAdded = false; + if ( + options.addSensors && + options.sensorType === 'camera' && + !document.querySelector('camera[name^="__platform_camera__"]') + ) { + const bodies = Array.from(worldbody.querySelectorAll('body')), + preferred = options.cameraMountBody + ? bodies.find((body) => body.getAttribute('name') === options.cameraMountBody) + : undefined; + const mount = + preferred ?? + bodies.find((body) => + /(head|camera|sensor|neck|头)/i.test(body.getAttribute('name') ?? ''), + ) ?? + bodies.at(-1); + if (mount) { + const directions: Record = { + '+X': '0 -1 0 0 0 1', + '-X': '0 1 0 0 0 1', + '+Y': '1 0 0 0 0 1', + '-Y': '-1 0 0 0 0 1', + '+Z': '0 1 0 1 0 0', + '-Z': '0 -1 0 1 0 0', + }, + position = options.cameraPosition ?? [0.1, 0, 0.05], + direction = options.cameraDirection ?? '+X'; + const camera = document.createElement('camera'); + camera.setAttribute('name', uniqueName(document, 'camera[name]', '__platform_camera__')); + camera.setAttribute('mode', 'fixed'); + camera.setAttribute( + 'pos', + position.map((value) => (Number.isFinite(value) ? value : 0)).join(' '), + ); + camera.setAttribute('xyaxes', directions[direction]); + camera.setAttribute('fovy', '45'); + camera.setAttribute('resolution', '640 480'); + mount.append(camera); + cameraAdded = true; } } - return {data:encoder.encode(new XMLSerializer().serializeToString(document)),actuatorCount,cameraAdded,imuAdded,unitreeGo2wTuned}; + return { + data: encoder.encode(new XMLSerializer().serializeToString(document)), + actuatorCount, + cameraAdded, + imuAdded, + unitreeGo2wTuned, + }; } /** 给 MuJoCo 从 URDF 导出的 MJCF 添加地面、设置基座类型,并整体抬升根 body。 */ -export function groundConvertedMjcf(data:Uint8Array,minimumZ:number,baseMode:UrdfBaseMode='fixed'):Uint8Array { - const document=new DOMParser().parseFromString(decoder.decode(data),'application/xml'); - if(document.querySelector('parsererror'))throw new Error('MuJoCo 导出的 MJCF XML 无法解析'); - const worldbody=document.querySelector('mujoco > worldbody'); - if(!worldbody)throw new Error('MuJoCo 导出的 MJCF 缺少 worldbody'); - const lift=Number.isFinite(minimumZ)?-minimumZ:0; - const rootBodies=Array.from(worldbody.children).filter(element=>element.tagName==='body'); - for(const [index,body] of rootBodies.entries()){ - const pos=numbers(body.getAttribute('pos')??undefined,3);pos[2]+=lift;body.setAttribute('pos',pos.join(' ')); - if(baseMode==='floating'&&!Array.from(body.children).some(element=>element.tagName==='freejoint'||element.tagName==='joint')){ - const freejoint=document.createElement('freejoint');freejoint.setAttribute('name',rootBodies.length===1?'__platform_base_freejoint__':`__platform_base_freejoint_${index}__`);body.prepend(freejoint); +export function groundConvertedMjcf( + data: Uint8Array, + minimumZ: number, + baseMode: UrdfBaseMode = 'fixed', +): Uint8Array { + const document = new DOMParser().parseFromString(decoder.decode(data), 'application/xml'); + if (document.querySelector('parsererror')) throw new Error('MuJoCo 导出的 MJCF XML 无法解析'); + const worldbody = document.querySelector('mujoco > worldbody'); + if (!worldbody) throw new Error('MuJoCo 导出的 MJCF 缺少 worldbody'); + const lift = Number.isFinite(minimumZ) ? -minimumZ : 0; + const rootBodies = Array.from(worldbody.children).filter((element) => element.tagName === 'body'); + for (const [index, body] of rootBodies.entries()) { + const pos = numbers(body.getAttribute('pos') ?? undefined, 3); + pos[2] += lift; + body.setAttribute('pos', pos.join(' ')); + if ( + baseMode === 'floating' && + !Array.from(body.children).some( + (element) => element.tagName === 'freejoint' || element.tagName === 'joint', + ) + ) { + const freejoint = document.createElement('freejoint'); + freejoint.setAttribute( + 'name', + rootBodies.length === 1 + ? '__platform_base_freejoint__' + : `__platform_base_freejoint_${index}__`, + ); + body.prepend(freejoint); } } - const floor=document.createElement('geom'); - floor.setAttribute('name','__platform_ground__');floor.setAttribute('type','plane');floor.setAttribute('size','1 1 0.1');floor.setAttribute('pos','0 0 0');floor.setAttribute('rgba','0.12 0.16 0.22 1');floor.setAttribute('group','5');floor.setAttribute('friction','1 0.005 0.0001'); + const floor = document.createElement('geom'); + floor.setAttribute('name', '__platform_ground__'); + floor.setAttribute('type', 'plane'); + floor.setAttribute('size', '1 1 0.1'); + floor.setAttribute('pos', '0 0 0'); + floor.setAttribute('rgba', '0.12 0.16 0.22 1'); + floor.setAttribute('group', '5'); + floor.setAttribute('friction', '1 0.005 0.0001'); worldbody.prepend(floor); return encoder.encode(new XMLSerializer().serializeToString(document)); } diff --git a/web_platform/src/project/workspace.test.ts b/web_platform/src/project/workspace.test.ts index 6a33a3f3..0b96ef48 100644 --- a/web_platform/src/project/workspace.test.ts +++ b/web_platform/src/project/workspace.test.ts @@ -1,4 +1,35 @@ -import {MemfsWorkspace} from './workspace'; -import type {MainModule} from '@mujoco/mujoco'; -import type {ProjectManifest} from './types'; -it('按相对路径挂载并逆序清理 MEMFS',()=>{const calls:string[]=[];const FS={mkdirTree:(p:string)=>calls.push(`mkdir:${p}`),writeFile:(p:string)=>calls.push(`write:${p}`),unlink:(p:string)=>calls.push(`unlink:${p}`),rmdir:(p:string)=>calls.push(`rmdir:${p}`)};const workspace=new MemfsWorkspace({FS} as unknown as MainModule,'safe');const data=new Uint8Array([1]);const manifest:ProjectManifest={id:'safe',name:'x',entries:[],files:[{path:'a/b/model.xml',data,size:1,source:'file',mimeType:''}],totalBytes:1};workspace.mount(manifest);workspace.dispose();expect(calls).toEqual(expect.arrayContaining(['/workspace/safe/a/b/model.xml'].map(p=>`write:${p}`)));expect(calls).toContain('rmdir:/workspace/safe/a/b');expect(calls).toContain('rmdir:/workspace/safe/a');expect(calls.indexOf('unlink:/workspace/safe/a/b/model.xml')).toBeLessThan(calls.indexOf('rmdir:/workspace/safe/a/b'));expect(calls.indexOf('rmdir:/workspace/safe/a/b')).toBeLessThan(calls.indexOf('rmdir:/workspace/safe/a'));}); +import { MemfsWorkspace } from './workspace'; +import type { MainModule } from '@mujoco/mujoco'; +import type { ProjectManifest } from './types'; +it('按相对路径挂载并逆序清理 MEMFS', () => { + const calls: string[] = []; + const FS = { + mkdirTree: (p: string) => calls.push(`mkdir:${p}`), + writeFile: (p: string) => calls.push(`write:${p}`), + unlink: (p: string) => calls.push(`unlink:${p}`), + rmdir: (p: string) => calls.push(`rmdir:${p}`), + }; + const workspace = new MemfsWorkspace({ FS } as unknown as MainModule, 'safe'); + const data = new Uint8Array([1]); + const manifest: ProjectManifest = { + id: 'safe', + name: 'x', + entries: [], + maps: [], + files: [{ path: 'a/b/model.xml', data, size: 1, source: 'file', mimeType: '' }], + totalBytes: 1, + }; + workspace.mount(manifest); + workspace.dispose(); + expect(calls).toEqual( + expect.arrayContaining(['/workspace/safe/a/b/model.xml'].map((p) => `write:${p}`)), + ); + expect(calls).toContain('rmdir:/workspace/safe/a/b'); + expect(calls).toContain('rmdir:/workspace/safe/a'); + expect(calls.indexOf('unlink:/workspace/safe/a/b/model.xml')).toBeLessThan( + calls.indexOf('rmdir:/workspace/safe/a/b'), + ); + expect(calls.indexOf('rmdir:/workspace/safe/a/b')).toBeLessThan( + calls.indexOf('rmdir:/workspace/safe/a'), + ); +}); diff --git a/web_platform/src/project/workspace.ts b/web_platform/src/project/workspace.ts index 9711f0a3..49e04080 100644 --- a/web_platform/src/project/workspace.ts +++ b/web_platform/src/project/workspace.ts @@ -1,29 +1,33 @@ -import type {MainModule} from '@mujoco/mujoco'; -import type {ProjectManifest} from './types'; +import type { MainModule } from '@mujoco/mujoco'; +import type { ProjectManifest } from './types'; interface EmscriptenFS { mkdirTree(path: string): void; writeFile(path: string, data: Uint8Array): void; - readFile(path:string,options:{encoding:'utf8'}):string; + readFile(path: string, options: { encoding: 'utf8' }): string; unlink(path: string): void; rmdir(path: string): void; } -type ModuleWithFS = MainModule & {FS: EmscriptenFS}; +type ModuleWithFS = MainModule & { FS: EmscriptenFS }; export class MemfsWorkspace { readonly root: string; private files: string[] = []; private directories: string[] = []; - constructor(private readonly module: MainModule, projectId: string) { + constructor( + private readonly module: MainModule, + projectId: string, + ) { const safeId = projectId.replace(/[^a-zA-Z0-9_-]/g, '_'); this.root = `/workspace/${safeId}`; } mount(manifest: ProjectManifest): void { const fs = (this.module as ModuleWithFS).FS; - fs.mkdirTree(this.root); this.directories.push(this.root); + fs.mkdirTree(this.root); + this.directories.push(this.root); for (const file of manifest.files) { const absolute = `${this.root}/${file.path}`; const directory = absolute.slice(0, absolute.lastIndexOf('/')); @@ -38,23 +42,42 @@ export class MemfsWorkspace { } } } - fs.writeFile(absolute, file.data); this.files.push(absolute); + fs.writeFile(absolute, file.data); + this.files.push(absolute); } } - path(relative: string): string { return `${this.root}/${relative}`; } + path(relative: string): string { + return `${this.root}/${relative}`; + } - readText(relative:string):string{return (this.module as ModuleWithFS).FS.readFile(this.path(relative),{encoding:'utf8'});} + readText(relative: string): string { + return (this.module as ModuleWithFS).FS.readFile(this.path(relative), { encoding: 'utf8' }); + } - writeGenerated(relative:string,data:Uint8Array):void { - const absolute=this.path(relative);(this.module as ModuleWithFS).FS.writeFile(absolute,data); - if(!this.files.includes(absolute))this.files.push(absolute); + writeGenerated(relative: string, data: Uint8Array): void { + const absolute = this.path(relative); + (this.module as ModuleWithFS).FS.writeFile(absolute, data); + if (!this.files.includes(absolute)) this.files.push(absolute); } dispose(): void { const fs = (this.module as ModuleWithFS).FS; - for (const file of this.files.reverse()) { try { fs.unlink(file); } catch { /* best-effort after failed mount */ } } - for (const dir of this.directories.sort((a, b) => b.length - a.length)) { try { fs.rmdir(dir); } catch { /* parent or shared root */ } } - this.files = []; this.directories = []; + for (const file of this.files.reverse()) { + try { + fs.unlink(file); + } catch { + /* best-effort after failed mount */ + } + } + for (const dir of this.directories.sort((a, b) => b.length - a.length)) { + try { + fs.rmdir(dir); + } catch { + /* parent or shared root */ + } + } + this.files = []; + this.directories = []; } } diff --git a/web_platform/src/rl/runtime/Go2wPolicyBindings.ts b/web_platform/src/rl/runtime/Go2wPolicyBindings.ts index a5d6a734..6152c5ec 100644 --- a/web_platform/src/rl/runtime/Go2wPolicyBindings.ts +++ b/web_platform/src/rl/runtime/Go2wPolicyBindings.ts @@ -1,79 +1,243 @@ -import type {MjData,MjModel} from '@mujoco/mujoco'; -import {buildGo2wObservation,GO2W_VELOCITY_TASK} from '../tasks/go2wVelocity'; -import type {JointBinding,RLCommand} from '../types'; -import type {PolicyRuntimeBindings} from './OnnxPolicyRuntime'; +import type { MjData, MjModel } from '@mujoco/mujoco'; +import { buildGo2wObservation, GO2W_VELOCITY_TASK } from '../tasks/go2wVelocity'; +import type { JointBinding, RLCommand } from '../types'; +import type { PolicyRuntimeBindings } from './OnnxPolicyRuntime'; -interface BoundJoint extends JointBinding {positionActuator:boolean;controlScale:number;} +interface BoundJoint extends JointBinding { + positionActuator: boolean; + controlScale: number; +} -function rotateInverse(quaternion:readonly number[],vector:readonly number[]):[number,number,number]{ - const [w,x,y,z]=quaternion,[vx,vy,vz]=vector; - const tx=2*(y*vz-z*vy),ty=2*(z*vx-x*vz),tz=2*(x*vy-y*vx); - return [vx-w*tx+(y*tz-z*ty),vy-w*ty+(z*tx-x*tz),vz-w*tz+(x*ty-y*tx)]; +function rotateInverse( + quaternion: readonly number[], + vector: readonly number[], +): [number, number, number] { + const [w, x, y, z] = quaternion, + [vx, vy, vz] = vector; + const tx = 2 * (y * vz - z * vy), + ty = 2 * (z * vx - x * vz), + tz = 2 * (x * vy - y * vx); + return [ + vx - w * tx + (y * tz - z * ty), + vy - w * ty + (z * tx - x * tz), + vz - w * tz + (x * ty - y * tx), + ]; } /** 将 mjlab Go2 velocity 的 47 维 actor 观测和 12 维关节位置动作映射到 MuJoCo。 */ export class Go2wPolicyBindings implements PolicyRuntimeBindings { - private readonly joints:BoundJoint[]; - private readonly baseBodyId:number; - private readonly baseFreeJointId:number; - private readonly gyroSensorId?:number; - private readonly wheelActuatorIds:number[]; + private readonly joints: BoundJoint[]; + private readonly baseBodyId: number; + private readonly baseFreeJointId: number; + private readonly gyroSensorId?: number; + private readonly wheelActuatorIds: number[]; - constructor(private readonly model:MjModel,private readonly data:MjData,private readonly setActuator:(id:number,value:number)=>void){ - const jointIds=new Map(),actuatorIds=new Map(),sensorIds=new Map(),bodyIds=new Map(); - for(let id=0;id{ - const jointId=jointIds.get(name);if(jointId===undefined)throw new Error(`Go2-W 策略找不到关节:${name}`); - const short=name.replace(/_joint$/,''); - const actuatorId=actuatorIds.get(short)??actuatorIds.get(`${name}_motor`); - if(actuatorId===undefined)throw new Error(`Go2-W 策略找不到驱动器:${short} 或 ${name}_motor`); - const joint=model.jnt(jointId),actuator=model.actuator(actuatorId); - try{ - const address=Number(model.actuator_ctrladr[actuatorId]??actuatorId),nextAddress=actuatorId+11e-5||Math.abs(gain-GO2W_VELOCITY_TASK.stiffness[index])>1e-4||Math.abs(Number(actuator.biasprm[2])+GO2W_VELOCITY_TASK.damping[index])>1e-4))throw new Error(`position 驱动器 ${actuator.name||actuatorId} 的 gear/kp/kd 与 mjlab deploy 配置不一致`); - const controlScale=gear*gain; - if(!Number.isFinite(controlScale)||Math.abs(controlScale)<1e-9)throw new Error(`驱动器 ${actuator.name||actuatorId} 的 gear × gain 无效`); - return {name,jointId,qposAddress:Number(joint.qposadr),qvelAddress:Number(joint.dofadr),actuatorId,positionActuator,controlScale}; - }finally{actuator.delete();joint.delete();} - }); - this.wheelActuatorIds=['FL','FR','RL','RR'].flatMap(prefix=>{ - const id=actuatorIds.get(`${prefix}_wheel`)??actuatorIds.get(`${prefix}_wheel_joint_motor`)??actuatorIds.get(`${prefix}_foot_joint_motor`); - return id===undefined?[]:[id]; - }); - } - - observe(time:number,lastAction:Float32Array,command:RLCommand):Float32Array{ - const quaternion=Array.from(this.data.xquat.subarray(this.baseBodyId*4,this.baseBodyId*4+4),Number); - const projectedGravity=rotateInverse(quaternion,[0,0,-1]); - let angularVelocity:[number,number,number]; - if(this.gyroSensorId!==undefined){const address=Number(this.model.sensor_adr[this.gyroSensorId]);angularVelocity=[Number(this.data.sensordata[address]),Number(this.data.sensordata[address+1]),Number(this.data.sensordata[address+2])];} - else {const joint=this.model.jnt(this.baseFreeJointId);try{const address=Number(joint.dofadr)+3;angularVelocity=[Number(this.data.qvel[address]),Number(this.data.qvel[address+1]),Number(this.data.qvel[address+2])];}finally{joint.delete();}} - return buildGo2wObservation({angularVelocity,projectedGravity,command,time,jointPosition:this.joints.map(item=>Number(this.data.qpos[item.qposAddress])),jointVelocity:this.joints.map(item=>Number(this.data.qvel[item.qvelAddress])),lastAction:Array.from(lastAction)}); - } - - apply(action:Float32Array):void{ - for(let index=0;index void, + ) { + const jointIds = new Map(), + actuatorIds = new Map(), + sensorIds = new Map(), + bodyIds = new Map(); + for (let id = 0; id < model.njnt; id += 1) { + const item = model.jnt(id); + try { + if (item.name) jointIds.set(item.name, id); + } finally { + item.delete(); + } } - for(const id of this.wheelActuatorIds)this.setActuator(id,0); + for (let id = 0; id < model.nactuator; id += 1) { + const item = model.actuator(id); + try { + if (item.name) actuatorIds.set(item.name, id); + } finally { + item.delete(); + } + } + for (let id = 0; id < model.nsensor; id += 1) { + const item = model.sensor(id); + try { + if (item.name) sensorIds.set(item.name, id); + } finally { + item.delete(); + } + } + for (let id = 0; id < model.nbody; id += 1) { + const item = model.body(id); + try { + if (item.name) bodyIds.set(item.name, id); + } finally { + item.delete(); + } + } + this.baseBodyId = + bodyIds.get('base_link') ?? bodyIds.get('base') ?? this.findFloatingBaseBody(); + this.baseFreeJointId = this.findFreeJoint(this.baseBodyId); + const gyroCandidate = sensorIds.get('imu_gyro') ?? sensorIds.get('__platform_imu_gyro__'); + this.gyroSensorId = + gyroCandidate !== undefined && this.isBaseAlignedGyro(gyroCandidate) + ? gyroCandidate + : undefined; + this.joints = GO2W_VELOCITY_TASK.jointNames.map((name, index) => { + const jointId = jointIds.get(name); + if (jointId === undefined) throw new Error(`Go2-W 策略找不到关节:${name}`); + const short = name.replace(/_joint$/, ''); + const actuatorId = actuatorIds.get(short) ?? actuatorIds.get(`${name}_motor`); + if (actuatorId === undefined) + throw new Error(`Go2-W 策略找不到驱动器:${short} 或 ${name}_motor`); + const joint = model.jnt(jointId), + actuator = model.actuator(actuatorId); + try { + const address = Number(model.actuator_ctrladr[actuatorId] ?? actuatorId), + nextAddress = + actuatorId + 1 < model.nactuator + ? Number(model.actuator_ctrladr[actuatorId + 1]) + : model.nu; + if ( + nextAddress - address !== 1 || + Number(actuator.trntype) !== 0 || + Number(actuator.trnid[0]) !== jointId + ) + throw new Error( + `驱动器 ${actuator.name || actuatorId} 不是关节 ${name} 的标量 joint transmission`, + ); + if (Number(actuator.gaintype) !== 0 || Number(actuator.dyntype) !== 0) + throw new Error( + `驱动器 ${actuator.name || actuatorId} 必须使用 fixed gain 和无激活动力学`, + ); + const gear = Number(actuator.gear[0]), + gain = Number(actuator.gainprm[0]), + positionActuator = + Number(actuator.biastype) === 1 && Math.abs(Number(actuator.biasprm[1]) + gain) < 1e-5; + const motorActuator = Number(actuator.biastype) === 0; + if (!positionActuator && !motorActuator) + throw new Error(`驱动器 ${actuator.name || actuatorId} 不是受支持的 motor/position 类型`); + if ( + positionActuator && + (Math.abs(gear - 1) > 1e-5 || + Math.abs(gain - GO2W_VELOCITY_TASK.stiffness[index]) > 1e-4 || + Math.abs(Number(actuator.biasprm[2]) + GO2W_VELOCITY_TASK.damping[index]) > 1e-4) + ) + throw new Error( + `position 驱动器 ${actuator.name || actuatorId} 的 gear/kp/kd 与 mjlab deploy 配置不一致`, + ); + const controlScale = gear * gain; + if (!Number.isFinite(controlScale) || Math.abs(controlScale) < 1e-9) + throw new Error(`驱动器 ${actuator.name || actuatorId} 的 gear × gain 无效`); + return { + name, + jointId, + qposAddress: Number(joint.qposadr), + qvelAddress: Number(joint.dofadr), + actuatorId, + positionActuator, + controlScale, + }; + } finally { + actuator.delete(); + joint.delete(); + } + }); + this.wheelActuatorIds = ['FL', 'FR', 'RL', 'RR'].flatMap((prefix) => { + const id = + actuatorIds.get(`${prefix}_wheel`) ?? + actuatorIds.get(`${prefix}_wheel_joint_motor`) ?? + actuatorIds.get(`${prefix}_foot_joint_motor`); + return id === undefined ? [] : [id]; + }); } - clear():void{for(const item of this.joints)this.setActuator(item.actuatorId,0);for(const id of this.wheelActuatorIds)this.setActuator(id,0);} - private isBaseAlignedGyro(sensorId:number):boolean{const siteId=Number(this.model.sensor_objid[sensorId]);if(Number(this.model.sensor_dim[sensorId])!==3||siteId<0||siteId>=this.model.nsite||Number(this.model.site_bodyid[siteId])!==this.baseBodyId)return false;const offset=siteId*4;return Math.abs(Number(this.model.site_quat[offset])-1)<1e-5&&Math.abs(Number(this.model.site_quat[offset+1]))<1e-5&&Math.abs(Number(this.model.site_quat[offset+2]))<1e-5&&Math.abs(Number(this.model.site_quat[offset+3]))<1e-5;} - private findFloatingBaseBody():number{for(let jointId=0;jointId Number(this.data.qpos[item.qposAddress])), + jointVelocity: this.joints.map((item) => Number(this.data.qvel[item.qvelAddress])), + lastAction: Array.from(lastAction), + }); + } + + apply(action: Float32Array): void { + for (let index = 0; index < this.joints.length; index += 1) { + const item = this.joints[index], + target = + GO2W_VELOCITY_TASK.defaultJointPosition[index] + + GO2W_VELOCITY_TASK.actionScale[index] * action[index]; + const torque = + GO2W_VELOCITY_TASK.stiffness[index] * (target - Number(this.data.qpos[item.qposAddress])) - + GO2W_VELOCITY_TASK.damping[index] * Number(this.data.qvel[item.qvelAddress]); + this.setActuator( + item.actuatorId, + item.positionActuator ? target : torque / item.controlScale, + ); + } + for (const id of this.wheelActuatorIds) this.setActuator(id, 0); + } + clear(): void { + for (const item of this.joints) this.setActuator(item.actuatorId, 0); + for (const id of this.wheelActuatorIds) this.setActuator(id, 0); + } + + private isBaseAlignedGyro(sensorId: number): boolean { + const siteId = Number(this.model.sensor_objid[sensorId]); + if ( + Number(this.model.sensor_dim[sensorId]) !== 3 || + siteId < 0 || + siteId >= this.model.nsite || + Number(this.model.site_bodyid[siteId]) !== this.baseBodyId + ) + return false; + const offset = siteId * 4; + return ( + Math.abs(Number(this.model.site_quat[offset]) - 1) < 1e-5 && + Math.abs(Number(this.model.site_quat[offset + 1])) < 1e-5 && + Math.abs(Number(this.model.site_quat[offset + 2])) < 1e-5 && + Math.abs(Number(this.model.site_quat[offset + 3])) < 1e-5 + ); + } + private findFloatingBaseBody(): number { + for (let jointId = 0; jointId < this.model.njnt; jointId += 1) + if (Number(this.model.jnt_type[jointId]) === 0) return Number(this.model.jnt_bodyid[jointId]); + throw new Error('Go2-W 策略需要浮动基座(free joint)'); + } + private findFreeJoint(bodyId: number): number { + for (let jointId = 0; jointId < this.model.njnt; jointId += 1) + if ( + Number(this.model.jnt_type[jointId]) === 0 && + Number(this.model.jnt_bodyid[jointId]) === bodyId + ) + return jointId; + throw new Error('Go2-W 基座没有 free joint,请使用浮动基座模型'); + } } diff --git a/web_platform/src/rl/runtime/OnnxPolicyRuntime.ts b/web_platform/src/rl/runtime/OnnxPolicyRuntime.ts index 71e0e26d..84d1cde7 100644 --- a/web_platform/src/rl/runtime/OnnxPolicyRuntime.ts +++ b/web_platform/src/rl/runtime/OnnxPolicyRuntime.ts @@ -1,83 +1,211 @@ import * as ort from 'onnxruntime-web/wasm'; -import {GO2W_VELOCITY_TASK,clampGo2wCommand} from '../tasks/go2wVelocity'; -import type {RLCommand,RLPolicyStatus} from '../types'; +import { GO2W_VELOCITY_TASK, clampGo2wCommand } from '../tasks/go2wVelocity'; +import type { RLCommand, RLPolicyStatus } from '../types'; -ort.env.wasm.numThreads=1; -ort.env.wasm.proxy=false; +ort.env.wasm.numThreads = 1; +ort.env.wasm.proxy = false; export interface PolicyRuntimeBindings { - observe(time:number,lastAction:Float32Array,command:RLCommand):Float32Array; - apply(action:Float32Array):void; - clear():void; + observe(time: number, lastAction: Float32Array, command: RLCommand): Float32Array; + apply(action: Float32Array): void; + clear(): void; } -function message(error:unknown):string{return error instanceof Error?error.message:String(error);} +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} /** * ONNX Runtime Web 的 run() 是异步 API。物理循环会在每个 mj_step 前持续施加最近一次 * 完成的动作,并按控制频率启动下一次推理,避免阻塞 MuJoCo 的同步步进循环。 */ export class OnnxPolicyRuntime { - private enabled=false; - private disposed=false; - private inFlight=false; - private nextInferenceTime=0; - private action=new Float32Array(GO2W_VELOCITY_TASK.actionSize); - private commandValue:RLCommand={linearX:0,linearY:0,angularZ:0}; - private inferenceCount=0; - private lastInferenceMs=0; - private error?:string; - private epoch=0; - private runPromise?:Promise; + private enabled = false; + private disposed = false; + private inFlight = false; + private nextInferenceTime = 0; + private action = new Float32Array(GO2W_VELOCITY_TASK.actionSize); + private commandValue: RLCommand = { linearX: 0, linearY: 0, angularZ: 0 }; + private inferenceCount = 0; + private lastInferenceMs = 0; + private error?: string; + private epoch = 0; + private runPromise?: Promise; - private constructor(private readonly session:ort.InferenceSession,private readonly bindings:PolicyRuntimeBindings,private readonly path:string,private readonly inputName:string,private readonly outputName:string){} + private constructor( + private readonly session: ort.InferenceSession, + private readonly bindings: PolicyRuntimeBindings, + private readonly path: string, + private readonly inputName: string, + private readonly outputName: string, + ) {} - static async load(model:Uint8Array,path:string,bindings:PolicyRuntimeBindings):Promise{ - const session=await ort.InferenceSession.create(model.slice(),{executionProviders:['wasm'],graphOptimizationLevel:'all'}); - try{ - if(session.inputNames.length!==1)throw new Error(`当前仅支持单输入策略,模型包含 ${session.inputNames.length} 个输入`); - if(session.outputNames.length<1)throw new Error('ONNX 策略没有输出'); - const input=session.inputMetadata[0],output=session.outputMetadata[0]; - if(!input?.isTensor||input.type!=='float32')throw new Error('策略输入必须是 float32 Tensor'); - if(!output?.isTensor||output.type!=='float32')throw new Error('策略输出必须是 float32 Tensor'); - if(input.shape.length!==2||output.shape.length!==2)throw new Error(`策略输入/输出必须是二维 [batch, features],实际为 [${input.shape}] / [${output.shape}]`); - const inputBatch=input.shape[0],outputBatch=output.shape[0],fixedInput=input.shape[1],fixedOutput=output.shape[1]; - if(typeof inputBatch==='number'&&inputBatch!==-1&&inputBatch!==1)throw new Error(`策略输入 batch 必须为 1 或动态维度,实际为 ${inputBatch}`); - if(typeof outputBatch==='number'&&outputBatch!==-1&&outputBatch!==1)throw new Error(`策略输出 batch 必须为 1 或动态维度,实际为 ${outputBatch}`); - if(typeof fixedInput==='number'&&fixedInput>0&&fixedInput!==GO2W_VELOCITY_TASK.observationSize)throw new Error(`策略观测维度不匹配:模型 ${fixedInput},任务 ${GO2W_VELOCITY_TASK.observationSize}`); - if(typeof fixedOutput==='number'&&fixedOutput>0&&fixedOutput!==GO2W_VELOCITY_TASK.actionSize)throw new Error(`策略动作维度不匹配:模型 ${fixedOutput},任务 ${GO2W_VELOCITY_TASK.actionSize}`); - return new OnnxPolicyRuntime(session,bindings,path,session.inputNames[0],session.outputNames[0]); - }catch(error){await session.release();throw error;} + static async load( + model: Uint8Array, + path: string, + bindings: PolicyRuntimeBindings, + ): Promise { + const session = await ort.InferenceSession.create(model.slice(), { + executionProviders: ['wasm'], + graphOptimizationLevel: 'all', + }); + try { + if (session.inputNames.length !== 1) + throw new Error(`当前仅支持单输入策略,模型包含 ${session.inputNames.length} 个输入`); + if (session.outputNames.length < 1) throw new Error('ONNX 策略没有输出'); + const input = session.inputMetadata[0], + output = session.outputMetadata[0]; + if (!input?.isTensor || input.type !== 'float32') + throw new Error('策略输入必须是 float32 Tensor'); + if (!output?.isTensor || output.type !== 'float32') + throw new Error('策略输出必须是 float32 Tensor'); + if (input.shape.length !== 2 || output.shape.length !== 2) + throw new Error( + `策略输入/输出必须是二维 [batch, features],实际为 [${input.shape}] / [${output.shape}]`, + ); + const inputBatch = input.shape[0], + outputBatch = output.shape[0], + fixedInput = input.shape[1], + fixedOutput = output.shape[1]; + if (typeof inputBatch === 'number' && inputBatch !== -1 && inputBatch !== 1) + throw new Error(`策略输入 batch 必须为 1 或动态维度,实际为 ${inputBatch}`); + if (typeof outputBatch === 'number' && outputBatch !== -1 && outputBatch !== 1) + throw new Error(`策略输出 batch 必须为 1 或动态维度,实际为 ${outputBatch}`); + if ( + typeof fixedInput === 'number' && + fixedInput > 0 && + fixedInput !== GO2W_VELOCITY_TASK.observationSize + ) + throw new Error( + `策略观测维度不匹配:模型 ${fixedInput},任务 ${GO2W_VELOCITY_TASK.observationSize}`, + ); + if ( + typeof fixedOutput === 'number' && + fixedOutput > 0 && + fixedOutput !== GO2W_VELOCITY_TASK.actionSize + ) + throw new Error( + `策略动作维度不匹配:模型 ${fixedOutput},任务 ${GO2W_VELOCITY_TASK.actionSize}`, + ); + return new OnnxPolicyRuntime( + session, + bindings, + path, + session.inputNames[0], + session.outputNames[0], + ); + } catch (error) { + await session.release(); + throw error; + } } - status():RLPolicyStatus{return {taskId:GO2W_VELOCITY_TASK.id,taskName:GO2W_VELOCITY_TASK.name,path:this.path,loaded:!this.disposed,enabled:this.enabled,controlHz:GO2W_VELOCITY_TASK.controlHz,observationSize:GO2W_VELOCITY_TASK.observationSize,actionSize:GO2W_VELOCITY_TASK.actionSize,inputName:this.inputName,outputName:this.outputName,command:{...this.commandValue},inferenceCount:this.inferenceCount,lastInferenceMs:this.lastInferenceMs,error:this.error};} - setCommand(command:RLCommand):void{this.commandValue=clampGo2wCommand(command);} - setEnabled(enabled:boolean,time:number):void{if(this.disposed)return;this.epoch+=1;this.enabled=enabled;this.error=undefined;this.nextInferenceTime=time;if(!enabled){this.action.fill(0);this.bindings.clear();}} - reset(time:number):void{this.epoch+=1;this.action.fill(0);this.nextInferenceTime=time;this.error=undefined;this.bindings.clear();} + status(): RLPolicyStatus { + return { + taskId: GO2W_VELOCITY_TASK.id, + taskName: GO2W_VELOCITY_TASK.name, + path: this.path, + loaded: !this.disposed, + enabled: this.enabled, + controlHz: GO2W_VELOCITY_TASK.controlHz, + observationSize: GO2W_VELOCITY_TASK.observationSize, + actionSize: GO2W_VELOCITY_TASK.actionSize, + inputName: this.inputName, + outputName: this.outputName, + command: { ...this.commandValue }, + inferenceCount: this.inferenceCount, + lastInferenceMs: this.lastInferenceMs, + error: this.error, + }; + } + setCommand(command: RLCommand): void { + this.commandValue = clampGo2wCommand(command); + } + setEnabled(enabled: boolean, time: number): void { + if (this.disposed) return; + this.epoch += 1; + this.enabled = enabled; + this.error = undefined; + this.nextInferenceTime = time; + if (!enabled) { + this.action.fill(0); + this.bindings.clear(); + } + } + reset(time: number): void { + this.epoch += 1; + this.action.fill(0); + this.nextInferenceTime = time; + this.error = undefined; + this.bindings.clear(); + } - step(time:number):void{ - if(!this.enabled||this.disposed)return; + step(time: number): void { + if (!this.enabled || this.disposed) return; this.bindings.apply(this.action); - if(this.inFlight||time+1e-9{ - try{ - const output=outputs[this.outputName]; - if(!output||output.type!=='float32')throw new Error(`找不到 float32 输出:${this.outputName}`); - if(output.data.length!==GO2W_VELOCITY_TASK.actionSize)throw new Error(`策略动作维度错误:期望 ${GO2W_VELOCITY_TASK.actionSize},实际 ${output.data.length}`); - const next=Float32Array.from(output.data as Float32Array,Number); - for(const value of next)if(!Number.isFinite(value))throw new Error('策略输出包含非有限数'); - if(!this.disposed&&this.enabled&&epoch===this.epoch){this.action=next;this.inferenceCount+=1;this.lastInferenceMs=performance.now()-started;} - }finally{for(const value of Object.values(outputs))value.dispose();} - }).catch(error=>{if(epoch===this.epoch)this.fail(error);}).finally(()=>{input.dispose();this.inFlight=false;this.runPromise=undefined;}); + if (this.inFlight || time + 1e-9 < this.nextInferenceTime) return; + let observation: Float32Array; + try { + observation = this.bindings.observe(time, this.action, this.commandValue); + } catch (error) { + this.fail(error); + return; + } + this.inFlight = true; + this.nextInferenceTime = time + 1 / GO2W_VELOCITY_TASK.controlHz; + const started = performance.now(), + epoch = this.epoch; + const input = new ort.Tensor('float32', observation, [1, observation.length]); + this.runPromise = this.session + .run({ [this.inputName]: input }) + .then((outputs) => { + try { + const output = outputs[this.outputName]; + if (!output || output.type !== 'float32') + throw new Error(`找不到 float32 输出:${this.outputName}`); + if (output.data.length !== GO2W_VELOCITY_TASK.actionSize) + throw new Error( + `策略动作维度错误:期望 ${GO2W_VELOCITY_TASK.actionSize},实际 ${output.data.length}`, + ); + const next = Float32Array.from(output.data as Float32Array, Number); + for (const value of next) + if (!Number.isFinite(value)) throw new Error('策略输出包含非有限数'); + if (!this.disposed && this.enabled && epoch === this.epoch) { + this.action = next; + this.inferenceCount += 1; + this.lastInferenceMs = performance.now() - started; + } + } finally { + for (const value of Object.values(outputs)) value.dispose(); + } + }) + .catch((error) => { + if (epoch === this.epoch) this.fail(error); + }) + .finally(() => { + input.dispose(); + this.inFlight = false; + this.runPromise = undefined; + }); } - private fail(error:unknown):void{if(this.disposed)return;this.error=message(error);this.enabled=false;this.bindings.clear();} - dispose():void{if(this.disposed)return;this.disposed=true;this.enabled=false;this.epoch+=1;this.bindings.clear();const pending=this.runPromise??Promise.resolve();void pending.catch(()=>{}).finally(()=>this.session.release().catch(error=>console.warn('[ONNX] 释放推理会话失败',error)));} + private fail(error: unknown): void { + if (this.disposed) return; + this.error = message(error); + this.enabled = false; + this.bindings.clear(); + } + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.enabled = false; + this.epoch += 1; + this.bindings.clear(); + const pending = this.runPromise ?? Promise.resolve(); + void pending + .catch(() => {}) + .finally(() => + this.session.release().catch((error) => console.warn('[ONNX] 释放推理会话失败', error)), + ); + } } diff --git a/web_platform/src/rl/tasks/go2wVelocity.test.ts b/web_platform/src/rl/tasks/go2wVelocity.test.ts index 6207eb67..779d41be 100644 --- a/web_platform/src/rl/tasks/go2wVelocity.test.ts +++ b/web_platform/src/rl/tasks/go2wVelocity.test.ts @@ -1,28 +1,57 @@ -import {describe,expect,it} from 'vitest'; -import {buildGo2wObservation,clampGo2wCommand,go2wGaitPhase,GO2W_VELOCITY_TASK} from './go2wVelocity'; +import { describe, expect, it } from 'vitest'; +import { + buildGo2wObservation, + clampGo2wCommand, + go2wGaitPhase, + GO2W_VELOCITY_TASK, +} from './go2wVelocity'; -describe('Go2-W velocity task',()=>{ - it('按 mjlab deploy 顺序构造 47 维 actor 观测',()=>{ - const jointPosition=GO2W_VELOCITY_TASK.defaultJointPosition.map(value=>value+0.1); - const observation=buildGo2wObservation({angularVelocity:[1,2,3],projectedGravity:[0,0,-1],command:{linearX:0.5,linearY:-0.25,angularZ:0.2},time:0,jointPosition,jointVelocity:Array(12).fill(0.3),lastAction:Array(12).fill(-0.4)}); +describe('Go2-W velocity task', () => { + it('按 mjlab deploy 顺序构造 47 维 actor 观测', () => { + const jointPosition = GO2W_VELOCITY_TASK.defaultJointPosition.map((value) => value + 0.1); + const observation = buildGo2wObservation({ + angularVelocity: [1, 2, 3], + projectedGravity: [0, 0, -1], + command: { linearX: 0.5, linearY: -0.25, angularZ: 0.2 }, + time: 0, + jointPosition, + jointVelocity: Array(12).fill(0.3), + lastAction: Array(12).fill(-0.4), + }); expect(observation).toHaveLength(47); - [1,2,3,0,0,-1,0.5,-0.25,0.2,0,1].forEach((value,index)=>expect(observation[index]).toBeCloseTo(value)); - for(const value of observation.slice(11,23))expect(value).toBeCloseTo(0.1); - for(const value of observation.slice(23,35))expect(value).toBeCloseTo(0.3); - for(const value of observation.slice(35,47))expect(value).toBeCloseTo(-0.4); + [1, 2, 3, 0, 0, -1, 0.5, -0.25, 0.2, 0, 1].forEach((value, index) => + expect(observation[index]).toBeCloseTo(value), + ); + for (const value of observation.slice(11, 23)) expect(value).toBeCloseTo(0.1); + for (const value of observation.slice(23, 35)) expect(value).toBeCloseTo(0.3); + for (const value of observation.slice(35, 47)) expect(value).toBeCloseTo(-0.4); }); - it('静止时关闭步态相位,并限制速度命令范围',()=>{ - expect(go2wGaitPhase(0.15,{linearX:0,linearY:0,angularZ:0})).toEqual([0,0]); - const moving=go2wGaitPhase(0.15,{linearX:1,linearY:0,angularZ:0}); + it('静止时关闭步态相位,并限制速度命令范围', () => { + expect(go2wGaitPhase(0.15, { linearX: 0, linearY: 0, angularZ: 0 })).toEqual([0, 0]); + const moving = go2wGaitPhase(0.15, { linearX: 1, linearY: 0, angularZ: 0 }); expect(moving[0]).toBeCloseTo(1); expect(moving[1]).toBeCloseTo(0); - expect(clampGo2wCommand({linearX:4,linearY:-4,angularZ:3})).toEqual({linearX:1,linearY:-0.5,angularZ:1}); + expect(clampGo2wCommand({ linearX: 4, linearY: -4, angularZ: 3 })).toEqual({ + linearX: 1, + linearY: -0.5, + angularZ: 1, + }); }); - it('拒绝维度错误或非有限观测',()=>{ - const valid={angularVelocity:[0,0,0],projectedGravity:[0,0,-1],command:{linearX:0,linearY:0,angularZ:0},time:0,jointPosition:Array(12).fill(0),jointVelocity:Array(12).fill(0),lastAction:Array(12).fill(0)}; - expect(()=>buildGo2wObservation({...valid,lastAction:[0]})).toThrow(/观测维度/); - expect(()=>buildGo2wObservation({...valid,angularVelocity:[Number.NaN,0,0]})).toThrow(/非有限数/); + it('拒绝维度错误或非有限观测', () => { + const valid = { + angularVelocity: [0, 0, 0], + projectedGravity: [0, 0, -1], + command: { linearX: 0, linearY: 0, angularZ: 0 }, + time: 0, + jointPosition: Array(12).fill(0), + jointVelocity: Array(12).fill(0), + lastAction: Array(12).fill(0), + }; + expect(() => buildGo2wObservation({ ...valid, lastAction: [0] })).toThrow(/观测维度/); + expect(() => buildGo2wObservation({ ...valid, angularVelocity: [Number.NaN, 0, 0] })).toThrow( + /非有限数/, + ); }); }); diff --git a/web_platform/src/rl/tasks/go2wVelocity.ts b/web_platform/src/rl/tasks/go2wVelocity.ts index 54eef7fb..362c1ab0 100644 --- a/web_platform/src/rl/tasks/go2wVelocity.ts +++ b/web_platform/src/rl/tasks/go2wVelocity.ts @@ -1,57 +1,82 @@ -import type {RLCommand} from '../types'; +import type { RLCommand } from '../types'; -export const GO2W_VELOCITY_TASK={ - id:'unitree-go2w-velocity' as const, - name:'Unitree Go2-W 平衡/速度控制', - controlHz:50, - gaitPeriod:0.6, - observationSize:47, - actionSize:12, - commandLimits:{linearX:[-0.5,1] as const,linearY:[-0.5,0.5] as const,angularZ:[-1,1] as const}, - jointNames:[ - 'FL_hip_joint','FL_thigh_joint','FL_calf_joint', - 'FR_hip_joint','FR_thigh_joint','FR_calf_joint', - 'RL_hip_joint','RL_thigh_joint','RL_calf_joint', - 'RR_hip_joint','RR_thigh_joint','RR_calf_joint', +export const GO2W_VELOCITY_TASK = { + id: 'unitree-go2w-velocity' as const, + name: 'Unitree Go2-W 平衡/速度控制', + controlHz: 50, + gaitPeriod: 0.6, + observationSize: 47, + actionSize: 12, + commandLimits: { + linearX: [-0.5, 1] as const, + linearY: [-0.5, 0.5] as const, + angularZ: [-1, 1] as const, + }, + jointNames: [ + 'FL_hip_joint', + 'FL_thigh_joint', + 'FL_calf_joint', + 'FR_hip_joint', + 'FR_thigh_joint', + 'FR_calf_joint', + 'RL_hip_joint', + 'RL_thigh_joint', + 'RL_calf_joint', + 'RR_hip_joint', + 'RR_thigh_joint', + 'RR_calf_joint', ] as const, - defaultJointPosition:[-0.1,0.9,-1.8,0.1,0.9,-1.8,-0.1,0.9,-1.8,0.1,0.9,-1.8] as const, - actionScale:[0.25,0.25,0.25,0.25,0.25,0.25,0.25,0.25,0.25,0.25,0.25,0.25] as const, - stiffness:[20,20,40,20,20,40,20,20,40,20,20,40] as const, - damping:[1,1,2,1,1,2,1,1,2,1,1,2] as const, + defaultJointPosition: [-0.1, 0.9, -1.8, 0.1, 0.9, -1.8, -0.1, 0.9, -1.8, 0.1, 0.9, -1.8] as const, + actionScale: [0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25] as const, + stiffness: [20, 20, 40, 20, 20, 40, 20, 20, 40, 20, 20, 40] as const, + damping: [1, 1, 2, 1, 1, 2, 1, 1, 2, 1, 1, 2] as const, }; -export function clampGo2wCommand(command:RLCommand):RLCommand { - const limits=GO2W_VELOCITY_TASK.commandLimits; - const clamp=(value:number,range:readonly[number,number])=>Math.min(range[1],Math.max(range[0],Number.isFinite(value)?value:0)); - return {linearX:clamp(command.linearX,limits.linearX),linearY:clamp(command.linearY,limits.linearY),angularZ:clamp(command.angularZ,limits.angularZ)}; +export function clampGo2wCommand(command: RLCommand): RLCommand { + const limits = GO2W_VELOCITY_TASK.commandLimits; + const clamp = (value: number, range: readonly [number, number]) => + Math.min(range[1], Math.max(range[0], Number.isFinite(value) ? value : 0)); + return { + linearX: clamp(command.linearX, limits.linearX), + linearY: clamp(command.linearY, limits.linearY), + angularZ: clamp(command.angularZ, limits.angularZ), + }; } -export function go2wGaitPhase(time:number,command:RLCommand):[number,number] { - if(Math.hypot(command.linearX,command.linearY,command.angularZ)<0.1)return [0,0]; - const phase=((time/GO2W_VELOCITY_TASK.gaitPeriod)%1+1)%1; - return [Math.sin(phase*2*Math.PI),Math.cos(phase*2*Math.PI)]; +export function go2wGaitPhase(time: number, command: RLCommand): [number, number] { + if (Math.hypot(command.linearX, command.linearY, command.angularZ) < 0.1) return [0, 0]; + const phase = (((time / GO2W_VELOCITY_TASK.gaitPeriod) % 1) + 1) % 1; + return [Math.sin(phase * 2 * Math.PI), Math.cos(phase * 2 * Math.PI)]; } -export function buildGo2wObservation(values:{ - angularVelocity:readonly number[]; - projectedGravity:readonly number[]; - command:RLCommand; - time:number; - jointPosition:readonly number[]; - jointVelocity:readonly number[]; - lastAction:readonly number[]; -}):Float32Array { - const phase=go2wGaitPhase(values.time,values.command); - const observation=new Float32Array([ - ...values.angularVelocity.slice(0,3), - ...values.projectedGravity.slice(0,3), - values.command.linearX,values.command.linearY,values.command.angularZ, +export function buildGo2wObservation(values: { + angularVelocity: readonly number[]; + projectedGravity: readonly number[]; + command: RLCommand; + time: number; + jointPosition: readonly number[]; + jointVelocity: readonly number[]; + lastAction: readonly number[]; +}): Float32Array { + const phase = go2wGaitPhase(values.time, values.command); + const observation = new Float32Array([ + ...values.angularVelocity.slice(0, 3), + ...values.projectedGravity.slice(0, 3), + values.command.linearX, + values.command.linearY, + values.command.angularZ, ...phase, - ...values.jointPosition.map((value,index)=>value-GO2W_VELOCITY_TASK.defaultJointPosition[index]), + ...values.jointPosition.map( + (value, index) => value - GO2W_VELOCITY_TASK.defaultJointPosition[index], + ), ...values.jointVelocity, ...values.lastAction, ]); - if(observation.length!==GO2W_VELOCITY_TASK.observationSize)throw new Error(`Go2-W 观测维度错误:期望 ${GO2W_VELOCITY_TASK.observationSize},实际 ${observation.length}`); - for(const value of observation)if(!Number.isFinite(value))throw new Error('Go2-W 观测包含非有限数'); + if (observation.length !== GO2W_VELOCITY_TASK.observationSize) + throw new Error( + `Go2-W 观测维度错误:期望 ${GO2W_VELOCITY_TASK.observationSize},实际 ${observation.length}`, + ); + for (const value of observation) + if (!Number.isFinite(value)) throw new Error('Go2-W 观测包含非有限数'); return observation; } diff --git a/web_platform/src/rl/types.ts b/web_platform/src/rl/types.ts index d38dfeb7..66be3f46 100644 --- a/web_platform/src/rl/types.ts +++ b/web_platform/src/rl/types.ts @@ -1,30 +1,30 @@ export interface RLCommand { - linearX:number; - linearY:number; - angularZ:number; + linearX: number; + linearY: number; + angularZ: number; } export interface RLPolicyStatus { - taskId:'unitree-go2w-velocity'; - taskName:string; - path:string; - loaded:boolean; - enabled:boolean; - controlHz:number; - observationSize:number; - actionSize:number; - inputName:string; - outputName:string; - command:RLCommand; - inferenceCount:number; - lastInferenceMs:number; - error?:string; + taskId: 'unitree-go2w-velocity'; + taskName: string; + path: string; + loaded: boolean; + enabled: boolean; + controlHz: number; + observationSize: number; + actionSize: number; + inputName: string; + outputName: string; + command: RLCommand; + inferenceCount: number; + lastInferenceMs: number; + error?: string; } export interface JointBinding { - name:string; - jointId:number; - qposAddress:number; - qvelAddress:number; - actuatorId:number; + name: string; + jointId: number; + qposAddress: number; + qvelAddress: number; + actuatorId: number; } diff --git a/web_platform/src/simulation/DataRecorder.test.ts b/web_platform/src/simulation/DataRecorder.test.ts new file mode 100644 index 00000000..d6f9f7f2 --- /dev/null +++ b/web_platform/src/simulation/DataRecorder.test.ts @@ -0,0 +1,121 @@ +import { DataRecorder, type TelemetrySource } from './DataRecorder'; + +function sourceFixture() { + let time = 0, + position: [number, number, number] = [0, 0, 0.5], + quaternion: [number, number, number, number] = [1, 0, 0, 0]; + const source: TelemetrySource = { + simulationTime: () => time, + bodyPose: () => ({ position, quaternion }), + controls: () => [3, 4], + actuatorForces: () => [2, -2], + actuatorVelocities: () => [3, -1], + generalizedVelocities: () => [0, 2], + contactCount: () => 4, + }; + return { + source, + move(nextTime: number, nextPosition: [number, number, number]) { + time = nextTime; + position = nextPosition; + }, + rotate(next: [number, number, number, number]) { + quaternion = next; + }, + }; +} + +describe('DataRecorder', () => { + it('按采样率记录位姿、移动速度、姿态和控制指标', () => { + const fixture = sourceFixture(), + recorder = new DataRecorder(fixture.source, [{ id: 1, name: 'base' }], { + sampleRateHz: 10, + }); + recorder.start(); + fixture.move(0.05, [0.05, 0, 0.5]); + recorder.capture(); + expect(recorder.status().sampleCount).toBe(1); + + fixture.move(0.1, [0.1, 0, 0.6]); + fixture.rotate([Math.cos(Math.PI / 8), 0, Math.sin(Math.PI / 8), 0]); + recorder.capture(); + const status = recorder.status(), + values = status.latest!.values; + expect(status.sampleCount).toBe(2); + expect(values.speed_horizontal).toBeCloseTo(1); + expect(values.velocity_z).toBeCloseTo(1); + expect(values.pitch).toBeCloseTo(Math.PI / 4); + expect(values.distance_horizontal).toBeCloseTo(0.1); + expect(values.control_rms).toBeCloseTo(Math.sqrt(12.5)); + expect(values.actuator_force_rms).toBe(2); + expect(values.actuator_power_abs).toBe(8); + expect(values.contact_count).toBe(4); + expect(status.summary.maxAbsPitch).toBeCloseTo(Math.PI / 4); + }); + + it('仿真重置后新建分段且不跨分段计算速度', () => { + const fixture = sourceFixture(), + recorder = new DataRecorder(fixture.source, [{ id: 1, name: 'base' }]); + recorder.start(); + fixture.move(0.02, [0.1, 0, 0.5]); + recorder.capture(true); + recorder.simulationReset(); + fixture.move(0, [0, 0, 0.5]); + recorder.capture(true); + expect(recorder.status().segmentCount).toBe(2); + expect(recorder.status().latest?.values.speed_horizontal).toBe(0); + }); + + it('支持自定义通道并导出稳定的 CSV/JSON 接口', () => { + const fixture = sourceFixture(), + recorder = new DataRecorder(fixture.source, [{ id: 1, name: 'base' }]); + recorder.registerChannel({ + key: 'stability_score', + label: '稳定性', + unit: '', + read: ({ pose }) => pose.position[2] * 2, + }); + recorder.start(); + const csv = new TextDecoder().decode(recorder.toCsv()), + json = JSON.parse(new TextDecoder().decode(recorder.toJson())) as { + schemaVersion: number; + channels: { key: string }[]; + samples: { values: Record }[]; + }; + expect(csv).toContain('simulation_time_s'); + expect(csv).toContain('stability_score'); + expect(json.schemaVersion).toBe(1); + expect(json.channels.some((channel) => channel.key === 'stability_score')).toBe(true); + expect(json.samples[0].values.stability_score).toBe(1); + }); + + it('修改采样对象或频率时清空旧数据,并在达到上限时停止', () => { + const fixture = sourceFixture(), + recorder = new DataRecorder( + fixture.source, + [ + { id: 1, name: 'base' }, + { id: 2, name: 'payload' }, + ], + { maxSamples: 100 }, + ); + recorder.start(); + recorder.stop(); + recorder.configure({ bodyId: 2, sampleRateHz: 20 }); + expect(recorder.status()).toMatchObject({ + sampleCount: 0, + body: { id: 2 }, + config: { sampleRateHz: 20 }, + }); + recorder.start(); + for (let index = 1; index < 100; index += 1) { + fixture.move(index / 20, [index / 20, 0, 0.5]); + recorder.capture(true); + } + expect(recorder.status()).toMatchObject({ + recording: false, + limitReached: true, + sampleCount: 100, + }); + }); +}); diff --git a/web_platform/src/simulation/DataRecorder.ts b/web_platform/src/simulation/DataRecorder.ts new file mode 100644 index 00000000..58aed3b5 --- /dev/null +++ b/web_platform/src/simulation/DataRecorder.ts @@ -0,0 +1,455 @@ +export interface TelemetryBody { + id: number; + name: string; +} + +export interface TelemetryPose { + position: readonly [number, number, number]; + /** MuJoCo 的 w、x、y、z 四元数顺序。 */ + quaternion: readonly [number, number, number, number]; +} + +/** + * 数据源接口刻意与 MuJoCo 类型解耦,后续可接入 Worker、远端仿真或自定义指标。 + */ +export interface TelemetrySource { + simulationTime(): number; + bodyPose(bodyId: number): TelemetryPose; + controls(): ArrayLike; + actuatorForces(): ArrayLike; + actuatorVelocities(): ArrayLike; + generalizedVelocities(): ArrayLike; + contactCount(): number; +} + +export interface TelemetryChannelContext { + source: TelemetrySource; + body: TelemetryBody; + simulationTime: number; + pose: TelemetryPose; +} + +/** 注册自定义通道时使用的稳定扩展接口。 */ +export interface TelemetryChannel { + key: string; + label: string; + unit: string; + read(context: TelemetryChannelContext): number; +} + +export interface DataRecorderConfig { + bodyId: number; + sampleRateHz: number; + maxSamples: number; +} + +export interface TelemetrySample { + sequence: number; + segment: number; + simulationTime: number; + values: Record; +} + +export interface TelemetrySummary { + duration: number; + distanceHorizontal: number; + maxHorizontalSpeed: number; + maxAbsRoll: number; + maxAbsPitch: number; + minHeight?: number; + maxHeight?: number; +} + +export interface DataRecorderStatus { + recording: boolean; + limitReached: boolean; + sampleCount: number; + segmentCount: number; + config: DataRecorderConfig; + body: TelemetryBody; + latest?: TelemetrySample; + summary: TelemetrySummary; +} + +const BUILTIN_CHANNELS: readonly Omit[] = [ + { key: 'position_x', label: '位置 X', unit: 'm' }, + { key: 'position_y', label: '位置 Y', unit: 'm' }, + { key: 'height', label: '机身高度', unit: 'm' }, + { key: 'velocity_x', label: '速度 X', unit: 'm/s' }, + { key: 'velocity_y', label: '速度 Y', unit: 'm/s' }, + { key: 'velocity_z', label: '速度 Z', unit: 'm/s' }, + { key: 'speed_horizontal', label: '水平移动速度', unit: 'm/s' }, + { key: 'speed_3d', label: '三维速度', unit: 'm/s' }, + { key: 'roll', label: '侧倾角', unit: 'rad' }, + { key: 'pitch', label: '俯仰角', unit: 'rad' }, + { key: 'yaw', label: '偏航角', unit: 'rad' }, + { key: 'angular_velocity_roll', label: '侧倾角速度', unit: 'rad/s' }, + { key: 'angular_velocity_pitch', label: '俯仰角速度', unit: 'rad/s' }, + { key: 'angular_velocity_yaw', label: '偏航角速度', unit: 'rad/s' }, + { key: 'distance_horizontal', label: '水平累计里程', unit: 'm' }, + { key: 'contact_count', label: '场景接触数', unit: '' }, + { key: 'control_rms', label: '控制输入 RMS', unit: '' }, + { key: 'actuator_force_rms', label: '驱动力 RMS', unit: '' }, + { key: 'actuator_power_abs', label: '驱动器绝对功率和', unit: 'W' }, + { key: 'joint_velocity_rms', label: '广义速度 RMS', unit: '' }, +]; + +const DEFAULT_MAX_SAMPLES = 30_000; + +function finite(value: number, fallback = 0): number { + return Number.isFinite(value) ? value : fallback; +} + +function clampInteger(value: number, minimum: number, maximum: number): number { + return Math.round(Math.min(maximum, Math.max(minimum, finite(value, minimum)))); +} + +function rootMeanSquare(values: ArrayLike): number { + if (!values.length) return 0; + let sum = 0; + for (let index = 0; index < values.length; index += 1) { + const value = finite(Number(values[index])); + sum += value * value; + } + return Math.sqrt(sum / values.length); +} + +function absoluteActuatorPower(forces: ArrayLike, velocities: ArrayLike): number { + let total = 0; + for (let index = 0; index < Math.min(forces.length, velocities.length); index += 1) + total += Math.abs(finite(Number(forces[index])) * finite(Number(velocities[index]))); + return total; +} + +function eulerFromQuaternion( + quaternion: readonly [number, number, number, number], +): [number, number, number] { + const [w, x, y, z] = quaternion.map((value) => finite(Number(value))) as [ + number, + number, + number, + number, + ], + norm = Math.hypot(w, x, y, z) || 1, + qw = w / norm, + qx = x / norm, + qy = y / norm, + qz = z / norm, + roll = Math.atan2(2 * (qw * qx + qy * qz), 1 - 2 * (qx * qx + qy * qy)), + pitch = Math.asin(Math.min(1, Math.max(-1, 2 * (qw * qy - qz * qx)))), + yaw = Math.atan2(2 * (qw * qz + qx * qy), 1 - 2 * (qy * qy + qz * qz)); + return [roll, pitch, yaw]; +} + +function angleDelta(next: number, previous: number): number { + return Math.atan2(Math.sin(next - previous), Math.cos(next - previous)); +} + +function csvCell(value: string | number): string { + const text = String(value); + return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text; +} + +function initialSummary(): TelemetrySummary { + return { + duration: 0, + distanceHorizontal: 0, + maxHorizontalSpeed: 0, + maxAbsRoll: 0, + maxAbsPitch: 0, + }; +} + +export class DataRecorder { + private configValue: DataRecorderConfig; + private samplesValue: TelemetrySample[] = []; + private customChannels = new Map(); + private recordingValue = false; + private limitReachedValue = false; + private nextSampleTime?: number; + private previousTime?: number; + private previousPosition?: [number, number, number]; + private previousEuler?: [number, number, number]; + private segment = 0; + private pendingSegment = false; + private summaryValue = initialSummary(); + + constructor( + private readonly source: TelemetrySource, + private readonly bodies: readonly TelemetryBody[], + config?: Partial, + ) { + if (!bodies.length) throw new Error('数据记录器至少需要一个可记录 Body'); + const requestedBody = bodies.some((body) => body.id === config?.bodyId) + ? config!.bodyId! + : bodies[0].id; + this.configValue = { + bodyId: requestedBody, + sampleRateHz: clampInteger(config?.sampleRateHz ?? 50, 1, 1000), + maxSamples: clampInteger(config?.maxSamples ?? DEFAULT_MAX_SAMPLES, 100, 1_000_000), + }; + } + + channels(): readonly Omit[] { + return [...BUILTIN_CHANNELS, ...this.customChannels.values()].map(({ key, label, unit }) => ({ + key, + label, + unit, + })); + } + + /** + * 注册业务自定义标量。key 会成为 CSV/JSON 的稳定列名;开始记录后不可变更列结构。 + */ + registerChannel(channel: TelemetryChannel): () => void { + if (this.recordingValue || this.samplesValue.length) + throw new Error('已有记录时不能修改数据通道,请先停止并清空记录'); + if (!/^[a-z][a-z0-9_]*$/i.test(channel.key)) + throw new Error(`数据通道 key 无效:${channel.key}`); + if ( + BUILTIN_CHANNELS.some((item) => item.key === channel.key) || + this.customChannels.has(channel.key) + ) + throw new Error(`数据通道 key 重复:${channel.key}`); + this.customChannels.set(channel.key, channel); + return () => { + if (this.recordingValue || this.samplesValue.length) + throw new Error('已有记录时不能移除数据通道,请先停止并清空记录'); + this.customChannels.delete(channel.key); + }; + } + + configure(patch: Partial): DataRecorderStatus { + if (this.recordingValue) throw new Error('请先停止记录再修改采样配置'); + const next: DataRecorderConfig = { + bodyId: patch.bodyId ?? this.configValue.bodyId, + sampleRateHz: clampInteger(patch.sampleRateHz ?? this.configValue.sampleRateHz, 1, 1000), + maxSamples: clampInteger(patch.maxSamples ?? this.configValue.maxSamples, 100, 1_000_000), + }; + if (!this.bodies.some((body) => body.id === next.bodyId)) + throw new Error(`无法记录不存在的 Body:${next.bodyId}`); + const changed = Object.keys(next).some( + (key) => + next[key as keyof DataRecorderConfig] !== this.configValue[key as keyof DataRecorderConfig], + ); + this.configValue = next; + if (changed && this.samplesValue.length) this.clear(); + return this.status(); + } + + start(): DataRecorderStatus { + if (this.samplesValue.length >= this.configValue.maxSamples) return this.status(); + this.recordingValue = true; + this.limitReachedValue = false; + this.resetSamplingClock(); + this.capture(true); + return this.status(); + } + + stop(): DataRecorderStatus { + this.recordingValue = false; + return this.status(); + } + + clear(): DataRecorderStatus { + this.samplesValue = []; + this.recordingValue = false; + this.limitReachedValue = false; + this.segment = 0; + this.pendingSegment = false; + this.summaryValue = initialSummary(); + this.resetSamplingClock(); + return this.status(); + } + + /** 仿真 reset 后开启新分段,保留之前的数据且不跨分段计算速度。 */ + simulationReset(): void { + this.pendingSegment = this.samplesValue.length > 0; + this.resetSamplingClock(); + } + + capture(force = false): void { + if (!this.recordingValue) return; + if (this.samplesValue.length >= this.configValue.maxSamples) { + this.recordingValue = false; + this.limitReachedValue = true; + return; + } + const time = finite(this.source.simulationTime()); + if (this.pendingSegment) { + this.segment += 1; + this.pendingSegment = false; + } + if (this.previousTime !== undefined && time + 1e-9 < this.previousTime) { + this.segment += 1; + this.resetSamplingClock(); + } + if (!force && this.nextSampleTime !== undefined && time + 1e-9 < this.nextSampleTime) return; + + const body = this.body(), + pose = this.source.bodyPose(body.id), + position: [number, number, number] = [ + finite(Number(pose.position[0])), + finite(Number(pose.position[1])), + finite(Number(pose.position[2])), + ], + euler = eulerFromQuaternion(pose.quaternion), + dt = this.previousTime === undefined ? 0 : Math.max(0, time - this.previousTime), + sameSegment = + dt > 1e-9 && this.previousPosition !== undefined && this.previousEuler !== undefined, + velocity: [number, number, number] = sameSegment + ? (position.map((value, axis) => (value - this.previousPosition![axis]) / dt) as [ + number, + number, + number, + ]) + : [0, 0, 0], + angularVelocity: [number, number, number] = sameSegment + ? (euler.map((value, axis) => angleDelta(value, this.previousEuler![axis]) / dt) as [ + number, + number, + number, + ]) + : [0, 0, 0], + horizontalDelta = sameSegment + ? Math.hypot( + position[0] - this.previousPosition![0], + position[1] - this.previousPosition![1], + ) + : 0; + + this.summaryValue.distanceHorizontal += horizontalDelta; + const values: Record = { + position_x: position[0], + position_y: position[1], + height: position[2], + velocity_x: velocity[0], + velocity_y: velocity[1], + velocity_z: velocity[2], + speed_horizontal: Math.hypot(velocity[0], velocity[1]), + speed_3d: Math.hypot(...velocity), + roll: euler[0], + pitch: euler[1], + yaw: euler[2], + angular_velocity_roll: angularVelocity[0], + angular_velocity_pitch: angularVelocity[1], + angular_velocity_yaw: angularVelocity[2], + distance_horizontal: this.summaryValue.distanceHorizontal, + contact_count: Math.max(0, Math.round(finite(this.source.contactCount()))), + control_rms: rootMeanSquare(this.source.controls()), + actuator_force_rms: rootMeanSquare(this.source.actuatorForces()), + actuator_power_abs: absoluteActuatorPower( + this.source.actuatorForces(), + this.source.actuatorVelocities(), + ), + joint_velocity_rms: rootMeanSquare(this.source.generalizedVelocities()), + }; + const context: TelemetryChannelContext = { + source: this.source, + body, + simulationTime: time, + pose, + }; + for (const channel of this.customChannels.values()) { + try { + values[channel.key] = finite(channel.read(context), Number.NaN); + } catch { + values[channel.key] = Number.NaN; + } + } + const sample: TelemetrySample = { + sequence: this.samplesValue.length, + segment: this.segment, + simulationTime: time, + values, + }; + this.samplesValue.push(sample); + if (sameSegment) this.summaryValue.duration += dt; + this.summaryValue.maxHorizontalSpeed = Math.max( + this.summaryValue.maxHorizontalSpeed, + values.speed_horizontal, + ); + this.summaryValue.maxAbsRoll = Math.max(this.summaryValue.maxAbsRoll, Math.abs(values.roll)); + this.summaryValue.maxAbsPitch = Math.max(this.summaryValue.maxAbsPitch, Math.abs(values.pitch)); + this.summaryValue.minHeight = + this.summaryValue.minHeight === undefined + ? values.height + : Math.min(this.summaryValue.minHeight, values.height); + this.summaryValue.maxHeight = + this.summaryValue.maxHeight === undefined + ? values.height + : Math.max(this.summaryValue.maxHeight, values.height); + this.previousTime = time; + this.previousPosition = position; + this.previousEuler = euler; + this.nextSampleTime = time + 1 / this.configValue.sampleRateHz; + if (this.samplesValue.length >= this.configValue.maxSamples) { + this.recordingValue = false; + this.limitReachedValue = true; + } + } + + status(): DataRecorderStatus { + const latest = this.samplesValue.at(-1); + return { + recording: this.recordingValue, + limitReached: this.limitReachedValue, + sampleCount: this.samplesValue.length, + segmentCount: this.samplesValue.length ? this.segment + 1 : 0, + config: { ...this.configValue }, + body: { ...this.body() }, + latest: latest ? { ...latest, values: { ...latest.values } } : undefined, + summary: { ...this.summaryValue }, + }; + } + + samples(): readonly TelemetrySample[] { + return this.samplesValue; + } + + toCsv(): Uint8Array { + const keys = this.channels().map((channel) => channel.key), + rows = [ + ['sequence', 'segment', 'simulation_time_s', ...keys].map(csvCell).join(','), + ...this.samplesValue.map((sample) => + [ + sample.sequence, + sample.segment, + sample.simulationTime, + ...keys.map((key) => sample.values[key] ?? Number.NaN), + ] + .map(csvCell) + .join(','), + ), + ]; + return new TextEncoder().encode(`${rows.join('\n')}\n`); + } + + toJson(): Uint8Array { + return new TextEncoder().encode( + `${JSON.stringify( + { + schemaVersion: 1, + body: this.body(), + config: this.configValue, + channels: this.channels(), + summary: this.summaryValue, + samples: this.samplesValue, + }, + null, + 2, + )}\n`, + ); + } + + private body(): TelemetryBody { + return this.bodies.find((body) => body.id === this.configValue.bodyId) ?? this.bodies[0]; + } + + private resetSamplingClock(): void { + this.nextSampleTime = undefined; + this.previousTime = undefined; + this.previousPosition = undefined; + this.previousEuler = undefined; + } +} diff --git a/web_platform/src/simulation/PhysicsAdapter.ts b/web_platform/src/simulation/PhysicsAdapter.ts index 7d6b24ba..d9c1ecbb 100644 --- a/web_platform/src/simulation/PhysicsAdapter.ts +++ b/web_platform/src/simulation/PhysicsAdapter.ts @@ -1,17 +1,49 @@ -import loadMujoco, {type MainModule} from '@mujoco/mujoco'; -import type {ProjectFile,ProjectManifest} from '../project/types'; -import {prepareProjectForMujoco} from '../project/importer'; -import {enhanceConvertedMjcf,groundConvertedMjcf,type UrdfBaseMode,type UrdfEnhancementOptions} from '../project/urdfToMjcf'; -import {MemfsWorkspace} from '../project/workspace'; -import {SimulationSession,type ActuatorParameters,type FrameResult,type SimulationSnapshot} from './SimulationSession'; -import type {ControllerCommand,ControllerStatus} from '../controller/types'; -import type {RLCommand,RLPolicyStatus} from '../rl/types'; +import type { MainModule } from '@mujoco/mujoco'; +import type { ProjectFile, ProjectManifest } from '../project/types'; +import { prepareProjectForMujoco } from '../project/importer'; +import { + enhanceConvertedMjcf, + groundConvertedMjcf, + type UrdfBaseMode, + type UrdfEnhancementOptions, +} from '../project/urdfToMjcf'; +import { MemfsWorkspace } from '../project/workspace'; +import { + SimulationSession, + type ActuatorParameters, + type FrameResult, + type SimulationSnapshot, +} from './SimulationSession'; +import type { ControllerCommand, ControllerStatus } from '../controller/types'; +import type { RLCommand, RLPolicyStatus } from '../rl/types'; +import type { DataRecorderConfig, DataRecorderStatus, TelemetryChannel } from './DataRecorder'; +import { composePhysicalMap } from '../map/physicalMap'; +import { composeProjectMap } from '../map/MapComposer'; +import { resolveProjectMap } from '../map/MapLoader'; +import { DEFAULT_MAP_SELECTION, type MapSelection } from '../map/types'; -export type UrdfLoadMode='mjcf'|'native'; -export type {UrdfBaseMode,UrdfEnhancementOptions}; +export type UrdfLoadMode = 'mjcf' | 'native'; +export type { UrdfBaseMode, UrdfEnhancementOptions }; + +export interface PhysicsLoadProgress { + value: number; + label: string; +} + +export interface PhysicsLoadOptions { + urdfMode?: UrdfLoadMode; + baseMode?: UrdfBaseMode; + enhancements?: UrdfEnhancementOptions; + map?: MapSelection; + onProgress?: (progress: PhysicsLoadProgress) => void; +} export interface PhysicsAdapter { - load(manifest:ProjectManifest,entryPath:string,urdfMode?:UrdfLoadMode,baseMode?:UrdfBaseMode,enhancements?:UrdfEnhancementOptions):Promise; + load( + manifest: ProjectManifest, + entryPath: string, + options?: PhysicsLoadOptions, + ): Promise; advance(now: number): FrameResult; snapshot(): SimulationSnapshot | null; setPaused(paused: boolean): void; @@ -19,21 +51,29 @@ export interface PhysicsAdapter { reset(): void; singleStep(): void; setActuator(id: number, value: number): void; - setActuatorParameters(id:number,parameters:ActuatorParameters):boolean; - setJointPosition(id:number,value:number):boolean; - resetJoints():void; - setIgnoreJointLimits(ignore:boolean):void; + setActuatorParameters(id: number, parameters: ActuatorParameters): boolean; + setJointPosition(id: number, value: number): boolean; + resetJoints(): void; + setIgnoreJointLimits(ignore: boolean): void; setExternalForce(bodyId: number, force: [number, number, number]): void; clearExternalForce(): void; - loadPythonController(source:string,path:string):Promise; - setControllerEnabled(enabled:boolean):void; - sendControllerCommand(command:ControllerCommand):void; - removeController():void; - loadRLPolicy(model:Uint8Array,path:string):Promise; - setRLPolicyEnabled(enabled:boolean):void; - setRLCommand(command:RLCommand):void; - removeRLPolicy():void; - cachedSupportFiles():ProjectFile[]; + loadPythonController(source: string, path: string): Promise; + setControllerEnabled(enabled: boolean): void; + sendControllerCommand(command: ControllerCommand): void; + removeController(): void; + loadRLPolicy(model: Uint8Array, path: string): Promise; + setRLPolicyEnabled(enabled: boolean): void; + setRLCommand(command: RLCommand): void; + removeRLPolicy(): void; + configureDataRecorder(config: Partial): DataRecorderStatus | undefined; + startDataRecording(): DataRecorderStatus | undefined; + stopDataRecording(): DataRecorderStatus | undefined; + clearDataRecording(): DataRecorderStatus | undefined; + registerDataChannel(channel: TelemetryChannel): (() => void) | undefined; + exportDataRecording(format: 'csv' | 'json'): Uint8Array; + cachedSupportFiles(): ProjectFile[]; + releaseRetired(): void; + rollbackRetired(): void; exportMjcf(): Uint8Array; dispose(): void; } @@ -42,10 +82,17 @@ let modulePromise: Promise | undefined; export function getMujocoModule(): Promise { if (!modulePromise) { console.info('[MuJoCo] 开始初始化单线程 WASM'); - modulePromise = loadMujoco().then((module) => { - console.info('[MuJoCo] WASM 初始化完成'); - return module; - }); + modulePromise = import('@mujoco/mujoco') + .then(({ default: loadMujoco }) => loadMujoco()) + .then((module) => { + console.info('[MuJoCo] WASM 初始化完成'); + return module; + }) + .catch((error) => { + // 初始化失败后允许用户重试,而不是永久复用已 rejected 的 Promise。 + modulePromise = undefined; + throw error; + }); } return modulePromise; } @@ -53,68 +100,302 @@ export function getMujocoModule(): Promise { export class MainThreadPhysicsAdapter implements PhysicsAdapter { session: SimulationSession | null = null; workspace: MemfsWorkspace | null = null; - private supportFiles:ProjectFile[]=[]; + private supportFiles: ProjectFile[] = []; + private loadGeneration = 0; + private disposed = false; + private retiredSession: SimulationSession | null = null; + private retiredWorkspace: MemfsWorkspace | null = null; + private retiredSupportFiles: ProjectFile[] = []; - async load(manifest:ProjectManifest,entryPath:string,urdfMode:UrdfLoadMode='mjcf',baseMode:UrdfBaseMode='floating',enhancements:UrdfEnhancementOptions={addActuators:false,addSensors:false,sensorType:'camera'}):Promise { - this.releaseCurrent();const module=await getMujocoModule();const workspace=new MemfsWorkspace(module,manifest.id);const prepared=prepareProjectForMujoco(manifest,entryPath); - this.supportFiles=prepared.manifest.files.filter(file=>!manifest.files.some(original=>original.path===file.path)); - try{ - console.info('[MuJoCo] 写入 MEMFS',prepared.manifest.files.length);workspace.mount(prepared.manifest); - const entry=prepared.manifest.entries.find(candidate=>candidate.path===entryPath);let modelPath=workspace.path(entryPath);const warnings=[...prepared.warnings]; - if(entry?.format==='urdf'&&urdfMode==='mjcf'){ - console.info('[MuJoCo] 编译 URDF 中间模型',entryPath);const intermediate=new SimulationSession(module,modelPath); - try{ - const minimumZ=intermediate.minimumGeometryZ();const slash=entryPath.lastIndexOf('/');const directory=slash>=0?entryPath.slice(0,slash+1):'';const convertedPath=`${directory}.__mujoco_converted_${manifest.id.replace(/[^a-zA-Z0-9_-]/g,'_')}.xml`; - if(module.mj_saveLastXML(workspace.path(convertedPath),intermediate.model)===0)throw new Error('MuJoCo 无法导出中间 MJCF'); - const grounded=groundConvertedMjcf(new TextEncoder().encode(workspace.readText(convertedPath)),minimumZ,baseMode); - const enhanced=enhanceConvertedMjcf(grounded,enhancements); - workspace.writeGenerated(convertedPath,enhanced.data);modelPath=workspace.path(convertedPath); - warnings.push(`URDF 已转换为 MJCF(${baseMode==='floating'?'浮动基座':'固定基座'}),并整体平移 ${(-minimumZ).toFixed(4)} m,使最低点接触 z=0 地面`); - if(enhanced.actuatorCount)warnings.push(`已为 ${enhanced.actuatorCount} 个 hinge/slide 关节生成 motor 驱动器(控制输入不限幅;hinge 输出单位 N·m,slide 输出单位 N)`); - if(enhanced.unitreeGo2wTuned)warnings.unshift('已识别 Unitree Go2-W,并补齐官方 MuJoCo 关节惯量/阻尼、力矩限幅和轮胎接触参数'); - if(enhanced.imuAdded)warnings.unshift('已在浮动基座添加6轴 IMU:imu_gyro(三轴角速度)和 imu_acc(三轴加速度)'); - if(enhanced.cameraAdded)warnings.push(`已将 640×480 摄像头固连到 ${enhancements.cameraMountBody||'自动选择的头部/末端 body'},局部位置 ${(enhancements.cameraPosition??[.1,0,.05]).join(' ')} m,朝向 ${enhancements.cameraDirection??'+X'}`); - }finally{intermediate.dispose();} + async load( + manifest: ProjectManifest, + entryPath: string, + options: PhysicsLoadOptions = {}, + ): Promise { + if (this.disposed) throw new Error('物理适配器已释放'); + const generation = ++this.loadGeneration; + const urdfMode = options.urdfMode ?? 'mjcf'; + const baseMode = options.baseMode ?? 'floating'; + const enhancements = options.enhancements ?? { + addActuators: false, + addSensors: false, + sensorType: 'camera', + }; + const mapSelection = options.map ?? DEFAULT_MAP_SELECTION; + const report = (value: number, label: string) => { + if (!this.disposed && generation === this.loadGeneration) + options.onProgress?.({ value, label }); + }; + report(0.08, '初始化 MuJoCo WebAssembly'); + const module = await getMujocoModule(); + if (this.disposed || generation !== this.loadGeneration) throw new Error('模型加载已取消'); + report(0.32, '校验并准备模型资源'); + const workspace = new MemfsWorkspace(module, `${manifest.id}_stage_${generation}`); + const prepared = await prepareProjectForMujoco(manifest, entryPath); + const supportFiles = prepared.manifest.files.filter( + (file) => !manifest.files.some((original) => original.path === file.path), + ); + let nextSession: SimulationSession | null = null; + try { + report(0.46, '写入浏览器内存文件系统'); + console.info('[MuJoCo] 写入 MEMFS', prepared.manifest.files.length); + workspace.mount(prepared.manifest); + const entry = prepared.manifest.entries.find((candidate) => candidate.path === entryPath); + let modelRelativePath = entryPath, + modelPath = workspace.path(modelRelativePath); + const warnings = [...prepared.warnings]; + if (entry?.format === 'urdf' && urdfMode === 'mjcf') { + report(0.58, '转换并增强 URDF 模型'); + console.info('[MuJoCo] 编译 URDF 中间模型', entryPath); + const intermediate = new SimulationSession(module, modelPath); + try { + const minimumZ = intermediate.minimumGeometryZ(); + const slash = entryPath.lastIndexOf('/'); + const directory = slash >= 0 ? entryPath.slice(0, slash + 1) : ''; + const convertedPath = `${directory}.__mujoco_converted_${manifest.id.replace(/[^a-zA-Z0-9_-]/g, '_')}.xml`; + if (module.mj_saveLastXML(workspace.path(convertedPath), intermediate.model) === 0) + throw new Error('MuJoCo 无法导出中间 MJCF'); + const grounded = groundConvertedMjcf( + new TextEncoder().encode(workspace.readText(convertedPath)), + minimumZ, + baseMode, + ); + const enhanced = enhanceConvertedMjcf(grounded, enhancements); + workspace.writeGenerated(convertedPath, enhanced.data); + modelRelativePath = convertedPath; + modelPath = workspace.path(modelRelativePath); + warnings.push( + `URDF 已转换为 MJCF(${baseMode === 'floating' ? '浮动基座' : '固定基座'}),并整体平移 ${(-minimumZ).toFixed(4)} m,使最低点接触 z=0 地面`, + ); + if (enhanced.actuatorCount) + warnings.push( + `已为 ${enhanced.actuatorCount} 个 hinge/slide 关节生成 motor 驱动器(控制输入不限幅;hinge 输出单位 N·m,slide 输出单位 N)`, + ); + if (enhanced.unitreeGo2wTuned) + warnings.unshift( + '已识别 Unitree Go2-W,并补齐官方 MuJoCo 关节惯量/阻尼、力矩限幅和轮胎接触参数', + ); + if (enhanced.imuAdded) + warnings.unshift( + '已在浮动基座添加6轴 IMU:imu_gyro(三轴角速度)和 imu_acc(三轴加速度)', + ); + if (enhanced.cameraAdded) + warnings.push( + `已将 640×480 摄像头固连到 ${enhancements.cameraMountBody || '自动选择的头部/末端 body'},局部位置 ${(enhancements.cameraPosition ?? [0.1, 0, 0.05]).join(' ')} m,朝向 ${enhancements.cameraDirection ?? '+X'}`, + ); + } finally { + intermediate.dispose(); + } } - console.info('[MuJoCo] 编译模型',modelPath);const session=new SimulationSession(module,modelPath,warnings); - if(entry?.format==='urdf'&&urdfMode==='native'){const offset=session.alignLowestPointToGround();warnings.push(`原生 URDF 已整体平移 ${offset.toFixed(4)} m,使最低点位于 z=0`);} - if(warnings.length)console.info('[MuJoCo] URDF 兼容处理',warnings); - console.info('[MuJoCo] 模型编译完成');this.workspace=workspace;this.session=session;const snapshot=session.snapshot();console.info('[MuJoCo] 状态快照完成');return snapshot; - }catch(error){workspace.dispose();throw new Error(`模型编译失败(${entryPath}):${error instanceof Error?error.message:String(error)}`,{cause:error});} + if (mapSelection.kind !== 'none') { + report(0.72, '组合机器人与物理地图'); + if (entry?.format === 'urdf' && urdfMode === 'native') + throw new Error('原生 URDF 模式暂不支持地图,请切换为转换模式'); + const slash = modelRelativePath.lastIndexOf('/'); + const directory = slash >= 0 ? modelRelativePath.slice(0, slash + 1) : ''; + const mapPath = `${directory}.__mujoco_map_scene_${manifest.id.replace(/[^a-zA-Z0-9_-]/g, '_')}.xml`; + if (mapSelection.kind === 'builtin') { + const composed = composePhysicalMap( + new TextEncoder().encode(workspace.readText(modelRelativePath)), + mapSelection.config, + ); + workspace.writeGenerated(mapPath, composed.data); + if (composed.summary) warnings.push(composed.summary); + } else { + const resolvedMap = resolveProjectMap(prepared.manifest, mapSelection.descriptorPath); + const composed = composeProjectMap( + new TextEncoder().encode(workspace.readText(modelRelativePath)), + mapPath, + prepared.manifest, + resolvedMap, + mapSelection, + ); + workspace.writeGenerated(mapPath, composed.data); + warnings.push(...composed.warnings, composed.summary); + } + modelRelativePath = mapPath; + modelPath = workspace.path(modelRelativePath); + } + report(0.84, '编译模型与物理数据'); + console.info('[MuJoCo] 编译模型', modelPath); + nextSession = new SimulationSession(module, modelPath, warnings); + if (entry?.format === 'urdf' && urdfMode === 'native') { + const offset = nextSession.alignLowestPointToGround(); + warnings.push(`原生 URDF 已整体平移 ${offset.toFixed(4)} m,使最低点位于 z=0`); + } + if (warnings.length) console.info('[MuJoCo] 兼容与地图处理', warnings); + console.info('[MuJoCo] 模型编译完成'); + report(0.94, '生成初始仿真状态'); + const snapshot = nextSession.snapshot(); + if (this.disposed || generation !== this.loadGeneration) throw new Error('模型加载已取消'); + this.releaseRetired(); + this.retiredSession = this.session; + this.retiredWorkspace = this.workspace; + this.retiredSupportFiles = this.supportFiles; + this.session = nextSession; + this.workspace = workspace; + this.supportFiles = supportFiles; + nextSession = null; + console.info('[MuJoCo] 状态快照完成'); + return snapshot; + } catch (error) { + nextSession?.dispose(); + workspace.dispose(); + throw new Error( + `模型编译失败(${entryPath}):${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } } - advance(now:number):FrameResult{return this.session?.advance(now)??{steps:0,stepMs:0,overBudget:false};} - snapshot():SimulationSnapshot|null{return this.session?.snapshot()??null;} - setPaused(value:boolean):void{this.session?.setPaused(value);} - setSpeed(value:number):void{this.session?.setSpeed(value);} - reset():void{this.session?.reset();} - singleStep():void{this.session?.singleStep();} - setActuator(id:number,value:number):void{this.session?.setActuator(id,value);} - setActuatorParameters(id:number,parameters:ActuatorParameters):boolean{return this.session?.setActuatorParameters(id,parameters)??false;} - setJointPosition(id:number,value:number):boolean{return this.session?.setJointPosition(id,value)??false;} - resetJoints():void {this.session?.resetJoints();} - setIgnoreJointLimits(ignore:boolean):void {this.session?.setIgnoreJointLimits(ignore);} - setExternalForce(bodyId:number,force:[number,number,number]):void{this.session?.setExternalForce(bodyId,force);} - clearExternalForce():void{this.session?.clearExternalForce();} - async loadPythonController(source:string,path:string):Promise{if(!this.session)throw new Error('请先加载模型');return this.session.loadPythonController(source,path);} - setControllerEnabled(enabled:boolean):void{this.session?.setControllerEnabled(enabled);} - sendControllerCommand(command:ControllerCommand):void{this.session?.sendControllerCommand(command);} - removeController():void{this.session?.removeController();} - async loadRLPolicy(model:Uint8Array,path:string):Promise{if(!this.session)throw new Error('请先加载模型');return this.session.loadRLPolicy(model,path);} - setRLPolicyEnabled(enabled:boolean):void{this.session?.setRLPolicyEnabled(enabled);} - setRLCommand(command:RLCommand):void{this.session?.setRLCommand(command);} - removeRLPolicy():void{this.session?.removeRLPolicy();} - cachedSupportFiles():ProjectFile[]{return this.supportFiles.map(file=>({...file,data:file.data.slice()}));} - exportMjcf():Uint8Array{ - if(!this.session||!this.workspace)throw new Error('尚未加载可导出的模型'); - const relative='.__platform_export__.xml'; - if(this.session.module.mj_saveLastXML(this.workspace.path(relative),this.session.model)===0)throw new Error('MuJoCo 无法生成 MJCF'); - const source=this.workspace.readText(relative),document=new DOMParser().parseFromString(source,'application/xml'),actuatorSection=document.querySelector('mujoco > actuator'); - if(document.querySelector('parsererror'))return new TextEncoder().encode(source); - const snapshot=this.session.snapshot(); - if(actuatorSection){for(const info of snapshot.actuators){const element=Array.from(actuatorSection.children).find(candidate=>candidate.getAttribute('name')===info.name);if(!element)continue;element.setAttribute('ctrllimited',info.ctrlLimited?'true':'false');element.setAttribute('forcelimited',info.forceLimited?'true':'false');}} - for(const info of snapshot.actuators){if(info.kind!=='motor'||!info.jointName)continue;const joint=Array.from(document.querySelectorAll('worldbody joint[name]')).find(candidate=>candidate.getAttribute('name')===info.jointName);if(joint){joint.setAttribute('stiffness',String(info.kp));joint.setAttribute('damping',String(info.kv));}} - const output=new TextEncoder().encode(new XMLSerializer().serializeToString(document));this.workspace.writeGenerated(relative,output);return output; + advance(now: number): FrameResult { + return this.session?.advance(now) ?? { steps: 0, stepMs: 0, overBudget: false }; + } + snapshot(): SimulationSnapshot | null { + return this.session?.snapshot() ?? null; + } + setPaused(value: boolean): void { + this.session?.setPaused(value); + } + setSpeed(value: number): void { + this.session?.setSpeed(value); + } + reset(): void { + this.session?.reset(); + } + singleStep(): void { + this.session?.singleStep(); + } + setActuator(id: number, value: number): void { + this.session?.setActuator(id, value); + } + setActuatorParameters(id: number, parameters: ActuatorParameters): boolean { + return this.session?.setActuatorParameters(id, parameters) ?? false; + } + setJointPosition(id: number, value: number): boolean { + return this.session?.setJointPosition(id, value) ?? false; + } + resetJoints(): void { + this.session?.resetJoints(); + } + setIgnoreJointLimits(ignore: boolean): void { + this.session?.setIgnoreJointLimits(ignore); + } + setExternalForce(bodyId: number, force: [number, number, number]): void { + this.session?.setExternalForce(bodyId, force); + } + clearExternalForce(): void { + this.session?.clearExternalForce(); + } + async loadPythonController(source: string, path: string): Promise { + if (!this.session) throw new Error('请先加载模型'); + return this.session.loadPythonController(source, path); + } + setControllerEnabled(enabled: boolean): void { + this.session?.setControllerEnabled(enabled); + } + sendControllerCommand(command: ControllerCommand): void { + this.session?.sendControllerCommand(command); + } + removeController(): void { + this.session?.removeController(); + } + async loadRLPolicy(model: Uint8Array, path: string): Promise { + if (!this.session) throw new Error('请先加载模型'); + return this.session.loadRLPolicy(model, path); + } + setRLPolicyEnabled(enabled: boolean): void { + this.session?.setRLPolicyEnabled(enabled); + } + setRLCommand(command: RLCommand): void { + this.session?.setRLCommand(command); + } + removeRLPolicy(): void { + this.session?.removeRLPolicy(); + } + configureDataRecorder(config: Partial): DataRecorderStatus | undefined { + return this.session?.configureDataRecorder(config); + } + startDataRecording(): DataRecorderStatus | undefined { + return this.session?.startDataRecording(); + } + stopDataRecording(): DataRecorderStatus | undefined { + return this.session?.stopDataRecording(); + } + clearDataRecording(): DataRecorderStatus | undefined { + return this.session?.clearDataRecording(); + } + registerDataChannel(channel: TelemetryChannel): (() => void) | undefined { + return this.session?.registerDataChannel(channel); + } + exportDataRecording(format: 'csv' | 'json'): Uint8Array { + if (!this.session) throw new Error('请先加载模型'); + return this.session.exportDataRecording(format); + } + cachedSupportFiles(): ProjectFile[] { + return this.supportFiles.map((file) => ({ ...file, data: file.data.slice() })); + } + releaseRetired(): void { + this.retiredSession?.dispose(); + this.retiredSession = null; + this.retiredWorkspace?.dispose(); + this.retiredWorkspace = null; + this.retiredSupportFiles = []; + } + rollbackRetired(): void { + this.session?.dispose(); + this.workspace?.dispose(); + this.session = this.retiredSession; + this.workspace = this.retiredWorkspace; + this.supportFiles = this.retiredSupportFiles; + this.retiredSession = null; + this.retiredWorkspace = null; + this.retiredSupportFiles = []; + } + exportMjcf(): Uint8Array { + if (!this.session || !this.workspace) throw new Error('尚未加载可导出的模型'); + const relative = '.__platform_export__.xml'; + if (this.session.module.mj_saveLastXML(this.workspace.path(relative), this.session.model) === 0) + throw new Error('MuJoCo 无法生成 MJCF'); + const source = this.workspace.readText(relative), + document = new DOMParser().parseFromString(source, 'application/xml'), + actuatorSection = document.querySelector('mujoco > actuator'); + if (document.querySelector('parsererror')) return new TextEncoder().encode(source); + const snapshot = this.session.snapshot(); + if (actuatorSection) { + for (const info of snapshot.actuators) { + const element = Array.from(actuatorSection.children).find( + (candidate) => candidate.getAttribute('name') === info.name, + ); + if (!element) continue; + element.setAttribute('ctrllimited', info.ctrlLimited ? 'true' : 'false'); + element.setAttribute('forcelimited', info.forceLimited ? 'true' : 'false'); + } + } + for (const info of snapshot.actuators) { + if (info.kind !== 'motor' || !info.jointName) continue; + const joint = Array.from(document.querySelectorAll('worldbody joint[name]')).find( + (candidate) => candidate.getAttribute('name') === info.jointName, + ); + if (joint) { + joint.setAttribute('stiffness', String(info.kp)); + joint.setAttribute('damping', String(info.kv)); + } + } + const output = new TextEncoder().encode(new XMLSerializer().serializeToString(document)); + this.workspace.writeGenerated(relative, output); + return output; + } + private releaseCurrent(): void { + this.releaseRetired(); + this.session?.dispose(); + this.session = null; + this.workspace?.dispose(); + this.workspace = null; + this.supportFiles = []; + } + dispose(): void { + this.disposed = true; + this.loadGeneration += 1; + this.releaseCurrent(); } - private releaseCurrent():void{this.session?.dispose(); this.session=null; this.workspace?.dispose(); this.workspace=null;this.supportFiles=[];} - dispose():void{this.releaseCurrent();} } diff --git a/web_platform/src/simulation/SimulationSession.ts b/web_platform/src/simulation/SimulationSession.ts index 829e3675..0dc2796f 100644 --- a/web_platform/src/simulation/SimulationSession.ts +++ b/web_platform/src/simulation/SimulationSession.ts @@ -1,17 +1,92 @@ -import type {MainModule, MjData, MjModel, MjvPerturb, MjvScene} from '@mujoco/mujoco'; -import {meshIdFromSceneDataId} from './geometry'; -import {PythonControllerRuntime} from '../controller/PythonControllerRuntime'; -import type {ControllerBindings,ControllerCommand,ControllerStatus} from '../controller/types'; -import {Go2wPolicyBindings} from '../rl/runtime/Go2wPolicyBindings'; -import type {OnnxPolicyRuntime} from '../rl/runtime/OnnxPolicyRuntime'; -import type {RLCommand,RLPolicyStatus} from '../rl/types'; +import type { MainModule, MjData, MjModel, MjvPerturb, MjvScene } from '@mujoco/mujoco'; +import { meshIdFromSceneDataId } from './geometry'; +import { PythonControllerRuntime } from '../controller/PythonControllerRuntime'; +import type { ControllerBindings, ControllerCommand, ControllerStatus } from '../controller/types'; +import { Go2wPolicyBindings } from '../rl/runtime/Go2wPolicyBindings'; +import type { OnnxPolicyRuntime } from '../rl/runtime/OnnxPolicyRuntime'; +import type { RLCommand, RLPolicyStatus } from '../rl/types'; +import { + DataRecorder, + type DataRecorderConfig, + type DataRecorderStatus, + type TelemetryBody, + type TelemetryChannel, +} from './DataRecorder'; -export interface ActuatorParameters {gear:number;gain:number;kp:number;kv:number;ctrlLimited:boolean;ctrlMin:number;ctrlMax:number;forceLimited:boolean;forceMin:number;forceMax:number;} -export interface ActuatorInfo extends ActuatorParameters {id:number;name:string;value:number;min:number;max:number;limited:boolean;jointId?:number;jointName?:string;jointType?:number;unit:string;kind:'motor'|'position'|'velocity'|'other';controlCount:number;} -export interface JointInfo {id:number;name:string;type:number;value:number;min:number;max:number;limitMin:number;limitMax:number;limited:boolean;limitsIgnored:boolean;editable:boolean;bodyId:number;axis:[number,number,number];} -export interface BodyInfo {id:number;name:string;parentId:number;} -export interface SimulationSnapshot {time: number; qpos: number[]; qvel: number[]; ctrl: number[]; actuators: ActuatorInfo[]; joints: JointInfo[]; bodies: BodyInfo[]; warnings: string[]; controller?:ControllerStatus; rlPolicy?:RLPolicyStatus; model:{nbody:number;njnt:number;ngeom:number;ncam:number;nactuator:number;nu:number;nq:number;nv:number};} -export interface FrameResult {steps: number; stepMs: number; overBudget: boolean;} +export interface ActuatorParameters { + gear: number; + gain: number; + kp: number; + kv: number; + ctrlLimited: boolean; + ctrlMin: number; + ctrlMax: number; + forceLimited: boolean; + forceMin: number; + forceMax: number; +} +export interface ActuatorInfo extends ActuatorParameters { + id: number; + name: string; + value: number; + min: number; + max: number; + limited: boolean; + jointId?: number; + jointName?: string; + jointType?: number; + unit: string; + kind: 'motor' | 'position' | 'velocity' | 'other'; + controlCount: number; +} +export interface JointInfo { + id: number; + name: string; + type: number; + value: number; + min: number; + max: number; + limitMin: number; + limitMax: number; + limited: boolean; + limitsIgnored: boolean; + editable: boolean; + bodyId: number; + axis: [number, number, number]; +} +export interface BodyInfo { + id: number; + name: string; + parentId: number; +} +export interface SimulationSnapshot { + time: number; + qpos: number[]; + qvel: number[]; + ctrl: number[]; + actuators: ActuatorInfo[]; + joints: JointInfo[]; + bodies: BodyInfo[]; + warnings: string[]; + controller?: ControllerStatus; + rlPolicy?: RLPolicyStatus; + telemetry: DataRecorderStatus; + model: { + nbody: number; + njnt: number; + ngeom: number; + ncam: number; + nactuator: number; + nu: number; + nq: number; + nv: number; + }; +} +export interface FrameResult { + steps: number; + stepMs: number; + overBudget: boolean; +} export class SimulationSession { readonly model: MjModel; @@ -25,103 +100,352 @@ export class SimulationSession { private lastNow?: number; private forceBody = -1; private force: [number, number, number] = [0, 0, 0]; - private disposed=false; - private ignoreJointLimits=false; - private jointLimits:{limited:boolean;min:number;max:number;type:number}[]=[]; - private pythonController?:PythonControllerRuntime; - private controllerLoadGeneration=0; - private rlPolicy?:OnnxPolicyRuntime; - private rlPolicyLoadGeneration=0; + private disposed = false; + private ignoreJointLimits = false; + private jointLimits: { limited: boolean; min: number; max: number; type: number }[] = []; + private pythonController?: PythonControllerRuntime; + private controllerLoadGeneration = 0; + private rlPolicy?: OnnxPolicyRuntime; + private rlPolicyLoadGeneration = 0; + private dataRecorder!: DataRecorder; - constructor(readonly module: MainModule, modelPath: string, readonly warnings: string[] = []) { - let model: MjModel | undefined; let data: MjData | undefined; let perturb: MjvPerturb | undefined; + constructor( + readonly module: MainModule, + modelPath: string, + readonly warnings: string[] = [], + ) { + let model: MjModel | undefined; + let data: MjData | undefined; + let perturb: MjvPerturb | undefined; try { model = module.MjModel.mj_loadXML(modelPath) ?? undefined; if (!model) throw new Error(`MuJoCo 无法编译模型:${modelPath}`); data = new module.MjData(model); - perturb = new module.MjvPerturb(); module.mjv_defaultPerturb(perturb); - this.model=model;this.data=data;this.perturb=perturb; - this.jointLimits=Array.from({length:model.njnt},(_,id)=>{const joint=model!.jnt(id);try{return {limited:Boolean(joint.limited),min:Number(joint.range[0]),max:Number(joint.range[1]),type:Number(joint.type)};}finally{joint.delete();}}); - module.mj_forward(model,data); - } catch (error) { perturb?.delete(); data?.delete(); model?.delete(); throw error; } + perturb = new module.MjvPerturb(); + module.mjv_defaultPerturb(perturb); + this.model = model; + this.data = data; + this.perturb = perturb; + this.jointLimits = Array.from({ length: model.njnt }, (_, id) => { + const joint = model!.jnt(id); + try { + return { + limited: Boolean(joint.limited), + min: Number(joint.range[0]), + max: Number(joint.range[1]), + type: Number(joint.type), + }; + } finally { + joint.delete(); + } + }); + module.mj_forward(model, data); + const bodies = this.telemetryBodies(); + this.dataRecorder = new DataRecorder( + { + simulationTime: () => Number(this.data.time), + bodyPose: (bodyId) => { + const positionAddress = bodyId * 3, + quaternionAddress = bodyId * 4; + return { + position: [ + Number(this.data.xpos[positionAddress]), + Number(this.data.xpos[positionAddress + 1]), + Number(this.data.xpos[positionAddress + 2]), + ], + quaternion: [ + Number(this.data.xquat[quaternionAddress]), + Number(this.data.xquat[quaternionAddress + 1]), + Number(this.data.xquat[quaternionAddress + 2]), + Number(this.data.xquat[quaternionAddress + 3]), + ], + }; + }, + controls: () => this.data.ctrl, + actuatorForces: () => this.data.actuator_force, + actuatorVelocities: () => this.data.actuator_velocity, + generalizedVelocities: () => this.data.qvel, + contactCount: () => Number(this.data.ncon), + }, + bodies, + { bodyId: this.defaultTelemetryBody(bodies) }, + ); + } catch (error) { + perturb?.delete(); + data?.delete(); + model?.delete(); + throw error; + } } - setPaused(paused: boolean): void {this.paused = paused; this.accumulator = 0; this.lastNow = undefined;} - setSpeed(speed: number): void {this.speed = Math.min(4, Math.max(0.1, speed));} - reset(): void {this.setPaused(true);this.module.mj_resetData(this.model,this.data);this.module.mj_forward(this.model,this.data);this.clearExternalForce();this.data.ctrl.fill(0);this.pythonController?.reset(Number(this.data.time));this.rlPolicy?.reset(Number(this.data.time));} - singleStep(): void {this.runController();this.applyForce();this.module.mj_step(this.model,this.data);} + setPaused(paused: boolean): void { + this.paused = paused; + this.accumulator = 0; + this.lastNow = undefined; + } + setSpeed(speed: number): void { + this.speed = Math.min(4, Math.max(0.1, speed)); + } + reset(): void { + this.setPaused(true); + this.module.mj_resetData(this.model, this.data); + this.module.mj_forward(this.model, this.data); + this.clearExternalForce(); + this.data.ctrl.fill(0); + this.pythonController?.reset(Number(this.data.time)); + this.rlPolicy?.reset(Number(this.data.time)); + this.dataRecorder.simulationReset(); + } + singleStep(): void { + this.runController(); + this.applyForce(); + this.module.mj_step(this.model, this.data); + this.dataRecorder.capture(); + } advance(now: number): FrameResult { - if (this.lastNow === undefined) {this.lastNow = now; return {steps: 0, stepMs: 0, overBudget: false};} - const elapsed = Math.min(0.1, Math.max(0, (now - this.lastNow) / 1000)); this.lastNow = now; - if (this.paused) return {steps: 0, stepMs: 0, overBudget: false}; + if (this.lastNow === undefined) { + this.lastNow = now; + return { steps: 0, stepMs: 0, overBudget: false }; + } + const elapsed = Math.min(0.1, Math.max(0, (now - this.lastNow) / 1000)); + this.lastNow = now; + if (this.paused) return { steps: 0, stepMs: 0, overBudget: false }; this.accumulator += elapsed * this.speed; - const dt = Number(this.model.opt.timestep) || 0.002; const started = performance.now(); let steps = 0; - while (this.accumulator >= dt && steps < this.maxCatchUpSteps && performance.now() - started < this.frameBudgetMs) { - this.runController();this.applyForce();this.module.mj_step(this.model,this.data);this.accumulator-=dt;steps++; + const dt = Number(this.model.opt.timestep) || 0.002; + const started = performance.now(); + let steps = 0; + while ( + this.accumulator >= dt && + steps < this.maxCatchUpSteps && + performance.now() - started < this.frameBudgetMs + ) { + this.runController(); + this.applyForce(); + this.module.mj_step(this.model, this.data); + this.dataRecorder.capture(); + this.accumulator -= dt; + steps++; } const overBudget = this.accumulator >= dt; if (steps >= this.maxCatchUpSteps) this.accumulator = Math.min(this.accumulator, dt); - return {steps, stepMs: performance.now() - started, overBudget}; + return { steps, stepMs: performance.now() - started, overBudget }; } - async loadPythonController(source:string,path:string):Promise{ - const generation=++this.controllerLoadGeneration; - const runtime=await PythonControllerRuntime.load(source,path,this.controllerBindings()); - if(this.disposed||generation!==this.controllerLoadGeneration){runtime.dispose();throw new Error('模型已切换,控制器加载已取消');} - this.pythonController?.dispose();this.pythonController=runtime; + async loadPythonController(source: string, path: string): Promise { + const generation = ++this.controllerLoadGeneration; + const runtime = await PythonControllerRuntime.load(source, path, this.controllerBindings()); + if (this.disposed || generation !== this.controllerLoadGeneration) { + runtime.dispose(); + throw new Error('模型已切换,控制器加载已取消'); + } + this.pythonController?.dispose(); + this.pythonController = runtime; return runtime.status(); } - setControllerEnabled(enabled:boolean):void { - if(enabled&&this.pythonController){this.data.ctrl.fill(0);this.rlPolicy?.setEnabled(false,Number(this.data.time));} - this.pythonController?.setEnabled(enabled,Number(this.data.time)); - if(!enabled)this.data.ctrl.fill(0); + setControllerEnabled(enabled: boolean): void { + if (enabled && this.pythonController) { + this.data.ctrl.fill(0); + this.rlPolicy?.setEnabled(false, Number(this.data.time)); + } + this.pythonController?.setEnabled(enabled, Number(this.data.time)); + if (!enabled) this.data.ctrl.fill(0); } - async loadRLPolicy(model:Uint8Array,path:string):Promise{ - const generation=++this.rlPolicyLoadGeneration; - const bindings=new Go2wPolicyBindings(this.model,this.data,(id,value)=>this.setActuator(id,value)); - const {OnnxPolicyRuntime:Runtime}=await import('../rl/runtime/OnnxPolicyRuntime'); - const runtime=await Runtime.load(model,path,bindings); - if(this.disposed||generation!==this.rlPolicyLoadGeneration){runtime.dispose();throw new Error('模型已切换,ONNX 策略加载已取消');} - this.data.ctrl.fill(0);this.rlPolicy?.dispose();this.rlPolicy=runtime; + async loadRLPolicy(model: Uint8Array, path: string): Promise { + const generation = ++this.rlPolicyLoadGeneration; + const bindings = new Go2wPolicyBindings(this.model, this.data, (id, value) => + this.setActuator(id, value), + ); + const { OnnxPolicyRuntime: Runtime } = await import('../rl/runtime/OnnxPolicyRuntime'); + const runtime = await Runtime.load(model, path, bindings); + if (this.disposed || generation !== this.rlPolicyLoadGeneration) { + runtime.dispose(); + throw new Error('模型已切换,ONNX 策略加载已取消'); + } + this.data.ctrl.fill(0); + this.rlPolicy?.dispose(); + this.rlPolicy = runtime; return runtime.status(); } - setRLPolicyEnabled(enabled:boolean):void { - if(enabled&&this.rlPolicy){this.data.ctrl.fill(0);this.pythonController?.setEnabled(false,Number(this.data.time));} - this.rlPolicy?.setEnabled(enabled,Number(this.data.time)); - if(!enabled)this.data.ctrl.fill(0); + setRLPolicyEnabled(enabled: boolean): void { + if (enabled && this.rlPolicy) { + this.data.ctrl.fill(0); + this.pythonController?.setEnabled(false, Number(this.data.time)); + } + this.rlPolicy?.setEnabled(enabled, Number(this.data.time)); + if (!enabled) this.data.ctrl.fill(0); } - setRLCommand(command:RLCommand):void {this.rlPolicy?.setCommand(command);} - removeRLPolicy():void {this.rlPolicyLoadGeneration+=1;this.rlPolicy?.dispose();this.rlPolicy=undefined;this.data.ctrl.fill(0);} - - sendControllerCommand(command:ControllerCommand):void {this.pythonController?.command(command);} - - removeController():void {this.controllerLoadGeneration+=1;this.pythonController?.dispose();this.pythonController=undefined;this.data.ctrl.fill(0);} - - private runController():void { - try{this.pythonController?.stepIfDue(Number(this.data.time));this.rlPolicy?.step(Number(this.data.time));} - catch(error){this.setPaused(true);this.data.ctrl.fill(0);throw error;} + setRLCommand(command: RLCommand): void { + this.rlPolicy?.setCommand(command); } - private controllerBindings():ControllerBindings { - const joints=new Map(),actuators=new Map(),sensors=new Map(),bodies=new Map(); - for(let id=0;id,kind:string,name:string)=>{const id=items.get(name);if(id===undefined)throw new Error(`模型中找不到${kind}:${name}`);return id;}; - return {model:{joint:name=>resolve(joints,'关节',name),actuator:name=>resolve(actuators,'驱动器',name),sensor:name=>resolve(sensors,'传感器',name),body:name=>resolve(bodies,'Body',name)},createStepApi:(time,dt)=>({time,dt,qpos:(jointId)=>{const joint=this.model.jnt(jointId);try{const type=Number(joint.type);if(type!==2&&type!==3)throw new Error(`关节 ${jointId} 不是标量 hinge/slide 关节`);return Number(this.data.qpos[Number(joint.qposadr)]);}finally{joint.delete();}},qvel:(jointId)=>{const joint=this.model.jnt(jointId);try{return Number(this.data.qvel[Number(joint.dofadr)]);}finally{joint.delete();}},sensor:(sensorId)=>{if(sensorId<0||sensorId>=this.model.nsensor)throw new Error(`传感器 ID 无效:${sensorId}`);const adr=Number(this.model.sensor_adr[sensorId]),dim=Number(this.model.sensor_dim[sensorId]);return Array.from(this.data.sensordata.subarray(adr,adr+dim),Number);},body_quat:(bodyId)=>{if(bodyId<0||bodyId>=this.model.nbody)throw new Error(`Body ID 无效:${bodyId}`);const adr=bodyId*4;return [Number(this.data.xquat[adr]),Number(this.data.xquat[adr+1]),Number(this.data.xquat[adr+2]),Number(this.data.xquat[adr+3])];},body_position:(bodyId)=>{if(bodyId<0||bodyId>=this.model.nbody)throw new Error(`Body ID 无效:${bodyId}`);const adr=bodyId*3;return [Number(this.data.xpos[adr]),Number(this.data.xpos[adr+1]),Number(this.data.xpos[adr+2])];},set_control:(actuatorId,value)=>{if(!Number.isFinite(value))throw new Error(`控制输出不是有限数:${value}`);this.setActuator(actuatorId,value);}})}; + configureDataRecorder(config: Partial): DataRecorderStatus { + return this.dataRecorder.configure(config); + } + startDataRecording(): DataRecorderStatus { + return this.dataRecorder.start(); + } + stopDataRecording(): DataRecorderStatus { + return this.dataRecorder.stop(); + } + clearDataRecording(): DataRecorderStatus { + return this.dataRecorder.clear(); + } + registerDataChannel(channel: TelemetryChannel): () => void { + return this.dataRecorder.registerChannel(channel); + } + exportDataRecording(format: 'csv' | 'json'): Uint8Array { + return format === 'csv' ? this.dataRecorder.toCsv() : this.dataRecorder.toJson(); + } + + removeRLPolicy(): void { + this.rlPolicyLoadGeneration += 1; + this.rlPolicy?.dispose(); + this.rlPolicy = undefined; + this.data.ctrl.fill(0); + } + + sendControllerCommand(command: ControllerCommand): void { + this.pythonController?.command(command); + } + + removeController(): void { + this.controllerLoadGeneration += 1; + this.pythonController?.dispose(); + this.pythonController = undefined; + this.data.ctrl.fill(0); + } + + private runController(): void { + try { + this.pythonController?.stepIfDue(Number(this.data.time)); + this.rlPolicy?.step(Number(this.data.time)); + } catch (error) { + this.setPaused(true); + this.data.ctrl.fill(0); + throw error; + } + } + + private controllerBindings(): ControllerBindings { + const joints = new Map(), + actuators = new Map(), + sensors = new Map(), + bodies = new Map(); + for (let id = 0; id < this.model.njnt; id += 1) { + const item = this.model.jnt(id); + try { + if (item.name) joints.set(item.name, id); + } finally { + item.delete(); + } + } + for (let id = 0; id < this.model.nactuator; id += 1) { + const item = this.model.actuator(id); + try { + if (item.name) actuators.set(item.name, id); + } finally { + item.delete(); + } + } + for (let id = 0; id < this.model.nsensor; id += 1) { + const item = this.model.sensor(id); + try { + if (item.name) sensors.set(item.name, id); + } finally { + item.delete(); + } + } + for (let id = 0; id < this.model.nbody; id += 1) { + const item = this.model.body(id); + try { + if (item.name) bodies.set(item.name, id); + } finally { + item.delete(); + } + } + const resolve = (items: Map, kind: string, name: string) => { + const id = items.get(name); + if (id === undefined) throw new Error(`模型中找不到${kind}:${name}`); + return id; + }; + return { + model: { + joint: (name) => resolve(joints, '关节', name), + actuator: (name) => resolve(actuators, '驱动器', name), + sensor: (name) => resolve(sensors, '传感器', name), + body: (name) => resolve(bodies, 'Body', name), + }, + createStepApi: (time, dt) => ({ + time, + dt, + qpos: (jointId) => { + const joint = this.model.jnt(jointId); + try { + const type = Number(joint.type); + if (type !== 2 && type !== 3) + throw new Error(`关节 ${jointId} 不是标量 hinge/slide 关节`); + return Number(this.data.qpos[Number(joint.qposadr)]); + } finally { + joint.delete(); + } + }, + qvel: (jointId) => { + const joint = this.model.jnt(jointId); + try { + return Number(this.data.qvel[Number(joint.dofadr)]); + } finally { + joint.delete(); + } + }, + sensor: (sensorId) => { + if (sensorId < 0 || sensorId >= this.model.nsensor) + throw new Error(`传感器 ID 无效:${sensorId}`); + const adr = Number(this.model.sensor_adr[sensorId]), + dim = Number(this.model.sensor_dim[sensorId]); + return Array.from(this.data.sensordata.subarray(adr, adr + dim), Number); + }, + body_quat: (bodyId) => { + if (bodyId < 0 || bodyId >= this.model.nbody) throw new Error(`Body ID 无效:${bodyId}`); + const adr = bodyId * 4; + return [ + Number(this.data.xquat[adr]), + Number(this.data.xquat[adr + 1]), + Number(this.data.xquat[adr + 2]), + Number(this.data.xquat[adr + 3]), + ]; + }, + body_position: (bodyId) => { + if (bodyId < 0 || bodyId >= this.model.nbody) throw new Error(`Body ID 无效:${bodyId}`); + const adr = bodyId * 3; + return [ + Number(this.data.xpos[adr]), + Number(this.data.xpos[adr + 1]), + Number(this.data.xpos[adr + 2]), + ]; + }, + set_control: (actuatorId, value) => { + if (!Number.isFinite(value)) throw new Error(`控制输出不是有限数:${value}`); + this.setActuator(actuatorId, value); + }, + }), + }; } setActuator(id: number, value: number): void { if (id < 0 || id >= this.model.nactuator) return; const actuator = this.model.actuator(id); try { - const address=Number(this.model.actuator_ctrladr[id]??id),nextAddress=id+1=this.model.nactuator)return false; - const finite=(value:number,fallback:number)=>Number.isFinite(value)?value:fallback; - const ordered=(a:number,b:number,fallbackA:number,fallbackB:number):[number,number]=>{const first=finite(a,fallbackA),second=finite(b,fallbackB),lower=Math.min(first,second),upper=Math.max(first,second);return upper-lower>=1e-9?[lower,upper]:[lower,lower+1e-6];}; - const actuator=this.model.actuator(id); - try{ - const address=Number(this.model.actuator_ctrladr[id]??id),nextAddress=id+1=0,plainDynamics=Number(actuator.gaintype)===0&&Number(actuator.dyntype)===0; - const motorLike=scalarJoint&&plainDynamics&&Number(actuator.biastype)===0,positionLike=scalarJoint&&plainDynamics&&Number(actuator.biastype)===1&&Math.abs(Number(actuator.biasprm[1])+Number(actuator.gainprm[0]))<1e-6;if(!motorLike&&!positionLike)return false; - const [ctrlMin,ctrlMax]=ordered(parameters.ctrlMin,parameters.ctrlMax,-1,1),[forceMin,forceMax]=ordered(parameters.forceMin,parameters.forceMax,-100,100); - actuator.gear[0]=finite(parameters.gear,1); - if(positionLike){const kp=Math.max(0,finite(parameters.kp,100)),kv=Math.max(0,finite(parameters.kv,10));actuator.gainprm[0]=kp;actuator.biasprm[1]=-kp;actuator.biasprm[2]=-kv;}else{actuator.gainprm[0]=finite(parameters.gain,1);const jointId=Number(actuator.trnid[0]);if(jointId>=0&&jointId= this.model.nactuator) return false; + const finite = (value: number, fallback: number) => (Number.isFinite(value) ? value : fallback); + const ordered = ( + a: number, + b: number, + fallbackA: number, + fallbackB: number, + ): [number, number] => { + const first = finite(a, fallbackA), + second = finite(b, fallbackB), + lower = Math.min(first, second), + upper = Math.max(first, second); + return upper - lower >= 1e-9 ? [lower, upper] : [lower, lower + 1e-6]; + }; + const actuator = this.model.actuator(id); + try { + const address = Number(this.model.actuator_ctrladr[id] ?? id), + nextAddress = + id + 1 < this.model.nactuator + ? Number(this.model.actuator_ctrladr[id + 1]) + : this.model.nu; + const scalarJoint = + nextAddress - address === 1 && + (Number(actuator.trntype) === 0 || Number(actuator.trntype) === 1) && + Number(actuator.trnid[0]) >= 0, + plainDynamics = Number(actuator.gaintype) === 0 && Number(actuator.dyntype) === 0; + const motorLike = scalarJoint && plainDynamics && Number(actuator.biastype) === 0, + positionLike = + scalarJoint && + plainDynamics && + Number(actuator.biastype) === 1 && + Math.abs(Number(actuator.biasprm[1]) + Number(actuator.gainprm[0])) < 1e-6; + if (!motorLike && !positionLike) return false; + const [ctrlMin, ctrlMax] = ordered(parameters.ctrlMin, parameters.ctrlMax, -1, 1), + [forceMin, forceMax] = ordered(parameters.forceMin, parameters.forceMax, -100, 100); + actuator.gear[0] = finite(parameters.gear, 1); + if (positionLike) { + const kp = Math.max(0, finite(parameters.kp, 100)), + kv = Math.max(0, finite(parameters.kv, 10)); + actuator.gainprm[0] = kp; + actuator.biasprm[1] = -kp; + actuator.biasprm[2] = -kv; + } else { + actuator.gainprm[0] = finite(parameters.gain, 1); + const jointId = Number(actuator.trnid[0]); + if (jointId >= 0 && jointId < this.model.njnt) { + const joint = this.model.jnt(jointId); + try { + joint.stiffness = Math.max(0, finite(parameters.kp, 0)); + joint.damping = Math.max(0, finite(parameters.kv, 0)); + } finally { + joint.delete(); + } + } + } + actuator.ctrllimited = parameters.ctrlLimited ? 1 : 0; + actuator.ctrlrange[0] = ctrlMin; + actuator.ctrlrange[1] = ctrlMax; + actuator.forcelimited = parameters.forceLimited ? 1 : 0; + actuator.forcerange[0] = forceMin; + actuator.forcerange[1] = forceMax; + if (parameters.ctrlLimited) + this.data.ctrl[address] = Math.min( + ctrlMax, + Math.max(ctrlMin, Number(this.data.ctrl[address])), + ); + this.module.mj_forward(this.model, this.data); + return true; + } finally { + actuator.delete(); + } } - setJointPosition(id:number,value:number):boolean { - if(id<0||id>=this.model.njnt)return false;const joint=this.model.jnt(id); - try{const type=Number(joint.type);if(type!==2&&type!==3)return false;const original=this.jointLimits[id];const next=!this.ignoreJointLimits&&original.limited?Math.min(original.max,Math.max(original.min,value)):value;this.setPaused(true);this.data.qpos[Number(joint.qposadr)]=next;this.module.mj_forward(this.model,this.data);return true;}finally{joint.delete();} + setJointPosition(id: number, value: number): boolean { + if (id < 0 || id >= this.model.njnt) return false; + const joint = this.model.jnt(id); + try { + const type = Number(joint.type); + if (type !== 2 && type !== 3) return false; + const original = this.jointLimits[id]; + const next = + !this.ignoreJointLimits && original.limited + ? Math.min(original.max, Math.max(original.min, value)) + : value; + this.setPaused(true); + this.data.qpos[Number(joint.qposadr)] = next; + this.module.mj_forward(this.model, this.data); + return true; + } finally { + joint.delete(); + } } - resetJoints():void {this.setPaused(true);for(let id=0;id 0 && bodyId < this.model.nbody ? bodyId : -1; this.force = force;} - clearExternalForce(): void {this.forceBody = -1; this.force = [0, 0, 0]; this.data.xfrc_applied.fill(0); this.perturb.active = 0;} - initializePerturb(scene: MjvScene, bodyId: number): void {this.perturb.select = bodyId; this.module.mjv_initPerturb(this.model, this.data, scene, this.perturb);} - applyPerturbForce(): void {if (this.forceBody > 0) this.module.mjv_applyPerturbForce(this.model, this.data, this.perturb);} + setExternalForce(bodyId: number, force: [number, number, number]): void { + this.forceBody = bodyId > 0 && bodyId < this.model.nbody ? bodyId : -1; + this.force = force; + } + clearExternalForce(): void { + this.forceBody = -1; + this.force = [0, 0, 0]; + this.data.xfrc_applied.fill(0); + this.perturb.active = 0; + } + initializePerturb(scene: MjvScene, bodyId: number): void { + this.perturb.select = bodyId; + this.module.mjv_initPerturb(this.model, this.data, scene, this.perturb); + } + applyPerturbForce(): void { + if (this.forceBody > 0) this.module.mjv_applyPerturbForce(this.model, this.data, this.perturb); + } private applyForce(): void { - this.data.xfrc_applied.fill(0); if (this.forceBody < 1) return; - this.applyPerturbForce(); const offset = this.forceBody * 6; - this.data.xfrc_applied[offset] += this.force[0]; this.data.xfrc_applied[offset + 1] += this.force[1]; this.data.xfrc_applied[offset + 2] += this.force[2]; + this.data.xfrc_applied.fill(0); + if (this.forceBody < 1) return; + this.applyPerturbForce(); + const offset = this.forceBody * 6; + this.data.xfrc_applied[offset] += this.force[0]; + this.data.xfrc_applied[offset + 1] += this.force[1]; + this.data.xfrc_applied[offset + 2] += this.force[2]; + } + + private telemetryBodies(): TelemetryBody[] { + const bodies = Array.from({ length: this.model.nbody }, (_, id) => { + const body = this.model.body(id); + try { + return { id, name: body.name || (id === 0 ? 'world' : `body_${id}`) }; + } finally { + body.delete(); + } + }).filter((body) => body.id > 0 && !body.name.startsWith('__platform_map_')); + return bodies.length ? bodies : [{ id: 0, name: 'world' }]; + } + + private defaultTelemetryBody(bodies: readonly TelemetryBody[]): number { + for (const body of bodies) { + if (Number(this.model.body_parentid[body.id]) !== 0) continue; + const firstJoint = Number(this.model.body_jntadr[body.id]), + jointCount = Number(this.model.body_jntnum[body.id]); + for (let offset = 0; offset < jointCount; offset += 1) + if (this.jointLimits[firstJoint + offset]?.type === 0) return body.id; + } + return ( + bodies.find((body) => Number(this.model.body_parentid[body.id]) === 0)?.id ?? bodies[0].id + ); } /** 用有限几何的包围球估算视图中心与范围,忽略地面等无限平面。 */ - geometryBounds():{center:[number,number,number];extent:number} { - const lower=[Number.POSITIVE_INFINITY,Number.POSITIVE_INFINITY,Number.POSITIVE_INFINITY];const upper=[Number.NEGATIVE_INFINITY,Number.NEGATIVE_INFINITY,Number.NEGATIVE_INFINITY]; - for(let geom=0;geom=0){ - const id=meshIdFromSceneDataId(geom.dataid),first=Number(this.model.mesh_vertadr[id]),count=Number(this.model.mesh_vertnum[id]);let minimum=Number.POSITIVE_INFINITY; - for(let vertex=0;vertex= 0) { + const id = meshIdFromSceneDataId(geom.dataid), + first = Number(this.model.mesh_vertadr[id]), + count = Number(this.model.mesh_vertnum[id]); + let minimum = Number.POSITIVE_INFINITY; + for (let vertex = 0; vertex < count; vertex += 1) { + const offset = (first + vertex) * 3; + minimum = Math.min( + minimum, + center + + r0 * this.model.mesh_vert[offset] + + r1 * this.model.mesh_vert[offset + 1] + + r2 * this.model.mesh_vert[offset + 2], + ); + } return minimum; } - const radius=geom.size[0]||0;return center-radius; + const radius = geom.size[0] || 0; + return center - radius; } snapshot(): SimulationSnapshot { - const actuators = Array.from({length: this.model.nactuator}, (_, id): ActuatorInfo => { + const actuators = Array.from({ length: this.model.nactuator }, (_, id): ActuatorInfo => { const actuator = this.model.actuator(id); try { const limited = Boolean(actuator.ctrllimited); - const address=Number(this.model.actuator_ctrladr[id]??id),nextAddress=id+1=0?Number(actuator.trnid[0]):undefined; - let jointName:string|undefined,jointType:number|undefined,jointStiffness=0,jointDamping=0;if(jointId!==undefined&&jointId= 0 + ? Number(actuator.trnid[0]) + : undefined; + let jointName: string | undefined, + jointType: number | undefined, + jointStiffness = 0, + jointDamping = 0; + if (jointId !== undefined && jointId < this.model.njnt) { + const joint = this.model.jnt(jointId); + try { + jointName = joint.name || `joint_${jointId}`; + jointType = Number(joint.type); + jointStiffness = Number(joint.stiffness); + jointDamping = Number(joint.damping); + } finally { + joint.delete(); + } + } + const ctrlMin = Number(actuator.ctrlrange[0]), + ctrlMax = Number(actuator.ctrlrange[1]), + forceLimited = Boolean(actuator.forcelimited); + const scalarJoint = controlCount === 1 && jointId !== undefined, + plainDynamics = Number(actuator.gaintype) === 0 && Number(actuator.dyntype) === 0, + gain = Number(actuator.gainprm[0]), + bias1 = Number(actuator.biasprm[1]), + bias2 = Number(actuator.biasprm[2]); + const kind: ActuatorInfo['kind'] = + scalarJoint && plainDynamics && Number(actuator.biastype) === 0 + ? 'motor' + : scalarJoint && + plainDynamics && + Number(actuator.biastype) === 1 && + Math.abs(bias1 + gain) < 1e-6 + ? 'position' + : scalarJoint && + plainDynamics && + Number(actuator.biastype) === 1 && + Math.abs(bias1) < 1e-9 && + Math.abs(bias2 + gain) < 1e-6 + ? 'velocity' + : 'other'; + return { + id, + name: actuator.name || `actuator_${id}`, + value: Number(this.data.ctrl[address]), + min: limited ? ctrlMin : -100, + max: limited ? ctrlMax : 100, + limited, + jointId, + jointName, + jointType, + unit: + kind === 'motor' + ? jointType === 3 + ? 'N·m' + : jointType === 2 + ? 'N' + : '' + : kind === 'position' + ? jointType === 3 + ? '°' + : jointType === 2 + ? 'm' + : '' + : '', + kind, + controlCount, + gear: Number(actuator.gear[0]), + gain, + kp: kind === 'position' ? gain : kind === 'motor' ? jointStiffness : 0, + kv: + kind === 'position' + ? Math.max(0, -bias2) + : kind === 'velocity' + ? gain + : kind === 'motor' + ? jointDamping + : 0, + ctrlLimited: limited, + ctrlMin, + ctrlMax, + forceLimited, + forceMin: Number(actuator.forcerange[0]), + forceMax: Number(actuator.forcerange[1]), + }; } finally { actuator.delete(); } }); - const joints = Array.from({length: this.model.njnt}, (_, id): JointInfo => { + const joints = Array.from({ length: this.model.njnt }, (_, id): JointInfo => { const joint = this.model.jnt(id); try { - const original=this.jointLimits[id],type=Number(joint.type),limited=original.limited;let min=limited?original.min:(type===2?-1:-Math.PI),max=limited?original.max:(type===2?1:Math.PI); - if(this.ignoreJointLimits){if(type===3){min=-2*Math.PI;max=2*Math.PI;}else if(type===2){const span=limited?Math.max(.25,original.max-original.min):1;min=limited?original.min-span:-1;max=limited?original.max+span:1;}} - return {id,name:joint.name||`joint_${id}`,type,value:Number(this.data.qpos[Number(joint.qposadr)]),min,max,limitMin:original.min,limitMax:original.max,limited,limitsIgnored:this.ignoreJointLimits,editable:type===2||type===3,bodyId:Number(joint.bodyid),axis:[Number(joint.axis[0]),Number(joint.axis[1]),Number(joint.axis[2])]}; + const original = this.jointLimits[id], + type = Number(joint.type), + limited = original.limited; + let min = limited ? original.min : type === 2 ? -1 : -Math.PI, + max = limited ? original.max : type === 2 ? 1 : Math.PI; + if (this.ignoreJointLimits) { + if (type === 3) { + min = -2 * Math.PI; + max = 2 * Math.PI; + } else if (type === 2) { + const span = limited ? Math.max(0.25, original.max - original.min) : 1; + min = limited ? original.min - span : -1; + max = limited ? original.max + span : 1; + } + } + return { + id, + name: joint.name || `joint_${id}`, + type, + value: Number(this.data.qpos[Number(joint.qposadr)]), + min, + max, + limitMin: original.min, + limitMax: original.max, + limited, + limitsIgnored: this.ignoreJointLimits, + editable: type === 2 || type === 3, + bodyId: Number(joint.bodyid), + axis: [Number(joint.axis[0]), Number(joint.axis[1]), Number(joint.axis[2])], + }; } finally { joint.delete(); } }); - const bodies = Array.from({length: this.model.nbody}, (_,id): BodyInfo => { + const bodies = Array.from({ length: this.model.nbody }, (_, id): BodyInfo => { const body = this.model.body(id); - try {return {id,name:body.name||`body_${id}`,parentId:Number(this.model.body_parentid[id])};} - finally { body.delete(); } + try { + return { + id, + name: body.name || `body_${id}`, + parentId: Number(this.model.body_parentid[id]), + }; + } finally { + body.delete(); + } }); - return {time:Number(this.data.time),qpos:Array.from(this.data.qpos),qvel:Array.from(this.data.qvel),ctrl:Array.from(this.data.ctrl),actuators,joints,bodies,warnings:this.warnings,controller:this.pythonController?.status(),rlPolicy:this.rlPolicy?.status(),model:{nbody:this.model.nbody,njnt:this.model.njnt,ngeom:this.model.ngeom,ncam:this.model.ncam,nactuator:this.model.nactuator,nu:this.model.nu,nq:this.model.nq,nv:this.model.nv}}; + return { + time: Number(this.data.time), + qpos: Array.from(this.data.qpos), + qvel: Array.from(this.data.qvel), + ctrl: Array.from(this.data.ctrl), + actuators, + joints, + bodies, + warnings: this.warnings, + controller: this.pythonController?.status(), + rlPolicy: this.rlPolicy?.status(), + telemetry: this.dataRecorder.status(), + model: { + nbody: this.model.nbody, + njnt: this.model.njnt, + ngeom: this.model.ngeom, + ncam: this.model.ncam, + nactuator: this.model.nactuator, + nu: this.model.nu, + nq: this.model.nq, + nv: this.model.nv, + }, + }; + } + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.removeController(); + this.removeRLPolicy(); + this.clearExternalForce(); + this.perturb.delete(); + this.data.delete(); + this.model.delete(); } - dispose(): void {if(this.disposed)return;this.disposed=true;this.removeController();this.removeRLPolicy();this.clearExternalForce();this.perturb.delete();this.data.delete();this.model.delete();} } diff --git a/web_platform/src/simulation/geometry.test.ts b/web_platform/src/simulation/geometry.test.ts index 410dbe25..6dda6a7f 100644 --- a/web_platform/src/simulation/geometry.test.ts +++ b/web_platform/src/simulation/geometry.test.ts @@ -1,11 +1,34 @@ -import {meshIdFromSceneDataId} from './geometry'; +import { heightfieldGeometryData, meshIdFromSceneDataId } from './geometry'; -describe('meshIdFromSceneDataId',()=>{ - it('解析 mjvGeom 的完整 mesh/凸包编码',()=>{ +describe('MuJoCo 几何辅助', () => { + it('解析 mjvGeom 的完整 mesh/凸包编码', () => { expect(meshIdFromSceneDataId(0)).toBe(0); expect(meshIdFromSceneDataId(1)).toBe(0); expect(meshIdFromSceneDataId(2)).toBe(1); expect(meshIdFromSceneDataId(15)).toBe(7); expect(meshIdFromSceneDataId(-1)).toBe(-1); }); + + it('把 MuJoCo 高度场转换为带正确高度和三角面的网格', () => { + const result = heightfieldGeometryData( + { + hfield_adr: [0], + hfield_data: [0, 0.25, 0.5, 1], + hfield_ncol: [2], + hfield_nrow: [2], + hfield_size: [2, 3, 0.8, 0.01], + }, + 0, + ); + expect(Array.from(result.positions).filter((_, index) => index % 3 !== 2)).toEqual([ + -2, -3, 2, -3, -2, 3, 2, 3, + ]); + expect([2, 5, 8, 11].map((index) => result.positions[index])).toEqual([ + expect.closeTo(0), + expect.closeTo(0.2), + expect.closeTo(0.4), + expect.closeTo(0.8), + ]); + expect(Array.from(result.indices)).toEqual([0, 1, 2, 1, 3, 2]); + }); }); diff --git a/web_platform/src/simulation/geometry.ts b/web_platform/src/simulation/geometry.ts index 4acc6ff8..7252d2e3 100644 --- a/web_platform/src/simulation/geometry.ts +++ b/web_platform/src/simulation/geometry.ts @@ -2,6 +2,60 @@ * mjvGeom.dataid 对 mesh 编码为 2 * meshId;最低位表示是否显示凸包。 * 它不能直接作为 mjModel.mesh_* 数组的索引。 */ -export function meshIdFromSceneDataId(dataId:number):number { - return dataId<0?-1:Math.floor(dataId/2); +export function meshIdFromSceneDataId(dataId: number): number { + return dataId < 0 ? -1 : Math.floor(dataId / 2); +} + +export interface HeightfieldModelArrays { + hfield_adr: ArrayLike; + hfield_data: ArrayLike; + hfield_ncol: ArrayLike; + hfield_nrow: ArrayLike; + hfield_size: ArrayLike; +} + +/** 将 MuJoCo 归一化高度场数据转换为以 geom 原点为基准的 Three.js 网格数据。 */ +export function heightfieldGeometryData( + model: HeightfieldModelArrays, + id: number, +): { positions: Float32Array; indices: Uint32Array } { + const rows = Number(model.hfield_nrow[id]), + columns = Number(model.hfield_ncol[id]), + address = Number(model.hfield_adr[id]), + halfWidth = Number(model.hfield_size[id * 4]), + halfLength = Number(model.hfield_size[id * 4 + 1]), + heightScale = Number(model.hfield_size[id * 4 + 2]); + if ( + id < 0 || + rows < 2 || + columns < 2 || + address < 0 || + !Number.isFinite(halfWidth) || + !Number.isFinite(halfLength) || + !Number.isFinite(heightScale) + ) + return { positions: new Float32Array(), indices: new Uint32Array() }; + + const positions = new Float32Array(rows * columns * 3); + for (let row = 0; row < rows; row += 1) + for (let column = 0; column < columns; column += 1) { + const vertex = row * columns + column, + offset = vertex * 3; + positions[offset] = -halfWidth + (2 * halfWidth * column) / (columns - 1); + positions[offset + 1] = -halfLength + (2 * halfLength * row) / (rows - 1); + positions[offset + 2] = Number(model.hfield_data[address + vertex]) * heightScale; + } + + const indices = new Uint32Array((rows - 1) * (columns - 1) * 6); + let offset = 0; + for (let row = 0; row < rows - 1; row += 1) + for (let column = 0; column < columns - 1; column += 1) { + const first = row * columns + column, + right = first + 1, + next = first + columns, + diagonal = next + 1; + indices.set([first, right, next, right, diagonal, next], offset); + offset += 6; + } + return { positions, indices }; } diff --git a/web_platform/src/stores/useAppStore.test.ts b/web_platform/src/stores/useAppStore.test.ts index ebfc3c61..a7792010 100644 --- a/web_platform/src/stores/useAppStore.test.ts +++ b/web_platform/src/stores/useAppStore.test.ts @@ -1,29 +1,34 @@ -import {useAppStore} from './useAppStore'; +import { useAppStore } from './useAppStore'; -describe('useAppStore.clearProject',()=>{ - afterEach(()=>useAppStore.getState().clearProject()); +describe('useAppStore.clearProject', () => { + afterEach(() => useAppStore.getState().clearProject()); - it('清空已导入工程及其运行状态',()=>{ - const store=useAppStore.getState(); - store.setProject('robot',[{path:'robot/model.xml',size:128}],[{path:'robot/model.xml',format:'mjcf',label:'model'}],'robot/model.xml'); + it('清空已导入工程及其运行状态', () => { + const store = useAppStore.getState(); + store.setProject( + 'robot', + [{ path: 'robot/model.xml', size: 128 }], + [{ path: 'robot/model.xml', format: 'mjcf', label: 'model' }], + 'robot/model.xml', + ); store.setLoading(true); - store.setDiagnostic({category:'导入',summary:'错误',detail:'detail',at:1}); + store.setDiagnostic({ category: '导入', summary: '错误', detail: 'detail', at: 1 }); useAppStore.getState().clearProject(); expect(useAppStore.getState()).toMatchObject({ - projectName:undefined, - files:[], - entries:[], - selectedEntry:undefined, - loading:false, - diagnostic:undefined, - snapshot:undefined, - selection:null, - paused:true, - fps:0, - stepMs:0, - overBudget:false, + projectName: undefined, + files: [], + entries: [], + selectedEntry: undefined, + loading: false, + diagnostic: undefined, + snapshot: undefined, + selection: null, + paused: true, + fps: 0, + stepMs: 0, + overBudget: false, }); }); }); diff --git a/web_platform/src/stores/useAppStore.ts b/web_platform/src/stores/useAppStore.ts index e56df061..2d21a995 100644 --- a/web_platform/src/stores/useAppStore.ts +++ b/web_platform/src/stores/useAppStore.ts @@ -1,25 +1,92 @@ -import {create} from 'zustand'; -import type {ModelEntry} from '../project/types'; -import type {SimulationSnapshot} from '../simulation/SimulationSession'; -import type {InteractionMode, ViewerSelection} from '../viewer/MuJoCoViewer'; +import { create } from 'zustand'; +import type { ModelEntry } from '../project/types'; +import type { SimulationSnapshot } from '../simulation/SimulationSession'; +import type { InteractionMode, ViewerSelection } from '../viewer/MuJoCoViewer'; -export interface AppDiagnostic {category:'导入'|'ZIP'|'文件系统'|'模型编译'|'仿真'|'渲染';summary:string;detail:string;path?:string;at:number;} -interface FileMeta {path:string;size:number;} -interface AppState { - projectName?:string; files:FileMeta[]; entries:ModelEntry[]; selectedEntry?:string; - loading:boolean; diagnostic?:AppDiagnostic; snapshot?:SimulationSnapshot; selection:ViewerSelection|null; - paused:boolean; speed:number; mode:InteractionMode; fps:number; stepMs:number; memoryMb?:number; overBudget:boolean; - setProject(name:string,files:FileMeta[],entries:ModelEntry[],selectedEntry?:string):void; - clearProject():void; - setEntry(path:string):void; setLoading(value:boolean):void; setDiagnostic(value?:AppDiagnostic):void; - setSnapshot(value?:SimulationSnapshot):void; setSelection(value:ViewerSelection|null):void; - setPaused(value:boolean):void; setSpeed(value:number):void; setMode(value:InteractionMode):void; - setMetrics(fps:number,stepMs:number,memoryMb:number|undefined,overBudget:boolean):void; +export interface AppDiagnostic { + category: '导入' | 'ZIP' | '文件系统' | '模型编译' | '仿真' | '渲染'; + summary: string; + detail: string; + path?: string; + at: number; } -export const useAppStore=create((set)=>({ - files:[],entries:[],loading:false,selection:null,paused:true,speed:1,mode:'select',fps:0,stepMs:0,overBudget:false, - setProject:(projectName,files,entries,selectedEntry)=>set({projectName,files,entries,selectedEntry,snapshot:undefined,selection:null,diagnostic:undefined}), - clearProject:()=>set({projectName:undefined,files:[],entries:[],selectedEntry:undefined,loading:false,diagnostic:undefined,snapshot:undefined,selection:null,paused:true,fps:0,stepMs:0,memoryMb:undefined,overBudget:false}), - setEntry:(selectedEntry)=>set({selectedEntry}),setLoading:(loading)=>set({loading}),setDiagnostic:(diagnostic)=>set({diagnostic}),setSnapshot:(snapshot)=>set({snapshot}),setSelection:(selection)=>set({selection}), - setPaused:(paused)=>set({paused}),setSpeed:(speed)=>set({speed}),setMode:(mode)=>set({mode}),setMetrics:(fps,stepMs,memoryMb,overBudget)=>set((s)=>({fps:fps||s.fps,stepMs,memoryMb:memoryMb??s.memoryMb,overBudget})) +interface FileMeta { + path: string; + size: number; +} +interface AppState { + projectName?: string; + files: FileMeta[]; + entries: ModelEntry[]; + selectedEntry?: string; + loading: boolean; + diagnostic?: AppDiagnostic; + snapshot?: SimulationSnapshot; + selection: ViewerSelection | null; + paused: boolean; + speed: number; + mode: InteractionMode; + fps: number; + stepMs: number; + memoryMb?: number; + overBudget: boolean; + setProject(name: string, files: FileMeta[], entries: ModelEntry[], selectedEntry?: string): void; + clearProject(): void; + setEntry(path: string): void; + setLoading(value: boolean): void; + setDiagnostic(value?: AppDiagnostic): void; + setSnapshot(value?: SimulationSnapshot): void; + setSelection(value: ViewerSelection | null): void; + setPaused(value: boolean): void; + setSpeed(value: number): void; + setMode(value: InteractionMode): void; + setMetrics(fps: number, stepMs: number, memoryMb: number | undefined, overBudget: boolean): void; +} +export const useAppStore = create((set) => ({ + files: [], + entries: [], + loading: false, + selection: null, + paused: true, + speed: 1, + mode: 'select', + fps: 0, + stepMs: 0, + overBudget: false, + setProject: (projectName, files, entries, selectedEntry) => + set({ + projectName, + files, + entries, + selectedEntry, + snapshot: undefined, + selection: null, + diagnostic: undefined, + }), + clearProject: () => + set({ + projectName: undefined, + files: [], + entries: [], + selectedEntry: undefined, + loading: false, + diagnostic: undefined, + snapshot: undefined, + selection: null, + paused: true, + fps: 0, + stepMs: 0, + memoryMb: undefined, + overBudget: false, + }), + setEntry: (selectedEntry) => set({ selectedEntry }), + setLoading: (loading) => set({ loading }), + setDiagnostic: (diagnostic) => set({ diagnostic }), + setSnapshot: (snapshot) => set({ snapshot }), + setSelection: (selection) => set({ selection }), + setPaused: (paused) => set({ paused }), + setSpeed: (speed) => set({ speed }), + setMode: (mode) => set({ mode }), + setMetrics: (fps, stepMs, memoryMb, overBudget) => + set((s) => ({ fps: fps || s.fps, stepMs, memoryMb: memoryMb ?? s.memoryMb, overBudget })), })); diff --git a/web_platform/src/styles.css b/web_platform/src/styles.css index 5a8e8498..8ef5a5d1 100644 --- a/web_platform/src/styles.css +++ b/web_platform/src/styles.css @@ -2,36 +2,175 @@ @tailwind components; @tailwind utilities; -:root{ - --ui-bg:#eef2f7;--ui-panel:#fbfcfe;--ui-surface:#f7f9fc;--ui-surface-elevated:#fff;--ui-input:#fff; - --ui-hover:#e9eef5;--ui-active:#dfe7f1;--ui-border:#d9e1eb;--ui-border-strong:#b8c4d2; - --ui-text-primary:#122033;--ui-text-secondary:#3d4d61;--ui-text-tertiary:#5f6f82; - --ui-accent:#16835f;--ui-accent-hover:#116b4d;--ui-accent-soft:#dff4ec; - --ui-danger:#c53b45;--ui-danger-soft:#fff0f1;--ui-danger-border:#f1b9bd; - --ui-warning:#a76612;--ui-warning-soft:#fff7e6;--ui-warning-border:#efd18f; - --ui-success:#16835f;--ui-success-soft:#e7f7f1;--ui-success-border:#a7ddca; - --ui-scrollbar:#a9b5c4;--ui-scrollbar-hover:#7f8ea1;color-scheme:light; +:root { + --ui-bg: #edf2f7; + --ui-panel: #fbfcfe; + --ui-surface: #f5f8fc; + --ui-surface-elevated: #fff; + --ui-input: #fff; + --ui-hover: #e8eef5; + --ui-active: #dce6f0; + --ui-border: #d6e0ea; + --ui-border-strong: #aebdcd; + --ui-text-primary: #102033; + --ui-text-secondary: #3a4d62; + --ui-text-tertiary: #68798c; + --ui-accent: #138462; + --ui-accent-hover: #0d6d50; + --ui-accent-soft: #ddf5ec; + --ui-danger: #c53b45; + --ui-danger-soft: #fff0f1; + --ui-danger-border: #f1b9bd; + --ui-warning: #a76612; + --ui-warning-soft: #fff7e6; + --ui-warning-border: #efd18f; + --ui-success: #16835f; + --ui-success-soft: #e7f7f1; + --ui-success-border: #a7ddca; + --ui-scrollbar: #a9b5c4; + --ui-scrollbar-hover: #7f8ea1; + color-scheme: light; } -.theme-dark{ - --ui-bg:#0e141d;--ui-panel:#171f2b;--ui-surface:#1d2735;--ui-surface-elevated:#263243;--ui-input:#121a25; - --ui-hover:#283548;--ui-active:#324258;--ui-border:#2c394b;--ui-border-strong:#43536a; - --ui-text-primary:#edf2f7;--ui-text-secondary:#c8d2df;--ui-text-tertiary:#8f9caf; - --ui-accent:#35c792;--ui-accent-hover:#2eae80;--ui-accent-soft:#163b33; - --ui-danger:#ff7a83;--ui-danger-soft:#401f26;--ui-danger-border:#71333c; - --ui-warning:#f3bd5c;--ui-warning-soft:#3d301b;--ui-warning-border:#685028; - --ui-success:#51d4a4;--ui-success-soft:#183b32;--ui-success-border:#285f50; - --ui-scrollbar:#46566c;--ui-scrollbar-hover:#61728a;color-scheme:dark; +.theme-dark { + --ui-bg: #09111e; + --ui-panel: #121c2a; + --ui-surface: #182536; + --ui-surface-elevated: #213148; + --ui-input: #0d1725; + --ui-hover: #223349; + --ui-active: #2b4059; + --ui-border: #26374b; + --ui-border-strong: #40566f; + --ui-text-primary: #f1f5f9; + --ui-text-secondary: #c7d3e0; + --ui-text-tertiary: #8fa0b5; + --ui-accent: #38d39f; + --ui-accent-hover: #2db789; + --ui-accent-soft: #123b31; + --ui-danger: #ff7a83; + --ui-danger-soft: #401f26; + --ui-danger-border: #71333c; + --ui-warning: #f3bd5c; + --ui-warning-soft: #3d301b; + --ui-warning-border: #685028; + --ui-success: #51d4a4; + --ui-success-soft: #183b32; + --ui-success-border: #285f50; + --ui-scrollbar: #46566c; + --ui-scrollbar-hover: #61728a; + color-scheme: dark; } -@layer base{ - html,body,#root{height:100%;margin:0}body{overflow:hidden;background:var(--ui-bg);color:var(--ui-text-primary);font-family:Inter,"Noto Sans SC",system-ui,sans-serif}button,input,select{font:inherit} - :where(button,input,select,textarea,[tabindex]):focus-visible{outline:2px solid var(--ui-accent);outline-offset:2px} +@layer base { + html, + body, + #root { + height: 100%; + margin: 0; + } + body { + overflow: hidden; + background: var(--ui-bg); + color: var(--ui-text-primary); + font-family: Inter, 'Noto Sans SC', 'Microsoft YaHei UI', system-ui, sans-serif; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + } + canvas { + display: block; + } + ::selection { + background: color-mix(in srgb, var(--ui-accent) 28%, transparent); + color: var(--ui-text-primary); + } + button, + input, + select { + font: inherit; + } + :where(button, input, select, textarea, [tabindex]):focus-visible { + outline: 2px solid var(--ui-accent); + outline-offset: 2px; + } } -@layer components{ - .panel-scroll{scrollbar-color:var(--ui-scrollbar) transparent;scrollbar-width:thin} - .control-slider{@apply w-full accent-accent} - .field{@apply rounded-md border border-border bg-input transition-colors hover:border-border-strong focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent} - .technical-value{font-variant-numeric:tabular-nums} +@layer components { + .panel-scroll { + scrollbar-color: var(--ui-scrollbar) transparent; + scrollbar-width: thin; + } + .control-slider { + @apply w-full accent-accent; + } + .field { + @apply rounded-md border border-border bg-input transition-colors hover:border-border-strong focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent; + } + .technical-value { + font-variant-numeric: tabular-nums; + } + .viewport-shell { + background: + radial-gradient( + circle at 50% 18%, + color-mix(in srgb, var(--ui-accent) 8%, transparent), + transparent 34% + ), + linear-gradient(145deg, var(--ui-surface), var(--ui-bg)); + } + .workspace-welcome { + animation: workspace-enter 360ms cubic-bezier(0.22, 1, 0.36, 1) both; + box-shadow: + 0 28px 80px rgb(2 8 23 / 28%), + inset 0 1px 0 rgb(255 255 255 / 5%); + } + .welcome-glow { + position: absolute; + top: -8rem; + left: 50%; + width: 22rem; + height: 15rem; + border-radius: 999px; + background: color-mix(in srgb, var(--ui-accent) 18%, transparent); + filter: blur(52px); + pointer-events: none; + transform: translateX(-50%); + } +} +@keyframes workspace-enter { + from { + opacity: 0; + transform: translateY(10px) scale(0.985); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} +::-webkit-scrollbar { + height: 6px; + width: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--ui-scrollbar); + border-radius: 999px; +} +::-webkit-scrollbar-thumb:hover { + background: var(--ui-scrollbar-hover); +} +.theme-light, +.theme-dark { + transition: + background-color 180ms ease-out, + color 180ms ease-out; +} +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } } -::-webkit-scrollbar{height:6px;width:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--ui-scrollbar);border-radius:999px}::-webkit-scrollbar-thumb:hover{background:var(--ui-scrollbar-hover)} -.theme-light,.theme-dark{transition:background-color 180ms ease-out,color 180ms ease-out} -@media(prefers-reduced-motion:reduce){*,*::before,*::after{scroll-behavior:auto!important;animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}} diff --git a/web_platform/src/training/LocalTrainingClient.test.ts b/web_platform/src/training/LocalTrainingClient.test.ts index 01f8bbaf..9126c140 100644 --- a/web_platform/src/training/LocalTrainingClient.test.ts +++ b/web_platform/src/training/LocalTrainingClient.test.ts @@ -1,23 +1,60 @@ -import {afterEach,describe,expect,it,vi} from 'vitest'; -import {LocalTrainingClient} from './LocalTrainingClient'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { LocalTrainingClient } from './LocalTrainingClient'; -afterEach(()=>vi.unstubAllGlobals()); +afterEach(() => vi.unstubAllGlobals()); -describe('LocalTrainingClient',()=>{ - it('规范化服务地址并提交受类型约束的 JSON 请求',async()=>{ - const fetchMock=vi.fn().mockResolvedValue(new Response(JSON.stringify({id:'a'.repeat(32),state:'queued'}),{status:202,headers:{'Content-Type':'application/json'}})); - vi.stubGlobal('fetch',fetchMock); - const client=new LocalTrainingClient('http://127.0.0.1:8765/'); - await client.start({taskId:'Unitree-Go2-Flat',numEnvs:16,maxIterations:2,seed:42,runName:'test',device:'cpu',gpuIds:[],wandbMode:'offline'}); - expect(fetchMock).toHaveBeenCalledWith('http://127.0.0.1:8765/api/training/jobs',expect.objectContaining({method:'POST'})); - const options=fetchMock.mock.calls[0][1] as RequestInit; - expect(JSON.parse(String(options.body))).toMatchObject({taskId:'Unitree-Go2-Flat',numEnvs:16,device:'cpu'}); +describe('LocalTrainingClient', () => { + it('规范化服务地址并提交受类型约束的 JSON 请求', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ id: 'a'.repeat(32), state: 'queued' }), { + status: 202, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + const client = new LocalTrainingClient('http://127.0.0.1:8765/', 'secret-token'); + await client.start({ + taskId: 'Unitree-Go2-Flat', + numEnvs: 16, + maxIterations: 2, + seed: 42, + runName: 'test', + device: 'cpu', + gpuIds: [], + wandbMode: 'offline', + }); + expect(fetchMock).toHaveBeenCalledWith( + 'http://127.0.0.1:8765/api/training/jobs', + expect.objectContaining({ method: 'POST' }), + ); + const options = fetchMock.mock.calls[0][1] as RequestInit; + expect(JSON.parse(String(options.body))).toMatchObject({ + taskId: 'Unitree-Go2-Flat', + numEnvs: 16, + device: 'cpu', + }); + expect(new Headers(options.headers).get('Authorization')).toBe('Bearer secret-token'); }); - it('显示服务端返回的中文错误',async()=>{ - vi.stubGlobal('fetch',vi.fn().mockResolvedValue(new Response(JSON.stringify({error:'已有训练任务正在运行'}),{status:409,headers:{'Content-Type':'application/json'}}))); - await expect(new LocalTrainingClient('http://localhost:8765').health()).rejects.toThrow('已有训练任务正在运行'); + it('显示服务端返回的中文错误', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: '已有训练任务正在运行' }), { + status: 409, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ); + await expect( + new LocalTrainingClient('http://localhost:8765', 'secret-token').health(), + ).rejects.toThrow('已有训练任务正在运行'); }); - it('拒绝非 HTTP 地址',()=>{expect(()=>new LocalTrainingClient('file:///tmp/socket')).toThrow('http 或 https');}); + it('拒绝非 HTTP 地址和空访问令牌', () => { + expect(() => new LocalTrainingClient('file:///tmp/socket', 'secret-token')).toThrow( + 'http 或 https', + ); + expect(() => new LocalTrainingClient('http://localhost:8765', '')).toThrow('访问令牌'); + }); }); diff --git a/web_platform/src/training/LocalTrainingClient.ts b/web_platform/src/training/LocalTrainingClient.ts index dff2eaa5..65173846 100644 --- a/web_platform/src/training/LocalTrainingClient.ts +++ b/web_platform/src/training/LocalTrainingClient.ts @@ -1,36 +1,72 @@ -import type {TrainingJob,TrainingRequest,TrainingServerInfo} from './types'; +import type { TrainingJob, TrainingRequest, TrainingServerInfo } from './types'; -function normalizeEndpoint(value:string):string{ - const endpoint=value.trim().replace(/\/+$/,''); - let url:URL; - try{url=new URL(endpoint);}catch{throw new Error('训练服务地址无效');} - if(url.protocol!=='http:'&&url.protocol!=='https:')throw new Error('训练服务地址必须使用 http 或 https'); - return url.toString().replace(/\/$/,''); +function normalizeEndpoint(value: string): string { + const endpoint = value.trim().replace(/\/+$/, ''); + let url: URL; + try { + url = new URL(endpoint); + } catch { + throw new Error('训练服务地址无效'); + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') + throw new Error('训练服务地址必须使用 http 或 https'); + return url.toString().replace(/\/$/, ''); } -async function responseError(response:Response):Promise{ - try{const body=await response.json() as {error?:string};if(body.error)return new Error(body.error);}catch{/* 使用 HTTP 状态作为回退 */} +async function responseError(response: Response): Promise { + try { + const body = (await response.json()) as { error?: string }; + if (body.error) return new Error(body.error); + } catch { + /* 使用 HTTP 状态作为回退 */ + } return new Error(`本地训练服务请求失败(HTTP ${response.status})`); } export class LocalTrainingClient { - readonly endpoint:string; - constructor(endpoint:string){this.endpoint=normalizeEndpoint(endpoint);} + readonly endpoint: string; + readonly token: string; + constructor(endpoint: string, token: string) { + this.endpoint = normalizeEndpoint(endpoint); + this.token = token.trim(); + if (!this.token) throw new Error('请输入训练服务访问令牌'); + } - private async json(path:string,init?:RequestInit):Promise{ - const response=await fetch(`${this.endpoint}${path}`,init); - if(!response.ok)throw await responseError(response); + private requestInit(init?: RequestInit): RequestInit { + const headers = new Headers(init?.headers); + headers.set('Authorization', `Bearer ${this.token}`); + return { ...init, headers }; + } + + private async json(path: string, init?: RequestInit): Promise { + const response = await fetch(`${this.endpoint}${path}`, this.requestInit(init)); + if (!response.ok) throw await responseError(response); return response.json() as Promise; } - health():Promise{return this.json('/api/training/health');} - start(request:TrainingRequest):Promise{return this.json('/api/training/jobs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(request)});} - job(id:string):Promise{return this.json(`/api/training/jobs/${encodeURIComponent(id)}`);} - cancel(id:string):Promise{return this.json(`/api/training/jobs/${encodeURIComponent(id)}`,{method:'DELETE'});} - async downloadPolicy(id:string):Promise{ - const response=await fetch(`${this.endpoint}/api/training/jobs/${encodeURIComponent(id)}/artifacts/policy.onnx`); - if(!response.ok)throw await responseError(response); - const blob=await response.blob(); - return new File([blob],`policy-${id.slice(0,8)}.onnx`,{type:'application/octet-stream'}); + health(): Promise { + return this.json('/api/training/health'); + } + start(request: TrainingRequest): Promise { + return this.json('/api/training/jobs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }); + } + job(id: string): Promise { + return this.json(`/api/training/jobs/${encodeURIComponent(id)}`); + } + cancel(id: string): Promise { + return this.json(`/api/training/jobs/${encodeURIComponent(id)}`, { method: 'DELETE' }); + } + async downloadPolicy(id: string): Promise { + const response = await fetch( + `${this.endpoint}/api/training/jobs/${encodeURIComponent(id)}/artifacts/policy.onnx`, + this.requestInit(), + ); + if (!response.ok) throw await responseError(response); + const blob = await response.blob(); + return new File([blob], `policy-${id.slice(0, 8)}.onnx`, { type: 'application/octet-stream' }); } } diff --git a/web_platform/src/training/types.ts b/web_platform/src/training/types.ts index 03195fdf..65f1f88d 100644 --- a/web_platform/src/training/types.ts +++ b/web_platform/src/training/types.ts @@ -1,40 +1,40 @@ -export type TrainingJobState='queued'|'running'|'succeeded'|'failed'|'cancelled'; -export type TrainingDevice='cpu'|'gpu'; -export type WandbMode='offline'|'online'|'disabled'; +export type TrainingJobState = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'; +export type TrainingDevice = 'cpu' | 'gpu'; +export type WandbMode = 'offline' | 'online' | 'disabled'; export interface TrainingServerInfo { - version:string; - ready:boolean; - trainerRoot:string; - python:string; - tasks:string[]; - activeJobId?:string; - error?:string; + version: string; + ready: boolean; + trainerRoot: string; + python: string; + tasks: string[]; + activeJobId?: string; + error?: string; } export interface TrainingRequest { - taskId:string; - numEnvs:number; - maxIterations:number; - seed:number; - runName:string; - device:TrainingDevice; - gpuIds:number[]; - wandbMode:WandbMode; + taskId: string; + numEnvs: number; + maxIterations: number; + seed: number; + runName: string; + device: TrainingDevice; + gpuIds: number[]; + wandbMode: WandbMode; } export interface TrainingJob { - id:string; - state:TrainingJobState; - taskId:string; - createdAt:string; - startedAt?:string; - endedAt?:string; - iteration:number; - maxIterations:number; - progress:number; - message:string; - logs:string[]; - artifactReady:boolean; - artifactName?:string; + id: string; + state: TrainingJobState; + taskId: string; + createdAt: string; + startedAt?: string; + endedAt?: string; + iteration: number; + maxIterations: number; + progress: number; + message: string; + logs: string[]; + artifactReady: boolean; + artifactName?: string; } diff --git a/web_platform/src/viewer/MapEditorLayer.test.ts b/web_platform/src/viewer/MapEditorLayer.test.ts new file mode 100644 index 00000000..ef53a447 --- /dev/null +++ b/web_platform/src/viewer/MapEditorLayer.test.ts @@ -0,0 +1,110 @@ +import * as THREE from 'three'; +import { createEditableObject, type EditableMapDocument } from '../map/editor/types'; +import { MapEditorLayer } from './MapEditorLayer'; + +const documentValue: EditableMapDocument = { + schemaVersion: 1, + mapId: 'map', + revision: 0, + objects: [createEditableObject('box', 'box_1')], + spawnPoints: [{ id: 'start', name: '起点', position: [1, 2, 0], yawDeg: 90 }], +}; + +function createLayer() { + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100); + camera.position.set(0, 0, 5); + camera.lookAt(0, 0, 0); + camera.updateMatrixWorld(); + camera.updateProjectionMatrix(); + const element = document.createElement('canvas'); + const callbacks = { + onSelect: vi.fn(), + onTransform: vi.fn(), + onDragging: vi.fn(), + }; + return { scene, callbacks, layer: new MapEditorLayer(scene, camera, element, callbacks) }; +} + +describe('MapEditorLayer', () => { + it('更新预览时释放旧几何和材质', () => { + const { scene, layer } = createLayer(); + layer.setDocument(documentValue); + expect(layer.group.children).toHaveLength(2); + expect(layer.group.children[1].name).toBe('__platform_map_editor_spawn_start'); + const preview = layer.group.children[0].children[0] as THREE.Mesh; + const geometryDispose = vi.spyOn(preview.geometry, 'dispose'); + const material = preview.material as THREE.Material; + const materialDispose = vi.spyOn(material, 'dispose'); + layer.setDocument(null); + expect(geometryDispose).toHaveBeenCalledOnce(); + expect(materialDispose).toHaveBeenCalledOnce(); + expect(layer.group.children).toHaveLength(0); + layer.dispose(); + expect(scene.children).not.toContain(layer.group); + }); + + it('在视口中拾取编辑对象', () => { + const { callbacks, layer } = createLayer(); + layer.setDocument(documentValue); + const bounds = { + left: 0, + top: 0, + width: 100, + height: 100, + right: 100, + bottom: 100, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect; + expect( + layer.handlePointerDown({ button: 0, clientX: 50, clientY: 50 } as PointerEvent, bounds), + ).toBe(true); + expect(callbacks.onSelect).toHaveBeenCalledWith('box_1'); + expect( + layer.handlePointerDown({ button: 0, clientX: 2, clientY: 2 } as PointerEvent, bounds), + ).toBe(false); + expect(callbacks.onSelect).toHaveBeenCalledTimes(1); + layer.dispose(); + }); + + it('锁定对象可选中但不挂载变换操纵器', () => { + const { layer } = createLayer(); + layer.setDocument({ + ...documentValue, + objects: [{ ...documentValue.objects[0], placementMode: 'locked' }], + }); + layer.selectObject('box_1'); + expect(layer.transform.object).toBeUndefined(); + expect(layer.transform.getHelper().visible).toBe(false); + layer.dispose(); + }); + + it('提交操纵器变换并保持 W/E 模式约束', () => { + const { callbacks, layer } = createLayer(); + layer.setDocument(documentValue); + layer.selectObject('box_1'); + layer.setTransformMode('scale'); + expect(layer.transform.mode).toBe('scale'); + expect(layer.transform.showX).toBe(true); + expect(layer.transform.showY).toBe(true); + layer.setTransformMode('rotate'); + expect(layer.transform.mode).toBe('rotate'); + expect(layer.transform.showX).toBe(false); + expect(layer.transform.showY).toBe(false); + expect(layer.transform.showZ).toBe(true); + const object = layer.group.children[0]; + object.position.set(2, 3, 4); + object.quaternion.setFromAxisAngle(new THREE.Vector3(0, 0, 1), Math.PI / 2); + object.scale.set(2, 3, 4); + layer.transform.dispatchEvent({ type: 'mouseUp', mode: 'rotate' }); + expect(callbacks.onTransform).toHaveBeenCalledWith( + 'box_1', + [2, 3, 4], + expect.arrayContaining([expect.any(Number), 0, 0, expect.any(Number)]), + [2, 3, 4], + ); + layer.dispose(); + }); +}); diff --git a/web_platform/src/viewer/MapEditorLayer.ts b/web_platform/src/viewer/MapEditorLayer.ts new file mode 100644 index 00000000..ff085f17 --- /dev/null +++ b/web_platform/src/viewer/MapEditorLayer.ts @@ -0,0 +1,267 @@ +import * as THREE from 'three'; +import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js'; +import type { + EditableMapDocument, + EditableMapObject, + MapEditorTransformMode, +} from '../map/editor/types'; +import type { SpawnPoint } from '../map/types'; + +export interface MapEditorLayerCallbacks { + onSelect(id: string | null): void; + onTransform( + id: string, + position: [number, number, number], + quaternion: [number, number, number, number], + scale: [number, number, number], + ): void; + onDragging(value: boolean): void; +} + +function material(object: EditableMapObject): THREE.MeshStandardMaterial { + const [r, g, b, a] = object.rgba; + return new THREE.MeshStandardMaterial({ + color: new THREE.Color(r, g, b), + emissive: new THREE.Color(0x000000), + opacity: Math.min(a, 0.65), + transparent: true, + depthWrite: false, + }); +} +function mesh(geometry: THREE.BufferGeometry, object: EditableMapObject): THREE.Mesh { + const result = new THREE.Mesh(geometry, material(object)); + result.castShadow = true; + result.receiveShadow = true; + return result; +} +function spawnPreview(spawn: SpawnPoint): THREE.Group { + const group = new THREE.Group(); + group.name = `__platform_map_editor_spawn_${spawn.id}`; + group.position.set(spawn.position[0], spawn.position[1], spawn.position[2] + 0.03); + group.rotation.z = (spawn.yawDeg * Math.PI) / 180; + const markerMaterial = new THREE.MeshStandardMaterial({ + color: 0x22c55e, + emissive: 0x14532d, + transparent: true, + opacity: 0.9, + }); + const ring = new THREE.Mesh(new THREE.TorusGeometry(0.22, 0.025, 8, 24), markerMaterial); + const arrow = new THREE.Mesh(new THREE.ConeGeometry(0.08, 0.32, 12), markerMaterial); + arrow.rotation.z = -Math.PI / 2; + arrow.position.x = 0.25; + group.add(ring, arrow); + return group; +} +function objectPreview(object: EditableMapObject): THREE.Group { + const group = new THREE.Group(), + p = object.parameters; + group.name = `__platform_map_editor_${object.id}`; + group.userData.mapEditorObjectId = object.id; + group.userData.mapEditorLocked = object.placementMode === 'locked'; + group.position.fromArray(object.pose.position); + group.quaternion.set( + object.pose.quaternion[1], + object.pose.quaternion[2], + object.pose.quaternion[3], + object.pose.quaternion[0], + ); + if (object.type === 'box') + group.add(mesh(new THREE.BoxGeometry(p.sizeX, p.sizeY, p.sizeZ), object)); + else if (object.type === 'cylinder') + group.add( + mesh( + new THREE.CylinderGeometry(p.radius, p.radius, p.height, 24).rotateX(Math.PI / 2), + object, + ), + ); + else if (object.type === 'capsule') + group.add( + mesh(new THREE.CapsuleGeometry(p.radius, p.length, 8, 16).rotateX(Math.PI / 2), object), + ); + else if (object.type === 'ramp') { + const item = mesh( + new THREE.BoxGeometry(Math.hypot(p.length, p.rise), p.width, p.thickness), + object, + ); + item.position.z = p.rise / 2; + item.rotation.y = -Math.atan2(p.rise, p.length); + group.add(item); + } else + for (let index = 0; index < p.count; index += 1) { + const height = p.stepHeight * (index + 1); + const item = mesh(new THREE.BoxGeometry(p.stepDepth, p.width, height), object); + item.position.set(p.stepDepth * index, 0, height / 2); + group.add(item); + } + group.traverse((child) => { + child.userData.mapEditorObjectId = object.id; + }); + return group; +} + +export class MapEditorLayer { + readonly group = new THREE.Group(); + readonly transform: TransformControls; + private readonly helper: THREE.Object3D; + private readonly raycaster = new THREE.Raycaster(); + private readonly pointer = new THREE.Vector2(); + private readonly objects = new Map(); + private selectedId: string | null = null; + private documentLoaded = false; + + constructor( + private readonly scene: THREE.Scene, + private readonly camera: THREE.Camera, + domElement: HTMLElement, + private readonly callbacks: MapEditorLayerCallbacks, + ) { + this.group.name = '__platform_map_editor__'; + scene.add(this.group); + this.transform = new TransformControls(camera, domElement); + this.transform.setSpace('world'); + this.transform.setSize(0.8); + this.transform.setTranslationSnap(0.1); + this.transform.setRotationSnap(THREE.MathUtils.degToRad(5)); + this.helper = this.transform.getHelper(); + this.helper.name = '__platform_map_editor_transform__'; + this.helper.visible = false; + scene.add(this.helper); + this.transform.addEventListener('dragging-changed', (event) => { + this.callbacks.onDragging(Boolean(event.value)); + }); + this.transform.addEventListener('mouseUp', () => this.commitTransform()); + this.setTransformMode('translate'); + } + + get enabled(): boolean { + return this.documentLoaded; + } + + setDocument(document: EditableMapDocument | null): void { + const selected = this.selectedId; + this.clearObjects(); + this.documentLoaded = Boolean(document); + this.transform.enabled = this.documentLoaded; + if (!document) { + this.selectObject(null, false); + return; + } + for (const object of document.objects) { + if (!object.enabled) continue; + const preview = objectPreview(object); + this.objects.set(object.id, preview); + this.group.add(preview); + } + for (const spawn of document.spawnPoints) this.group.add(spawnPreview(spawn)); + this.selectObject(selected && this.objects.has(selected) ? selected : null, false); + } + + setTransformMode(mode: MapEditorTransformMode): void { + this.transform.setMode(mode); + const translate = mode === 'translate'; + const scale = mode === 'scale'; + this.transform.showX = translate || scale; + this.transform.showY = translate || scale; + this.transform.showZ = translate || scale || mode === 'rotate'; + this.transform.showXY = translate; + this.transform.showYZ = false; + this.transform.showXZ = false; + this.transform.showE = false; + } + + setSnapping(translation: number | null, rotationDegrees: number | null): void { + this.transform.setTranslationSnap(translation); + this.transform.setRotationSnap( + rotationDegrees === null ? null : THREE.MathUtils.degToRad(rotationDegrees), + ); + } + + selectObject(id: string | null, notify = false): void { + this.selectedId = id && this.objects.has(id) ? id : null; + this.transform.detach(); + for (const [objectId, root] of this.objects) + root.traverse((child) => { + if (!(child instanceof THREE.Mesh)) return; + const materials = Array.isArray(child.material) ? child.material : [child.material]; + for (const item of materials) { + if (!(item instanceof THREE.MeshStandardMaterial)) continue; + item.emissive.setHex(objectId === this.selectedId ? 0x1d4ed8 : 0x000000); + item.emissiveIntensity = objectId === this.selectedId ? 0.45 : 1; + item.opacity = objectId === this.selectedId ? 0.85 : 0.65; + } + }); + const selected = this.selectedId ? this.objects.get(this.selectedId) : undefined; + const editable = Boolean(selected && !selected.userData.mapEditorLocked); + if (selected && editable) this.transform.attach(selected); + this.helper.visible = editable; + if (notify) this.callbacks.onSelect(this.selectedId); + } + + /** 命中编辑对象或操纵器时消费左键;空白区域留给 OrbitControls 旋转视角。 */ + handlePointerDown(event: PointerEvent, bounds: DOMRect): boolean { + if (!this.enabled || event.button !== 0) return false; + if (this.transform.axis || this.transform.dragging) return true; + this.pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1; + this.pointer.y = -((event.clientY - bounds.top) / bounds.height) * 2 + 1; + this.raycaster.setFromCamera(this.pointer, this.camera); + const hit = this.raycaster.intersectObjects([...this.objects.values()], true)[0]; + const id = hit?.object.userData.mapEditorObjectId; + if (typeof id !== 'string') return false; + this.selectObject(id, true); + return true; + } + + private commitTransform(): void { + if (!this.selectedId) return; + const object = this.objects.get(this.selectedId); + if (!object) return; + const position: [number, number, number] = [ + object.position.x, + object.position.y, + object.position.z, + ]; + const quaternion: [number, number, number, number] = [ + object.quaternion.w, + object.quaternion.x, + object.quaternion.y, + object.quaternion.z, + ]; + const scale: [number, number, number] = [object.scale.x, object.scale.y, object.scale.z]; + this.callbacks.onTransform(this.selectedId, position, quaternion, scale); + } + + private clearObjects(): void { + this.transform.detach(); + this.helper.visible = false; + const geometries = new Set(), + materials = new Set(); + for (const child of this.group.children) + child.traverse((object) => { + if (object instanceof THREE.Mesh) { + geometries.add(object.geometry); + for (const item of Array.isArray(object.material) ? object.material : [object.material]) + materials.add(item); + } + }); + this.group.clear(); + this.objects.clear(); + for (const geometry of geometries) geometry.dispose(); + for (const item of materials) item.dispose(); + } + + clear(): void { + this.documentLoaded = false; + this.selectedId = null; + this.clearObjects(); + this.transform.enabled = false; + this.callbacks.onDragging(false); + } + + dispose(): void { + this.clear(); + this.callbacks.onDragging(false); + this.transform.dispose(); + this.helper.removeFromParent(); + this.group.removeFromParent(); + } +} diff --git a/web_platform/src/viewer/MuJoCoViewer.ts b/web_platform/src/viewer/MuJoCoViewer.ts index 88424dd7..3182e238 100644 --- a/web_platform/src/viewer/MuJoCoViewer.ts +++ b/web_platform/src/viewer/MuJoCoViewer.ts @@ -1,101 +1,971 @@ import * as THREE from 'three'; -import {OrbitControls} from 'three/examples/jsm/controls/OrbitControls.js'; -import type {MjvGeom, MjvOption, MjvCamera, MjvScene} from '@mujoco/mujoco'; -import type {FrameResult, SimulationSession, SimulationSnapshot} from '../simulation/SimulationSession'; -import {meshIdFromSceneDataId} from '../simulation/geometry'; -import {OrientationGizmo} from './OrientationGizmo'; -import {closestRayAxisParameter,forceFromScreenDrag,resolveHingeDragDelta,signedAngleAroundAxis} from './interactionMath'; -import {texturePixelsToRgba} from './texturePixels'; -import {DEFAULT_VIEWER_DISPLAY_OPTIONS,type ViewerDisplayOptions} from './displayOptions'; -import {ViewerVisualizationHelpers} from './ViewerVisualizationHelpers'; +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; +import type { MjvGeom, MjvOption, MjvCamera, MjvScene } from '@mujoco/mujoco'; +import type { + FrameResult, + SimulationSession, + SimulationSnapshot, +} from '../simulation/SimulationSession'; +import { heightfieldGeometryData, meshIdFromSceneDataId } from '../simulation/geometry'; +import { OrientationGizmo } from './OrientationGizmo'; +import { + closestRayAxisParameter, + forceFromScreenDrag, + resolveHingeDragDelta, + signedAngleAroundAxis, +} from './interactionMath'; +import { texturePixelsToRgba } from './texturePixels'; +import { DEFAULT_VIEWER_DISPLAY_OPTIONS, type ViewerDisplayOptions } from './displayOptions'; +import { ViewerVisualizationHelpers } from './ViewerVisualizationHelpers'; +import { VisualMapLayer } from './VisualMapLayer'; +import type { VisualMapAsset } from '../map/types'; +import type { EditableMapDocument, MapEditorTransformMode } from '../map/editor/types'; +import { MapEditorLayer } from './MapEditorLayer'; export type InteractionMode = 'select' | 'joint' | 'force'; -export type ViewerTheme='light'|'dark'; -export interface ViewerSelection {bodyId: number; geomId: number; bodyName: string; geomType: number; position: [number, number, number];} -interface ViewerCallbacks {onSelection(selection: ViewerSelection | null): void; onFrame(frame: FrameResult, fps: number, snapshot?: SimulationSnapshot): void; onError(error: Error): void;} +export type ViewerTheme = 'light' | 'dark'; +export interface ViewerSelection { + bodyId: number; + geomId: number; + bodyName: string; + geomType: number; + position: [number, number, number]; +} +interface ViewerCallbacks { + onSelection(selection: ViewerSelection | null): void; + onFrame(frame: FrameResult, fps: number, snapshot?: SimulationSnapshot): void; + onError(error: Error): void; + onMapEditorSelect(id: string | null): void; + onMapEditorTransform( + id: string, + position: [number, number, number], + quaternion: [number, number, number, number], + scale: [number, number, number], + ): void; +} class CapsuleGeometry extends THREE.BufferGeometry { - constructor(radius:number,length:number) {super(); const path=new THREE.Path(); path.absarc(0,-length/2,radius,Math.PI*1.5,0); path.absarc(0,length/2,radius,0,Math.PI*.5); const source=new THREE.LatheGeometry(path.getPoints(24),16); this.copy(source); source.dispose(); this.rotateX(Math.PI/2);} + constructor(radius: number, length: number) { + super(); + const path = new THREE.Path(); + path.absarc(0, -length / 2, radius, Math.PI * 1.5, 0); + path.absarc(0, length / 2, radius, 0, Math.PI * 0.5); + const source = new THREE.LatheGeometry(path.getPoints(24), 16); + this.copy(source); + source.dispose(); + this.rotateX(Math.PI / 2); + } } export class MuJoCoViewer { readonly scene = new THREE.Scene(); - readonly camera = new THREE.PerspectiveCamera(45,1,.01,2_000); - private readonly sensorCamera=new THREE.PerspectiveCamera(45,4/3,.01,2_000); + readonly camera = new THREE.PerspectiveCamera(45, 1, 0.01, 2_000); + private readonly sensorCamera = new THREE.PerspectiveCamera(45, 4 / 3, 0.01, 2_000); readonly renderer: THREE.WebGLRenderer; readonly controls: OrbitControls; mode: InteractionMode = 'select'; forceScale = 50; - private displayOptions:ViewerDisplayOptions={...DEFAULT_VIEWER_DISPLAY_OPTIONS}; - private modelHasVisuals=false; - private showSensorCamera=true; - private sensorCameraId=-1; - private modelExtent=2; + private displayOptions: ViewerDisplayOptions = { ...DEFAULT_VIEWER_DISPLAY_OPTIONS }; + private modelHasVisuals = false; + private mapHasVisuals = false; + private showVisualMap = true; + private showMapCollision = false; + private showSensorCamera = true; + private sensorCameraId = -1; + private modelExtent = 2; private session: SimulationSession | null = null; private option: MjvOption | null = null; private mjCamera: MjvCamera | null = null; private mjScene: MjvScene | null = null; - private frame = 0; private lastFpsAt=performance.now(); private fpsFrames=0; private lastSnapshotAt=0; - private meshes: THREE.Mesh[]=[]; private geometries=new Map(); private textures=new Map(); - private readonly sensorRotation=new THREE.Matrix4();private readonly renderSize=new THREE.Vector2(); - private raycaster=new THREE.Raycaster(); private pointer=new THREE.Vector2(); private selected:THREE.Mesh|null=null; private dragStart:THREE.Vector2|null=null; private dragJointId=-1; private dragJointType=-1;private dragJointValue=0;private dragHitDistance=0;private dragSlideParameter=0;private dragJointPivot=new THREE.Vector3();private dragJointAxisWorld=new THREE.Vector3();private dragJointStartWorld=new THREE.Vector3();private dragJointStartPlaneVector=new THREE.Vector3();private dragForceOrigin=new THREE.Vector3();private dragForcePlane=new THREE.Plane(); private arrow:THREE.ArrowHelper|null=null;private highlightedJointId=-1;private highlightedBodyId=-1;private jointMarker:THREE.Mesh|null=null; - private resizeObserver:ResizeObserver; - private orientationGizmo:OrientationGizmo; - private visualizationHelpers:ViewerVisualizationHelpers; - private grid:THREE.GridHelper; - private hemisphere:THREE.HemisphereLight; - private themeTransition?:{started:number;fromBackground:THREE.Color;toBackground:THREE.Color;fromGround:THREE.Color;toGround:THREE.Color;}; + private frame = 0; + private lastFpsAt = performance.now(); + private fpsFrames = 0; + private lastSnapshotAt = 0; + private snapshotDirty = false; + private runtimeErrorReported = false; + private meshes: THREE.Mesh[] = []; + private geometries = new Map(); + private textures = new Map(); + private readonly sensorRotation = new THREE.Matrix4(); + private readonly renderSize = new THREE.Vector2(); + private raycaster = new THREE.Raycaster(); + private pointer = new THREE.Vector2(); + private selected: THREE.Mesh | null = null; + private dragStart: THREE.Vector2 | null = null; + private dragJointId = -1; + private dragJointType = -1; + private dragJointValue = 0; + private dragHitDistance = 0; + private dragSlideParameter = 0; + private dragJointPivot = new THREE.Vector3(); + private dragJointAxisWorld = new THREE.Vector3(); + private dragJointStartWorld = new THREE.Vector3(); + private dragJointStartPlaneVector = new THREE.Vector3(); + private dragForceOrigin = new THREE.Vector3(); + private dragForcePlane = new THREE.Plane(); + private arrow: THREE.ArrowHelper | null = null; + private highlightedJointId = -1; + private highlightedBodyId = -1; + private jointMarker: THREE.Mesh | null = null; + private resizeObserver: ResizeObserver; + private orientationGizmo: OrientationGizmo; + private visualizationHelpers: ViewerVisualizationHelpers; + private visualMapLayer: VisualMapLayer; + private mapEditorLayer: MapEditorLayer; + private grid: THREE.GridHelper; + private hemisphere: THREE.HemisphereLight; + private themeTransition?: { + started: number; + fromBackground: THREE.Color; + toBackground: THREE.Color; + fromGround: THREE.Color; + toGround: THREE.Color; + }; - constructor(private readonly host:HTMLElement, private readonly callbacks:ViewerCallbacks) { - this.renderer=new THREE.WebGLRenderer({antialias:true,alpha:false}); this.renderer.setPixelRatio(Math.min(devicePixelRatio,2)); this.renderer.shadowMap.enabled=true; this.renderer.outputColorSpace=THREE.SRGBColorSpace; host.append(this.renderer.domElement); - this.camera.up.set(0,0,1); this.camera.position.set(3,-3,2); this.controls=new OrbitControls(this.camera,this.renderer.domElement); this.controls.enableDamping=true; - this.scene.background=new THREE.Color(0x0b1220);this.hemisphere=new THREE.HemisphereLight(0xffffff,0x223344,1.3);this.scene.add(this.hemisphere); const light=new THREE.DirectionalLight(0xffffff,2); light.position.set(4,-3,7); light.castShadow=true; this.scene.add(light);this.grid=new THREE.GridHelper(20,40,0x3b82f6,0x253047).rotateX(Math.PI/2);this.grid.visible=this.displayOptions.showGrid;this.scene.add(this.grid);this.visualizationHelpers=new ViewerVisualizationHelpers(this.scene);this.orientationGizmo=new OrientationGizmo(host);this.orientationGizmo.update(this.camera); - this.resizeObserver=new ResizeObserver(()=>this.resize()); this.resizeObserver.observe(host); this.resize(); - this.renderer.domElement.addEventListener('pointerdown',this.onPointerDown);this.renderer.domElement.addEventListener('pointermove',this.onPointerMove);this.renderer.domElement.addEventListener('lostpointercapture',this.onPointerUp);window.addEventListener('pointerup',this.onPointerUp);window.addEventListener('pointercancel',this.onPointerUp);window.addEventListener('blur',this.onPointerUp); - this.frame=requestAnimationFrame(this.animate); + constructor( + private readonly host: HTMLElement, + private readonly callbacks: ViewerCallbacks, + ) { + this.renderer = new THREE.WebGLRenderer({ + antialias: true, + alpha: false, + stencil: false, + powerPreference: 'high-performance', + }); + // 高 DPI 屏幕限制内部像素比,显著降低双视口与阴影的 GPU 填充压力。 + this.renderer.setPixelRatio(Math.min(devicePixelRatio, 1.75)); + this.renderer.shadowMap.enabled = true; + this.renderer.outputColorSpace = THREE.SRGBColorSpace; + host.append(this.renderer.domElement); + this.camera.up.set(0, 0, 1); + this.camera.position.set(3, -3, 2); + this.controls = new OrbitControls(this.camera, this.renderer.domElement); + this.controls.enableDamping = true; + this.scene.background = new THREE.Color(0x0b1220); + this.hemisphere = new THREE.HemisphereLight(0xffffff, 0x223344, 1.3); + this.scene.add(this.hemisphere); + const light = new THREE.DirectionalLight(0xffffff, 2); + light.position.set(4, -3, 7); + light.castShadow = true; + this.scene.add(light); + this.grid = new THREE.GridHelper(20, 40, 0x3b82f6, 0x253047).rotateX(Math.PI / 2); + this.grid.visible = this.displayOptions.showGrid; + this.scene.add(this.grid); + this.visualizationHelpers = new ViewerVisualizationHelpers(this.scene); + this.visualMapLayer = new VisualMapLayer(this.scene); + this.mapEditorLayer = new MapEditorLayer(this.scene, this.camera, this.renderer.domElement, { + onSelect: (id) => this.callbacks.onMapEditorSelect(id), + onTransform: (id, position, quaternion, scale) => + this.callbacks.onMapEditorTransform(id, position, quaternion, scale), + onDragging: (value) => { + this.controls.enabled = !value; + }, + }); + this.orientationGizmo = new OrientationGizmo(host); + this.orientationGizmo.update(this.camera); + this.resizeObserver = new ResizeObserver(() => this.resize()); + this.resizeObserver.observe(host); + this.resize(); + this.renderer.domElement.addEventListener('pointerdown', this.onPointerDown); + this.renderer.domElement.addEventListener('pointermove', this.onPointerMove); + this.renderer.domElement.addEventListener('lostpointercapture', this.onPointerUp); + window.addEventListener('pointerup', this.onPointerUp); + window.addEventListener('pointercancel', this.onPointerUp); + window.addEventListener('blur', this.onPointerUp); + this.frame = requestAnimationFrame(this.animate); } - attach(session:SimulationSession|null):void {this.releaseModel(); this.session=session; if(!session)return; this.option=new session.module.MjvOption(); session.module.mjv_defaultOption(this.option);this.applyGeomVisibility(); this.mjCamera=new session.module.MjvCamera(); session.module.mjv_defaultCamera(this.mjCamera); this.mjScene=new session.module.MjvScene(session.model,32768);for(let id=0;id=0&&jointId { + await this.visualMapLayer.load(null); + this.mapHasVisuals = false; + this.applyGeomVisibility(); + if (!asset) return; + await this.visualMapLayer.load(asset); + this.mapHasVisuals = this.visualMapLayer.loaded; + this.visualMapLayer.visible = this.showVisualMap && this.displayOptions.showVisual; + this.applyGeomVisibility(); + } + setMapEditorDocument(document: EditableMapDocument | null): void { + this.mapEditorLayer.setDocument(document); + this.controls.enableRotate = this.mode === 'select'; + } + selectMapEditorObject(id: string | null): void { + this.mapEditorLayer.selectObject(id); + } + setMapEditorTransformMode(mode: MapEditorTransformMode): void { + this.mapEditorLayer.setTransformMode(mode); + } + setMapEditorSnapping(translation: number | null, rotationDegrees: number | null): void { + this.mapEditorLayer.setSnapping(translation, rotationDegrees); + } + mapPlanePoint(clientX: number, clientY: number): [number, number, number] | null { + const rect = this.renderer.domElement.getBoundingClientRect(); + if ( + rect.width <= 0 || + rect.height <= 0 || + clientX < rect.left || + clientX > rect.right || + clientY < rect.top || + clientY > rect.bottom + ) + return null; + this.pointer.set( + ((clientX - rect.left) / rect.width) * 2 - 1, + -((clientY - rect.top) / rect.height) * 2 + 1, + ); + this.raycaster.setFromCamera(this.pointer, this.camera); + const point = this.raycaster.ray.intersectPlane( + new THREE.Plane(new THREE.Vector3(0, 0, 1), 0), + new THREE.Vector3(), + ); + return point ? [point.x, point.y, 0] : null; + } + setMapDisplay(showVisual: boolean, showCollision: boolean): void { + this.showVisualMap = showVisual; + this.showMapCollision = showCollision; + this.visualMapLayer.visible = showVisual && this.displayOptions.showVisual; + this.applyGeomVisibility(); + } + setShowSensorCamera(value: boolean): void { + this.showSensorCamera = value; + } + setTheme(theme: ViewerTheme): void { + const light = theme === 'light'; + const background = + this.scene.background instanceof THREE.Color + ? this.scene.background + : new THREE.Color(0x0b1220); + this.scene.background = background; + this.themeTransition = { + started: performance.now(), + fromBackground: background.clone(), + toBackground: new THREE.Color(light ? 0xf8fafc : 0x0b1220), + fromGround: this.hemisphere.groundColor.clone(), + toGround: new THREE.Color(light ? 0xcbd5e1 : 0x223344), + }; + const next = new THREE.GridHelper( + 20, + 40, + light ? 0x64748b : 0x3b82f6, + light ? 0xcbd5e1 : 0x253047, + ).rotateX(Math.PI / 2); + next.visible = this.displayOptions.showGrid; + this.scene.remove(this.grid); + this.grid.geometry.dispose(); + const materials = Array.isArray(this.grid.material) ? this.grid.material : [this.grid.material]; + for (const material of materials) material.dispose(); + this.grid = next; + this.scene.add(this.grid); + this.orientationGizmo.setTheme(theme); + } + private updateThemeTransition(now: number): void { + const transition = this.themeTransition; + if (!transition) return; + const progress = Math.min(1, Math.max(0, (now - transition.started) / 420)), + eased = 1 - Math.pow(1 - progress, 3); + (this.scene.background as THREE.Color) + .copy(transition.fromBackground) + .lerp(transition.toBackground, eased); + this.hemisphere.groundColor.copy(transition.fromGround).lerp(transition.toGround, eased); + if (progress === 1) this.themeTransition = undefined; + } + private applyGeomVisibility(): void { + if (!this.option || !this.session) return; + this.modelHasVisuals = false; + for (let index = 0; index < this.session.model.ngeom; index += 1) { + if (Number(this.session.model.geom_group[index]) !== 1) continue; + const geom = this.session.model.geom(index); + try { + if (!geom.name.startsWith('__platform_map_') && geom.name !== '__platform_ground__') { + this.modelHasVisuals = true; + break; + } + } finally { + geom.delete(); + } + } + this.option.geomgroup[0] = this.modelHasVisuals + ? this.displayOptions.showCollision + ? 1 + : 0 + : this.displayOptions.showVisual || this.displayOptions.showCollision + ? 1 + : 0; + this.option.geomgroup[1] = this.displayOptions.showVisual ? 1 : 0; + this.option.geomgroup[2] = + this.showMapCollision || (!this.mapHasVisuals && this.showVisualMap) ? 1 : 0; + this.option.geomgroup[5] = 0; + this.visualMapLayer.visible = this.showVisualMap && this.displayOptions.showVisual; + } + resetCamera(): void { + if (this.session) this.fitCamera(this.session); + } + highlightJoint(jointId: number | null): void { + this.highlightedJointId = jointId ?? -1; + this.highlightedBodyId = -1; + if (this.session && jointId !== null && jointId >= 0 && jointId < this.session.model.njnt) { + const joint = this.session.model.jnt(jointId); + try { + this.highlightedBodyId = Number(joint.bodyid); + } finally { + joint.delete(); + } + if (!this.jointMarker) { + const radius = Math.max(0.008, this.modelExtent * 0.012); + this.jointMarker = new THREE.Mesh( + new THREE.SphereGeometry(radius, 18, 12), + new THREE.MeshBasicMaterial({ + color: 0xfacc15, + depthTest: false, + transparent: true, + opacity: 0.95, + }), + ); + this.jointMarker.renderOrder = 100; + this.scene.add(this.jointMarker); + } + this.jointMarker.visible = true; + this.updateJointMarker(); + } else if (this.jointMarker) this.jointMarker.visible = false; + for (const mesh of this.meshes) this.applyMeshHighlight(mesh); + } + private updateJointMarker(): void { + if (!this.session || !this.jointMarker || this.highlightedJointId < 0) return; + const offset = this.highlightedJointId * 3; + this.jointMarker.position.set( + Number(this.session.data.xanchor[offset]), + Number(this.session.data.xanchor[offset + 1]), + Number(this.session.data.xanchor[offset + 2]), + ); + } + private applyMeshHighlight(mesh: THREE.Mesh): void { + const material = mesh.material as THREE.MeshStandardMaterial; + if (Number(mesh.userData.bodyId) === this.highlightedBodyId) { + material.emissive.setHex(0x8a6d00); + material.emissiveIntensity = 0.85; + } else if (mesh === this.selected) { + material.emissive.setHex(0x14532d); + material.emissiveIntensity = 1; + } else { + material.emissive.setHex(0); + material.emissiveIntensity = 1; + } + } - private fitCamera(session:SimulationSession):void {const {extent,center}=session.geometryBounds();this.modelExtent=extent;this.controls.target.set(center[0],center[1],center[2]);this.camera.position.set(center[0]+extent*1.5,center[1]-extent*1.5,center[2]+extent);this.camera.near=Math.max(.001,extent/1000);this.camera.far=Math.max(100,extent*100);this.camera.updateProjectionMatrix();this.controls.update();} - private resize():void {const w=Math.max(1,this.host.clientWidth),h=Math.max(1,this.host.clientHeight); this.renderer.setSize(w,h,false); this.camera.aspect=w/h; this.camera.updateProjectionMatrix();} + private fitCamera(session: SimulationSession): void { + const { extent, center } = session.geometryBounds(); + this.modelExtent = extent; + this.controls.target.set(center[0], center[1], center[2]); + this.camera.position.set( + center[0] + extent * 1.5, + center[1] - extent * 1.5, + center[2] + extent, + ); + this.camera.near = Math.max(0.001, extent / 1000); + this.camera.far = Math.max(100, extent * 100); + this.camera.updateProjectionMatrix(); + this.controls.update(); + } + private resize(): void { + const w = Math.max(1, this.host.clientWidth), + h = Math.max(1, this.host.clientHeight); + this.renderer.setSize(w, h, false); + this.camera.aspect = w / h; + this.camera.updateProjectionMatrix(); + } - private animate=(now:number):void=>{try {const result=this.session?.advance(now)??{steps:0,stepMs:0,overBudget:false};this.updateThemeTransition(now); this.controls.update();this.orientationGizmo.update(this.camera); if(this.session){this.updateMuJoCoScene();this.updateJointMarker();this.visualizationHelpers.update();} this.renderer.setScissorTest(false);this.renderer.render(this.scene,this.camera);if(this.showSensorCamera&&this.updateSensorCamera())this.renderSensorCamera(); this.fpsFrames++; let fps=0;if(now-this.lastFpsAt>=500){fps=this.fpsFrames*1000/(now-this.lastFpsAt);this.fpsFrames=0;this.lastFpsAt=now;} const snapshot=this.session&&now-this.lastSnapshotAt>150?(this.lastSnapshotAt=now,this.session.snapshot()):undefined; /* 避免每个 RAF 都触发 Zustand/React 全树重渲染。 */ if(snapshot||fps>0)this.callbacks.onFrame(result,fps,snapshot);}catch(error){this.callbacks.onError(error instanceof Error?error:new Error(String(error)));} this.frame=requestAnimationFrame(this.animate);}; + private animate = (now: number): void => { + try { + const result = this.session?.advance(now) ?? { steps: 0, stepMs: 0, overBudget: false }; + this.updateThemeTransition(now); + this.controls.update(); + this.orientationGizmo.update(this.camera); + if (this.session) { + this.updateMuJoCoScene(); + this.updateJointMarker(); + this.visualizationHelpers.update(); + } + this.renderer.setScissorTest(false); + this.renderer.render(this.scene, this.camera); + if (this.showSensorCamera && this.updateSensorCamera()) this.renderSensorCamera(); + this.fpsFrames++; + let fps = 0; + if (now - this.lastFpsAt >= 500) { + fps = (this.fpsFrames * 1000) / (now - this.lastFpsAt); + this.fpsFrames = 0; + this.lastFpsAt = now; + } + const snapshot = + this.session && (result.steps > 0 || this.snapshotDirty) && now - this.lastSnapshotAt > 200 + ? ((this.lastSnapshotAt = now), (this.snapshotDirty = false), this.session.snapshot()) + : undefined; + // 暂停时仅在交互真正修改状态后刷新,不再固定轮询完整模型快照。 + if (snapshot || fps > 0) this.callbacks.onFrame(result, fps, snapshot); + } catch (error) { + if (!this.runtimeErrorReported) { + this.runtimeErrorReported = true; + this.callbacks.onError(error instanceof Error ? error : new Error(String(error))); + } + } + this.frame = requestAnimationFrame(this.animate); + }; - private updateSensorCamera():boolean {if(!this.session||this.sensorCameraId<0||this.sensorCameraId>=this.session.model.ncam)return false;const id=this.sensorCameraId,p=id*3,m=id*9,data=this.session.data,model=this.session.model;this.sensorCamera.position.set(Number(data.cam_xpos[p]),Number(data.cam_xpos[p+1]),Number(data.cam_xpos[p+2]));this.sensorRotation.set(Number(data.cam_xmat[m]),Number(data.cam_xmat[m+1]),Number(data.cam_xmat[m+2]),0,Number(data.cam_xmat[m+3]),Number(data.cam_xmat[m+4]),Number(data.cam_xmat[m+5]),0,Number(data.cam_xmat[m+6]),Number(data.cam_xmat[m+7]),Number(data.cam_xmat[m+8]),0,0,0,0,1);this.sensorCamera.quaternion.setFromRotationMatrix(this.sensorRotation);this.sensorCamera.fov=Number(model.cam_fovy[id])||45;const extent=this.modelExtent;this.sensorCamera.near=Math.max(.001,extent/1000);this.sensorCamera.far=Math.max(100,extent*100);this.sensorCamera.updateProjectionMatrix();return true;} - private renderSensorCamera():void {const size=this.renderer.getSize(this.renderSize),width=Math.max(120,Math.min(320,size.x*.32)),height=width*3/4,margin=16;this.sensorCamera.aspect=width/height;this.sensorCamera.updateProjectionMatrix();this.renderer.setViewport(margin,margin,width,height);this.renderer.setScissor(margin,margin,width,height);this.renderer.setScissorTest(true);this.renderer.render(this.scene,this.sensorCamera);this.renderer.setScissorTest(false);this.renderer.setViewport(0,0,size.x,size.y);} + private updateSensorCamera(): boolean { + if (!this.session || this.sensorCameraId < 0 || this.sensorCameraId >= this.session.model.ncam) + return false; + const id = this.sensorCameraId, + p = id * 3, + m = id * 9, + data = this.session.data, + model = this.session.model; + this.sensorCamera.position.set( + Number(data.cam_xpos[p]), + Number(data.cam_xpos[p + 1]), + Number(data.cam_xpos[p + 2]), + ); + this.sensorRotation.set( + Number(data.cam_xmat[m]), + Number(data.cam_xmat[m + 1]), + Number(data.cam_xmat[m + 2]), + 0, + Number(data.cam_xmat[m + 3]), + Number(data.cam_xmat[m + 4]), + Number(data.cam_xmat[m + 5]), + 0, + Number(data.cam_xmat[m + 6]), + Number(data.cam_xmat[m + 7]), + Number(data.cam_xmat[m + 8]), + 0, + 0, + 0, + 0, + 1, + ); + this.sensorCamera.quaternion.setFromRotationMatrix(this.sensorRotation); + this.sensorCamera.fov = Number(model.cam_fovy[id]) || 45; + const extent = this.modelExtent; + this.sensorCamera.near = Math.max(0.001, extent / 1000); + this.sensorCamera.far = Math.max(100, extent * 100); + this.sensorCamera.updateProjectionMatrix(); + return true; + } + private renderSensorCamera(): void { + const size = this.renderer.getSize(this.renderSize), + width = Math.max(120, Math.min(320, size.x * 0.32)), + height = (width * 3) / 4, + margin = 16; + this.sensorCamera.aspect = width / height; + this.sensorCamera.updateProjectionMatrix(); + this.renderer.setViewport(margin, margin, width, height); + this.renderer.setScissor(margin, margin, width, height); + this.renderer.setScissorTest(true); + this.renderer.render(this.scene, this.sensorCamera); + this.renderer.setScissorTest(false); + this.renderer.setViewport(0, 0, size.x, size.y); + } - private updateMuJoCoScene():void {const s=this.session!; s.module.mjv_updateScene(s.model,s.data,this.option!,s.perturb,this.mjCamera!,s.module.mjtCatBit.mjCAT_ALL.value,this.mjScene!); const geoms=this.mjScene!.geoms; try {const count=geoms.size();for(let i=0;i=0)return this.meshGeometry(meshIdFromSceneDataId(g.dataid));return new THREE.BufferGeometry();} - private meshGeometry(id:number):THREE.BufferGeometry {const m=this.session!.model;const va=Number(m.mesh_vertadr[id]),vn=Number(m.mesh_vertnum[id]),fa=Number(m.mesh_faceadr[id]),fn=Number(m.mesh_facenum[id]);const positions=new Float32Array(vn*3);for(let i=0;i=0){const uv=new Float32Array(tn*2);for(let i=0;i= 0) + return this.heightfieldGeometry(g.dataid); + if (t === m.mjtGeom.mjGEOM_PLANE.value) + return new THREE.PlaneGeometry(2 * (s[0] || 1e3), 2 * (s[1] || 1e3)); + if (t === m.mjtGeom.mjGEOM_SPHERE.value) return new THREE.SphereGeometry(s[0], 24, 16); + if (t === m.mjtGeom.mjGEOM_CAPSULE.value) return new CapsuleGeometry(s[0], 2 * s[2]); + if (t === m.mjtGeom.mjGEOM_BOX.value) + return new THREE.BoxGeometry(2 * s[0], 2 * s[1], 2 * s[2]); + if (t === m.mjtGeom.mjGEOM_CYLINDER.value) { + const x = new THREE.CylinderGeometry(s[0], s[0], 2 * s[2], 24); + x.rotateX(Math.PI / 2); + return x; + } + if (t === m.mjtGeom.mjGEOM_ELLIPSOID.value) { + const x = new THREE.SphereGeometry(1, 24, 16); + x.scale(s[0], s[1], s[2]); + return x; + } + if (t === m.mjtGeom.mjGEOM_MESH.value && g.dataid >= 0) + return this.meshGeometry(meshIdFromSceneDataId(g.dataid)); + return new THREE.BufferGeometry(); + } + private heightfieldGeometry(id: number): THREE.BufferGeometry { + const data = heightfieldGeometryData(this.session!.model, id), + geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.BufferAttribute(data.positions, 3)); + geometry.setIndex(new THREE.BufferAttribute(data.indices, 1)); + geometry.computeVertexNormals(); + geometry.computeBoundingSphere(); + return geometry; + } + private meshGeometry(id: number): THREE.BufferGeometry { + const m = this.session!.model; + const va = Number(m.mesh_vertadr[id]), + vn = Number(m.mesh_vertnum[id]), + fa = Number(m.mesh_faceadr[id]), + fn = Number(m.mesh_facenum[id]); + const positions = new Float32Array(vn * 3); + for (let i = 0; i < positions.length; i++) positions[i] = m.mesh_vert[va * 3 + i]; + const indices = new Uint32Array(fn * 3); + for (let i = 0; i < indices.length; i++) indices[i] = m.mesh_face[fa * 3 + i]; + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geometry.setIndex(new THREE.BufferAttribute(indices, 1)); + const na = Number(m.mesh_normaladr[id]), + nn = Number(m.mesh_normalnum[id]); + if (nn === vn) { + const normals = new Float32Array(nn * 3); + for (let i = 0; i < normals.length; i++) normals[i] = m.mesh_normal[na * 3 + i]; + geometry.setAttribute('normal', new THREE.BufferAttribute(normals, 3)); + } else geometry.computeVertexNormals(); + const ta = Number(m.mesh_texcoordadr[id]), + tn = Number(m.mesh_texcoordnum[id]); + if (tn === vn && ta >= 0) { + const uv = new Float32Array(tn * 2); + for (let i = 0; i < uv.length; i++) uv[i] = m.mesh_texcoord[ta * 2 + i]; + geometry.setAttribute('uv', new THREE.BufferAttribute(uv, 2)); + } + geometry.computeBoundingSphere(); + return geometry; + } + private texture(id: number): THREE.DataTexture | undefined { + if (id < 0) return; + let found = this.textures.get(id); + if (found) return found; + const m = this.session!.model, + w = Number(m.tex_width[id]), + h = Number(m.tex_height[id]), + channels = Number(m.tex_nchannel[id] || 3), + adr = Number(m.tex_adr[id]); + if (!w || !h) return; + const source = new Uint8Array(w * h * channels); + for (let i = 0; i < source.length; i++) source[i] = m.tex_data[adr + i]; + const data = texturePixelsToRgba(source, w * h, channels); + found = new THREE.DataTexture(data, w, h, THREE.RGBAFormat); + found.colorSpace = THREE.SRGBColorSpace; + found.flipY = true; + found.needsUpdate = true; + this.textures.set(id, found); + return found; + } // 只缓存真正可复用的模型 mesh。动态接触几何会逐帧改变尺寸,必须由所属 // THREE.Mesh 在替换时释放,否则 geometry key 会无限增长并最终耗尽标签页内存。 - private createMesh(g:MjvGeom,key:string):THREE.Mesh {const m=this.session!.module,isMesh=g.type===m.mjtGeom.mjGEOM_MESH.value&&g.dataid>=0,sharedKey=isMesh?`mesh:${meshIdFromSceneDataId(g.dataid)}`:undefined;let geometry=sharedKey?this.geometries.get(sharedKey):undefined;if(!geometry){geometry=this.primitive(g);if(sharedKey)this.geometries.set(sharedKey,geometry);}const map=this.texture(g.texid);const material=new THREE.MeshStandardMaterial({color:new THREE.Color(g.rgba[0],g.rgba[1],g.rgba[2]),opacity:g.rgba[3],transparent:g.rgba[3]<1,...(map?{map}:{}),roughness:Math.max(.05,1-g.shininess),metalness:g.reflectance});const mesh=new THREE.Mesh(geometry,material);mesh.matrixAutoUpdate=false;mesh.castShadow=true;mesh.receiveShadow=true;mesh.userData.geometryKey=key;mesh.userData.ownsGeometry=!sharedKey;return mesh;} - private updateMesh(mesh:THREE.Mesh,g:MjvGeom):void {const mat=mesh.material as THREE.MeshStandardMaterial,geomId=g.objtype===this.session!.module.mjtObj.mjOBJ_GEOM.value?g.objid:-1,bodyId=geomId>=0?Number(this.session!.model.geom_bodyid[geomId]):-1,isCollision=this.modelHasVisuals&&geomId>=0&&Number(this.session!.model.geom_group[geomId])===0;if(isCollision){const worldCollision=bodyId===0;mat.color.setHex(worldCollision?0x64748b:0x22d3ee);mat.opacity=worldCollision ? .12 : .28;mat.transparent=true;mat.depthTest=worldCollision;mat.depthWrite=false;mat.roughness=.85;mat.metalness=0;mesh.renderOrder=worldCollision?0:60;}else{mat.color.setRGB(g.rgba[0],g.rgba[1],g.rgba[2]);mat.opacity=g.rgba[3];mat.transparent=g.rgba[3]<1;mat.depthTest=true;mat.depthWrite=true;mat.roughness=Math.max(.05,1-g.shininess);mat.metalness=g.reflectance;mesh.renderOrder=0;}mesh.matrix.set(g.mat[0],g.mat[1],g.mat[2],g.pos[0],g.mat[3],g.mat[4],g.mat[5],g.pos[1],g.mat[6],g.mat[7],g.mat[8],g.pos[2],0,0,0,1);mesh.matrixWorldNeedsUpdate=true;mesh.userData.geomId=geomId;mesh.userData.bodyId=bodyId;mesh.userData.geomType=g.type;mesh.userData.isCollision=isCollision;this.applyMeshHighlight(mesh);} + private createMesh(g: MjvGeom, key: string): THREE.Mesh { + const m = this.session!.module, + isMesh = g.type === m.mjtGeom.mjGEOM_MESH.value && g.dataid >= 0, + isHeightfield = g.type === m.mjtGeom.mjGEOM_HFIELD.value && g.dataid >= 0, + sharedKey = isMesh + ? `mesh:${meshIdFromSceneDataId(g.dataid)}` + : isHeightfield + ? `hfield:${g.dataid}` + : undefined; + let geometry = sharedKey ? this.geometries.get(sharedKey) : undefined; + if (!geometry) { + geometry = this.primitive(g); + if (sharedKey) this.geometries.set(sharedKey, geometry); + } + const map = this.texture(g.texid); + const material = new THREE.MeshStandardMaterial({ + color: new THREE.Color(g.rgba[0], g.rgba[1], g.rgba[2]), + opacity: g.rgba[3], + transparent: g.rgba[3] < 1, + ...(map ? { map } : {}), + roughness: Math.max(0.05, 1 - g.shininess), + metalness: g.reflectance, + }); + const mesh = new THREE.Mesh(geometry, material); + mesh.matrixAutoUpdate = false; + mesh.castShadow = true; + mesh.receiveShadow = true; + mesh.userData.geometryKey = key; + mesh.userData.ownsGeometry = !sharedKey; + return mesh; + } + private updateMesh(mesh: THREE.Mesh, g: MjvGeom): void { + const mat = mesh.material as THREE.MeshStandardMaterial, + geomId = g.objtype === this.session!.module.mjtObj.mjOBJ_GEOM.value ? g.objid : -1, + bodyId = geomId >= 0 ? Number(this.session!.model.geom_bodyid[geomId]) : -1, + isCollision = + this.modelHasVisuals && geomId >= 0 && Number(this.session!.model.geom_group[geomId]) === 0; + if (isCollision) { + const worldCollision = bodyId === 0; + mat.color.setHex(worldCollision ? 0x64748b : 0x22d3ee); + mat.opacity = worldCollision ? 0.12 : 0.28; + mat.transparent = true; + mat.depthTest = worldCollision; + mat.depthWrite = false; + mat.roughness = 0.85; + mat.metalness = 0; + mesh.renderOrder = worldCollision ? 0 : 60; + } else { + mat.color.setRGB(g.rgba[0], g.rgba[1], g.rgba[2]); + mat.opacity = g.rgba[3]; + mat.transparent = g.rgba[3] < 1; + mat.depthTest = true; + mat.depthWrite = true; + mat.roughness = Math.max(0.05, 1 - g.shininess); + mat.metalness = g.reflectance; + mesh.renderOrder = 0; + } + mesh.matrix.set( + g.mat[0], + g.mat[1], + g.mat[2], + g.pos[0], + g.mat[3], + g.mat[4], + g.mat[5], + g.pos[1], + g.mat[6], + g.mat[7], + g.mat[8], + g.pos[2], + 0, + 0, + 0, + 1, + ); + mesh.matrixWorldNeedsUpdate = true; + mesh.userData.geomId = geomId; + mesh.userData.bodyId = bodyId; + mesh.userData.geomType = g.type; + mesh.userData.isCollision = isCollision; + this.applyMeshHighlight(mesh); + } - private eventPointer(event:PointerEvent):void {const r=this.renderer.domElement.getBoundingClientRect();this.pointer.set((event.clientX-r.left)/r.width*2-1,-((event.clientY-r.top)/r.height)*2+1);} - private onPointerDown=(event:PointerEvent):void=>{if(event.button!==0)return;this.eventPointer(event);this.raycaster.setFromCamera(this.pointer,this.camera);const hit=this.raycaster.intersectObjects(this.meshes.filter(m=>m.visible),false)[0];if(!hit)return;const mesh=hit.object as THREE.Mesh;this.select(mesh);const bodyId=Number(mesh.userData.bodyId);if(this.mode==='joint'){const joint=this.session?.snapshot().joints.find(j=>j.bodyId===bodyId&&j.editable);if(joint){this.dragStart=this.pointer.clone();this.dragJointId=joint.id;this.dragJointType=joint.type;this.dragJointValue=joint.value;this.dragHitDistance=hit.distance;const offset=joint.id*3;this.dragJointPivot.set(Number(this.session!.data.xanchor[offset]),Number(this.session!.data.xanchor[offset+1]),Number(this.session!.data.xanchor[offset+2]));this.dragJointAxisWorld.set(Number(this.session!.data.xaxis[offset]),Number(this.session!.data.xaxis[offset+1]),Number(this.session!.data.xaxis[offset+2])).normalize();this.raycaster.ray.at(hit.distance,this.dragJointStartWorld);const plane=new THREE.Plane().setFromNormalAndCoplanarPoint(this.dragJointAxisWorld,this.dragJointPivot),projected=plane.projectPoint(this.dragJointStartWorld,new THREE.Vector3());this.dragJointStartPlaneVector.copy(projected).sub(this.dragJointPivot);this.dragSlideParameter=closestRayAxisParameter(this.raycaster.ray,this.dragJointPivot,this.dragJointAxisWorld);this.renderer.domElement.setPointerCapture(event.pointerId);}}else if(this.mode==='force'&&bodyId>0){this.dragStart=this.pointer.clone();this.dragHitDistance=hit.distance;this.dragForceOrigin.copy(hit.point);this.dragForcePlane.setFromNormalAndCoplanarPoint(this.camera.getWorldDirection(new THREE.Vector3()),hit.point);this.session?.initializePerturb(this.mjScene!,bodyId);this.showArrow(hit.point);this.renderer.domElement.setPointerCapture(event.pointerId);}}; - private onPointerMove=(event:PointerEvent):void=>{if(!this.dragStart||!this.session)return;this.eventPointer(event);this.raycaster.setFromCamera(this.pointer,this.camera);const delta=this.pointer.clone().sub(this.dragStart),rect=this.renderer.domElement.getBoundingClientRect(),aspect=rect.width/Math.max(1,rect.height),screenDelta=new THREE.Vector2(delta.x*aspect,delta.y);if(this.mode==='joint'&&this.dragJointId>=0){let value=this.dragJointValue,currentPlaneVector:THREE.Vector3|undefined,currentSlideParameter=Number.NaN;if(this.dragJointType===2){currentSlideParameter=closestRayAxisParameter(this.raycaster.ray,this.dragJointPivot,this.dragJointAxisWorld);if(Number.isFinite(currentSlideParameter)&&Number.isFinite(this.dragSlideParameter))value+=currentSlideParameter-this.dragSlideParameter;}else if(this.dragJointType===3){const currentWorld=this.raycaster.ray.at(this.dragHitDistance,new THREE.Vector3()),plane=new THREE.Plane().setFromNormalAndCoplanarPoint(this.dragJointAxisWorld,this.dragJointPivot);currentPlaneVector=plane.projectPoint(currentWorld,new THREE.Vector3()).sub(this.dragJointPivot);const worldDelta=signedAngleAroundAxis(this.dragJointStartPlaneVector,currentPlaneVector,this.dragJointAxisWorld),cameraForward=this.camera.getWorldDirection(new THREE.Vector3()),planeFacing=Math.abs(this.raycaster.ray.direction.dot(this.dragJointAxisWorld));const tangentWorld=cameraForward.clone().cross(this.dragJointAxisWorld).normalize(),a=this.dragJointPivot.clone().project(this.camera),b=this.dragJointPivot.clone().add(tangentWorld).project(this.camera),tangentScreen=new THREE.Vector2((b.x-a.x)*aspect,b.y-a.y);const tangentDelta=tangentScreen.lengthSq()>1e-10?screenDelta.dot(tangentScreen.normalize())*Math.PI:0;value+=resolveHingeDragDelta(worldDelta,tangentDelta,planeFacing);}if(this.session.setJointPosition(this.dragJointId,value)){this.dragJointValue=value;this.dragStart.copy(this.pointer);if(currentPlaneVector&¤tPlaneVector.lengthSq()>1e-12)this.dragJointStartPlaneVector.copy(currentPlaneVector);if(Number.isFinite(currentSlideParameter))this.dragSlideParameter=currentSlideParameter;}}else if(this.mode==='force'&&this.selected){const endpoint=this.raycaster.ray.intersectPlane(this.dragForcePlane,new THREE.Vector3());if(!endpoint)return;const dragVector=endpoint.sub(this.dragForceOrigin),vector=forceFromScreenDrag(dragVector,screenDelta.length(),this.forceScale),bodyId=Number(this.selected.userData.bodyId),force:[number,number,number]=[vector.x,vector.y,vector.z];this.session.setExternalForce(bodyId,force);this.updateArrow(dragVector);}}; - private onPointerUp=():void=>{this.stopDrag();}; - private stopDrag():void {this.dragStart=null;this.dragJointId=-1;this.dragJointType=-1;this.dragHitDistance=0;this.dragSlideParameter=0;this.session?.clearExternalForce();if(this.arrow){this.scene.remove(this.arrow);this.arrow.dispose();this.arrow=null;}} - private select(mesh:THREE.Mesh):void {const previous=this.selected;this.selected=mesh;if(previous)this.applyMeshHighlight(previous);this.applyMeshHighlight(mesh);const bodyId=Number(mesh.userData.bodyId),geomId=Number(mesh.userData.geomId);const name=this.session?.snapshot().bodies.find(b=>b.id===bodyId)?.name??`body_${bodyId}`;const e=mesh.matrix.elements;this.callbacks.onSelection({bodyId,geomId,bodyName:name,geomType:Number(mesh.userData.geomType),position:[e[12],e[13],e[14]]});} - private showArrow(origin:THREE.Vector3):void {this.arrow=new THREE.ArrowHelper(new THREE.Vector3(1,0,0),origin,0.01,0xf97316);this.arrow.visible=false;this.scene.add(this.arrow);} - private updateArrow(dragVector:THREE.Vector3):void {if(!this.arrow)return;const length=dragVector.length();this.arrow.visible=length>1e-6;if(!this.arrow.visible)return;const maxHeadLength=Math.max(.015,this.modelExtent*.06),headLength=Math.min(maxHeadLength,length*.25),headWidth=Math.min(maxHeadLength*.55,length*.14);this.arrow.setDirection(dragVector.clone().normalize());this.arrow.setLength(length,headLength,headWidth);} - private disposeMesh(mesh:THREE.Mesh):void {(mesh.material as THREE.Material).dispose();if(mesh.userData.ownsGeometry)mesh.geometry.dispose();} - private releaseModel():void {this.stopDrag();for(const mesh of this.meshes){this.scene.remove(mesh);this.disposeMesh(mesh);}this.meshes=[];for(const g of this.geometries.values())g.dispose();this.geometries.clear();for(const t of this.textures.values())t.dispose();this.textures.clear();this.mjScene?.delete();this.mjCamera?.delete();this.option?.delete();this.mjScene=null;this.mjCamera=null;this.option=null;this.session=null;this.sensorCameraId=-1;this.modelExtent=2;this.modelHasVisuals=false;this.visualizationHelpers.attach(null,this.modelExtent);this.selected=null;this.highlightedJointId=-1;this.highlightedBodyId=-1;if(this.jointMarker){this.scene.remove(this.jointMarker);this.jointMarker.geometry.dispose();(this.jointMarker.material as THREE.Material).dispose();this.jointMarker=null;}} - dispose():void {cancelAnimationFrame(this.frame);this.releaseModel();this.resizeObserver.disconnect();this.renderer.domElement.removeEventListener('pointerdown',this.onPointerDown);this.renderer.domElement.removeEventListener('pointermove',this.onPointerMove);this.renderer.domElement.removeEventListener('lostpointercapture',this.onPointerUp);window.removeEventListener('pointerup',this.onPointerUp);window.removeEventListener('pointercancel',this.onPointerUp);window.removeEventListener('blur',this.onPointerUp);this.controls.dispose();this.orientationGizmo.dispose();this.visualizationHelpers.dispose();this.grid.geometry.dispose();const gridMaterials=Array.isArray(this.grid.material)?this.grid.material:[this.grid.material];for(const material of gridMaterials)material.dispose();this.renderer.dispose();this.renderer.domElement.remove();} + private eventPointer(event: PointerEvent): void { + const r = this.renderer.domElement.getBoundingClientRect(); + this.pointer.set( + ((event.clientX - r.left) / r.width) * 2 - 1, + -((event.clientY - r.top) / r.height) * 2 + 1, + ); + } + private onPointerDown = (event: PointerEvent): void => { + if (event.button !== 0) return; + if ( + this.mapEditorLayer.handlePointerDown(event, this.renderer.domElement.getBoundingClientRect()) + ) + return; + this.eventPointer(event); + this.raycaster.setFromCamera(this.pointer, this.camera); + const hit = this.raycaster.intersectObjects( + this.meshes.filter((m) => m.visible), + false, + )[0]; + if (!hit) return; + const mesh = hit.object as THREE.Mesh; + this.select(mesh); + const bodyId = Number(mesh.userData.bodyId); + if (this.mode === 'joint') { + const joint = this.session?.snapshot().joints.find((j) => j.bodyId === bodyId && j.editable); + if (joint) { + this.dragStart = this.pointer.clone(); + this.dragJointId = joint.id; + this.dragJointType = joint.type; + this.dragJointValue = joint.value; + this.dragHitDistance = hit.distance; + const offset = joint.id * 3; + this.dragJointPivot.set( + Number(this.session!.data.xanchor[offset]), + Number(this.session!.data.xanchor[offset + 1]), + Number(this.session!.data.xanchor[offset + 2]), + ); + this.dragJointAxisWorld + .set( + Number(this.session!.data.xaxis[offset]), + Number(this.session!.data.xaxis[offset + 1]), + Number(this.session!.data.xaxis[offset + 2]), + ) + .normalize(); + this.raycaster.ray.at(hit.distance, this.dragJointStartWorld); + const plane = new THREE.Plane().setFromNormalAndCoplanarPoint( + this.dragJointAxisWorld, + this.dragJointPivot, + ), + projected = plane.projectPoint(this.dragJointStartWorld, new THREE.Vector3()); + this.dragJointStartPlaneVector.copy(projected).sub(this.dragJointPivot); + this.dragSlideParameter = closestRayAxisParameter( + this.raycaster.ray, + this.dragJointPivot, + this.dragJointAxisWorld, + ); + this.renderer.domElement.setPointerCapture(event.pointerId); + } + } else if (this.mode === 'force' && bodyId > 0) { + this.dragStart = this.pointer.clone(); + this.dragHitDistance = hit.distance; + this.dragForceOrigin.copy(hit.point); + this.dragForcePlane.setFromNormalAndCoplanarPoint( + this.camera.getWorldDirection(new THREE.Vector3()), + hit.point, + ); + this.session?.initializePerturb(this.mjScene!, bodyId); + this.showArrow(hit.point); + this.renderer.domElement.setPointerCapture(event.pointerId); + } + }; + private onPointerMove = (event: PointerEvent): void => { + if (!this.dragStart || !this.session) return; + this.eventPointer(event); + this.raycaster.setFromCamera(this.pointer, this.camera); + const delta = this.pointer.clone().sub(this.dragStart), + rect = this.renderer.domElement.getBoundingClientRect(), + aspect = rect.width / Math.max(1, rect.height), + screenDelta = new THREE.Vector2(delta.x * aspect, delta.y); + if (this.mode === 'joint' && this.dragJointId >= 0) { + let value = this.dragJointValue, + currentPlaneVector: THREE.Vector3 | undefined, + currentSlideParameter = Number.NaN; + if (this.dragJointType === 2) { + currentSlideParameter = closestRayAxisParameter( + this.raycaster.ray, + this.dragJointPivot, + this.dragJointAxisWorld, + ); + if (Number.isFinite(currentSlideParameter) && Number.isFinite(this.dragSlideParameter)) + value += currentSlideParameter - this.dragSlideParameter; + } else if (this.dragJointType === 3) { + const currentWorld = this.raycaster.ray.at(this.dragHitDistance, new THREE.Vector3()), + plane = new THREE.Plane().setFromNormalAndCoplanarPoint( + this.dragJointAxisWorld, + this.dragJointPivot, + ); + currentPlaneVector = plane + .projectPoint(currentWorld, new THREE.Vector3()) + .sub(this.dragJointPivot); + const worldDelta = signedAngleAroundAxis( + this.dragJointStartPlaneVector, + currentPlaneVector, + this.dragJointAxisWorld, + ), + cameraForward = this.camera.getWorldDirection(new THREE.Vector3()), + planeFacing = Math.abs(this.raycaster.ray.direction.dot(this.dragJointAxisWorld)); + const tangentWorld = cameraForward.clone().cross(this.dragJointAxisWorld).normalize(), + a = this.dragJointPivot.clone().project(this.camera), + b = this.dragJointPivot.clone().add(tangentWorld).project(this.camera), + tangentScreen = new THREE.Vector2((b.x - a.x) * aspect, b.y - a.y); + const tangentDelta = + tangentScreen.lengthSq() > 1e-10 + ? screenDelta.dot(tangentScreen.normalize()) * Math.PI + : 0; + value += resolveHingeDragDelta(worldDelta, tangentDelta, planeFacing); + } + if (this.session.setJointPosition(this.dragJointId, value)) { + this.snapshotDirty = true; + this.dragJointValue = value; + this.dragStart.copy(this.pointer); + if (currentPlaneVector && currentPlaneVector.lengthSq() > 1e-12) + this.dragJointStartPlaneVector.copy(currentPlaneVector); + if (Number.isFinite(currentSlideParameter)) this.dragSlideParameter = currentSlideParameter; + } + } else if (this.mode === 'force' && this.selected) { + const endpoint = this.raycaster.ray.intersectPlane(this.dragForcePlane, new THREE.Vector3()); + if (!endpoint) return; + const dragVector = endpoint.sub(this.dragForceOrigin), + vector = forceFromScreenDrag(dragVector, screenDelta.length(), this.forceScale), + bodyId = Number(this.selected.userData.bodyId), + force: [number, number, number] = [vector.x, vector.y, vector.z]; + this.session.setExternalForce(bodyId, force); + this.updateArrow(dragVector); + } + }; + private onPointerUp = (): void => { + this.stopDrag(); + }; + private stopDrag(): void { + this.dragStart = null; + this.dragJointId = -1; + this.dragJointType = -1; + this.dragHitDistance = 0; + this.dragSlideParameter = 0; + this.session?.clearExternalForce(); + if (this.arrow) { + this.scene.remove(this.arrow); + this.arrow.dispose(); + this.arrow = null; + } + } + private select(mesh: THREE.Mesh): void { + const previous = this.selected; + this.selected = mesh; + if (previous) this.applyMeshHighlight(previous); + this.applyMeshHighlight(mesh); + const bodyId = Number(mesh.userData.bodyId), + geomId = Number(mesh.userData.geomId); + const name = + this.session?.snapshot().bodies.find((b) => b.id === bodyId)?.name ?? `body_${bodyId}`; + const e = mesh.matrix.elements; + this.callbacks.onSelection({ + bodyId, + geomId, + bodyName: name, + geomType: Number(mesh.userData.geomType), + position: [e[12], e[13], e[14]], + }); + } + private showArrow(origin: THREE.Vector3): void { + this.arrow = new THREE.ArrowHelper(new THREE.Vector3(1, 0, 0), origin, 0.01, 0xf97316); + this.arrow.visible = false; + this.scene.add(this.arrow); + } + private updateArrow(dragVector: THREE.Vector3): void { + if (!this.arrow) return; + const length = dragVector.length(); + this.arrow.visible = length > 1e-6; + if (!this.arrow.visible) return; + const maxHeadLength = Math.max(0.015, this.modelExtent * 0.06), + headLength = Math.min(maxHeadLength, length * 0.25), + headWidth = Math.min(maxHeadLength * 0.55, length * 0.14); + this.arrow.setDirection(dragVector.clone().normalize()); + this.arrow.setLength(length, headLength, headWidth); + } + private disposeMesh(mesh: THREE.Mesh): void { + (mesh.material as THREE.Material).dispose(); + if (mesh.userData.ownsGeometry) mesh.geometry.dispose(); + } + private releaseModel(): void { + this.stopDrag(); + for (const mesh of this.meshes) { + this.scene.remove(mesh); + this.disposeMesh(mesh); + } + this.meshes = []; + for (const g of this.geometries.values()) g.dispose(); + this.geometries.clear(); + for (const t of this.textures.values()) t.dispose(); + this.textures.clear(); + this.mjScene?.delete(); + this.mjCamera?.delete(); + this.option?.delete(); + this.mjScene = null; + this.mjCamera = null; + this.option = null; + this.session = null; + this.sensorCameraId = -1; + this.modelExtent = 2; + this.modelHasVisuals = false; + this.visualizationHelpers.attach(null, this.modelExtent); + this.selected = null; + this.highlightedJointId = -1; + this.highlightedBodyId = -1; + if (this.jointMarker) { + this.scene.remove(this.jointMarker); + this.jointMarker.geometry.dispose(); + (this.jointMarker.material as THREE.Material).dispose(); + this.jointMarker = null; + } + } + dispose(): void { + cancelAnimationFrame(this.frame); + this.releaseModel(); + this.resizeObserver.disconnect(); + this.renderer.domElement.removeEventListener('pointerdown', this.onPointerDown); + this.renderer.domElement.removeEventListener('pointermove', this.onPointerMove); + this.renderer.domElement.removeEventListener('lostpointercapture', this.onPointerUp); + window.removeEventListener('pointerup', this.onPointerUp); + window.removeEventListener('pointercancel', this.onPointerUp); + window.removeEventListener('blur', this.onPointerUp); + this.controls.dispose(); + this.orientationGizmo.dispose(); + this.visualizationHelpers.dispose(); + this.visualMapLayer.dispose(); + this.mapEditorLayer.dispose(); + this.grid.geometry.dispose(); + const gridMaterials = Array.isArray(this.grid.material) + ? this.grid.material + : [this.grid.material]; + for (const material of gridMaterials) material.dispose(); + this.renderer.dispose(); + this.renderer.domElement.remove(); + } } diff --git a/web_platform/src/viewer/OrientationGizmo.ts b/web_platform/src/viewer/OrientationGizmo.ts index 83dd89e2..661b11d7 100644 --- a/web_platform/src/viewer/OrientationGizmo.ts +++ b/web_platform/src/viewer/OrientationGizmo.ts @@ -1,45 +1,114 @@ import * as THREE from 'three'; -const SVG_NS='http://www.w3.org/2000/svg'; -interface AxisElements {axis:THREE.Vector3;line:SVGLineElement;negative:SVGCircleElement;positive:SVGCircleElement;label:SVGTextElement;} +const SVG_NS = 'http://www.w3.org/2000/svg'; +interface AxisElements { + axis: THREE.Vector3; + line: SVGLineElement; + negative: SVGCircleElement; + positive: SVGCircleElement; + label: SVGTextElement; +} -function svgElement(name:K):SVGElementTagNameMap[K]{return document.createElementNS(SVG_NS,name);} +function svgElement(name: K): SVGElementTagNameMap[K] { + return document.createElementNS(SVG_NS, name); +} /** 固定在视口右下角、随相机旋转的 XYZ 方向示意器。 */ export class OrientationGizmo { - readonly element:SVGSVGElement; - private readonly axes:AxisElements[]=[]; - private readonly inverseQuaternion=new THREE.Quaternion(); - private readonly projected=new THREE.Vector3(); - private readonly center:SVGCircleElement; + readonly element: SVGSVGElement; + private readonly axes: AxisElements[] = []; + private readonly inverseQuaternion = new THREE.Quaternion(); + private readonly projected = new THREE.Vector3(); + private readonly center: SVGCircleElement; - constructor(host:HTMLElement){ - this.element=svgElement('svg');this.element.setAttribute('viewBox','0 0 100 100');this.element.setAttribute('role','img');this.element.setAttribute('aria-label','XYZ 方向指示器'); - Object.assign(this.element.style,{position:'absolute',right:'14px',bottom:'14px',width:'92px',height:'92px',pointerEvents:'none',border:'1px solid rgba(100,116,139,.45)',borderRadius:'10px',background:'rgba(15,23,42,.72)',backdropFilter:'blur(3px)'}); - const definitions:[string,number,THREE.Vector3][]=[['X',0xef4444,new THREE.Vector3(1,0,0)],['Y',0x22c55e,new THREE.Vector3(0,1,0)],['Z',0x3b82f6,new THREE.Vector3(0,0,1)]]; - for(const [name,colorValue,axis] of definitions){ - const color=`#${colorValue.toString(16).padStart(6,'0')}`;const group=svgElement('g');const line=svgElement('line');line.setAttribute('stroke',color);line.setAttribute('stroke-width','2.5');line.setAttribute('stroke-linecap','round'); - const negative=svgElement('circle');negative.setAttribute('r','4');negative.setAttribute('fill',color);negative.setAttribute('fill-opacity','.7'); - const positive=svgElement('circle');positive.setAttribute('r','8');positive.setAttribute('fill',color); - const label=svgElement('text');label.textContent=name;label.setAttribute('fill','#0f172a');label.setAttribute('font-size','10');label.setAttribute('font-weight','700');label.setAttribute('text-anchor','middle');label.setAttribute('dominant-baseline','central'); - group.append(line,negative,positive,label);this.element.append(group);this.axes.push({axis,line,negative,positive,label}); + constructor(host: HTMLElement) { + this.element = svgElement('svg'); + this.element.setAttribute('viewBox', '0 0 100 100'); + this.element.setAttribute('role', 'img'); + this.element.setAttribute('aria-label', 'XYZ 方向指示器'); + Object.assign(this.element.style, { + position: 'absolute', + right: '14px', + bottom: '14px', + width: '92px', + height: '92px', + pointerEvents: 'none', + border: '1px solid rgba(100,116,139,.45)', + borderRadius: '10px', + background: 'rgba(15,23,42,.72)', + backdropFilter: 'blur(3px)', + }); + const definitions: [string, number, THREE.Vector3][] = [ + ['X', 0xef4444, new THREE.Vector3(1, 0, 0)], + ['Y', 0x22c55e, new THREE.Vector3(0, 1, 0)], + ['Z', 0x3b82f6, new THREE.Vector3(0, 0, 1)], + ]; + for (const [name, colorValue, axis] of definitions) { + const color = `#${colorValue.toString(16).padStart(6, '0')}`; + const group = svgElement('g'); + const line = svgElement('line'); + line.setAttribute('stroke', color); + line.setAttribute('stroke-width', '2.5'); + line.setAttribute('stroke-linecap', 'round'); + const negative = svgElement('circle'); + negative.setAttribute('r', '4'); + negative.setAttribute('fill', color); + negative.setAttribute('fill-opacity', '.7'); + const positive = svgElement('circle'); + positive.setAttribute('r', '8'); + positive.setAttribute('fill', color); + const label = svgElement('text'); + label.textContent = name; + label.setAttribute('fill', '#0f172a'); + label.setAttribute('font-size', '10'); + label.setAttribute('font-weight', '700'); + label.setAttribute('text-anchor', 'middle'); + label.setAttribute('dominant-baseline', 'central'); + group.append(line, negative, positive, label); + this.element.append(group); + this.axes.push({ axis, line, negative, positive, label }); } - this.center=svgElement('circle');this.center.setAttribute('cx','50');this.center.setAttribute('cy','50');this.center.setAttribute('r','3.5');this.center.setAttribute('fill','#cbd5e1');this.element.append(this.center);host.append(this.element); + this.center = svgElement('circle'); + this.center.setAttribute('cx', '50'); + this.center.setAttribute('cy', '50'); + this.center.setAttribute('r', '3.5'); + this.center.setAttribute('fill', '#cbd5e1'); + this.element.append(this.center); + host.append(this.element); } - update(camera:THREE.Camera):void { + update(camera: THREE.Camera): void { this.inverseQuaternion.copy(camera.quaternion).invert(); - for(const item of this.axes){ - this.projected.copy(item.axis).applyQuaternion(this.inverseQuaternion);const x=50+this.projected.x*30,y=50-this.projected.y*30;const nx=50-this.projected.x*30,ny=50+this.projected.y*30; - item.line.setAttribute('x1',String(nx));item.line.setAttribute('y1',String(ny));item.line.setAttribute('x2',String(x));item.line.setAttribute('y2',String(y)); - item.negative.setAttribute('cx',String(nx));item.negative.setAttribute('cy',String(ny));item.positive.setAttribute('cx',String(x));item.positive.setAttribute('cy',String(y));item.label.setAttribute('x',String(x));item.label.setAttribute('y',String(y+.5)); - const opacity=String(.65+.35*Math.max(0,this.projected.z));item.positive.setAttribute('fill-opacity',opacity);item.label.setAttribute('fill-opacity',opacity); + for (const item of this.axes) { + this.projected.copy(item.axis).applyQuaternion(this.inverseQuaternion); + const x = 50 + this.projected.x * 30, + y = 50 - this.projected.y * 30; + const nx = 50 - this.projected.x * 30, + ny = 50 + this.projected.y * 30; + item.line.setAttribute('x1', String(nx)); + item.line.setAttribute('y1', String(ny)); + item.line.setAttribute('x2', String(x)); + item.line.setAttribute('y2', String(y)); + item.negative.setAttribute('cx', String(nx)); + item.negative.setAttribute('cy', String(ny)); + item.positive.setAttribute('cx', String(x)); + item.positive.setAttribute('cy', String(y)); + item.label.setAttribute('x', String(x)); + item.label.setAttribute('y', String(y + 0.5)); + const opacity = String(0.65 + 0.35 * Math.max(0, this.projected.z)); + item.positive.setAttribute('fill-opacity', opacity); + item.label.setAttribute('fill-opacity', opacity); } } - setTheme(theme:'light'|'dark'):void { - const light=theme==='light';this.element.style.background=light?'rgba(248,250,252,.82)':'rgba(15,23,42,.72)';this.element.style.borderColor=light?'rgba(100,116,139,.35)':'rgba(100,116,139,.45)';this.center.setAttribute('fill',light?'#475569':'#cbd5e1'); + setTheme(theme: 'light' | 'dark'): void { + const light = theme === 'light'; + this.element.style.background = light ? 'rgba(248,250,252,.82)' : 'rgba(15,23,42,.72)'; + this.element.style.borderColor = light ? 'rgba(100,116,139,.35)' : 'rgba(100,116,139,.45)'; + this.center.setAttribute('fill', light ? '#475569' : '#cbd5e1'); } - dispose():void {this.element.remove();} + dispose(): void { + this.element.remove(); + } } diff --git a/web_platform/src/viewer/ViewerVisualizationHelpers.ts b/web_platform/src/viewer/ViewerVisualizationHelpers.ts index f8aa5c87..ddcde936 100644 --- a/web_platform/src/viewer/ViewerVisualizationHelpers.ts +++ b/web_platform/src/viewer/ViewerVisualizationHelpers.ts @@ -1,83 +1,257 @@ import * as THREE from 'three'; -import type {SimulationSession} from '../simulation/SimulationSession'; -import {DEFAULT_VIEWER_DISPLAY_OPTIONS,type ViewerDisplayOptions} from './displayOptions'; -import {inertiaBoxDimensions} from './visualizationMath'; +import type { SimulationSession } from '../simulation/SimulationSession'; +import { DEFAULT_VIEWER_DISPLAY_OPTIONS, type ViewerDisplayOptions } from './displayOptions'; +import { inertiaBoxDimensions } from './visualizationMath'; /** 独立于 MuJoCo 场景几何的机器人结构辅助可视化。 */ export class ViewerVisualizationHelpers { - private readonly root=new THREE.Group(); - private readonly frames=new THREE.Group(); - private readonly jointAxes=new THREE.Group(); - private readonly centersOfMass=new THREE.Group(); - private readonly inertiaBoxes=new THREE.Group(); - private session:SimulationSession|null=null; - private options:ViewerDisplayOptions={...DEFAULT_VIEWER_DISPLAY_OPTIONS}; - private frameHelpers:THREE.AxesHelper[]=[]; - private jointHelpers=new Map(); - private comHelpers=new Map(); - private inertiaHelpers=new Map(); - private readonly jointAxisVector=new THREE.Vector3(); - private readonly inertiaRotation=new THREE.Matrix4(); + private readonly root = new THREE.Group(); + private readonly frames = new THREE.Group(); + private readonly jointAxes = new THREE.Group(); + private readonly centersOfMass = new THREE.Group(); + private readonly inertiaBoxes = new THREE.Group(); + private session: SimulationSession | null = null; + private options: ViewerDisplayOptions = { ...DEFAULT_VIEWER_DISPLAY_OPTIONS }; + private frameHelpers: THREE.AxesHelper[] = []; + private jointHelpers = new Map(); + private comHelpers = new Map(); + private inertiaHelpers = new Map(); + private readonly jointAxisVector = new THREE.Vector3(); + private readonly inertiaRotation = new THREE.Matrix4(); - constructor(private readonly scene:THREE.Scene){ - this.root.name='viewer-visualization-helpers'; - this.frames.name='body-coordinate-frames'; - this.jointAxes.name='joint-axes'; - this.centersOfMass.name='centers-of-mass'; - this.inertiaBoxes.name='inertia-boxes'; - this.root.add(this.frames,this.jointAxes,this.centersOfMass,this.inertiaBoxes); + constructor(private readonly scene: THREE.Scene) { + this.root.name = 'viewer-visualization-helpers'; + this.frames.name = 'body-coordinate-frames'; + this.jointAxes.name = 'joint-axes'; + this.centersOfMass.name = 'centers-of-mass'; + this.inertiaBoxes.name = 'inertia-boxes'; + this.root.add(this.frames, this.jointAxes, this.centersOfMass, this.inertiaBoxes); scene.add(this.root); this.applyVisibility(); } - attach(session:SimulationSession|null,modelExtent:number):void { + attach(session: SimulationSession | null, modelExtent: number): void { this.clear(); - this.session=session; - if(!session)return; - const extent=Math.max(modelExtent,.01),frameSize=Math.max(.04,extent*.1),jointLength=Math.max(.06,extent*.14),comRadius=Math.max(.006,extent*.012); - for(let bodyId=0;bodyId0))continue; - const com=new THREE.Mesh(new THREE.SphereGeometry(comRadius,16,12),new THREE.MeshBasicMaterial({color:0xfacc15,depthTest:false,transparent:true,opacity:.95}));com.name=`center-of-mass-${bodyId}`;com.renderOrder=92;this.centersOfMass.add(com);this.comHelpers.set(bodyId,com); - const offset=bodyId*3,dimensions=inertiaBoxDimensions(Number(session.model.body_mass[bodyId]),[Number(session.model.body_inertia[offset]),Number(session.model.body_inertia[offset+1]),Number(session.model.body_inertia[offset+2])]); - if(dimensions){const minimum=Math.max(.004,extent*.004),maximum=Math.max(.05,extent*1.5),size=new THREE.Vector3(...dimensions).clampScalar(minimum,maximum),group=this.createInertiaBox(size);group.name=`inertia-box-${bodyId}`;this.inertiaBoxes.add(group);this.inertiaHelpers.set(bodyId,group);} + this.session = session; + if (!session) return; + const extent = Math.max(modelExtent, 0.01), + frameSize = Math.max(0.04, extent * 0.1), + jointLength = Math.max(0.06, extent * 0.14), + comRadius = Math.max(0.006, extent * 0.012); + for (let bodyId = 0; bodyId < session.model.nbody; bodyId += 1) { + const axes = new THREE.AxesHelper(frameSize); + axes.name = `body-frame-${bodyId}`; + axes.renderOrder = 80; + this.frames.add(axes); + this.frameHelpers[bodyId] = axes; + if (bodyId === 0 || !(Number(session.model.body_mass[bodyId]) > 0)) continue; + const com = new THREE.Mesh( + new THREE.SphereGeometry(comRadius, 16, 12), + new THREE.MeshBasicMaterial({ + color: 0xfacc15, + depthTest: false, + transparent: true, + opacity: 0.95, + }), + ); + com.name = `center-of-mass-${bodyId}`; + com.renderOrder = 92; + this.centersOfMass.add(com); + this.comHelpers.set(bodyId, com); + const offset = bodyId * 3, + dimensions = inertiaBoxDimensions(Number(session.model.body_mass[bodyId]), [ + Number(session.model.body_inertia[offset]), + Number(session.model.body_inertia[offset + 1]), + Number(session.model.body_inertia[offset + 2]), + ]); + if (dimensions) { + const minimum = Math.max(0.004, extent * 0.004), + maximum = Math.max(0.05, extent * 1.5), + size = new THREE.Vector3(...dimensions).clampScalar(minimum, maximum), + group = this.createInertiaBox(size); + group.name = `inertia-box-${bodyId}`; + this.inertiaBoxes.add(group); + this.inertiaHelpers.set(bodyId, group); + } } - for(let jointId=0;jointId1e-12)arrow.setDirection(axis.normalize());arrow.position.set(Number(data.xanchor[offset]),Number(data.xanchor[offset+1]),Number(data.xanchor[offset+2]));} - if(this.centersOfMass.visible)for(const [bodyId,com] of this.comHelpers){const offset=bodyId*3;com.position.set(Number(data.xipos[offset]),Number(data.xipos[offset+1]),Number(data.xipos[offset+2]));} - if(this.inertiaBoxes.visible)for(const [bodyId,box] of this.inertiaHelpers){const positionOffset=bodyId*3,matrixOffset=bodyId*9,rotation=this.inertiaRotation.set(Number(data.ximat[matrixOffset]),Number(data.ximat[matrixOffset+1]),Number(data.ximat[matrixOffset+2]),0,Number(data.ximat[matrixOffset+3]),Number(data.ximat[matrixOffset+4]),Number(data.ximat[matrixOffset+5]),0,Number(data.ximat[matrixOffset+6]),Number(data.ximat[matrixOffset+7]),Number(data.ximat[matrixOffset+8]),0,0,0,0,1);box.position.set(Number(data.xipos[positionOffset]),Number(data.xipos[positionOffset+1]),Number(data.xipos[positionOffset+2]));box.quaternion.setFromRotationMatrix(rotation);} + setOptions(options: ViewerDisplayOptions): void { + this.options = { ...options }; + this.applyVisibility(); } - dispose():void {this.clear();this.scene.remove(this.root);} - - private createInertiaBox(size:THREE.Vector3):THREE.Group { - const group=new THREE.Group(),geometry=new THREE.BoxGeometry(size.x,size.y,size.z),fill=new THREE.MeshBasicMaterial({color:0x22d3ee,transparent:true,opacity:.18,depthWrite:false}),edgeMaterial=new THREE.LineBasicMaterial({color:0x22d3ee,transparent:true,opacity:.8,depthWrite:false}); - const mesh=new THREE.Mesh(geometry,fill),edges=new THREE.LineSegments(new THREE.EdgesGeometry(geometry),edgeMaterial);mesh.renderOrder=70;edges.renderOrder=71;group.add(mesh,edges);return group; + update(): void { + const session = this.session; + if (!session) return; + const { data } = session; + if (this.frames.visible) + for (let bodyId = 0; bodyId < this.frameHelpers.length; bodyId += 1) { + const helper = this.frameHelpers[bodyId]; + if (helper) this.setWorldMatrix(helper, data.xmat, data.xpos, bodyId); + } + if (this.jointAxes.visible) + for (const [jointId, arrow] of this.jointHelpers) { + const offset = jointId * 3, + axis = this.jointAxisVector.set( + Number(data.xaxis[offset]), + Number(data.xaxis[offset + 1]), + Number(data.xaxis[offset + 2]), + ); + if (axis.lengthSq() > 1e-12) arrow.setDirection(axis.normalize()); + arrow.position.set( + Number(data.xanchor[offset]), + Number(data.xanchor[offset + 1]), + Number(data.xanchor[offset + 2]), + ); + } + if (this.centersOfMass.visible) + for (const [bodyId, com] of this.comHelpers) { + const offset = bodyId * 3; + com.position.set( + Number(data.xipos[offset]), + Number(data.xipos[offset + 1]), + Number(data.xipos[offset + 2]), + ); + } + if (this.inertiaBoxes.visible) + for (const [bodyId, box] of this.inertiaHelpers) { + const positionOffset = bodyId * 3, + matrixOffset = bodyId * 9, + rotation = this.inertiaRotation.set( + Number(data.ximat[matrixOffset]), + Number(data.ximat[matrixOffset + 1]), + Number(data.ximat[matrixOffset + 2]), + 0, + Number(data.ximat[matrixOffset + 3]), + Number(data.ximat[matrixOffset + 4]), + Number(data.ximat[matrixOffset + 5]), + 0, + Number(data.ximat[matrixOffset + 6]), + Number(data.ximat[matrixOffset + 7]), + Number(data.ximat[matrixOffset + 8]), + 0, + 0, + 0, + 0, + 1, + ); + box.position.set( + Number(data.xipos[positionOffset]), + Number(data.xipos[positionOffset + 1]), + Number(data.xipos[positionOffset + 2]), + ); + box.quaternion.setFromRotationMatrix(rotation); + } } - private setWorldMatrix(object:THREE.Object3D,rotations:ArrayLike,positions:ArrayLike,id:number):void { - const m=id*9,p=id*3;object.matrixAutoUpdate=false;object.matrix.set(Number(rotations[m]),Number(rotations[m+1]),Number(rotations[m+2]),Number(positions[p]),Number(rotations[m+3]),Number(rotations[m+4]),Number(rotations[m+5]),Number(positions[p+1]),Number(rotations[m+6]),Number(rotations[m+7]),Number(rotations[m+8]),Number(positions[p+2]),0,0,0,1);object.matrixWorldNeedsUpdate=true; + dispose(): void { + this.clear(); + this.scene.remove(this.root); } - private applyVisibility():void {this.frames.visible=this.options.showFrames;this.jointAxes.visible=this.options.showJointAxes;this.centersOfMass.visible=this.options.showCenterOfMass;this.inertiaBoxes.visible=this.options.showInertia;} + private createInertiaBox(size: THREE.Vector3): THREE.Group { + const group = new THREE.Group(), + geometry = new THREE.BoxGeometry(size.x, size.y, size.z), + fill = new THREE.MeshBasicMaterial({ + color: 0x22d3ee, + transparent: true, + opacity: 0.18, + depthWrite: false, + }), + edgeMaterial = new THREE.LineBasicMaterial({ + color: 0x22d3ee, + transparent: true, + opacity: 0.8, + depthWrite: false, + }); + const mesh = new THREE.Mesh(geometry, fill), + edges = new THREE.LineSegments(new THREE.EdgesGeometry(geometry), edgeMaterial); + mesh.renderOrder = 70; + edges.renderOrder = 71; + group.add(mesh, edges); + return group; + } - private clear():void { - const geometries=new Set(),materials=new Set(); - for(const group of [this.frames,this.jointAxes,this.centersOfMass,this.inertiaBoxes]){group.traverse(object=>{const candidate=object as THREE.Mesh|THREE.LineSegments;if(candidate.geometry)geometries.add(candidate.geometry);if(candidate.material){const values=Array.isArray(candidate.material)?candidate.material:[candidate.material];for(const material of values)materials.add(material);}});group.clear();} - for(const geometry of geometries)geometry.dispose();for(const material of materials)material.dispose(); - this.frameHelpers=[];this.jointHelpers.clear();this.comHelpers.clear();this.inertiaHelpers.clear();this.session=null; + private setWorldMatrix( + object: THREE.Object3D, + rotations: ArrayLike, + positions: ArrayLike, + id: number, + ): void { + const m = id * 9, + p = id * 3; + object.matrixAutoUpdate = false; + object.matrix.set( + Number(rotations[m]), + Number(rotations[m + 1]), + Number(rotations[m + 2]), + Number(positions[p]), + Number(rotations[m + 3]), + Number(rotations[m + 4]), + Number(rotations[m + 5]), + Number(positions[p + 1]), + Number(rotations[m + 6]), + Number(rotations[m + 7]), + Number(rotations[m + 8]), + Number(positions[p + 2]), + 0, + 0, + 0, + 1, + ); + object.matrixWorldNeedsUpdate = true; + } + + private applyVisibility(): void { + this.frames.visible = this.options.showFrames; + this.jointAxes.visible = this.options.showJointAxes; + this.centersOfMass.visible = this.options.showCenterOfMass; + this.inertiaBoxes.visible = this.options.showInertia; + } + + private clear(): void { + const geometries = new Set(), + materials = new Set(); + for (const group of [this.frames, this.jointAxes, this.centersOfMass, this.inertiaBoxes]) { + group.traverse((object) => { + const candidate = object as THREE.Mesh | THREE.LineSegments; + if (candidate.geometry) geometries.add(candidate.geometry); + if (candidate.material) { + const values = Array.isArray(candidate.material) + ? candidate.material + : [candidate.material]; + for (const material of values) materials.add(material); + } + }); + group.clear(); + } + for (const geometry of geometries) geometry.dispose(); + for (const material of materials) material.dispose(); + this.frameHelpers = []; + this.jointHelpers.clear(); + this.comHelpers.clear(); + this.inertiaHelpers.clear(); + this.session = null; } } diff --git a/web_platform/src/viewer/VisualMapLayer.test.ts b/web_platform/src/viewer/VisualMapLayer.test.ts new file mode 100644 index 00000000..a9a73e7d --- /dev/null +++ b/web_platform/src/viewer/VisualMapLayer.test.ts @@ -0,0 +1,72 @@ +import * as THREE from 'three'; +import { VisualMapLayer, validateSelfContainedGlb } from './VisualMapLayer'; +import type { VisualMapAsset } from '../map/types'; + +function glb(jsonValue: unknown, declaredLengthOffset = 0): Uint8Array { + const json = new TextEncoder().encode(JSON.stringify(jsonValue)); + const paddedLength = Math.ceil(json.byteLength / 4) * 4; + const data = new Uint8Array(20 + paddedLength); + data.fill(0x20, 20); + data.set(json, 20); + const view = new DataView(data.buffer); + view.setUint32(0, 0x46546c67, true); + view.setUint32(4, 2, true); + view.setUint32(8, data.byteLength + declaredLengthOffset, true); + view.setUint32(12, paddedLength, true); + view.setUint32(16, 0x4e4f534a, true); + return data; +} + +function asset(data: Uint8Array): VisualMapAsset { + return { + id: 'test', + name: 'test', + path: 'scene.glb', + data, + castShadow: true, + receiveShadow: true, + }; +} + +describe('validateSelfContainedGlb', () => { + it('允许内嵌 data URI 和无 URI 的 GLB buffer', () => { + expect(() => + validateSelfContainedGlb( + asset(glb({ asset: { version: '2.0' }, buffers: [{}], images: [{ uri: 'DATA:abc' }] })), + ), + ).not.toThrow(); + }); + + it('拒绝空 URI、外部 URI 和错误总长度', () => { + expect(() => + validateSelfContainedGlb(asset(glb({ asset: { version: '2.0' }, buffers: [{ uri: '' }] }))), + ).toThrow('外部资源 URI'); + expect(() => + validateSelfContainedGlb( + asset(glb({ asset: { version: '2.0' }, images: [{ uri: 'texture.png' }] })), + ), + ).toThrow('外部资源 URI'); + expect(() => validateSelfContainedGlb(asset(glb({ asset: { version: '2.0' } }, 4)))).toThrow( + '总长度', + ); + }); + + it('清理共享 Three.js 资源并关闭 ImageBitmap', () => { + const scene = new THREE.Scene(); + const layer = new VisualMapLayer(scene); + const geometry = new THREE.BufferGeometry(); + const close = vi.fn(); + const texture = new THREE.Texture({ close } as unknown as TexImageSource); + const material = new THREE.MeshStandardMaterial({ map: texture }); + const geometryDispose = vi.spyOn(geometry, 'dispose'); + const materialDispose = vi.spyOn(material, 'dispose'); + const textureDispose = vi.spyOn(texture, 'dispose'); + layer.group.add(new THREE.Mesh(geometry, material), new THREE.Mesh(geometry, material)); + layer.clear(); + expect(geometryDispose).toHaveBeenCalledOnce(); + expect(materialDispose).toHaveBeenCalledOnce(); + expect(textureDispose).toHaveBeenCalledOnce(); + expect(close).toHaveBeenCalledOnce(); + layer.dispose(); + }); +}); diff --git a/web_platform/src/viewer/VisualMapLayer.ts b/web_platform/src/viewer/VisualMapLayer.ts new file mode 100644 index 00000000..13b58942 --- /dev/null +++ b/web_platform/src/viewer/VisualMapLayer.ts @@ -0,0 +1,142 @@ +import * as THREE from 'three'; +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; +import type { VisualMapAsset } from '../map/types'; + +function exactArrayBuffer(data: Uint8Array): ArrayBuffer { + return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer; +} + +export function validateSelfContainedGlb(asset: VisualMapAsset): void { + const data = asset.data; + if (data.byteLength < 20) throw new Error(`${asset.path} 不是有效的 GLB 文件`); + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + if (view.getUint32(0, true) !== 0x46546c67 || view.getUint32(4, true) !== 2) + throw new Error(`${asset.path} 必须是 GLB 2.0`); + if (view.getUint32(8, true) !== data.byteLength) + throw new Error(`${asset.path} 的 GLB 总长度字段与实际文件不一致`); + if (view.getUint32(12, true) + 20 > data.byteLength || view.getUint32(16, true) !== 0x4e4f534a) + throw new Error(`${asset.path} 缺少有效 JSON Chunk`); + for (let offset = 12; offset < data.byteLength;) { + if (offset + 8 > data.byteLength) throw new Error(`${asset.path} 包含截断的 GLB Chunk`); + const chunkLength = view.getUint32(offset, true); + offset += 8 + chunkLength; + if (offset > data.byteLength) throw new Error(`${asset.path} 包含越界的 GLB Chunk`); + } + let json: { buffers?: Array<{ uri?: string }>; images?: Array<{ uri?: string }> }; + try { + const length = view.getUint32(12, true); + const text = new TextDecoder().decode(data.subarray(20, 20 + length)).replace(/\0+$/g, ''); + json = JSON.parse(text) as typeof json; + } catch (error) { + throw new Error( + `GLB JSON 无法解析:${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + for (const resource of [...(json.buffers ?? []), ...(json.images ?? [])]) { + if (!Object.hasOwn(resource, 'uri')) continue; + if ( + typeof resource.uri !== 'string' || + !resource.uri.trim() || + !resource.uri.toLowerCase().startsWith('data:') + ) + throw new Error(`GLB 包含外部资源 URI,V2 仅支持自包含文件:${resource.uri ?? ''}`); + } +} + +export class VisualMapLayer { + readonly group = new THREE.Group(); + private generation = 0; + + constructor(scene: THREE.Scene) { + this.group.name = '__platform_visual_map__'; + scene.add(this.group); + } + + get loaded(): boolean { + return this.group.children.length > 0; + } + + set visible(value: boolean) { + this.group.visible = value; + } + + async load(asset: VisualMapAsset | null): Promise { + const generation = ++this.generation; + if (!asset) { + this.clear(); + return; + } + validateSelfContainedGlb(asset); + const gltf = await new GLTFLoader().parseAsync(exactArrayBuffer(asset.data), ''); + if (generation !== this.generation) { + this.disposeObjects([gltf.scene]); + return; + } + let triangles = 0; + gltf.scene.traverse((object) => { + if (!(object instanceof THREE.Mesh)) return; + triangles += + (object.geometry.index?.count ?? object.geometry.attributes.position?.count ?? 0) / 3; + }); + if (triangles > 3_000_000) { + this.disposeObjects([gltf.scene]); + throw new Error(`GLB 三角面数量超过 300 万限制:${Math.round(triangles).toLocaleString()}`); + } + if (triangles > 1_000_000) + console.warn( + `[MuJoCo] 视觉地图包含 ${Math.round(triangles).toLocaleString()} 个三角面,可能影响帧率`, + ); + gltf.scene.name = `__platform_visual_map_${asset.id}__`; + gltf.scene.traverse((object) => { + if (!(object instanceof THREE.Mesh)) return; + object.castShadow = asset.castShadow; + object.receiveShadow = asset.receiveShadow; + object.userData.platformVisualMap = true; + }); + this.clear(false); + this.group.add(gltf.scene); + } + + clear(invalidate = true): void { + if (invalidate) this.generation += 1; + const children = [...this.group.children]; + for (const child of children) this.group.remove(child); + this.disposeObjects(children); + } + + dispose(): void { + this.clear(); + this.group.removeFromParent(); + } + + private disposeObjects(roots: THREE.Object3D[]): void { + const geometries = new Set(); + const materials = new Set(); + const textures = new Set(); + for (const root of roots) + root.traverse((object) => { + if (!(object instanceof THREE.Mesh)) return; + geometries.add(object.geometry); + for (const material of Array.isArray(object.material) ? object.material : [object.material]) + materials.add(material); + }); + for (const material of materials) + for (const value of Object.values(material)) + if (value instanceof THREE.Texture) textures.add(value); + const closedImages = new Set(); + for (const geometry of geometries) geometry.dispose(); + for (const texture of textures) { + const source = texture.source.data; + const images = Array.isArray(source) ? source : [source]; + texture.dispose(); + for (const image of images) { + if (!image || typeof image !== 'object' || closedImages.has(image)) continue; + closedImages.add(image); + (image as { close?: () => void }).close?.(); + } + texture.source.data = null; + } + for (const material of materials) material.dispose(); + } +} diff --git a/web_platform/src/viewer/displayOptions.ts b/web_platform/src/viewer/displayOptions.ts index cf80edce..31f19166 100644 --- a/web_platform/src/viewer/displayOptions.ts +++ b/web_platform/src/viewer/displayOptions.ts @@ -1,19 +1,19 @@ export interface ViewerDisplayOptions { - showVisual:boolean; - showCollision:boolean; - showFrames:boolean; - showJointAxes:boolean; - showCenterOfMass:boolean; - showInertia:boolean; - showGrid:boolean; + showVisual: boolean; + showCollision: boolean; + showFrames: boolean; + showJointAxes: boolean; + showCenterOfMass: boolean; + showInertia: boolean; + showGrid: boolean; } -export const DEFAULT_VIEWER_DISPLAY_OPTIONS:ViewerDisplayOptions={ - showVisual:true, - showCollision:false, - showFrames:false, - showJointAxes:false, - showCenterOfMass:false, - showInertia:false, - showGrid:true, +export const DEFAULT_VIEWER_DISPLAY_OPTIONS: ViewerDisplayOptions = { + showVisual: true, + showCollision: false, + showFrames: false, + showJointAxes: false, + showCenterOfMass: false, + showInertia: false, + showGrid: true, }; diff --git a/web_platform/src/viewer/interactionMath.test.ts b/web_platform/src/viewer/interactionMath.test.ts index 6f599a93..895d8a48 100644 --- a/web_platform/src/viewer/interactionMath.test.ts +++ b/web_platform/src/viewer/interactionMath.test.ts @@ -1,31 +1,67 @@ import * as THREE from 'three'; -import {closestRayAxisParameter,forceFromScreenDrag,resolveHingeDragDelta,signedAngleAroundAxis} from './interactionMath'; +import { + closestRayAxisParameter, + forceFromScreenDrag, + resolveHingeDragDelta, + signedAngleAroundAxis, +} from './interactionMath'; -describe('interactionMath',()=>{ - it('按右手定则计算绕关节轴的旋转方向',()=>{ - expect(signedAngleAroundAxis(new THREE.Vector3(1,0,0),new THREE.Vector3(0,1,0),new THREE.Vector3(0,0,1))).toBeCloseTo(Math.PI/2); - expect(signedAngleAroundAxis(new THREE.Vector3(1,0,0),new THREE.Vector3(0,-1,0),new THREE.Vector3(0,0,1))).toBeCloseTo(-Math.PI/2); +describe('interactionMath', () => { + it('按右手定则计算绕关节轴的旋转方向', () => { + expect( + signedAngleAroundAxis( + new THREE.Vector3(1, 0, 0), + new THREE.Vector3(0, 1, 0), + new THREE.Vector3(0, 0, 1), + ), + ).toBeCloseTo(Math.PI / 2); + expect( + signedAngleAroundAxis( + new THREE.Vector3(1, 0, 0), + new THREE.Vector3(0, -1, 0), + new THREE.Vector3(0, 0, 1), + ), + ).toBeCloseTo(-Math.PI / 2); }); - it('侧视关节平面时采用切线方向',()=>{ - expect(resolveHingeDragDelta(-.4,.25,.05)).toBe(.25); - expect(resolveHingeDragDelta(-.4,.25,.8)).toBe(-.4); - expect(resolveHingeDragDelta(0,.25,.8)).toBe(.25); + it('侧视关节平面时采用切线方向', () => { + expect(resolveHingeDragDelta(-0.4, 0.25, 0.05)).toBe(0.25); + expect(resolveHingeDragDelta(-0.4, 0.25, 0.8)).toBe(-0.4); + expect(resolveHingeDragDelta(0, 0.25, 0.8)).toBe(0.25); }); - it('通过指针射线和关节轴最近点稳定求解 slide 位移',()=>{ - const axisOrigin=new THREE.Vector3(0,0,0),axis=new THREE.Vector3(1,0,0); - expect(closestRayAxisParameter(new THREE.Ray(new THREE.Vector3(2,0,3),new THREE.Vector3(0,0,-1)),axisOrigin,axis)).toBeCloseTo(2); - expect(closestRayAxisParameter(new THREE.Ray(new THREE.Vector3(-1,0,3),new THREE.Vector3(0,0,-1)),axisOrigin,axis)).toBeCloseTo(-1); - expect(closestRayAxisParameter(new THREE.Ray(new THREE.Vector3(),new THREE.Vector3(1,0,0)),axisOrigin,axis)).toBeNaN(); + it('通过指针射线和关节轴最近点稳定求解 slide 位移', () => { + const axisOrigin = new THREE.Vector3(0, 0, 0), + axis = new THREE.Vector3(1, 0, 0); + expect( + closestRayAxisParameter( + new THREE.Ray(new THREE.Vector3(2, 0, 3), new THREE.Vector3(0, 0, -1)), + axisOrigin, + axis, + ), + ).toBeCloseTo(2); + expect( + closestRayAxisParameter( + new THREE.Ray(new THREE.Vector3(-1, 0, 3), new THREE.Vector3(0, 0, -1)), + axisOrigin, + axis, + ), + ).toBeCloseTo(-1); + expect( + closestRayAxisParameter( + new THREE.Ray(new THREE.Vector3(), new THREE.Vector3(1, 0, 0)), + axisOrigin, + axis, + ), + ).toBeNaN(); }); - it('外力与屏幕拖动箭头同向,并由屏幕距离决定大小',()=>{ - const force=forceFromScreenDrag(new THREE.Vector3(3,4,0),.5,100); + it('外力与屏幕拖动箭头同向,并由屏幕距离决定大小', () => { + const force = forceFromScreenDrag(new THREE.Vector3(3, 4, 0), 0.5, 100); expect(force.length()).toBeCloseTo(50); - expect(force.x/force.length()).toBeCloseTo(.6); - expect(force.y/force.length()).toBeCloseTo(.8); + expect(force.x / force.length()).toBeCloseTo(0.6); + expect(force.y / force.length()).toBeCloseTo(0.8); expect(force.z).toBe(0); - expect(forceFromScreenDrag(new THREE.Vector3(),1,100).length()).toBe(0); + expect(forceFromScreenDrag(new THREE.Vector3(), 1, 100).length()).toBe(0); }); }); diff --git a/web_platform/src/viewer/interactionMath.ts b/web_platform/src/viewer/interactionMath.ts index 35333271..087b510d 100644 --- a/web_platform/src/viewer/interactionMath.ts +++ b/web_platform/src/viewer/interactionMath.ts @@ -1,29 +1,62 @@ import * as THREE from 'three'; /** 计算绕世界轴从 start 到 end 的有符号角度,遵循右手定则。 */ -export function signedAngleAroundAxis(start:THREE.Vector3,end:THREE.Vector3,axis:THREE.Vector3):number { - if(start.lengthSq()<=1e-12||end.lengthSq()<=1e-12||axis.lengthSq()<=1e-12)return Number.NaN; - const a=start.clone().normalize(),b=end.clone().normalize(),normal=axis.clone().normalize(); - return Math.atan2(normal.dot(a.clone().cross(b)),THREE.MathUtils.clamp(a.dot(b),-1,1)); +export function signedAngleAroundAxis( + start: THREE.Vector3, + end: THREE.Vector3, + axis: THREE.Vector3, +): number { + if (start.lengthSq() <= 1e-12 || end.lengthSq() <= 1e-12 || axis.lengthSq() <= 1e-12) + return Number.NaN; + const a = start.clone().normalize(), + b = end.clone().normalize(), + normal = axis.clone().normalize(); + return Math.atan2(normal.dot(a.clone().cross(b)), THREE.MathUtils.clamp(a.dot(b), -1, 1)); } /** 关节旋转平面接近侧视时,使用相机切线拖动,避免投影退化和方向跳变。 */ -export function resolveHingeDragDelta(worldDelta:number,tangentDelta:number,planeFacingRatio:number,threshold=.2):number { - const tangentValid=Number.isFinite(tangentDelta),worldValid=Number.isFinite(worldDelta)&&(Math.abs(worldDelta)>1e-8||!tangentValid||Math.abs(tangentDelta)<=1e-8); - if(planeFacingRatio 1e-8 || !tangentValid || Math.abs(tangentDelta) <= 1e-8); + if (planeFacingRatio < threshold && tangentValid) return tangentDelta; + if (worldValid) return worldDelta; + return tangentValid ? tangentDelta : 0; } /** 求指针射线和世界关节轴两条直线的最近点参数,参数单位与 slide qpos 一致。 */ -export function closestRayAxisParameter(ray:THREE.Ray,axisOrigin:THREE.Vector3,axisDirection:THREE.Vector3):number { - const direction=ray.direction.clone().normalize(),axis=axisDirection.clone().normalize();if(direction.lengthSq()<=1e-12||axis.lengthSq()<=1e-12)return Number.NaN; - const offset=ray.origin.clone().sub(axisOrigin),dot=direction.dot(axis),denominator=1-dot*dot;if(denominator<=1e-8)return Number.NaN; - const value=(axis.dot(offset)-dot*direction.dot(offset))/denominator;return Number.isFinite(value)?value:Number.NaN; +export function closestRayAxisParameter( + ray: THREE.Ray, + axisOrigin: THREE.Vector3, + axisDirection: THREE.Vector3, +): number { + const direction = ray.direction.clone().normalize(), + axis = axisDirection.clone().normalize(); + if (direction.lengthSq() <= 1e-12 || axis.lengthSq() <= 1e-12) return Number.NaN; + const offset = ray.origin.clone().sub(axisOrigin), + dot = direction.dot(axis), + denominator = 1 - dot * dot; + if (denominator <= 1e-8) return Number.NaN; + const value = (axis.dot(offset) - dot * direction.dot(offset)) / denominator; + return Number.isFinite(value) ? value : Number.NaN; } /** 将屏幕拖动映射为与可视箭头同向的外力;screenDistance 使用视口归一化距离。 */ -export function forceFromScreenDrag(dragVector:THREE.Vector3,screenDistance:number,scale:number):THREE.Vector3 { - if(dragVector.lengthSq()<=1e-12||!Number.isFinite(screenDistance)||!Number.isFinite(scale))return new THREE.Vector3(); - return dragVector.clone().normalize().multiplyScalar(Math.max(0,screenDistance)*Math.max(0,scale)); +export function forceFromScreenDrag( + dragVector: THREE.Vector3, + screenDistance: number, + scale: number, +): THREE.Vector3 { + if (dragVector.lengthSq() <= 1e-12 || !Number.isFinite(screenDistance) || !Number.isFinite(scale)) + return new THREE.Vector3(); + return dragVector + .clone() + .normalize() + .multiplyScalar(Math.max(0, screenDistance) * Math.max(0, scale)); } diff --git a/web_platform/src/viewer/texturePixels.test.ts b/web_platform/src/viewer/texturePixels.test.ts index dd2322e4..127a5567 100644 --- a/web_platform/src/viewer/texturePixels.test.ts +++ b/web_platform/src/viewer/texturePixels.test.ts @@ -1,17 +1,23 @@ -import {texturePixelsToRgba} from './texturePixels'; +import { texturePixelsToRgba } from './texturePixels'; -describe('texturePixelsToRgba',()=>{ - it('将 RGB 像素补齐为 WebGL 兼容的 RGBA',()=>{ - expect(Array.from(texturePixelsToRgba(new Uint8Array([1,2,3,4,5,6]),2,3))).toEqual([1,2,3,255,4,5,6,255]); +describe('texturePixelsToRgba', () => { + it('将 RGB 像素补齐为 WebGL 兼容的 RGBA', () => { + expect(Array.from(texturePixelsToRgba(new Uint8Array([1, 2, 3, 4, 5, 6]), 2, 3))).toEqual([ + 1, 2, 3, 255, 4, 5, 6, 255, + ]); }); - it('保留灰度透明度与 RGBA 数据',()=>{ - expect(Array.from(texturePixelsToRgba(new Uint8Array([12,34]),1,2))).toEqual([12,12,12,34]); - expect(Array.from(texturePixelsToRgba(new Uint8Array([1,2,3,4]),1,4))).toEqual([1,2,3,4]); + it('保留灰度透明度与 RGBA 数据', () => { + expect(Array.from(texturePixelsToRgba(new Uint8Array([12, 34]), 1, 2))).toEqual([ + 12, 12, 12, 34, + ]); + expect(Array.from(texturePixelsToRgba(new Uint8Array([1, 2, 3, 4]), 1, 4))).toEqual([ + 1, 2, 3, 4, + ]); }); - it('拒绝不完整数据和未知通道数',()=>{ - expect(()=>texturePixelsToRgba(new Uint8Array([1,2]),1,3)).toThrow('纹理像素数据不完整'); - expect(()=>texturePixelsToRgba(new Uint8Array(),0,5)).toThrow('不支持 5 通道纹理'); + it('拒绝不完整数据和未知通道数', () => { + expect(() => texturePixelsToRgba(new Uint8Array([1, 2]), 1, 3)).toThrow('纹理像素数据不完整'); + expect(() => texturePixelsToRgba(new Uint8Array(), 0, 5)).toThrow('不支持 5 通道纹理'); }); }); diff --git a/web_platform/src/viewer/texturePixels.ts b/web_platform/src/viewer/texturePixels.ts index 91219157..30fec408 100644 --- a/web_platform/src/viewer/texturePixels.ts +++ b/web_platform/src/viewer/texturePixels.ts @@ -1,14 +1,22 @@ -export function texturePixelsToRgba(source:Uint8Array,pixelCount:number,channels:number):Uint8Array{ - if(!Number.isInteger(pixelCount)||pixelCount<0)throw new Error('纹理像素数量无效'); - if(!Number.isInteger(channels)||channels<1||channels>4)throw new Error(`不支持 ${channels} 通道纹理`); - if(source.length 4) + throw new Error(`不支持 ${channels} 通道纹理`); + if (source.length < pixelCount * channels) throw new Error('纹理像素数据不完整'); + const rgba = new Uint8Array(pixelCount * 4); + for (let pixel = 0; pixel < pixelCount; pixel += 1) { + const sourceOffset = pixel * channels, + targetOffset = pixel * 4, + red = source[sourceOffset]; + rgba[targetOffset] = red; + rgba[targetOffset + 1] = channels === 1 || channels === 2 ? red : source[sourceOffset + 1]; + rgba[targetOffset + 2] = channels === 1 || channels === 2 ? red : source[sourceOffset + 2]; + rgba[targetOffset + 3] = + channels === 2 ? source[sourceOffset + 1] : channels === 4 ? source[sourceOffset + 3] : 255; } return rgba; } diff --git a/web_platform/src/viewer/visualizationMath.test.ts b/web_platform/src/viewer/visualizationMath.test.ts index 7bf08203..7561b61e 100644 --- a/web_platform/src/viewer/visualizationMath.test.ts +++ b/web_platform/src/viewer/visualizationMath.test.ts @@ -1,21 +1,24 @@ -import {inertiaBoxDimensions} from './visualizationMath'; +import { inertiaBoxDimensions } from './visualizationMath'; -describe('visualizationMath',()=>{ - it('从主惯量恢复等效实心盒尺寸',()=>{ - const mass=12,width=2,height=4,depth=6; - const inertia:[number,number,number]=[ - mass*(height**2+depth**2)/12, - mass*(width**2+depth**2)/12, - mass*(width**2+height**2)/12, +describe('visualizationMath', () => { + it('从主惯量恢复等效实心盒尺寸', () => { + const mass = 12, + width = 2, + height = 4, + depth = 6; + const inertia: [number, number, number] = [ + (mass * (height ** 2 + depth ** 2)) / 12, + (mass * (width ** 2 + depth ** 2)) / 12, + (mass * (width ** 2 + height ** 2)) / 12, ]; - const dimensions=inertiaBoxDimensions(mass,inertia); + const dimensions = inertiaBoxDimensions(mass, inertia); expect(dimensions?.[0]).toBeCloseTo(width); expect(dimensions?.[1]).toBeCloseTo(height); expect(dimensions?.[2]).toBeCloseTo(depth); }); - it('拒绝无质量或不构成实体盒的惯量',()=>{ - expect(inertiaBoxDimensions(0,[1,1,1])).toBeNull(); - expect(inertiaBoxDimensions(1,[10,1,1])).toBeNull(); + it('拒绝无质量或不构成实体盒的惯量', () => { + expect(inertiaBoxDimensions(0, [1, 1, 1])).toBeNull(); + expect(inertiaBoxDimensions(1, [10, 1, 1])).toBeNull(); }); }); diff --git a/web_platform/src/viewer/visualizationMath.ts b/web_platform/src/viewer/visualizationMath.ts index 5cd97286..812a83d9 100644 --- a/web_platform/src/viewer/visualizationMath.ts +++ b/web_platform/src/viewer/visualizationMath.ts @@ -1,16 +1,20 @@ -export type PrincipalInertia=readonly [number,number,number]; -export type BoxDimensions=readonly [number,number,number]; +export type PrincipalInertia = readonly [number, number, number]; +export type BoxDimensions = readonly [number, number, number]; /** 根据实体质量和主惯量,求具有相同惯量的等效实心盒尺寸。 */ -export function inertiaBoxDimensions(mass:number,inertia:PrincipalInertia):BoxDimensions|null { - const [ix,iy,iz]=inertia; - if(!(mass>0)||![ix,iy,iz].every(value=>Number.isFinite(value)&&value>=0))return null; - const factor=6/mass; - const squared:[number,number,number]=[ - factor*(iy+iz-ix), - factor*(ix+iz-iy), - factor*(ix+iy-iz), +export function inertiaBoxDimensions( + mass: number, + inertia: PrincipalInertia, +): BoxDimensions | null { + const [ix, iy, iz] = inertia; + if (!(mass > 0) || ![ix, iy, iz].every((value) => Number.isFinite(value) && value >= 0)) + return null; + const factor = 6 / mass; + const squared: [number, number, number] = [ + factor * (iy + iz - ix), + factor * (ix + iz - iy), + factor * (ix + iy - iz), ]; - if(squared.some(value=>value<=0||!Number.isFinite(value)))return null; - return [Math.sqrt(squared[0]),Math.sqrt(squared[1]),Math.sqrt(squared[2])]; + if (squared.some((value) => value <= 0 || !Number.isFinite(value))) return null; + return [Math.sqrt(squared[0]), Math.sqrt(squared[1]), Math.sqrt(squared[2])]; } diff --git a/web_platform/tailwind.config.cjs b/web_platform/tailwind.config.cjs index bd7c22bf..adef15d9 100644 --- a/web_platform/tailwind.config.cjs +++ b/web_platform/tailwind.config.cjs @@ -1,13 +1,34 @@ -module.exports={ - content:['./web_platform/index.html','./web_platform/src/**/*.{ts,tsx}'], - theme:{extend:{colors:{ - app:'var(--ui-bg)',panel:'var(--ui-panel)',surface:'var(--ui-surface)','surface-elevated':'var(--ui-surface-elevated)', - input:'var(--ui-input)','element-hover':'var(--ui-hover)','element-active':'var(--ui-active)', - border:'var(--ui-border)','border-strong':'var(--ui-border-strong)', - 'text-primary':'var(--ui-text-primary)','text-secondary':'var(--ui-text-secondary)','text-tertiary':'var(--ui-text-tertiary)', - accent:'var(--ui-accent)','accent-hover':'var(--ui-accent-hover)','accent-soft':'var(--ui-accent-soft)', - danger:'var(--ui-danger)','danger-soft':'var(--ui-danger-soft)','danger-border':'var(--ui-danger-border)', - warning:'var(--ui-warning)','warning-soft':'var(--ui-warning-soft)','warning-border':'var(--ui-warning-border)', - success:'var(--ui-success)','success-soft':'var(--ui-success-soft)','success-border':'var(--ui-success-border)' - }}},plugins:[] +module.exports = { + content: ['./web_platform/index.html', './web_platform/src/**/*.{ts,tsx}'], + theme: { + extend: { + colors: { + app: 'var(--ui-bg)', + panel: 'var(--ui-panel)', + surface: 'var(--ui-surface)', + 'surface-elevated': 'var(--ui-surface-elevated)', + input: 'var(--ui-input)', + 'element-hover': 'var(--ui-hover)', + 'element-active': 'var(--ui-active)', + border: 'var(--ui-border)', + 'border-strong': 'var(--ui-border-strong)', + 'text-primary': 'var(--ui-text-primary)', + 'text-secondary': 'var(--ui-text-secondary)', + 'text-tertiary': 'var(--ui-text-tertiary)', + accent: 'var(--ui-accent)', + 'accent-hover': 'var(--ui-accent-hover)', + 'accent-soft': 'var(--ui-accent-soft)', + danger: 'var(--ui-danger)', + 'danger-soft': 'var(--ui-danger-soft)', + 'danger-border': 'var(--ui-danger-border)', + warning: 'var(--ui-warning)', + 'warning-soft': 'var(--ui-warning-soft)', + 'warning-border': 'var(--ui-warning-border)', + success: 'var(--ui-success)', + 'success-soft': 'var(--ui-success-soft)', + 'success-border': 'var(--ui-success-border)', + }, + }, + }, + plugins: [], }; diff --git a/web_platform/tsconfig.json b/web_platform/tsconfig.json index 1a71acca..37aa27b4 100644 --- a/web_platform/tsconfig.json +++ b/web_platform/tsconfig.json @@ -1,9 +1,21 @@ { "compilerOptions": { - "target": "ES2022", "useDefineForClassFields": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], - "allowJs": false, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, - "strict": true, "forceConsistentCasingInFileNames": true, "module": "ESNext", "moduleResolution": "Bundler", - "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", "types": ["vitest/globals", "node"] - }, "include": ["src", "vite.config.ts", "playwright.config.ts", "e2e"] + }, + "include": ["src", "vite.config.ts", "playwright.config.ts", "e2e"] } diff --git a/web_platform/vite.config.ts b/web_platform/vite.config.ts index d4b34ba2..e5a5617f 100644 --- a/web_platform/vite.config.ts +++ b/web_platform/vite.config.ts @@ -1,24 +1,116 @@ -import {readFileSync} from 'node:fs'; -import {dirname,resolve} from 'node:path'; -import {fileURLToPath} from 'node:url'; -import {defineConfig} from 'vitest/config'; -import type {Plugin} from 'vite'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; +import type { Plugin } from 'vite'; import react from '@vitejs/plugin-react'; -const PYODIDE_ASSETS=['pyodide.asm.js','pyodide.asm.wasm','python_stdlib.zip','pyodide-lock.json'] as const; -const pyodideDirectory=resolve(dirname(fileURLToPath(import.meta.url)),'../node_modules/pyodide'); +const PYODIDE_ASSETS = [ + 'pyodide.asm.js', + 'pyodide.asm.wasm', + 'python_stdlib.zip', + 'pyodide-lock.json', +] as const; +const pyodideDirectory = resolve( + dirname(fileURLToPath(import.meta.url)), + '../node_modules/pyodide', +); + +/** 生产预览中长期缓存带内容哈希的资源,同时允许入口文件及时更新。 */ +function previewCacheHeaders(): Plugin { + return { + name: 'preview-cache-headers', + configurePreviewServer(server) { + server.middlewares.use((request, response, next) => { + const path = request.url?.split(/[?#]/, 1)[0] ?? ''; + if (path.startsWith('/assets/')) + response.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + else if (path.startsWith('/pyodide/')) + response.setHeader( + 'Cache-Control', + 'public, max-age=86400, stale-while-revalidate=604800', + ); + else response.setHeader('Cache-Control', 'no-cache'); + next(); + }); + }, + }; +} /** 让开发服务器与生产构建都从本地 npm 包提供 Pyodide,保持平台可离线部署。 */ -function localPyodideAssets():Plugin { - let isBuild=false; - return {name:'local-pyodide-assets',configResolved(config){isBuild=config.command==='build';},configureServer(server){server.middlewares.use((request,response,next)=>{const name=request.url?.split(/[?#]/,1)[0].replace(/^\/pyodide\//,'');if(!name||!PYODIDE_ASSETS.includes(name as typeof PYODIDE_ASSETS[number])){next();return;}response.setHeader('Content-Type',name.endsWith('.wasm')?'application/wasm':name.endsWith('.zip')?'application/zip':name.endsWith('.json')?'application/json':'text/javascript');response.end(readFileSync(resolve(pyodideDirectory,name)));});},buildStart(){if(isBuild)for(const name of PYODIDE_ASSETS)this.emitFile({type:'asset',fileName:`pyodide/${name}`,source:readFileSync(resolve(pyodideDirectory,name))});}}; +function localPyodideAssets(): Plugin { + let isBuild = false; + return { + name: 'local-pyodide-assets', + configResolved(config) { + isBuild = config.command === 'build'; + }, + configureServer(server) { + server.middlewares.use((request, response, next) => { + const name = request.url?.split(/[?#]/, 1)[0].replace(/^\/pyodide\//, ''); + if (!name || !PYODIDE_ASSETS.includes(name as (typeof PYODIDE_ASSETS)[number])) { + next(); + return; + } + response.setHeader( + 'Content-Type', + name.endsWith('.wasm') + ? 'application/wasm' + : name.endsWith('.zip') + ? 'application/zip' + : name.endsWith('.json') + ? 'application/json' + : 'text/javascript', + ); + response.end(readFileSync(resolve(pyodideDirectory, name))); + }); + }, + buildStart() { + if (isBuild) + for (const name of PYODIDE_ASSETS) + this.emitFile({ + type: 'asset', + fileName: `pyodide/${name}`, + source: readFileSync(resolve(pyodideDirectory, name)), + }); + }, + }; } export default defineConfig({ - root:'web_platform',base:'./',plugins:[react(),localPyodideAssets()],publicDir:'public', - build:{outDir:'../web-platform-dist',emptyOutDir:true,target:'es2022'}, - worker:{format:'es'}, - server:{open:true,fs:{allow:['..']}}, - preview:{headers:{'Cache-Control':'no-store'}}, - test:{globals:true,environment:'jsdom',setupFiles:'./src/test/setup.ts',include:['src/**/*.test.ts','src/**/*.test.tsx']} + root: 'web_platform', + base: './', + plugins: [react(), localPyodideAssets(), previewCacheHeaders()], + publicDir: 'public', + build: { + outDir: '../web-platform-dist', + emptyOutDir: true, + target: 'es2022', + modulePreload: { polyfill: false }, + }, + worker: { format: 'es' }, + server: { open: true, fs: { allow: ['..'] } }, + test: { + globals: true, + environment: 'jsdom', + setupFiles: './src/test/setup.ts', + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'json-summary'], + include: [ + 'src/project/cachedFiles.ts', + 'src/project/importer.ts', + 'src/project/urdfToMjcf.ts', + 'src/project/workspace.ts', + 'src/simulation/geometry.ts', + 'src/stores/useAppStore.ts', + 'src/training/LocalTrainingClient.ts', + 'src/viewer/interactionMath.ts', + 'src/viewer/texturePixels.ts', + 'src/viewer/visualizationMath.ts', + ], + thresholds: { lines: 85, functions: 75, branches: 70, statements: 80 }, + }, + }, });