From ba7ee9697c627e0c7094d31ea8319c20022e9538 Mon Sep 17 00:00:00 2001 From: cen617-code <1057290604@qq.com> Date: Fri, 21 Aug 2026 18:07:52 +0800 Subject: [PATCH 1/4] feat(web-platform): release V0.2.1 --- wasm/package-lock.json | 69 ++++++++ wasm/package.json | 2 + wasm/web_platform/V0.1_CAPABILITIES.md | 166 ++++++++++++++++++ wasm/web_platform/e2e/app.spec.ts | 31 ++++ .../fixtures/urdf_dae/robot/dae/triangle.dae | 4 + .../fixtures/urdf_dae/robot/urdf/robot.urdf | 8 + wasm/web_platform/src/app/App.tsx | 22 ++- .../src/app/components/SourceEditorDialog.tsx | 27 +++ .../src/app/components/WorkbenchHeader.tsx | 4 +- .../src/app/components/monacoSetup.ts | 8 + .../src/project/cachedFiles.test.ts | 13 ++ wasm/web_platform/src/project/cachedFiles.ts | 57 ++++++ .../src/simulation/PhysicsAdapter.ts | 16 +- wasm/web_platform/src/vite-env.d.ts | 5 + 14 files changed, 422 insertions(+), 10 deletions(-) create mode 100644 wasm/web_platform/V0.1_CAPABILITIES.md create mode 100644 wasm/web_platform/fixtures/urdf_dae/robot/dae/triangle.dae create mode 100644 wasm/web_platform/fixtures/urdf_dae/robot/urdf/robot.urdf create mode 100644 wasm/web_platform/src/app/components/SourceEditorDialog.tsx create mode 100644 wasm/web_platform/src/app/components/monacoSetup.ts create mode 100644 wasm/web_platform/src/project/cachedFiles.test.ts create mode 100644 wasm/web_platform/src/project/cachedFiles.ts create mode 100644 wasm/web_platform/src/vite-env.d.ts diff --git a/wasm/package-lock.json b/wasm/package-lock.json index 7edd2e56..3f858f82 100644 --- a/wasm/package-lock.json +++ b/wasm/package-lock.json @@ -9,9 +9,11 @@ "version": "1.0.0-alpha.1", "license": "Apache-2.0", "dependencies": { + "@monaco-editor/react": "^4.7.0", "@mujoco/mujoco": "^3.11.0", "fflate": "^0.8.3", "lucide-react": "^0.555.0", + "monaco-editor": "^0.55.1", "react": "^19.2.8", "react-dom": "^19.2.8", "zustand": "^5.0.15" @@ -931,6 +933,29 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@monaco-editor/loader": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.5.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@mujoco/mujoco": { "version": "3.11.0", "resolved": "https://registry.npmjs.org/@mujoco/mujoco/-/mujoco-3.11.0.tgz", @@ -1550,6 +1575,13 @@ "meshoptimizer": "~1.1.1" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/webxr": { "version": "0.5.24", "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", @@ -2542,6 +2574,15 @@ "license": "MIT", "peer": true }, + "node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -3801,6 +3842,18 @@ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "dev": true }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/mdn-data": { "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", @@ -3887,6 +3940,16 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "license": "MIT", + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4682,6 +4745,12 @@ "dev": true, "license": "MIT" }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, "node_modules/std-env": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", diff --git a/wasm/package.json b/wasm/package.json index 44f8981d..dd773403 100644 --- a/wasm/package.json +++ b/wasm/package.json @@ -56,9 +56,11 @@ }, "type": "module", "dependencies": { + "@monaco-editor/react": "^4.7.0", "@mujoco/mujoco": "^3.11.0", "fflate": "^0.8.3", "lucide-react": "^0.555.0", + "monaco-editor": "^0.55.1", "react": "^19.2.8", "react-dom": "^19.2.8", "zustand": "^5.0.15" diff --git a/wasm/web_platform/V0.1_CAPABILITIES.md b/wasm/web_platform/V0.1_CAPABILITIES.md new file mode 100644 index 00000000..cfc87307 --- /dev/null +++ b/wasm/web_platform/V0.1_CAPABILITIES.md @@ -0,0 +1,166 @@ +# MuJoCo Web Platform V0.1 能力总结 + +## 1. 浏览器内模型导入 + +支持以下模型和资源格式: + +- MJCF/XML +- URDF +- ZIP 工程包 +- 文件夹工程 +- OBJ、STL、DAE Mesh +- PNG、JPG 等纹理资源 +- MJCF `include` 多文件工程 +- URDF `package://` 资源路径 + +导入工程仅保存在当前浏览器会话和 WASM 文件系统中,不会修改或删除本地文件。 + +## 2. 工程资源管理 + +- 按真实目录层级展示工程文件。 +- 目录优先并按文件名排序。 +- 标记和高亮当前模型入口。 +- 导入 URDF 时,只自动展开选中 URDF 所在的目录路径。 +- 其他目录默认折叠,仍可手动展开。 +- 支持从当前会话中移除整个工程。 + +## 3. URDF 兼容处理 + +提供两种加载方式: + +- **转换为 MJCF(推荐)** +- **MuJoCo 原生 URDF** + +自动兼容处理包括: + +- 将 `package://` 地址改写为工程相对路径。 +- 在浏览器内将 DAE 转换为 OBJ。 +- 保留 DAE 单位、坐标轴和节点变换。 +- 保留 URDF visual mesh 和 Body 层级。 +- 处理 MuJoCo 不支持的重复 material。 +- DAE 转换失败时提供降级几何。 +- 自动添加 `z=0` 物理地面。 +- 计算模型最低几何点并对齐到地面。 + +## 4. 固定与浮动基座 + +URDF 转 MJCF 时可以选择: + +- **浮动基座** + - 自动添加根 `freejoint`。 + - 支持重力、碰撞和外力运动。 +- **固定基座** + - 根 Body 与世界固连。 + +切换基座类型后会自动重新转换并加载模型。 + +## 5. Three.js 模型可视化 + +- 渲染 MuJoCo Body、Geom 和 Mesh。 +- 支持 Box、Sphere、Capsule、Cylinder、Ellipsoid、Plane 和 Mesh。 +- 正确解析 MuJoCo `mjvGeom.dataid` 的 Mesh/凸包编码。 +- 支持材质颜色、透明度和基础纹理。 +- 默认显示 visual 并隐藏 collision。 +- 可切换显示碰撞几何。 +- 自动计算相机中心和观察范围。 +- 无限地面不会影响相机包围范围。 +- 支持相机旋转、缩放、平移和复位。 + +## 6. XYZ 方向指示器 + +- 右下角提供固定的 XYZ 方向指示器。 +- X、Y、Z 分别使用红、绿、蓝色。 +- 指示器随相机方向实时旋转。 +- 不参与模型拾取和交互。 + +## 7. 模型结构树 + +左侧工程文件树下方显示 MuJoCo 模型结构: + +- 按 Body 父子关系组织。 +- 显示每个 Body 所属的 Joint。 +- 支持层级展开和折叠。 +- 鼠标悬停或键盘聚焦关节时: + - 高亮关节所属 Body。 + - 在关节锚点显示黄色标记。 + - 标记随仿真运动实时更新。 + +## 8. 仿真控制 + +支持: + +- 播放和暂停。 +- 单步运行。 +- 完整重置。 +- `0.25×`~`4×` 仿真速度。 +- 实时仿真时间和物理耗时。 +- 主线程步进预算及追帧限制。 +- 播放中重置后仍可正常再次播放和暂停。 + +快捷键: + +- `Space`:播放/暂停。 +- `R`:重置。 +- `1/2/3`:切换交互模式。 + +## 9. 关节控制 + +- 使用滑块调整旋转和滑动关节。 +- 只读显示 Free、Ball 等不适合单滑块编辑的关节。 +- 调整关节时自动暂停仿真。 +- 支持重置所有可编辑关节。 +- 重置时恢复初始位置并清零对应速度。 +- 支持忽略 MuJoCo 关节限位。 +- 恢复限位时自动将超限值钳制回合法范围。 +- 高级模式显示原始上下限和“已忽略”状态。 +- 旋转关节支持弧度制和角度制切换。 +- 滑动关节保持使用米制单位。 + +## 10. Actuator 与交互工具 + +- 显示模型 Actuator。 +- 根据 `ctrlrange` 提供控制滑块。 +- 提供三种视口交互模式: + - 物体选择。 + - 关节拖动。 + - 外力施加。 +- 外力强度可调。 +- 松开鼠标后自动清除外力。 +- 显示当前选中的 Body、Geom、类型和世界位置。 + +## 11. 界面与主题 + +- 中文用户界面。 +- 左右面板支持显示、隐藏和宽度调整。 +- 支持白天与黑夜主题切换。 +- 主题选择保存到浏览器本地。 +- UI、视口、网格和 XYZ 指示器同步换色。 +- 使用约 `420 ms` 的平滑主题切换动画。 +- 支持系统“减少动态效果”设置。 + +## 12. 状态与诊断 + +实时显示: + +- 仿真时间。 +- FPS。 +- 物理步进耗时。 +- 浏览器内存使用情况。 +- WASM 加载状态。 +- Body、Joint、Geom、Actuator、qpos 和 qvel 数量。 + +错误分类包括: + +- 导入失败。 +- ZIP 解析失败。 +- 模型编译失败。 +- 渲染失败。 + +URDF 自动兼容处理会以中文列出具体修改内容。 + +## 13. 当前限制 + +- DAE 转 OBJ 主要保留几何、单位和节点变换,内嵌材质及复杂贴图不能完全保留。 +- 仿真和渲染目前运行在浏览器主线程。 +- 关节滑块主要面向 Hinge 和 Slide,Free、Ball Joint 暂不提供完整的多自由度编辑器。 +- 导入工程不会持久化,刷新页面后需要重新导入。 diff --git a/wasm/web_platform/e2e/app.spec.ts b/wasm/web_platform/e2e/app.spec.ts index a38c7753..81245f9a 100644 --- a/wasm/web_platform/e2e/app.spec.ts +++ b/wasm/web_platform/e2e/app.spec.ts @@ -1,5 +1,7 @@ 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)); @@ -98,6 +100,24 @@ test('显示中文平台骨架并加载单文件模型', async ({page}) => { await expect(page.getByRole('button',{name:'▶ 播放'})).toBeVisible(); }); +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 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); +}); + test('加载包含 include、OBJ、STL 与 PNG 的工程', async ({page}) => { await page.goto('/'); await page.locator('input[type="file"]').first().setInputFiles([ @@ -128,6 +148,17 @@ test('加载引用 OBJ 的 URDF 工程', async ({page}) => { 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(); +}); + +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 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('中等规模模型持续步进并可重复加载', async ({page}) => { diff --git a/wasm/web_platform/fixtures/urdf_dae/robot/dae/triangle.dae b/wasm/web_platform/fixtures/urdf_dae/robot/dae/triangle.dae new file mode 100644 index 00000000..f802ac81 --- /dev/null +++ b/wasm/web_platform/fixtures/urdf_dae/robot/dae/triangle.dae @@ -0,0 +1,4 @@ + +Z_UP1 1 1 1 +0 0 0 1 0 0 0 1 0 0 0 1

0 2 1 0 1 3 0 3 2 1 2 3

+
diff --git a/wasm/web_platform/fixtures/urdf_dae/robot/urdf/robot.urdf b/wasm/web_platform/fixtures/urdf_dae/robot/urdf/robot.urdf new file mode 100644 index 00000000..a5cc17c8 --- /dev/null +++ b/wasm/web_platform/fixtures/urdf_dae/robot/urdf/robot.urdf @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/wasm/web_platform/src/app/App.tsx b/wasm/web_platform/src/app/App.tsx index a4fc9475..876f8286 100644 --- a/wasm/web_platform/src/app/App.tsx +++ b/wasm/web_platform/src/app/App.tsx @@ -1,7 +1,7 @@ /* Zustand 的 action 引用稳定;初始化 viewer 与导入回调有意只创建一次。 */ /* eslint-disable react-hooks/exhaustive-deps */ import {useCallback,useEffect,useRef,useState,type ChangeEvent,type DragEvent} from 'react'; -import {Camera,ChevronLeft,ChevronRight,CircleHelp,Crosshair,Hand,Maximize,MousePointer2,PanelsTopLeft,Pause,Play,RotateCcw,Settings as SettingsIcon,SunMoon} from 'lucide-react'; +import {Camera,ChevronLeft,ChevronRight,CircleHelp,Code2,Crosshair,Download,Hand,Maximize,MousePointer2,PanelsTopLeft,Pause,Play,RotateCcw,Settings as SettingsIcon,SunMoon} from 'lucide-react'; import type {ProjectManifest} from '../project/types'; import {filesFromDrop,importBrowserFiles,ProjectImportError} from '../project/importer'; import {MainThreadPhysicsAdapter,type UrdfBaseMode,type UrdfLoadMode} from '../simulation/PhysicsAdapter'; @@ -23,14 +23,17 @@ import {dispatchLayoutWidths,LayoutSettingsDialog,type LayoutPreset} from './com import {ConfirmDialog,IconButton} from '../components/ui'; import {DiagnosticsDrawer} from './components/DiagnosticsDrawer'; import {ToolbarOverflowMenu} from './components/ToolbarOverflowMenu'; +import {SourceEditorDialog} from './components/SourceEditorDialog'; +import {downloadBytes,exportedFileName,mergeCachedFiles,readCachedText,upsertCachedMjcf} from '../project/cachedFiles'; 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 convertedCachePath(entryPath:string):string{const slash=entryPath.lastIndexOf('/');return `${slash>=0?entryPath.slice(0,slash+1):''}.__converted_mjcf_cache__.xml`;} 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); - const [forceScale,setForceScale]=useState(50),[leftOpen,setLeftOpen]=useState(true),[rightOpen,setRightOpen]=useState(true),[helpOpen,setHelpOpen]=useState(false),[commandOpen,setCommandOpen]=useState(false),[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(); + const [forceScale,setForceScale]=useState(50),[leftOpen,setLeftOpen]=useState(true),[rightOpen,setRightOpen]=useState(true),[helpOpen,setHelpOpen]=useState(false),[commandOpen,setCommandOpen]=useState(false),[sourceOpen,setSourceOpen]=useState(false),[generatedMjcf,setGeneratedMjcf]=useState(),[generatedMjcfPath,setGeneratedMjcfPath]=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(); const [urdfMode,setUrdfMode]=useState('mjcf'),urdfModeRef=useRef('mjcf'); const [baseMode,setBaseMode]=useState('floating'),baseModeRef=useRef('floating'); const [showCollision,setShowCollision]=useState(false),[theme,setTheme]=useState(initialTheme),[jointAdvanced,setJointAdvanced]=useState(false),[ignoreJointLimits,setIgnoreJointLimits]=useState(false),[angleUnit,setAngleUnit]=useState<'rad'|'deg'>('rad'); @@ -40,10 +43,10 @@ export function App(){ useEffect(()=>{viewer.current?.setShowCollision(showCollision);},[showCollision]); 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);state.setEntry(path);state.setLoading(true);setImportProgress({label:'初始化 WASM 与编译模型',value:.65});state.setDiagnostic(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);setImportProgress({label:'创建视口场景',value:.92});adapter.current.setSpeed(useAppStore.getState().speed);state.setSnapshot(snapshot);state.setPaused(true);viewer.current?.attach(adapter.current.session);const notice:WorkbenchNotification={id:++notificationId.current,title:snapshot.warnings.length?'URDF 兼容处理':'模型加载完成',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 loadEntry=useCallback(async(path:string,requestedMode?:UrdfLoadMode)=>{if(!manifest.current||loadInFlight.current)return;loadInFlight.current=true;setIgnoreJointLimits(false);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);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?'URDF 兼容处理':'模型加载完成',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 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;state.setProject(next.name,next.files.map(({path,size})=>({path,size})),next.entries,next.selectedEntry);if(next.selectedEntry)await loadEntry(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);}},[loadEntry]); const removeProject=()=>{if(state.projectName)setRemoveConfirmOpen(true);}; - const confirmRemoveProject=()=>{viewer.current?.attach(null);adapter.current.dispose();manifest.current=null;state.clearProject();setRemoveConfirmOpen(false);}; + const confirmRemoveProject=()=>{viewer.current?.attach(null);adapter.current.dispose();manifest.current=null;setGeneratedMjcf(undefined);setGeneratedMjcfPath(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')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='';}; @@ -57,6 +60,10 @@ export function App(){ 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 setJoint=(id:number,value:number)=>{adapter.current.setJointPosition(id,value);state.setPaused(true);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);}); @@ -68,6 +75,9 @@ export function App(){ {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')}, @@ -75,8 +85,8 @@ export function App(){ {id:'help',label:'查看快捷键帮助',group:'帮助',icon:,run:()=>setHelpOpen(true)}, ]; return
event.preventDefault()} onDrop={drop}> - 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()}/>}/> + 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()}/>}/>
void loadEntry(path)} onJointHover={jointId=>viewer.current?.highlightJoint(jointId)}/>
setToast(undefined)}/>{state.entries.length>1&&!state.selectedEntry&&} {state.diagnostic&&state.setDiagnostic(undefined)} onRetry={state.diagnostic.category==='模型编译'&&state.diagnostic.path?()=>void loadEntry(state.diagnostic!.path!):undefined} onOpenProject={()=>{setLeftOpen(true);state.setDiagnostic(undefined);}}/>}
setJointAdvanced(value=>!value)} onToggleAngleUnit={()=>setAngleUnit(value=>value==='rad'?'deg':'rad')} onActuator={setActuator} onJoint={setJoint} onForceScale={setForceScale}/>
- 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}”吗?

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

