feat(web-platform): release V0.2.1
This commit is contained in:
Generated
+69
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 暂不提供完整的多自由度编辑器。
|
||||
- 导入工程不会持久化,刷新页面后需要重新导入。
|
||||
@@ -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}) => {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0"?><COLLADA xmlns="http://www.collada.org/2005/11/COLLADASchema" version="1.4.1">
|
||||
<asset><unit meter="1"/><up_axis>Z_UP</up_axis></asset><library_effects><effect id="fx"><profile_COMMON><technique sid="common"><lambert><diffuse><color>1 1 1 1</color></diffuse></lambert></technique></profile_COMMON></effect></library_effects><library_materials><material id="mat"><instance_effect url="#fx"/></material></library_materials>
|
||||
<library_geometries><geometry id="tetrahedron"><mesh><source id="positions"><float_array id="positions-array" count="12">0 0 0 1 0 0 0 1 0 0 0 1</float_array><technique_common><accessor source="#positions-array" count="4" stride="3"><param name="X" type="float"/><param name="Y" type="float"/><param name="Z" type="float"/></accessor></technique_common></source><vertices id="vertices"><input semantic="POSITION" source="#positions"/></vertices><triangles count="4" material="mat"><input semantic="VERTEX" source="#vertices" offset="0"/><p>0 2 1 0 1 3 0 3 2 1 2 3</p></triangles></mesh></geometry></library_geometries>
|
||||
<library_visual_scenes><visual_scene id="scene"><node id="node"><instance_geometry url="#tetrahedron"><bind_material><technique_common><instance_material symbol="mat" target="#mat"/></technique_common></bind_material></instance_geometry></node></visual_scene></library_visual_scenes><scene><instance_visual_scene url="#scene"/></scene></COLLADA>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0"?>
|
||||
<robot name="dae_fixture">
|
||||
<link name="base">
|
||||
<inertial><origin xyz="0 0 0"/><mass value="1"/><inertia ixx="0.1" ixy="0" ixz="0" iyy="0.1" iyz="0" izz="0.1"/></inertial>
|
||||
<visual><geometry><mesh filename="../dae/triangle.dae"/></geometry></visual>
|
||||
<collision><geometry><mesh filename="../dae/triangle.dae"/></geometry></collision>
|
||||
</link>
|
||||
</robot>
|
||||
@@ -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<ProjectManifest|null>(null),notificationId=useRef(0),loadInFlight=useRef(false),importInFlight=useRef(false),adapter=useRef(new MainThreadPhysicsAdapter()),root=useRef<HTMLDivElement>(null),viewerHost=useRef<HTMLDivElement>(null),viewer=useRef<MuJoCoViewer|null>(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<ImportProgress>(),[notifications,setNotifications]=useState<WorkbenchNotification[]>([]),[toast,setToast]=useState<WorkbenchNotification>();
|
||||
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<string>(),[generatedMjcfPath,setGeneratedMjcfPath]=useState<string>(),[removeConfirmOpen,setRemoveConfirmOpen]=useState(false),[fullscreen,setFullscreen]=useState(false),[settingsOpen,setSettingsOpen]=useState(false),[layoutOpen,setLayoutOpen]=useState(false),[diagnosticsOpen,setDiagnosticsOpen]=useState(false),[importProgress,setImportProgress]=useState<ImportProgress>(),[notifications,setNotifications]=useState<WorkbenchNotification[]>([]),[toast,setToast]=useState<WorkbenchNotification>();
|
||||
const [urdfMode,setUrdfMode]=useState<UrdfLoadMode>('mjcf'),urdfModeRef=useRef<UrdfLoadMode>('mjcf');
|
||||
const [baseMode,setBaseMode]=useState<UrdfBaseMode>('floating'),baseModeRef=useRef<UrdfBaseMode>('floating');
|
||||
const [showCollision,setShowCollision]=useState(false),[theme,setTheme]=useState<ViewerTheme>(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<HTMLInputElement>)=>{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:<Hand className="h-4 w-4"/>,shortcut:'2',run:()=>mode('joint')},
|
||||
{id:'force',label:'切换到外力施加',group:'视口',icon:<Crosshair className="h-4 w-4"/>,shortcut:'3',run:()=>mode('force')},
|
||||
{id:'camera',label:'复位相机',group:'视口',icon:<Camera className="h-4 w-4"/>,run:()=>viewer.current?.resetCamera()},
|
||||
{id:'source',label:'查看和修改缓存源代码',group:'工程',icon:<Code2 className="h-4 w-4"/>,disabled:!generatedMjcf,run:()=>setSourceOpen(true)},
|
||||
{id:'export-urdf',label:'导出 URDF 文件',group:'工程',icon:<Download className="h-4 w-4"/>,disabled:selectedFormat!=='urdf',run:exportUrdf},
|
||||
{id:'export-mjcf',label:'导出 MJCF 文件',group:'工程',icon:<Download className="h-4 w-4"/>,disabled:!state.snapshot,run:exportMjcf},
|
||||
{id:'left',label:leftOpen?'隐藏工程面板':'显示工程面板',group:'布局',icon:leftOpen?<ChevronLeft className="h-4 w-4"/>:<ChevronRight className="h-4 w-4"/>,run:()=>setLeftOpen(value=>!value)},
|
||||
{id:'right',label:rightOpen?'隐藏属性面板':'显示属性面板',group:'布局',icon:rightOpen?<ChevronRight className="h-4 w-4"/>:<ChevronLeft className="h-4 w-4"/>,run:()=>setRightOpen(value=>!value)},
|
||||
{id:'theme',label:theme==='dark'?'切换到白天主题':'切换到黑夜主题',group:'外观',icon:<SunMoon className="h-4 w-4"/>,run:()=>setTheme(value=>value==='dark'?'light':'dark')},
|
||||
@@ -75,8 +85,8 @@ export function App(){
|
||||
{id:'help',label:'查看快捷键帮助',group:'帮助',icon:<CircleHelp className="h-4 w-4"/>,run:()=>setHelpOpen(true)},
|
||||
];
|
||||
return <div ref={root} className={`${theme==='light'?'theme-light':'theme-dark'} flex h-screen min-w-[1024px] flex-col overflow-hidden bg-app text-text-primary`} onDragOver={event=>event.preventDefault()} onDrop={drop}>
|
||||
<WorkbenchHeader paused={state.paused} ready={Boolean(state.snapshot)} speed={state.speed} theme={theme} loading={state.loading} leftOpen={leftOpen} rightOpen={rightOpen} fullscreen={fullscreen} onFiles={changeFiles} onFolder={changeFiles} 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={<><NotificationCenter items={notifications} onDismiss={id=>setNotifications(items=>items.filter(item=>item.id!==id))} onClear={()=>setNotifications([])} onOpenLog={()=>setDiagnosticsOpen(true)}/><span className="hidden items-center gap-0.5 xl:flex"><IconButton tooltip="布局设置" aria-label="布局设置" onClick={()=>setLayoutOpen(true)}><PanelsTopLeft className="h-4 w-4"/></IconButton><IconButton tooltip="工作台设置" aria-label="工作台设置" onClick={()=>setSettingsOpen(true)}><SettingsIcon className="h-4 w-4"/></IconButton></span></>} compactMenu={<ToolbarOverflowMenu fullscreen={fullscreen} onCommands={()=>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={<ViewerToolDock mode={state.mode} onModeChange={mode} onResetCamera={()=>viewer.current?.resetCamera()}/>}/>
|
||||
<WorkbenchHeader paused={state.paused} ready={Boolean(state.snapshot)} speed={state.speed} theme={theme} loading={state.loading} leftOpen={leftOpen} rightOpen={rightOpen} fullscreen={fullscreen} hasProject={Boolean(generatedMjcf)} onFiles={changeFiles} onFolder={changeFiles} onOpenSource={()=>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={<><NotificationCenter items={notifications} onDismiss={id=>setNotifications(items=>items.filter(item=>item.id!==id))} onClear={()=>setNotifications([])} onOpenLog={()=>setDiagnosticsOpen(true)}/><span className="hidden items-center gap-0.5 xl:flex"><IconButton tooltip="布局设置" aria-label="布局设置" onClick={()=>setLayoutOpen(true)}><PanelsTopLeft className="h-4 w-4"/></IconButton><IconButton tooltip="工作台设置" aria-label="工作台设置" onClick={()=>setSettingsOpen(true)}><SettingsIcon className="h-4 w-4"/></IconButton></span></>} compactMenu={<ToolbarOverflowMenu fullscreen={fullscreen} onCommands={()=>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={<ViewerToolDock mode={state.mode} onModeChange={mode} onResetCamera={()=>viewer.current?.resetCamera()}/>}/>
|
||||
<div className="flex min-h-0 flex-1"><ProjectSidebar visible={leftOpen} projectName={state.projectName} files={state.files} entries={state.entries} selectedEntry={state.selectedEntry} snapshot={state.snapshot} loading={state.loading} onRemove={removeProject} onSelectEntry={path=>void loadEntry(path)} onJointHover={jointId=>viewer.current?.highlightJoint(jointId)}/><main className="relative min-w-0 flex-1"><div ref={viewerHost} className="absolute inset-0"/><ViewportHUD paused={state.paused} mode={state.mode} selection={state.selection} ready={Boolean(state.snapshot)}/><WorkspaceOverlays loading={state.loading} hasSnapshot={Boolean(state.snapshot)} progress={importProgress}/><ToastViewport item={toast} onDismiss={()=>setToast(undefined)}/>{state.entries.length>1&&!state.selectedEntry&&<EntrySelectionDialog entries={state.entries} onSelect={loadEntry}/>} {state.diagnostic&&<ErrorRecoveryPanel key={state.diagnostic.at} value={state.diagnostic} onClose={()=>state.setDiagnostic(undefined)} onRetry={state.diagnostic.category==='模型编译'&&state.diagnostic.path?()=>void loadEntry(state.diagnostic!.path!):undefined} onOpenProject={()=>{setLeftOpen(true);state.setDiagnostic(undefined);}}/>}</main><ModelControlsSidebar visible={rightOpen} snapshot={state.snapshot} selection={state.selection} selectedFormat={selectedFormat} loading={state.loading} urdfMode={urdfMode} baseMode={baseMode} showCollision={showCollision} ignoreJointLimits={ignoreJointLimits} jointAdvanced={jointAdvanced} angleUnit={angleUnit} forceScale={forceScale} onUrdfMode={changeUrdfMode} onBaseMode={changeBaseMode} onShowCollision={setShowCollision} onResetJoints={resetJoints} onToggleJointLimits={toggleJointLimits} onToggleAdvanced={()=>setJointAdvanced(value=>!value)} onToggleAngleUnit={()=>setAngleUnit(value=>value==='rad'?'deg':'rad')} onActuator={setActuator} onJoint={setJoint} onForceScale={setForceScale}/></div>
|
||||
<ShortcutHelpDialog open={helpOpen} onClose={()=>setHelpOpen(false)}/><DiagnosticsDrawer open={diagnosticsOpen} items={notifications} onClose={()=>setDiagnosticsOpen(false)} onClear={()=>setNotifications([])}/><SettingsDialog open={settingsOpen} onClose={()=>setSettingsOpen(false)} theme={theme} angleUnit={angleUnit} showCollision={showCollision} jointAdvanced={jointAdvanced} forceScale={forceScale} onTheme={setTheme} onAngleUnit={setAngleUnit} onShowCollision={setShowCollision} onJointAdvanced={setJointAdvanced} onForceScale={setForceScale}/><LayoutSettingsDialog open={layoutOpen} onClose={()=>setLayoutOpen(false)} leftOpen={leftOpen} rightOpen={rightOpen} onLeftOpen={setLeftOpen} onRightOpen={setRightOpen} onPreset={applyLayoutPreset} onReset={()=>applyLayoutPreset('default')}/><CommandPalette open={commandOpen} onClose={()=>setCommandOpen(false)} commands={commands}/><ConfirmDialog open={removeConfirmOpen} title="移除当前工程" confirmLabel="移除工程" danger onConfirm={confirmRemoveProject} onClose={()=>setRemoveConfirmOpen(false)}><p className="text-sm text-text-secondary">确定从当前会话中移除“<strong className="text-text-primary">{state.projectName}</strong>”吗?</p><p className="mt-2 text-xs text-text-tertiary">该操作不会删除本地文件。</p></ConfirmDialog><StatusBar time={state.snapshot?.time} fps={state.fps} stepMs={state.stepMs} memoryMb={state.memoryMb} loaded={Boolean(state.snapshot)} overBudget={state.overBudget}/>
|
||||
{sourceOpen&&generatedMjcf&&generatedMjcfPath&&<SourceEditorDialog open code={generatedMjcf} filePath={generatedMjcfPath} theme={theme} onClose={()=>setSourceOpen(false)} onSave={saveCachedSource}/>}<ShortcutHelpDialog open={helpOpen} onClose={()=>setHelpOpen(false)}/><DiagnosticsDrawer open={diagnosticsOpen} items={notifications} onClose={()=>setDiagnosticsOpen(false)} onClear={()=>setNotifications([])}/><SettingsDialog open={settingsOpen} onClose={()=>setSettingsOpen(false)} theme={theme} angleUnit={angleUnit} showCollision={showCollision} jointAdvanced={jointAdvanced} forceScale={forceScale} onTheme={setTheme} onAngleUnit={setAngleUnit} onShowCollision={setShowCollision} onJointAdvanced={setJointAdvanced} onForceScale={setForceScale}/><LayoutSettingsDialog open={layoutOpen} onClose={()=>setLayoutOpen(false)} leftOpen={leftOpen} rightOpen={rightOpen} onLeftOpen={setLeftOpen} onRightOpen={setRightOpen} onPreset={applyLayoutPreset} onReset={()=>applyLayoutPreset('default')}/><CommandPalette open={commandOpen} onClose={()=>setCommandOpen(false)} commands={commands}/><ConfirmDialog open={removeConfirmOpen} title="移除当前工程" confirmLabel="移除工程" danger onConfirm={confirmRemoveProject} onClose={()=>setRemoveConfirmOpen(false)}><p className="text-sm text-text-secondary">确定从当前会话中移除“<strong className="text-text-primary">{state.projectName}</strong>”吗?</p><p className="mt-2 text-xs text-text-tertiary">该操作不会删除本地文件。</p></ConfirmDialog><StatusBar time={state.snapshot?.time} fps={state.fps} stepMs={state.stepMs} memoryMb={state.memoryMb} loaded={Boolean(state.snapshot)} overBudget={state.overBudget}/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -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<void>}){
|
||||
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 <div className="fixed inset-0 z-[390] pointer-events-none" role="presentation"><section role="dialog" aria-modal="false" aria-label="转换后的 MJCF 编辑器" style={maximized?undefined:{left:position.x,top:position.y,width:900,height:650}} className={`source-editor-window pointer-events-auto fixed flex min-h-[360px] min-w-[520px] flex-col overflow-hidden border border-border-strong bg-panel shadow-2xl ${maximized?'inset-0 h-full w-full':'resize'}`}>
|
||||
<header className="flex h-11 shrink-0 cursor-move select-none items-center gap-3 border-b border-border bg-surface px-3" onPointerDown={pointerDown} onPointerMove={pointerMove} onPointerUp={()=>{drag.current=null;}} onDoubleClick={()=>setMaximized(value=>!value)}><Code2 className="h-4 w-4 shrink-0 text-accent"/><div className="min-w-0 flex-1"><div className="truncate font-mono text-xs font-semibold text-text-primary">转换后的 MJCF</div><div className="truncate font-mono text-[9px] text-text-tertiary" title={filePath}>{filePath}</div></div><span className="text-[10px] text-text-tertiary">{contentSize(code)}</span><span className="rounded bg-accent-soft px-1.5 py-0.5 text-[9px] font-semibold text-accent">缓存文件 · 可编辑</span>{dirty&&<span className="rounded bg-warning-soft px-1.5 py-0.5 text-[9px] font-semibold text-warning">已修改</span>}<Button variant="primary" icon={<Save className="h-3 w-3"/>} disabled={!dirty||saving||Boolean(problem)} onClick={()=>void save()}>{saving?'重新载入中…':'保存并重新载入'}</Button><Button variant="ghost" icon={<Download className="h-3.5 w-3.5"/>} onClick={download}>下载</Button><Button variant="ghost" icon={copied?<Check className="h-3.5 w-3.5"/>:<Copy className="h-3.5 w-3.5"/>} onClick={()=>void copy()}>{copied?'已复制':'复制'}</Button><IconButton tooltip={maximized?'还原':'最大化'} aria-label={maximized?'还原':'最大化'} onClick={()=>setMaximized(value=>!value)}>{maximized?<Minimize2 className="h-4 w-4"/>:<Maximize2 className="h-4 w-4"/>}</IconButton><IconButton tooltip="关闭" aria-label="关闭源代码编辑器" onClick={onClose}><X className="h-4 w-4"/></IconButton></header>
|
||||
<div className="min-h-0 flex-1 bg-input"><Editor height="100%" language="xml" theme={theme==='light'?'light':'vs-dark'} value={code} onChange={value=>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'}}/></div>
|
||||
<footer className="flex h-7 shrink-0 items-center justify-between gap-3 border-t border-border bg-surface px-3 text-[10px]"><div className={problem?'truncate text-warning':'text-success'}>{problem?`XML 错误:${problem}`:'✓ XML 结构正常'}</div><div className="flex items-center gap-2 font-mono text-text-tertiary"><span>Ctrl+S 保存并重新载入</span><span>•</span><span>MJCF / XML</span></div></footer>
|
||||
</section></div>;
|
||||
}
|
||||
@@ -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<HTMLInputElement>)=>void;onFolder:(event:ChangeEvent<HTMLInputElement>)=>void;onTogglePause:()=>void;onStep:()=>void;onReset:()=>void;onSpeed:(value:number)=>void;onToggleLeft:()=>void;onToggleRight:()=>void;onToggleTheme:()=>void;onHelp:()=>void;onCommands:()=>void;onToggleFullscreen:()=>void}){return <header className="relative z-40 grid h-10 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(max-content,1fr)] items-center gap-2 border-b border-border bg-panel px-2.5"><div className="flex min-w-0 items-center gap-1"><h1 className="mr-2 hidden truncate border-r border-border pr-3 text-sm font-semibold text-text-primary xl:block">MuJoCo Web 仿真平台</h1><label aria-disabled={loading} className={`${fileActionClass} ${loading?'pointer-events-none opacity-40':''}`}><Upload className="h-3.5 w-3.5"/>打开文件<input className="sr-only" type="file" disabled={loading} multiple accept=".xml,.urdf,.zip,.obj,.stl,.dae,.msh,.png,.jpg,.jpeg,.bmp,.tga,.hdr" onChange={onFiles}/></label><label aria-disabled={loading} className={`${fileActionClass} ${loading?'pointer-events-none opacity-40':''}`}><FolderOpen className="h-3.5 w-3.5"/>文件夹<input className="sr-only" type="file" disabled={loading} multiple {...({webkitdirectory:'',directory:''} as object)} onChange={onFolder}/></label></div><div className="flex items-center justify-center">{center}</div><div className="flex min-w-0 items-center justify-end gap-0.5"><Button variant="ghost" onClick={onTogglePause} disabled={!ready} aria-label={paused?'▶ 播放':'⏸ 暂停'} icon={paused?<Play className="h-3.5 w-3.5"/>:<Pause className="h-3.5 w-3.5"/>}>{paused?'播放':'暂停'}</Button><IconButton tooltip="单步" aria-label="单步" onClick={onStep} disabled={!ready||!paused}><StepForward className="h-3.5 w-3.5"/></IconButton><IconButton tooltip="重置" aria-label="重置" onClick={onReset} disabled={!ready}><RotateCcw className="h-3.5 w-3.5"/></IconButton><Select aria-label="仿真速度" value={speed} disabled={loading} onChange={event=>onSpeed(Number(event.target.value))} className="w-[70px]"><option value={.25}>0.25×</option><option value={.5}>0.5×</option><option value={1}>1×</option><option value={2}>2×</option><option value={4}>4×</option></Select><span className="mx-1 h-5 border-l border-border"/><IconButton tooltip={leftOpen?'隐藏工程面板':'显示工程面板'} aria-label={leftOpen?'隐藏工程面板':'显示工程面板'} aria-expanded={leftOpen} onClick={onToggleLeft}><PanelLeft className="h-4 w-4"/></IconButton><IconButton tooltip={rightOpen?'隐藏属性面板':'显示属性面板'} aria-label={rightOpen?'隐藏属性面板':'显示属性面板'} aria-expanded={rightOpen} onClick={onToggleRight}><PanelRight className="h-4 w-4"/></IconButton>{endActions}{compactMenu}<IconButton className="hidden xl:inline-flex" tooltip="命令面板(Ctrl+K)" aria-label="打开命令面板" onClick={onCommands}><Search className="h-4 w-4"/></IconButton><IconButton className="hidden xl:inline-flex" tooltip={fullscreen?'退出全屏':'进入全屏'} aria-label={fullscreen?'退出全屏':'进入全屏'} onClick={onToggleFullscreen}>{fullscreen?<Minimize className="h-4 w-4"/>:<Expand className="h-4 w-4"/>}</IconButton><IconButton className="hidden xl:inline-flex" tooltip="快捷键帮助" aria-label="快捷键帮助" onClick={onHelp}><CircleHelp className="h-4 w-4"/></IconButton><IconButton className="hidden xl:inline-flex" tooltip={theme==='dark'?'切换到白天主题':'切换到黑夜主题'} aria-label={theme==='dark'?'切换到白天主题':'切换到黑夜主题'} onClick={onToggleTheme}>{theme==='dark'?<Sun className="h-4 w-4"/>:<Moon className="h-4 w-4"/>}</IconButton></div></header>;}
|
||||
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<HTMLInputElement>)=>void;onFolder:(event:ChangeEvent<HTMLInputElement>)=>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 <header className="relative z-40 grid h-10 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(max-content,1fr)] items-center gap-2 border-b border-border bg-panel px-2.5"><div className="flex min-w-0 items-center gap-1"><h1 className="mr-2 hidden truncate border-r border-border pr-3 text-sm font-semibold text-text-primary xl:block">MuJoCo Web 仿真平台</h1><label aria-disabled={loading} className={`${fileActionClass} ${loading?'pointer-events-none opacity-40':''}`}><Upload className="h-3.5 w-3.5"/>打开文件<input className="sr-only" type="file" disabled={loading} multiple accept=".xml,.urdf,.zip,.obj,.stl,.dae,.msh,.png,.jpg,.jpeg,.bmp,.tga,.hdr" onChange={onFiles}/></label><label aria-disabled={loading} className={`${fileActionClass} ${loading?'pointer-events-none opacity-40':''}`}><FolderOpen className="h-3.5 w-3.5"/>文件夹<input className="sr-only" type="file" disabled={loading} multiple {...({webkitdirectory:'',directory:''} as object)} onChange={onFolder}/></label><IconButton tooltip="查看和修改缓存源代码" aria-label="源代码" disabled={!hasProject||loading} onClick={onOpenSource}><Code2 className="h-4 w-4"/></IconButton></div><div className="flex items-center justify-center">{center}</div><div className="flex min-w-0 items-center justify-end gap-0.5"><Button variant="ghost" onClick={onTogglePause} disabled={!ready} aria-label={paused?'▶ 播放':'⏸ 暂停'} icon={paused?<Play className="h-3.5 w-3.5"/>:<Pause className="h-3.5 w-3.5"/>}>{paused?'播放':'暂停'}</Button><IconButton tooltip="单步" aria-label="单步" onClick={onStep} disabled={!ready||!paused}><StepForward className="h-3.5 w-3.5"/></IconButton><IconButton tooltip="重置" aria-label="重置" onClick={onReset} disabled={!ready}><RotateCcw className="h-3.5 w-3.5"/></IconButton><Select aria-label="仿真速度" value={speed} disabled={loading} onChange={event=>onSpeed(Number(event.target.value))} className="w-[70px]"><option value={.25}>0.25×</option><option value={.5}>0.5×</option><option value={1}>1×</option><option value={2}>2×</option><option value={4}>4×</option></Select><span className="mx-1 h-5 border-l border-border"/><IconButton tooltip={leftOpen?'隐藏工程面板':'显示工程面板'} aria-label={leftOpen?'隐藏工程面板':'显示工程面板'} aria-expanded={leftOpen} onClick={onToggleLeft}><PanelLeft className="h-4 w-4"/></IconButton><IconButton tooltip={rightOpen?'隐藏属性面板':'显示属性面板'} aria-label={rightOpen?'隐藏属性面板':'显示属性面板'} aria-expanded={rightOpen} onClick={onToggleRight}><PanelRight className="h-4 w-4"/></IconButton>{endActions}{compactMenu}<IconButton className="hidden xl:inline-flex" tooltip="命令面板(Ctrl+K)" aria-label="打开命令面板" onClick={onCommands}><Search className="h-4 w-4"/></IconButton><IconButton className="hidden xl:inline-flex" tooltip={fullscreen?'退出全屏':'进入全屏'} aria-label={fullscreen?'退出全屏':'进入全屏'} onClick={onToggleFullscreen}>{fullscreen?<Minimize className="h-4 w-4"/>:<Expand className="h-4 w-4"/>}</IconButton><IconButton className="hidden xl:inline-flex" tooltip="快捷键帮助" aria-label="快捷键帮助" onClick={onHelp}><CircleHelp className="h-4 w-4"/></IconButton><IconButton className="hidden xl:inline-flex" tooltip={theme==='dark'?'切换到白天主题':'切换到黑夜主题'} aria-label={theme==='dark'?'切换到白天主题':'切换到黑夜主题'} onClick={onToggleTheme}>{theme==='dark'?<Sun className="h-4 w-4"/>:<Moon className="h-4 w-4"/>}</IconButton></div></header>;}
|
||||
|
||||
@@ -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});
|
||||
@@ -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('<mujoco/>'),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('<mujoco/>');expect(()=>readCachedText(manifest,'texture.png')).toThrow('二进制');});
|
||||
it('以不可变方式更新会话缓存和大小',()=>{const original=fixture(),updated=updateCachedText(original,'model.xml','<mujoco model="edited"/>');expect(readCachedText(updated,'model.xml')).toContain('edited');expect(readCachedText(original,'model.xml')).toBe('<mujoco/>');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','<mujoco model="cached"/>');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');});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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<MainModule> {
|
||||
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<SimulationSnapshot> {
|
||||
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();}
|
||||
}
|
||||
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module 'monaco-editor/esm/vs/editor/edcore.main' {
|
||||
export * from 'monaco-editor';
|
||||
}
|
||||
Reference in New Issue
Block a user