feat: add browser-based MuJoCo simulation platform #1
@@ -40,6 +40,10 @@ MUJOCO_LOG.TXT
|
|||||||
|
|
||||||
# JavaScript bindings build
|
# JavaScript bindings build
|
||||||
wasm/**/dist/
|
wasm/**/dist/
|
||||||
|
wasm/web-platform-dist/
|
||||||
|
wasm/test-results/
|
||||||
|
wasm/web_platform/test-results/
|
||||||
|
wasm/web_platform/node_modules/.vite/
|
||||||
**/node_modules/
|
**/node_modules/
|
||||||
|
|
||||||
.venv/
|
.venv/
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
# MuJoCo Web 仿真平台实施计划
|
||||||
|
|
||||||
|
> **计划状态**:✅ MVP 已实施并通过自动化验证
|
||||||
|
> **进度维护规则**:实施时将阶段/任务复选框由 `[ ]` 更新为 `[x]`,并同步“进度看板”中的状态、完成日期与备注。
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
目标是在本仓库官方 `wasm/` JavaScript/TypeScript 绑定和 Three.js 示例之上,建设一个纯浏览器 MuJoCo 仿真平台,支持模型工程上传、浏览器内文件组织、模型加载、三维展示、仿真控制和状态查看,交互布局参考 `urdf.enkeebot.com`。
|
||||||
|
|
||||||
|
已确认的仓库基础:
|
||||||
|
|
||||||
|
- `wasm/dist/` 有本地构建的绑定产物,但当前 `build/CMakeCache.txt` 显示其启用了线程;MVP 因此使用同版本官方 `@mujoco/mujoco` 默认单线程入口。
|
||||||
|
- `wasm/demo_app/` 已有 Vite + TypeScript + Three.js 的最小示例,可作为加载、场景构建与逐帧同步的起点。
|
||||||
|
- 官方绑定要求显式调用 `.delete()` 管理 Embind/C++ 对象生命周期。
|
||||||
|
- 官方同时支持默认单线程入口与需要 COOP/COEP 的多线程入口;MVP 已确定使用默认单线程入口。
|
||||||
|
|
||||||
|
## Scope / MVP 边界
|
||||||
|
|
||||||
|
- **技术栈**:React + TypeScript + Zustand + Vite + TailwindCSS + Three.js。
|
||||||
|
- **输入**:单个 MJCF/XML、单个 URDF、保留相对路径的文件夹、ZIP 工程;关联 mesh/贴图随工程导入。
|
||||||
|
- **编辑边界**:首版只负责导入、查看和仿真,不提供 XML 编辑、热重载、工程保存或导出。
|
||||||
|
- **物理执行**:首版使用主线程、单线程 `mujoco.wasm`;模块边界保留未来迁移 Worker 的能力,但不在 MVP 实现 Worker。
|
||||||
|
- **控制优先级**:actuator 滑杆、关节拖动、外力施加,另含播放、暂停、单步、重置和速度控制。
|
||||||
|
- **部署边界**:桌面浏览器中的中文界面,通过本地静态 HTTP 服务器运行;无 PWA、账号、后端、云存储或分享服务,导入工程仅驻留当前浏览器会话内存。
|
||||||
|
- **非目标**:URDF/MJCF 互转、Xacro 展开、控制脚本、轨迹编辑与多线程 WASM。
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
已采用 **React + TypeScript + Vite + TailwindCSS + Three.js**,在 `wasm/web_platform/` 新增独立平台应用,复用官方 WASM 包和示例逻辑,未改动 MuJoCo 核心绑定或生成产物。
|
||||||
|
|
||||||
|
初步分层:
|
||||||
|
|
||||||
|
1. **UI / 工程层**:工程导入、资源树、属性/控制面板、全局状态和错误展示。
|
||||||
|
2. **应用服务层**:工程文件规范化、入口模型识别、仿真会话生命周期、命令调度。
|
||||||
|
3. **物理层**:封装 `MjModel`、`MjData`、step/reset/ctrl,并集中管理 `.delete()`。
|
||||||
|
4. **文件系统层**:将上传文件按相对路径写入 Emscripten MEMFS,校验引用与加载错误。
|
||||||
|
5. **渲染层**:Three.js 场景、相机、灯光、MuJoCo primitive/mesh 映射和逐帧位姿同步。
|
||||||
|
6. **交互控制层**:Three.js Raycaster 完成选择;关节拖动映射到 `qpos` 并调用 `mj_forward`,外力拖拽通过 `MjvPerturb`/`mjv_applyPerturbForce` 在 step 前施加。
|
||||||
|
7. **线程边界**:MVP 直接调用主线程单线程绑定,但通过 `PhysicsAdapter` 隔离 UI,未来可在不改 UI 的情况下迁移 Worker。
|
||||||
|
|
||||||
|
### 数据流、错误与生命周期
|
||||||
|
|
||||||
|
- **数据流**:File/Directory/ZIP → 路径校验与入口识别 → MEMFS workspace → `mj_loadXML` → `SimulationSession` → `mjv_updateScene`/状态快照 → Three.js 与 Zustand UI。
|
||||||
|
- **状态边界**:Zustand 只保存可序列化的工程元数据、控制参数、选择和 UI 状态;`MjModel`/`MjData`/Three.js 实例由服务对象持有,避免 React 重渲染复制 WASM 视图。
|
||||||
|
- **错误模型**:按导入、ZIP、文件系统、模型编译、仿真、渲染分类,统一包含中文摘要、阶段、相关路径和原始错误;致命错误停止当前会话但保留工程树供排查。
|
||||||
|
- **生命周期**:新模型采用“先释放旧渲染资源和 Embind 对象 → 清理旧 workspace → 写入新工程 → 创建新会话”的串行切换;所有失败分支使用 `finally` 回收已创建对象。
|
||||||
|
|
||||||
|
## Files to modify
|
||||||
|
|
||||||
|
- `Plan.md`:计划与持续进度记录。
|
||||||
|
- `wasm/demo_app/app.ts`:只读复用来源,原则上不直接扩建为平台。
|
||||||
|
- `wasm/package.json`、`wasm/package-lock.json`:加入 React、Zustand、TailwindCSS、`fflate`、Vitest、Playwright 及平台脚本。
|
||||||
|
- `wasm/web_platform/index.html`、`wasm/web_platform/vite.config.ts`:平台入口、WASM 静态资源定位和相对路径静态部署配置。
|
||||||
|
- `wasm/web_platform/src/app/`:React 壳层、布局、全局错误边界与启动流程。
|
||||||
|
- `wasm/web_platform/src/stores/`:Zustand 工程、仿真、选择和 UI 状态切片。
|
||||||
|
- `wasm/web_platform/src/project/`:文件/目录/ZIP 导入、路径规范化、入口识别和 MEMFS 工作区。
|
||||||
|
- `wasm/web_platform/src/simulation/`:WASM 初始化、`PhysicsAdapter`、`SimulationSession`、步进和控制命令。
|
||||||
|
- `wasm/web_platform/src/viewer/`:Three.js renderer、MuJoCo 场景适配、拾取、关节拖动和外力交互。
|
||||||
|
- `wasm/web_platform/src/**/*.test.ts(x)`、`wasm/web_platform/e2e/`、`wasm/web_platform/fixtures/`:单测、浏览器测试与示例工程。
|
||||||
|
- `@mujoco/mujoco`:作为默认单线程构建输入;`wasm/dist/*` 未手工修改。
|
||||||
|
|
||||||
|
## Reuse
|
||||||
|
|
||||||
|
- `wasm/demo_app/app.ts`:复用 `loadMujoco()` 初始化、`mjv_updateScene` 场景提取、Z-up 相机、固定仿真时长步进、矩阵同步和确定性释放思路;现有 `getBufferGeometry` 仅覆盖 plane/sphere/capsule/box/cylinder/ellipsoid,mesh 与贴图需补齐,且不可直接复制其全局 `app`/DOM 写法。
|
||||||
|
- `wasm/demo_app/vite.demo.config.ts`:复用 `root`、相对 `base`、独立输出目录和开发响应头模式;MVP 单线程不依赖 COOP/COEP。
|
||||||
|
- `wasm/tests/bindings_test.ts`:复用 `FS.writeFile/unlink`、`MjModel.mj_loadXML(path)`、`MjVFS.addBuffer`、带外部 OBJ 的加载测试以及 `finally + .delete()` 生命周期范式。
|
||||||
|
- `@mujoco/mujoco` 类型:使用 `MjModel`/`MjData` typed-array 视图、named accessor、`MjvPerturb` 与 `mjv_applyPerturbForce` 等 API;避开 bool typed-memory-view 的绑定限制,并显式删除 accessor。
|
||||||
|
- `doc/overview.rst`、`doc/modeling.rst`、`doc/APIreference/functions_override.rst`:MuJoCo 原生 `mj_loadXML` 可解析 MJCF 与 URDF,VFS 可容纳 XML/include、STL、PNG 等资源,因此 URDF 不另做平台侧转换。
|
||||||
|
- 参考站点仅复用信息架构理念:本地导入、3D 视口、关节树/属性与控制面板;MVP 不复刻其编辑、格式转换或云端工具能力。
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### 进度看板
|
||||||
|
|
||||||
|
| 阶段 | 状态 | 完成度 | 完成日期 | 备注 |
|
||||||
|
|---|---|---:|---|---|
|
||||||
|
| 0. 需求与架构定稿 | 🟢 已完成 | 100% | 当前 | 产品边界、交互语义和架构已确认 |
|
||||||
|
| 1. 应用骨架与质量基线 | 🟢 已完成 | 100% | 当前 | typecheck、lint、Vitest、Playwright、build 通过 |
|
||||||
|
| 2. 工程导入与 MEMFS | 🟢 已完成 | 100% | 当前 | 单/多文件、目录、ZIP、安全限制与入口选择 |
|
||||||
|
| 3. MuJoCo 会话与仿真控制 | 🟢 已完成 | 100% | 当前 | 主线程单线程、固定步进、控制与确定性释放 |
|
||||||
|
| 4. Three.js 可视化与交互 | 🟢 已完成 | 100% | 当前 | primitive/mesh/texture、拾取、关节与外力交互 |
|
||||||
|
| 5. 平台 UI 与状态管理 | 🟢 已完成 | 100% | 当前 | 中文桌面布局、面板、属性和状态栏 |
|
||||||
|
| 6. 性能、健壮性与交付 | 🟢 已完成 | 100% | 当前 | 预算保护、夹具/E2E、静态部署文档 |
|
||||||
|
|
||||||
|
### 0. 需求与架构定稿
|
||||||
|
|
||||||
|
- [x] 确认 React + Zustand,采用 Vitest + Playwright;仅支持桌面版 Chrome/Edge/Firefox 最新两个大版本。
|
||||||
|
- [x] 确认 MVP 输入格式为 MJCF/XML、URDF、文件夹和 ZIP;工程入口规则见后续步骤。
|
||||||
|
- [x] 确认 MVP 使用主线程、单线程 WASM,不实现 Worker/MT。
|
||||||
|
- [x] 明确参考站点只借鉴本地导入、工程树、中心视口、属性/控制面板的信息架构。
|
||||||
|
- [x] 输出模块边界、数据流、错误模型和资源生命周期设计。
|
||||||
|
|
||||||
|
### 1. 应用骨架与质量基线
|
||||||
|
|
||||||
|
- [x] 创建独立平台应用入口、Vite 配置、TailwindCSS 和基础布局。
|
||||||
|
- [x] 配置 TypeScript 严格检查、lint、单元测试与最小 E2E 测试。
|
||||||
|
- [x] 配置 WASM 静态资源定位及开发/生产构建路径,保持 `base: './'` 以支持任意静态子路径。
|
||||||
|
- [x] 验证本地静态 HTTP 服务器可正确提供 JS/CSS/WASM;不支持直接以 `file://` 打开,也不注册 service worker。
|
||||||
|
- [x] 建立错误边界、加载状态、日志和中文可诊断错误展示。
|
||||||
|
|
||||||
|
### 2. 工程导入与 MEMFS
|
||||||
|
|
||||||
|
- [x] 定义 `ProjectFile`、虚拟路径、入口模型和资源清单模型。
|
||||||
|
- [x] 支持单文件、多文件和目录拖放/选择,保留 `webkitRelativePath`;使用 ZIP 库在浏览器内解包 `.zip`,拒绝加密包、目录穿越和超限压缩内容。
|
||||||
|
- [x] 统一 `/workspace/<project-id>/...` 虚拟根目录,规范化路径后通过 `FS.mkdirTree/writeFile` 写入 MEMFS;二进制文件保持 `Uint8Array`,文本 XML 使用 UTF-8。
|
||||||
|
- [x] 入口规则:单个 XML/URDF 自动选中;多候选优先根目录 `model.xml`/`scene.xml`/唯一 `.urdf`,否则弹窗选择且按根元素 `<mujoco>`/`<robot>`标识类型。
|
||||||
|
- [x] 使用 `MjModel.mj_loadXML(entryPath)` 让 MuJoCo 原生解析 MJCF/URDF 及相对资源;加载失败保留工程树并展示入口、资源路径和 MuJoCo 错误。
|
||||||
|
- [x] 切换工程前按逆序释放会话对象,再递归删除 MEMFS 工作区;对文件数、单文件、解压总量设置可配置上限。
|
||||||
|
- [x] 为 MJCF include + OBJ/STL/PNG、URDF + mesh、目录、ZIP、路径冲突和缺失资源添加测试夹具。
|
||||||
|
|
||||||
|
### 3. MuJoCo 会话与仿真控制
|
||||||
|
|
||||||
|
- [x] 封装 WASM 单例初始化和 `SimulationSession` 生命周期。
|
||||||
|
- [x] 创建/销毁 `MjModel`、`MjData`,实现异常路径下的确定性释放。
|
||||||
|
- [x] 实现播放、暂停、单步、重置、时间倍率、固定步长累积器。
|
||||||
|
- [x] 实现 actuator 控件和 qpos/qvel/ctrl 等状态快照。
|
||||||
|
- [x] 实现同步 `PhysicsAdapter` 接口并保持 UI 不直接持有 Embind 对象;Worker/message protocol 仅记录为后续扩展点。
|
||||||
|
|
||||||
|
### 4. Three.js 可视化与交互
|
||||||
|
|
||||||
|
- [x] 复用官方 demo 的 primitive 构建与 `mjv_updateScene` 方法,抽离为可测试的场景适配器。
|
||||||
|
- [x] 支持 MuJoCo primitive;对 mesh 根据 `mjvGeom.dataid` 读取 `mesh_vert/face/normal/texcoord` 及地址/数量数组构建缓存的 `BufferGeometry`,并从 `tex_data/width/height` 创建纹理,覆盖材质和坐标/矩阵转换。
|
||||||
|
- [x] 每帧同步动态 body/geom 位姿,避免重复分配临时对象。
|
||||||
|
- [x] 实现 OrbitControls、相机复位、网格/坐标轴、灯光和 resize。
|
||||||
|
- [x] 用 Raycaster 实现对象拾取、选中高亮及与属性面板联动,并维护 Three.js object → MuJoCo body/geom id 映射。
|
||||||
|
- [x] 关节拖动仅对可直接编辑的 hinge/slide joint 开放:暂停仿真、按 joint axis 将拖动量写入对应 `qpos`、按 `jnt_range` 限位并执行 `mj_forward`;free/ball joint MVP 只读。
|
||||||
|
- [x] 外力施加:选中动态 body 后以拖拽箭头显示方向/大小,在每个 `mj_step` 前通过 `MjvPerturb`/`mjv_applyPerturbForce` 施力,松开即清零,并提供强度刻度。
|
||||||
|
- [x] 在模型切换/卸载时释放 geometry、material、texture、Embind 临时 accessor/vector 和渲染循环资源。
|
||||||
|
|
||||||
|
### 5. 平台 UI 与状态管理
|
||||||
|
|
||||||
|
- [x] 搭建参考站点式布局:顶部工具栏、左侧工程树、中心视口、右侧属性/控制面板、底部状态区。
|
||||||
|
- [x] 实现 MJCF/XML、URDF、文件夹、ZIP 导入流程、入口选择、最近错误、加载进度与空状态。
|
||||||
|
- [x] 实现播放/暂停/单步/重置/速度控件、基于 `actuator_ctrlrange` 的 actuator 滑杆,以及 joint 拖动/外力模式开关。
|
||||||
|
- [x] 实现模型信息、body/joint/geom 属性检查器。
|
||||||
|
- [x] 针对桌面宽屏提供可调整/折叠面板、中文文案、键盘操作和基本无障碍语义;手机和平板不在 MVP 验收范围。
|
||||||
|
|
||||||
|
### 6. 性能、健壮性与交付
|
||||||
|
|
||||||
|
- [x] 建立 FPS、step 耗时、模型规模和内存趋势监控。
|
||||||
|
- [x] 验证大模型、资源缺失、无效 XML、重复加载和长时间运行。
|
||||||
|
- [x] 对主线程 step 设置每帧预算与最大追赶步数,超预算时显示性能警告而非无限追帧;Worker/SharedArrayBuffer 留作后续版本。
|
||||||
|
- [x] 完成生产构建、纯静态部署/离线使用说明、示例工程和用户文档。
|
||||||
|
- [x] 更新本计划进度看板并记录遗留项。
|
||||||
|
|
||||||
|
## Implementation Result
|
||||||
|
|
||||||
|
- 平台代码:`wasm/web_platform/`;使用 `@mujoco/mujoco@3.11.0` 默认单线程入口。
|
||||||
|
- 自动化结果:TypeScript、ESLint、10 个 Vitest 测试、5 个 Chrome Playwright E2E、生产构建均通过。
|
||||||
|
- E2E 覆盖:单文件 MJCF、MJCF include + OBJ/STL/PNG、URDF + OBJ、中等规模持续步进/重复加载、无效模型错误保留;另用完整 Go2W ZIP 实测 ROS `package://`、重复 material 和 DAE 降级后可编译。
|
||||||
|
- 静态服务验证:Python HTTP server 对 HTML 与 WASM 均返回 200,WASM MIME 为 `application/wasm`。
|
||||||
|
- 依赖审计:`npm audit` 0 vulnerabilities。
|
||||||
|
|
||||||
|
### 遗留验收项
|
||||||
|
|
||||||
|
- Edge/Firefox 最新版本尚未在当前环境做人工交互验收。
|
||||||
|
- 关节拖动、外力箭头与超大模型的视觉手感仍需结合真实机器人工程人工调参。
|
||||||
|
- 当前构建有约 865 KiB JS chunk 的 Vite 体积警告;不影响 MVP,后续可按面板/Three.js 做代码分割。
|
||||||
|
- E2E 的持续步进是秒级冒烟;发布前建议增加 30–60 分钟内存与稳定性 soak test。
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- 自动化:TypeScript 类型检查、单元测试、构建测试、浏览器 E2E 冒烟测试。
|
||||||
|
- 导入:MJCF/URDF 与其 mesh/texture 相对引用可加载;缺失/冲突/非法路径给出可定位错误。
|
||||||
|
- 物理:播放、暂停、单步、重置和 actuator 控制结果符合 MuJoCo 时间步;重复加载无悬挂循环。
|
||||||
|
- 渲染:primitive/mesh 位姿与 `xpos/xquat` 一致,相机和选中交互稳定,窗口缩放正常。
|
||||||
|
- 生命周期:连续切换模型和长时间仿真时,WASM/Three.js 资源无持续异常增长。
|
||||||
|
- 浏览器:桌面版 Chrome、Edge、Firefox 最新两个大版本完成手工验收;不测试手机、平板和多线程模式。
|
||||||
|
- 交付:生产构建可由本地静态 HTTP 服务器启动,`.wasm` MIME 与相对资源路径正确;明确 `file://` 不受支持。
|
||||||
|
|
||||||
|
## Confirmed Decisions
|
||||||
|
|
||||||
|
1. React + Zustand。
|
||||||
|
2. MVP 支持 MJCF/XML、URDF、文件夹、ZIP;URDF 交由 MuJoCo 原生 loader 解析。
|
||||||
|
3. 首版不做 XML 编辑、热重载和工程导出。
|
||||||
|
4. 首版主线程单线程 WASM。
|
||||||
|
5. 纯静态、离线应用,无后端和账号体系。
|
||||||
|
6. 首版交互优先 actuator 滑杆、关节拖动、外力施加;关节拖动时暂停且仅支持 hinge/slide,外力拖拽期间持续施加、松开清零。
|
||||||
|
7. 仅支持桌面浏览器,中文界面;多个入口由用户弹窗选择。
|
||||||
|
8. 通过本地静态 HTTP 服务器运行,不实现 PWA。
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import globals from 'globals';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||||
|
export default tseslint.config({ignores:['dist','demo-dist','web-platform-dist','node_modules','codegen/generated']},js.configs.recommended,...tseslint.configs.recommended,{files:['web_platform/**/*.{ts,tsx}'],languageOptions:{globals:{...globals.browser,...globals.node}},plugins:{'react-hooks':reactHooks,'react-refresh':reactRefresh},rules:{...reactHooks.configs.recommended.rules,'react-refresh/only-export-components':['warn',{allowConstantExport:true}],'@typescript-eslint/no-explicit-any':'off'}});
|
||||||
Generated
+4066
-15
File diff suppressed because it is too large
Load Diff
+35
-3
@@ -16,18 +16,50 @@
|
|||||||
"dev:sandbox": "vite --config tests/sandbox/vite.sandbox.config.ts",
|
"dev:sandbox": "vite --config tests/sandbox/vite.sandbox.config.ts",
|
||||||
"build:sandbox": "vite build --config tests/sandbox/vite.sandbox.config.ts",
|
"build:sandbox": "vite build --config tests/sandbox/vite.sandbox.config.ts",
|
||||||
"dev:demo": "vite --config demo_app/vite.demo.config.ts",
|
"dev:demo": "vite --config demo_app/vite.demo.config.ts",
|
||||||
"build:demo": "vite build --config demo_app/vite.demo.config.ts"
|
"build:demo": "vite build --config demo_app/vite.demo.config.ts",
|
||||||
|
"dev:platform": "vite --config web_platform/vite.config.ts",
|
||||||
|
"build:platform": "vite build --config web_platform/vite.config.ts",
|
||||||
|
"preview:platform": "vite preview --config web_platform/vite.config.ts",
|
||||||
|
"typecheck:platform": "tsc -p web_platform/tsconfig.json --noEmit",
|
||||||
|
"lint:platform": "eslint web_platform/src web_platform/e2e",
|
||||||
|
"test:platform": "vitest run --config web_platform/vite.config.ts",
|
||||||
|
"test:e2e:platform": "playwright test -c web_platform/playwright.config.ts"
|
||||||
},
|
},
|
||||||
"author": "Google DeepMind",
|
"author": "Google DeepMind",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@playwright/test": "^1.62.1",
|
||||||
|
"@testing-library/jest-dom": "^7.0.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/jasmine": "^5.1.8",
|
"@types/jasmine": "^5.1.8",
|
||||||
"@types/node": "^24.1.0",
|
"@types/node": "^24.1.0",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-dom": "^19.2.4",
|
||||||
|
"@types/three": "^0.185.4",
|
||||||
|
"@vitejs/plugin-react": "^6.1.0",
|
||||||
|
"autoprefixer": "^10.5.4",
|
||||||
|
"eslint": "^10.8.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.4",
|
||||||
|
"globals": "^17.11.0",
|
||||||
"jasmine": "^5.9.0",
|
"jasmine": "^5.9.0",
|
||||||
|
"jsdom": "^30.0.1",
|
||||||
|
"postcss": "^8.5.26",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
"three": "^0.178.0",
|
"three": "^0.178.0",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "5.8.2",
|
"typescript": "5.8.2",
|
||||||
"vite": "^8.0.16"
|
"typescript-eslint": "^8.67.0",
|
||||||
|
"vite": "^8.0.16",
|
||||||
|
"vitest": "^4.1.11"
|
||||||
},
|
},
|
||||||
"type": "module"
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@mujoco/mujoco": "^3.11.0",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
"zustand": "^5.0.15"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# MuJoCo Web 仿真平台
|
||||||
|
|
||||||
|
基于 MuJoCo 官方 JavaScript/WASM 绑定的中文桌面仿真平台。平台依赖与当前源码版本一致的 `@mujoco/mujoco` 默认入口(单线程);本工作区现有 `wasm/dist/` 是启用线程构建的产物,因此不作为 MVP 运行入口。所有模型和资源只写入浏览器内的 Emscripten MEMFS,不上传到服务器。
|
||||||
|
|
||||||
|
## MVP 功能
|
||||||
|
|
||||||
|
- 导入单个/多个 MJCF(XML)、URDF 和关联资源
|
||||||
|
- 兼容常见 ROS URDF:规范化重复 material、解析 `package://` 工程内资源路径
|
||||||
|
- 导入保留相对路径的文件夹或 ZIP 工程
|
||||||
|
- 多模型入口选择、中文加载和编译错误
|
||||||
|
- Three.js primitive、mesh、材质/贴图显示与对象选择
|
||||||
|
- 播放、暂停、单步、重置、0.25×–4× 速度
|
||||||
|
- actuator 滑杆、hinge/slide 关节拖动、动态 body 外力拖拽
|
||||||
|
- FPS、物理耗时和主线程步进预算提示
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
从仓库根目录运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install --prefix wasm
|
||||||
|
npm run dev:platform --prefix wasm
|
||||||
|
```
|
||||||
|
|
||||||
|
打开 Vite 输出的 HTTP 地址。应用**不支持**通过 `file://` 直接打开,也不注册 Service Worker/PWA。
|
||||||
|
|
||||||
|
## 质量检查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck:platform --prefix wasm
|
||||||
|
npm run lint:platform --prefix wasm
|
||||||
|
npm run test:platform --prefix wasm
|
||||||
|
npm run build:platform --prefix wasm
|
||||||
|
npm run test:e2e:platform --prefix wasm
|
||||||
|
```
|
||||||
|
|
||||||
|
E2E 默认使用系统安装的 Google Chrome。若没有 Chrome,可修改 `playwright.config.ts` 或运行 `npx playwright install chromium` 后移除 `channel: 'chrome'`。
|
||||||
|
|
||||||
|
## 生产构建与本地静态部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:platform --prefix wasm
|
||||||
|
python3 -m http.server 8080 --directory wasm/web-platform-dist
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 <http://127.0.0.1:8080/>。构建使用 `base: './'`,可部署到任意静态子目录。静态服务器需将 `.wasm` 返回为 `application/wasm`;Python 3.11+、Vite preview、Nginx 等常见服务器均支持。
|
||||||
|
|
||||||
|
## 工程导入约定
|
||||||
|
|
||||||
|
- 文件夹和 ZIP 中必须至少有一个根元素为 `<mujoco>` 或 `<robot>` 的 `.xml`/`.urdf`。
|
||||||
|
- 只有一个入口时自动加载;多个入口优先根目录 `model.xml`、`scene.xml` 或唯一 URDF,否则弹窗选择。
|
||||||
|
- XML 的 `include`、mesh 和贴图路径必须相对于入口/编译器配置可解析。
|
||||||
|
- 路径穿越、绝对路径、加密 ZIP、重复路径会被拒绝。
|
||||||
|
- 默认限制:2000 个文件、单文件 128 MiB、总解压大小 512 MiB、ZIP 文件 128 MiB。
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
`fixtures/` 包含(用于测试和手工验收,不会打进生产构建):
|
||||||
|
|
||||||
|
- `mjcf_include/`:MJCF include、OBJ/STL mesh 和 PNG texture;
|
||||||
|
- `urdf_mesh/`:引用 OBJ 的 URDF;
|
||||||
|
- `invalid.xml`:无效模型;
|
||||||
|
- `missing-resource.xml`:缺失资源错误示例。
|
||||||
|
|
||||||
|
在“打开文件夹”中选择夹具目录即可加载。
|
||||||
|
|
||||||
|
## 当前限制
|
||||||
|
|
||||||
|
- 仅面向桌面版 Chrome、Edge、Firefox;未适配手机和平板。
|
||||||
|
- 物理运行在主线程、单线程 WASM。超出每帧预算时限制追帧并提示。
|
||||||
|
- 不支持 Xacro、XML 在线编辑、热重载、导出、账号或云端保存。
|
||||||
|
- 关节拖动只支持 hinge/slide;ball/free joint 只读。
|
||||||
|
- MuJoCo WASM 本身不支持 DAE mesh。平台会移除 DAE visual,并以 collision 几何显示;DAE collision 会替换为半径 0.05 m 的占位球体并在界面警告。高精度仿真应先将 DAE 转为 OBJ/STL 或改为 URDF primitive。
|
||||||
|
- 导入工程只存在当前页面内存,刷新页面后需重新导入。
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import {expect, test} from '@playwright/test';
|
||||||
|
import {fileURLToPath} from 'node:url';
|
||||||
|
|
||||||
|
const fixture = (relative: string) => fileURLToPath(new URL(`../fixtures/${relative}`, import.meta.url));
|
||||||
|
|
||||||
|
const SIMPLE_MODEL = `
|
||||||
|
<mujoco model="e2e">
|
||||||
|
<worldbody>
|
||||||
|
<light pos="0 0 3"/>
|
||||||
|
<body name="box" pos="0 0 1">
|
||||||
|
<joint name="slide" type="slide" axis="1 0 0" range="-1 1"/>
|
||||||
|
<geom name="box_geom" type="box" size=".2 .2 .2" mass="1"/>
|
||||||
|
</body>
|
||||||
|
</worldbody>
|
||||||
|
<actuator><motor name="motor" joint="slide" ctrlrange="-2 2"/></actuator>
|
||||||
|
</mujoco>`;
|
||||||
|
|
||||||
|
const LARGE_MODEL = `
|
||||||
|
<mujoco model="soak">
|
||||||
|
<option timestep=".005"/>
|
||||||
|
<worldbody>
|
||||||
|
<replicate count="6" offset=".12 0 0">
|
||||||
|
<replicate count="6" offset="0 .12 0">
|
||||||
|
<replicate count="6" offset="0 0 .12">
|
||||||
|
<body pos="-.3 -.3 .5"><joint type="free"/><geom type="sphere" size=".03" mass=".01"/></body>
|
||||||
|
</replicate>
|
||||||
|
</replicate>
|
||||||
|
</replicate>
|
||||||
|
<geom type="plane" size="2 2 .1"/>
|
||||||
|
</worldbody>
|
||||||
|
</mujoco>`;
|
||||||
|
|
||||||
|
test('显示中文平台骨架并加载单文件模型', async ({page}) => {
|
||||||
|
page.on('console', (message) => console.log(`[browser:${message.type()}] ${message.text()}`));
|
||||||
|
page.on('pageerror', (error) => console.log(`[browser:error] ${error.message}`));
|
||||||
|
page.on('requestfailed', (request) => console.log(`[browser:requestfailed] ${request.url()} ${request.failure()?.errorText}`));
|
||||||
|
page.on('response', (response) => { if (response.status() >= 400 || response.url().endsWith('.wasm')) console.log(`[browser:response] ${response.status()} ${response.url()} ${response.headers()['content-type'] ?? ''}`); });
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.getByRole('heading', {name: 'MuJoCo Web 仿真平台'})).toBeVisible();
|
||||||
|
await expect(page.getByRole('main').getByText('拖放模型工程到此处')).toBeVisible();
|
||||||
|
await expect(page.getByRole('img',{name:'XYZ 方向指示器'})).toBeVisible();
|
||||||
|
|
||||||
|
await 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 expect(page.getByText('motor')).toBeVisible();
|
||||||
|
await expect(page.getByText('slide')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('加载包含 include、OBJ、STL 与 PNG 的工程', async ({page}) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('input[type="file"]').first().setInputFiles([
|
||||||
|
fixture('mjcf_include/model.xml'),
|
||||||
|
fixture('mjcf_include/world.xml'),
|
||||||
|
fixture('mjcf_include/triangle.obj'),
|
||||||
|
fixture('mjcf_include/triangle.stl'),
|
||||||
|
fixture('mjcf_include/checker.png'),
|
||||||
|
]);
|
||||||
|
await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000});
|
||||||
|
await expect(page.getByText('5 个文件')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('加载引用 OBJ 的 URDF 工程', async ({page}) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('input[type="file"]').first().setInputFiles([
|
||||||
|
fixture('urdf_mesh/robot.urdf'),
|
||||||
|
fixture('urdf_mesh/triangle.obj'),
|
||||||
|
]);
|
||||||
|
await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000});
|
||||||
|
await expect(page.getByText('2 个文件')).toBeVisible();
|
||||||
|
await expect(page.getByLabel('URDF 处理方式')).toHaveValue('mjcf');
|
||||||
|
await expect(page.getByLabel('URDF 基座类型')).toHaveValue('floating');
|
||||||
|
await expect(page.getByText(/URDF 已转换为 MJCF(浮动基座),并整体平移/)).toBeVisible();
|
||||||
|
await expect(page.getByLabel('显示碰撞几何')).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('中等规模模型持续步进并可重复加载', async ({page}) => {
|
||||||
|
await page.goto('/');
|
||||||
|
const input = page.locator('input[type="file"]').first();
|
||||||
|
const modelFile = {name:'large.xml',mimeType:'text/xml',buffer:Buffer.from(LARGE_MODEL)};
|
||||||
|
await input.setInputFiles(modelFile);
|
||||||
|
await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000});
|
||||||
|
await page.getByRole('button', {name:'▶ 播放'}).click();
|
||||||
|
await page.waitForTimeout(2_000);
|
||||||
|
await expect(page.locator('footer')).not.toContainText('时间 0.000 s');
|
||||||
|
|
||||||
|
// 播放过程中重置必须同时暂停底层会话,之后仍可正常播放和暂停。
|
||||||
|
await page.getByRole('button',{name:'重置'}).click();
|
||||||
|
await expect(page.getByRole('button',{name:'▶ 播放'})).toBeVisible();
|
||||||
|
await expect(page.locator('footer')).toContainText('时间 0.000 s');
|
||||||
|
await page.getByRole('button',{name:'▶ 播放'}).click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.getByRole('button',{name:'⏸ 暂停'}).click();
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
const pausedTime=(await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1];
|
||||||
|
expect(Number(pausedTime)).toBeGreaterThan(0);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
expect((await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1]).toBe(pausedTime);
|
||||||
|
|
||||||
|
await input.setInputFiles(modelFile);
|
||||||
|
await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000});
|
||||||
|
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('无效模型显示中文诊断且保留工程树', async ({page}) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('input[type="file"]').first().setInputFiles(fixture('invalid.xml'));
|
||||||
|
await expect(page.getByRole('alert')).toContainText('模型编译失败', {timeout: 30_000});
|
||||||
|
await expect(page.getByText('invalid.xml', {exact: false}).first()).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<mujoco><worldbody><invalid/></worldbody></mujoco>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<mujoco><asset><mesh name="missing" file="does-not-exist.stl"/></asset><worldbody><geom type="mesh" mesh="missing"/></worldbody></mujoco>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 73 B |
@@ -0,0 +1 @@
|
|||||||
|
<mujoco model="fixture"><include file="world.xml"/></mujoco>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
v 0 0 0
|
||||||
|
v 1 0 0
|
||||||
|
v 0 1 0
|
||||||
|
v 0 0 1
|
||||||
|
f 1 3 2
|
||||||
|
f 1 2 4
|
||||||
|
f 1 4 3
|
||||||
|
f 2 3 4
|
||||||
Binary file not shown.
@@ -0,0 +1,17 @@
|
|||||||
|
<mujoco>
|
||||||
|
<asset>
|
||||||
|
<mesh name="triangle" file="triangle.obj"/>
|
||||||
|
<mesh name="triangle_stl" file="triangle.stl"/>
|
||||||
|
<texture name="checker" type="2d" file="checker.png"/>
|
||||||
|
<material name="checker_mat" texture="checker"/>
|
||||||
|
</asset>
|
||||||
|
<worldbody>
|
||||||
|
<light pos="0 0 3"/>
|
||||||
|
<geom type="plane" size="2 2 .1" material="checker_mat"/>
|
||||||
|
<body name="triangle_body" pos="0 0 1">
|
||||||
|
<freejoint/>
|
||||||
|
<geom name="triangle_geom" type="mesh" mesh="triangle" mass="1"/>
|
||||||
|
<geom name="stl_geom" type="mesh" mesh="triangle_stl" pos="0 0 .1" mass=".1"/>
|
||||||
|
</body>
|
||||||
|
</worldbody>
|
||||||
|
</mujoco>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<robot name="fixture_robot"><link name="base"><inertial><mass value="1"/><origin xyz="0 0 0"/><inertia ixx=".1" iyy=".1" izz=".1" ixy="0" ixz="0" iyz="0"/></inertial><visual><geometry><mesh filename="triangle.obj"/></geometry></visual><collision><geometry><mesh filename="triangle.obj"/></geometry></collision></link></robot>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
v 0 0 0
|
||||||
|
v 1 0 0
|
||||||
|
v 0 1 0
|
||||||
|
v 0 0 1
|
||||||
|
f 1 3 2
|
||||||
|
f 1 2 4
|
||||||
|
f 1 4 3
|
||||||
|
f 2 3 4
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#101827"/><link rel="icon" href="data:,"/><title>MuJoCo Web 仿真平台</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import {defineConfig} from '@playwright/test';
|
||||||
|
export default defineConfig({testDir:'./e2e', timeout:120_000, use:{baseURL:'http://127.0.0.1:4173',channel:'chrome'}, webServer:{command:'npm run preview:platform --prefix .. -- --host 127.0.0.1',url:'http://127.0.0.1:4173',reuseExistingServer:true}});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
module.exports = {plugins: {tailwindcss: {config: './web_platform/tailwind.config.cjs'}, autoprefixer: {}}};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/* Zustand 的 action 引用稳定;初始化 viewer 与导入回调有意只创建一次。 */
|
||||||
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
|
import {useCallback,useEffect,useRef,useState,type ChangeEvent,type DragEvent} from 'react';
|
||||||
|
import type {ProjectManifest} from '../project/types';
|
||||||
|
import {filesFromDrop,importBrowserFiles,ProjectImportError} from '../project/importer';
|
||||||
|
import {ProjectTree} from '../project/ProjectTree';
|
||||||
|
import {MainThreadPhysicsAdapter,type UrdfBaseMode,type UrdfLoadMode} from '../simulation/PhysicsAdapter';
|
||||||
|
import {MuJoCoViewer,type InteractionMode} from '../viewer/MuJoCoViewer';
|
||||||
|
import {useAppStore,type AppDiagnostic} from '../stores/useAppStore';
|
||||||
|
|
||||||
|
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()};}
|
||||||
|
const modeLabels:Record<InteractionMode,string>={select:'选择',joint:'关节拖动',force:'外力施加'};
|
||||||
|
|
||||||
|
export function App(){
|
||||||
|
const state=useAppStore(); const manifest=useRef<ProjectManifest|null>(null); const adapter=useRef(new MainThreadPhysicsAdapter()); const viewerHost=useRef<HTMLDivElement>(null); const viewer=useRef<MuJoCoViewer|null>(null); const [forceScale,setForceScale]=useState(50); const [leftOpen,setLeftOpen]=useState(true);const [rightOpen,setRightOpen]=useState(true);const [urdfMode,setUrdfMode]=useState<UrdfLoadMode>('mjcf');const urdfModeRef=useRef<UrdfLoadMode>('mjcf');const [baseMode,setBaseMode]=useState<UrdfBaseMode>('floating');const baseModeRef=useRef<UrdfBaseMode>('floating');const [showCollision,setShowCollision]=useState(false);
|
||||||
|
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]);
|
||||||
|
const loadEntry=useCallback(async(path:string,requestedMode?:UrdfLoadMode)=>{if(!manifest.current)return;state.setEntry(path);state.setLoading(true);state.setDiagnostic(undefined);viewer.current?.attach(null);try{const snapshot=await adapter.current.load(manifest.current,path,requestedMode??urdfModeRef.current,baseModeRef.current);state.setSnapshot(snapshot);state.setPaused(true);viewer.current?.attach(adapter.current.session);}catch(error){state.setDiagnostic(diagnostic('模型编译',error,path));}finally{state.setLoading(false);}},[]);
|
||||||
|
const ingest=useCallback(async(files:File[])=>{state.setLoading(true);try{const next=await importBrowserFiles(files);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));}finally{state.setLoading(false);}},[loadEntry]);
|
||||||
|
const removeProject=()=>{if(!state.projectName||!window.confirm(`确定从当前会话中移除“${state.projectName}”吗?\n不会删除本地文件。`))return;viewer.current?.attach(null);adapter.current.dispose();manifest.current=null;state.clearProject();};
|
||||||
|
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='';};
|
||||||
|
const drop=(event:DragEvent)=>{event.preventDefault();void filesFromDrop(event.dataTransfer.items,event.dataTransfer.files).then(ingest).catch((e)=>state.setDiagnostic(diagnostic('导入',e)));};
|
||||||
|
const togglePause=()=>{const value=!state.paused;state.setPaused(value);adapter.current.setPaused(value);};
|
||||||
|
const reset=()=>{adapter.current.setPaused(true);adapter.current.reset();state.setSnapshot(adapter.current.snapshot()??undefined);state.setPaused(true);};
|
||||||
|
const singleStep=()=>{adapter.current.singleStep();state.setSnapshot(adapter.current.snapshot()??undefined);};
|
||||||
|
const changeSpeed=(value:number)=>{state.setSpeed(value);adapter.current.setSpeed(value);};
|
||||||
|
const mode=(value:InteractionMode)=>{state.setMode(value);};
|
||||||
|
useEffect(()=>{const key=(e:KeyboardEvent)=>{if((e.target as HTMLElement).matches('input,select,button'))return;if(e.code==='Space'){e.preventDefault();togglePause();}if(e.key==='r')reset();if(e.key==='1')mode('select');if(e.key==='2')mode('joint');if(e.key==='3')mode('force');};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);});
|
||||||
|
return <div className="flex h-screen min-w-[1024px] flex-col overflow-hidden bg-slate-950 text-slate-100" onDragOver={e=>e.preventDefault()} onDrop={drop}>
|
||||||
|
<header className="flex h-14 shrink-0 items-center gap-3 border-b border-slate-700 bg-slate-900 px-4"><h1 className="mr-3 text-base font-semibold">MuJoCo Web 仿真平台</h1><label className="btn cursor-pointer">打开文件/ZIP<input className="sr-only" type="file" multiple accept=".xml,.urdf,.zip,.obj,.stl,.dae,.msh,.png,.jpg,.jpeg,.bmp,.tga,.hdr" onChange={changeFiles}/></label><label className="btn cursor-pointer">打开文件夹<input className="sr-only" type="file" multiple {...({webkitdirectory:'',directory:''} as object)} onChange={changeFiles}/></label><span className="h-6 border-l border-slate-700"/><button className="btn" onClick={togglePause} disabled={!state.snapshot}>{state.paused?'▶ 播放':'⏸ 暂停'}</button><button className="btn" onClick={singleStep} disabled={!state.snapshot||!state.paused}>单步</button><button className="btn" onClick={reset} disabled={!state.snapshot}>重置</button><select aria-label="仿真速度" value={state.speed} onChange={e=>changeSpeed(Number(e.target.value))} className="field w-24"><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="h-6 border-l border-slate-700"/>{(Object.keys(modeLabels) as InteractionMode[]).map(value=><button key={value} className={`btn ${state.mode===value?'border-green-500 bg-green-900/50':''}`} onClick={()=>mode(value)}>{modeLabels[value]}</button>)}<button className="btn ml-auto" onClick={()=>viewer.current?.resetCamera()}>相机复位</button><button className="icon-btn" aria-label="切换工程面板" onClick={()=>setLeftOpen(v=>!v)}>☰</button><button className="icon-btn" aria-label="切换属性面板" onClick={()=>setRightOpen(v=>!v)}>⚙</button></header>
|
||||||
|
<div className="flex min-h-0 flex-1">{leftOpen&&<aside className="panel w-72 min-w-56 max-w-[40vw] shrink-0 resize-x overflow-auto border-r"><PanelTitle>工程资源</PanelTitle>{state.projectName?<><div className="flex items-center gap-2 px-3 py-2"><div className="min-w-0 flex-1 truncate text-sm font-medium text-green-300" title={state.projectName}>{state.projectName}</div><button type="button" className="btn shrink-0 border-red-900 px-2 text-red-300 hover:border-red-700 hover:bg-red-950" onClick={removeProject} disabled={state.loading} aria-label="移除当前工程" title="从当前会话中移除,不会删除本地文件">移除</button></div><div className="px-3 pb-2 text-xs text-slate-400">{state.files.length} 个文件</div><div className="px-2 pb-3"><ProjectTree files={state.files} entries={state.entries} selectedEntry={state.selectedEntry}/></div></>:<EmptyImport/>}</aside>}
|
||||||
|
<main className="relative min-w-0 flex-1"><div ref={viewerHost} className="absolute inset-0"/>{!state.snapshot&&!state.loading&&<div className="pointer-events-none absolute inset-0 grid place-items-center"><EmptyImport/></div>}{state.loading&&<div role="status" className="absolute inset-0 grid place-items-center bg-slate-950/70"><div className="rounded bg-slate-800 px-5 py-3">正在加载 MuJoCo 与模型…</div></div>}{state.entries.length>1&&!state.selectedEntry&&<EntryDialog entries={state.entries} onSelect={loadEntry}/>} {state.diagnostic&&<DiagnosticCard value={state.diagnostic} onClose={()=>state.setDiagnostic(undefined)}/>}</main>
|
||||||
|
{rightOpen&&<aside className="panel w-80 min-w-64 max-w-[40vw] shrink-0 resize-x overflow-auto border-l"><PanelTitle>模型与控制</PanelTitle>{state.snapshot?<><section className="section"><h3>模型信息</h3><dl className="grid grid-cols-2 gap-1 text-xs"><dt>Body</dt><dd>{state.snapshot.model.nbody}</dd><dt>Joint</dt><dd>{state.snapshot.model.njnt}</dd><dt>Geom</dt><dd>{state.snapshot.model.ngeom}</dd><dt>Actuator</dt><dd>{state.snapshot.model.nu}</dd><dt>qpos / qvel</dt><dd>{state.snapshot.model.nq} / {state.snapshot.model.nv}</dd></dl></section>{state.entries.find(entry=>entry.path===state.selectedEntry)?.format==='urdf'&&<section className="section"><h3>URDF 处理方式</h3><select aria-label="URDF 处理方式" className="field w-full" value={urdfMode} disabled={state.loading} onChange={event=>changeUrdfMode(event.target.value as UrdfLoadMode)}><option value="mjcf">转换为 MJCF(推荐)</option><option value="native">MuJoCo 原生 URDF</option></select><label className="mt-3 block text-xs text-slate-300"><span className="mb-1 block">基座类型</span><select aria-label="URDF 基座类型" className="field w-full" value={baseMode} disabled={state.loading||urdfMode==='native'} onChange={event=>changeBaseMode(event.target.value as UrdfBaseMode)}><option value="floating">浮动基座(Free Joint)</option><option value="fixed">固定基座(连接世界)</option></select></label><p className="hint mt-2">MJCF 模式会保留 visual mesh、添加物理地面,并将模型最低点对齐到 z=0。浮动基座可受重力和外力运动;固定基座保持与世界固连。</p><label className="mt-3 flex items-center gap-2 text-xs"><input type="checkbox" className="accent-green-500" checked={showCollision} onChange={event=>setShowCollision(event.target.checked)}/>显示碰撞几何</label></section>}{state.snapshot.warnings.length>0&&<section className="section border-amber-700/60 bg-amber-950/20"><h3 className="text-amber-300">URDF 兼容处理</h3><ul className="list-disc space-y-1 pl-4 text-xs text-amber-200">{state.snapshot.warnings.map(message=><li key={message}>{message}</li>)}</ul></section>}<section className="section"><h3>当前选择</h3>{state.selection?<div className="text-xs"><p>{state.selection.bodyName}</p><p className="text-slate-400">body {state.selection.bodyId} · geom {state.selection.geomId} · type {state.selection.geomType}</p><p className="text-slate-400">位置 {state.selection.position.map(v=>v.toFixed(3)).join(', ')}</p></div>:<p className="hint">在视口中单击物体</p>}</section><section className="section"><h3>Actuator</h3>{state.snapshot.actuators.length?state.snapshot.actuators.map(a=><ControlSlider key={a.id} label={a.name} value={a.value} min={a.min} max={a.max} onChange={v=>{adapter.current.setActuator(a.id,v);state.setSnapshot(adapter.current.snapshot()??undefined);}}/>):<p className="hint">模型没有 actuator</p>}</section><section className="section"><h3>关节</h3>{state.snapshot.joints.map(j=><ControlSlider key={j.id} label={`${j.name}${j.editable?'':'(只读)'}`} value={j.value} min={j.min} max={j.max} disabled={!j.editable} onChange={v=>{adapter.current.setJointPosition(j.id,v);state.setPaused(true);state.setSnapshot(adapter.current.snapshot()??undefined);}}/>)}</section><section className="section"><h3>外力强度</h3><ControlSlider label={`${forceScale.toFixed(0)} N/屏幕单位`} value={forceScale} min={5} max={200} onChange={setForceScale}/><p className="hint">选择“外力施加”,在动态物体上按住拖动,松开即清零。</p></section></>:<p className="p-4 text-sm text-slate-400">导入模型后显示属性</p>}</aside>}</div>
|
||||||
|
<footer className="flex h-7 shrink-0 items-center gap-5 border-t border-slate-700 bg-slate-900 px-3 text-xs text-slate-400"><span>时间 {state.snapshot?.time.toFixed(3)??'—'} s</span><span>FPS {state.fps.toFixed(0)}</span><span>物理 {state.stepMs.toFixed(2)} ms</span><span>内存 {state.memoryMb===undefined?'—':`${state.memoryMb.toFixed(1)} MiB`}</span><span>WASM {state.snapshot?'已加载':'未加载'}</span>{state.overBudget&&<span className="text-amber-400">主线程超出步进预算,已限制追帧</span>}<span className="ml-auto">快捷键:Space 播放/暂停 · R 重置 · 1/2/3 模式</span></footer>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
function PanelTitle({children}:{children:string}){return <h2 className="sticky top-0 border-b border-slate-700 bg-slate-900/95 px-3 py-2 text-sm font-semibold">{children}</h2>}
|
||||||
|
function EmptyImport(){return <div className="rounded-lg border border-dashed border-slate-600 bg-slate-900/80 p-6 text-center"><p className="font-medium">拖放模型工程到此处</p><p className="mt-1 text-xs text-slate-400">支持 MJCF/XML、URDF、文件夹和 ZIP</p></div>}
|
||||||
|
function EntryDialog({entries,onSelect}:{entries:{path:string;label:string}[];onSelect:(path:string)=>void}){return <div role="dialog" aria-modal="true" aria-label="选择模型入口" className="absolute inset-0 grid place-items-center bg-slate-950/80"><div className="w-[32rem] rounded border border-slate-600 bg-slate-900 p-5"><h2 className="font-semibold">选择模型入口</h2><p className="mt-1 text-sm text-slate-400">工程包含多个可加载模型,请选择一个。</p><div className="mt-4 space-y-2">{entries.map(e=><button className="btn block w-full overflow-hidden text-ellipsis text-left" key={e.path} onClick={()=>onSelect(e.path)}>{e.label}</button>)}</div></div></div>}
|
||||||
|
function DiagnosticCard({value,onClose}:{value:AppDiagnostic;onClose:()=>void}){return <section role="alert" className="absolute bottom-4 left-4 right-4 max-h-48 overflow-auto rounded border border-red-700 bg-red-950/95 p-3 shadow-xl"><button aria-label="关闭错误" className="float-right" onClick={onClose}>×</button><h2 className="font-semibold text-red-200">{value.summary}</h2>{value.path&&<p className="mt-1 text-xs text-red-300">路径:{value.path}</p>}<pre className="mt-2 whitespace-pre-wrap text-xs text-red-100">{value.detail}</pre></section>}
|
||||||
|
function ControlSlider({label,value,min,max,onChange,disabled=false}:{label:string;value:number;min:number;max:number;onChange:(v:number)=>void;disabled?:boolean}){const sane=Number.isFinite(value)?value:0;return <label className="mb-3 block text-xs"><span className="mb-1 flex justify-between"><span className="truncate pr-2">{label}</span><output>{sane.toFixed(3)}</output></span><input className="w-full accent-green-500" type="range" disabled={disabled} value={Math.min(max,Math.max(min,sane))} min={min} max={max} step={(max-min)/500||.001} onChange={e=>onChange(Number(e.target.value))}/></label>}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import {Component, type ErrorInfo, type ReactNode} from 'react';
|
||||||
|
export class ErrorBoundary extends Component<{children:ReactNode},{error?:Error}>{state:{error?:Error}={};static getDerivedStateFromError(error:Error){return {error};}componentDidCatch(error:Error,info:ErrorInfo){console.error('React fatal error',error,info);}render(){return this.state.error?<main className="grid h-screen place-items-center bg-slate-950 text-slate-100"><section className="max-w-xl rounded border border-red-700 bg-red-950/50 p-6"><h1 className="text-xl font-semibold">界面发生致命错误</h1><pre className="mt-3 whitespace-pre-wrap text-sm text-red-200">{this.state.error.message}</pre><button className="btn mt-4" onClick={()=>location.reload()}>重新加载</button></section></main>:this.props.children;}}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import {StrictMode} from 'react';
|
||||||
|
import {createRoot} from 'react-dom/client';
|
||||||
|
import {App} from './app/App';
|
||||||
|
import {ErrorBoundary} from './app/ErrorBoundary';
|
||||||
|
import './styles.css';
|
||||||
|
if(location.protocol==='file:')document.body.innerHTML='<main style="font-family:sans-serif;padding:2rem"><h1>需要本地 HTTP 服务器</h1><p>请运行 npm run dev:platform --prefix wasm,不能直接通过 file:// 打开。</p></main>';else createRoot(document.getElementById('root')!).render(<StrictMode><ErrorBoundary><App/></ErrorBoundary></StrictMode>);
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import {render,screen,within} from '@testing-library/react';
|
||||||
|
import {buildProjectTree,ProjectTree} from './ProjectTree';
|
||||||
|
|
||||||
|
const files=[
|
||||||
|
{path:'robot/meshes/arm.obj',size:2048},
|
||||||
|
{path:'robot/model.xml',size:512},
|
||||||
|
{path:'robot/textures/body.png',size:4096},
|
||||||
|
{path:'README.txt',size:10},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('ProjectTree',()=>{
|
||||||
|
it('按路径构建多级目录,并将目录排在文件前面',()=>{
|
||||||
|
const tree=buildProjectTree(files);
|
||||||
|
expect(tree.map(node=>[node.kind,node.name])).toEqual([['directory','robot'],['file','README.txt']]);
|
||||||
|
const robot=tree[0];
|
||||||
|
expect(robot.children?.map(node=>[node.kind,node.name])).toEqual([
|
||||||
|
['directory','meshes'],['directory','textures'],['file','model.xml'],
|
||||||
|
]);
|
||||||
|
expect(robot.children?.[0].children?.[0]).toMatchObject({kind:'file',name:'arm.obj',path:'robot/meshes/arm.obj'});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('以可折叠目录显示文件名,而不是平铺完整路径',()=>{
|
||||||
|
render(<ProjectTree files={files} entries={[{path:'robot/model.xml',format:'mjcf',label:'model'}]} selectedEntry="robot/model.xml"/>);
|
||||||
|
const tree=screen.getByRole('navigation',{name:'工程文件树'});
|
||||||
|
expect(within(tree).getByText('robot')).toBeVisible();
|
||||||
|
expect(within(tree).getByText('meshes')).toBeVisible();
|
||||||
|
expect(within(tree).getByText('arm.obj')).toBeVisible();
|
||||||
|
expect(within(tree).queryByText('robot/meshes/arm.obj')).not.toBeInTheDocument();
|
||||||
|
expect(within(tree).getByText('mjcf')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type {ModelEntry} from './types';
|
||||||
|
|
||||||
|
export interface ProjectTreeFile {path:string;size:number;}
|
||||||
|
|
||||||
|
export interface ProjectTreeNode {
|
||||||
|
name:string;
|
||||||
|
path:string;
|
||||||
|
kind:'directory'|'file';
|
||||||
|
size?:number;
|
||||||
|
children?:ProjectTreeNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MutableDirectory {
|
||||||
|
name:string;
|
||||||
|
path:string;
|
||||||
|
directories:Map<string,MutableDirectory>;
|
||||||
|
files:ProjectTreeNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareNodes(a:ProjectTreeNode,b:ProjectTreeNode):number {
|
||||||
|
if(a.kind!==b.kind)return a.kind==='directory'?-1:1;
|
||||||
|
return a.name.localeCompare(b.name,'zh-CN',{numeric:true,sensitivity:'base'});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将规范化后的工程路径转换为“目录优先、名称排序”的资源树。 */
|
||||||
|
// 同文件导出纯函数是为了让资源树的数据转换可独立测试。
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
|
export function buildProjectTree(files:ProjectTreeFile[]):ProjectTreeNode[] {
|
||||||
|
const root:MutableDirectory={name:'',path:'',directories:new Map(),files:[]};
|
||||||
|
for(const file of files){
|
||||||
|
const parts=file.path.split('/').filter(Boolean);
|
||||||
|
if(!parts.length)continue;
|
||||||
|
let parent=root;
|
||||||
|
for(const part of parts.slice(0,-1)){
|
||||||
|
const path=parent.path?`${parent.path}/${part}`:part;
|
||||||
|
let directory=parent.directories.get(part);
|
||||||
|
if(!directory){directory={name:part,path,directories:new Map(),files:[]};parent.directories.set(part,directory);}
|
||||||
|
parent=directory;
|
||||||
|
}
|
||||||
|
parent.files.push({name:parts.at(-1)!,path:file.path,kind:'file',size:file.size});
|
||||||
|
}
|
||||||
|
const finish=(directory:MutableDirectory):ProjectTreeNode[]=>[
|
||||||
|
...Array.from(directory.directories.values(),child=>({name:child.name,path:child.path,kind:'directory' as const,children:finish(child)})),
|
||||||
|
...directory.files,
|
||||||
|
].sort(compareNodes);
|
||||||
|
return finish(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes:number):string {
|
||||||
|
if(bytes<1024)return `${bytes} B`;
|
||||||
|
if(bytes<1024*1024)return `${(bytes/1024).toFixed(bytes<10*1024?1:0)} KB`;
|
||||||
|
return `${(bytes/(1024*1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TreeNodes({nodes,entryFormats,selectedEntry}:{nodes:ProjectTreeNode[];entryFormats:Map<string,ModelEntry['format']>;selectedEntry?:string}){
|
||||||
|
return <ul role="group" className="ml-3 border-l border-slate-800 pl-1">
|
||||||
|
{nodes.map(node=>node.kind==='directory'?<li key={`d:${node.path}`}>
|
||||||
|
<details open>
|
||||||
|
<summary title={node.path} className="cursor-pointer select-none truncate rounded px-1.5 py-1 text-xs text-slate-300 hover:bg-slate-800"><span aria-hidden="true" className="mr-1">📁</span>{node.name}</summary>
|
||||||
|
<TreeNodes nodes={node.children??[]} entryFormats={entryFormats} selectedEntry={selectedEntry}/>
|
||||||
|
</details>
|
||||||
|
</li>:<li key={`f:${node.path}`} title={node.path} className={`flex min-w-0 items-center gap-1 rounded px-1.5 py-1 text-xs ${selectedEntry===node.path?'bg-green-900/40 text-green-200':'text-slate-300 hover:bg-slate-800'}`}>
|
||||||
|
<span aria-hidden="true">{entryFormats.has(node.path)?'◇':'▧'}</span><span className="min-w-0 flex-1 truncate">{node.name}</span>{entryFormats.has(node.path)&&<span className="shrink-0 text-[10px] uppercase text-green-400">{entryFormats.get(node.path)}</span>}<span className="shrink-0 text-[10px] text-slate-500">{formatSize(node.size??0)}</span>
|
||||||
|
</li>)}
|
||||||
|
</ul>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectTree({files,entries,selectedEntry}:{files:ProjectTreeFile[];entries:ModelEntry[];selectedEntry?:string}){
|
||||||
|
const nodes=buildProjectTree(files);
|
||||||
|
const entryFormats=new Map(entries.map(entry=>[entry.path,entry.format]));
|
||||||
|
return <nav aria-label="工程文件树"><TreeNodes nodes={nodes} entryFormats={entryFormats} selectedEntry={selectedEntry}/></nav>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import {LoadingManager,type Material,type Mesh,type Texture} from 'three';
|
||||||
|
import {OBJExporter} from 'three/addons/exporters/OBJExporter.js';
|
||||||
|
import {ColladaLoader} from 'three/addons/loaders/ColladaLoader.js';
|
||||||
|
|
||||||
|
const TRANSPARENT_PIXEL='data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Collada 几何转换为 MuJoCo WASM 可读取的 OBJ。
|
||||||
|
* ColladaLoader 会先统一为 Y-up;额外旋转到 MuJoCo 使用的 Z-up,并烘焙节点变换与单位缩放。
|
||||||
|
*/
|
||||||
|
export function convertDaeToObj(data:Uint8Array,path:string):Uint8Array {
|
||||||
|
const manager=new LoadingManager();
|
||||||
|
// 转换只需要几何。拦截贴图 URL,避免为浏览器内存文件发起无效网络请求。
|
||||||
|
manager.setURLModifier(()=>TRANSPARENT_PIXEL);
|
||||||
|
const loader=new ColladaLoader(manager);
|
||||||
|
const text=new TextDecoder('utf-8').decode(data);
|
||||||
|
const xml=new DOMParser().parseFromString(text,'application/xml');
|
||||||
|
if(xml.querySelector('parsererror'))throw new Error('Collada XML 格式无效');
|
||||||
|
const upAxis=xml.getElementsByTagName('up_axis')[0]?.textContent?.trim().toUpperCase()??'Y_UP';
|
||||||
|
// 禁用 ColladaLoader 自带的 Z-up → Y-up 旋转,改为直接统一到 MuJoCo 的 Z-up。
|
||||||
|
if(upAxis==='Z_UP')xml.getElementsByTagName('up_axis')[0]!.textContent='Y_UP';
|
||||||
|
const normalized=new XMLSerializer().serializeToString(xml);
|
||||||
|
const result=loader.parse(normalized,path.slice(0,path.lastIndexOf('/')+1));
|
||||||
|
if(!result?.scene)throw new Error('Collada 文件无法解析');
|
||||||
|
const scene=result.scene;
|
||||||
|
if(upAxis==='Y_UP')scene.rotation.x+=Math.PI/2;
|
||||||
|
else if(upAxis==='X_UP')scene.rotation.y-=Math.PI/2;
|
||||||
|
scene.updateMatrixWorld(true);
|
||||||
|
let meshCount=0;
|
||||||
|
scene.traverse(object=>{
|
||||||
|
const mesh=object as Mesh;
|
||||||
|
if(!mesh.isMesh)return;
|
||||||
|
meshCount+=1;
|
||||||
|
const materials=Array.isArray(mesh.material)?mesh.material:[mesh.material];
|
||||||
|
for(const material of materials)if(material)material.name='';
|
||||||
|
});
|
||||||
|
if(!meshCount)throw new Error('Collada 文件不包含可转换的三角网格');
|
||||||
|
try {
|
||||||
|
const output=new OBJExporter().parse(scene);
|
||||||
|
if(!/^v\s/m.test(output)||!/^f\s/m.test(output))throw new Error('Collada 文件未生成有效三角面');
|
||||||
|
return new TextEncoder().encode(output);
|
||||||
|
} finally {
|
||||||
|
scene.traverse(object=>{
|
||||||
|
const mesh=object as Mesh;
|
||||||
|
if(!mesh.isMesh)return;
|
||||||
|
mesh.geometry?.dispose();
|
||||||
|
const materials:Material[]=Array.isArray(mesh.material)?mesh.material:[mesh.material];
|
||||||
|
for(const material of materials){
|
||||||
|
for(const value of Object.values(material))if(value&&typeof value==='object'&&(value as Texture).isTexture)(value as Texture).dispose();
|
||||||
|
material.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import {zipSync} from 'fflate';
|
||||||
|
import {choosePreferredEntry,discoverEntries,importBrowserFiles,normalizeProjectPath,prepareProjectForMujoco,ProjectImportError} from './importer';
|
||||||
|
import type {ProjectFile} from './types';
|
||||||
|
const encode=(s:string)=>new TextEncoder().encode(s);
|
||||||
|
const projectFile=(path:string,text:string):ProjectFile=>({path,data:encode(text),size:encode(text).length,source:'file',mimeType:'text/xml'});
|
||||||
|
const TRIANGLE_DAE=`<?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="triangle"><mesh><source id="positions"><float_array id="positions-array" count="9">0 0 0 1 0 0 0 1 0</float_array><technique_common><accessor source="#positions-array" count="3" 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="1" material="mat"><input semantic="VERTEX" source="#vertices" offset="0"/><p>0 1 2</p></triangles></mesh></geometry></library_geometries>
|
||||||
|
<library_visual_scenes><visual_scene id="scene"><node id="node"><instance_geometry url="#triangle"><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>`;
|
||||||
|
describe('project importer',()=>{
|
||||||
|
it('拒绝路径穿越与绝对路径',()=>{expect(()=>normalizeProjectPath('../model.xml')).toThrow(ProjectImportError);expect(()=>normalizeProjectPath('/model.xml')).toThrow(ProjectImportError);expect(normalizeProjectPath('robot\\mesh\\a.obj')).toBe('robot/mesh/a.obj');});
|
||||||
|
it('识别 MJCF 与 URDF 并执行入口优先级',()=>{const entries=discoverEntries([projectFile('other.xml','<mujoco/>'),projectFile('model.xml','<mujoco/>'),projectFile('robot.urdf','<robot/>')]);expect(entries).toHaveLength(3);expect(choosePreferredEntry(entries)).toBe('model.xml');});
|
||||||
|
it('解压 ZIP 并保留二进制数据',async()=>{const zipped=zipSync({'robot/model.urdf':encode('<robot name="r"/>'),'robot/mesh.obj':encode('v 0 0 0')});const file=new File([zipped],'robot.zip',{type:'application/zip'});const result=await importBrowserFiles([file]);expect(result.files.map(f=>f.path)).toContain('robot/mesh.obj');expect(result.selectedEntry).toBe('robot/model.urdf');});
|
||||||
|
it('拒绝 ZIP 路径穿越',async()=>{const zipped=zipSync({'../model.xml':encode('<mujoco/>')});await expect(importBrowserFiles([new File([zipped],'bad.zip')])).rejects.toThrow('路径包含越界片段');});
|
||||||
|
it('拒绝同名路径',async()=>{const a=new File(['<mujoco/>'],'model.xml');const b=new File(['<mujoco/>'],'model.xml');await expect(importBrowserFiles([a,b])).rejects.toThrow('同名路径');});
|
||||||
|
it('拒绝超过限制的文件',async()=>{const file=new File(['<mujoco/>'],'model.xml');await expect(importBrowserFiles([file],{maxFiles:1,maxFileBytes:2,maxTotalBytes:2,maxZipBytes:2})).rejects.toThrow('单文件超过限制');});
|
||||||
|
it('规范化 MuJoCo 不接受的重复 material 和 ROS package URI',()=>{const urdf=projectFile('go2w_description/urdf/robot.urdf','<robot><link name="base"><visual><geometry><mesh filename="package://go2w_description/meshes/base.obj"/></geometry><material name="a"/><material name="b"/></visual></link></robot>');const mesh:ProjectFile={path:'go2w_description/meshes/base.obj',data:new Uint8Array([1]),size:1,source:'directory',mimeType:''};const manifest={id:'go2w',name:'go2w',files:[urdf,mesh],entries:[{path:urdf.path,format:'urdf' as const,label:'robot'}],selectedEntry:urdf.path,totalBytes:urdf.size+1};const prepared=prepareProjectForMujoco(manifest,urdf.path);const text=new TextDecoder().decode(prepared.manifest.files[0].data);expect((text.match(/<material/g)??[])).toHaveLength(1);expect(text).toContain('filename="../meshes/base.obj"');expect(text).toContain('discardvisual="false"');expect(text).toContain('fusestatic="false"');expect(prepared.warnings).toHaveLength(2);});
|
||||||
|
it('将 DAE mesh 转换为 MuJoCo 可读取的 OBJ,并复用于 visual/collision',()=>{const urdf=projectFile('robot/robot.urdf','<robot><link name="base"><visual><geometry><mesh filename="meshes/triangle.dae"/></geometry></visual><collision><geometry><mesh filename="meshes/triangle.dae"/></geometry></collision></link></robot>');const dae=projectFile('robot/meshes/triangle.dae',TRIANGLE_DAE);const manifest={id:'dae',name:'dae',files:[urdf,dae],entries:[{path:urdf.path,format:'urdf' as const,label:'robot'}],selectedEntry:urdf.path,totalBytes:urdf.size+dae.size};const prepared=prepareProjectForMujoco(manifest,urdf.path);const text=new TextDecoder().decode(prepared.manifest.files.find(file=>file.path===urdf.path)!.data);expect(text).not.toContain('.dae');expect(text.match(/meshes\/triangle\.mujoco\.obj/g)).toHaveLength(2);const obj=prepared.manifest.files.find(file=>file.path==='robot/meshes/triangle.mujoco.obj');expect(new TextDecoder().decode(obj!.data)).toMatch(/^f\s/m);expect(prepared.warnings.join(' ')).toContain('1 个 DAE 文件转换为 OBJ');});
|
||||||
|
it('DAE 缺失或转换失败时安全降级',()=>{const urdf=projectFile('robot.urdf','<robot><link name="base"><visual><geometry><mesh filename="visual.dae"/></geometry></visual><collision><geometry><mesh filename="collision.dae"/></geometry></collision></link></robot>');const manifest={id:'dae',name:'dae',files:[urdf],entries:[{path:urdf.path,format:'urdf' as const,label:'robot'}],selectedEntry:urdf.path,totalBytes:urdf.size};const prepared=prepareProjectForMujoco(manifest,urdf.path);const text=new TextDecoder().decode(prepared.manifest.files[0].data);expect(text).not.toContain('<visual>');expect(text).toContain('<collision>');expect(text).toContain('<sphere radius="0.05"');expect(prepared.warnings.join(' ')).toContain('DAE visual');expect(prepared.warnings.join(' ')).toContain('DAE collision');});
|
||||||
|
it('在解压前依据 ZIP 元数据拒绝膨胀内容',async()=>{const zipped=zipSync({'model.xml':encode(`<mujoco>${' '.repeat(4096)}</mujoco>`)});const file=new File([zipped],'large.zip');await expect(importBrowserFiles([file],{maxFiles:2,maxFileBytes:128,maxTotalBytes:256,maxZipBytes:4096})).rejects.toThrow('单文件超过限制');});
|
||||||
|
});
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import {unzipSync} from 'fflate';
|
||||||
|
import {DEFAULT_IMPORT_LIMITS, type ImportLimits, type ModelEntry, type ProjectFile, type ProjectManifest} from './types';
|
||||||
|
import {convertDaeToObj} from './daeConverter';
|
||||||
|
|
||||||
|
const decoder = new TextDecoder('utf-8', {fatal: false});
|
||||||
|
|
||||||
|
export class ProjectImportError extends Error {
|
||||||
|
constructor(message: string, readonly path?: string) { super(message); this.name = 'ProjectImportError'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeProjectPath(input: string): string {
|
||||||
|
const path = input.replaceAll('\\', '/').replace(/^\.\//, '');
|
||||||
|
if (!path || path.startsWith('/') || path.includes('\0') || /^[A-Za-z]:/.test(path)) throw new ProjectImportError('不允许绝对路径或空路径', input);
|
||||||
|
const parts = path.split('/').filter((part) => part !== '' && part !== '.');
|
||||||
|
if (!parts.length || parts.some((part) => part === '..')) throw new ProjectImportError('路径包含越界片段', input);
|
||||||
|
return parts.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkEncryptedZip(data: Uint8Array): void {
|
||||||
|
for (let i = 0; i + 8 < data.length; i++) {
|
||||||
|
if (data[i] === 0x50 && data[i + 1] === 0x4b && (data[i + 2] === 0x03 || data[i + 2] === 0x01) && (data[i + 3] === 0x04 || data[i + 3] === 0x02)) {
|
||||||
|
const flags = data[i + 6] | (data[i + 7] << 8);
|
||||||
|
if ((flags & 1) !== 0) throw new ProjectImportError('不支持加密 ZIP');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enforceLimits(files: ProjectFile[], limits: ImportLimits): void {
|
||||||
|
if (files.length > limits.maxFiles) throw new ProjectImportError(`文件数量超过限制(${limits.maxFiles})`);
|
||||||
|
let total = 0;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const file of files) {
|
||||||
|
if (seen.has(file.path)) throw new ProjectImportError('工程中存在同名路径', file.path);
|
||||||
|
seen.add(file.path);
|
||||||
|
if (file.size > limits.maxFileBytes) throw new ProjectImportError(`单文件超过限制(${limits.maxFileBytes} 字节)`, file.path);
|
||||||
|
total += file.size;
|
||||||
|
if (total > limits.maxTotalBytes) throw new ProjectImportError(`工程总大小超过限制(${limits.maxTotalBytes} 字节)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function discoverEntries(files: ProjectFile[]): ModelEntry[] {
|
||||||
|
return files.flatMap((file): ModelEntry[] => {
|
||||||
|
if (!/\.(xml|urdf)$/i.test(file.path)) return [];
|
||||||
|
const head = decoder.decode(file.data.subarray(0, Math.min(file.data.length, 256 * 1024))).replace(/^\uFEFF/, '');
|
||||||
|
const format = /<robot(?:\s|\/?>)/i.test(head) ? 'urdf' : /<mujoco(?:\s|\/?>)/i.test(head) ? 'mjcf' : undefined;
|
||||||
|
return format ? [{path: file.path, format, label: `${file.path} (${format.toUpperCase()})`}] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreparedProject {
|
||||||
|
manifest: ProjectManifest;
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeProjectPath(fromFile: string, toFile: string): string {
|
||||||
|
const from = fromFile.split('/').slice(0, -1);
|
||||||
|
const to = toFile.split('/');
|
||||||
|
while (from.length && to.length && from[0] === to[0]) { from.shift(); to.shift(); }
|
||||||
|
return `${'../'.repeat(from.length)}${to.join('/')}` || './';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProjectReference(fromFile:string,reference:string):string|undefined {
|
||||||
|
if(/^[a-z][a-z\d+.-]*:/i.test(reference))return;
|
||||||
|
let decoded:string;
|
||||||
|
try{decoded=decodeURIComponent(reference.split(/[?#]/,1)[0]);}catch{return;}
|
||||||
|
const parts=fromFile.split('/').slice(0,-1);
|
||||||
|
for(const part of decoded.replaceAll('\\','/').split('/')){
|
||||||
|
if(!part||part==='.')continue;
|
||||||
|
if(part==='..'){if(!parts.length)return;parts.pop();}
|
||||||
|
else parts.push(part);
|
||||||
|
}
|
||||||
|
return parts.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatedObjPath(daePath:string,occupied:Set<string>):string {
|
||||||
|
const base=daePath.replace(/\.dae$/i,'');
|
||||||
|
let candidate=`${base}.mujoco.obj`;
|
||||||
|
for(let index=2;occupied.has(candidate);index+=1)candidate=`${base}.mujoco-${index}.obj`;
|
||||||
|
occupied.add(candidate);
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalizes common ROS URDF constructs that MuJoCo's stricter parser rejects. */
|
||||||
|
export function prepareProjectForMujoco(manifest: ProjectManifest, entryPath: string): PreparedProject {
|
||||||
|
const entry = manifest.entries.find((candidate) => candidate.path === entryPath);
|
||||||
|
if (entry?.format !== 'urdf') return {manifest, warnings: []};
|
||||||
|
const source = manifest.files.find((file) => file.path === entryPath);
|
||||||
|
if (!source) return {manifest, warnings: []};
|
||||||
|
const document = new DOMParser().parseFromString(decoder.decode(source.data), 'application/xml');
|
||||||
|
if (document.querySelector('parsererror')) return {manifest, warnings: []};
|
||||||
|
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const robot=document.documentElement;
|
||||||
|
let mujoco=Array.from(robot.children).find(child=>child.tagName==='mujoco');
|
||||||
|
if(!mujoco){mujoco=document.createElement('mujoco');robot.prepend(mujoco);}
|
||||||
|
let compiler=Array.from(mujoco.children).find(child=>child.tagName==='compiler');
|
||||||
|
if(!compiler){compiler=document.createElement('compiler');mujoco.append(compiler);}
|
||||||
|
compiler.setAttribute('discardvisual','false');
|
||||||
|
compiler.setAttribute('fusestatic','false');
|
||||||
|
|
||||||
|
let removedMaterials = 0;
|
||||||
|
for (const visual of Array.from(document.querySelectorAll('visual'))) {
|
||||||
|
const materials = Array.from(visual.children).filter((child) => child.tagName === 'material');
|
||||||
|
for (const duplicate of materials.slice(1)) { duplicate.remove(); removedMaterials += 1; }
|
||||||
|
}
|
||||||
|
if (removedMaterials) warnings.push(`为兼容 MuJoCo,已移除 visual 中 ${removedMaterials} 个重复 material(保留第一个)`);
|
||||||
|
|
||||||
|
const paths = manifest.files.map((file) => file.path);
|
||||||
|
let rewrittenUris = 0;
|
||||||
|
const unresolved: string[] = [];
|
||||||
|
for (const element of Array.from(document.querySelectorAll('[filename]'))) {
|
||||||
|
const value = element.getAttribute('filename');
|
||||||
|
if (!value?.startsWith('package://')) continue;
|
||||||
|
const packagePath = normalizeProjectPath(value.slice('package://'.length));
|
||||||
|
const target = paths.find((path) => path === packagePath) ?? paths.find((path) => path.endsWith(`/${packagePath}`));
|
||||||
|
if (!target) { unresolved.push(value); continue; }
|
||||||
|
element.setAttribute('filename', relativeProjectPath(entryPath, target));
|
||||||
|
rewrittenUris += 1;
|
||||||
|
}
|
||||||
|
if (rewrittenUris) warnings.push(`已将 ${rewrittenUris} 个 package:// 资源地址改写为工程内相对路径`);
|
||||||
|
if (unresolved.length) warnings.push(`有 ${unresolved.length} 个 package:// 资源未在工程中找到`);
|
||||||
|
|
||||||
|
const occupied=new Set(manifest.files.map(file=>file.path));
|
||||||
|
const converted=new Map<string,ProjectFile>();
|
||||||
|
let convertedDaeReferences=0;
|
||||||
|
let removedDaeVisuals=0;
|
||||||
|
let daeCollisionFallbacks=0;
|
||||||
|
for(const mesh of Array.from(document.querySelectorAll('mesh[filename]'))){
|
||||||
|
const filename=mesh.getAttribute('filename');
|
||||||
|
if(!filename?.toLowerCase().split(/[?#]/)[0].endsWith('.dae'))continue;
|
||||||
|
const daePath=resolveProjectReference(entryPath,filename);
|
||||||
|
const daeFile=daePath?manifest.files.find(file=>file.path===daePath):undefined;
|
||||||
|
try{
|
||||||
|
if(!daeFile||!daePath)throw new Error('工程中找不到 DAE 文件');
|
||||||
|
let objFile=converted.get(daePath);
|
||||||
|
if(!objFile){
|
||||||
|
const data=convertDaeToObj(daeFile.data,daePath);
|
||||||
|
if(data.byteLength>DEFAULT_IMPORT_LIMITS.maxFileBytes)throw new Error('转换后的 OBJ 超过单文件大小限制');
|
||||||
|
objFile={path:generatedObjPath(daePath,occupied),data,size:data.byteLength,source:daeFile.source,mimeType:'text/plain'};
|
||||||
|
converted.set(daePath,objFile);
|
||||||
|
}
|
||||||
|
mesh.setAttribute('filename',relativeProjectPath(entryPath,objFile.path));
|
||||||
|
convertedDaeReferences+=1;
|
||||||
|
}catch(error){
|
||||||
|
console.warn(`[MuJoCo] DAE 转换失败:${filename}`,error);
|
||||||
|
const visual=mesh.closest('visual');
|
||||||
|
if(visual){visual.remove();removedDaeVisuals+=1;}
|
||||||
|
else if(mesh.closest('collision')){
|
||||||
|
const sphere=document.createElement('sphere');sphere.setAttribute('radius','0.05');mesh.replaceWith(sphere);daeCollisionFallbacks+=1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(convertedDaeReferences)warnings.push(`已将 ${converted.size} 个 DAE 文件转换为 OBJ,供 ${convertedDaeReferences} 个 visual/collision 使用`);
|
||||||
|
if(removedDaeVisuals)warnings.push(`${removedDaeVisuals} 个 DAE visual 转换失败,已移除并使用其他 collision 几何显示/仿真`);
|
||||||
|
if(daeCollisionFallbacks)warnings.push(`${daeCollisionFallbacks} 个 DAE collision 转换失败,已替换为半径 0.05 m 的占位球体;碰撞精度会降低`);
|
||||||
|
|
||||||
|
const xml=new TextEncoder().encode(new XMLSerializer().serializeToString(document));
|
||||||
|
const replacement:ProjectFile={...source,data:xml,size:xml.byteLength};
|
||||||
|
const generated=Array.from(converted.values());
|
||||||
|
const files=[...manifest.files.map(file=>file===source?replacement:file),...generated];
|
||||||
|
return {manifest:{...manifest,files,totalBytes:files.reduce((total,file)=>total+file.size,0)},warnings};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function choosePreferredEntry(entries: ModelEntry[]): string | undefined {
|
||||||
|
if (entries.length === 1) return entries[0].path;
|
||||||
|
const rootPreferred = entries.find((e) => !e.path.includes('/') && /^(model|scene)\.xml$/i.test(e.path));
|
||||||
|
if (rootPreferred) return rootPreferred.path;
|
||||||
|
const urdfs = entries.filter((e) => e.format === 'urdf');
|
||||||
|
return urdfs.length === 1 ? urdfs[0].path : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function manifest(name: string, files: ProjectFile[]): ProjectManifest {
|
||||||
|
const entries = discoverEntries(files);
|
||||||
|
if (!entries.length) throw new ProjectImportError('未发现包含 <mujoco> 或 <robot> 根元素的 XML/URDF 入口');
|
||||||
|
return {id: `${Date.now().toString(36)}-${crypto.randomUUID().slice(0, 8)}`, name, files, entries, selectedEntry: choosePreferredEntry(entries), totalBytes: files.reduce((n, f) => n + f.size, 0)};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importBrowserFiles(input: File[], limits: ImportLimits = DEFAULT_IMPORT_LIMITS): Promise<ProjectManifest> {
|
||||||
|
if (!input.length) throw new ProjectImportError('未选择文件');
|
||||||
|
if (input.length === 1 && /\.zip$/i.test(input[0].name)) {
|
||||||
|
if (input[0].size > limits.maxZipBytes) throw new ProjectImportError(`ZIP 超过限制(${limits.maxZipBytes} 字节)`);
|
||||||
|
const bytes = new Uint8Array(await input[0].arrayBuffer()); checkEncryptedZip(bytes);
|
||||||
|
let unpacked: Record<string, Uint8Array>;
|
||||||
|
try {
|
||||||
|
let fileCount = 0;
|
||||||
|
let expandedBytes = 0;
|
||||||
|
unpacked = unzipSync(bytes, {filter: (entry) => {
|
||||||
|
if (entry.name.endsWith('/')) return false;
|
||||||
|
normalizeProjectPath(entry.name);
|
||||||
|
fileCount += 1;
|
||||||
|
expandedBytes += entry.originalSize;
|
||||||
|
if (fileCount > limits.maxFiles) throw new ProjectImportError(`文件数量超过限制(${limits.maxFiles})`);
|
||||||
|
if (entry.originalSize > limits.maxFileBytes) throw new ProjectImportError(`单文件超过限制(${limits.maxFileBytes} 字节)`, entry.name);
|
||||||
|
if (expandedBytes > limits.maxTotalBytes) throw new ProjectImportError(`ZIP 解压后总大小超过限制(${limits.maxTotalBytes} 字节)`);
|
||||||
|
return true;
|
||||||
|
}});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ProjectImportError) throw error;
|
||||||
|
throw new ProjectImportError(`ZIP 解压失败:${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
const files = Object.entries(unpacked).filter(([path]) => !path.endsWith('/')).map(([path, data]): ProjectFile => ({path: normalizeProjectPath(path), data, size: data.byteLength, source: 'zip', mimeType: ''}));
|
||||||
|
enforceLimits(files, limits); return manifest(input[0].name.replace(/\.zip$/i, ''), files);
|
||||||
|
}
|
||||||
|
const files = await Promise.all(input.map(async (file): Promise<ProjectFile> => {
|
||||||
|
const relative = (file as File & {webkitRelativePath?: string}).webkitRelativePath || file.name;
|
||||||
|
const data = new Uint8Array(await file.arrayBuffer());
|
||||||
|
return {path: normalizeProjectPath(relative), data, size: data.byteLength, source: relative === file.name ? 'file' : 'directory', mimeType: file.type};
|
||||||
|
}));
|
||||||
|
enforceLimits(files, limits); return manifest(files[0].path.split('/')[0] || '工程', files);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LegacyEntry {isFile: boolean; isDirectory: boolean; name: string; file(cb: (file: File) => void, err: (e: DOMException) => void): void; createReader(): {readEntries(cb: (entries: LegacyEntry[]) => void, err: (e: DOMException) => void): void};}
|
||||||
|
async function readEntry(entry: LegacyEntry, prefix = ''): Promise<File[]> {
|
||||||
|
if (entry.isFile) return [await new Promise<File>((resolve, reject) => entry.file((file) => {Object.defineProperty(file, 'webkitRelativePath', {value: `${prefix}${file.name}`}); resolve(file);}, reject))];
|
||||||
|
const reader = entry.createReader(); const children: LegacyEntry[] = [];
|
||||||
|
for (;;) { const batch = await new Promise<LegacyEntry[]>((resolve, reject) => reader.readEntries(resolve, reject)); if (!batch.length) break; children.push(...batch); }
|
||||||
|
return (await Promise.all(children.map((child) => readEntry(child, `${prefix}${entry.name}/`)))).flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function filesFromDrop(items: DataTransferItemList, fallback: FileList): Promise<File[]> {
|
||||||
|
const entries = Array.from(items).map((item) => (item as unknown as {webkitGetAsEntry?: () => LegacyEntry | null}).webkitGetAsEntry?.() ?? null).filter((entry): entry is LegacyEntry => entry !== null);
|
||||||
|
return entries.length ? (await Promise.all(entries.map((entry) => readEntry(entry)))).flat() : Array.from(fallback);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
export type ModelFormat = 'mjcf' | 'urdf';
|
||||||
|
|
||||||
|
export interface ProjectFile {
|
||||||
|
path: string;
|
||||||
|
data: Uint8Array;
|
||||||
|
size: number;
|
||||||
|
source: 'file' | 'directory' | 'zip';
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelEntry {
|
||||||
|
path: string;
|
||||||
|
format: ModelFormat;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectManifest {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
files: ProjectFile[];
|
||||||
|
entries: ModelEntry[];
|
||||||
|
selectedEntry?: string;
|
||||||
|
totalBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportLimits {
|
||||||
|
maxFiles: number;
|
||||||
|
maxFileBytes: number;
|
||||||
|
maxTotalBytes: number;
|
||||||
|
maxZipBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_IMPORT_LIMITS: ImportLimits = {
|
||||||
|
maxFiles: 2_000,
|
||||||
|
maxFileBytes: 128 * 1024 * 1024,
|
||||||
|
maxTotalBytes: 512 * 1024 * 1024,
|
||||||
|
maxZipBytes: 128 * 1024 * 1024,
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import {groundConvertedMjcf} from './urdfToMjcf';
|
||||||
|
|
||||||
|
const encode=(value:string)=>new TextEncoder().encode(value);
|
||||||
|
const decode=(value:Uint8Array)=>new TextDecoder().decode(value);
|
||||||
|
|
||||||
|
describe('groundConvertedMjcf',()=>{
|
||||||
|
it('抬升所有根 body,并在 z=0 添加地面',()=>{
|
||||||
|
const result=decode(groundConvertedMjcf(encode('<mujoco><worldbody><body name="robot" pos="1 2 0.1"><geom type="box" size="1 1 1"/></body></worldbody></mujoco>'),-0.4,'fixed'));
|
||||||
|
const document=new DOMParser().parseFromString(result,'application/xml');
|
||||||
|
expect(document.querySelector('body[name="robot"]')?.getAttribute('pos')).toBe('1 2 0.5');
|
||||||
|
expect(document.querySelector('geom[name="__platform_ground__"]')).toMatchObject({tagName:'geom'});
|
||||||
|
expect(document.querySelector('geom[name="__platform_ground__"]')?.getAttribute('group')).toBe('5');
|
||||||
|
expect(document.querySelector('freejoint')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('为浮动基座的每个世界根 body 添加 freejoint',()=>{
|
||||||
|
const result=decode(groundConvertedMjcf(encode('<mujoco><worldbody><body name="robot"><geom type="sphere" size="1"/></body></worldbody></mujoco>'),-1,'floating'));
|
||||||
|
const document=new DOMParser().parseFromString(result,'application/xml');
|
||||||
|
expect(document.querySelector('body[name="robot"] > freejoint')?.getAttribute('name')).toBe('__platform_base_freejoint__');
|
||||||
|
expect(document.querySelector('body[name="robot"]')?.getAttribute('pos')).toBe('0 0 1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
const decoder=new TextDecoder('utf-8');
|
||||||
|
const encoder=new TextEncoder();
|
||||||
|
|
||||||
|
function numbers(value:string|undefined,count:number):number[]{
|
||||||
|
const parsed=(value??'').trim().split(/\s+/).filter(Boolean).map(Number);
|
||||||
|
return Array.from({length:count},(_,index)=>Number.isFinite(parsed[index])?parsed[index]:0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UrdfBaseMode='floating'|'fixed';
|
||||||
|
|
||||||
|
/** 给 MuJoCo 从 URDF 导出的 MJCF 添加地面、设置基座类型,并整体抬升根 body。 */
|
||||||
|
export function groundConvertedMjcf(data:Uint8Array,minimumZ:number,baseMode:UrdfBaseMode='fixed'):Uint8Array {
|
||||||
|
const document=new DOMParser().parseFromString(decoder.decode(data),'application/xml');
|
||||||
|
if(document.querySelector('parsererror'))throw new Error('MuJoCo 导出的 MJCF XML 无法解析');
|
||||||
|
const worldbody=document.querySelector('mujoco > worldbody');
|
||||||
|
if(!worldbody)throw new Error('MuJoCo 导出的 MJCF 缺少 worldbody');
|
||||||
|
const lift=Number.isFinite(minimumZ)?-minimumZ:0;
|
||||||
|
const rootBodies=Array.from(worldbody.children).filter(element=>element.tagName==='body');
|
||||||
|
for(const [index,body] of rootBodies.entries()){
|
||||||
|
const pos=numbers(body.getAttribute('pos')??undefined,3);pos[2]+=lift;body.setAttribute('pos',pos.join(' '));
|
||||||
|
if(baseMode==='floating'&&!Array.from(body.children).some(element=>element.tagName==='freejoint'||element.tagName==='joint')){
|
||||||
|
const freejoint=document.createElement('freejoint');freejoint.setAttribute('name',rootBodies.length===1?'__platform_base_freejoint__':`__platform_base_freejoint_${index}__`);body.prepend(freejoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const floor=document.createElement('geom');
|
||||||
|
floor.setAttribute('name','__platform_ground__');floor.setAttribute('type','plane');floor.setAttribute('size','1 1 0.1');floor.setAttribute('pos','0 0 0');floor.setAttribute('rgba','0.12 0.16 0.22 1');floor.setAttribute('group','5');floor.setAttribute('friction','1 0.005 0.0001');
|
||||||
|
worldbody.prepend(floor);
|
||||||
|
return encoder.encode(new XMLSerializer().serializeToString(document));
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import {MemfsWorkspace} from './workspace';
|
||||||
|
import type {MainModule} from '@mujoco/mujoco';
|
||||||
|
import type {ProjectManifest} from './types';
|
||||||
|
it('按相对路径挂载并逆序清理 MEMFS',()=>{const calls:string[]=[];const FS={mkdirTree:(p:string)=>calls.push(`mkdir:${p}`),writeFile:(p:string)=>calls.push(`write:${p}`),unlink:(p:string)=>calls.push(`unlink:${p}`),rmdir:(p:string)=>calls.push(`rmdir:${p}`)};const workspace=new MemfsWorkspace({FS} as unknown as MainModule,'safe');const data=new Uint8Array([1]);const manifest:ProjectManifest={id:'safe',name:'x',entries:[],files:[{path:'a/b/model.xml',data,size:1,source:'file',mimeType:''}],totalBytes:1};workspace.mount(manifest);workspace.dispose();expect(calls).toEqual(expect.arrayContaining(['/workspace/safe/a/b/model.xml'].map(p=>`write:${p}`)));expect(calls).toContain('rmdir:/workspace/safe/a/b');expect(calls).toContain('rmdir:/workspace/safe/a');expect(calls.indexOf('unlink:/workspace/safe/a/b/model.xml')).toBeLessThan(calls.indexOf('rmdir:/workspace/safe/a/b'));expect(calls.indexOf('rmdir:/workspace/safe/a/b')).toBeLessThan(calls.indexOf('rmdir:/workspace/safe/a'));});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type {MainModule} from '@mujoco/mujoco';
|
||||||
|
import type {ProjectManifest} from './types';
|
||||||
|
|
||||||
|
interface EmscriptenFS {
|
||||||
|
mkdirTree(path: string): void;
|
||||||
|
writeFile(path: string, data: Uint8Array): void;
|
||||||
|
readFile(path:string,options:{encoding:'utf8'}):string;
|
||||||
|
unlink(path: string): void;
|
||||||
|
rmdir(path: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModuleWithFS = MainModule & {FS: EmscriptenFS};
|
||||||
|
|
||||||
|
export class MemfsWorkspace {
|
||||||
|
readonly root: string;
|
||||||
|
private files: string[] = [];
|
||||||
|
private directories: string[] = [];
|
||||||
|
|
||||||
|
constructor(private readonly module: MainModule, projectId: string) {
|
||||||
|
const safeId = projectId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||||
|
this.root = `/workspace/${safeId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
mount(manifest: ProjectManifest): void {
|
||||||
|
const fs = (this.module as ModuleWithFS).FS;
|
||||||
|
fs.mkdirTree(this.root); this.directories.push(this.root);
|
||||||
|
for (const file of manifest.files) {
|
||||||
|
const absolute = `${this.root}/${file.path}`;
|
||||||
|
const directory = absolute.slice(0, absolute.lastIndexOf('/'));
|
||||||
|
if (directory !== this.root) {
|
||||||
|
const relativeDirectory = directory.slice(this.root.length + 1);
|
||||||
|
let current = this.root;
|
||||||
|
for (const segment of relativeDirectory.split('/')) {
|
||||||
|
current = `${current}/${segment}`;
|
||||||
|
if (!this.directories.includes(current)) {
|
||||||
|
fs.mkdirTree(current);
|
||||||
|
this.directories.push(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs.writeFile(absolute, file.data); this.files.push(absolute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path(relative: string): string { return `${this.root}/${relative}`; }
|
||||||
|
|
||||||
|
readText(relative:string):string{return (this.module as ModuleWithFS).FS.readFile(this.path(relative),{encoding:'utf8'});}
|
||||||
|
|
||||||
|
writeGenerated(relative:string,data:Uint8Array):void {
|
||||||
|
const absolute=this.path(relative);(this.module as ModuleWithFS).FS.writeFile(absolute,data);
|
||||||
|
if(!this.files.includes(absolute))this.files.push(absolute);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
const fs = (this.module as ModuleWithFS).FS;
|
||||||
|
for (const file of this.files.reverse()) { try { fs.unlink(file); } catch { /* best-effort after failed mount */ } }
|
||||||
|
for (const dir of this.directories.sort((a, b) => b.length - a.length)) { try { fs.rmdir(dir); } catch { /* parent or shared root */ } }
|
||||||
|
this.files = []; this.directories = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import loadMujoco, {type MainModule} from '@mujoco/mujoco';
|
||||||
|
import type {ProjectManifest} from '../project/types';
|
||||||
|
import {prepareProjectForMujoco} from '../project/importer';
|
||||||
|
import {groundConvertedMjcf,type UrdfBaseMode} from '../project/urdfToMjcf';
|
||||||
|
import {MemfsWorkspace} from '../project/workspace';
|
||||||
|
import {SimulationSession, type FrameResult, type SimulationSnapshot} from './SimulationSession';
|
||||||
|
|
||||||
|
export type UrdfLoadMode='mjcf'|'native';
|
||||||
|
export type {UrdfBaseMode};
|
||||||
|
|
||||||
|
export interface PhysicsAdapter {
|
||||||
|
load(manifest:ProjectManifest,entryPath:string,urdfMode?:UrdfLoadMode,baseMode?:UrdfBaseMode):Promise<SimulationSnapshot>;
|
||||||
|
advance(now: number): FrameResult;
|
||||||
|
snapshot(): SimulationSnapshot | null;
|
||||||
|
setPaused(paused: boolean): void;
|
||||||
|
setSpeed(speed: number): void;
|
||||||
|
reset(): void;
|
||||||
|
singleStep(): void;
|
||||||
|
setActuator(id: number, value: number): void;
|
||||||
|
setJointPosition(id: number, value: number): boolean;
|
||||||
|
setExternalForce(bodyId: number, force: [number, number, number]): void;
|
||||||
|
clearExternalForce(): void;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let modulePromise: Promise<MainModule> | undefined;
|
||||||
|
export function getMujocoModule(): Promise<MainModule> {
|
||||||
|
if (!modulePromise) {
|
||||||
|
console.info('[MuJoCo] 开始初始化单线程 WASM');
|
||||||
|
modulePromise = loadMujoco().then((module) => {
|
||||||
|
console.info('[MuJoCo] WASM 初始化完成');
|
||||||
|
return module;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return modulePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MainThreadPhysicsAdapter implements PhysicsAdapter {
|
||||||
|
session: SimulationSession | null = null;
|
||||||
|
workspace: MemfsWorkspace | null = null;
|
||||||
|
|
||||||
|
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);
|
||||||
|
try{
|
||||||
|
console.info('[MuJoCo] 写入 MEMFS',prepared.manifest.files.length);workspace.mount(prepared.manifest);
|
||||||
|
const entry=prepared.manifest.entries.find(candidate=>candidate.path===entryPath);let modelPath=workspace.path(entryPath);const warnings=[...prepared.warnings];
|
||||||
|
if(entry?.format==='urdf'&&urdfMode==='mjcf'){
|
||||||
|
console.info('[MuJoCo] 编译 URDF 中间模型',entryPath);const intermediate=new SimulationSession(module,modelPath);
|
||||||
|
try{
|
||||||
|
const minimumZ=intermediate.minimumGeometryZ();const slash=entryPath.lastIndexOf('/');const directory=slash>=0?entryPath.slice(0,slash+1):'';const convertedPath=`${directory}.__mujoco_converted_${manifest.id.replace(/[^a-zA-Z0-9_-]/g,'_')}.xml`;
|
||||||
|
if(module.mj_saveLastXML(workspace.path(convertedPath),intermediate.model)===0)throw new Error('MuJoCo 无法导出中间 MJCF');
|
||||||
|
workspace.writeGenerated(convertedPath,groundConvertedMjcf(new TextEncoder().encode(workspace.readText(convertedPath)),minimumZ,baseMode));modelPath=workspace.path(convertedPath);
|
||||||
|
warnings.push(`URDF 已转换为 MJCF(${baseMode==='floating'?'浮动基座':'固定基座'}),并整体平移 ${(-minimumZ).toFixed(4)} m,使最低点接触 z=0 地面`);
|
||||||
|
}finally{intermediate.dispose();}
|
||||||
|
}
|
||||||
|
console.info('[MuJoCo] 编译模型',modelPath);const session=new SimulationSession(module,modelPath,warnings);
|
||||||
|
if(entry?.format==='urdf'&&urdfMode==='native'){const offset=session.alignLowestPointToGround();warnings.push(`原生 URDF 已整体平移 ${offset.toFixed(4)} m,使最低点位于 z=0`);}
|
||||||
|
if(warnings.length)console.warn('[MuJoCo] URDF 兼容处理',warnings);
|
||||||
|
console.info('[MuJoCo] 模型编译完成');this.workspace=workspace;this.session=session;const snapshot=session.snapshot();console.info('[MuJoCo] 状态快照完成');return snapshot;
|
||||||
|
}catch(error){workspace.dispose();throw new Error(`模型编译失败(${entryPath}):${error instanceof Error?error.message:String(error)}`,{cause:error});}
|
||||||
|
}
|
||||||
|
advance(now:number):FrameResult{return this.session?.advance(now)??{steps:0,stepMs:0,overBudget:false};}
|
||||||
|
snapshot():SimulationSnapshot|null{return this.session?.snapshot()??null;}
|
||||||
|
setPaused(value:boolean):void{this.session?.setPaused(value);}
|
||||||
|
setSpeed(value:number):void{this.session?.setSpeed(value);}
|
||||||
|
reset():void{this.session?.reset();}
|
||||||
|
singleStep():void{this.session?.singleStep();}
|
||||||
|
setActuator(id:number,value:number):void{this.session?.setActuator(id,value);}
|
||||||
|
setJointPosition(id:number,value:number):boolean{return this.session?.setJointPosition(id,value)??false;}
|
||||||
|
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;}
|
||||||
|
dispose():void{this.releaseCurrent();}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
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 JointInfo {id: number; name: string; type: number; value: number; min: number; max: number; limited: boolean; editable: boolean; bodyId: number; axis: [number, number, number];}
|
||||||
|
export interface BodyInfo {id: number; name: string;}
|
||||||
|
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 FrameResult {steps: number; stepMs: number; overBudget: boolean;}
|
||||||
|
|
||||||
|
export class SimulationSession {
|
||||||
|
readonly model: MjModel;
|
||||||
|
readonly data: MjData;
|
||||||
|
readonly perturb: MjvPerturb;
|
||||||
|
paused = true;
|
||||||
|
speed = 1;
|
||||||
|
readonly frameBudgetMs = 8;
|
||||||
|
readonly maxCatchUpSteps = 100;
|
||||||
|
private accumulator = 0;
|
||||||
|
private lastNow?: number;
|
||||||
|
private forceBody = -1;
|
||||||
|
private force: [number, number, number] = [0, 0, 0];
|
||||||
|
private disposed = false;
|
||||||
|
|
||||||
|
constructor(readonly module: MainModule, modelPath: string, readonly warnings: string[] = []) {
|
||||||
|
let model: MjModel | undefined; let data: MjData | undefined; let perturb: MjvPerturb | undefined;
|
||||||
|
try {
|
||||||
|
model = module.MjModel.mj_loadXML(modelPath) ?? undefined;
|
||||||
|
if (!model) throw new Error(`MuJoCo 无法编译模型:${modelPath}`);
|
||||||
|
data = new module.MjData(model);
|
||||||
|
perturb = new module.MjvPerturb(); module.mjv_defaultPerturb(perturb);
|
||||||
|
this.model = model; this.data = data; this.perturb = perturb;
|
||||||
|
module.mj_forward(model, data);
|
||||||
|
} catch (error) { perturb?.delete(); data?.delete(); model?.delete(); throw error; }
|
||||||
|
}
|
||||||
|
|
||||||
|
setPaused(paused: boolean): void {this.paused = paused; this.accumulator = 0; this.lastNow = undefined;}
|
||||||
|
setSpeed(speed: number): void {this.speed = Math.min(4, Math.max(0.1, speed));}
|
||||||
|
reset(): void {this.setPaused(true);this.module.mj_resetData(this.model,this.data);this.module.mj_forward(this.model,this.data);this.clearExternalForce();}
|
||||||
|
singleStep(): void {this.applyForce(); this.module.mj_step(this.model, this.data);}
|
||||||
|
|
||||||
|
advance(now: number): FrameResult {
|
||||||
|
if (this.lastNow === undefined) {this.lastNow = now; return {steps: 0, stepMs: 0, overBudget: false};}
|
||||||
|
const elapsed = Math.min(0.1, Math.max(0, (now - this.lastNow) / 1000)); this.lastNow = now;
|
||||||
|
if (this.paused) return {steps: 0, stepMs: 0, overBudget: false};
|
||||||
|
this.accumulator += elapsed * this.speed;
|
||||||
|
const dt = Number(this.model.opt.timestep) || 0.002; const started = performance.now(); let steps = 0;
|
||||||
|
while (this.accumulator >= dt && steps < this.maxCatchUpSteps && performance.now() - started < this.frameBudgetMs) {
|
||||||
|
this.applyForce(); this.module.mj_step(this.model, this.data); this.accumulator -= dt; steps++;
|
||||||
|
}
|
||||||
|
const overBudget = this.accumulator >= dt;
|
||||||
|
if (steps >= this.maxCatchUpSteps) this.accumulator = Math.min(this.accumulator, dt);
|
||||||
|
return {steps, stepMs: performance.now() - started, overBudget};
|
||||||
|
}
|
||||||
|
|
||||||
|
setActuator(id: number, value: number): void {
|
||||||
|
if (id < 0 || id >= this.model.nu) return;
|
||||||
|
const actuator = this.model.actuator(id);
|
||||||
|
try {
|
||||||
|
const limited = Boolean(actuator.ctrllimited);
|
||||||
|
const min = limited ? Number(actuator.ctrlrange[0]) : -1;
|
||||||
|
const max = limited ? Number(actuator.ctrlrange[1]) : 1;
|
||||||
|
const address = Number(this.model.actuator_ctrladr[id] ?? id);
|
||||||
|
this.data.ctrl[address] = Math.min(max, Math.max(min, value));
|
||||||
|
} finally {
|
||||||
|
actuator.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setJointPosition(id: number, value: number): boolean {
|
||||||
|
if (id < 0 || id >= this.model.njnt) return false;
|
||||||
|
const joint = this.model.jnt(id);
|
||||||
|
try {
|
||||||
|
const type = Number(joint.type);
|
||||||
|
if (type !== 2 && type !== 3) return false;
|
||||||
|
const limited = Boolean(joint.limited);
|
||||||
|
const min = limited ? Number(joint.range[0]) : -Math.PI;
|
||||||
|
const max = limited ? Number(joint.range[1]) : Math.PI;
|
||||||
|
this.setPaused(true);
|
||||||
|
this.data.qpos[Number(joint.qposadr)] = Math.min(max, Math.max(min, value));
|
||||||
|
this.module.mj_forward(this.model, this.data);
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
joint.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setExternalForce(bodyId: number, force: [number, number, number]): void {this.forceBody = bodyId > 0 && bodyId < this.model.nbody ? bodyId : -1; this.force = force;}
|
||||||
|
clearExternalForce(): void {this.forceBody = -1; this.force = [0, 0, 0]; this.data.xfrc_applied.fill(0); this.perturb.active = 0;}
|
||||||
|
initializePerturb(scene: MjvScene, bodyId: number): void {this.perturb.select = bodyId; this.module.mjv_initPerturb(this.model, this.data, scene, this.perturb);}
|
||||||
|
applyPerturbForce(): void {if (this.forceBody > 0) this.module.mjv_applyPerturbForce(this.model, this.data, this.perturb);}
|
||||||
|
|
||||||
|
private applyForce(): void {
|
||||||
|
this.data.xfrc_applied.fill(0); if (this.forceBody < 1) return;
|
||||||
|
this.applyPerturbForce(); const offset = this.forceBody * 6;
|
||||||
|
this.data.xfrc_applied[offset] += this.force[0]; this.data.xfrc_applied[offset + 1] += this.force[1]; this.data.xfrc_applied[offset + 2] += this.force[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用有限几何的包围球估算视图中心与范围,忽略地面等无限平面。 */
|
||||||
|
geometryBounds():{center:[number,number,number];extent:number} {
|
||||||
|
const lower=[Number.POSITIVE_INFINITY,Number.POSITIVE_INFINITY,Number.POSITIVE_INFINITY];const upper=[Number.NEGATIVE_INFINITY,Number.NEGATIVE_INFINITY,Number.NEGATIVE_INFINITY];
|
||||||
|
for(let geom=0;geom<this.model.ngeom;geom+=1){if(Number(this.model.geom_type[geom])===this.module.mjtGeom.mjGEOM_PLANE.value)continue;const radius=Math.max(0,Number(this.model.geom_rbound[geom]));for(let axis=0;axis<3;axis+=1){const value=Number(this.data.geom_xpos[geom*3+axis]);lower[axis]=Math.min(lower[axis],value-radius);upper[axis]=Math.max(upper[axis],value+radius);}}
|
||||||
|
if(!lower.every(Number.isFinite)||!upper.every(Number.isFinite))return {center:[0,0,0],extent:2};
|
||||||
|
return {center:[(lower[0]+upper[0])/2,(lower[1]+upper[1])/2,(lower[2]+upper[2])/2],extent:Math.max(.5,upper[0]-lower[0],upper[1]-lower[1],upper[2]-lower[2])};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回当前姿态全部模型几何(不含无限平面)的最低世界坐标。 */
|
||||||
|
minimumGeometryZ():number {
|
||||||
|
const option=new this.module.MjvOption();const camera=new this.module.MjvCamera();const scene=new this.module.MjvScene(this.model,32768);
|
||||||
|
this.module.mjv_defaultOption(option);this.module.mjv_defaultCamera(camera);
|
||||||
|
let minimum=Number.POSITIVE_INFINITY;
|
||||||
|
try{
|
||||||
|
this.module.mjv_updateScene(this.model,this.data,option,this.perturb,camera,this.module.mjtCatBit.mjCAT_ALL.value,scene);
|
||||||
|
const geoms=scene.geoms;
|
||||||
|
try{for(let index=0;index<geoms.size();index+=1){const geom=geoms.get(index);if(!geom)continue;try{
|
||||||
|
if(geom.type===this.module.mjtGeom.mjGEOM_PLANE.value)continue;
|
||||||
|
const z=this.geomMinimumZ(geom);if(Number.isFinite(z))minimum=Math.min(minimum,z);
|
||||||
|
}finally{geom.delete();}}}finally{geoms.delete();}
|
||||||
|
}finally{scene.delete();camera.delete();option.delete();}
|
||||||
|
return Number.isFinite(minimum)?minimum:0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 平移所有世界根 body,使当前姿态的最低点位于 z=0。 */
|
||||||
|
alignLowestPointToGround():number {
|
||||||
|
const offset=-this.minimumGeometryZ();
|
||||||
|
if(Math.abs(offset)<1e-9)return 0;
|
||||||
|
for(let body=1;body<this.model.nbody;body+=1)if(Number(this.model.body_parentid[body])===0)this.model.body_pos[body*3+2]+=offset;
|
||||||
|
this.module.mj_forward(this.model,this.data);
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
private geomMinimumZ(geom:import('@mujoco/mujoco').MjvGeom):number {
|
||||||
|
const m=this.module,type=geom.type,s=geom.size,r0=geom.mat[6],r1=geom.mat[7],r2=geom.mat[8],center=geom.pos[2];
|
||||||
|
if(type===m.mjtGeom.mjGEOM_SPHERE.value)return center-s[0];
|
||||||
|
if(type===m.mjtGeom.mjGEOM_BOX.value)return center-(Math.abs(r0)*s[0]+Math.abs(r1)*s[1]+Math.abs(r2)*s[2]);
|
||||||
|
if(type===m.mjtGeom.mjGEOM_ELLIPSOID.value)return center-Math.hypot(r0*s[0],r1*s[1],r2*s[2]);
|
||||||
|
if(type===m.mjtGeom.mjGEOM_CYLINDER.value)return center-(Math.hypot(r0,r1)*s[0]+Math.abs(r2)*s[2]);
|
||||||
|
if(type===m.mjtGeom.mjGEOM_CAPSULE.value)return center-(s[0]+Math.abs(r2)*s[2]);
|
||||||
|
if(type===m.mjtGeom.mjGEOM_MESH.value&&geom.dataid>=0){
|
||||||
|
const id=meshIdFromSceneDataId(geom.dataid),first=Number(this.model.mesh_vertadr[id]),count=Number(this.model.mesh_vertnum[id]);let minimum=Number.POSITIVE_INFINITY;
|
||||||
|
for(let vertex=0;vertex<count;vertex+=1){const offset=(first+vertex)*3;minimum=Math.min(minimum,center+r0*this.model.mesh_vert[offset]+r1*this.model.mesh_vert[offset+1]+r2*this.model.mesh_vert[offset+2]);}
|
||||||
|
return minimum;
|
||||||
|
}
|
||||||
|
const radius=geom.size[0]||0;return center-radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot(): SimulationSnapshot {
|
||||||
|
const actuators = Array.from({length: this.model.nu}, (_, 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};
|
||||||
|
} finally {
|
||||||
|
actuator.delete();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const joints = Array.from({length: this.model.njnt}, (_, id): JointInfo => {
|
||||||
|
const joint = this.model.jnt(id);
|
||||||
|
try {
|
||||||
|
const type=Number(joint.type); const limited=Boolean(joint.limited);
|
||||||
|
return {id,name:joint.name || `joint_${id}`,type,value:Number(this.data.qpos[Number(joint.qposadr)]),min:limited?Number(joint.range[0]):-Math.PI,max:limited?Number(joint.range[1]):Math.PI,limited,editable:type===2||type===3,bodyId:Number(joint.bodyid),axis:[Number(joint.axis[0]),Number(joint.axis[1]),Number(joint.axis[2])]};
|
||||||
|
} finally {
|
||||||
|
joint.delete();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const bodies = Array.from({length: this.model.nbody}, (_,id): BodyInfo => {
|
||||||
|
const body = this.model.body(id);
|
||||||
|
try { return {id,name:body.name || `body_${id}`}; }
|
||||||
|
finally { body.delete(); }
|
||||||
|
});
|
||||||
|
return {time:Number(this.data.time),qpos:Array.from(this.data.qpos),qvel:Array.from(this.data.qvel),ctrl:Array.from(this.data.ctrl),actuators,joints,bodies,warnings:this.warnings,model:{nbody:this.model.nbody,njnt:this.model.njnt,ngeom:this.model.ngeom,nu:this.model.nu,nq:this.model.nq,nv:this.model.nv}};
|
||||||
|
}
|
||||||
|
dispose(): void {if(this.disposed)return; this.disposed=true; this.clearExternalForce(); this.perturb.delete(); this.data.delete(); this.model.delete();}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import {meshIdFromSceneDataId} from './geometry';
|
||||||
|
|
||||||
|
describe('meshIdFromSceneDataId',()=>{
|
||||||
|
it('解析 mjvGeom 的完整 mesh/凸包编码',()=>{
|
||||||
|
expect(meshIdFromSceneDataId(0)).toBe(0);
|
||||||
|
expect(meshIdFromSceneDataId(1)).toBe(0);
|
||||||
|
expect(meshIdFromSceneDataId(2)).toBe(1);
|
||||||
|
expect(meshIdFromSceneDataId(15)).toBe(7);
|
||||||
|
expect(meshIdFromSceneDataId(-1)).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* mjvGeom.dataid 对 mesh 编码为 2 * meshId;最低位表示是否显示凸包。
|
||||||
|
* 它不能直接作为 mjModel.mesh_* 数组的索引。
|
||||||
|
*/
|
||||||
|
export function meshIdFromSceneDataId(dataId:number):number {
|
||||||
|
return dataId<0?-1:Math.floor(dataId/2);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import {useAppStore} from './useAppStore';
|
||||||
|
|
||||||
|
describe('useAppStore.clearProject',()=>{
|
||||||
|
afterEach(()=>useAppStore.getState().clearProject());
|
||||||
|
|
||||||
|
it('清空已导入工程及其运行状态',()=>{
|
||||||
|
const store=useAppStore.getState();
|
||||||
|
store.setProject('robot',[{path:'robot/model.xml',size:128}],[{path:'robot/model.xml',format:'mjcf',label:'model'}],'robot/model.xml');
|
||||||
|
store.setLoading(true);
|
||||||
|
store.setDiagnostic({category:'导入',summary:'错误',detail:'detail',at:1});
|
||||||
|
|
||||||
|
useAppStore.getState().clearProject();
|
||||||
|
|
||||||
|
expect(useAppStore.getState()).toMatchObject({
|
||||||
|
projectName:undefined,
|
||||||
|
files:[],
|
||||||
|
entries:[],
|
||||||
|
selectedEntry:undefined,
|
||||||
|
loading:false,
|
||||||
|
diagnostic:undefined,
|
||||||
|
snapshot:undefined,
|
||||||
|
selection:null,
|
||||||
|
paused:true,
|
||||||
|
fps:0,
|
||||||
|
stepMs:0,
|
||||||
|
overBudget:false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import {create} from 'zustand';
|
||||||
|
import type {ModelEntry} from '../project/types';
|
||||||
|
import type {SimulationSnapshot} from '../simulation/SimulationSession';
|
||||||
|
import type {InteractionMode, ViewerSelection} from '../viewer/MuJoCoViewer';
|
||||||
|
|
||||||
|
export interface AppDiagnostic {category:'导入'|'ZIP'|'文件系统'|'模型编译'|'仿真'|'渲染';summary:string;detail:string;path?:string;at:number;}
|
||||||
|
interface FileMeta {path:string;size:number;}
|
||||||
|
interface AppState {
|
||||||
|
projectName?:string; files:FileMeta[]; entries:ModelEntry[]; selectedEntry?:string;
|
||||||
|
loading:boolean; diagnostic?:AppDiagnostic; snapshot?:SimulationSnapshot; selection:ViewerSelection|null;
|
||||||
|
paused:boolean; speed:number; mode:InteractionMode; fps:number; stepMs:number; memoryMb?:number; overBudget:boolean;
|
||||||
|
setProject(name:string,files:FileMeta[],entries:ModelEntry[],selectedEntry?:string):void;
|
||||||
|
clearProject():void;
|
||||||
|
setEntry(path:string):void; setLoading(value:boolean):void; setDiagnostic(value?:AppDiagnostic):void;
|
||||||
|
setSnapshot(value?:SimulationSnapshot):void; setSelection(value:ViewerSelection|null):void;
|
||||||
|
setPaused(value:boolean):void; setSpeed(value:number):void; setMode(value:InteractionMode):void;
|
||||||
|
setMetrics(fps:number,stepMs:number,memoryMb:number|undefined,overBudget:boolean):void;
|
||||||
|
}
|
||||||
|
export const useAppStore=create<AppState>((set)=>({
|
||||||
|
files:[],entries:[],loading:false,selection:null,paused:true,speed:1,mode:'select',fps:0,stepMs:0,overBudget:false,
|
||||||
|
setProject:(projectName,files,entries,selectedEntry)=>set({projectName,files,entries,selectedEntry,snapshot:undefined,selection:null,diagnostic:undefined}),
|
||||||
|
clearProject:()=>set({projectName:undefined,files:[],entries:[],selectedEntry:undefined,loading:false,diagnostic:undefined,snapshot:undefined,selection:null,paused:true,fps:0,stepMs:0,memoryMb:undefined,overBudget:false}),
|
||||||
|
setEntry:(selectedEntry)=>set({selectedEntry}),setLoading:(loading)=>set({loading}),setDiagnostic:(diagnostic)=>set({diagnostic}),setSnapshot:(snapshot)=>set({snapshot}),setSelection:(selection)=>set({selection}),
|
||||||
|
setPaused:(paused)=>set({paused}),setSpeed:(speed)=>set({speed}),setMode:(mode)=>set({mode}),setMetrics:(fps,stepMs,memoryMb,overBudget)=>set((s)=>({fps:fps||s.fps,stepMs,memoryMb:memoryMb??s.memoryMb,overBudget}))
|
||||||
|
}));
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
@layer base {html,body,#root{height:100%;margin:0}body{font-family:Inter,"Noto Sans SC",system-ui,sans-serif}button,input,select{font:inherit}}
|
||||||
|
@layer components {.btn{@apply rounded border border-slate-600 bg-slate-800 px-2.5 py-1.5 text-xs text-slate-100 transition hover:border-slate-400 hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-40}.icon-btn{@apply btn min-w-8 text-sm}.field{@apply rounded border border-slate-600 bg-slate-800 px-2 py-1 text-xs}.panel{@apply bg-panel}.section{@apply border-b border-slate-700 p-3}.section h3{@apply mb-3 text-xs font-semibold uppercase tracking-wide text-slate-300}.section dt{@apply text-slate-400}.section dd{@apply text-right}.hint{@apply text-xs text-slate-500}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import {OrbitControls} from 'three/examples/jsm/controls/OrbitControls.js';
|
||||||
|
import type {MjvGeom, MjvOption, MjvCamera, MjvScene} from '@mujoco/mujoco';
|
||||||
|
import type {FrameResult, SimulationSession, SimulationSnapshot} from '../simulation/SimulationSession';
|
||||||
|
import {meshIdFromSceneDataId} from '../simulation/geometry';
|
||||||
|
import {OrientationGizmo} from './OrientationGizmo';
|
||||||
|
|
||||||
|
export type InteractionMode = 'select' | 'joint' | 'force';
|
||||||
|
export interface ViewerSelection {bodyId: number; geomId: number; bodyName: string; geomType: number; position: [number, number, number];}
|
||||||
|
interface ViewerCallbacks {onSelection(selection: ViewerSelection | null): void; onFrame(frame: FrameResult, fps: number, snapshot?: SimulationSnapshot): void; onError(error: Error): void;}
|
||||||
|
|
||||||
|
class CapsuleGeometry extends THREE.BufferGeometry {
|
||||||
|
constructor(radius:number,length:number) {super(); const path=new THREE.Path(); path.absarc(0,-length/2,radius,Math.PI*1.5,0); path.absarc(0,length/2,radius,0,Math.PI*.5); const source=new THREE.LatheGeometry(path.getPoints(24),16); this.copy(source); source.dispose(); this.rotateX(Math.PI/2);}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MuJoCoViewer {
|
||||||
|
readonly scene = new THREE.Scene();
|
||||||
|
readonly camera = new THREE.PerspectiveCamera(45,1,.01,2_000);
|
||||||
|
readonly renderer: THREE.WebGLRenderer;
|
||||||
|
readonly controls: OrbitControls;
|
||||||
|
mode: InteractionMode = 'select';
|
||||||
|
forceScale = 50;
|
||||||
|
private showCollision=false;
|
||||||
|
private session: SimulationSession | null = null;
|
||||||
|
private option: MjvOption | null = null;
|
||||||
|
private mjCamera: MjvCamera | null = null;
|
||||||
|
private mjScene: MjvScene | null = null;
|
||||||
|
private frame = 0; private lastFpsAt=performance.now(); private fpsFrames=0; private lastSnapshotAt=0;
|
||||||
|
private meshes: THREE.Mesh[]=[]; private geometries=new Map<string,THREE.BufferGeometry>(); private textures=new Map<number,THREE.DataTexture>();
|
||||||
|
private raycaster=new THREE.Raycaster(); private pointer=new THREE.Vector2(); private selected:THREE.Mesh|null=null; private dragStart:THREE.Vector2|null=null; private dragAxis=new THREE.Vector2(1,0); private dragJointId=-1; private dragJointValue=0; private arrow:THREE.ArrowHelper|null=null;
|
||||||
|
private resizeObserver:ResizeObserver;
|
||||||
|
private orientationGizmo:OrientationGizmo;
|
||||||
|
|
||||||
|
constructor(private readonly host:HTMLElement, private readonly callbacks:ViewerCallbacks) {
|
||||||
|
this.renderer=new THREE.WebGLRenderer({antialias:true,alpha:false}); this.renderer.setPixelRatio(Math.min(devicePixelRatio,2)); this.renderer.shadowMap.enabled=true; this.renderer.outputColorSpace=THREE.SRGBColorSpace; host.append(this.renderer.domElement);
|
||||||
|
this.camera.up.set(0,0,1); this.camera.position.set(3,-3,2); this.controls=new OrbitControls(this.camera,this.renderer.domElement); this.controls.enableDamping=true;
|
||||||
|
this.scene.background=new THREE.Color(0x0b1220); this.scene.add(new THREE.HemisphereLight(0xffffff,0x223344,1.3)); const light=new THREE.DirectionalLight(0xffffff,2); light.position.set(4,-3,7); light.castShadow=true; this.scene.add(light); this.scene.add(new THREE.GridHelper(20,40,0x3b82f6,0x253047).rotateX(Math.PI/2));this.orientationGizmo=new OrientationGizmo(host);this.orientationGizmo.update(this.camera);
|
||||||
|
this.resizeObserver=new ResizeObserver(()=>this.resize()); this.resizeObserver.observe(host); this.resize();
|
||||||
|
this.renderer.domElement.addEventListener('pointerdown',this.onPointerDown); this.renderer.domElement.addEventListener('pointermove',this.onPointerMove); window.addEventListener('pointerup',this.onPointerUp);
|
||||||
|
this.frame=requestAnimationFrame(this.animate);
|
||||||
|
}
|
||||||
|
|
||||||
|
attach(session:SimulationSession|null):void {this.releaseModel(); this.session=session; if(!session)return; this.option=new session.module.MjvOption(); session.module.mjv_defaultOption(this.option);this.applyGeomVisibility(); this.mjCamera=new session.module.MjvCamera(); session.module.mjv_defaultCamera(this.mjCamera); this.mjScene=new session.module.MjvScene(session.model,32768); this.fitCamera(session);}
|
||||||
|
setMode(mode:InteractionMode):void {this.mode=mode; this.controls.enabled=mode==='select'; this.stopDrag();}
|
||||||
|
setShowCollision(value:boolean):void {this.showCollision=value;this.applyGeomVisibility();}
|
||||||
|
private applyGeomVisibility():void {if(!this.option||!this.session)return;let hasVisual=false;for(let index=0;index<this.session.model.ngeom;index+=1)if(Number(this.session.model.geom_group[index])===1){hasVisual=true;break;}this.option.geomgroup[0]=!hasVisual||this.showCollision?1:0;this.option.geomgroup[1]=1;this.option.geomgroup[5]=0;}
|
||||||
|
resetCamera():void {if(this.session)this.fitCamera(this.session);}
|
||||||
|
|
||||||
|
private fitCamera(session:SimulationSession):void {const {extent,center}=session.geometryBounds();this.controls.target.set(center[0],center[1],center[2]);this.camera.position.set(center[0]+extent*1.5,center[1]-extent*1.5,center[2]+extent);this.camera.near=Math.max(.001,extent/1000);this.camera.far=Math.max(100,extent*100);this.camera.updateProjectionMatrix();this.controls.update();}
|
||||||
|
private resize():void {const w=Math.max(1,this.host.clientWidth),h=Math.max(1,this.host.clientHeight); this.renderer.setSize(w,h,false); this.camera.aspect=w/h; this.camera.updateProjectionMatrix();}
|
||||||
|
|
||||||
|
private animate=(now:number):void=>{try {const result=this.session?.advance(now)??{steps:0,stepMs:0,overBudget:false}; this.controls.update();this.orientationGizmo.update(this.camera); if(this.session)this.updateMuJoCoScene(); 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 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;i<geoms.size();i++){const geom=geoms.get(i);if(!geom)continue;try{let mesh=this.meshes[i];const key=this.geometryKey(geom);if(!mesh||mesh.userData.geometryKey!==key){if(mesh){this.scene.remove(mesh);this.disposeMesh(mesh);} mesh=this.createMesh(geom,key);this.meshes[i]=mesh;this.scene.add(mesh);} mesh.visible=true;this.updateMesh(mesh,geom);}finally{geom.delete();}} for(let i=geoms.size();i<this.meshes.length;i++)this.meshes[i].visible=false;}finally{geoms.delete();}}
|
||||||
|
private geometryKey(g:MjvGeom):string {const dataId=g.type===this.session!.module.mjtGeom.mjGEOM_MESH.value?meshIdFromSceneDataId(g.dataid):g.dataid;return `${g.type}:${dataId}:${Array.from(g.size).join(',')}`;}
|
||||||
|
private primitive(g:MjvGeom):THREE.BufferGeometry {const m=this.session!.module,t=g.type,s=g.size;if(t===m.mjtGeom.mjGEOM_PLANE.value)return new THREE.PlaneGeometry(2*(s[0]||1e3),2*(s[1]||1e3));if(t===m.mjtGeom.mjGEOM_SPHERE.value)return new THREE.SphereGeometry(s[0],24,16);if(t===m.mjtGeom.mjGEOM_CAPSULE.value)return new CapsuleGeometry(s[0],2*s[2]);if(t===m.mjtGeom.mjGEOM_BOX.value)return new THREE.BoxGeometry(2*s[0],2*s[1],2*s[2]);if(t===m.mjtGeom.mjGEOM_CYLINDER.value){const x=new THREE.CylinderGeometry(s[0],s[0],2*s[2],24);x.rotateX(Math.PI/2);return x;}if(t===m.mjtGeom.mjGEOM_ELLIPSOID.value){const x=new THREE.SphereGeometry(1,24,16);x.scale(s[0],s[1],s[2]);return x;}if(t===m.mjtGeom.mjGEOM_MESH.value&&g.dataid>=0)return this.meshGeometry(meshIdFromSceneDataId(g.dataid));return new THREE.BufferGeometry();}
|
||||||
|
private meshGeometry(id:number):THREE.BufferGeometry {const m=this.session!.model;const va=Number(m.mesh_vertadr[id]),vn=Number(m.mesh_vertnum[id]),fa=Number(m.mesh_faceadr[id]),fn=Number(m.mesh_facenum[id]);const positions=new Float32Array(vn*3);for(let i=0;i<positions.length;i++)positions[i]=m.mesh_vert[va*3+i];const indices=new Uint32Array(fn*3);for(let i=0;i<indices.length;i++)indices[i]=m.mesh_face[fa*3+i];const geometry=new THREE.BufferGeometry();geometry.setAttribute('position',new THREE.BufferAttribute(positions,3));geometry.setIndex(new THREE.BufferAttribute(indices,1));const na=Number(m.mesh_normaladr[id]),nn=Number(m.mesh_normalnum[id]);if(nn===vn){const normals=new Float32Array(nn*3);for(let i=0;i<normals.length;i++)normals[i]=m.mesh_normal[na*3+i];geometry.setAttribute('normal',new THREE.BufferAttribute(normals,3));}else geometry.computeVertexNormals();const ta=Number(m.mesh_texcoordadr[id]),tn=Number(m.mesh_texcoordnum[id]);if(tn===vn&&ta>=0){const uv=new Float32Array(tn*2);for(let i=0;i<uv.length;i++)uv[i]=m.mesh_texcoord[ta*2+i];geometry.setAttribute('uv',new THREE.BufferAttribute(uv,2));}geometry.computeBoundingSphere();return geometry;}
|
||||||
|
private texture(id:number):THREE.DataTexture|undefined {if(id<0)return;let found=this.textures.get(id);if(found)return found;const m=this.session!.model,w=Number(m.tex_width[id]),h=Number(m.tex_height[id]),channels=Number(m.tex_nchannel[id]||3),adr=Number(m.tex_adr[id]);if(!w||!h)return;const data=new Uint8Array(w*h*channels);for(let i=0;i<data.length;i++)data[i]=m.tex_data[adr+i];found=new THREE.DataTexture(data,w,h,channels===4?THREE.RGBAFormat:THREE.RGBFormat);found.colorSpace=THREE.SRGBColorSpace;found.flipY=true;found.needsUpdate=true;this.textures.set(id,found);return found;}
|
||||||
|
private createMesh(g:MjvGeom,key:string):THREE.Mesh {let geometry=this.geometries.get(key);if(!geometry){geometry=this.primitive(g);this.geometries.set(key,geometry);}const map=this.texture(g.texid);const material=new THREE.MeshStandardMaterial({color:new THREE.Color(g.rgba[0],g.rgba[1],g.rgba[2]),opacity:g.rgba[3],transparent:g.rgba[3]<1,...(map?{map}:{}),roughness:Math.max(.05,1-g.shininess),metalness:g.reflectance});const mesh=new THREE.Mesh(geometry,material);mesh.matrixAutoUpdate=false;mesh.castShadow=true;mesh.receiveShadow=true;mesh.userData.geometryKey=key;return mesh;}
|
||||||
|
private updateMesh(mesh:THREE.Mesh,g:MjvGeom):void {const mat=mesh.material as THREE.MeshStandardMaterial;mat.color.setRGB(g.rgba[0],g.rgba[1],g.rgba[2]);mat.opacity=g.rgba[3];mat.transparent=g.rgba[3]<1;mesh.matrix.set(g.mat[0],g.mat[1],g.mat[2],g.pos[0],g.mat[3],g.mat[4],g.mat[5],g.pos[1],g.mat[6],g.mat[7],g.mat[8],g.pos[2],0,0,0,1);mesh.matrixWorldNeedsUpdate=true;const geomId=g.objtype===this.session!.module.mjtObj.mjOBJ_GEOM.value?g.objid:-1;const bodyId=geomId>=0?Number(this.session!.model.geom_bodyid[geomId]):-1;mesh.userData.geomId=geomId;mesh.userData.bodyId=bodyId;mesh.userData.geomType=g.type;}
|
||||||
|
|
||||||
|
private eventPointer(event:PointerEvent):void {const r=this.renderer.domElement.getBoundingClientRect();this.pointer.set((event.clientX-r.left)/r.width*2-1,-((event.clientY-r.top)/r.height)*2+1);}
|
||||||
|
private onPointerDown=(event:PointerEvent):void=>{this.eventPointer(event);this.raycaster.setFromCamera(this.pointer,this.camera);const hit=this.raycaster.intersectObjects(this.meshes.filter(m=>m.visible),false)[0];if(!hit)return;const mesh=hit.object as THREE.Mesh;this.select(mesh);const bodyId=Number(mesh.userData.bodyId);if(this.mode==='joint'){const joint=this.session?.snapshot().joints.find(j=>j.bodyId===bodyId&&j.editable);if(joint){this.dragStart=this.pointer.clone();this.dragJointId=joint.id;this.dragJointValue=joint.value;const p=joint.bodyId*3,xm=joint.bodyId*9;const origin=new THREE.Vector3(this.session!.data.xpos[p],this.session!.data.xpos[p+1],this.session!.data.xpos[p+2]);const local=new THREE.Vector3(...joint.axis);const axis=new THREE.Vector3(this.session!.data.xmat[xm]*local.x+this.session!.data.xmat[xm+1]*local.y+this.session!.data.xmat[xm+2]*local.z,this.session!.data.xmat[xm+3]*local.x+this.session!.data.xmat[xm+4]*local.y+this.session!.data.xmat[xm+5]*local.z,this.session!.data.xmat[xm+6]*local.x+this.session!.data.xmat[xm+7]*local.y+this.session!.data.xmat[xm+8]*local.z);const a=origin.clone().project(this.camera),b=origin.clone().add(axis).project(this.camera);this.dragAxis.set(b.x-a.x,b.y-a.y);if(this.dragAxis.lengthSq()<1e-6)this.dragAxis.set(1,0);else this.dragAxis.normalize();}}else if(this.mode==='force'&&bodyId>0){this.dragStart=this.pointer.clone();this.session?.initializePerturb(this.mjScene!,bodyId);this.showArrow(hit.point);this.renderer.domElement.setPointerCapture(event.pointerId);}};
|
||||||
|
private onPointerMove=(event:PointerEvent):void=>{if(!this.dragStart||!this.session)return;this.eventPointer(event);const dx=this.pointer.x-this.dragStart.x,dy=this.pointer.y-this.dragStart.y;if(this.mode==='joint'&&this.dragJointId>=0)this.session.setJointPosition(this.dragJointId,this.dragJointValue+(dx*this.dragAxis.x+dy*this.dragAxis.y)*Math.PI);else if(this.mode==='force'&&this.selected){const bodyId=Number(this.selected.userData.bodyId),scale=this.forceScale;const force:[number,number,number]=[dx*scale,0,-dy*scale];this.session.setExternalForce(bodyId,force);this.updateArrow(force);}};
|
||||||
|
private onPointerUp=():void=>{this.stopDrag();};
|
||||||
|
private stopDrag():void {this.dragStart=null;this.dragJointId=-1;this.session?.clearExternalForce();if(this.arrow){this.scene.remove(this.arrow);this.arrow.dispose();this.arrow=null;}}
|
||||||
|
private select(mesh:THREE.Mesh):void {if(this.selected){const old=this.selected.material as THREE.MeshStandardMaterial;old.emissive.setHex(0);}this.selected=mesh;const material=mesh.material as THREE.MeshStandardMaterial;material.emissive.setHex(0x14532d);const bodyId=Number(mesh.userData.bodyId),geomId=Number(mesh.userData.geomId);const name=this.session?.snapshot().bodies.find(b=>b.id===bodyId)?.name??`body_${bodyId}`;const e=mesh.matrix.elements;this.callbacks.onSelection({bodyId,geomId,bodyName:name,geomType:Number(mesh.userData.geomType),position:[e[12],e[13],e[14]]});}
|
||||||
|
private showArrow(origin:THREE.Vector3):void {this.arrow=new THREE.ArrowHelper(new THREE.Vector3(1,0,0),origin,0.01,0xf97316);this.scene.add(this.arrow);}
|
||||||
|
private updateArrow(force:[number,number,number]):void {if(!this.arrow)return;const v=new THREE.Vector3(...force),length=v.length()/25;if(length>1e-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;}
|
||||||
|
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.renderer.dispose();this.renderer.domElement.remove();}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
const SVG_NS='http://www.w3.org/2000/svg';
|
||||||
|
interface AxisElements {axis:THREE.Vector3;line:SVGLineElement;negative:SVGCircleElement;positive:SVGCircleElement;label:SVGTextElement;}
|
||||||
|
|
||||||
|
function svgElement<K extends keyof SVGElementTagNameMap>(name:K):SVGElementTagNameMap[K]{return document.createElementNS(SVG_NS,name);}
|
||||||
|
|
||||||
|
/** 固定在视口右下角、随相机旋转的 XYZ 方向示意器。 */
|
||||||
|
export class OrientationGizmo {
|
||||||
|
readonly element:SVGSVGElement;
|
||||||
|
private readonly axes:AxisElements[]=[];
|
||||||
|
private readonly inverseQuaternion=new THREE.Quaternion();
|
||||||
|
private readonly projected=new THREE.Vector3();
|
||||||
|
|
||||||
|
constructor(host:HTMLElement){
|
||||||
|
this.element=svgElement('svg');this.element.setAttribute('viewBox','0 0 100 100');this.element.setAttribute('role','img');this.element.setAttribute('aria-label','XYZ 方向指示器');
|
||||||
|
Object.assign(this.element.style,{position:'absolute',right:'14px',bottom:'14px',width:'92px',height:'92px',pointerEvents:'none',border:'1px solid rgba(100,116,139,.45)',borderRadius:'10px',background:'rgba(15,23,42,.72)',backdropFilter:'blur(3px)'});
|
||||||
|
const definitions:[string,number,THREE.Vector3][]=[['X',0xef4444,new THREE.Vector3(1,0,0)],['Y',0x22c55e,new THREE.Vector3(0,1,0)],['Z',0x3b82f6,new THREE.Vector3(0,0,1)]];
|
||||||
|
for(const [name,colorValue,axis] of definitions){
|
||||||
|
const color=`#${colorValue.toString(16).padStart(6,'0')}`;const group=svgElement('g');const line=svgElement('line');line.setAttribute('stroke',color);line.setAttribute('stroke-width','2.5');line.setAttribute('stroke-linecap','round');
|
||||||
|
const negative=svgElement('circle');negative.setAttribute('r','4');negative.setAttribute('fill',color);negative.setAttribute('fill-opacity','.7');
|
||||||
|
const positive=svgElement('circle');positive.setAttribute('r','8');positive.setAttribute('fill',color);
|
||||||
|
const label=svgElement('text');label.textContent=name;label.setAttribute('fill','#0f172a');label.setAttribute('font-size','10');label.setAttribute('font-weight','700');label.setAttribute('text-anchor','middle');label.setAttribute('dominant-baseline','central');
|
||||||
|
group.append(line,negative,positive,label);this.element.append(group);this.axes.push({axis,line,negative,positive,label});
|
||||||
|
}
|
||||||
|
const center=svgElement('circle');center.setAttribute('cx','50');center.setAttribute('cy','50');center.setAttribute('r','3.5');center.setAttribute('fill','#cbd5e1');this.element.append(center);host.append(this.element);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(camera:THREE.Camera):void {
|
||||||
|
this.inverseQuaternion.copy(camera.quaternion).invert();
|
||||||
|
for(const item of this.axes){
|
||||||
|
this.projected.copy(item.axis).applyQuaternion(this.inverseQuaternion);const x=50+this.projected.x*30,y=50-this.projected.y*30;const nx=50-this.projected.x*30,ny=50+this.projected.y*30;
|
||||||
|
item.line.setAttribute('x1',String(nx));item.line.setAttribute('y1',String(ny));item.line.setAttribute('x2',String(x));item.line.setAttribute('y2',String(y));
|
||||||
|
item.negative.setAttribute('cx',String(nx));item.negative.setAttribute('cy',String(ny));item.positive.setAttribute('cx',String(x));item.positive.setAttribute('cy',String(y));item.label.setAttribute('x',String(x));item.label.setAttribute('y',String(y+.5));
|
||||||
|
const opacity=String(.65+.35*Math.max(0,this.projected.z));item.positive.setAttribute('fill-opacity',opacity);item.label.setAttribute('fill-opacity',opacity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose():void {this.element.remove();}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
module.exports = {content: ['./web_platform/index.html','./web_platform/src/**/*.{ts,tsx}'], theme: {extend: {colors: {panel:'#162033',accent:'#22c55e'}}}, plugins: []};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022", "useDefineForClassFields": true, "lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"allowJs": false, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true, "forceConsistentCasingInFileNames": true, "module": "ESNext", "moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx",
|
||||||
|
"types": ["vitest/globals", "node"]
|
||||||
|
}, "include": ["src", "vite.config.ts", "playwright.config.ts", "e2e"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import {defineConfig} from 'vitest/config';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
export default defineConfig({
|
||||||
|
root: 'web_platform', base: './', plugins: [react()], publicDir: 'public',
|
||||||
|
build: {outDir: '../web-platform-dist', emptyOutDir: true, target: 'es2022'},
|
||||||
|
worker: {format: 'es'},
|
||||||
|
server: {open: true, fs: {allow: ['..']}},
|
||||||
|
preview: {headers: {'Cache-Control': 'no-store'}},
|
||||||
|
test: {globals: true, environment: 'jsdom', setupFiles: './src/test/setup.ts', include: ['src/**/*.test.ts', 'src/**/*.test.tsx']}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user