+ {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/wasm/web_platform/src/app/components/SourceEditorDialog.tsx b/wasm/web_platform/src/app/components/SourceEditorDialog.tsx new file mode 100644 index 00000000..1b8f3d74 --- /dev/null +++ b/wasm/web_platform/src/app/components/SourceEditorDialog.tsx @@ -0,0 +1,27 @@ +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,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),[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),dirty=code!==savedCode,problem=xmlProblem(code); + 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(()=>{const key=(event:KeyboardEvent)=>{if((event.ctrlKey||event.metaKey)&&event.key.toLowerCase()==='s'&&dirty&&!problem){event.preventDefault();void save();}};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);},[dirty,problem,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
+
; +} diff --git a/wasm/web_platform/src/app/components/WorkbenchHeader.tsx b/wasm/web_platform/src/app/components/WorkbenchHeader.tsx index ee67086c..743739ec 100644 --- a/wasm/web_platform/src/app/components/WorkbenchHeader.tsx +++ b/wasm/web_platform/src/app/components/WorkbenchHeader.tsx @@ -1,5 +1,5 @@ import type {ChangeEvent,ReactNode} from 'react'; -import {CircleHelp,Expand,FolderOpen,Minimize,PanelLeft,PanelRight,Pause,Play,RotateCcw,Search,StepForward,Sun,Moon,Upload} from 'lucide-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'; -export function WorkbenchHeader({paused,ready,speed,theme,loading,leftOpen,rightOpen,fullscreen,center,endActions,compactMenu,onFiles,onFolder,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;center:ReactNode;endActions?:ReactNode;compactMenu?:ReactNode;onFiles:(event:ChangeEvent)=>void;onFolder:(event:ChangeEvent)=>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'?:}
;} +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/wasm/web_platform/src/app/components/monacoSetup.ts b/wasm/web_platform/src/app/components/monacoSetup.ts new file mode 100644 index 00000000..d5700da8 --- /dev/null +++ b/wasm/web_platform/src/app/components/monacoSetup.ts @@ -0,0 +1,8 @@ +import {loader} from '@monaco-editor/react'; +import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main'; +import 'monaco-editor/esm/vs/basic-languages/xml/xml.contribution'; +import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'; + +type MonacoGlobal=typeof globalThis&{MonacoEnvironment?:{getWorker?:()=>Worker}}; +(globalThis as MonacoGlobal).MonacoEnvironment={getWorker:()=>new EditorWorker()}; +loader.config({monaco}); diff --git a/wasm/web_platform/src/project/cachedFiles.test.ts b/wasm/web_platform/src/project/cachedFiles.test.ts new file mode 100644 index 00000000..51b3e0b1 --- /dev/null +++ b/wasm/web_platform/src/project/cachedFiles.test.ts @@ -0,0 +1,13 @@ +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};} + +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/wasm/web_platform/src/project/cachedFiles.ts b/wasm/web_platform/src/project/cachedFiles.ts new file mode 100644 index 00000000..97de3d3b --- /dev/null +++ b/wasm/web_platform/src/project/cachedFiles.ts @@ -0,0 +1,57 @@ +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(); + +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 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 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 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'; + return `${stem}.${extension}`; +} diff --git a/wasm/web_platform/src/simulation/PhysicsAdapter.ts b/wasm/web_platform/src/simulation/PhysicsAdapter.ts index c70b3d41..d3af53e9 100644 --- a/wasm/web_platform/src/simulation/PhysicsAdapter.ts +++ b/wasm/web_platform/src/simulation/PhysicsAdapter.ts @@ -1,5 +1,5 @@ import loadMujoco, {type MainModule} from '@mujoco/mujoco'; -import type {ProjectManifest} from '../project/types'; +import type {ProjectFile,ProjectManifest} from '../project/types'; import {prepareProjectForMujoco} from '../project/importer'; import {groundConvertedMjcf,type UrdfBaseMode} from '../project/urdfToMjcf'; import {MemfsWorkspace} from '../project/workspace'; @@ -22,6 +22,8 @@ export interface PhysicsAdapter { setIgnoreJointLimits(ignore:boolean):void; setExternalForce(bodyId: number, force: [number, number, number]): void; clearExternalForce(): void; + cachedSupportFiles():ProjectFile[]; + exportMjcf(): Uint8Array; dispose(): void; } @@ -40,9 +42,11 @@ export function getMujocoModule(): Promise { export class MainThreadPhysicsAdapter implements PhysicsAdapter { session: SimulationSession | null = null; workspace: MemfsWorkspace | null = null; + private supportFiles:ProjectFile[]=[]; async load(manifest:ProjectManifest,entryPath:string,urdfMode:UrdfLoadMode='mjcf',baseMode:UrdfBaseMode='floating'):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]; @@ -73,6 +77,14 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter { 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();} - private releaseCurrent():void{this.session?.dispose(); this.session=null; this.workspace?.dispose(); this.workspace=null;} + 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'); + this.workspace.writeGenerated(relative,new TextEncoder().encode(this.workspace.readText(relative))); + return new TextEncoder().encode(this.workspace.readText(relative)); + } + private releaseCurrent():void{this.session?.dispose(); this.session=null; this.workspace?.dispose(); this.workspace=null;this.supportFiles=[];} dispose():void{this.releaseCurrent();} } diff --git a/wasm/web_platform/src/vite-env.d.ts b/wasm/web_platform/src/vite-env.d.ts new file mode 100644 index 00000000..c3232fa1 --- /dev/null +++ b/wasm/web_platform/src/vite-env.d.ts @@ -0,0 +1,5 @@ +/// + +declare module 'monaco-editor/esm/vs/editor/edcore.main' { + export * from 'monaco-editor'; +} -- 2.52.0 From dc494f7a2d0c80f73c04bddda35bf69ad2311e5a Mon Sep 17 00:00:00 2001 From: cen617-code <1057290604@qq.com> Date: Mon, 24 Aug 2026 11:47:34 +0800 Subject: [PATCH 2/4] feat(web-platform): release V0.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加驱动器与传感器 --- wasm/web_platform/README.md | 1 + wasm/web_platform/e2e/app.spec.ts | 23 ++++++++- wasm/web_platform/src/app/App.tsx | 30 +++++++----- .../app/components/ActuatorControl.test.tsx | 41 ++++++++++++++++ .../src/app/components/SidebarPanel.tsx | 32 +++++++++++-- .../UrdfImportOptionsDialog.test.tsx | 31 ++++++++++++ .../components/UrdfImportOptionsDialog.tsx | 26 ++++++++++ .../src/project/urdfToMjcf.test.ts | 40 +++++++++++++++- wasm/web_platform/src/project/urdfToMjcf.ts | 47 +++++++++++++++++++ .../src/simulation/PhysicsAdapter.ts | 26 ++++++---- .../src/simulation/SimulationSession.ts | 45 ++++++++++++++---- wasm/web_platform/src/viewer/MuJoCoViewer.ts | 13 +++-- 12 files changed, 316 insertions(+), 39 deletions(-) create mode 100644 wasm/web_platform/src/app/components/ActuatorControl.test.tsx create mode 100644 wasm/web_platform/src/app/components/UrdfImportOptionsDialog.test.tsx create mode 100644 wasm/web_platform/src/app/components/UrdfImportOptionsDialog.tsx diff --git a/wasm/web_platform/README.md b/wasm/web_platform/README.md index 077f3fc4..d6b61ac9 100644 --- a/wasm/web_platform/README.md +++ b/wasm/web_platform/README.md @@ -6,6 +6,7 @@ - 导入单个/多个 MJCF(XML)、URDF 和关联资源 - 兼容常见 ROS URDF:规范化重复 material、解析 `package://` 工程内资源路径 +- URDF 导入时可选为 hinge/slide 关节生成 motor 驱动器,并可用 kp/kv 调整对应 MJCF 关节刚度与阻尼,并将可调位置/朝向的摄像头固连到指定机器人 Body - 导入保留相对路径的文件夹或 ZIP 工程 - 多模型入口选择、中文加载和编译错误 - Three.js primitive、mesh、材质/贴图显示与对象选择 diff --git a/wasm/web_platform/e2e/app.spec.ts b/wasm/web_platform/e2e/app.spec.ts index 81245f9a..49bc1ab4 100644 --- a/wasm/web_platform/e2e/app.spec.ts +++ b/wasm/web_platform/e2e/app.spec.ts @@ -81,7 +81,7 @@ test('显示中文平台骨架并加载单文件模型', async ({page}) => { await page.keyboard.press('Escape'); await page.getByRole('tab',{name:'控制'}).click(); await page.getByRole('button',{name:'Actuator'}).click(); - await expect(page.getByText('motor')).toBeVisible(); + 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(); @@ -137,6 +137,10 @@ test('加载引用 OBJ 的 URDF 工程', async ({page}) => { 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(); @@ -155,9 +159,24 @@ test('加载引用 OBJ 的 URDF 工程', async ({page}) => { 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:'通知中心'}); + 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'); +}); + 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 expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000}); + 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); }); diff --git a/wasm/web_platform/src/app/App.tsx b/wasm/web_platform/src/app/App.tsx index 876f8286..925ae5dc 100644 --- a/wasm/web_platform/src/app/App.tsx +++ b/wasm/web_platform/src/app/App.tsx @@ -4,7 +4,8 @@ import {useCallback,useEffect,useRef,useState,type ChangeEvent,type DragEvent} f import {Camera,ChevronLeft,ChevronRight,CircleHelp,Code2,Crosshair,Download,Hand,Maximize,MousePointer2,PanelsTopLeft,Pause,Play,RotateCcw,Settings as SettingsIcon,SunMoon} from 'lucide-react'; import type {ProjectManifest} from '../project/types'; import {filesFromDrop,importBrowserFiles,ProjectImportError} from '../project/importer'; -import {MainThreadPhysicsAdapter,type UrdfBaseMode,type UrdfLoadMode} from '../simulation/PhysicsAdapter'; +import {MainThreadPhysicsAdapter,type UrdfBaseMode,type UrdfEnhancementOptions,type UrdfLoadMode} from '../simulation/PhysicsAdapter'; +import type {ActuatorParameters} from '../simulation/SimulationSession'; import {MuJoCoViewer,type InteractionMode,type ViewerTheme} from '../viewer/MuJoCoViewer'; import {useAppStore,type AppDiagnostic} from '../stores/useAppStore'; import {WorkbenchHeader} from './components/WorkbenchHeader'; @@ -20,34 +21,40 @@ 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 {ConfirmDialog,IconButton} from '../components/ui'; +import {Button,ConfirmDialog,IconButton} from '../components/ui'; import {DiagnosticsDrawer} from './components/DiagnosticsDrawer'; import {ToolbarOverflowMenu} from './components/ToolbarOverflowMenu'; import {SourceEditorDialog} from './components/SourceEditorDialog'; +import {UrdfImportOptionsDialog} from './components/UrdfImportOptionsDialog'; import {downloadBytes,exportedFileName,mergeCachedFiles,readCachedText,upsertCachedMjcf} from '../project/cachedFiles'; 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 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); - const [forceScale,setForceScale]=useState(50),[leftOpen,setLeftOpen]=useState(true),[rightOpen,setRightOpen]=useState(true),[helpOpen,setHelpOpen]=useState(false),[commandOpen,setCommandOpen]=useState(false),[sourceOpen,setSourceOpen]=useState(false),[generatedMjcf,setGeneratedMjcf]=useState(),[generatedMjcfPath,setGeneratedMjcfPath]=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(); + 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(true),[rightOpen,setRightOpen]=useState(true),[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(); const [urdfMode,setUrdfMode]=useState('mjcf'),urdfModeRef=useRef('mjcf'); const [baseMode,setBaseMode]=useState('floating'),baseModeRef=useRef('floating'); - const [showCollision,setShowCollision]=useState(false),[theme,setTheme]=useState(initialTheme),[jointAdvanced,setJointAdvanced]=useState(false),[ignoreJointLimits,setIgnoreJointLimits]=useState(false),[angleUnit,setAngleUnit]=useState<'rad'|'deg'>('rad'); + const [showCollision,setShowCollision]=useState(false),[showSensorCamera,setShowSensorCamera]=useState(true),[theme,setTheme]=useState(initialTheme),[jointAdvanced,setJointAdvanced]=useState(false),[ignoreJointLimits,setIgnoreJointLimits]=useState(false),[angleUnit,setAngleUnit]=useState<'rad'|'deg'>('rad'); 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);},onError:error=>state.setDiagnostic(diagnostic('渲染',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?.setShowCollision(showCollision);},[showCollision]); + 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);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);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?'URDF 兼容处理':'模型加载完成',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 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;state.setProject(next.name,next.files.map(({path,size})=>({path,size})),next.entries,next.selectedEntry);if(next.selectedEntry)await loadEntry(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);}},[loadEntry]); + const loadEntry=useCallback(async(path:string,requestedMode?:UrdfLoadMode)=>{if(!manifest.current||loadInFlight.current)return;loadInFlight.current=true;setIgnoreJointLimits(false);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?'URDF 兼容处理':'模型加载完成',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;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);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')void loadEntry(entry.path,value);}; + const confirmRemoveProject=()=>{viewer.current?.attach(null);adapter.current.dispose();manifest.current=null;setGeneratedMjcf(undefined);setGeneratedMjcfPath(undefined);setPendingUrdfPath(undefined);setPendingUrdfMounts([]);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));}})();}; @@ -59,6 +66,7 @@ export function App(){ 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 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);}; @@ -86,7 +94,7 @@ export function App(){ ]; 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()}/>}/> -
void loadEntry(path)} onJointHover={jointId=>viewer.current?.highlightJoint(jointId)}/>
setToast(undefined)}/>{state.entries.length>1&&!state.selectedEntry&&} {state.diagnostic&&state.setDiagnostic(undefined)} onRetry={state.diagnostic.category==='模型编译'&&state.diagnostic.path?()=>void loadEntry(state.diagnostic!.path!):undefined} onOpenProject={()=>{setLeftOpen(true);state.setDiagnostic(undefined);}}/>}
setJointAdvanced(value=>!value)} onToggleAngleUnit={()=>setAngleUnit(value=>value==='rad'?'deg':'rad')} onActuator={setActuator} onJoint={setJoint} onForceScale={setForceScale}/>
- {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}”吗?

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

