From 60d3a6d68c713966886f0f8b9efe7c3d5dfe756a Mon Sep 17 00:00:00 2001 From: cen617-code <1057290604@qq.com> Date: Fri, 28 Aug 2026 15:38:10 +0800 Subject: [PATCH 1/3] =?UTF-8?q?chore(web-platform):=20release=20V0.6.1=20?= =?UTF-8?q?=E5=B7=A5=E7=A8=8B=E8=B4=A8=E9=87=8F=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .editorconfig | 14 + .github/workflows/ci.yml | 15 +- .github/workflows/release.yml | 46 + .npmrc | 1 + .nvmrc | 1 + .prettierignore | 14 + .prettierrc.json | 7 + CHANGELOG.md | 35 + README.md | 16 +- eslint.config.js | 16 +- package-lock.json | 240 ++- package.json | 18 +- pyproject.toml | 11 + requirements-dev.txt | 1 + training_server/README.md | 13 +- training_server/server.py | 931 +++++++----- training_server/tests/test_server.py | 222 ++- web_platform/README.md | 4 +- web_platform/e2e/app.spec.ts | 467 ++++-- web_platform/index.html | 15 +- web_platform/playwright.config.ts | 13 +- web_platform/postcss.config.cjs | 4 +- web_platform/src/app/App.tsx | 1294 +++++++++++++++-- web_platform/src/app/ErrorBoundary.tsx | 31 +- .../app/components/ActuatorControl.test.tsx | 99 +- .../src/app/components/CommandPalette.tsx | 130 +- .../src/app/components/DiagnosticNotice.tsx | 55 +- .../src/app/components/DiagnosticsDrawer.tsx | 103 +- .../components/EntrySelectionDialog.test.tsx | 20 +- .../app/components/EntrySelectionDialog.tsx | 32 +- .../src/app/components/ErrorRecoveryPanel.tsx | 73 +- .../components/FeedbackComponents.test.tsx | 43 +- .../components/FifthBatchComponents.test.tsx | 76 +- .../components/FourthBatchComponents.test.tsx | 114 +- .../app/components/LayoutSettingsDialog.tsx | 83 +- .../components/LocalTrainingPanel.test.tsx | 100 +- .../src/app/components/LocalTrainingPanel.tsx | 506 ++++++- .../src/app/components/NotificationCenter.tsx | 148 +- .../src/app/components/PerformancePopover.tsx | 71 +- .../src/app/components/ProjectBreadcrumb.tsx | 56 +- .../components/PythonControllerPanel.test.tsx | 80 +- .../app/components/PythonControllerPanel.tsx | 209 ++- .../src/app/components/RLPolicyPanel.tsx | 208 ++- .../components/SecondBatchComponents.test.tsx | 73 +- .../src/app/components/SettingsDialog.tsx | 109 +- .../src/app/components/ShortcutHelpDialog.tsx | 40 +- .../src/app/components/SidebarPanel.tsx | 828 ++++++++++- .../src/app/components/SourceEditorDialog.tsx | 269 +++- web_platform/src/app/components/StatusBar.tsx | 58 +- .../components/ThirdBatchComponents.test.tsx | 35 +- .../app/components/ToolbarOverflowMenu.tsx | 75 +- .../src/app/components/TreeSearchField.tsx | 49 +- .../UrdfImportOptionsDialog.test.tsx | 65 +- .../components/UrdfImportOptionsDialog.tsx | 196 ++- .../app/components/ViewerDisplayPopover.tsx | 180 ++- .../src/app/components/ViewerToolDock.tsx | 40 +- .../src/app/components/ViewportHUD.tsx | 78 +- .../app/components/WorkbenchHeader.test.tsx | 55 +- .../src/app/components/WorkbenchHeader.tsx | 215 ++- .../src/app/components/WorkspaceOverlays.tsx | 135 +- .../src/app/components/monacoSetup.ts | 8 +- web_platform/src/components/ui/Badge.tsx | 29 +- web_platform/src/components/ui/Button.tsx | 52 +- .../components/ui/CollapsibleSection.test.tsx | 28 +- .../src/components/ui/CollapsibleSection.tsx | 45 +- .../src/components/ui/ConfirmDialog.tsx | 44 +- web_platform/src/components/ui/CopyButton.tsx | 35 +- .../src/components/ui/Dialog.test.tsx | 58 +- web_platform/src/components/ui/Dialog.tsx | 115 +- .../src/components/ui/DropdownMenu.tsx | 92 +- .../src/components/ui/EmptySearchState.tsx | 11 +- .../src/components/ui/FifthBatchUi.test.tsx | 53 +- web_platform/src/components/ui/IconButton.tsx | 28 +- web_platform/src/components/ui/Kbd.tsx | 10 +- web_platform/src/components/ui/LiveRegion.tsx | 21 +- web_platform/src/components/ui/Popover.tsx | 67 +- .../src/components/ui/ProgressBar.tsx | 25 +- .../src/components/ui/PropertyRow.tsx | 22 +- .../src/components/ui/ResizablePanel.tsx | 135 +- .../src/components/ui/SearchHighlight.tsx | 28 +- .../src/components/ui/SearchableCombobox.tsx | 125 +- .../src/components/ui/SecondBatchUi.test.tsx | 67 +- web_platform/src/components/ui/Select.tsx | 11 +- web_platform/src/components/ui/Separator.tsx | 16 +- web_platform/src/components/ui/Skeleton.tsx | 9 +- web_platform/src/components/ui/Tabs.tsx | 87 +- .../src/components/ui/ThirdBatchUi.test.tsx | 42 +- .../src/components/ui/ToolbarToggleGroup.tsx | 45 +- web_platform/src/components/ui/Tooltip.tsx | 29 +- .../ui/VirtualTreeViewport.test.tsx | 29 +- .../src/components/ui/VirtualTreeViewport.tsx | 124 +- .../src/controller/PythonControllerRuntime.ts | 256 ++-- web_platform/src/controller/types.ts | 51 +- web_platform/src/main.tsx | 20 +- .../src/project/ModelStructureTree.test.tsx | 70 +- .../src/project/ModelStructureTree.tsx | 271 +++- web_platform/src/project/ProjectTree.test.tsx | 76 +- web_platform/src/project/ProjectTree.tsx | 342 ++++- web_platform/src/project/cachedFiles.test.ts | 84 +- web_platform/src/project/cachedFiles.ts | 126 +- web_platform/src/project/daeConverter.ts | 74 +- web_platform/src/project/importer.test.ts | 170 ++- web_platform/src/project/importer.ts | 396 +++-- web_platform/src/project/urdfToMjcf.test.ts | 170 ++- web_platform/src/project/urdfToMjcf.ts | 330 ++++- web_platform/src/project/workspace.test.ts | 38 +- web_platform/src/project/workspace.ts | 53 +- .../src/rl/runtime/Go2wPolicyBindings.ts | 300 +++- .../src/rl/runtime/OnnxPolicyRuntime.ts | 252 +++- .../src/rl/tasks/go2wVelocity.test.ts | 65 +- web_platform/src/rl/tasks/go2wVelocity.ts | 111 +- web_platform/src/rl/types.ts | 44 +- web_platform/src/simulation/PhysicsAdapter.ts | 320 ++-- .../src/simulation/SimulationSession.ts | 909 ++++++++++-- web_platform/src/simulation/geometry.test.ts | 6 +- web_platform/src/simulation/geometry.ts | 4 +- web_platform/src/stores/useAppStore.test.ts | 43 +- web_platform/src/stores/useAppStore.ts | 111 +- web_platform/src/styles.css | 151 +- .../src/training/LocalTrainingClient.test.ts | 69 +- .../src/training/LocalTrainingClient.ts | 82 +- web_platform/src/training/types.ts | 62 +- web_platform/src/viewer/MuJoCoViewer.ts | 892 +++++++++++- web_platform/src/viewer/OrientationGizmo.ts | 125 +- .../src/viewer/ViewerVisualizationHelpers.ts | 290 +++- web_platform/src/viewer/displayOptions.ts | 30 +- .../src/viewer/interactionMath.test.ts | 74 +- web_platform/src/viewer/interactionMath.ts | 65 +- web_platform/src/viewer/texturePixels.test.ts | 26 +- web_platform/src/viewer/texturePixels.ts | 30 +- .../src/viewer/visualizationMath.test.ts | 27 +- web_platform/src/viewer/visualizationMath.ts | 28 +- web_platform/tailwind.config.cjs | 45 +- web_platform/tsconfig.json | 22 +- web_platform/vite.config.ts | 99 +- 135 files changed, 13552 insertions(+), 2882 deletions(-) create mode 100644 .editorconfig create mode 100644 .github/workflows/release.yml create mode 100644 .npmrc create mode 100644 .nvmrc create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 CHANGELOG.md create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt 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/.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..23de50ee --- /dev/null +++ b/.prettierignore @@ -0,0 +1,14 @@ +AGENTS.md +context.md +plans/ +.git/ +.venv/ +build/ +node_modules/ +web-platform-dist/ +coverage/ +playwright-report/ +test-results/ +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..355180fb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,35 @@ +# 更新日志 + +本项目的重要变更记录在此文件中,版本标签沿用仓库现有的 `V主版本.次版本[.修订版本]` 格式。 + +## [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..c92040c1 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,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 +36,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..7cb572fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mujoco-web-platform", - "version": "0.6.0", + "version": "0.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mujoco-web-platform", - "version": "0.6.0", + "version": "0.6.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..898d3ecf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mujoco-web-platform", - "version": "0.6.0", + "version": "0.6.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..bdec43b2 100644 --- a/web_platform/README.md +++ b/web_platform/README.md @@ -77,9 +77,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..7565d976 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,7 @@ const SIMPLE_MODEL = ` `; -const SLIDE_DIRECTION_MODEL=``; +const SLIDE_DIRECTION_MODEL = ``; const LARGE_MODEL = ` @@ -36,238 +37,392 @@ 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(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..83215cb0 100644 --- a/web_platform/index.html +++ b/web_platform/index.html @@ -1 +1,14 @@ -MuJoCo Web 仿真平台
+ + + + + + + + MuJoCo Web 仿真平台 + + +
+ + + diff --git a/web_platform/playwright.config.ts b/web_platform/playwright.config.ts index 0f211a7c..3c13e201 100644 --- a/web_platform/playwright.config.ts +++ b/web_platform/playwright.config.ts @@ -1,2 +1,11 @@ -import {defineConfig} from '@playwright/test'; -export default defineConfig({testDir:'./e2e', timeout:120_000, use:{baseURL:'http://127.0.0.1:4173',channel:'chrome'}, webServer:{command:'npm run preview --prefix .. -- --host 127.0.0.1',url:'http://127.0.0.1:4173',reuseExistingServer:true}}); +import { defineConfig } from '@playwright/test'; +export default defineConfig({ + testDir: './e2e', + timeout: 120_000, + use: { baseURL: 'http://127.0.0.1:4173' }, + webServer: { + command: 'npm run preview --prefix .. -- --host 127.0.0.1', + url: 'http://127.0.0.1:4173', + reuseExistingServer: true, + }, +}); diff --git a/web_platform/postcss.config.cjs b/web_platform/postcss.config.cjs index ec5e4b82..5ff0f895 100644 --- a/web_platform/postcss.config.cjs +++ b/web_platform/postcss.config.cjs @@ -1 +1,3 @@ -module.exports = {plugins: {tailwindcss: {config: './web_platform/tailwind.config.cjs'}, autoprefixer: {}}}; +module.exports = { + plugins: { tailwindcss: { config: './web_platform/tailwind.config.cjs' }, autoprefixer: {} }, +}; diff --git a/web_platform/src/app/App.tsx b/web_platform/src/app/App.tsx index 9df99cce..aeae7a3b 100644 --- a/web_platform/src/app/App.tsx +++ b/web_platform/src/app/App.tsx @@ -1,121 +1,1185 @@ /* 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 { + 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 {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'; -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(); + 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: 0.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: 0.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: 0.12 }); + try { + const next = await importBrowserFiles(files); + setImportProgress({ label: '处理模型资源与入口', value: 0.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: 0.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: 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({ label: '初始化 ONNX Runtime 并加载策略', 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 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} + ”吗? +

+

该操作不会删除本地文件。

+
+ + + ); } 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/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..85afc34b 100644 --- a/web_platform/src/app/components/FeedbackComponents.test.tsx +++ b/web_platform/src/app/components/FeedbackComponents.test.tsx @@ -1,5 +1,38 @@ -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')).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/NotificationCenter.tsx b/web_platform/src/app/components/NotificationCenter.tsx index ffb38769..d8b4a3e2 100644 --- a/web_platform/src/app/components/NotificationCenter.tsx +++ b/web_platform/src/app/components/NotificationCenter.tsx @@ -1,7 +1,141 @@ -import {useEffect,useRef} from 'react'; -import {Bell,CheckCircle2,Info,Trash2,TriangleAlert,XCircle} from 'lucide-react'; -import {Badge,IconButton,Popover} from '../../components/ui'; -export interface WorkbenchNotification{id:number;title:string;detail?:string;tone:'success'|'warning'|'danger'|'info';at:number;} -const icons={success:CheckCircle2,warning:TriangleAlert,danger:XCircle,info:Info}; -export function NotificationCenter({items,onDismiss,onClear,onOpenLog}:{items:WorkbenchNotification[];onDismiss:(id:number)=>void;onClear:()=>void;onOpenLog?:()=>void}){return {items.length>0&&}}>{({close})=>

通知

{onOpenLog&&}{items.length>0&&}
{items.length?items.map(item=>{const Icon=icons[item.tone];return

{item.title}

{new Date(item.at).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'})}
{item.detail&&

{item.detail}

}
onDismiss(item.id)}>
}):

当前没有通知

}
}
;} -export function ToastViewport({item,onDismiss}:{item?:WorkbenchNotification;onDismiss:(id:number)=>void}){const dismissRef=useRef(onDismiss);useEffect(()=>{dismissRef.current=onDismiss;},[onDismiss]);useEffect(()=>{if(!item)return;const timer=window.setTimeout(()=>dismissRef.current(item.id),4000);return()=>window.clearTimeout(timer);},[item]);if(!item)return null;const Icon=icons[item.tone];return

{item.title}

{item.detail&&

{item.detail}

}
;} +import { useEffect, useRef } from 'react'; +import { Bell, CheckCircle2, Info, Trash2, TriangleAlert, XCircle } from 'lucide-react'; +import { Badge, IconButton, Popover } from '../../components/ui'; +export interface WorkbenchNotification { + id: number; + title: string; + detail?: string; + tone: 'success' | 'warning' | 'danger' | 'info'; + at: number; +} +const icons = { success: CheckCircle2, warning: TriangleAlert, danger: XCircle, info: Info }; +export function NotificationCenter({ + items, + onDismiss, + onClear, + onOpenLog, +}: { + items: WorkbenchNotification[]; + onDismiss: (id: number) => void; + onClear: () => void; + onOpenLog?: () => void; +}) { + return ( + ( + + + {items.length > 0 && ( + + )} + + )} + > + {({ close }) => ( +
+
+

通知

+
+ {onOpenLog && ( + + )} + {items.length > 0 && ( + + )} +
+
+
+ {items.length ? ( + items.map((item) => { + const Icon = icons[item.tone]; + return ( +
+ +
+
+

{item.title}

+ + {new Date(item.at).toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + })} + +
+ {item.detail && ( +

+ {item.detail} +

+ )} +
+ onDismiss(item.id)} + > + + +
+ ); + }) + ) : ( +

当前没有通知

+ )} +
+
+ )} +
+ ); +} +export function ToastViewport({ + item, + onDismiss, +}: { + item?: WorkbenchNotification; + onDismiss: (id: number) => void; +}) { + const dismissRef = useRef(onDismiss); + useEffect(() => { + dismissRef.current = onDismiss; + }, [onDismiss]); + useEffect(() => { + if (!item) return; + const timer = window.setTimeout(() => dismissRef.current(item.id), 4000); + return () => window.clearTimeout(timer); + }, [item]); + if (!item) return null; + const Icon = icons[item.tone]; + return ( +
+ +
+

{item.title}

+ {item.detail && ( +

{item.detail}

+ )} +
+
+ ); +} diff --git a/web_platform/src/app/components/PerformancePopover.tsx b/web_platform/src/app/components/PerformancePopover.tsx index f08f172c..916e6603 100644 --- a/web_platform/src/app/components/PerformancePopover.tsx +++ b/web_platform/src/app/components/PerformancePopover.tsx @@ -1,3 +1,68 @@ -import {Activity,ChevronUp,Cpu,MemoryStick,TriangleAlert} from 'lucide-react'; -import {Badge,Popover,PropertyRow,Separator} from '../../components/ui'; -export function PerformancePopover({fps,stepMs,memoryMb,overBudget}:{fps:number;stepMs:number;memoryMb?:number;overBudget:boolean}){return }>{()=>

运行性能

{overBudget?'预算超限':'运行正常'}
{overBudget?

主线程超出步进预算,平台已限制追帧以保持交互响应。

:

指标来自浏览器运行时,仅用于当前会话诊断。

}
}
;} +import { Activity, ChevronUp, Cpu, MemoryStick, TriangleAlert } from 'lucide-react'; +import { Badge, Popover, PropertyRow, Separator } from '../../components/ui'; +export function PerformancePopover({ + fps, + stepMs, + memoryMb, + overBudget, +}: { + fps: number; + stepMs: number; + memoryMb?: number; + overBudget: boolean; +}) { + return ( + ( + + )} + > + {() => ( +
+
+

运行性能

+ + {overBudget ? '预算超限' : '运行正常'} + +
+ + + + + {overBudget ? ( +

+ + 主线程超出步进预算,平台已限制追帧以保持交互响应。 +

+ ) : ( +

+ + 指标来自浏览器运行时,仅用于当前会话诊断。 +

+ )} +
+ )} +
+ ); +} diff --git a/web_platform/src/app/components/ProjectBreadcrumb.tsx b/web_platform/src/app/components/ProjectBreadcrumb.tsx index 605b1b5d..4973b15d 100644 --- a/web_platform/src/app/components/ProjectBreadcrumb.tsx +++ b/web_platform/src/app/components/ProjectBreadcrumb.tsx @@ -1,4 +1,52 @@ -import {ChevronRight,FolderRoot} from 'lucide-react'; -import type {ModelEntry} from '../../project/types'; -import {SearchableCombobox} from '../../components/ui'; -export function ProjectBreadcrumb({projectName,entries,selectedEntry,loading=false,onSelect}:{projectName:string;entries:ModelEntry[];selectedEntry?:string;loading?:boolean;onSelect:(path:string)=>void}){const parts=selectedEntry?.split('/').filter(Boolean)??[];return
{projectName}{parts.map((part,index)=>{part})}
{entries.length>1&&
({value:entry.path,label:entry.label,description:entry.path}))}/>
}
;} +import { ChevronRight, FolderRoot } from 'lucide-react'; +import type { ModelEntry } from '../../project/types'; +import { SearchableCombobox } from '../../components/ui'; +export function ProjectBreadcrumb({ + projectName, + entries, + selectedEntry, + loading = false, + onSelect, +}: { + projectName: string; + entries: ModelEntry[]; + selectedEntry?: string; + loading?: boolean; + onSelect: (path: string) => void; +}) { + const parts = selectedEntry?.split('/').filter(Boolean) ?? []; + return ( +
+
+ + {projectName} + {parts.map((part, index) => ( + + + + {part} + + + ))} +
+ {entries.length > 1 && ( +
+ ({ + value: entry.path, + label: entry.label, + description: entry.path, + }))} + /> +
+ )} +
+ ); +} diff --git a/web_platform/src/app/components/PythonControllerPanel.test.tsx b/web_platform/src/app/components/PythonControllerPanel.test.tsx index dd66fd55..7fa7ccea 100644 --- a/web_platform/src/app/components/PythonControllerPanel.test.tsx +++ b/web_platform/src/app/components/PythonControllerPanel.test.tsx @@ -1,23 +1,69 @@ -import {fireEvent,render,screen} from '@testing-library/react'; -import {describe,expect,it,vi} from 'vitest'; -import {PythonControllerPanel} from './PythonControllerPanel'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { PythonControllerPanel } from './PythonControllerPanel'; -const noop=()=>{}; +const noop = () => {}; -describe('PythonControllerPanel',()=>{ - it('向支持 command 的已启用控制器发送基本移动指令',()=>{ - const onCommand=vi.fn(); - render(); - fireEvent.click(screen.getByRole('button',{name:'前进'})); - fireEvent.click(screen.getByRole('button',{name:'左转'})); - fireEvent.click(screen.getByRole('button',{name:'起跳'})); - expect(onCommand.mock.calls).toEqual([['forward'],['turn_left'],['jump']]); - expect(screen.getByRole('button',{name:'移动停止'})).toHaveAttribute('aria-pressed','true'); +describe('PythonControllerPanel', () => { + it('向支持 command 的已启用控制器发送基本移动指令', () => { + const onCommand = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: '前进' })); + fireEvent.click(screen.getByRole('button', { name: '左转' })); + fireEvent.click(screen.getByRole('button', { name: '起跳' })); + expect(onCommand.mock.calls).toEqual([['forward'], ['turn_left'], ['jump']]); + expect(screen.getByRole('button', { name: '移动停止' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); }); - it('控制器未启用时禁用基本移动按钮',()=>{ - render(); - expect(screen.getByRole('button',{name:'前进'})).toBeDisabled(); - expect(screen.getByRole('button',{name:'起跳'})).toBeDisabled(); + it('控制器未启用时禁用基本移动按钮', () => { + render( + , + ); + expect(screen.getByRole('button', { name: '前进' })).toBeDisabled(); + expect(screen.getByRole('button', { name: '起跳' })).toBeDisabled(); }); }); diff --git a/web_platform/src/app/components/PythonControllerPanel.tsx b/web_platform/src/app/components/PythonControllerPanel.tsx index 04571f79..c3c314bd 100644 --- a/web_platform/src/app/components/PythonControllerPanel.tsx +++ b/web_platform/src/app/components/PythonControllerPanel.tsx @@ -1,37 +1,184 @@ -import {useRef,type ChangeEvent} from 'react'; -import {ArrowDown,ArrowLeft,ArrowRight,ArrowUp,FileUp,Octagon,Power,RotateCw,Trash2} from 'lucide-react'; -import type {ControllerCommand,ControllerStatus} from '../../controller/types'; -import {Badge,Button,PropertyRow,Select} from '../../components/ui'; +import { useRef, type ChangeEvent } from 'react'; +import { + ArrowDown, + ArrowLeft, + ArrowRight, + ArrowUp, + FileUp, + Octagon, + Power, + RotateCw, + Trash2, +} from 'lucide-react'; +import type { ControllerCommand, ControllerStatus } from '../../controller/types'; +import { Badge, Button, PropertyRow, Select } from '../../components/ui'; export interface PythonControllerPanelProps { - paths:string[]; - selectedPath?:string; - status?:ControllerStatus; - loading:boolean; - onSelectPath(path:string):void; - onLoadPath(path:string):void; - onImport(file:File):void; - onToggle(enabled:boolean):void; - onCommand(command:ControllerCommand):void; - onRemove():void; + paths: string[]; + selectedPath?: string; + status?: ControllerStatus; + loading: boolean; + onSelectPath(path: string): void; + onLoadPath(path: string): void; + onImport(file: File): void; + onToggle(enabled: boolean): void; + onCommand(command: ControllerCommand): void; + onRemove(): void; } -export function PythonControllerPanel({paths,selectedPath,status,loading,onSelectPath,onLoadPath,onImport,onToggle,onCommand,onRemove}:PythonControllerPanelProps){ - const input=useRef(null); - const importFile=(event:ChangeEvent)=>{const file=event.target.files?.[0];if(file)onImport(file);event.target.value='';}; - return
- - {paths.length>0&&} -
- - +export function PythonControllerPanel({ + paths, + selectedPath, + status, + loading, + onSelectPath, + onLoadPath, + onImport, + onToggle, + onCommand, + onRemove, +}: PythonControllerPanelProps) { + const input = useRef(null); + const importFile = (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) onImport(file); + event.target.value = ''; + }; + return ( +
+ + {paths.length > 0 && ( + + )} +
+ + +
+ {status ? ( +
+
+ + {status.name} + + {status.enabled ? '运行中' : '已停止'} +
+ + + + {status.error && ( +

+ {status.error} +

+ )} + {status.acceptsCommands && ( +
+

基本移动指令

+
+ + + + + + + + + +
+
+ )} +
+ + +
+
+ ) : ( +

+ 加载可信的单文件 Python 控制器。脚本在每次 mj_step 前按仿真时间同步执行,默认 100 Hz。 +

+ )}
- {status?
-
{status.name}{status.enabled?'运行中':'已停止'}
- - {status.error&&

{status.error}

} - {status.acceptsCommands&&

基本移动指令

} -
-
:

加载可信的单文件 Python 控制器。脚本在每次 mj_step 前按仿真时间同步执行,默认 100 Hz。

} -
; + ); } diff --git a/web_platform/src/app/components/RLPolicyPanel.tsx b/web_platform/src/app/components/RLPolicyPanel.tsx index 391f6899..cb53777f 100644 --- a/web_platform/src/app/components/RLPolicyPanel.tsx +++ b/web_platform/src/app/components/RLPolicyPanel.tsx @@ -1,30 +1,190 @@ -import {useRef,type ChangeEvent} from 'react'; -import {BrainCircuit,FileUp,Power,RotateCw,Trash2} from 'lucide-react'; -import type {RLCommand,RLPolicyStatus} from '../../rl/types'; -import {Badge,Button,PropertyRow,Select} from '../../components/ui'; +import { useRef, type ChangeEvent } from 'react'; +import { BrainCircuit, FileUp, Power, RotateCw, Trash2 } from 'lucide-react'; +import type { RLCommand, RLPolicyStatus } from '../../rl/types'; +import { Badge, Button, PropertyRow, Select } from '../../components/ui'; export interface RLPolicyPanelProps { - paths:string[];selectedPath?:string;status?:RLPolicyStatus;loading:boolean; - onSelectPath(path:string):void;onLoadPath(path:string):void;onImport(file:File):void; - onToggle(enabled:boolean):void;onCommand(command:RLCommand):void;onRemove():void; + paths: string[]; + selectedPath?: string; + status?: RLPolicyStatus; + loading: boolean; + onSelectPath(path: string): void; + onLoadPath(path: string): void; + onImport(file: File): void; + onToggle(enabled: boolean): void; + onCommand(command: RLCommand): void; + onRemove(): void; } -export function RLPolicyPanel({paths,selectedPath,status,loading,onSelectPath,onLoadPath,onImport,onToggle,onCommand,onRemove}:RLPolicyPanelProps){ - const input=useRef(null); - const importFile=(event:ChangeEvent)=>{const file=event.target.files?.[0];if(file)onImport(file);event.target.value='';}; - const command=status?.command??{linearX:0,linearY:0,angularZ:0}; - return
- - {paths.length>0&&} -
- {status?
-
{status.taskName}{status.enabled?'推理中':'已停止'}
- -

速度指令(机身坐标系)

onCommand({...command,linearX})}/>onCommand({...command,linearY})}/>onCommand({...command,angularZ})}/>
- {status.error&&

{status.error}

} -
-
:

加载 mjlab 导出的单输入、单动作输出 policy.onnx。首个内置任务使用 47 维 Go2 actor 观测和 12 维腿部关节位置动作;Go2-W 轮电机保持零力矩。

} -
; +export function RLPolicyPanel({ + paths, + selectedPath, + status, + loading, + onSelectPath, + onLoadPath, + onImport, + onToggle, + onCommand, + onRemove, +}: RLPolicyPanelProps) { + const input = useRef(null); + const importFile = (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) onImport(file); + event.target.value = ''; + }; + const command = status?.command ?? { linearX: 0, linearY: 0, angularZ: 0 }; + return ( +
+ + {paths.length > 0 && ( + + )} +
+ + +
+ {status ? ( +
+
+ + + {status.taskName} + + {status.enabled ? '推理中' : '已停止'} +
+ + + + +
+

速度指令(机身坐标系)

+ onCommand({ ...command, linearX })} + /> + onCommand({ ...command, linearY })} + /> + onCommand({ ...command, angularZ })} + /> + +
+ {status.error && ( +

+ {status.error} +

+ )} +
+ + +
+
+ ) : ( +

+ 加载 mjlab 导出的单输入、单动作输出 policy.onnx。首个内置任务使用 47 维 Go2 actor 观测和 + 12 维腿部关节位置动作;Go2-W 轮电机保持零力矩。 +

+ )} +
+ ); } -function CommandInput({label,value,min,max,onChange}:{label:string;value:number;min:number;max:number;onChange(value:number):void}){return ;} +function CommandInput({ + label, + value, + min, + max, + onChange, +}: { + label: string; + value: number; + min: number; + max: number; + onChange(value: number): void; +}) { + return ( + + ); +} diff --git a/web_platform/src/app/components/SecondBatchComponents.test.tsx b/web_platform/src/app/components/SecondBatchComponents.test.tsx index e1166102..673b522a 100644 --- a/web_platform/src/app/components/SecondBatchComponents.test.tsx +++ b/web_platform/src/app/components/SecondBatchComponents.test.tsx @@ -1,15 +1,62 @@ -import {fireEvent,render,screen} from '@testing-library/react'; -import {ShortcutHelpDialog} from './ShortcutHelpDialog'; -import {TreeSearchField} from './TreeSearchField'; -import {ViewportHUD} from './ViewportHUD'; -import {EmptyWorkspace} from './WorkspaceOverlays'; -import {ViewerDisplayPopover} from './ViewerDisplayPopover'; -import {DEFAULT_VIEWER_DISPLAY_OPTIONS} from '../../viewer/displayOptions'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { ShortcutHelpDialog } from './ShortcutHelpDialog'; +import { TreeSearchField } from './TreeSearchField'; +import { ViewportHUD } from './ViewportHUD'; +import { EmptyWorkspace } from './WorkspaceOverlays'; +import { ViewerDisplayPopover } from './ViewerDisplayPopover'; +import { DEFAULT_VIEWER_DISPLAY_OPTIONS } from '../../viewer/displayOptions'; -describe('第二批工作台组件',()=>{ - it('搜索框发送内容并可清除',()=>{const change=vi.fn();const {rerender}=render();fireEvent.change(screen.getByRole('searchbox'),{target:{value:'arm'}});expect(change).toHaveBeenCalledWith('arm');rerender();expect(screen.getByRole('status')).toHaveTextContent('找到 2 个匹配项');fireEvent.click(screen.getByRole('button',{name:'清除搜索'}));expect(change).toHaveBeenLastCalledWith('');}); - it('快捷键帮助展示说明并支持 Escape',()=>{const close=vi.fn();render();expect(screen.getByRole('dialog',{name:'快捷键与视口操作'})).toBeVisible();expect(screen.getByText('播放 / 暂停')).toBeVisible();fireEvent.keyDown(document,{key:'Escape'});expect(close).toHaveBeenCalledTimes(1);}); - it('视口 HUD 复用状态并给出当前模式的鼠标提示',()=>{render();expect(screen.getByLabelText('视口状态')).toHaveTextContent('仿真中');expect(screen.getByLabelText('视口状态')).toHaveTextContent('关节拖动');expect(screen.getByLabelText('视口状态')).toHaveTextContent('arm');expect(screen.getByLabelText('视口操作提示')).toHaveTextContent('左键拖动关节');expect(screen.getByLabelText('视口操作提示')).toHaveTextContent('右键平移');}); - it('空工作区解释导入到仿真的三步流程',()=>{render();expect(screen.getByRole('region',{name:'导入模型工程'})).toBeVisible();expect(screen.getByRole('list',{name:'仿真工作流程'})).toHaveTextContent('导入');expect(screen.getByRole('list',{name:'仿真工作流程'})).toHaveTextContent('检查与配置');expect(screen.getByRole('list',{name:'仿真工作流程'})).toHaveTextContent('运行与调试');expect(screen.getByText('模型与资源仅在当前浏览器会话中处理')).toBeVisible();}); - it('显示浮窗切换碰撞体和结构辅助标记',()=>{const change=vi.fn();render();fireEvent.click(screen.getByRole('button',{name:'显示设置'}));expect(screen.getByRole('dialog',{name:'视图显示设置'})).toBeVisible();expect(screen.getAllByRole('switch')).toHaveLength(7);fireEvent.click(screen.getByRole('switch',{name:/碰撞体/}));expect(change).toHaveBeenCalledWith({...DEFAULT_VIEWER_DISPLAY_OPTIONS,showCollision:true});}); +describe('第二批工作台组件', () => { + it('搜索框发送内容并可清除', () => { + const change = vi.fn(); + const { rerender } = render(); + fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'arm' } }); + expect(change).toHaveBeenCalledWith('arm'); + rerender(); + expect(screen.getByRole('status')).toHaveTextContent('找到 2 个匹配项'); + fireEvent.click(screen.getByRole('button', { name: '清除搜索' })); + expect(change).toHaveBeenLastCalledWith(''); + }); + it('快捷键帮助展示说明并支持 Escape', () => { + const close = vi.fn(); + render(); + expect(screen.getByRole('dialog', { name: '快捷键与视口操作' })).toBeVisible(); + expect(screen.getByText('播放 / 暂停')).toBeVisible(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(close).toHaveBeenCalledTimes(1); + }); + it('视口 HUD 复用状态并给出当前模式的鼠标提示', () => { + render( + , + ); + expect(screen.getByLabelText('视口状态')).toHaveTextContent('仿真中'); + expect(screen.getByLabelText('视口状态')).toHaveTextContent('关节拖动'); + expect(screen.getByLabelText('视口状态')).toHaveTextContent('arm'); + expect(screen.getByLabelText('视口操作提示')).toHaveTextContent('左键拖动关节'); + expect(screen.getByLabelText('视口操作提示')).toHaveTextContent('右键平移'); + }); + it('空工作区解释导入到仿真的三步流程', () => { + render(); + expect(screen.getByRole('region', { name: '导入模型工程' })).toBeVisible(); + expect(screen.getByRole('list', { name: '仿真工作流程' })).toHaveTextContent('导入'); + expect(screen.getByRole('list', { name: '仿真工作流程' })).toHaveTextContent('检查与配置'); + expect(screen.getByRole('list', { name: '仿真工作流程' })).toHaveTextContent('运行与调试'); + expect(screen.getByText('模型与资源仅在当前浏览器会话中处理')).toBeVisible(); + }); + it('显示浮窗切换碰撞体和结构辅助标记', () => { + const change = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: '显示设置' })); + expect(screen.getByRole('dialog', { name: '视图显示设置' })).toBeVisible(); + expect(screen.getAllByRole('switch')).toHaveLength(7); + fireEvent.click(screen.getByRole('switch', { name: /碰撞体/ })); + expect(change).toHaveBeenCalledWith({ ...DEFAULT_VIEWER_DISPLAY_OPTIONS, showCollision: true }); + }); }); diff --git a/web_platform/src/app/components/SettingsDialog.tsx b/web_platform/src/app/components/SettingsDialog.tsx index 9318d8e4..5f17b6cf 100644 --- a/web_platform/src/app/components/SettingsDialog.tsx +++ b/web_platform/src/app/components/SettingsDialog.tsx @@ -1,3 +1,106 @@ -import {Dialog,PropertyRow,Select} from '../../components/ui'; -export function SettingsDialog({open,onClose,theme,angleUnit,showCollision,jointAdvanced,forceScale,onTheme,onAngleUnit,onShowCollision,onJointAdvanced,onForceScale}:{open:boolean;onClose:()=>void;theme:'light'|'dark';angleUnit:'rad'|'deg';showCollision:boolean;jointAdvanced:boolean;forceScale:number;onTheme:(value:'light'|'dark')=>void;onAngleUnit:(value:'rad'|'deg')=>void;onShowCollision:(value:boolean)=>void;onJointAdvanced:(value:boolean)=>void;onForceScale:(value:number)=>void}){return

外观

onTheme(event.target.value as 'light'|'dark')}>}/>

模型与控制

onAngleUnit(event.target.value as 'rad'|'deg')}>}/>
;} -function Check({label,checked,onChange}:{label:string;checked:boolean;onChange:(value:boolean)=>void}){return ;} +import { Dialog, PropertyRow, Select } from '../../components/ui'; +export function SettingsDialog({ + open, + onClose, + theme, + angleUnit, + showCollision, + jointAdvanced, + forceScale, + onTheme, + onAngleUnit, + onShowCollision, + onJointAdvanced, + onForceScale, +}: { + open: boolean; + onClose: () => void; + theme: 'light' | 'dark'; + angleUnit: 'rad' | 'deg'; + showCollision: boolean; + jointAdvanced: boolean; + forceScale: number; + onTheme: (value: 'light' | 'dark') => void; + onAngleUnit: (value: 'rad' | 'deg') => void; + onShowCollision: (value: boolean) => void; + onJointAdvanced: (value: boolean) => void; + onForceScale: (value: number) => void; +}) { + return ( + +
+
+

外观

+ onTheme(event.target.value as 'light' | 'dark')} + > + + + + } + /> +
+
+

模型与控制

+ onAngleUnit(event.target.value as 'rad' | 'deg')} + > + + + + } + /> + + + +
+
+
+ ); +} +function Check({ + label, + checked, + onChange, +}: { + label: string; + checked: boolean; + onChange: (value: boolean) => void; +}) { + return ( + + ); +} diff --git a/web_platform/src/app/components/ShortcutHelpDialog.tsx b/web_platform/src/app/components/ShortcutHelpDialog.tsx index fdf5a0a9..f1577a17 100644 --- a/web_platform/src/app/components/ShortcutHelpDialog.tsx +++ b/web_platform/src/app/components/ShortcutHelpDialog.tsx @@ -1,3 +1,37 @@ -import {Dialog,Kbd,Separator} from '../../components/ui'; -const shortcuts=[['Space','播放 / 暂停'],['R','重置仿真'],['1','选择模式'],['2','关节拖动'],['3','外力施加']]; -export function ShortcutHelpDialog({open,onClose}:{open:boolean;onClose:()=>void}){return

键盘快捷键

{shortcuts.map(([key,label])=>
{label}
{key}
)}

鼠标操作

  • 左键拖动:旋转相机或执行当前交互工具
  • 右键拖动:平移相机
  • 滚轮:缩放视口
  • 选择物体后可在右侧“属性”中查看信息
;} +import { Dialog, Kbd, Separator } from '../../components/ui'; +const shortcuts = [ + ['Space', '播放 / 暂停'], + ['R', '重置仿真'], + ['1', '选择模式'], + ['2', '关节拖动'], + ['3', '外力施加'], +]; +export function ShortcutHelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) { + return ( + +
+

键盘快捷键

+
+ {shortcuts.map(([key, label]) => ( +
+
{label}
+
+ {key} +
+
+ ))} +
+
+ +
+

鼠标操作

+
    +
  • 左键拖动:旋转相机或执行当前交互工具
  • +
  • 右键拖动:平移相机
  • +
  • 滚轮:缩放视口
  • +
  • 选择物体后可在右侧“属性”中查看信息
  • +
+
+
+ ); +} diff --git a/web_platform/src/app/components/SidebarPanel.tsx b/web_platform/src/app/components/SidebarPanel.tsx index e1a488af..b486de6b 100644 --- a/web_platform/src/app/components/SidebarPanel.tsx +++ b/web_platform/src/app/components/SidebarPanel.tsx @@ -1,67 +1,769 @@ -import {useState,type ReactNode} from 'react'; -import {Box,FolderTree,Info,Settings2,SlidersHorizontal} from 'lucide-react'; -import type {ModelEntry} from '../../project/types'; -import {countProjectSearchResults,ProjectTree,type ProjectTreeFile} from '../../project/ProjectTree'; -import {countModelStructureSearchResults,ModelStructureTree} from '../../project/ModelStructureTree'; -import type {ActuatorInfo,ActuatorParameters,SimulationSnapshot} from '../../simulation/SimulationSession'; -import type {UrdfBaseMode,UrdfLoadMode} from '../../simulation/PhysicsAdapter'; -import type {ViewerSelection} from '../../viewer/MuJoCoViewer'; -import type {ControllerCommand,ControllerStatus} from '../../controller/types'; -import type {RLCommand,RLPolicyStatus} from '../../rl/types'; -import {Badge,Button,CollapsibleSection,CopyButton,PropertyRow,ResizablePanel,Select,Tabs} from '../../components/ui'; -import {TreeSearchField} from './TreeSearchField'; -import {ProjectBreadcrumb} from './ProjectBreadcrumb'; -import {PythonControllerPanel} from './PythonControllerPanel'; -import {RLPolicyPanel} from './RLPolicyPanel'; -import {LocalTrainingPanel} from './LocalTrainingPanel'; +import { useState, type ReactNode } from 'react'; +import { Box, FolderTree, Info, Settings2, SlidersHorizontal } from 'lucide-react'; +import type { ModelEntry } from '../../project/types'; +import { + countProjectSearchResults, + ProjectTree, + type ProjectTreeFile, +} from '../../project/ProjectTree'; +import { + countModelStructureSearchResults, + ModelStructureTree, +} from '../../project/ModelStructureTree'; +import type { + ActuatorInfo, + ActuatorParameters, + SimulationSnapshot, +} from '../../simulation/SimulationSession'; +import type { UrdfBaseMode, UrdfLoadMode } from '../../simulation/PhysicsAdapter'; +import type { ViewerSelection } from '../../viewer/MuJoCoViewer'; +import type { ControllerCommand, ControllerStatus } from '../../controller/types'; +import type { RLCommand, RLPolicyStatus } from '../../rl/types'; +import { + Badge, + Button, + CollapsibleSection, + CopyButton, + PropertyRow, + ResizablePanel, + Select, + Tabs, +} from '../../components/ui'; +import { TreeSearchField } from './TreeSearchField'; +import { ProjectBreadcrumb } from './ProjectBreadcrumb'; +import { PythonControllerPanel } from './PythonControllerPanel'; +import { RLPolicyPanel } from './RLPolicyPanel'; +import { LocalTrainingPanel } from './LocalTrainingPanel'; -export function SidebarPanel({title,side,children,visible=true}:{title:string;side:'left'|'right';children:ReactNode;visible?:boolean}){return ;} - -export function ProjectSidebar({projectName,files,entries,selectedEntry,snapshot,loading,visible=true,onRemove,onSelectEntry,onJointHover}:{projectName?:string;files:ProjectTreeFile[];entries:ModelEntry[];selectedEntry?:string;snapshot?:SimulationSnapshot;loading:boolean;visible?:boolean;onRemove:()=>void;onSelectEntry:(path:string)=>void;onJointHover:(jointId:number|null)=>void}){const [tab,setTab]=useState<'project'|'structure'>('project'),[fileQuery,setFileQuery]=useState(''),[structureQuery,setStructureQuery]=useState('');const fileMatches=countProjectSearchResults(files,fileQuery),structureMatches=snapshot?countModelStructureSearchResults(snapshot.bodies,snapshot.joints,structureQuery):0;return {projectName?<>
{projectName}
{files.length} 个文件
,content:<>
},{value:'structure',label:'模型结构',icon:,disabled:!snapshot,content:snapshot?<>
:

加载模型后显示结构

}]}/>:
导入模型后显示工程资源
}
;} - -interface ModelControlsProps{ - snapshot?:SimulationSnapshot;selection:ViewerSelection|null;selectedFormat?:ModelEntry['format'];loading:boolean;visible?:boolean; - urdfMode:UrdfLoadMode;baseMode:UrdfBaseMode;showCollision:boolean;ignoreJointLimits:boolean;jointAdvanced:boolean;angleUnit:'rad'|'deg';forceScale:number; - controllerPaths:string[];selectedControllerPath?:string;controllerStatus?:ControllerStatus; - policyPaths:string[];selectedPolicyPath?:string;policyStatus?:RLPolicyStatus; - onUrdfMode:(value:UrdfLoadMode)=>void;onBaseMode:(value:UrdfBaseMode)=>void;onShowCollision:(value:boolean)=>void; - onResetJoints:()=>void;onToggleJointLimits:()=>void;onToggleAdvanced:()=>void;onToggleAngleUnit:()=>void; - onActuator:(id:number,value:number)=>void;onActuatorParameters:(id:number,parameters:ActuatorParameters)=>void;onJoint:(id:number,value:number)=>void;onForceScale:(value:number)=>void; - onSelectControllerPath:(path:string)=>void;onLoadControllerPath:(path:string)=>void;onImportController:(file:File)=>void;onToggleController:(enabled:boolean)=>void;onControllerCommand:(command:ControllerCommand)=>void;onRemoveController:()=>void; - onSelectPolicyPath:(path:string)=>void;onLoadPolicyPath:(path:string)=>void;onImportPolicy:(file:File)=>void;onTogglePolicy:(enabled:boolean)=>void;onPolicyCommand:(command:RLCommand)=>void;onRemovePolicy:()=>void; -} -export function ModelControlsSidebar(props:ModelControlsProps){const [tab,setTab]=useState<'properties'|'controls'>('properties'),s=props.snapshot;if(!s)return
导入模型后显示属性
; - const properties=<>{s.model.nbody} Body}>
- {props.selectedFormat==='urdf'&&

MJCF 模式保留 visual mesh、添加物理地面,并将模型最低点对齐到 z=0。

} - {props.selection?
}/>}/>value.toFixed(3)).join(', ')} action={}/>
:

在视口中单击物体

}
; - const controls=<>{s.rlPolicy.enabled?'推理':'停止'}:undefined}>{s.controller.enabled?'运行':'停止'}:undefined}>{s.actuators.length}}>{s.actuators.length?s.actuators.map(actuator=>props.onActuator(actuator.id,value)} onParameters={parameters=>props.onActuatorParameters(actuator.id,parameters)}/>):

模型没有驱动器

}
- {s.joints.length}}>
{s.joints.map(joint=>{const scale=joint.type===3&&props.angleUnit==='deg'?180/Math.PI:1,unit=joint.type===3?(props.angleUnit==='deg'?'°':' rad'):joint.type===2?' m':'';return props.onJoint(joint.id,value/scale)}/>;})}
-

选择“外力施加”,在动态物体上按住拖动,松开即清零。

; - return ,content:properties},{value:'controls',label:'控制',icon:,content:controls}]}/>; +export function SidebarPanel({ + title, + side, + children, + visible = true, +}: { + title: string; + side: 'left' | 'right'; + children: ReactNode; + visible?: boolean; +}) { + return ( + + + + ); } -export function ActuatorControl({actuator,onControl,onParameters}:{actuator:ActuatorInfo;onControl:(value:number)=>void;onParameters:(parameters:ActuatorParameters)=>void}){ - const isMotor=actuator.kind==='motor',isPosition=actuator.kind==='position',editable=isMotor||isPosition,baseTargetScale=isPosition&&actuator.jointType===3?180/Math.PI:1,targetScale=isPosition&&Math.abs(actuator.gear)>1e-9?baseTargetScale/actuator.gear:baseTargetScale; - const clampForce=(value:number)=>actuator.forceLimited?Math.min(actuator.forceMax,Math.max(actuator.forceMin,value)):value,physicalScale=actuator.gear*actuator.gain; - const forceA=clampForce(actuator.min*actuator.gain)*actuator.gear,forceB=clampForce(actuator.max*actuator.gain)*actuator.gear; - const targetA=actuator.min*targetScale,targetB=actuator.max*targetScale,outputMin=isMotor?Math.min(forceA,forceB):Math.min(targetA,targetB),outputMax=isMotor?Math.max(forceA,forceB):Math.max(targetA,targetB); - const outputValue=isMotor?clampForce(actuator.value*actuator.gain)*actuator.gear:actuator.value*targetScale,outputDisabled=(isMotor&&Math.abs(physicalScale)<=1e-9)||(isPosition&&Math.abs(actuator.gear)<=1e-9); - const outputLabel=isMotor?(actuator.jointType===3?'输出力矩':'输出力'):isPosition?(actuator.jointType===3?'目标角度':'目标位置'):'控制输入'; - const forceUnit=actuator.jointType===3?'N·m':actuator.jointType===2?'N':'',jointForceA=actuator.forceMin*actuator.gear,jointForceB=actuator.forceMax*actuator.gear,jointForceMin=Math.min(jointForceA,jointForceB),jointForceMax=Math.max(jointForceA,jointForceB); - const update=(patch:Partial)=>onParameters({...actuator,...patch}),controlLabel=isPosition?(actuator.jointType===3?'角度':'位置'):'控制',gearSquared=actuator.gear*actuator.gear; - return
-
{actuator.name}
{actuator.jointName?`关节:${actuator.jointName}`:'未关联标量关节'}
{actuator.unit||'u'}
- {actuator.controlCount===1?{if(outputDisabled)return;onControl(isMotor?value/physicalScale:value/targetScale);}}/>:

该驱动器包含 {actuator.controlCount} 个控制分量,请在 MJCF 源码或专用控制器中设置。

} - {actuator.controlCount===1&&!actuator.ctrlLimited&&
onControl(value/targetScale)}/>
} - {editable?
常用参数 -
{isPosition?<>update({kp:kp/gearSquared})}/>update({kv:kv/gearSquared})}/>:<>update({kp})}/>update({kv})}/>}
- update({forceLimited})}/> -
update(actuator.gear>=0?{forceMin:value/actuator.gear}:{forceMax:value/actuator.gear})}/>update(actuator.gear>=0?{forceMax:value/actuator.gear}:{forceMin:value/actuator.gear})}/>
-

{isPosition?'position 伺服使用 kp 跟踪目标位置,kv 提供速度阻尼。':'motor 保持力/力矩控制且控制输入不限幅。MJCF 的 motor 没有 kp/kv 属性;这里的 kp、kv 会分别保存为对应 joint 的 stiffness、damping。'} 参数修改会立即作用于当前模型,并可随 MJCF 导出。

-
:

该驱动器不是可直接编辑的 motor/position 类型,控制值按模型原始单位显示;请在 MJCF 源码中修改专用参数。

} -
; +export function ProjectSidebar({ + projectName, + files, + entries, + selectedEntry, + snapshot, + loading, + visible = true, + onRemove, + onSelectEntry, + onJointHover, +}: { + projectName?: string; + files: ProjectTreeFile[]; + entries: ModelEntry[]; + selectedEntry?: string; + snapshot?: SimulationSnapshot; + loading: boolean; + visible?: boolean; + onRemove: () => void; + onSelectEntry: (path: string) => void; + onJointHover: (jointId: number | null) => void; +}) { + const [tab, setTab] = useState<'project' | 'structure'>('project'), + [fileQuery, setFileQuery] = useState(''), + [structureQuery, setStructureQuery] = useState(''); + const fileMatches = countProjectSearchResults(files, fileQuery), + structureMatches = snapshot + ? countModelStructureSearchResults(snapshot.bodies, snapshot.joints, structureQuery) + : 0; + return ( + + {projectName ? ( + <> +
+
+
+ {projectName} +
+
{files.length} 个文件
+
+ +
+ + , + content: ( + <> + +
+ +
+ + ), + }, + { + value: 'structure', + label: '模型结构', + icon: , + disabled: !snapshot, + content: snapshot ? ( + <> + +
+ +
+ + ) : ( +

加载模型后显示结构

+ ), + }, + ]} + /> + + ) : ( +
导入模型后显示工程资源
+ )} +
+ ); +} + +interface ModelControlsProps { + snapshot?: SimulationSnapshot; + selection: ViewerSelection | null; + selectedFormat?: ModelEntry['format']; + loading: boolean; + visible?: boolean; + urdfMode: UrdfLoadMode; + baseMode: UrdfBaseMode; + showCollision: boolean; + ignoreJointLimits: boolean; + jointAdvanced: boolean; + angleUnit: 'rad' | 'deg'; + forceScale: number; + controllerPaths: string[]; + selectedControllerPath?: string; + controllerStatus?: ControllerStatus; + policyPaths: string[]; + selectedPolicyPath?: string; + policyStatus?: RLPolicyStatus; + onUrdfMode: (value: UrdfLoadMode) => void; + onBaseMode: (value: UrdfBaseMode) => void; + onShowCollision: (value: boolean) => void; + onResetJoints: () => void; + onToggleJointLimits: () => void; + onToggleAdvanced: () => void; + onToggleAngleUnit: () => void; + onActuator: (id: number, value: number) => void; + onActuatorParameters: (id: number, parameters: ActuatorParameters) => void; + onJoint: (id: number, value: number) => void; + onForceScale: (value: number) => void; + onSelectControllerPath: (path: string) => void; + onLoadControllerPath: (path: string) => void; + onImportController: (file: File) => void; + onToggleController: (enabled: boolean) => void; + onControllerCommand: (command: ControllerCommand) => void; + onRemoveController: () => void; + onSelectPolicyPath: (path: string) => void; + onLoadPolicyPath: (path: string) => void; + onImportPolicy: (file: File) => void; + onTogglePolicy: (enabled: boolean) => void; + onPolicyCommand: (command: RLCommand) => void; + onRemovePolicy: () => void; +} +export function ModelControlsSidebar(props: ModelControlsProps) { + const [tab, setTab] = useState<'properties' | 'controls'>('properties'), + s = props.snapshot; + if (!s) + return ( + +
导入模型后显示属性
+
+ ); + const properties = ( + <> + {s.model.nbody} Body}> +
+ + + + + +
+
+ {props.selectedFormat === 'urdf' && ( + + + +

+ MJCF 模式保留 visual mesh、添加物理地面,并将模型最低点对齐到 z=0。 +

+ +
+ )} + + {props.selection ? ( +
+ } + /> + + } + /> + value.toFixed(3)).join(', ')} + action={} + /> +
+ ) : ( +

+ + 在视口中单击物体 +

+ )} +
+ + ); + const controls = ( + <> + {s.rlPolicy.enabled ? '推理' : '停止'} : undefined} + > + + + + + + {s.controller.enabled ? '运行' : '停止'} : undefined} + > + + + {s.actuators.length}} + > + {s.actuators.length ? ( + s.actuators.map((actuator) => ( + props.onActuator(actuator.id, value)} + onParameters={(parameters) => props.onActuatorParameters(actuator.id, parameters)} + /> + )) + ) : ( +

模型没有驱动器

+ )} +
+ {s.joints.length}}> +
+ + + + +
+ {s.joints.map((joint) => { + const scale = joint.type === 3 && props.angleUnit === 'deg' ? 180 / Math.PI : 1, + unit = + joint.type === 3 + ? props.angleUnit === 'deg' + ? '°' + : ' rad' + : joint.type === 2 + ? ' m' + : ''; + return ( + props.onJoint(joint.id, value / scale)} + /> + ); + })} +
+ + +

+ 选择“外力施加”,在动态物体上按住拖动,松开即清零。 +

+
+ + ); + return ( + + , + content: properties, + }, + { + value: 'controls', + label: '控制', + icon: , + content: controls, + }, + ]} + /> + + ); +} + +export function ActuatorControl({ + actuator, + onControl, + onParameters, +}: { + actuator: ActuatorInfo; + onControl: (value: number) => void; + onParameters: (parameters: ActuatorParameters) => void; +}) { + const isMotor = actuator.kind === 'motor', + isPosition = actuator.kind === 'position', + editable = isMotor || isPosition, + baseTargetScale = isPosition && actuator.jointType === 3 ? 180 / Math.PI : 1, + targetScale = + isPosition && Math.abs(actuator.gear) > 1e-9 + ? baseTargetScale / actuator.gear + : baseTargetScale; + const clampForce = (value: number) => + actuator.forceLimited + ? Math.min(actuator.forceMax, Math.max(actuator.forceMin, value)) + : value, + physicalScale = actuator.gear * actuator.gain; + const forceA = clampForce(actuator.min * actuator.gain) * actuator.gear, + forceB = clampForce(actuator.max * actuator.gain) * actuator.gear; + const targetA = actuator.min * targetScale, + targetB = actuator.max * targetScale, + outputMin = isMotor ? Math.min(forceA, forceB) : Math.min(targetA, targetB), + outputMax = isMotor ? Math.max(forceA, forceB) : Math.max(targetA, targetB); + const outputValue = isMotor + ? clampForce(actuator.value * actuator.gain) * actuator.gear + : actuator.value * targetScale, + outputDisabled = + (isMotor && Math.abs(physicalScale) <= 1e-9) || + (isPosition && Math.abs(actuator.gear) <= 1e-9); + const outputLabel = isMotor + ? actuator.jointType === 3 + ? '输出力矩' + : '输出力' + : isPosition + ? actuator.jointType === 3 + ? '目标角度' + : '目标位置' + : '控制输入'; + const forceUnit = actuator.jointType === 3 ? 'N·m' : actuator.jointType === 2 ? 'N' : '', + jointForceA = actuator.forceMin * actuator.gear, + jointForceB = actuator.forceMax * actuator.gear, + jointForceMin = Math.min(jointForceA, jointForceB), + jointForceMax = Math.max(jointForceA, jointForceB); + const update = (patch: Partial) => onParameters({ ...actuator, ...patch }), + controlLabel = isPosition ? (actuator.jointType === 3 ? '角度' : '位置') : '控制', + gearSquared = actuator.gear * actuator.gear; + return ( +
+
+
+
+ {actuator.name} +
+
+ {actuator.jointName ? `关节:${actuator.jointName}` : '未关联标量关节'} +
+
+ {actuator.unit || 'u'} +
+ {actuator.controlCount === 1 ? ( + { + if (outputDisabled) return; + onControl(isMotor ? value / physicalScale : value / targetScale); + }} + /> + ) : ( +

+ 该驱动器包含 {actuator.controlCount} 个控制分量,请在 MJCF 源码或专用控制器中设置。 +

+ )} + {actuator.controlCount === 1 && !actuator.ctrlLimited && ( +
+ onControl(value / targetScale)} + /> +
+ )} + {editable ? ( +
+ + 常用参数 + +
+ {isPosition ? ( + <> + update({ kp: kp / gearSquared })} + /> + update({ kv: kv / gearSquared })} + /> + + ) : ( + <> + update({ kp })} + /> + update({ kv })} + /> + + )} +
+ update({ forceLimited })} + /> +
+ + update( + actuator.gear >= 0 + ? { forceMin: value / actuator.gear } + : { forceMax: value / actuator.gear }, + ) + } + /> + + update( + actuator.gear >= 0 + ? { forceMax: value / actuator.gear } + : { forceMin: value / actuator.gear }, + ) + } + /> +
+

+ {isPosition + ? 'position 伺服使用 kp 跟踪目标位置,kv 提供速度阻尼。' + : 'motor 保持力/力矩控制且控制输入不限幅。MJCF 的 motor 没有 kp/kv 属性;这里的 kp、kv 会分别保存为对应 joint 的 stiffness、damping。'}{' '} + 参数修改会立即作用于当前模型,并可随 MJCF 导出。 +

+
+ ) : ( +

+ 该驱动器不是可直接编辑的 motor/position 类型,控制值按模型原始单位显示;请在 MJCF + 源码中修改专用参数。 +

+ )} +
+ ); +} +function ParameterInput({ + label, + value, + onCommit, + disabled = false, +}: { + label: string; + value: number; + onCommit: (value: number) => void; + disabled?: boolean; +}) { + return ( + + ); +} +function ParameterToggle({ + label, + checked, + onChange, +}: { + label: string; + checked: boolean; + onChange: (value: boolean) => void; +}) { + return ( + + ); +} +function Check({ + label, + checked, + onChange, +}: { + label: string; + checked: boolean; + onChange: (value: boolean) => void; +}) { + return ( + + ); +} +function ControlSlider({ + label, + value, + min, + max, + onChange, + disabled = false, + unit = '', + advanced = false, + limited = false, + limitsIgnored = false, + limitMin = 0, + limitMax = 0, +}: { + label: string; + value: number; + min: number; + max: number; + onChange: (value: number) => void; + disabled?: boolean; + unit?: string; + advanced?: boolean; + limited?: boolean; + limitsIgnored?: boolean; + limitMin?: number; + limitMax?: number; +}) { + const sane = Number.isFinite(value) ? value : 0, + format = (number: number) => `${number.toFixed(3)}${unit}`; + return ( + + ); } -function ParameterInput({label,value,onCommit,disabled=false}:{label:string;value:number;onCommit:(value:number)=>void;disabled?:boolean}){return ;} -function ParameterToggle({label,checked,onChange}:{label:string;checked:boolean;onChange:(value:boolean)=>void}){return ;} -function Check({label,checked,onChange}:{label:string;checked:boolean;onChange:(value:boolean)=>void}){return ;} -function ControlSlider({label,value,min,max,onChange,disabled=false,unit='',advanced=false,limited=false,limitsIgnored=false,limitMin=0,limitMax=0}:{label:string;value:number;min:number;max:number;onChange:(value:number)=>void;disabled?:boolean;unit?:string;advanced?:boolean;limited?:boolean;limitsIgnored?:boolean;limitMin?:number;limitMax?:number}){const sane=Number.isFinite(value)?value:0,format=(number:number)=>`${number.toFixed(3)}${unit}`;return ;} diff --git a/web_platform/src/app/components/SourceEditorDialog.tsx b/web_platform/src/app/components/SourceEditorDialog.tsx index 33ce5449..4c5e6c62 100644 --- a/web_platform/src/app/components/SourceEditorDialog.tsx +++ b/web_platform/src/app/components/SourceEditorDialog.tsx @@ -1,29 +1,248 @@ import './monacoSetup'; import Editor from '@monaco-editor/react'; -import {useCallback,useEffect,useRef,useState,type PointerEvent as ReactPointerEvent} from 'react'; -import {Check,Code2,Copy,Download,Maximize2,Minimize2,Save,X} from 'lucide-react'; -import {downloadBytes} from '../../project/cachedFiles'; -import {Button,ConfirmDialog,IconButton} from '../../components/ui'; +import { + useCallback, + useEffect, + useRef, + useState, + type PointerEvent as ReactPointerEvent, +} from 'react'; +import { Check, Code2, Copy, Download, Maximize2, Minimize2, Save, X } from 'lucide-react'; +import { downloadBytes } from '../../project/cachedFiles'; +import { Button, ConfirmDialog, IconButton } from '../../components/ui'; -function basename(path:string):string{return path.split('/').at(-1)??path;} -function contentSize(content:string):string{const bytes=new Blob([content]).size;return bytes<1024?`${bytes} B`:`${(bytes/1024).toFixed(1)} KB`;} -function xmlProblem(code:string):string|undefined{const document=new DOMParser().parseFromString(code,'application/xml'),error=document.querySelector('parsererror');return error?.textContent?.split('\n')[0]||undefined;} - -export function SourceEditorDialog({open,code:sourceCode,filePath,theme,onClose,onSave}:{open:boolean;code:string;filePath:string;theme:'light'|'dark';onClose:()=>void;onSave:(path:string,text:string)=>void|Promise}){ - const [code,setCode]=useState(sourceCode),[savedCode,setSavedCode]=useState(sourceCode),[saving,setSaving]=useState(false),[copied,setCopied]=useState(false),[maximized,setMaximized]=useState(false),[discardOpen,setDiscardOpen]=useState(false),[position,setPosition]=useState(()=>({x:Math.max(24,(window.innerWidth-900)/2),y:Math.max(52,(window.innerHeight-650)/2)})); - const drag=useRef<{x:number;y:number;left:number;top:number}|null>(null),dialog=useRef(null),previousFocus=useRef(null),dirty=code!==savedCode,problem=xmlProblem(code); - const requestClose=useCallback(()=>{if(dirty)setDiscardOpen(true);else onClose();},[dirty,onClose]); - const save=useCallback(async()=>{if(!dirty||problem)return;setSaving(true);try{await onSave(filePath,code);setSavedCode(code);}finally{setSaving(false);}},[code,dirty,filePath,onSave,problem]); - useEffect(()=>{if(!open)return;previousFocus.current=document.activeElement instanceof HTMLElement?document.activeElement:null;requestAnimationFrame(()=>dialog.current?.focus());return()=>{if(previousFocus.current&&document.contains(previousFocus.current))previousFocus.current.focus();};},[open]); - useEffect(()=>{const key=(event:KeyboardEvent)=>{if(discardOpen)return;if((event.ctrlKey||event.metaKey)&&event.key.toLowerCase()==='s'&&dirty&&!problem){event.preventDefault();void save();}else if(event.key==='Escape'){event.preventDefault();requestClose();}};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);},[dirty,discardOpen,problem,requestClose,save]); - const copy=async()=>{await navigator.clipboard.writeText(code);setCopied(true);window.setTimeout(()=>setCopied(false),1500);}; - const download=()=>downloadBytes(new TextEncoder().encode(code),basename(filePath),'application/xml'); - const pointerDown=(event:ReactPointerEvent)=>{if(maximized||event.button!==0||(event.target as HTMLElement).closest('button'))return;drag.current={x:event.clientX,y:event.clientY,left:position.x,top:position.y};event.currentTarget.setPointerCapture(event.pointerId);}; - const pointerMove=(event:ReactPointerEvent)=>{if(!drag.current)return;setPosition({x:Math.min(window.innerWidth-120,Math.max(-780,drag.current.left+event.clientX-drag.current.x)),y:Math.min(window.innerHeight-48,Math.max(0,drag.current.top+event.clientY-drag.current.y))});}; - if(!open)return null; - return <>
-
{drag.current=null;}} onDoubleClick={()=>setMaximized(value=>!value)}>
转换后的 MJCF
{filePath}
{contentSize(code)}缓存文件 · 可编辑{dirty&&已修改}setMaximized(value=>!value)}>{maximized?:}
-
setCode(value??'')} options={{automaticLayout:true,minimap:{enabled:false},fontFamily:"'JetBrains Mono','Fira Code',ui-monospace,monospace",fontSize:13,fontLigatures:true,scrollBeyondLastLine:false,wordWrap:'off',stickyScroll:{enabled:false},tabSize:2,formatOnPaste:true,formatOnType:true,lineNumbersMinChars:4,padding:{top:12,bottom:14},renderLineHighlight:'all'}}/>
-
{problem?`XML 错误:${problem}`:'✓ XML 结构正常'}
Ctrl+S 保存并重新载入MJCF / XML
-
setDiscardOpen(false)}>

当前 MJCF 源码包含未保存的修改。关闭后,这些修改将无法恢复。

; +function basename(path: string): string { + return path.split('/').at(-1) ?? path; +} +function contentSize(content: string): string { + const bytes = new Blob([content]).size; + return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`; +} +function xmlProblem(code: string): string | undefined { + const document = new DOMParser().parseFromString(code, 'application/xml'), + error = document.querySelector('parsererror'); + return error?.textContent?.split('\n')[0] || undefined; +} + +export function SourceEditorDialog({ + open, + code: sourceCode, + filePath, + theme, + onClose, + onSave, +}: { + open: boolean; + code: string; + filePath: string; + theme: 'light' | 'dark'; + onClose: () => void; + onSave: (path: string, text: string) => void | Promise; +}) { + const [code, setCode] = useState(sourceCode), + [savedCode, setSavedCode] = useState(sourceCode), + [saving, setSaving] = useState(false), + [copied, setCopied] = useState(false), + [maximized, setMaximized] = useState(false), + [discardOpen, setDiscardOpen] = useState(false), + [position, setPosition] = useState(() => ({ + x: Math.max(24, (window.innerWidth - 900) / 2), + y: Math.max(52, (window.innerHeight - 650) / 2), + })); + const drag = useRef<{ x: number; y: number; left: number; top: number } | null>(null), + dialog = useRef(null), + previousFocus = useRef(null), + dirty = code !== savedCode, + problem = xmlProblem(code); + const requestClose = useCallback(() => { + if (dirty) setDiscardOpen(true); + else onClose(); + }, [dirty, onClose]); + const save = useCallback(async () => { + if (!dirty || problem) return; + setSaving(true); + try { + await onSave(filePath, code); + setSavedCode(code); + } finally { + setSaving(false); + } + }, [code, dirty, filePath, onSave, problem]); + useEffect(() => { + if (!open) return; + previousFocus.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + requestAnimationFrame(() => dialog.current?.focus()); + return () => { + if (previousFocus.current && document.contains(previousFocus.current)) + previousFocus.current.focus(); + }; + }, [open]); + useEffect(() => { + const key = (event: KeyboardEvent) => { + if (discardOpen) return; + if ( + (event.ctrlKey || event.metaKey) && + event.key.toLowerCase() === 's' && + dirty && + !problem + ) { + event.preventDefault(); + void save(); + } else if (event.key === 'Escape') { + event.preventDefault(); + requestClose(); + } + }; + window.addEventListener('keydown', key); + return () => window.removeEventListener('keydown', key); + }, [dirty, discardOpen, problem, requestClose, save]); + const copy = async () => { + await navigator.clipboard.writeText(code); + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + }; + const download = () => + downloadBytes(new TextEncoder().encode(code), basename(filePath), 'application/xml'); + const pointerDown = (event: ReactPointerEvent) => { + if (maximized || event.button !== 0 || (event.target as HTMLElement).closest('button')) return; + drag.current = { x: event.clientX, y: event.clientY, left: position.x, top: position.y }; + event.currentTarget.setPointerCapture(event.pointerId); + }; + const pointerMove = (event: ReactPointerEvent) => { + if (!drag.current) return; + setPosition({ + x: Math.min( + window.innerWidth - 120, + Math.max(-780, drag.current.left + event.clientX - drag.current.x), + ), + y: Math.min( + window.innerHeight - 48, + Math.max(0, drag.current.top + event.clientY - drag.current.y), + ), + }); + }; + if (!open) return null; + return ( + <> +
+
+
{ + drag.current = null; + }} + onDoubleClick={() => setMaximized((value) => !value)} + > + +
+
+ 转换后的 MJCF +
+
+ {filePath} +
+
+ {contentSize(code)} + + 缓存文件 · 可编辑 + + {dirty && ( + + 已修改 + + )} + + + + setMaximized((value) => !value)} + > + {maximized ? : } + + + + +
+
+ setCode(value ?? '')} + options={{ + automaticLayout: true, + minimap: { enabled: false }, + fontFamily: "'JetBrains Mono','Fira Code',ui-monospace,monospace", + fontSize: 13, + fontLigatures: true, + scrollBeyondLastLine: false, + wordWrap: 'off', + stickyScroll: { enabled: false }, + tabSize: 2, + formatOnPaste: true, + formatOnType: true, + lineNumbersMinChars: 4, + padding: { top: 12, bottom: 14 }, + renderLineHighlight: 'all', + }} + /> +
+
+
+ {problem ? `XML 错误:${problem}` : '✓ XML 结构正常'} +
+
+ Ctrl+S 保存并重新载入 + + MJCF / XML +
+
+
+
+ setDiscardOpen(false)} + > +

+ 当前 MJCF 源码包含未保存的修改。关闭后,这些修改将无法恢复。 +

+
+ + ); } diff --git a/web_platform/src/app/components/StatusBar.tsx b/web_platform/src/app/components/StatusBar.tsx index f97658bb..1270d947 100644 --- a/web_platform/src/app/components/StatusBar.tsx +++ b/web_platform/src/app/components/StatusBar.tsx @@ -1,7 +1,51 @@ -import type {ReactNode} from 'react'; -import {Box,Clock3,MemoryStick,TriangleAlert} from 'lucide-react'; -import {Kbd} from '../../components/ui'; -import {PerformancePopover} from './PerformancePopover'; -export interface StatusBarProps{time?:number;fps:number;stepMs:number;memoryMb?:number;loaded:boolean;overBudget:boolean;} -function Item({icon:Icon,children,className=''}:{icon:typeof Clock3;children:ReactNode;className?:string}){return ;} -export function StatusBar({time,fps,stepMs,memoryMb,loaded,overBudget}:StatusBarProps){return
时间 {time?.toFixed(3)??'—'} s内存 {memoryMb===undefined?'—':`${memoryMb.toFixed(1)} MiB`}WASM {loaded?'已加载':'未加载'}{overBudget&&主线程超出步进预算,已限制追帧}Space 播放/暂停 · R 重置 · 1/2/3 模式
;} +import type { ReactNode } from 'react'; +import { Box, Clock3, MemoryStick, TriangleAlert } from 'lucide-react'; +import { Kbd } from '../../components/ui'; +import { PerformancePopover } from './PerformancePopover'; +export interface StatusBarProps { + time?: number; + fps: number; + stepMs: number; + memoryMb?: number; + loaded: boolean; + overBudget: boolean; +} +function Item({ + icon: Icon, + children, + className = '', +}: { + icon: typeof Clock3; + children: ReactNode; + className?: string; +}) { + return ( + + + ); +} +export function StatusBar({ time, fps, stepMs, memoryMb, loaded, overBudget }: StatusBarProps) { + return ( +
+ 时间 {time?.toFixed(3) ?? '—'} s + + + 内存 {memoryMb === undefined ? '—' : `${memoryMb.toFixed(1)} MiB`} + + + WASM {loaded ? '已加载' : '未加载'} + + {overBudget && ( + + + 主线程超出步进预算,已限制追帧 + + )} + + Space 播放/暂停 · R 重置 · 1/2/3 模式 + +
+ ); +} diff --git a/web_platform/src/app/components/ThirdBatchComponents.test.tsx b/web_platform/src/app/components/ThirdBatchComponents.test.tsx index e98856f7..2146c23a 100644 --- a/web_platform/src/app/components/ThirdBatchComponents.test.tsx +++ b/web_platform/src/app/components/ThirdBatchComponents.test.tsx @@ -1,8 +1,31 @@ -import {fireEvent,render,screen} from '@testing-library/react'; -import {CommandPalette,type WorkbenchCommand} from './CommandPalette'; -import {PerformancePopover} from './PerformancePopover'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { CommandPalette, type WorkbenchCommand } from './CommandPalette'; +import { PerformancePopover } from './PerformancePopover'; -describe('第三批工作台组件',()=>{ - it('命令面板可搜索并执行现有命令',()=>{const run=vi.fn(),close=vi.fn(),commands:WorkbenchCommand[]=[{id:'reset',label:'重置仿真',group:'仿真',run},{id:'theme',label:'切换主题',group:'外观',run:vi.fn()}];render();const input=screen.getByLabelText('搜索命令');fireEvent.change(input,{target:{value:'重置'}});expect(screen.queryByText('切换主题')).not.toBeInTheDocument();fireEvent.keyDown(input,{key:'Enter'});expect(run).toHaveBeenCalledTimes(1);expect(close).toHaveBeenCalledTimes(1);expect(input).toHaveAttribute('aria-controls');expect(input).toHaveAttribute('aria-activedescendant');}); - it('状态栏性能入口展示已有指标',()=>{render();fireEvent.click(screen.getByRole('button'));expect(screen.getByRole('dialog',{name:'性能详情'})).toHaveTextContent('60 FPS');expect(screen.getByRole('dialog',{name:'性能详情'})).toHaveTextContent('42.5 MiB');fireEvent.keyDown(document,{key:'k',ctrlKey:true});expect(screen.queryByRole('dialog',{name:'性能详情'})).not.toBeInTheDocument();}); +describe('第三批工作台组件', () => { + it('命令面板可搜索并执行现有命令', () => { + const run = vi.fn(), + close = vi.fn(), + commands: WorkbenchCommand[] = [ + { id: 'reset', label: '重置仿真', group: '仿真', run }, + { id: 'theme', label: '切换主题', group: '外观', run: vi.fn() }, + ]; + render(); + const input = screen.getByLabelText('搜索命令'); + fireEvent.change(input, { target: { value: '重置' } }); + expect(screen.queryByText('切换主题')).not.toBeInTheDocument(); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(run).toHaveBeenCalledTimes(1); + expect(close).toHaveBeenCalledTimes(1); + expect(input).toHaveAttribute('aria-controls'); + expect(input).toHaveAttribute('aria-activedescendant'); + }); + it('状态栏性能入口展示已有指标', () => { + render(); + fireEvent.click(screen.getByRole('button')); + expect(screen.getByRole('dialog', { name: '性能详情' })).toHaveTextContent('60 FPS'); + expect(screen.getByRole('dialog', { name: '性能详情' })).toHaveTextContent('42.5 MiB'); + fireEvent.keyDown(document, { key: 'k', ctrlKey: true }); + expect(screen.queryByRole('dialog', { name: '性能详情' })).not.toBeInTheDocument(); + }); }); diff --git a/web_platform/src/app/components/ToolbarOverflowMenu.tsx b/web_platform/src/app/components/ToolbarOverflowMenu.tsx index 56752379..be14edc1 100644 --- a/web_platform/src/app/components/ToolbarOverflowMenu.tsx +++ b/web_platform/src/app/components/ToolbarOverflowMenu.tsx @@ -1,3 +1,72 @@ -import {CircleHelp,Expand,LayoutDashboard,Maximize,Search,Settings,SunMoon} from 'lucide-react'; -import {DropdownMenu} from '../../components/ui'; -export function ToolbarOverflowMenu({fullscreen,onCommands,onLayout,onSettings,onFullscreen,onHelp,onTheme}:{fullscreen:boolean;onCommands:()=>void;onLayout:()=>void;onSettings:()=>void;onFullscreen:()=>void;onHelp:()=>void;onTheme:()=>void}){return ,onSelect:onCommands},{id:'layout',label:'布局设置',icon:,onSelect:onLayout},{id:'settings',label:'工作台设置',icon:,onSelect:onSettings},{id:'fullscreen',label:fullscreen?'退出全屏':'进入全屏',icon:fullscreen?:,onSelect:onFullscreen},{id:'help',label:'快捷键帮助',icon:,onSelect:onHelp},{id:'theme',label:'切换主题',icon:,onSelect:onTheme}]}/>;} +import { + CircleHelp, + Expand, + LayoutDashboard, + Maximize, + Search, + Settings, + SunMoon, +} from 'lucide-react'; +import { DropdownMenu } from '../../components/ui'; +export function ToolbarOverflowMenu({ + fullscreen, + onCommands, + onLayout, + onSettings, + onFullscreen, + onHelp, + onTheme, +}: { + fullscreen: boolean; + onCommands: () => void; + onLayout: () => void; + onSettings: () => void; + onFullscreen: () => void; + onHelp: () => void; + onTheme: () => void; +}) { + return ( + , + onSelect: onCommands, + }, + { + id: 'layout', + label: '布局设置', + icon: , + onSelect: onLayout, + }, + { + id: 'settings', + label: '工作台设置', + icon: , + onSelect: onSettings, + }, + { + id: 'fullscreen', + label: fullscreen ? '退出全屏' : '进入全屏', + icon: fullscreen ? : , + onSelect: onFullscreen, + }, + { + id: 'help', + label: '快捷键帮助', + icon: , + onSelect: onHelp, + }, + { + id: 'theme', + label: '切换主题', + icon: , + onSelect: onTheme, + }, + ]} + /> + ); +} diff --git a/web_platform/src/app/components/TreeSearchField.tsx b/web_platform/src/app/components/TreeSearchField.tsx index cb983f15..56d904dc 100644 --- a/web_platform/src/app/components/TreeSearchField.tsx +++ b/web_platform/src/app/components/TreeSearchField.tsx @@ -1,3 +1,46 @@ -import {Search,X} from 'lucide-react'; -import {IconButton} from '../../components/ui'; -export function TreeSearchField({value,onChange,resultCount,placeholder='搜索…',label='搜索树'}:{value:string;onChange:(value:string)=>void;resultCount?:number;placeholder?:string;label?:string}){return
{value&&resultCount!==undefined&&

找到 {resultCount} 个匹配项

}
;} +import { Search, X } from 'lucide-react'; +import { IconButton } from '../../components/ui'; +export function TreeSearchField({ + value, + onChange, + resultCount, + placeholder = '搜索…', + label = '搜索树', +}: { + value: string; + onChange: (value: string) => void; + resultCount?: number; + placeholder?: string; + label?: string; +}) { + return ( +
+
+
+ {value && resultCount !== undefined && ( +

+ 找到 {resultCount} 个匹配项 +

+ )} +
+ ); +} diff --git a/web_platform/src/app/components/UrdfImportOptionsDialog.test.tsx b/web_platform/src/app/components/UrdfImportOptionsDialog.test.tsx index 9435aded..542b881e 100644 --- a/web_platform/src/app/components/UrdfImportOptionsDialog.test.tsx +++ b/web_platform/src/app/components/UrdfImportOptionsDialog.test.tsx @@ -1,31 +1,52 @@ -import {fireEvent,render,screen} from '@testing-library/react'; -import {UrdfImportOptionsDialog} from './UrdfImportOptionsDialog'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { UrdfImportOptionsDialog } from './UrdfImportOptionsDialog'; -describe('UrdfImportOptionsDialog',()=>{ - it('默认选择关节驱动器和摄像头传感器',()=>{ - const onConfirm=vi.fn(); - render({}}/>); - expect(screen.getByRole('checkbox',{name:/为关节添加驱动器/})).toBeChecked(); - expect(screen.getByRole('checkbox',{name:/添加传感器/})).toBeChecked(); +describe('UrdfImportOptionsDialog', () => { + it('默认选择关节驱动器和摄像头传感器', () => { + const onConfirm = vi.fn(); + render( + {}} + />, + ); + expect(screen.getByRole('checkbox', { name: /为关节添加驱动器/ })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: /添加传感器/ })).toBeChecked(); expect(screen.getByLabelText('摄像头固连 Body')).toHaveValue('head_link'); - fireEvent.change(screen.getByLabelText('摄像头位置 X'),{target:{value:'0.2'}}); - fireEvent.click(screen.getByRole('button',{name:'转换并加载'})); - expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({addActuators:true,addSensors:true,sensorType:'camera',cameraMountBody:'head_link',cameraPosition:[.2,0,.05],cameraDirection:'+X'})); + fireEvent.change(screen.getByLabelText('摄像头位置 X'), { target: { value: '0.2' } }); + fireEvent.click(screen.getByRole('button', { name: '转换并加载' })); + expect(onConfirm).toHaveBeenCalledWith( + expect.objectContaining({ + addActuators: true, + addSensors: true, + sensorType: 'camera', + cameraMountBody: 'head_link', + cameraPosition: [0.2, 0, 0.05], + cameraDirection: '+X', + }), + ); }); - it('允许分别关闭自动生成项',()=>{ - const onConfirm=vi.fn(); - render({}}/>); - fireEvent.click(screen.getByRole('checkbox',{name:/为关节添加驱动器/})); - fireEvent.click(screen.getByRole('checkbox',{name:/添加传感器/})); - fireEvent.click(screen.getByRole('button',{name:'转换并加载'})); - expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({addActuators:false,addSensors:false,sensorType:'camera'})); + it('允许分别关闭自动生成项', () => { + const onConfirm = vi.fn(); + render( + {}} />, + ); + fireEvent.click(screen.getByRole('checkbox', { name: /为关节添加驱动器/ })); + fireEvent.click(screen.getByRole('checkbox', { name: /添加传感器/ })); + fireEvent.click(screen.getByRole('button', { name: '转换并加载' })); + expect(onConfirm).toHaveBeenCalledWith( + expect.objectContaining({ addActuators: false, addSensors: false, sensorType: 'camera' }), + ); }); - it('可以不添加组件并继续加载',()=>{ - const onSkip=vi.fn(); - render({}} onSkip={onSkip}/>); - fireEvent.click(screen.getByRole('button',{name:'不添加,直接加载'})); + it('可以不添加组件并继续加载', () => { + const onSkip = vi.fn(); + render( {}} onSkip={onSkip} />); + fireEvent.click(screen.getByRole('button', { name: '不添加,直接加载' })); expect(onSkip).toHaveBeenCalledOnce(); }); }); diff --git a/web_platform/src/app/components/UrdfImportOptionsDialog.tsx b/web_platform/src/app/components/UrdfImportOptionsDialog.tsx index ee41ef25..c67669f2 100644 --- a/web_platform/src/app/components/UrdfImportOptionsDialog.tsx +++ b/web_platform/src/app/components/UrdfImportOptionsDialog.tsx @@ -1,26 +1,178 @@ -import {useState,type ReactNode} from 'react'; -import {Camera,Settings2} from 'lucide-react'; -import type {CameraDirection,UrdfEnhancementOptions} from '../../project/urdfToMjcf'; -import {Button,Dialog,Select} from '../../components/ui'; +import { useState, type ReactNode } from 'react'; +import { Camera, Settings2 } from 'lucide-react'; +import type { CameraDirection, UrdfEnhancementOptions } from '../../project/urdfToMjcf'; +import { Button, Dialog, Select } from '../../components/ui'; -function OptionCard({checked,onChange,icon,title,description,children}:{checked:boolean;onChange:(checked:boolean)=>void;icon:ReactNode;title:string;description:string;children?:ReactNode}){ - return ; +function OptionCard({ + checked, + onChange, + icon, + title, + description, + children, +}: { + checked: boolean; + onChange: (checked: boolean) => void; + icon: ReactNode; + title: string; + description: string; + children?: ReactNode; +}) { + return ( + + ); } -export function UrdfImportOptionsDialog({open,path,mountBodies=[],onConfirm,onSkip}:{open:boolean;path?:string;mountBodies?:string[];onConfirm:(options:UrdfEnhancementOptions)=>void;onSkip:()=>void}){ - const [options,setOptions]=useState(()=>({addActuators:true,addSensors:true,sensorType:'camera',cameraMountBody:mountBodies.find(name=>/(head|camera|sensor|neck|头)/i.test(name))??mountBodies.at(-1),cameraPosition:[.1,0,.05],cameraDirection:'+X'})); - const setPosition=(axis:number,value:number)=>setOptions(current=>{const position:[number,number,number]=[...(current.cameraPosition??[.1,0,.05])];position[axis]=Number.isFinite(value)?value:0;return {...current,cameraPosition:position};}); - return
}> -

导入 {path} 后,是否自动补充以下仿真组件?稍后重新选择该 URDF 时仍会再次询问。

-
- setOptions(value=>({...value,addActuators}))} icon={} title="为关节添加驱动器" description="为每个 hinge/slide 关节生成控制输入不限幅的 motor 驱动器;hinge 使用 N·m、slide 使用 N。kp/kv 用于调整对应 MJCF 关节的刚度和阻尼,已有驱动器不会重复添加。"/> - setOptions(value=>({...value,addSensors}))} icon={} title="添加传感器" description="在浮动基座添加三轴陀螺仪和三轴加速度计(6轴 IMU),并添加一台 640×480 固定摄像头。"/> - {options.addSensors&&
摄像头安装参数
{(['X','Y','Z'] as const).map((axis,index)=>)}

位置和朝向均相对于所选 Body;常见 ROS 头部摄像头使用 +X 朝前、+Z 朝上。

} -
-

自动组件只写入浏览器内生成的 MJCF,不会修改本地 URDF 文件;使用“原生 URDF”加载模式时不会注入这些组件。

- ; +export function UrdfImportOptionsDialog({ + open, + path, + mountBodies = [], + onConfirm, + onSkip, +}: { + open: boolean; + path?: string; + mountBodies?: string[]; + onConfirm: (options: UrdfEnhancementOptions) => void; + onSkip: () => void; +}) { + const [options, setOptions] = useState(() => ({ + addActuators: true, + addSensors: true, + sensorType: 'camera', + cameraMountBody: + mountBodies.find((name) => /(head|camera|sensor|neck|头)/i.test(name)) ?? mountBodies.at(-1), + cameraPosition: [0.1, 0, 0.05], + cameraDirection: '+X', + })); + const setPosition = (axis: number, value: number) => + setOptions((current) => { + const position: [number, number, number] = [...(current.cameraPosition ?? [0.1, 0, 0.05])]; + position[axis] = Number.isFinite(value) ? value : 0; + return { ...current, cameraPosition: position }; + }); + return ( + + + + + } + > +

+ 导入 {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..df730bd7 100644 --- a/web_platform/src/app/components/WorkbenchHeader.test.tsx +++ b/web_platform/src/app/components/WorkbenchHeader.test.tsx @@ -1,4 +1,51 @@ -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(); + 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(); + }); +}); diff --git a/web_platform/src/app/components/WorkbenchHeader.tsx b/web_platform/src/app/components/WorkbenchHeader.tsx index 2b56ad3d..41bad505 100644 --- a/web_platform/src/app/components/WorkbenchHeader.tsx +++ b/web_platform/src/app/components/WorkbenchHeader.tsx @@ -1,6 +1,209 @@ -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 { + 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' ? : } + +
+
+ ); +} diff --git a/web_platform/src/app/components/WorkspaceOverlays.tsx b/web_platform/src/app/components/WorkspaceOverlays.tsx index 7b4797f3..fb043f2d 100644 --- a/web_platform/src/app/components/WorkspaceOverlays.tsx +++ b/web_platform/src/app/components/WorkspaceOverlays.tsx @@ -1,6 +1,129 @@ -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, + 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 ? ( +
+ +
+ ) : ( +
+ + +
+ )} +
+
+ )} + + ); +} 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/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..0a66892f 100644 --- a/web_platform/src/project/cachedFiles.test.ts +++ b/web_platform/src/project/cachedFiles.test.ts @@ -1,13 +1,77 @@ -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' }], + 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..91146716 100644 --- a/web_platform/src/project/importer.test.ts +++ b/web_platform/src/project/importer.test.ts @@ -1,21 +1,157 @@ -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 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('单文件超过限制'); + }); }); diff --git a/web_platform/src/project/importer.ts b/web_platform/src/project/importer.ts index f54085bc..df457928 100644 --- a/web_platform/src/project/importer.ts +++ b/web_platform/src/project/importer.ts @@ -1,24 +1,43 @@ -import {unzipSync} from 'fflate'; -import {DEFAULT_IMPORT_LIMITS, type ImportLimits, type ModelEntry, type ProjectFile, type ProjectManifest} from './types'; -import {convertDaeToObj} from './daeConverter'; +import { unzipSync } from 'fflate'; +import { + DEFAULT_IMPORT_LIMITS, + type ImportLimits, + type ModelEntry, + type ProjectFile, + type ProjectManifest, +} from './types'; +import { convertDaeToObj } from './daeConverter'; -const decoder = new TextDecoder('utf-8', {fatal: false}); +const decoder = new TextDecoder('utf-8', { fatal: false }); 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 +45,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 +85,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 function prepareProjectForMujoco( + manifest: ProjectManifest, + entryPath: string, +): PreparedProject { 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,58 +167,97 @@ 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(); + 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); } - 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; @@ -171,53 +265,133 @@ export function choosePreferredEntry(entries: ModelEntry[]): string | 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)}; + 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), + }; } -export async function importBrowserFiles(input: File[], limits: ImportLimits = DEFAULT_IMPORT_LIMITS): Promise { +export async function importBrowserFiles( + input: File[], + limits: ImportLimits = DEFAULT_IMPORT_LIMITS, +): 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} 字节)`); + const bytes = new Uint8Array(await input[0].arrayBuffer()); + 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; - }}); + 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; + }, + }); } 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); + 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); + 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); } -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/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..295542f8 100644 --- a/web_platform/src/project/workspace.test.ts +++ b/web_platform/src/project/workspace.test.ts @@ -1,4 +1,34 @@ -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: [], + 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/PhysicsAdapter.ts b/web_platform/src/simulation/PhysicsAdapter.ts index 7d6b24ba..c924fe7e 100644 --- a/web_platform/src/simulation/PhysicsAdapter.ts +++ b/web_platform/src/simulation/PhysicsAdapter.ts @@ -1,17 +1,33 @@ -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 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'; -export type UrdfLoadMode='mjcf'|'native'; -export type {UrdfBaseMode,UrdfEnhancementOptions}; +export type UrdfLoadMode = 'mjcf' | 'native'; +export type { UrdfBaseMode, UrdfEnhancementOptions }; export interface PhysicsAdapter { - load(manifest:ProjectManifest,entryPath:string,urdfMode?:UrdfLoadMode,baseMode?:UrdfBaseMode,enhancements?:UrdfEnhancementOptions):Promise; + load( + manifest: ProjectManifest, + entryPath: string, + urdfMode?: UrdfLoadMode, + baseMode?: UrdfBaseMode, + enhancements?: UrdfEnhancementOptions, + ): Promise; advance(now: number): FrameResult; snapshot(): SimulationSnapshot | null; setPaused(paused: boolean): void; @@ -19,21 +35,21 @@ 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; + cachedSupportFiles(): ProjectFile[]; exportMjcf(): Uint8Array; dispose(): void; } @@ -53,68 +69,204 @@ export function getMujocoModule(): Promise { export class MainThreadPhysicsAdapter implements PhysicsAdapter { session: SimulationSession | null = null; workspace: MemfsWorkspace | null = null; - private supportFiles:ProjectFile[]=[]; + private supportFiles: 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, + 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 ?? [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});} + 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 }, + ); + } } - 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(); + } + 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; + } + private releaseCurrent(): void { + this.session?.dispose(); + this.session = null; + this.workspace?.dispose(); + this.workspace = null; + this.supportFiles = []; + } + dispose(): void { + 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..a78578c4 100644 --- a/web_platform/src/simulation/SimulationSession.ts +++ b/web_platform/src/simulation/SimulationSession.ts @@ -1,17 +1,84 @@ -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'; -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; + 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 +92,298 @@ 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; - 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); + } 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)); + } + singleStep(): void { + this.runController(); + this.applyForce(); + this.module.mj_step(this.model, this.data); + } 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.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); + } + removeRLPolicy(): void { + this.rlPolicyLoadGeneration += 1; + this.rlPolicy?.dispose(); + this.rlPolicy = undefined; + this.data.ctrl.fill(0); } - 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);}})}; + 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]; } /** 用有限几何的包围球估算视图中心与范围,忽略地面等无限平面。 */ - 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(), + 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..f95ec046 100644 --- a/web_platform/src/simulation/geometry.test.ts +++ b/web_platform/src/simulation/geometry.test.ts @@ -1,7 +1,7 @@ -import {meshIdFromSceneDataId} from './geometry'; +import { meshIdFromSceneDataId } from './geometry'; -describe('meshIdFromSceneDataId',()=>{ - it('解析 mjvGeom 的完整 mesh/凸包编码',()=>{ +describe('meshIdFromSceneDataId', () => { + it('解析 mjvGeom 的完整 mesh/凸包编码', () => { expect(meshIdFromSceneDataId(0)).toBe(0); expect(meshIdFromSceneDataId(1)).toBe(0); expect(meshIdFromSceneDataId(2)).toBe(1); diff --git a/web_platform/src/simulation/geometry.ts b/web_platform/src/simulation/geometry.ts index 4acc6ff8..4d795953 100644 --- a/web_platform/src/simulation/geometry.ts +++ b/web_platform/src/simulation/geometry.ts @@ -2,6 +2,6 @@ * 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); } 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..128f0290 100644 --- a/web_platform/src/styles.css +++ b/web_platform/src/styles.css @@ -2,36 +2,129 @@ @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: #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; } -.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: #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; } -@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', 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 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; + } +} +::-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/MuJoCoViewer.ts b/web_platform/src/viewer/MuJoCoViewer.ts index 88424dd7..e4584799 100644 --- a/web_platform/src/viewer/MuJoCoViewer.ts +++ b/web_platform/src/viewer/MuJoCoViewer.ts @@ -1,101 +1,849 @@ 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 { 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'; 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; +} 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 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 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; + }; - 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 }); + 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); } - 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= 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 && 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 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.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 < 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, + 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(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; + 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 && 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.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/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..1f76f7ae 100644 --- a/web_platform/vite.config.ts +++ b/web_platform/vite.config.ts @@ -1,24 +1,91 @@ -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', +); /** 让开发服务器与生产构建都从本地 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()], + 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'], + 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 }, + }, + }, }); -- 2.52.0 From 3b4ced6437289b04d91fd9ba1fa945c4136b4158 Mon Sep 17 00:00:00 2001 From: cen617-code <1057290604@qq.com> Date: Tue, 1 Sep 2026 14:23:49 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(web-platform):=20release=20V0.7=20?= =?UTF-8?q?=E5=9C=B0=E5=9B=BE=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .prettierignore | 1 + README.md | 3 + package-lock.json | 4 +- package.json | 2 +- web_platform/README.md | 36 + web_platform/e2e/app.spec.ts | 318 +++++++ web_platform/index.html | 62 +- web_platform/src/app/App.tsx | 804 ++++++++++++++++-- .../components/FeedbackComponents.test.tsx | 25 +- .../app/components/MapAssetLibrary.test.tsx | 51 ++ .../src/app/components/MapAssetLibrary.tsx | 298 +++++++ .../app/components/MapEditorPanel.test.tsx | 106 +++ .../src/app/components/MapEditorPanel.tsx | 597 +++++++++++++ .../app/components/PhysicalMapPanel.test.tsx | 118 +++ .../src/app/components/PhysicalMapPanel.tsx | 474 +++++++++++ .../src/app/components/SidebarPanel.tsx | 137 ++- web_platform/src/app/components/StatusBar.tsx | 17 + .../app/components/WorkbenchHeader.test.tsx | 9 +- .../src/app/components/WorkbenchHeader.tsx | 28 +- .../src/app/components/WorkspaceOverlays.tsx | 195 +++-- web_platform/src/map/MapComposer.test.ts | 174 ++++ web_platform/src/map/MapComposer.ts | 263 ++++++ web_platform/src/map/MapLoader.test.ts | 118 +++ web_platform/src/map/MapLoader.ts | 103 +++ .../src/map/editor/MapDocumentCompiler.ts | 54 ++ .../map/editor/MapDocumentImporter.test.ts | 65 ++ .../src/map/editor/MapDocumentImporter.ts | 230 +++++ web_platform/src/map/editor/MapEditSession.ts | 151 ++++ .../src/map/editor/MapPackageExporter.ts | 64 ++ web_platform/src/map/editor/assetCatalog.ts | 58 ++ web_platform/src/map/editor/editor.test.ts | 162 ++++ web_platform/src/map/editor/editorSchema.ts | 188 ++++ web_platform/src/map/editor/placement.ts | 81 ++ web_platform/src/map/editor/types.ts | 88 ++ web_platform/src/map/mapPaths.test.ts | 23 + web_platform/src/map/mapPaths.ts | 44 + web_platform/src/map/mapSchema.ts | 138 +++ web_platform/src/map/physicalMap.test.ts | 156 ++++ web_platform/src/map/physicalMap.ts | 295 +++++++ web_platform/src/map/terrainGenerator.test.ts | 63 ++ web_platform/src/map/terrainGenerator.ts | 288 +++++++ web_platform/src/map/types.ts | 140 +++ web_platform/src/project/cachedFiles.test.ts | 1 + web_platform/src/project/importer.test.ts | 60 +- web_platform/src/project/importer.ts | 146 +++- web_platform/src/project/types.ts | 12 + web_platform/src/project/workspace.test.ts | 1 + web_platform/src/simulation/PhysicsAdapter.ts | 151 +++- web_platform/src/simulation/geometry.test.ts | 27 +- web_platform/src/simulation/geometry.ts | 54 ++ web_platform/src/styles.css | 102 ++- .../src/viewer/MapEditorLayer.test.ts | 110 +++ web_platform/src/viewer/MapEditorLayer.ts | 267 ++++++ web_platform/src/viewer/MuJoCoViewer.ts | 148 +++- .../src/viewer/VisualMapLayer.test.ts | 72 ++ web_platform/src/viewer/VisualMapLayer.ts | 142 ++++ web_platform/vite.config.ts | 31 +- 58 files changed, 7287 insertions(+), 269 deletions(-) create mode 100644 web_platform/src/app/components/MapAssetLibrary.test.tsx create mode 100644 web_platform/src/app/components/MapAssetLibrary.tsx create mode 100644 web_platform/src/app/components/MapEditorPanel.test.tsx create mode 100644 web_platform/src/app/components/MapEditorPanel.tsx create mode 100644 web_platform/src/app/components/PhysicalMapPanel.test.tsx create mode 100644 web_platform/src/app/components/PhysicalMapPanel.tsx create mode 100644 web_platform/src/map/MapComposer.test.ts create mode 100644 web_platform/src/map/MapComposer.ts create mode 100644 web_platform/src/map/MapLoader.test.ts create mode 100644 web_platform/src/map/MapLoader.ts create mode 100644 web_platform/src/map/editor/MapDocumentCompiler.ts create mode 100644 web_platform/src/map/editor/MapDocumentImporter.test.ts create mode 100644 web_platform/src/map/editor/MapDocumentImporter.ts create mode 100644 web_platform/src/map/editor/MapEditSession.ts create mode 100644 web_platform/src/map/editor/MapPackageExporter.ts create mode 100644 web_platform/src/map/editor/assetCatalog.ts create mode 100644 web_platform/src/map/editor/editor.test.ts create mode 100644 web_platform/src/map/editor/editorSchema.ts create mode 100644 web_platform/src/map/editor/placement.ts create mode 100644 web_platform/src/map/editor/types.ts create mode 100644 web_platform/src/map/mapPaths.test.ts create mode 100644 web_platform/src/map/mapPaths.ts create mode 100644 web_platform/src/map/mapSchema.ts create mode 100644 web_platform/src/map/physicalMap.test.ts create mode 100644 web_platform/src/map/physicalMap.ts create mode 100644 web_platform/src/map/terrainGenerator.test.ts create mode 100644 web_platform/src/map/terrainGenerator.ts create mode 100644 web_platform/src/map/types.ts create mode 100644 web_platform/src/viewer/MapEditorLayer.test.ts create mode 100644 web_platform/src/viewer/MapEditorLayer.ts create mode 100644 web_platform/src/viewer/VisualMapLayer.test.ts create mode 100644 web_platform/src/viewer/VisualMapLayer.ts diff --git a/.gitignore b/.gitignore index 7cdc71b1..dc57c6d5 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ playwright-report/ web_platform/test-results/ web_platform/playwright-report/ web_platform/node_modules/.vite/ +.playwright-cli/ *.tsbuildinfo # Python diff --git a/.prettierignore b/.prettierignore index 23de50ee..944d5227 100644 --- a/.prettierignore +++ b/.prettierignore @@ -9,6 +9,7 @@ web-platform-dist/ coverage/ playwright-report/ test-results/ +.playwright-cli/ web_platform/fixtures/ web_platform/public/ package-lock.json diff --git a/README.md b/README.md index c92040c1..f7467dee 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,9 @@ - ROS `package://`、常见 URDF 兼容转换及 DAE 降级处理 - Three.js 模型、碰撞体、坐标系、关节轴、质心和惯量可视化 - 播放、暂停、单步、重置、变速、关节拖动与外力交互 +- 内置平地、坡道、楼梯、随机障碍物及 9 类系统参数化地形(粗糙/波浪、金字塔阶梯、深坑、沟壑等) +- 工程地图包:静态 MJCF/OBJ/STL/高度场碰撞层、GLB 视觉层和机器人出生点 +- V3 地图创作层:认证资产库支持点击添加、拖到画布落位和首个资产自动创建场景,并可通过表单与视口操纵器继续编辑、事务式应用及导出地图 ZIP - 浏览器内 Python 控制器(Pyodide) - ONNX 强化学习策略推理(ONNX Runtime Web) - 可选的本机 mjlab 训练桥接服务 diff --git a/package-lock.json b/package-lock.json index 7cb572fb..644be042 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mujoco-web-platform", - "version": "0.6.1", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mujoco-web-platform", - "version": "0.6.1", + "version": "0.7.0", "license": "Apache-2.0", "dependencies": { "@monaco-editor/react": "^4.7.0", diff --git a/package.json b/package.json index 898d3ecf..249bf2af 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mujoco-web-platform", - "version": "0.6.1", + "version": "0.7.0", "description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台", "private": true, "type": "module", diff --git a/web_platform/README.md b/web_platform/README.md index bdec43b2..d54d719a 100644 --- a/web_platform/README.md +++ b/web_platform/README.md @@ -12,6 +12,7 @@ - Three.js primitive、mesh、材质/贴图显示与对象选择 - 播放、暂停、单步、重置、0.25×–4× 速度 - actuator 滑杆、hinge/slide 关节拖动、动态 body 外力拖拽 +- 内置平地、坡道、楼梯、可复现随机障碍物及 9 类系统参数化地形,可配置尺寸、摩擦、难度、种子与高度场采样精度 - 导入单文件 `.py` 控制器,通过本地 Pyodide 在 `mj_step` 前按仿真时间同步执行 - 导入 mjlab 导出的 `policy.onnx`,在浏览器本地执行 Go2-W 平衡/速度策略推理 - 从图形界面向本机训练桥接服务发起 mjlab 强化学习训练、查看进度/日志、停止任务并导入训练生成的 ONNX @@ -61,6 +62,41 @@ python3 -m http.server 8080 --directory web-platform-dist - 默认限制:2000 个文件、单文件 128 MiB、总解压大小 512 MiB、ZIP 文件 128 MiB。 - 文件夹或 ZIP 中的 `.py` 会显示在“控制 → Python 控制器”;也可以在加载模型后单独导入不超过 1 MiB 的 `.py`。 +## 地图模块 + +模型加载后打开右侧“地图”标签,可以选择平地、坡道、楼梯、随机障碍物、系统参数化地形或工程内地图包。参数化地形包括离散障碍、沟壑、倒金字塔阶梯、深坑、金字塔阶梯、轨道、随机粗糙、踏石和波浪地形;相同参数与随机种子会确定性生成相同碰撞层。点击“应用并重新编译”后,平台会在当前入口同目录生成临时组合 MJCF;原始工程文件不会被修改。模型编译失败时保留上一个可用仿真会话。 + +工程地图由 `map.json`、静态 MJCF 碰撞层和可选的自包含 GLB 视觉层组成: + +```text +maps/warehouse/ +├── map.json +├── physics/world.xml +├── physics/meshes/*.obj +├── visuals/scene.glb +└── authoring/map.scene.json # V3 可选创作层 +``` + +最小描述示例: + +```json +{ + "schemaVersion": 1, + "id": "warehouse", + "name": "仓库", + "coordinateSystem": { "units": "m", "up": "Z", "forward": "+X" }, + "physics": { "source": "physics/world.xml" }, + "visual": { "source": "visuals/scene.glb" }, + "spawnPoints": [{ "id": "main", "name": "主入口", "position": [0, 0, 0.35], "yawDeg": 0 }] +} +``` + +物理地图仅允许静态 `worldbody` 以及 mesh、heightfield、texture、material 等基础 asset,不允许 joint、mocap body、actuator、sensor、include 或 default class。OBJ/STL 应使用简化碰撞模型;高精度模型只放入 GLB。GLB 必须是 2.0 自包含文件,外部 URI 会被拒绝。地图统一使用米制、Z-up、+X 前向坐标系。 + +V3 可编辑地图使用 `schemaVersion: 2`,并增加 `"authoring": { "source": "authoring/map.scene.json" }`。创作层支持方盒、圆柱、胶囊、坡道、楼梯和出生点。“场景 · 资产库”中的认证资产可点击添加或拖到画布落位;没有可编辑地图时,首个资产会立即创建 Schema V2 场景草稿,不触发 MuJoCo 重编译。新增对象支持三种放置方式:自动贴地、沿世界 `-Z` 落到最高静态承载面的自动重力落位,以及禁止位姿编辑的锁定模式。只有点击“应用并重新编译”后才提交物理层。可以通过表单或视口 TransformControls 修改位置、绕 Z 轴旋转和原语尺寸,支持移动/旋转吸附、视口拾取、复制、删除及对齐地面;`W`/`E`/`S` 切换移动、旋转和缩放工具,`Delete` 删除,`Ctrl+Z`/`Ctrl+Y` 撤销重做。编辑只更新 Three.js 草稿预览,点击编辑器内“应用并重新编译”后才生成确定性的静态 MJCF。失败时保留旧仿真和草稿。浏览器不会直接写回原目录,可使用“导出地图 ZIP”下载当前已提交地图包。没有 `authoring.source` 的 V1/V2 地图默认只读;仅由 `box`、`cylinder`、`capsule` 构成且不含 asset、材质、碰撞过滤或隐藏姿态语义的静态 MJCF,可通过“创建可编辑副本”显式升级。转换会生成 `authoring/map.scene.json`、Schema V2 描述和确定性物理层;任何不可逆语义都会导致整体拒绝,不会静默丢失内容。 + +物理地图超过 2000 个 geom 会产生性能警告,超过 10000 个会被拒绝;GLB 超过 100 万三角面会警告,超过 300 万会被拒绝。当前原生 URDF 模式不支持地图,请切换到“转换为 MJCF”。 + ## Python 控制器 Python 控制器是可信的单文件脚本,必须同步定义 `step(ctx, state)`;可选定义 `NAME`、`CONTROL_HZ`(限制为 1–500 Hz)、`init(api)`、`command(name, state)`、`reset(state)` 和 `dispose(state)`。`init` 可用 `api.joint(name)`、`api.actuator(name)`、`api.sensor(name)`、`api.body(name)` 预解析 ID;`step` 可用 `ctx.qpos(id)`、`ctx.qvel(id)`、`ctx.sensor(id)`、`ctx.body_quat(id)`、`ctx.body_position(id)` 读取状态,并用 `ctx.set_control(id, value)` 写入经过有限值检查和 actuator 限幅的控制量。定义 `command` 后,界面会显示停止、前进、后退、左转、右转和起跳按钮,并分别传入 `stop`、`forward`、`backward`、`turn_left`、`turn_right`、`jump`。所有回调都必须同步;异常会自动停止控制器或显示诊断,运行期异常还会暂停仿真并清零 `ctrl`。 diff --git a/web_platform/e2e/app.spec.ts b/web_platform/e2e/app.spec.ts index 7565d976..3c067464 100644 --- a/web_platform/e2e/app.spec.ts +++ b/web_platform/e2e/app.spec.ts @@ -22,6 +22,46 @@ const SIMPLE_MODEL = ` const SLIDE_DIRECTION_MODEL = ``; +function minimalGlb(): Buffer { + const json = Buffer.from( + JSON.stringify({ + asset: { version: '2.0' }, + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0 }], + meshes: [{ primitives: [{ attributes: { POSITION: 0 } }] }], + buffers: [{ byteLength: 36 }], + bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: 36, target: 34962 }], + accessors: [ + { + bufferView: 0, + componentType: 5126, + count: 3, + type: 'VEC3', + min: [0, 0, 0], + max: [1, 1, 0], + }, + ], + }), + ); + const jsonPadding = (4 - (json.length % 4)) % 4; + const jsonChunk = Buffer.concat([json, Buffer.alloc(jsonPadding, 0x20)]); + const positions = Buffer.from(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]).buffer); + const totalLength = 12 + 8 + jsonChunk.length + 8 + positions.length; + const glb = Buffer.alloc(totalLength); + glb.writeUInt32LE(0x46546c67, 0); + glb.writeUInt32LE(2, 4); + glb.writeUInt32LE(totalLength, 8); + glb.writeUInt32LE(jsonChunk.length, 12); + glb.writeUInt32LE(0x4e4f534a, 16); + jsonChunk.copy(glb, 20); + const binOffset = 20 + jsonChunk.length; + glb.writeUInt32LE(positions.length, binOffset); + glb.writeUInt32LE(0x004e4942, binOffset + 4); + positions.copy(glb, binOffset + 8); + return glb; +} + const LARGE_MODEL = `