+
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);}}/>}
setJointAdvanced(value=>!value)} onToggleAngleUnit={()=>setAngleUnit(value=>value==='rad'?'deg':'rad')} onActuator={setActuator} onActuatorParameters={setActuatorParameters} onJoint={setJoint} onForceScale={setForceScale}/>
+ {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/wasm/web_platform/src/app/components/ActuatorControl.test.tsx b/wasm/web_platform/src/app/components/ActuatorControl.test.tsx new file mode 100644 index 00000000..08cba473 --- /dev/null +++ b/wasm/web_platform/src/app/components/ActuatorControl.test.tsx @@ -0,0 +1,41 @@ +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}; + +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'}}); + 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})); + }); + + 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={()=>{}}/>); + expect(screen.getByText('0.250')).toBeVisible(); + expect(screen.queryByText('常用参数')).not.toBeInTheDocument(); + expect(screen.getByText(/不是可直接编辑的 motor\/position/)).toBeVisible(); + }); +}); diff --git a/wasm/web_platform/src/app/components/SidebarPanel.tsx b/wasm/web_platform/src/app/components/SidebarPanel.tsx index 66cc963e..5f2758bc 100644 --- a/wasm/web_platform/src/app/components/SidebarPanel.tsx +++ b/wasm/web_platform/src/app/components/SidebarPanel.tsx @@ -3,7 +3,7 @@ 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 {SimulationSnapshot} from '../../simulation/SimulationSession'; +import type {ActuatorInfo,ActuatorParameters,SimulationSnapshot} from '../../simulation/SimulationSession'; import type {UrdfBaseMode,UrdfLoadMode} from '../../simulation/PhysicsAdapter'; import type {ViewerSelection} from '../../viewer/MuJoCoViewer'; import {Badge,Button,CollapsibleSection,CopyButton,PropertyRow,ResizablePanel,Select,Tabs} from '../../components/ui'; @@ -19,16 +19,40 @@ interface ModelControlsProps{ urdfMode:UrdfLoadMode;baseMode:UrdfBaseMode;showCollision:boolean;ignoreJointLimits:boolean;jointAdvanced:boolean;angleUnit:'rad'|'deg';forceScale:number; 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;onJoint:(id:number,value:number)=>void;onForceScale:(value:number)=>void; + onActuator:(id:number,value:number)=>void;onActuatorParameters:(id:number,parameters:ActuatorParameters)=>void;onJoint:(id:number,value:number)=>void;onForceScale:(value:number)=>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}>
+ const properties=<>{s.model.nbody} Body}>
{props.selectedFormat==='urdf'&&

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

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

在视口中单击物体

}
; - const controls=<>{s.actuators.length}}>{s.actuators.length?s.actuators.map(actuator=>props.onActuator(actuator.id,value)}/>):

模型没有 actuator

}
+ const controls=<>{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 ;} diff --git a/wasm/web_platform/src/app/components/UrdfImportOptionsDialog.test.tsx b/wasm/web_platform/src/app/components/UrdfImportOptionsDialog.test.tsx new file mode 100644 index 00000000..9435aded --- /dev/null +++ b/wasm/web_platform/src/app/components/UrdfImportOptionsDialog.test.tsx @@ -0,0 +1,31 @@ +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(); + 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'})); + }); + + 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:'不添加,直接加载'})); + expect(onSkip).toHaveBeenCalledOnce(); + }); +}); diff --git a/wasm/web_platform/src/app/components/UrdfImportOptionsDialog.tsx b/wasm/web_platform/src/app/components/UrdfImportOptionsDialog.tsx new file mode 100644 index 00000000..1e201faf --- /dev/null +++ b/wasm/web_platform/src/app/components/UrdfImportOptionsDialog.tsx @@ -0,0 +1,26 @@ +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 ; +} + +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="添加一台 640×480 固定摄像头,默认优先固连到名称含 head/camera/neck 的机器人 body。"/> + {options.addSensors&&
摄像头安装参数
{(['X','Y','Z'] as const).map((axis,index)=>)}

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

} +
+

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

+
; +} diff --git a/wasm/web_platform/src/project/urdfToMjcf.test.ts b/wasm/web_platform/src/project/urdfToMjcf.test.ts index 0aa4263f..4e9b4cc9 100644 --- a/wasm/web_platform/src/project/urdfToMjcf.test.ts +++ b/wasm/web_platform/src/project/urdfToMjcf.test.ts @@ -1,4 +1,4 @@ -import {groundConvertedMjcf} from './urdfToMjcf'; +import {enhanceConvertedMjcf,groundConvertedMjcf} from './urdfToMjcf'; const encode=(value:string)=>new TextEncoder().encode(value); const decode=(value:Uint8Array)=>new TextDecoder().decode(value); @@ -20,3 +20,41 @@ describe('groundConvertedMjcf',()=>{ 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'); + expect(result.actuatorCount).toBe(1); + 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('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.querySelector('[joint="ball"]')).toBeNull(); + }); + + 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'); + expect(result.cameraAdded).toBe(true); + 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'}); + expect(new DOMParser().parseFromString(decode(repeated.data),'application/xml').querySelectorAll('camera')).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'); + expect(document.querySelector('actuator')).toBeNull(); + expect(document.querySelector('camera')).toBeNull(); + }); +}); diff --git a/wasm/web_platform/src/project/urdfToMjcf.ts b/wasm/web_platform/src/project/urdfToMjcf.ts index 1c36fafa..ee451350 100644 --- a/wasm/web_platform/src/project/urdfToMjcf.ts +++ b/wasm/web_platform/src/project/urdfToMjcf.ts @@ -7,6 +7,53 @@ function numbers(value:string|undefined,count:number):number[]{ } 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;} + +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}`; +} + +/** 为转换后的 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; + } + if(!actuator.children.length)actuator.remove(); + } + 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; + } + } + return {data:encoder.encode(new XMLSerializer().serializeToString(document)),actuatorCount,cameraAdded}; +} /** 给 MuJoCo 从 URDF 导出的 MJCF 添加地面、设置基座类型,并整体抬升根 body。 */ export function groundConvertedMjcf(data:Uint8Array,minimumZ:number,baseMode:UrdfBaseMode='fixed'):Uint8Array { diff --git a/wasm/web_platform/src/simulation/PhysicsAdapter.ts b/wasm/web_platform/src/simulation/PhysicsAdapter.ts index d3af53e9..eb3ddb89 100644 --- a/wasm/web_platform/src/simulation/PhysicsAdapter.ts +++ b/wasm/web_platform/src/simulation/PhysicsAdapter.ts @@ -1,15 +1,15 @@ import loadMujoco, {type MainModule} from '@mujoco/mujoco'; import type {ProjectFile,ProjectManifest} from '../project/types'; import {prepareProjectForMujoco} from '../project/importer'; -import {groundConvertedMjcf,type UrdfBaseMode} from '../project/urdfToMjcf'; +import {enhanceConvertedMjcf,groundConvertedMjcf,type UrdfBaseMode,type UrdfEnhancementOptions} from '../project/urdfToMjcf'; import {MemfsWorkspace} from '../project/workspace'; -import {SimulationSession, type FrameResult, type SimulationSnapshot} from './SimulationSession'; +import {SimulationSession,type ActuatorParameters,type FrameResult,type SimulationSnapshot} from './SimulationSession'; export type UrdfLoadMode='mjcf'|'native'; -export type {UrdfBaseMode}; +export type {UrdfBaseMode,UrdfEnhancementOptions}; export interface PhysicsAdapter { - load(manifest:ProjectManifest,entryPath:string,urdfMode?:UrdfLoadMode,baseMode?:UrdfBaseMode):Promise; + load(manifest:ProjectManifest,entryPath:string,urdfMode?:UrdfLoadMode,baseMode?:UrdfBaseMode,enhancements?:UrdfEnhancementOptions):Promise; advance(now: number): FrameResult; snapshot(): SimulationSnapshot | null; setPaused(paused: boolean): void; @@ -17,6 +17,7 @@ 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; @@ -44,7 +45,7 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter { workspace: MemfsWorkspace | null = null; private supportFiles:ProjectFile[]=[]; - async load(manifest:ProjectManifest,entryPath:string,urdfMode:UrdfLoadMode='mjcf',baseMode:UrdfBaseMode='floating'):Promise { + 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{ @@ -55,8 +56,12 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter { 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'); - workspace.writeGenerated(convertedPath,groundConvertedMjcf(new TextEncoder().encode(workspace.readText(convertedPath)),minimumZ,baseMode));modelPath=workspace.path(convertedPath); + 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.cameraAdded)warnings.push(`已将 640×480 摄像头固连到 ${enhancements.cameraMountBody||'自动选择的头部/末端 body'},局部位置 ${(enhancements.cameraPosition??[.1,0,.05]).join(' ')} m,朝向 ${enhancements.cameraDirection??'+X'}`); }finally{intermediate.dispose();} } console.info('[MuJoCo] 编译模型',modelPath);const session=new SimulationSession(module,modelPath,warnings); @@ -72,6 +77,7 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter { 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);} @@ -82,8 +88,12 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter { 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'); - this.workspace.writeGenerated(relative,new TextEncoder().encode(this.workspace.readText(relative))); - return new TextEncoder().encode(this.workspace.readText(relative)); + 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();} diff --git a/wasm/web_platform/src/simulation/SimulationSession.ts b/wasm/web_platform/src/simulation/SimulationSession.ts index 22db18ae..7e8715a3 100644 --- a/wasm/web_platform/src/simulation/SimulationSession.ts +++ b/wasm/web_platform/src/simulation/SimulationSession.ts @@ -1,10 +1,11 @@ import type {MainModule, MjData, MjModel, MjvPerturb, MjvScene} from '@mujoco/mujoco'; import {meshIdFromSceneDataId} from './geometry'; -export interface ActuatorInfo {id: number; name: string; value: number; min: number; max: number; limited: 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[]; model: {nbody: number; njnt: number; ngeom: number; nu: number; nq: number; nv: number};} +export interface SimulationSnapshot {time: number; qpos: number[]; qvel: number[]; ctrl: number[]; actuators: ActuatorInfo[]; joints: JointInfo[]; bodies: BodyInfo[]; warnings: string[]; 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 { @@ -56,19 +57,38 @@ export class SimulationSession { } setActuator(id: number, value: number): void { - if (id < 0 || id >= this.model.nu) return; + 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.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();} @@ -138,12 +158,17 @@ export class SimulationSession { } snapshot(): SimulationSnapshot { - const actuators = Array.from({length: this.model.nu}, (_, 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); - return {id,name:actuator.name || `actuator_${id}`,value:Number(this.data.ctrl[address]),min:limited?Number(actuator.ctrlrange[0]):-1,max:limited?Number(actuator.ctrlrange[1]):1,limited}; + 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{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.renderer.render(this.scene,this.camera); 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; 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.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; 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]));const rotation=new THREE.Matrix4().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(rotation);this.sensorCamera.fov=Number(model.cam_fovy[id])||45;const extent=this.session.geometryBounds().extent;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(new THREE.Vector2()),width=Math.max(120,Math.min(320,size.x*.32)),height=width*9/16,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 {for(let i=0;i1e-6){this.arrow.setDirection(v.normalize());this.arrow.setLength(length,Math.min(.2,length*.2),Math.min(.1,length*.1));}} private disposeMesh(mesh:THREE.Mesh):void {(mesh.material as THREE.Material).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.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;}} + 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.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);window.removeEventListener('pointerup',this.onPointerUp);this.controls.dispose();this.orientationGizmo.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();} } -- 2.52.0 From c12e52f8439d079d7a1ef928ad47c7872f5082f5 Mon Sep 17 00:00:00 2001 From: cen617-code <1057290604@qq.com> Date: Mon, 24 Aug 2026 14:45:52 +0800 Subject: [PATCH 3/4] feat(web-platform): release V0.4 balance control --- wasm/package-lock.json | 41 ++++ wasm/package.json | 1 + wasm/src/README.md | 25 +++ wasm/src/go2_w_balance.py | 186 ++++++++++++++++++ wasm/web_platform/README.md | 11 +- wasm/web_platform/e2e/app.spec.ts | 14 ++ .../fixtures/python_controller/balance.py | 22 +++ .../fixtures/python_controller/model.xml | 13 ++ wasm/web_platform/src/app/App.tsx | 20 +- .../app/components/PythonControllerPanel.tsx | 35 ++++ .../src/app/components/SidebarPanel.tsx | 6 +- .../components/UrdfImportOptionsDialog.tsx | 2 +- .../src/controller/PythonControllerRuntime.ts | 115 +++++++++++ wasm/web_platform/src/controller/types.ts | 33 ++++ .../src/project/urdfToMjcf.test.ts | 16 +- wasm/web_platform/src/project/urdfToMjcf.ts | 29 ++- .../src/simulation/PhysicsAdapter.ts | 9 + .../src/simulation/SimulationSession.ts | 46 ++++- wasm/web_platform/vite.config.ts | 26 ++- 19 files changed, 625 insertions(+), 25 deletions(-) create mode 100644 wasm/src/README.md create mode 100644 wasm/src/go2_w_balance.py create mode 100644 wasm/web_platform/fixtures/python_controller/balance.py create mode 100644 wasm/web_platform/fixtures/python_controller/model.xml create mode 100644 wasm/web_platform/src/app/components/PythonControllerPanel.tsx create mode 100644 wasm/web_platform/src/controller/PythonControllerRuntime.ts create mode 100644 wasm/web_platform/src/controller/types.ts diff --git a/wasm/package-lock.json b/wasm/package-lock.json index 3f858f82..f9ec0335 100644 --- a/wasm/package-lock.json +++ b/wasm/package-lock.json @@ -14,6 +14,7 @@ "fflate": "^0.8.3", "lucide-react": "^0.555.0", "monaco-editor": "^0.55.1", + "pyodide": "^0.29.4", "react": "^19.2.8", "react-dom": "^19.2.8", "zustand": "^5.0.15" @@ -1497,6 +1498,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/emscripten": { + "version": "1.41.5", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", + "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -4461,6 +4468,19 @@ "node": ">=6" } }, + "node_modules/pyodide": { + "version": "0.29.4", + "resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.29.4.tgz", + "integrity": "sha512-tCseTsqU3kSxZIjkue5zXxTMNEwrKZwOIIEQRBA/VzHxFN1hoCxe4w41phfCdHd9it9RcCNQb5K/Re0InqMgvA==", + "license": "MPL-2.0", + "dependencies": { + "@types/emscripten": "^1.41.4", + "ws": "^8.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -5627,6 +5647,27 @@ "node": ">=8" } }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/wasm/package.json b/wasm/package.json index dd773403..6222b96a 100644 --- a/wasm/package.json +++ b/wasm/package.json @@ -61,6 +61,7 @@ "fflate": "^0.8.3", "lucide-react": "^0.555.0", "monaco-editor": "^0.55.1", + "pyodide": "^0.29.4", "react": "^19.2.8", "react-dom": "^19.2.8", "zustand": "^5.0.15" diff --git a/wasm/src/README.md b/wasm/src/README.md new file mode 100644 index 00000000..3ae44eb6 --- /dev/null +++ b/wasm/src/README.md @@ -0,0 +1,25 @@ +# Web 仿真控制脚本 + +该目录存放可由 MuJoCo Web 平台“控制 → Python 控制器”直接导入的可信本地脚本。 + +## Go2-W 平衡控制 + +文件:`go2_w_balance.py` + +使用条件: + +1. 可导入 `/home/cen/Embodied_Workspace/unitree_ros/robots/go2w_description/` 文件夹/ZIP,或 Unitree 官方 `/home/cen/Embodied_Workspace/unitree_mujoco/unitree_robots/go2w/` MJCF 工程; +2. 使用 URDF 时选择“转换为 MJCF”和“浮动基座”; +3. 使用 URDF 时勾选“为关节添加驱动器”和“添加传感器”,平台会在浮动基座注入6轴 IMU; +4. 模型加载后进入“控制 → Python 控制器”,导入本文件; +5. 点击“加载脚本”或直接导入后,点击“启用”,再播放仿真。 + +控制器兼容两套命名: + +- Body:官方 MJCF 的 `base_link` 或 URDF 转换模型的 `base` +- 腿关节:`FL/FR/RL/RR_{hip,thigh,calf}_joint` +- 轮关节:官方 `*_wheel_joint` 或 URDF 转换后的 `*_foot_joint` +- actuator:官方 `FL_hip`/`FL_wheel` 风格,或平台生成的 `_motor` +- 6轴 IMU:`imu_gyro`(三轴角速度)和 `imu_acc`(三轴加速度) + +脚本默认只做原地站立、基于 IMU 的 roll/pitch 调平和轮毂姿态反馈,不包含行走、转向或轨迹规划。不同接触参数、质量或初始姿态下,可在脚本顶部调整 `NOMINAL`、`KP_FINAL`、`KD`、`RAMP_SECONDS` 和 `MAX_WHEEL_TORQUE`。 diff --git a/wasm/src/go2_w_balance.py b/wasm/src/go2_w_balance.py new file mode 100644 index 00000000..ee3a6884 --- /dev/null +++ b/wasm/src/go2_w_balance.py @@ -0,0 +1,186 @@ +"""Unitree Go2-W 原地站立平衡控制器(纯 MuJoCo/Python 版本)。 + +兼容两种模型: + +* unitree_mujoco/unitree_robots/go2w/go2w.xml; +* Web 平台由 unitree_ros/go2w_description.urdf 转换出的浮动基座 MJCF。 + +控制策略参考 unitree_mujoco/example/python/stand_go2.py:站立关节角、kp=50、 +kd=3.5,并在浏览器中直接计算 motor torque。姿态由基座6轴 IMU 的角速度与 +重力加速度互补滤波获得,不包含 DDS、CRC、实机电机模式或 sim2real 逻辑。 +""" + +import math + +NAME = "Unitree Go2-W 原地平衡控制" +CONTROL_HZ = 200 + +LEG_NAMES = ("FL", "FR", "RL", "RR") + +# 来自 unitree_mujoco/example/python/stand_go2.py 的 stand_up_joint_pos。 +HIP_TARGET = {"FL": 0.00571868, "FR": -0.00571868, + "RL": 0.00571868, "RR": -0.00571868} +NOMINAL = {"thigh": 0.608813, "calf": -1.21763} +HIP_DOWN = {"FL": 0.0473455, "FR": -0.0473455, + "RL": 0.0473455, "RR": -0.0473455} +STAND_DOWN = {"thigh": 1.22187, "calf": -2.44375} + +# SDK 示例使用 kp=50、kd=3.5。力矩上限按 Go2-W URDF/MJCF 保守取值。 +KP_FINAL = 50.0 +KP_INITIAL = 20.0 +KD = 3.5 +EFFORT_LIMIT = {"hip": 23.7, "thigh": 23.7, "calf": 35.55} +RAMP_SECONDS = 1.2 +WHEEL_DAMPING = 0.12 +MAX_WHEEL_TORQUE = 6.0 + + +def _clamp(value, lower, upper): + return max(lower, min(upper, value)) + + +def _accel_attitude(acceleration): + """由 Body 局部系中的重力方向估计横滚角和俯仰角。""" + ax, ay, az = acceleration + return math.atan2(ay, az), math.atan2(-ax, math.sqrt(ay * ay + az * az)) + + +def _resolve(api, kind, names): + """依次尝试官方 MJCF 与 URDF 转换模型的命名。""" + resolver = getattr(api, kind) + last_error = None + for name in names: + try: + return resolver(name) + except Exception as error: + last_error = error + raise RuntimeError(f"无法解析 {kind},候选名称:{', '.join(names)}") from last_error + + +def init(api): + legs = [] + for prefix in LEG_NAMES: + joints = {} + actuators = {} + for part in ("hip", "thigh", "calf"): + joint_name = f"{prefix}_{part}_joint" + joints[part] = _resolve(api, "joint", (joint_name,)) + actuators[part] = _resolve( + api, "actuator", (f"{prefix}_{part}", f"{joint_name}_motor") + ) + + wheel_joint = _resolve( + api, "joint", (f"{prefix}_wheel_joint", f"{prefix}_foot_joint") + ) + wheel_actuator = _resolve( + api, + "actuator", + (f"{prefix}_wheel", f"{prefix}_wheel_joint_motor", f"{prefix}_foot_joint_motor"), + ) + legs.append({ + "name": prefix, + "joints": joints, + "actuators": actuators, + "wheel_joint": wheel_joint, + "wheel_actuator": wheel_actuator, + "side": 1.0 if prefix.endswith("L") else -1.0, + "fore": 1.0 if prefix.startswith("F") else -1.0, + }) + + return { + "base": _resolve(api, "body", ("base_link", "base")), + "imu_gyro": _resolve(api, "sensor", ("imu_gyro", "__platform_imu_gyro__")), + "imu_acc": _resolve(api, "sensor", ("imu_acc", "__platform_imu_acc__")), + "legs": legs, + "started": False, + "start_time": 0.0, + "estimated_roll": 0.0, + "estimated_pitch": 0.0, + "filtered_droll": 0.0, + "filtered_dpitch": 0.0, + "unstable_duration": 0.0, + } + + +def _initialize(ctx, state, acceleration): + state["started"] = True + state["start_time"] = ctx.time + state["estimated_roll"], state["estimated_pitch"] = _accel_attitude(acceleration) + + +def step(ctx, state): + gyro = ctx.sensor(state["imu_gyro"]) + acceleration = ctx.sensor(state["imu_acc"]) + base_position = ctx.body_position(state["base"]) + if not state["started"]: + _initialize(ctx, state, acceleration) + + dt = max(1.0e-4, ctx.dt) + rate_alpha = 0.25 + state["filtered_droll"] += rate_alpha * (gyro[0] - state["filtered_droll"]) + state["filtered_dpitch"] += rate_alpha * (gyro[1] - state["filtered_dpitch"]) + accel_roll, accel_pitch = _accel_attitude(acceleration) + fusion = 0.015 + state["estimated_roll"] = (1.0 - fusion) * ( + state["estimated_roll"] + state["filtered_droll"] * dt + ) + fusion * accel_roll + state["estimated_pitch"] = (1.0 - fusion) * ( + state["estimated_pitch"] + state["filtered_dpitch"] * dt + ) + fusion * accel_pitch + roll, pitch = state["estimated_roll"], state["estimated_pitch"] + + elapsed = max(0.0, ctx.time - state["start_time"]) + ramp = 1.0 if elapsed >= 3.0 else math.tanh(elapsed / RAMP_SECONDS) + kp = KP_INITIAL + (KP_FINAL - KP_INITIAL) * ramp + + # SDK 示例的核心:12 个腿关节平滑进入站立姿态并保持 PD 闭环。 + for leg in state["legs"]: + for part in ("hip", "thigh", "calf"): + up = HIP_TARGET[leg["name"]] if part == "hip" else NOMINAL[part] + down = HIP_DOWN[leg["name"]] if part == "hip" else STAND_DOWN[part] + desired = down + ramp * (up - down) + if part == "calf" and ramp > 0.8: + # 通过左右/前后轮腿长度差调平车身:高侧缩短,低侧伸长。 + roll_term = 0.34 * roll + 0.025 * state["filtered_droll"] + pitch_term = 0.30 * pitch + 0.022 * state["filtered_dpitch"] + desired -= leg["side"] * roll_term + desired += leg["fore"] * pitch_term + target = desired + position = ctx.qpos(leg["joints"][part]) + velocity = ctx.qvel(leg["joints"][part]) + torque = kp * (target - position) - KD * velocity + limit = EFFORT_LIMIT[part] + ctx.set_control(leg["actuators"][part], _clamp(torque, -limit, limit)) + + # Go2-W 四轮保持自由滚动,只施加温和阻尼以抑制无指令漂移。 + wheel_velocities = [ctx.qvel(leg["wheel_joint"]) for leg in state["legs"]] + average_velocity = sum(wheel_velocities) / len(wheel_velocities) + wheel_torque = 1.4 * pitch + 0.10 * state["filtered_dpitch"] + wheel_torque -= WHEEL_DAMPING * average_velocity + wheel_torque = _clamp(wheel_torque, -MAX_WHEEL_TORQUE, MAX_WHEEL_TORQUE) + for leg in state["legs"]: + ctx.set_control(leg["wheel_actuator"], wheel_torque) + + # 持续失稳时主动停止,避免倒地后控制器继续输出饱和力矩。 + unstable = ramp > 0.95 and ( + base_position[2] < 0.16 or abs(roll) > 1.0 or abs(pitch) > 1.0 + ) + state["unstable_duration"] = state["unstable_duration"] + dt if unstable else 0.0 + if state["unstable_duration"] > 0.35: + raise RuntimeError( + f"Go2-W 已失稳:z={base_position[2]:.3f} m, " + f"roll={roll:.3f} rad, pitch={pitch:.3f} rad;请重置后检查模型接触参数" + ) + + +def reset(state): + state["started"] = False + state["estimated_roll"] = 0.0 + state["estimated_pitch"] = 0.0 + state["filtered_droll"] = 0.0 + state["filtered_dpitch"] = 0.0 + state["unstable_duration"] = 0.0 + + +def dispose(state): + pass diff --git a/wasm/web_platform/README.md b/wasm/web_platform/README.md index d6b61ac9..90e76613 100644 --- a/wasm/web_platform/README.md +++ b/wasm/web_platform/README.md @@ -12,6 +12,7 @@ - Three.js primitive、mesh、材质/贴图显示与对象选择 - 播放、暂停、单步、重置、0.25×–4× 速度 - actuator 滑杆、hinge/slide 关节拖动、动态 body 外力拖拽 +- 导入单文件 `.py` 控制器,通过本地 Pyodide 在 `mj_step` 前按仿真时间同步执行 - FPS、物理耗时和主线程步进预算提示 ## 开发 @@ -53,6 +54,13 @@ python3 -m http.server 8080 --directory wasm/web-platform-dist - XML 的 `include`、mesh 和贴图路径必须相对于入口/编译器配置可解析。 - 路径穿越、绝对路径、加密 ZIP、重复路径会被拒绝。 - 默认限制:2000 个文件、单文件 128 MiB、总解压大小 512 MiB、ZIP 文件 128 MiB。 +- 文件夹或 ZIP 中的 `.py` 会显示在“控制 → Python 控制器”;也可以在加载模型后单独导入不超过 1 MiB 的 `.py`。 + +## Python 控制器 + +Python 控制器是可信的单文件脚本,必须同步定义 `step(ctx, state)`;可选定义 `NAME`、`CONTROL_HZ`(限制为 1–500 Hz)、`init(api)`、`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 限幅的控制量。异常会自动停止控制器、暂停仿真并清零 `ctrl`。 + +当前 Python 与 MuJoCo 都运行在主线程,以保证闭环调用严格位于 `mj_step` 前。仅运行可信脚本;死循环仍可能阻塞页面。Pyodide 及 Python 标准库由 npm 包随生产构建离线发布,不从 CDN 下载;暂不支持第三方 Python 包、`pip` 或多文件 import。 ## 示例 @@ -60,6 +68,7 @@ python3 -m http.server 8080 --directory wasm/web-platform-dist - `mjcf_include/`:MJCF include、OBJ/STL mesh 和 PNG texture; - `urdf_mesh/`:引用 OBJ 的 URDF; +- `python_controller/`:倒立摆模型及 `balance.py` PD 控制器; - `invalid.xml`:无效模型; - `missing-resource.xml`:缺失资源错误示例。 @@ -69,7 +78,7 @@ python3 -m http.server 8080 --directory wasm/web-platform-dist - 仅面向桌面版 Chrome、Edge、Firefox;未适配手机和平板。 - 物理运行在主线程、单线程 WASM。超出每帧预算时限制追帧并提示。 -- 不支持 Xacro、XML 在线编辑、热重载、导出、账号或云端保存。 +- 不支持 Xacro、账号或云端保存;Python 控制器暂不支持第三方包和不可信代码隔离。 - 关节拖动只支持 hinge/slide;ball/free joint 只读。 - MuJoCo WASM 本身不支持 DAE mesh。平台会移除 DAE visual,并以 collision 几何显示;DAE collision 会替换为半径 0.05 m 的占位球体并在界面警告。高精度仿真应先将 DAE 转为 OBJ/STL 或改为 URDF primitive。 - 导入工程只存在当前页面内存,刷新页面后需重新导入。 diff --git a/wasm/web_platform/e2e/app.spec.ts b/wasm/web_platform/e2e/app.spec.ts index 49bc1ab4..954796e8 100644 --- a/wasm/web_platform/e2e/app.spec.ts +++ b/wasm/web_platform/e2e/app.spec.ts @@ -19,6 +19,8 @@ const SIMPLE_MODEL = `
`; +const SLIDE_DIRECTION_MODEL=``; + const LARGE_MODEL = `